Text.Sentiment classifies text as :positive, :negative, or :neutral and produces a fine-grained polarity score. Two backends ship in the box: a fast, deterministic, multilingual lexicon backend (default) and an optional neural backend for higher quality on hard cases.
The right backend depends on your throughput, latency, and quality budget. The lexicon backend handles tens of thousands of items per second per core with no model download; the neural backend produces measurably better labels on figurative, sarcastic, or short-form text — at the cost of a one-time ~1 GB model download and an order-of-magnitude higher per-call latency.
Don't panic. The defaults work end-to-end with no setup. The options below tune behaviour rather than enabling it.
Quick start
Text.Sentiment.analyze("I really love this product!")
#=> %{
#=> label: :positive,
#=> compound: 0.6,
#=> sum: 3.0,
#=> tokens: 6,
#=> matched: 1,
#=> language: :en
#=> }
Text.Sentiment.label("Don't talk to me about life.")
#=> :negativeanalyze/2 returns the full result; label/2 is a thin wrapper that returns just the label.
Result shape
| Field | Meaning |
|---|---|
:label | :positive, :negative, or :neutral after threshold rules. |
:compound | Normalised polarity score in [-1.0, +1.0]. The standard "give me one number" answer. |
:sum | Raw signed sum of token scores before normalisation (lexicon backend only). |
:tokens | Count of input tokens after splitting. |
:matched | Count of tokens that scored against the lexicon. |
:language | The language tag actually used (after fallback resolution). |
:backend | The module that produced the result (set by the Bumblebee backend; absent for lexicon). |
:scores | Per-class probability map (Bumblebee backend only). |
The :compound score is the value to map to UI elements (a polarity bar, a colour scale, a sort key); the :label is for human-readable display and downstream filtering.
Choosing a backend
Lexicon backend (default)
Text.Sentiment.Backends.Lexicon scores tokens against a bundled per-language lexicon, applies VADER-style adjustments for negation ("not good" flips polarity) and intensifiers/diminishers ("very good" boosts, "slightly good" dampens), and normalises the sum to the compound [-1, 1] range.
- No model download. No optional deps. Always available.
- Multilingual via lexicon swap. Bundled AFINN lexicons cover English, Danish, Finnish, French, Polish, Swedish, Turkish, plus a language-agnostic emoticon lexicon.
- Deterministic. Same input always produces the same output.
- Fast. ~10–100 µs per item on typical hardware.
The bundled language tags:
Text.Sentiment.Lexicons.AFINN.available()
#=> [:en, :da, :fi, :fr, :pl, :sv, :tr, :emoticon]Bumblebee backend (optional, neural)
Text.Sentiment.Backends.Bumblebee runs the document through a multilingual XLM-RoBERTa classifier (cardiffnlp/twitter-xlm-roberta-base-sentiment by default). It catches sarcasm and idiomatic polarity that the lexicon misses, and works across ~30 languages without per-language tuning.
Requires
:bumblebeeand (recommended):exla. Add to yourmix.exs:{:bumblebee, "~> 0.6", optional: true}, {:exla, "~> 0.9", optional: true}First call is slow. Cold start downloads ~1.1 GB of model weights and traces the inference graph. Subsequent calls hit a
:persistent_term-cachedNx.Servingand run in single-digit milliseconds.Pre-download for production:
mix text.download_models --sentiment.
To switch globally:
# config/config.exs
config :text, :sentiment_backend, Text.Sentiment.Backends.BumblebeeOr per call:
Text.Sentiment.analyze("This was a bad experience.",
backend: Text.Sentiment.Backends.Bumblebee
)The result shape is the same on both backends — the :label and :compound fields are always present, so call sites don't need to know which backend ran.
Languages
The :language option controls which AFINN lexicon (or which language the neural model is told the input is in) is used. It accepts:
- An atom:
:en,:fr,:pl, … - A BCP-47 string:
"en","en-US","fr-CA". - A
Localize.LanguageTagstruct, when the optional:localizedependency is loaded.
Text.Sentiment.analyze("J'adore ce livre.", language: :fr).label
#=> :positive
Text.Sentiment.analyze("Ten film jest okropny.", language: :pl).label
#=> :negativeIf the requested language isn't bundled (for the lexicon backend), the result falls back to :en. Override with :fallback_language:
Text.Sentiment.analyze("¡Qué increíble!",
language: :es,
fallback_language: :en # Spanish isn't bundled — fall back to English
)The neural backend ignores :language for routing (XLM-RoBERTa is intrinsically multilingual) but still records the value in the result for round-tripping.
Composing custom lexicons
Text.Sentiment.lexicon_for/2 builds composite lexicons. Common pattern: a base language plus the emoticon lexicon plus your own domain-specific overrides.
lexicon = Text.Sentiment.lexicon_for(:en,
with_emoticons: true,
overrides: %{
"lit" => 3,
"mid" => -1,
"based" => 4
}
)
Text.Sentiment.analyze("That was lit :-)", lexicon: lexicon).label
#=> :positiveThe :lexicon option overrides :language, so a custom lexicon is fully self-contained — you can mix words from multiple languages, slang, brand-charged terms, anything you want.
Score values are integers in [-5, +5] by AFINN convention but any number works; the compound score normalisation handles whatever range you pick.
Negation, intensifiers, diminishers
The lexicon backend applies VADER-inspired adjustments based on tokens immediately preceding a scored term:
- Negators flip the sign:
"not good"→-good. Default English negators:not,no,never,n't-style contractions. - Intensifiers multiply:
"very good"× 1.293. Default boosters:very,extremely,absolutely, … - Diminishers dampen:
"slightly good"× 0.293. Default dampers:slightly,barely,hardly, …
Override via options:
Text.Sentiment.analyze("Marvin was incredibly miserable.",
intensifiers: ["incredibly", "ridiculously", "unbelievably"]
)All three options are forwarded through to Text.Sentiment.Lexicon.score/3. Use them to localise the modifiers when working with a non-English lexicon (the bundled AFINN lexicons cover content words but not modifier classes).
Threshold tuning
By default, results map to a label using compound >= 0.05 for positive and compound <= -0.05 for negative, with everything else neutral. These VADER-derived defaults work well for most short-form text but can be too eager for longer documents (where small per-token signal accumulates and pushes neutral content into the polar buckets).
Text.Sentiment.analyze("Tea, Earl Grey, hot.",
positive_threshold: 0.2,
negative_threshold: -0.2
)For batch labelling pipelines it's often easier to skip the :label and bin the :compound score yourself with thresholds tuned to your domain.
Production checklist
Pre-download neural model weights at deploy time:
mix text.download_models --sentiment. Avoids cold-start latency on the first request.Start a named
Nx.Servingat boot if you're using the Bumblebee backend at high QPS:{:ok, _} = Nx.Serving.start_link( serving: Bumblebee.Text.text_classification(model_info, tokenizer, ...), name: MyApp.SentimentServing ) Text.Sentiment.analyze(text, serving: MyApp.SentimentServing)This skips the
:persistent_termlazy cache entirely.Set the global backend in config so call sites stay backend-agnostic. Tests can override per call with
backend: Text.Sentiment.Backends.Lexiconfor speed.Cap input length for the Bumblebee backend (default sequence length 128 tokens). Long text gets truncated; if your domain requires whole-document scoring, chunk by sentence and aggregate.
When to prefer which backend
| Use case | Recommended backend |
|---|---|
| High-throughput batch jobs | Lexicon |
| Multilingual short text (tweets, reviews) | Bumblebee |
| Long-form formal prose | Lexicon with raised thresholds |
| Sarcasm / idiomatic / figurative | Bumblebee |
Languages outside the bundled AFINN set without an :overrides map | Bumblebee |
| Embedded / no-model-download environments | Lexicon (only option) |
| Determinism required (reproducible audits) | Lexicon |