Packages

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

Current section

Files

Jump to
text lib readability.ex
Raw

lib/readability.ex

defmodule Text.Readability do
@moduledoc """
Readability metrics for English text.
Implements the classic readability indices used to estimate the
reading difficulty of a passage:
* `flesch/2` — Flesch Reading Ease (higher = easier).
* `flesch_kincaid/2` — Flesch-Kincaid Grade Level (US school grade).
* `gunning_fog/2` — Gunning-Fog Index (years of education).
* `smog/2` — SMOG Index (years of education; needs ~30 sentences).
* `ari/2` — Automated Readability Index (US school grade).
* `coleman_liau/2` — Coleman-Liau Index (US school grade).
* `lix/2` — LIX (Läsbarhetsindex; language-agnostic-ish).
* `dale_chall/2` — Dale-Chall (US school grade); uses the bundled
~3,000-word easy-words list.
* `spache/2` — Spache (US school grade, K-3 readers); uses the
bundled ~1,000-word easy-words list.
Use `metrics/2` to compute every index in one pass over the text,
and `statistics/2` to inspect the raw counts (words, sentences,
syllables, characters, polysyllables, long words) the metrics are
built from.
Sentence and word segmentation use `Text.Segment` (UAX #29 with
CLDR abbreviation suppressions). Syllable counting uses
`Text.Syllable`, which currently supports English only — so all
metrics that depend on syllable counts (Flesch, Flesch-Kincaid,
Gunning-Fog, SMOG) are English-only. ARI, Coleman-Liau, and LIX
are character/length-based and work for any whitespace-segmented
language. Dale-Chall and Spache are English-only and use the
bundled easy-word lists in `priv/readability/`.
"""
alias Text.Segment
alias Text.Syllable
@dale_chall_path "priv/readability/dale_chall.txt"
@spache_path "priv/readability/spache.txt"
@external_resource @dale_chall_path
@external_resource @spache_path
@dale_chall_words @dale_chall_path
|> File.read!()
|> String.split(~r/\r?\n/, trim: true)
|> Enum.map(&String.downcase/1)
|> MapSet.new()
@spache_words @spache_path
|> File.read!()
|> String.split(~r/\r?\n/, trim: true)
|> Enum.map(&String.downcase/1)
|> MapSet.new()
@typedoc "Raw text statistics used to compute the readability metrics."
@type statistics :: %{
characters: non_neg_integer(),
letters: non_neg_integer(),
words: non_neg_integer(),
sentences: non_neg_integer(),
syllables: non_neg_integer(),
polysyllables: non_neg_integer(),
long_words: non_neg_integer(),
difficult_words: non_neg_integer(),
unfamiliar_words: non_neg_integer(),
average_sentence_length: float(),
average_syllables_per_word: float()
}
@typedoc "Map of every readability metric computed for a text."
@type metrics :: %{
flesch: float(),
flesch_kincaid: float(),
gunning_fog: float(),
smog: float(),
ari: float(),
coleman_liau: float(),
lix: float(),
dale_chall: float(),
spache: float()
}
@long_word_threshold 7
@polysyllable_threshold 3
@doc """
Returns the raw text statistics used by the readability metrics.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the language used for syllable counting and
sentence segmentation. The default is `:en`. Only `:en` is
supported by the syllable counter today.
### Returns
* A map with the keys `:characters`, `:letters`, `:words`,
`:sentences`, `:syllables`, `:polysyllables`, `:long_words`,
`:average_sentence_length`, and `:average_syllables_per_word`.
Empty input returns zeroed counts and `0.0` averages.
### Examples
iex> stats = Text.Readability.statistics("The cat sat on the mat. The dog ran.")
iex> stats.words
9
iex> stats.sentences
2
"""
@spec statistics(String.t(), keyword()) :: statistics()
def statistics(text, options \\ []) when is_binary(text) do
language = Keyword.get(options, :language, :en)
sentences = Segment.sentences(text, locale: to_string(language))
sentence_count = length(sentences)
words = Segment.words(text, locale: to_string(language))
word_count = length(words)
{syllables, polysyllables, long_words, letters, difficult, unfamiliar} =
Enum.reduce(words, {0, 0, 0, 0, 0, 0}, fn word, {sy, poly, long, letters, dc, sp} ->
s = Syllable.count(word, language: language)
l = letters_in(word)
word_lower = String.downcase(word)
word_len = String.length(word)
{
sy + s,
poly + if(s >= @polysyllable_threshold, do: 1, else: 0),
long + if(word_len >= @long_word_threshold, do: 1, else: 0),
letters + l,
dc + if(letter_word?(word) and not MapSet.member?(@dale_chall_words, word_lower), do: 1, else: 0),
sp + if(letter_word?(word) and not MapSet.member?(@spache_words, word_lower), do: 1, else: 0)
}
end)
%{
characters: String.length(text),
letters: letters,
words: word_count,
sentences: sentence_count,
syllables: syllables,
polysyllables: polysyllables,
long_words: long_words,
difficult_words: difficult,
unfamiliar_words: unfamiliar,
average_sentence_length: safe_div(word_count, sentence_count),
average_syllables_per_word: safe_div(syllables, word_count)
}
end
@doc """
Returns the Flesch Reading Ease score.
Higher scores mean easier text. 60-70 is "plain English"; 30-50
is "difficult"; 0-30 is "very confusing".
Formula: `206.835 − 1.015 × (words/sentences) − 84.6 × (syllables/words)`.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the syllable-counting language, default `:en`.
### Returns
* A float. Returns `0.0` if the text has no sentences or words.
### Examples
iex> Text.Readability.flesch("The cat sat on the mat.") |> Float.round(1)
116.1
"""
@spec flesch(String.t() | statistics(), keyword()) :: float()
def flesch(text_or_stats, options \\ [])
def flesch(text, options) when is_binary(text), do: flesch(statistics(text, options), options)
def flesch(%{words: 0}, _options), do: 0.0
def flesch(%{sentences: 0}, _options), do: 0.0
def flesch(%{average_sentence_length: asl, average_syllables_per_word: aspw}, _options) do
206.835 - 1.015 * asl - 84.6 * aspw
end
@doc """
Returns the Flesch-Kincaid Grade Level.
Output is a US school grade level (e.g. `8.5` ≈ mid eighth grade).
Formula: `0.39 × (words/sentences) + 11.8 × (syllables/words) − 15.59`.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the syllable-counting language, default `:en`.
### Returns
* A float grade level. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.flesch_kincaid("The cat sat on the mat.") |> Float.round(1)
-1.4
"""
@spec flesch_kincaid(String.t() | statistics(), keyword()) :: float()
def flesch_kincaid(text_or_stats, options \\ [])
def flesch_kincaid(text, options) when is_binary(text),
do: flesch_kincaid(statistics(text, options), options)
def flesch_kincaid(%{words: 0}, _options), do: 0.0
def flesch_kincaid(%{sentences: 0}, _options), do: 0.0
def flesch_kincaid(%{average_sentence_length: asl, average_syllables_per_word: aspw}, _options) do
0.39 * asl + 11.8 * aspw - 15.59
end
@doc """
Returns the Gunning-Fog Index.
Output is roughly the years of formal education a reader needs.
12 is a US high-school senior; 17+ is graduate-level.
Formula: `0.4 × ((words/sentences) + 100 × (complex_words/words))`,
where a complex word is one with 3 or more syllables.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the syllable-counting language, default `:en`.
### Returns
* A float. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.gunning_fog("The complicated explanation confused everyone immediately.") |> Float.round(1)
35.7
"""
@spec gunning_fog(String.t() | statistics(), keyword()) :: float()
def gunning_fog(text_or_stats, options \\ [])
def gunning_fog(text, options) when is_binary(text),
do: gunning_fog(statistics(text, options), options)
def gunning_fog(%{words: 0}, _options), do: 0.0
def gunning_fog(%{sentences: 0}, _options), do: 0.0
def gunning_fog(%{words: words, sentences: sentences, polysyllables: complex}, _options) do
0.4 * (words / sentences + 100 * complex / words)
end
@doc """
Returns the SMOG Index (Simple Measure Of Gobbledygook).
Output is roughly the years of formal education a reader needs.
Designed to be applied to passages of about 30 sentences; results
on shorter passages are less reliable.
Formula: `1.0430 × √(polysyllables × 30 / sentences) + 3.1291`.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the syllable-counting language, default `:en`.
### Returns
* A float. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.smog("The cat sat on the mat. The dog ran fast.") |> is_float()
true
"""
@spec smog(String.t() | statistics(), keyword()) :: float()
def smog(text_or_stats, options \\ [])
def smog(text, options) when is_binary(text), do: smog(statistics(text, options), options)
def smog(%{sentences: 0}, _options), do: 0.0
def smog(%{polysyllables: poly, sentences: sentences}, _options) do
1.0430 * :math.sqrt(poly * 30 / sentences) + 3.1291
end
@doc """
Returns the Automated Readability Index.
Output is a US school grade level. Character-based rather than
syllable-based, so it works for any whitespace-segmented language.
Formula: `4.71 × (letters/words) + 0.5 × (words/sentences) − 21.43`.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the segmentation locale, default `:en`.
### Returns
* A float grade level. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.ari("The cat sat on the mat.") |> Float.round(1)
-5.1
"""
@spec ari(String.t() | statistics(), keyword()) :: float()
def ari(text_or_stats, options \\ [])
def ari(text, options) when is_binary(text), do: ari(statistics(text, options), options)
def ari(%{words: 0}, _options), do: 0.0
def ari(%{sentences: 0}, _options), do: 0.0
def ari(%{letters: letters, words: words, sentences: sentences}, _options) do
4.71 * (letters / words) + 0.5 * (words / sentences) - 21.43
end
@doc """
Returns the Coleman-Liau Index.
Output is a US school grade level. Character-based; no syllable
counting required.
Formula: `0.0588 × L − 0.296 × S − 15.8`, where `L` is letters
per 100 words and `S` is sentences per 100 words.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the segmentation locale, default `:en`.
### Returns
* A float grade level. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.coleman_liau("The cat sat on the mat.") |> Float.round(1)
-4.1
"""
@spec coleman_liau(String.t() | statistics(), keyword()) :: float()
def coleman_liau(text_or_stats, options \\ [])
def coleman_liau(text, options) when is_binary(text),
do: coleman_liau(statistics(text, options), options)
def coleman_liau(%{words: 0}, _options), do: 0.0
def coleman_liau(%{letters: letters, words: words, sentences: sentences}, _options) do
l = letters * 100 / words
s = sentences * 100 / words
0.0588 * l - 0.296 * s - 15.8
end
@doc """
Returns the LIX (Läsbarhetsindex) readability score.
Designed for Swedish but used as a language-agnostic indicator.
A long word is one with 7 or more characters.
Formula: `(words/sentences) + 100 × (long_words/words)`.
Interpretation: <30 very easy, 30-40 easy, 40-50 medium,
50-60 difficult, >60 very difficult.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the segmentation locale, default `:en`.
### Returns
* A float. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.lix("The cat sat on the mat.") |> Float.round(1)
6.0
"""
@spec lix(String.t() | statistics(), keyword()) :: float()
def lix(text_or_stats, options \\ [])
def lix(text, options) when is_binary(text), do: lix(statistics(text, options), options)
def lix(%{words: 0}, _options), do: 0.0
def lix(%{sentences: 0}, _options), do: 0.0
def lix(%{words: words, sentences: sentences, long_words: long}, _options) do
words / sentences + 100 * long / words
end
@doc """
Returns the Dale-Chall readability score.
Output is a US school grade level. Computed from the percentage of
*difficult* words — words **not** in the bundled ~3,000-word
easy-words list — and the average sentence length. Uses the
Chall-Dale 1995 adjustment: the raw score is shifted by `+3.6365`
when more than 5% of words are difficult.
Formula: `0.1579 × (PDW × 100) + 0.0496 × ASL [+ 3.6365 if PDW > 0.05]`,
where `PDW` is the proportion of difficult words and `ASL` is
average sentence length.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the segmentation locale, default `:en`. The
bundled easy-words list is English-only.
### Returns
* A float grade level. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.dale_chall("The cat sat on the mat.") |> is_float()
true
"""
@spec dale_chall(String.t() | statistics(), keyword()) :: float()
def dale_chall(text_or_stats, options \\ [])
def dale_chall(text, options) when is_binary(text),
do: dale_chall(statistics(text, options), options)
def dale_chall(%{words: 0}, _options), do: 0.0
def dale_chall(%{sentences: 0}, _options), do: 0.0
def dale_chall(%{words: words, sentences: sentences, difficult_words: difficult}, _options) do
pdw = difficult / words
asl = words / sentences
raw = 0.1579 * (pdw * 100) + 0.0496 * asl
if pdw > 0.05, do: raw + 3.6365, else: raw
end
@doc """
Returns the Spache readability score.
Output is a US school grade level, intended for K-3 reading
material. Computed from the percentage of *unfamiliar* words —
words **not** in the bundled ~1,000-word Spache easy-words list —
and average sentence length.
Formula: `0.121 × ASL + 0.082 × (UPW × 100) + 0.659`, where `ASL`
is average sentence length and `UPW` is the proportion of
unfamiliar words.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the segmentation locale, default `:en`. The
bundled easy-words list is English-only.
### Returns
* A float grade level. Returns `0.0` for empty input.
### Examples
iex> Text.Readability.spache("The cat sat on the mat.") |> is_float()
true
"""
@spec spache(String.t() | statistics(), keyword()) :: float()
def spache(text_or_stats, options \\ [])
def spache(text, options) when is_binary(text),
do: spache(statistics(text, options), options)
def spache(%{words: 0}, _options), do: 0.0
def spache(%{sentences: 0}, _options), do: 0.0
def spache(%{words: words, sentences: sentences, unfamiliar_words: unfamiliar}, _options) do
asl = words / sentences
upw = unfamiliar / words * 100
0.121 * asl + 0.082 * upw + 0.659
end
@doc """
Computes every readability metric in one pass.
### Arguments
* `text` is a string of one or more sentences.
### Options
* `:language` is the syllable-counting and segmentation language,
default `:en`.
### Returns
* A map with the keys `:flesch`, `:flesch_kincaid`, `:gunning_fog`,
`:smog`, `:ari`, `:coleman_liau`, `:lix`, `:dale_chall`, and
`:spache`. Empty input yields `0.0` for every metric.
### Examples
iex> Text.Readability.metrics("The cat sat on the mat.") |> Map.keys() |> Enum.sort()
[:ari, :coleman_liau, :dale_chall, :flesch, :flesch_kincaid, :gunning_fog, :lix, :smog, :spache]
"""
@spec metrics(String.t(), keyword()) :: metrics()
def metrics(text, options \\ []) when is_binary(text) do
stats = statistics(text, options)
%{
flesch: flesch(stats, options),
flesch_kincaid: flesch_kincaid(stats, options),
gunning_fog: gunning_fog(stats, options),
smog: smog(stats, options),
ari: ari(stats, options),
coleman_liau: coleman_liau(stats, options),
lix: lix(stats, options),
dale_chall: dale_chall(stats, options),
spache: spache(stats, options)
}
end
defp letters_in(word) do
word
|> String.to_charlist()
|> Enum.count(&letter?/1)
end
defp letter?(c) when c >= ?a and c <= ?z, do: true
defp letter?(c) when c >= ?A and c <= ?Z, do: true
defp letter?(_), do: false
# Treat tokens with at least one letter as candidates for the
# easy/difficult check; pure punctuation and digit tokens shouldn't
# count as "difficult" simply because they're not in the lexicon.
defp letter_word?(word) do
word
|> String.to_charlist()
|> Enum.any?(&letter?/1)
end
defp safe_div(_numerator, 0), do: 0.0
defp safe_div(numerator, denominator), do: numerator / denominator
end