Current section
Files
Jump to
Current section
Files
lib/collocation.ex
defmodule Text.Collocation do
@moduledoc """
Extract statistically significant word bigrams from a token stream.
Given a sequence of tokens (or a string that gets tokenised), returns
the bigrams that occur together more often than chance would predict.
Useful for surfacing multi-word expressions ("New York", "machine
learning"), finding domain-specific phrases in a corpus, and as a
pre-processing step for higher-order analytics.
### Association measures
Three measures are supported:
* `:frequency` — raw bigram count. Cheapest baseline; biased toward
high-frequency stop-word pairs.
* `:pmi` — pointwise mutual information,
`log(P(a,b) / (P(a) · P(b)))`. Highlights pairs that appear
together far more than independence predicts. Sensitive to rare
pairs, so combine with `:min_count`.
* `:log_likelihood` — Dunning's log-likelihood ratio (G²). Less
sensitive to rare-pair noise than PMI; the standard choice in
corpus linguistics.
### Defaults
By default, `bigrams/2` lower-cases tokens, drops bigrams that
appear fewer than 3 times, and ranks by `:log_likelihood`. Override
via the keyword options.
"""
alias Text.Segment
@typedoc "An association measure used to rank bigrams."
@type measure :: :frequency | :pmi | :log_likelihood
@typedoc "A token bigram represented as a 2-element list."
@type bigram :: [String.t()]
@doc """
Returns the most strongly-associated bigrams in `input`.
### Arguments
* `input` is either:
* a string (tokenised via the `:tokenizer` option), or
* a pre-tokenised list of strings (treated as a single contiguous
token stream).
### Options
* `:measure` — `:frequency`, `:pmi`, or `:log_likelihood`. Defaults
to `:log_likelihood`.
* `:min_count` — drops bigrams with raw count below this. Defaults
to `3`. Keeps PMI and friends from being dominated by once-seen
rare-pair noise.
* `:k` — number of results. Defaults to `20`. Pass `:infinity` to
return all bigrams that pass `:min_count`.
* `:tokenizer` — string-to-tokens function used when `input` is a
string. Defaults to `&Text.Segment.words/1`.
* `:fold_case` — when `true` (default), tokens are lowercased.
* `:stopwords` — a list or `MapSet` of tokens to exclude from
bigrams. A bigram with either side in the stopword set is dropped.
Defaults to `[]`.
### Returns
* A list of `{bigram, score}` tuples sorted by score descending,
where `bigram` is a two-element list.
### Examples
iex> tokens = ~w[the cat sat on the mat the cat ran fast the cat slept]
iex> [{top_bigram, _score} | _] =
...> Text.Collocation.bigrams(tokens, min_count: 2, k: 5)
iex> top_bigram
["the", "cat"]
iex> Text.Collocation.bigrams("a a a", min_count: 1, measure: :frequency)
[{["a", "a"], 2}]
"""
# Dialyzer can't always trace the opacity of `MapSet`s constructed at
# runtime from caller-supplied options through the `Enum.reject/2`
# boundary; the warning is benign.
@dialyzer {:nowarn_function, bigrams: 1, bigrams: 2}
@spec bigrams(String.t() | [String.t()], keyword()) :: [{bigram(), number()}]
def bigrams(input, options \\ []) do
measure = Keyword.get(options, :measure, :log_likelihood)
min_count = Keyword.get(options, :min_count, 3)
k = Keyword.get(options, :k, 20)
tokenizer = Keyword.get(options, :tokenizer, &Segment.words/1)
fold_case? = Keyword.get(options, :fold_case, true)
stopwords = Keyword.get(options, :stopwords, []) |> MapSet.new()
tokens =
input
|> tokenize(tokenizer)
|> maybe_fold_case(fold_case?)
|> Enum.reject(&MapSet.member?(stopwords, &1))
{bigram_counts, unigram_counts, total_bigrams} = count(tokens)
bigram_counts
|> Enum.filter(fn {_pair, count} -> count >= min_count end)
|> Enum.map(fn {pair, count} ->
score = score_pair(measure, pair, count, unigram_counts, total_bigrams)
{pair, score}
end)
|> Enum.sort_by(fn {_pair, score} -> -score end)
|> take(k)
end
# ---- internal ---------------------------------------------------------
defp tokenize(input, tokenizer) when is_binary(input), do: tokenizer.(input)
defp tokenize(tokens, _tokenizer) when is_list(tokens), do: tokens
defp maybe_fold_case(tokens, false), do: tokens
defp maybe_fold_case(tokens, true), do: Enum.map(tokens, &String.downcase/1)
# Walk the token stream once, building unigram and bigram counts.
defp count(tokens) do
unigrams = count_terms(tokens)
bigrams =
tokens
|> Enum.chunk_every(2, 1, :discard)
|> count_terms()
{bigrams, unigrams, max(length(tokens) - 1, 0)}
end
defp count_terms(items) do
Enum.reduce(items, %{}, fn item, acc ->
Map.update(acc, item, 1, &(&1 + 1))
end)
end
# ---- measures ---------------------------------------------------------
defp score_pair(:frequency, _pair, count, _unigrams, _total), do: count
defp score_pair(:pmi, [a, b], count, unigrams, total) do
p_ab = count / total
p_a = (Map.get(unigrams, a) || 0) / total
p_b = (Map.get(unigrams, b) || 0) / total
if p_a == 0 or p_b == 0 do
0.0
else
:math.log(p_ab / (p_a * p_b))
end
end
defp score_pair(:log_likelihood, [a, b], k11, unigrams, total) do
# 2x2 contingency table:
# k11 = count(a, b) — bigram observed
# k12 = count(a, ¬b) — a followed by non-b
# k21 = count(¬a, b) — non-a followed by b
# k22 = count(¬a, ¬b) — neither
n_a = Map.get(unigrams, a) || 0
n_b = Map.get(unigrams, b) || 0
k12 = max(n_a - k11, 0)
k21 = max(n_b - k11, 0)
k22 = max(total - k11 - k12 - k21, 0)
2 *
(xlogx(k11) + xlogx(k12) + xlogx(k21) + xlogx(k22) - xlogx(k11 + k12) -
xlogx(k11 + k21) - xlogx(k12 + k22) - xlogx(k21 + k22) + xlogx(total))
end
defp xlogx(0), do: 0.0
defp xlogx(n) when is_integer(n) and n > 0, do: n * :math.log(n)
# ---- top-k ------------------------------------------------------------
defp take(list, :infinity), do: list
defp take(list, k) when is_integer(k) and k >= 0, do: Enum.take(list, k)
end