Skip to main content

polystring

PyPI Python CI License: MIT

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)

Source distribution for polystring 1.0.0
File Size Uploaded
polystring-1.0.0.tar.gz 67.8 MB Details

Built distribution (wheel)

Table of built distributions (wheels) for polystring 1.0.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page