polystring
Span-level language detection for mixed-language text.
Most language detectors return one label for a whole string. polystring returns a labelled span for every part of it, with character offsets, a confidence and the script it is written in, so a sentence that switches language mid-way is not flattened into a single wrong guess.
import polystring
for span in polystring.detect("je ne peux pas venir demain, can you call me later?").spans:
print(f"{span.lang:8} {span.confidence:.2f} {span.text!r}")
fr 1.00 'je ne peux pas venir demain, '
en 0.94 'can you call me later?'
Install
pip install polystring
Python 3.9 or newer. The model ships with the package as data files, so the first call works offline.
Usage
import polystring
detection = polystring.detect("Здравствуйте, my friend and 你好")
for span in detection.spans:
print(span.start, span.end, span.lang, span.script, round(span.confidence, 2))
0 14 ru Cyrl 0.74
14 28 en Latn 0.66
28 30 zh Hani 0.55
Reuse a Detector when you are calling it repeatedly; the model is loaded
once and shared.
detector = polystring.Detector(abstain_below=0.5)
results = [detector.detect(line) for line in lines]
Detection
| attribute | meaning |
|---|---|
text |
the input, NFC-normalized unless you disabled it |
spans |
every span, in order, covering the text exactly |
languages |
the set of languages found |
is_mixed |
whether more than one language is present |
Span
| attribute | meaning |
|---|---|
start, end |
Unicode code-point offsets; span.text == text[start:end] |
text |
the span's own text |
kind |
LANG, SPECIAL, NEUTRAL or UND |
lang |
BCP-47 tag such as en, ur-Latn; None unless kind is LANG |
language |
the language alone, without the script subtag |
orthography |
"native" or "romanized" |
script |
ISO 15924 code such as Latn, Arab, Deva |
confidence |
calibrated probability in [0, 1] |
candidates |
ranked alternatives, for genuinely ambiguous spans |
Spans always tile the input: they are sorted, non-overlapping, and together
cover [0, len(text)). URLs, mentions, hashtags, emoji and bare numerals
come back as SPECIAL spans and are never given a language. Where the
evidence does not support an answer, the span is UND rather than a guess.
Detector options
| option | default | meaning |
|---|---|---|
abstain_below |
0.40 |
confidence below which a span becomes UND |
normalize |
True |
apply NFC normalization before detecting |
Errors
All exceptions derive from polystring.PolystringError.
| exception | meaning |
|---|---|
InputError |
bad input, such as a non-string or an unrecognised option |
ModelNotFoundError |
the packaged model data is missing or unreadable, so the install is damaged |
ContractViolation |
the output failed its own tiling invariant, which is a bug in polystring rather than in your input |
InvalidTagError |
an emitted tag failed BCP-47 validation, also a bug in polystring |
How it works
Three stages, and which one answers explains most of the accuracy.
Extraction claims the parts of the text that are not language: URLs, mentions, hashtags, emoji, numerals.
Script segmentation splits the rest into runs of a single script. A script used by only one language is answered here, with no model call, because there is nothing to disambiguate. A script shared by several languages becomes a window for the next stage, carrying the language it resolves to on script evidence alone. Perso-Arabic and Cyrillic have marker-codepoint tables, since some of their languages have exclusive letters.
Disambiguation decides the windows that remain. It shortlists the languages plausibly present in the window, scores each token against that shortlist, and decodes the result as a sequence, so a token's neighbours can outweigh its own weak evidence and a genuine one-word switch still survives.
The classifier is a multinomial Naive Bayes model over character n-grams,
with two further sources of evidence folded into the same score: whole-word
probabilities, and an orthographic penalty for a language that does not write
a letter present in the text. A separate term asks whether the input is a
language at all, which is what makes keyboard mash come back as UND.
Coverage
394 language and script pairs across 30 scripts. Labels are canonical
<ISO 639-3>_<ISO 15924> internally and BCP-47 tags at the API boundary, and
the two round-trip:
from polystring import inventory
inventory.normalize("ur-Latn") # 'urd_Latn'
inventory.to_legacy("urd_Latn") # 'ur-Latn'
inventory.scripts_for("urd") # ('Arab', 'Latn')
Because language and script are separate axes, Urdu in Nastaliq and Roman
Urdu are different labels with different difficulty, and span.orthography
tells them apart without string-matching the tag.
Romanized text
Romanized labels are detected and allocated to their language, covering the informal writing and typing patterns people use in several languages:
polystring.detect("hava cok guzel").spans[0].lang # 'tr'
polystring.detect("kya tum mere saath chaloge").spans[0].lang # 'hi-Latn'
Confusion groups
Some labels cannot be reliably told apart, and splitting a dialect continuum finely would otherwise make the detector abstain more, not less. Abstention is decided on the pooled confidence of the winner's group, while the reported confidence stays with the individual label:
from polystring import confusion
sorted(confusion.group_of("hin_Latn")) # ['hif_Latn', 'hin_Latn', 'urd_Latn']
Accuracy
Measured on held-out text with no training overlap, one sample per language so that a common language cannot carry the average.
| exact | 88.6% |
| right family, wrong member | 4.5% |
| wrong | 5.5% |
| abstained | 1.4% |
| median per-language accuracy | 97.7% |
Against the other Python detectors, using the same held-out text for each. Every system is scored only on the languages it actually supports, so a missing label counts against coverage rather than against accuracy:
| languages | single language | finds both | |
|---|---|---|---|
| polystring | 394 | 96.3% | 91.2% |
| langdetect | 55 | 94.2% | n/a |
| lingua | 75 | 93.5% | 70.0% |
| langid | 97 | 82.5% | n/a |
The test set is drawn from the 74 languages both lingua and polystring support. langdetect has labels for 50 of them and langid for 66, and each is scored on its own subset.
"Finds both" splices two languages into one line and asks for both back. langid and langdetect return one label per document by design, so the column does not apply to them.
Development
git clone https://github.com/saadlohani/polystring
cd polystring
pip install -e ".[dev]"
pytest
Linting and typing are enforced in CI:
ruff format --check src/ tests/
ruff check src/ tests/
mypy --strict src/polystring/
The same formatting and lint checks run locally as pre-commit hooks:
pre-commit install
The corpus and the scripts that build the model from it are not in this
repository; the trained artifacts in src/polystring/data/ are what the
library needs, and they are versioned with it.
License
MIT. See LICENSE.
Metadata
Release files for polystring 1.0.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| polystring-1.0.0.tar.gz | 67.8 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| polystring-1.0.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 135.8 MB
Release files / polystring-1.0.0.tar.gz
| Download URL | polystring-1.0.0.tar.gz |
|---|---|
| Size | 67.8 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
bd46853ab6e5b1d8a7116d200138b8cc6113a03f6f05f1c52eec45eb5125f4cd
|
|
BLAKE2b-256 checksum How to use checksums |
fdcdba2209df42a3ffdfd4c151d248462bb09d2b344aeb1fc6e97937f6a42a35
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency logRelease files / polystring-1.0.0-py3-none-any.whl
| Download URL | polystring-1.0.0-py3-none-any.whl |
|---|---|
| Size | 68.0 MB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5f97ec068fa01e423fae72b8f079191edf1bc498a97ba8e7aeef81b97169534e
|
|
BLAKE2b-256 checksum How to use checksums |
45cf02ca3322c7f7007f531668e07f29ba1944a1dc94a19c2011bdd9fb9721cd
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 6, 2026.
Transparency log