Packages

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

Current section

Files

Jump to
text lib ner.ex
Raw

lib/ner.ex

defmodule Text.NER do
@moduledoc """
Named-entity recognition via [Bumblebee](https://hex.pm/packages/bumblebee).
Identifies named entities (people, organisations, locations, …) in
natural-language text and returns each as a span with type and
confidence. Backed by Bumblebee's
`Bumblebee.Text.token_classification/3` serving with span
aggregation, loading a pre-trained transformer from Hugging Face.
### Default model
The default is
[`Davlan/bert-base-multilingual-cased-ner-hrl`](https://huggingface.co/Davlan/bert-base-multilingual-cased-ner-hrl),
trained on 10 high-resource languages (Arabic, German, English,
Spanish, French, Italian, Latvian, Dutch, Portuguese, Chinese)
with the standard four-class CoNLL-2003 tag set:
* `:per` — person
* `:org` — organisation
* `:loc` — location
* `:misc` — miscellaneous
Override with `:model` to use a language-specific or
domain-specific NER model.
### Optional dependency
Like `Text.POS`, NER requires the optional `:bumblebee` and
`:exla` Hex packages. Without `:bumblebee`, `extract/2` raises
with a helpful message.
### Cold start and caching
First call downloads the model (~700 MB for the multilingual
default) and traces the inference graph. Subsequent calls hit a
`:persistent_term`-cached serving. For production, start a named
serving at boot:
serving = Bumblebee.Text.token_classification(model_info, tokenizer,
aggregation: :same)
{:ok, _pid} = Nx.Serving.start_link(serving: serving, name: MyApp.NER)
Text.NER.extract(text, serving: MyApp.NER)
### Result shape
%Text.NER.Entity{
text: "Barack Obama",
type: :per,
start: 0,
end: 12,
score: 0.99
}
Spans are byte offsets into the original input.
"""
defmodule Entity do
@moduledoc """
A single named entity span.
### Fields
* `text` — the span's surface text, exactly as it appeared in
the input.
* `type` — the entity category. Atoms drawn from the model's
label set; the default model uses `:per`, `:org`, `:loc`, or
`:misc`.
* `start`, `end` — byte offsets of the span within the input.
* `score` — the model's confidence in `[0.0, 1.0]`.
"""
@type t :: %__MODULE__{
text: String.t(),
type: atom(),
start: non_neg_integer(),
end: non_neg_integer(),
score: float()
}
defstruct [:text, :type, :start, :end, :score]
end
@compile {:no_warn_undefined, [Bumblebee, Bumblebee.Text, EXLA, Nx.Serving]}
@default_model "Davlan/bert-base-multilingual-cased-ner-hrl"
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 %{
"Davlan/bert-base-multilingual-cased-ner-hrl" => "google-bert/bert-base-multilingual-cased"
}
@doc """
Extracts named entities from `text`.
### Arguments
* `text` is a UTF-8 binary.
### Options
* `:model` — the Hugging Face model id. Defaults to the
multilingual `"#{@default_model}"`.
* `:serving` — pass a name or pid of a pre-started `Nx.Serving`
to skip the lazy cache.
* `:compile` — defn-compilation options. Defaults to
`[batch_size: 1, sequence_length: 256]`.
* `:min_score` — filter out entities below this confidence.
Defaults to `0.0` (keep everything).
### Returns
* A list of `Text.NER.Entity` structs in document order.
"""
@spec extract(String.t(), keyword()) :: [Entity.t()]
def extract(text, options \\ []) when is_binary(text) do
serving = resolve_serving(options)
min_score = Keyword.get(options, :min_score, 0.0)
%{entities: entities} = Nx.Serving.run(serving, text)
entities
|> Enum.filter(fn e -> e.score >= min_score end)
|> Enum.map(&to_entity/1)
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: 256)
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
defp to_entity(raw) do
%Entity{
text: raw.phrase,
type: type_to_atom(raw.label),
start: raw.start,
end: raw.end,
score: raw.score
}
end
# CoNLL-style labels are uppercase ("PER", "ORG", "LOC", "MISC")
# often prefixed with B-/I- for span boundaries (handled by
# Bumblebee's `aggregation: :same`). Normalise to lowercase atoms.
defp type_to_atom(label) when is_binary(label) do
label |> String.downcase() |> String.to_atom()
end
else
def extract(_text, _options \\ []) do
raise """
Text.NER.extract/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