Packages

Text analysis and processing for Elixir including ngram, language detection and more.

Current section

Files

Jump to
text lib word_cloud backends rake.ex
Raw

lib/word_cloud/backends/rake.ex

defmodule Text.WordCloud.Backends.RAKE do
@moduledoc """
RAKE (Rapid Automatic Keyword Extraction) backend for `Text.WordCloud`.
RAKE is a classic, language-agnostic keyword extractor (Rose et al.,
2010). It splits the text on stopwords and punctuation to produce
candidate phrases, then scores each member word by the ratio of its
total degree (cumulative phrase length) to its raw frequency. A
phrase score is the sum of its member word scores.
Higher scores = more important. The result naturally surfaces multi-
word phrases, since phrases that consist exclusively of distinctive
content words score above the sum of their parts.
### Strengths
* No reference corpus.
* Phrase-aware by construction.
* Trivially multilingual via the stopword swap (uses `Text.Stopwords`
for the resolved language).
### Caveats
* Quality is sensitive to stopword-list completeness — under-filtering
produces bloated, low-quality phrases.
* Tends to over-rank phrases composed of low-frequency words; YAKE!
is usually a better default.
### Options
Honours the orchestrator's `:language`, `:stopwords`, `:case_fold`,
and `:locale`. RAKE's candidates are determined by stopword
boundaries; `:ngram_range` is honoured as a post-filter on the
resulting phrase lengths.
"""
@behaviour Text.WordCloud.Backend
alias Text.Segment
@impl true
def score(input, options) do
{min_n, max_n} = Keyword.get(options, :ngram_range, {1, 3})
case_fold? = Keyword.get(options, :case_fold, true)
locale = Keyword.get(options, :locale)
stopwords = resolve_stopwords(options)
sentences = tokenise_sentences(input, locale)
folded_sentences =
sentences
|> Enum.map(fn tokens ->
if case_fold?, do: Enum.map(tokens, &String.downcase/1), else: tokens
end)
phrases =
folded_sentences
|> Enum.flat_map(&split_on_stopwords(&1, stopwords))
|> Enum.filter(fn phrase ->
n = length(phrase)
n >= min_n and n <= max_n
end)
word_scores = compute_word_scores(phrases)
phrases
|> count_phrases()
|> Enum.map(fn {tokens, count} ->
kind = if length(tokens) == 1, do: :word, else: :phrase
score = phrase_score(tokens, word_scores)
{Enum.join(tokens, " "), score, count, kind}
end)
end
# ---- tokenisation ----------------------------------------------------
defp tokenise_sentences(input, locale) when is_binary(input),
do: tokenise_sentences([input], locale)
defp tokenise_sentences(documents, locale) when is_list(documents) do
documents
|> Enum.flat_map(fn text ->
text
|> normalise_whitespace()
|> split_sentences(locale)
|> Enum.map(&split_words(&1, locale))
end)
|> Enum.reject(&(&1 == []))
end
# `unicode_string`'s sentence segmenter treats newlines as sentence
# boundaries (per UAX #29), which spuriously fragments multi-line
# input pasted from documents. Collapse all whitespace runs to a
# single space before segmenting so sentence boundaries come from
# punctuation alone.
defp normalise_whitespace(text), do: String.replace(text, ~r/\s+/u, " ")
defp split_sentences(text, nil), do: Segment.sentences(text)
defp split_sentences(text, locale), do: Segment.sentences(text, locale: locale)
# RAKE treats punctuation as phrase boundaries (per Rose et al. 2010),
# so we ask the segmenter to keep punctuation tokens — the boundary
# logic in `split_on_stopwords/2` then treats them the same way it
# treats stopwords.
defp split_words(text, nil), do: Segment.words(text, punctuation: :keep)
defp split_words(text, locale), do: Segment.words(text, locale: locale, punctuation: :keep)
# Punctuation-only token: every grapheme is in a Unicode punctuation
# or symbol class. Apostrophes inside contractions ("don't") do not
# match because they're embedded in alphabetic tokens by UAX #29.
defp punctuation?(token) do
Regex.match?(~r/^[\p{P}\p{S}]+$/u, token)
end
defp resolve_stopwords(options) do
case Keyword.get(options, :stopwords, :auto) do
:none ->
MapSet.new()
:auto ->
language = Keyword.get(options, :language)
cond do
is_nil(language) -> MapSet.new()
Text.Stopwords.available?(language) -> Text.Stopwords.for(language)
true -> MapSet.new()
end
{:extend, extras} ->
language = Keyword.get(options, :language)
if language && Text.Stopwords.available?(language) do
Text.Stopwords.extend(language, extras)
else
MapSet.new(extras)
end
%MapSet{} = set ->
set
list when is_list(list) ->
MapSet.new(list)
end
end
# ---- candidate phrases -----------------------------------------------
# Walks a token sequence and emits every maximal run of non-stopword
# tokens. Stopwords (and punctuation, which the segmenter already
# drops in `:drop` mode) act as boundaries.
defp split_on_stopwords(tokens, stopwords) do
{phrases, current} =
Enum.reduce(tokens, {[], []}, fn token, {acc, current} ->
if MapSet.member?(stopwords, token) or punctuation?(token) do
if current == [], do: {acc, []}, else: {[Enum.reverse(current) | acc], []}
else
{acc, [token | current]}
end
end)
if current == [],
do: Enum.reverse(phrases),
else: Enum.reverse([Enum.reverse(current) | phrases])
end
# ---- per-word scoring ------------------------------------------------
# word_score(w) = degree(w) / freq(w)
#
# degree(w) = sum over phrases p containing w of length(p)
# freq(w) = number of phrases containing w (counted with multiplicity)
defp compute_word_scores(phrases) do
{degree, frequency} =
Enum.reduce(phrases, {%{}, %{}}, fn phrase, {deg, freq} ->
phrase_length = length(phrase)
{deg2, freq2} =
Enum.reduce(phrase, {deg, freq}, fn word, {d, f} ->
{Map.update(d, word, phrase_length, &(&1 + phrase_length)),
Map.update(f, word, 1, &(&1 + 1))}
end)
{deg2, freq2}
end)
Map.new(degree, fn {word, deg} ->
f = Map.fetch!(frequency, word)
{word, deg / f}
end)
end
defp phrase_score(tokens, word_scores) do
tokens
|> Enum.map(&Map.get(word_scores, &1, 0.0))
|> Enum.sum()
end
defp count_phrases(phrases) do
phrases
|> Enum.reduce({%{}, []}, fn phrase, {counts, order} ->
case Map.fetch(counts, phrase) do
{:ok, _} -> {Map.update!(counts, phrase, &(&1 + 1)), order}
:error -> {Map.put(counts, phrase, 1), [phrase | order]}
end
end)
|> then(fn {counts, order} ->
order
|> Enum.reverse()
|> Enum.map(fn phrase -> {phrase, Map.fetch!(counts, phrase)} end)
end)
end
end