Packages

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

Current section

Files

Jump to
text lib pos.ex
Raw

lib/pos.ex

defmodule Text.POS do
@moduledoc """
Part-of-speech tagging via [Bumblebee](https://hex.pm/packages/bumblebee).
Exposes `tag/2` which assigns a part-of-speech label
(`:noun`, `:verb`, `:adj`, …) to every word in an input sentence.
Backed by a pre-trained transformer loaded through Bumblebee's
`token_classification/3` serving — the default model is
[`vblagoje/bert-english-uncased-finetuned-pos`](https://huggingface.co/vblagoje/bert-english-uncased-finetuned-pos),
trained on the OntoNotes 5.0 / Penn Treebank tag set.
### Optional dependency
POS tagging requires the `:bumblebee` and (recommended) `:exla`
Hex packages. They are declared as optional dependencies of
`:text`; add them to your application's `mix.exs` to enable this
module:
{:bumblebee, "~> 0.6"},
{:exla, "~> 0.9"}
Without `:bumblebee`, calling `tag/2` raises with a clear "add
these to your deps" message.
### Cold start and caching
The first call to `tag/2` downloads the model (~440 MB for the
default English model) from Hugging Face, traces the inference
graph, and compiles it under EXLA. Subsequent calls hit a cached
`Nx.Serving` in `:persistent_term` and run in single-digit
milliseconds.
For production, prefer starting a named serving at boot:
serving = Bumblebee.Text.token_classification(model_info, tokenizer)
{:ok, _pid} = Nx.Serving.start_link(serving: serving, name: MyApp.POS)
Text.POS.tag("the cat sat", serving: MyApp.POS)
### Result shape
`tag/2` returns a list of `{token, tag, score}` triples. The
`tag` is an atom drawn from the model's label set — for the
default English model that's the Penn Treebank-derived
`:noun, :verb, :adj, :adv, :pron, :det, :punct, …`.
### Languages
The default model is English-only. For multilingual POS, supply
a `:model` option pointing to a multilingual checkpoint — for
example, `"QCRI/bert-base-multilingual-cased-pos-english"` for
English, or one of the language-specific BERT POS models on
Hugging Face. The result shape is the same; only the tag
vocabulary changes.
"""
@compile {:no_warn_undefined, [Bumblebee, Bumblebee.Text, EXLA, Nx.Serving]}
@default_model "vblagoje/bert-english-uncased-finetuned-pos"
@typedoc "A single token-and-tag entry in the result list."
@type tagged_token :: {String.t(), atom(), float()}
if Code.ensure_loaded?(Bumblebee) do
# Some Hugging Face fine-tunes ship without the Rust-compatible
# `tokenizer.json` Bumblebee expects — they only have the raw
# WordPiece/BPE files. The override maps each such fine-tune to a
# base-model repo that does have the right tokenizer file. Models
# not in the table use their own repo as both model and tokenizer
# source.
@tokenizer_overrides %{
"vblagoje/bert-english-uncased-finetuned-pos" => "google-bert/bert-base-uncased"
}
@doc """
Returns the part-of-speech tags for `text`.
### Arguments
* `text` is a UTF-8 binary.
### Options
* `:model` — the Hugging Face model id to use. Defaults to
`"#{@default_model}"`. Any sequence-tagging checkpoint
compatible with `Bumblebee.Text.token_classification/3`
works.
* `:serving` — pass a name or pid of a pre-started `Nx.Serving`
to skip the lazy `:persistent_term` cache. Useful in
production, especially for sharing a single serving across an
application.
* `:compile` — defn-compilation options for the model. Defaults
to `[batch_size: 1, sequence_length: 128]`.
### Returns
* A list of `{token, tag, score}` triples.
"""
@spec tag(String.t(), keyword()) :: [tagged_token()]
def tag(text, options \\ []) when is_binary(text) do
serving = resolve_serving(options)
%{entities: entities} = Nx.Serving.run(serving, text)
Enum.map(entities, fn entity ->
{entity.phrase, label_to_atom(entity.label), entity.score}
end)
end
@doc """
Drops the cached `Nx.Serving` for the given model (or all models).
"""
@spec reset(String.t() | :all) :: :ok
def reset(model \\ @default_model)
def reset(:all) do
:persistent_term.get()
|> Enum.each(fn
{{__MODULE__, :serving, _model}, _value} = entry ->
:persistent_term.erase(elem(entry, 0))
_ ->
:ok
end)
end
def reset(model) when is_binary(model) do
_ = :persistent_term.erase({__MODULE__, :serving, model})
:ok
end
# ---- internal -----------------------------------------------------
defp resolve_serving(options) do
case Keyword.get(options, :serving) do
nil ->
model = Keyword.get(options, :model, @default_model)
cache_key = {__MODULE__, :serving, model}
case :persistent_term.get(cache_key, nil) do
nil ->
serving = build_serving(model, options)
:persistent_term.put(cache_key, serving)
serving
serving ->
serving
end
serving ->
serving
end
end
defp build_serving(model, options) do
compile = Keyword.get(options, :compile, batch_size: 1, sequence_length: 128)
defn_options = Keyword.get(options, :defn_options, default_defn_options())
tokenizer_repo = resolve_tokenizer_repo(model, options)
{:ok, model_info} = Bumblebee.load_model({:hf, model})
{:ok, tokenizer} = Bumblebee.load_tokenizer({:hf, tokenizer_repo})
Bumblebee.Text.token_classification(model_info, tokenizer,
compile: compile,
defn_options: defn_options,
aggregation: :same
)
end
defp resolve_tokenizer_repo(model, options) do
Keyword.get(options, :tokenizer_repo) || Map.get(@tokenizer_overrides, model, model)
end
defp default_defn_options do
if Code.ensure_loaded?(EXLA), do: [compiler: EXLA], else: []
end
# Penn Treebank / OntoNotes tags are uppercase strings ("NN",
# "NNS", "VB", "VBD", ...). Map to coarser atoms for ergonomics;
# callers needing the fine-grained tag can use the model's raw
# label by reaching into the underlying serving directly.
defp label_to_atom(label) when is_binary(label) do
cond do
String.starts_with?(label, "NN") -> :noun
String.starts_with?(label, "VB") -> :verb
String.starts_with?(label, "JJ") -> :adj
String.starts_with?(label, "RB") -> :adv
String.starts_with?(label, "PRP") -> :pron
String.starts_with?(label, "DT") -> :det
label in ["IN", "TO"] -> :prep
label in ["CC"] -> :conj
label in ["UH"] -> :interj
label in ["CD"] -> :num
label in ["MD"] -> :modal
label in [".", ",", ":", "(", ")", "``", "''"] -> :punct
true -> label |> String.downcase() |> String.to_atom()
end
end
else
def tag(_text, _options \\ []) do
raise """
Text.POS.tag/2 requires the :bumblebee dependency to load the
default model #{@default_model}.
Add to your mix.exs:
{:bumblebee, "~> 0.6"},
{:exla, "~> 0.9"}
Then run `mix deps.get`.
"""
end
end
end