Current section
Files
Jump to
Current section
Files
lib/language.ex
defmodule Text.Language do
@moduledoc """
A module to support natural language
detection.
The primary models are implementations
derived from [Language Identification from Text
Using N-gram Based Cumulative Frequency Addition](http://www.csis.pace.edu/~ctappert/srd2004/paper12.pdf)
"""
@known_classifiers [
Text.Language.Classifier.NaiveBayesian,
Text.Language.Classifier.CummulativeFrequency,
Text.Language.Classifier.RankOrder
]
@default_max_demand 20
@doc """
Detect the natural language of a given text.
## Arguments
* `text` is a binary text from which
the language is detected.
* `options` is a keyword list of
options.
## Options
* `:corpus` is a module encapsulating a body of
text in one or more natural languages.A corpus
module implements the `Text.Corpus` behaviour.
The default is `Text.Corpus.Udhr` which is implemented by the
[text_corpus_udhr](https://hex.pm/packages/text_corpus_udhr)
package. This package must be installed as a dependency in
order for this default to be used.
* `:classifier` is the module used to detect the language.
The default is `Text.Language.Classifier.NaiveBayesian`.
Other classifiers are `Text.Language.Classifier.RankOrder`,
`Text.Classifier.CummulativeFrequency` and
`Text.Language.Classifier.Spearman`. Any module that
implements the `Text.Language.Classifier` behaviour
may be used.
* `:vocabulary` is the vocabulary to be used. The
default is `hd(corpus.known_vocabularies())`. Available
vocabularies are returned from `corpus.known_vocabularies/0`.
* `:only` is a list of languages to be used
as candidates for the language of `text`. The
default is `corpus.known_languages/0`
which is all the lanuages known to a given
corpus.
* `:max_demand` is used to determine the batch size
for `Flow.from_enumerable/1`. The default is
`#{@default_max_demand}`.
## Returns
* A list of `2-tuples` in order of confidence with
the first element being the BCP-47 language code
and the second element being the score as determined
by the requested classifier. The score has no meaning
except to order the results by confidence level.
"""
@spec detect(String.t, Keyword.t) :: {:ok, Text.language} | {:error, {module(), String.t}}
def detect(text, options \\ []) when is_binary(text) do
case classify(text, options) do
{:error, _} = error -> error
[{language, _} | _rest] -> {:ok, language}
end
end
@doc """
Classify the natural language of a given text.
## Arguments
* `text` is a binary text from which
the language is detected.
* `options` is a keyword list of
options.
## Options
* `:corpus` is a module encapsulating a body of
text in one or more natural languages.A corpus
module implements the `Text.Corpus` behaviour.
The default is `Text.Corpus.Udhr` which is implemented by the
[text_corpus_udhr](https://hex.pm/packages/text_corpus_udhr)
package. This package must be installed as a dependency in
order for this default to be used.
* `:classifier` is the module used to detect the language.
The default is `Text.Language.Classifier.NaiveBayesian`.
Other classifiers are `Text.Language.Classifier.RankOrder`,
`Text.Classifier.CummulativeFrequency` and
`Text.Language.Classifier.Spearman`. Any module that
implements the `Text.Language.Classifier` behaviour
may be used.
* `:vocabulary` is the vocabulary to be used. The
default is `hd(corpus.known_vocabularies())`. Available
vocabularies are returned from `corpus.known_vocabularies/0`.
* `:only` is a list of languages to be used
as candidates for the language of `text`. The
default is `corpus.known_languages/0`
which is all the lanuages known to a given
corpus.
* `:max_demand` is used to determine the batch size
for `Flow.from_enumerable/1`. The default is
`#{@default_max_demand}`.
## Returns
* A list of `2-tuples` in order of confidence with
the first element being the BCP-47 language code
and the second element being the score as determined
by the requested classifier. The score has no meaning
except to order the results by confidence level.
"""
@spec classify(String.t, Keyword.t) ::
Text.Language.Classifier.frequency_list() | {:error, {module(), String.t}}
def classify(text, options \\ []) when is_binary(text) do
corpus = Keyword.get(options, :corpus, Text.Corpus.Udhr)
classifier = Keyword.get(options, :classifier, Text.Language.Classifier.NaiveBayesian)
vocabulary = Keyword.get(options, :vocabulary)
languages = Keyword.get(options, :only, corpus.known_languages())
max_demand = Keyword.get(options, :max_demand, @default_max_demand)
with {:ok, corpus} <- validate(:corpus, corpus),
{:ok, classifier} <- validate(:classifier, classifier),
{:ok, vocabulary} <- validate(:vocabulary, corpus, vocabulary),
{:ok, languages} <- validate(:only, corpus, languages) do
ensure_vocabulary_loaded!(vocabulary)
text_ngrams =
text
|> corpus.normalize_text
|> vocabulary.calculate_ngrams
languages
|> Flow.from_enumerable(max_demand: max_demand)
|> Flow.map(&classifier.score_one_language(&1, text_ngrams, vocabulary))
|> Enum.to_list
|> classifier.order_scores()
end
end
@doc """
Returns a list of the known
classifiers that can be applied as
a `:classifer` option to `Text.Language.detect/2`
"""
@spec known_classifiers :: [Text.Language.Classifier.t, ...]
def known_classifiers do
@known_classifiers
end
@doc """
Function to remove text elements that
interfer with language detection.
Each corpus has a callback `normalize_text/1`
that is applied when training the
classifier and when detecting language
from natural text. If desired, the corpus
can delegate to this function.
## Argument
* `text` is any `String.t`
## Returns
* A normalized `String.t`
"""
@spec normalize_text(String.t) :: String.t
def normalize_text(text) do
text
# Downcase
|> String.downcase()
# Make sure that there is letter before punctuation
|> String.replace(~r/\.\s*/u, "_")
# Discard all digits
|> String.replace(~r/[0-9]/u, "")
# Discard all punctuation except for apostrophe
|> String.replace(~r/[&\/\\#,+()$~%.":*?<>{}]/u, "")
# Remove duplicate spaces
|> String.replace(~r/\s+/u, " ")
end
defp ensure_vocabulary_loaded!(vocabulary) do
:persistent_term.get({vocabulary, :languages}, nil) || vocabulary.load_vocabulary!
end
defp validate(:corpus, corpus) when is_atom(corpus) do
if corpus_module?(corpus) do
{:ok, corpus}
else
{:error, {ArgumentError, "Unknown corpus #{inspect(corpus)}"}}
end
end
defp validate(:classifier, classifier) when classifier in @known_classifiers do
{:ok, classifier}
end
defp validate(:classifier, classifier) do
{:error,
{ArgumentError,
"Unknown classifier #{inspect(classifier)}. " <>
"Known classifiers are #{inspect(@known_classifiers)}."}}
end
defp validate(:vocabulary, corpus, nil) do
known_vocabularies = corpus.known_vocabularies
validate(:vocabulary, corpus, hd(known_vocabularies))
end
defp validate(:vocabulary, corpus, vocabulary) do
known_vocabularies = corpus.known_vocabularies
if vocabulary in corpus.known_vocabularies do
{:ok, vocabulary}
else
{:error,
{ArgumentError,
"Unknown vocabulary #{inspect(vocabulary)}. " <>
"Known vocabularies are #{inspect(known_vocabularies)}."
}}
end
end
defp validate(:only, corpus, languages) do
known_languages = corpus.known_languages
unknown_languages = Enum.filter(languages, &(&1 not in known_languages))
if unknown_languages == [] do
{:ok, languages}
else
{:error,
{ArgumentError,
"Unknown languages #{inspect(unknown_languages)}. " <>
"Known languages are #{inspect(known_languages)}."}}
end
end
@doc false
def corpus_module?(corpus) do
Code.ensure_loaded?(corpus) && function_exported?(corpus, :known_vocabularies, 0)
end
end