Current section
Files
Jump to
Current section
Files
lib/language/classifier/fasttext/script_detector.ex
defmodule Text.Language.Classifier.Fasttext.ScriptDetector do
@moduledoc """
Identifies the dominant Unicode script of a piece of text.
fastText's `lid.176` classifier reports a language code (e.g. `zh`,
`sr`) without distinguishing scripts: Chinese is `zh` whether the input
is Simplified or Traditional Han, Serbian is `sr` whether written in
Latin or Cyrillic. The script signal is needed downstream to assemble a
full CLDR locale (e.g. `zh-Hans-CN` vs `zh-Hant-TW`).
This module is a thin wrapper around `Unicode.script_dominance/1` from
the [`unicode`](https://hex.pm/packages/unicode) Hex package. The
wrapper does two things:
* Returns the most-frequent script as a single ISO 15924 four-letter
atom (`:Latn`, `:Cyrl`, `:Hans`, ...) suitable for direct use in
BCP-47 locale strings.
* Folds Unicode's `:common` script (digits, punctuation, whitespace —
"characters used in many scripts") out of the dominance computation,
so a sentence with one Cyrillic word and three trailing punctuation
marks still resolves to `:Cyrl`.
### Han disambiguation
`Unicode.script_dominance/1` reports CJK ideographs as `:han`. When
the dominant script of the input is Han, `detect/1` runs a second
pass over the codepoints comparing them against curated lists of
Simplified-only (`Hans`) and Traditional-only (`Hant`) characters.
The variant whose count is higher wins; ties (including text using
only shared Han codepoints) fall back to the generic `:Hani`.
The curated lists cover the ~50 most distinguishing characters for
each variant — the high-frequency function words and pronouns that
reliably differ between Simplified and Traditional. They are not
exhaustive (the [Unihan
Variants](https://www.unicode.org/reports/tr38/) database has
thousands of entries) but cover the realistic case where the input
is more than a handful of characters long. For shorter or
ambiguous input, the disambiguation may stay at `:Hani`.
"""
# Mapping from `Unicode` package script atoms (lowercase, descriptive)
# to ISO 15924 four-letter codes used by BCP-47 / CLDR. Only the scripts
# `lid.176` actually emits language tags for are listed.
@iso_15924 %{
latin: :Latn,
cyrillic: :Cyrl,
greek: :Grek,
arabic: :Arab,
hebrew: :Hebr,
devanagari: :Deva,
bengali: :Beng,
tamil: :Taml,
telugu: :Telu,
gujarati: :Gujr,
gurmukhi: :Guru,
kannada: :Knda,
malayalam: :Mlym,
sinhala: :Sinh,
oriya: :Orya,
thai: :Thai,
lao: :Laoo,
myanmar: :Mymr,
khmer: :Khmr,
georgian: :Geor,
armenian: :Armn,
han: :Hani,
hiragana: :Hira,
katakana: :Kana,
hangul: :Hang,
ethiopic: :Ethi,
tibetan: :Tibt,
mongolian: :Mong
}
@type script :: atom()
# Codepoints that exist *only* in Simplified Chinese: their
# Traditional equivalents have different codepoints (`国` ↔ `國`,
# `学` ↔ `學`, …). Curated from the most common ~60 distinguishing
# function words, pronouns, common verbs, and high-frequency nouns.
@hans_only_codepoints MapSet.new(
~c"国学时来这个们与为经后还没现实当处应让总极区选议队体点开关龙华爱进发业东车报边长万门问间听说样头觉见画书认识语话读写传专属赞议价较脑亲笔诉觉养"
)
# Codepoints that exist *only* in Traditional Chinese.
@hant_only_codepoints MapSet.new(
~c"國學時來這個們與為經後還沒現實當處應讓總極區選議隊體點開關龍華愛進發業東車報邊長萬門問間聽說樣頭覺見畫書認識語話讀寫傳專屬贊議價較腦親筆訴覺養"
)
@doc """
Returns the dominant script of `text` as an ISO 15924 four-letter atom.
### Arguments
* `text` is a UTF-8 binary.
### Returns
* An ISO 15924 four-letter script code (e.g. `:Latn`, `:Cyrl`, `:Hani`,
`:Hira`, `:Hang`).
* `:Zyyy` (the ISO 15924 "common" sentinel) when the input is empty or
contains only digits, punctuation, and other non-script characters.
### Examples
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("Hello world")
:Latn
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("Привет мир")
:Cyrl
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("你好世界,这是中文")
:Hans
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("你好世界,這是中文")
:Hant
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("你好世界")
:Hani
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("こんにちは")
:Hira
iex> Text.Language.Classifier.Fasttext.ScriptDetector.detect("123 !!!")
:Zyyy
"""
@spec detect(binary()) :: script()
def detect(text) when is_binary(text) do
# `Unicode.script_dominance/1` lists scripts in order of first
# appearance, not by frequency, so a sentence like "hi мир там"
# would resolve to Latin if we just took the head. Sort by count
# descending to actually pick the dominant script.
iso =
text
|> Unicode.script_dominance()
|> Enum.reject(fn {script, _} -> script == :common end)
|> Enum.max_by(fn {_script, {_first_pos, count}} -> count end, fn -> nil end)
|> case do
nil -> :Zyyy
{script, _} -> Map.get(@iso_15924, script, :Zzzz)
end
# Run the second-pass Hans/Hant disambiguation only when the
# primary detection settled on generic Han.
case iso do
:Hani -> han_variant(text)
other -> other
end
end
@doc """
Returns the Han variant (`:Hans`, `:Hant`, or `:Hani`) for the
Han-script content of `text`.
Counts codepoints against curated lists of Simplified-only and
Traditional-only characters. The variant with the higher count
wins; ties (or text containing only codepoints shared between the
variants) return `:Hani`.
Useful when the caller already knows the script is Han (for
instance, after running `detect/1` and seeing `:Hani`) and wants
the variant separately. `detect/1` calls this internally.
### Examples
iex> Text.Language.Classifier.Fasttext.ScriptDetector.han_variant("国学时来这")
:Hans
iex> Text.Language.Classifier.Fasttext.ScriptDetector.han_variant("國學時來這")
:Hant
iex> Text.Language.Classifier.Fasttext.ScriptDetector.han_variant("你好世界")
:Hani
iex> Text.Language.Classifier.Fasttext.ScriptDetector.han_variant("Hello world")
:Hani
"""
@spec han_variant(binary()) :: :Hans | :Hant | :Hani
def han_variant(text) when is_binary(text) do
{hans, hant} =
text
|> :unicode.characters_to_list(:utf8)
|> Enum.reduce({0, 0}, fn cp, {hans, hant} ->
cond do
MapSet.member?(@hans_only_codepoints, cp) -> {hans + 1, hant}
MapSet.member?(@hant_only_codepoints, cp) -> {hans, hant + 1}
true -> {hans, hant}
end
end)
cond do
hans > hant -> :Hans
hant > hans -> :Hant
true -> :Hani
end
end
@doc """
Returns the per-script codepoint counts as a map keyed by ISO 15924
atoms.
Useful when a caller needs more than the dominant script — e.g. a
mixed-script input may want a confidence ratio across scripts.
### Examples
iex> Text.Language.Classifier.Fasttext.ScriptDetector.tally("Hello мир")
%{Latn: 5, Cyrl: 3}
"""
@spec tally(binary()) :: %{script() => non_neg_integer()}
def tally(text) when is_binary(text) do
text
|> Unicode.script_statistic()
|> Enum.reduce(%{}, fn
{:common, _}, acc ->
acc
{unicode_script, {_first_pos, count}}, acc ->
iso = Map.get(@iso_15924, unicode_script, :Zzzz)
Map.update(acc, iso, count, &(&1 + count))
end)
end
end