Skip to main content

nltk-punkt-tokenize

A modern implementation of the Punkt unsupervised sentence boundary detector (Kiss & Strunk, 2006) that cannot be made to execute code by a model file.

Zero dependencies. Standard library only. Pretrained models for 19 languages ship inside the wheel; nothing is downloaded, ever.

The dependency direction is deliberate: this package is intended for NLTK to depend on, so that Punkt can be vendored out of NLTK. Nothing here imports NLTK — not the library, not the tests, not the CI.

pip install nltk-punkt-tokenize
import punkt

punkt.sent_tokenize("Dr. Smith went home. He was tired.")
# ['Dr. Smith went home.', 'He was tired.']

punkt.sent_tokenize("Das ist z.B. ein Satz. Und noch einer.", "german")
# ['Das ist z.B. ein Satz.', 'Und noch einer.']

punkt.sent_tokenize("这是一支笔。那是一本书。", "chinese")
# ['这是一支笔。', '那是一本书。']

On WMT24++, 21 of 21 languages find every segment boundary — 0 missed, 100.00% recall across Latin, Cyrillic, Greek, Chinese, Japanese and Korean. See Accuracy.


Why this exists

Punkt is a good algorithm with a bad delivery mechanism. NLTK's models were distributed as Python pickles fetched at run time, which is CVE-2024-39705: the pickle grammar contains an opcode meaning "import this module, look up this name, and call it with these arguments", so loading a model is arbitrary code execution. No amount of care in the calling code changes that, because the file chooses what gets called.

The usual reflex — swap pickle for json and declare victory — does not finish the job. JSON cannot name a class, so it is not a code-execution primitive; but json.loads builds the entire object graph before any validation code runs, which leaves the whole denial-of-service family intact. See Security.

What's different

Models are data, not programs. A Punkt model is four containers of strings and integers. The bundled ones are compiled into the package as Python literals, so loading one is an import — no file to find, no format to parse, no initialisation. Everything else is read by a hand-written bounded parser that has no way to construct a class or call a function.

The layers are separated. NLTK's punkt.py is one 1,800-line module where the language rules, the learner, and the tokenizer share mutable state through a common base class. Here:

Layer Holds Depends on
punkt.rules Language conventions, regexes, the token object nothing
punkt.model Learned parameters as data, plus every codec rules
punkt.annotate The Kiss & Strunk decision procedure rules, model
punkt.training The learner. Writes models, never tokenizes the above
punkt.inference The tokenizer. Reads models, never trains the above

The trainer has no tokenize(). The tokenizer has no train(). Hyperparameters live in a frozen TrainerConfig rather than as class attributes you have to monkey-patch.

It matches NLTK exactly. NLTK's output was captured once, from identical parameters, into tests/data/parity.json. The suite asserts against that snapshot: equal sentences and spans over 400 generated documents, equal trained models, and bit-exact log-likelihood scores. Because the reference is recorded rather than imported, parity is checked on every machine and in every CI job — and NLTK never has to be installed to check it. Four divergences are deliberate and asserted as such, so they cannot quietly become five.

Usage

Splitting

tokenizer = punkt.PunktSentenceTokenizer(punkt.load_model("english"))

tokenizer.tokenize(text)                          # list[str]
list(tokenizer.span_tokenize(text))               # [(start, end), ...]
tokenizer.tokenize(text, realign_boundaries=False)

Whitespace inside a sentence is preserved exactly, including newlines. Only whitespace between sentences is dropped.

Training

Punkt learns from unannotated text — no labelled sentence boundaries required.

params = punkt.train(corpus_text, language="danish")
punkt.write_model(params, "danish.punkt.gz")

Incrementally, for a corpus too large to hold in memory:

trainer = punkt.PunktTrainer()
for chunk in chunks:
    trainer.train(chunk, finalize=False)
params = trainer.get_params()

Tuning:

config = punkt.TrainerConfig(abbrev_threshold=0.5, include_all_collocations=True)
params = punkt.train(corpus_text, config=config)

Understanding a decision

for decision in tokenizer.debug_decisions("Dr. Smith went home."):
    print(punkt.format_decision(decision))
Text: 'Dr. Smith' (at offset 2)
Sentence break? False (default decision)
Collocation? False
'dr.':
    known abbreviation: True
    is initial: False
'smith':
    known sentence starter: False
    orthographic heuristic suggests is a sentence starter? unknown
    orthographic contexts in training: {'MID-UC', 'UNK-UC'}

Documents with headlines and captions

Punkt keys entirely off punctuation, so a line that ends a sentence by layout rather than with a period is invisible to it. On WMT24++ that is where nearly every missed boundary came from:

punkt.sent_tokenize(text, line_breaks="always")     # any newline ends a sentence
punkt.sent_tokenize(text, line_breaks="paragraph")  # a blank line does

The default is "ignore" — correct for hard-wrapped prose, where newlines fall mid-sentence. Pick by how your text is formatted.

Abbreviations the model never saw

The bundled English model was trained on the Wall Street Journal, so it knows Dr. and Corp. but not e.g. or i.e. — the source of the most-reported Punkt failures (nltk#2376, #2154, #3370, all open).

punkt.sent_tokenize("Use a hammer, e.g. a claw hammer. Then hit the nail.", prefixes=True)
# ['Use a hammer, e.g. a claw hammer.', 'Then hit the nail.']

This is opt-in, because every abbreviation added is a boundary that can no longer be found: "Add water, sugar, etc. Then stir." stops splitting. Good trade for technical prose, bad for narrative — measure on your own text.

etc, al and dept are held back even from that list, because each one genuinely ends sentences as often as not. They become placeable once the model can judge the following word:

params = punkt.with_nonbreaking_prefixes(punkt.load_model("english_web"),
                                         "english", ambiguous=True)
tok = punkt.PunktSentenceTokenizer(params)
tok.tokenize("Cats, dogs, etc. are common pets.")   # 1 sentence
tok.tokenize("Add water, sugar, etc. Then stir.")   # 2 sentences

On the bundled english model the same flag is a pure trade and gains nothing; on english_web it takes those six cases from 3/6 to 5/6.

A subset is safe unconditionally. Moses marks some prefixes as non-breaking only before a number, which a flat list cannot express:

punkt.sent_tokenize("See No. 5 on the list. It is important.")
# ['See No.', '5 on the list.', 'It is important.']   <- wrong

params = punkt.with_nonbreaking_prefixes(punkt.load_model("english"), "english",
                                         include_always=False)
punkt.PunktSentenceTokenizer(params).tokenize("See No. 5 on the list. It is important.")
# ['See No. 5 on the list.', 'It is important.']      <- and "There is no. Not at all." still splits

A new language

Language rules are an immutable value, not a subclass:

greek = punkt.LanguageVars(sent_end_chars=(".", ";", "·", "!"))
tokenizer = punkt.PunktSentenceTokenizer(params, rules=greek)

Ready-made rule sets ship for the languages the defaults do not fit:

from punkt.rules.presets import CHINESE, JAPANESE, KOREAN, GREEK, THAI

Chinese and Japanese need them structurally, not cosmetically: those scripts put no space between sentences, so the default lookahead finds zero boundary candidates and returns the whole text as one sentence. Tan & Bond (2011) abandoned Punkt over exactly this when building the NTU-MC corpus.

Accuracy

Measured with tools/eval_wmt24pp.py against WMT24++ segment boundaries, using line_breaks="always" since those documents are newline-separated paragraphs:

recall missed boundaries
21 languages, this release 100.00% 0 of 790 each
punctuation only (line_breaks="ignore") 65–78% 172–274 each

Only recall is reported. A WMT24++ segment is a translation segment, not a sentence — roughly half the English ones hold more than one — so a split inside a segment is usually a correct sentence break that the segmentation did not record. Counting those against a splitter would punish it for being right. Missing a segment boundary, on the other hand, is unambiguously wrong.

Command line

punkt tokenize book.txt                 # one sentence per line
punkt tokenize -m german artikel.txt
punkt spans book.txt                    # start, end, sentence
cat corpus.txt | punkt train - -o mine.punkt.gz -l english
punkt convert english.pickle english.punkt.gz
punkt info english --show
punkt explain "Dr. Smith went home."
punkt languages

Migrating from NLTK

NLTK here
nltk.sent_tokenize(text) punkt.sent_tokenize(text)
nltk.download("punkt") not needed — models are bundled
PunktSentenceTokenizer(train_text) PunktSentenceTokenizer(punkt.train(train_text))
PunktTrainer.ABBREV = 0.5 TrainerConfig(abbrev_threshold=0.5)
tokenizer._params tokenizer.params
PunktLanguageVars subclass LanguageVars(...) value

Existing .pickle models convert without being unpickled:

punkt convert ~/nltk_data/tokenizers/punkt/english.pickle english.punkt.gz

punkt_tab directories are read directly, and write_punkt_tab() produces directories NLTK can load.

Deliberate differences from NLTK 3.8.1

  • Unicode quotes. Curly quotes and guillemets are treated as closing punctuation, so “Hello there.” Bye. realigns correctly. NLTK added this after 3.8.1 (gh-1682); this package follows the newer behaviour.
  • Abbreviation smoothing. The abbreviation log-likelihood adds 1e-8 to its null probability, matching NLTK's development branch. Without it a corpus containing no period-final tokens raises ValueError from log(0).
  • No mutation on read. NLTK stores ortho_context in a defaultdict(int) and reads it with [], so tokenizing silently grows the model with a zero entry per unknown word. Reads here do not mutate.
  • Real booleans. is_initial and friends return bool, not a truthy re.Match. The orthographic heuristic returns a three-valued enum instead of True/False/"unknown" in one variable.

Why the name

nltk-punkt on PyPI is an unrelated 1.2 KB package that downloads NLTK's punkt data — the very thing this replaces — so the distribution is nltk-punkt-tokenize. The import name is just punkt.

Security

Threat model

A model file is untrusted input. It may come from a package index, a colleague, a CI cache, or an attacker.

Attack Defence
Code execution via pickle No loader calls pickle.load. Legacy pickles are parsed with pickletools.genops and replayed through a data-only machine; GLOBAL pushes an inert marker, REDUCE consults a fixed table, PERSID/EXT* are refused
Gzip bomb Decompressed bytes counted while streaming and capped
Multi-gigabyte model Size, line count, line length and per-section entry caps
JSON nesting exhausting the parser stack Bracket depth counted before json.loads is called
Quadratic number parsing Numeric literal length is bounded. int(digits) is O(n²), and Python 3.9/3.10 have no ceiling — a 2 MB run of digits passes both a depth check and a size check, then pins a core for minutes. Measured: >120 s unguarded, 0.09 s guarded
Hash/memory pressure from huge objects Total element count is bounded before parsing. A post-hoc len() cannot help: the dict already exists
Unbounded JSON string String literal length is bounded
Schema confusion Every JSON field is type- and range-checked; unknown keys are refused, not ignored
Malformed data loading as a different model Strict UTF-8, no errors="replace"; unknown sections rejected; flag bits validated against a mask
Callback-driven JSON parsing object_hook, object_pairs_hook and parse_constant are never passed

The four JSON bounds are applied to the raw text in a single linear pass by punkt.model.safety.prescan_json, before the parser is invoked at all — because anything checked after json.loads returns is checked too late.

Limits are configurable:

punkt.read_model("untrusted.punkt", limits=punkt.LoadLimits(max_bytes=8 << 20))

The one place code runs

Bundled models are Python modules, and importing a module executes it. That is the same trust boundary every Python package already has — you trust the code you installed — and it is why the bundled route is used only for models inside the wheel, covered by the wheel's own hash and reviewed at release.

Models from anywhere else go through the parsers, which cannot execute anything. What made CVE-2024-39705 a vulnerability was that NLTK's models fell on the wrong side of that line: downloaded at run time, then unpickled.

Two tests enforce this. One walks the package AST asserting no unpickling call exists. The other walks the generated data modules asserting they contain nothing but assignments.

Performance

Loading a bundled model is an unmarshal of a precompiled .pyc rather than a parse:

time
Parse a 20,000-entry text model ~66 ms
Import the equivalent compiled module ~5 ms

The first import of a language compiles its module to a .pyc — 200 ms for English, 800 ms for Finnish, once per installation. pip normally does this at install time, so it is not usually observed.

Training caches the corpus token total, which NLTK recomputes inside two per-type loops; this makes training linear in vocabulary size rather than quadratic, with identical results.

Models

19 languages, from Kiss & Strunk's original multilingual evaluation plus later contributions, as redistributed by NLTK: Czech, Danish, Dutch, English, Estonian, Finnish, French, German, Greek, Italian, Malayalam, Norwegian, Polish, Portuguese, Russian, Slovene, Spanish, Swedish, Turkish. Plus english_web, described below.

punkt.available_languages()

Models are looked up in order: an explicit path, then $PUNKT_MODEL_PATH, then ~/.punkt/models, then the bundled modules — so a locally trained english.punkt shadows the bundled one with no code change.

The Russian model contains abbreviations only, with no orthographic data. That is how it is distributed upstream; it will detect fewer boundaries than the others.

english_web

One extra model, opt-in. The bundled english is the Wall Street Journal model, which knows 39 sentence starters and 20,366 orthographic types. Punkt's second pass uses exactly those to decide whether an abbreviation also ends a sentence, so with that little evidence it usually answers "unknown".

english_web keeps the same abbreviations and adds evidence from 400 MB of HPLT 2.0 web text: 908 sentence starters, 381,201 orthographic types. On Universal Dependencies English it takes punctuated F1 from 0.9785 to 0.9838.

tokenizer = punkt.PunktSentenceTokenizer(punkt.load_model("english_web"))

english is untouched, so parity with NLTK and existing output are unaffected. Regenerate the model with:

python tools/train_statistics.py english --bytes 400000000 --emit english_web

The same command works for any of 35 languages. Abbreviations learned from the crawl are dropped — crawl is good at statistics and bad at abbreviations, and 400 MB of English yielded 1,648 new ones that were almost entirely $40000, !m and 3c•. Curated abbreviations come from punkt.data.curated instead.

Requirements

Python 3.9+. No dependencies, at runtime or otherwise — the package imports only the standard library, and pip install nltk-punkt-tokenize pulls in nothing else. CI asserts this three ways: over the AST, over the distribution metadata, and by making import nltk raise and then exercising the package.

Development

pip install -e ".[dev]"
pytest
pytest -m nltk        # parity tests, requires nltk installed
ruff check src tests
mypy

The test suite never imports NLTK. Parity is asserted against a recorded snapshot of NLTK's output in tests/data/parity.json, which is both cycle-free and pinned, so a change in an installed NLTK cannot silently move the goalposts.

Two maintainer tools exist, neither shipped in the distribution:

# Rebuild the bundled models from NLTK data.
python tools/build_data.py ~/nltk_data/tokenizers/punkt_tab

# Re-baseline the parity snapshot. The only file in the repo that imports NLTK.
pip install nltk && python tools/generate_parity_fixtures.py

References

Kiss, T. & Strunk, J. (2006). Unsupervised Multilingual Sentence Boundary Detection. Computational Linguistics, 32(4), 485–525.

Dunning, T. (1993). Accurate Methods for the Statistics of Surprise and Coincidence. Computational Linguistics, 19(1), 61–74.

Licence

Apache-2.0. The pretrained models are redistributed from the NLTK project and were trained by Jan Strunk and Tibor Kiss; see NOTICE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

nltk_punkt_tokenize-1.3.1.tar.gz (5.7 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

nltk_punkt_tokenize-1.3.1-py3-none-any.whl (5.4 MB view details)

Uploaded Python 3

File details

Details for the file nltk_punkt_tokenize-1.3.1.tar.gz.

File metadata

  • Download URL: nltk_punkt_tokenize-1.3.1.tar.gz
  • Upload date:
  • Size: 5.7 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.9.12

File hashes

Hashes for nltk_punkt_tokenize-1.3.1.tar.gz
Algorithm Hash digest
SHA256 0d9828c13e7e31c4a18a91722d2afc460a7163dc1996333d79abd4349d4653fb
MD5 460cd0c735eafe7bac56f179bb504834
BLAKE2b-256 006eb10e31b7eff9424842d47574aeb886296aeff3989357e8858f288b774aa9

See more details on using hashes here.

File details

Details for the file nltk_punkt_tokenize-1.3.1-py3-none-any.whl.

File metadata

File hashes

Hashes for nltk_punkt_tokenize-1.3.1-py3-none-any.whl
Algorithm Hash digest
SHA256 67cfd5fd4cb179cbfbc5e7468fbecfc9628bb4ea2a871216bec3fdb848a417ee
MD5 7e3fa5d3058e40e2c2e11fa0e226c2ec
BLAKE2b-256 a19bf69a17aa7fc28993532451a0d1e77f3253ed7e6469ecee1122195f500223

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page