nltk-punkt-tokenize
A modern implementation of the Punkt unsupervised sentence boundary detector (Kiss & Strunk, 2006) that does not read model files at all.
Zero dependencies. Standard library only. Pretrained models for 19 languages ship inside the wheel as compiled Python modules; nothing is downloaded, ever, and nothing is parsed.
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.
The conclusion this package eventually reached is that the safest parser is the
one that is not there. A Punkt model is four containers of strings and integers,
which Python can already express, so the models are compiled into the package as
Python literals and the library ships no model reader of any kind. Reading
files is a separate job for separate code you run deliberately — see
tools/ — and not something an installed library does on
your behalf. See Security.
What's different
Models are data, not programs. A Punkt model is four containers of strings
and integers. Every model the package can load is compiled into it as Python
literals, so loading one is an import — no file to find, no format to parse,
no initialisation.
The package contains no model parser at all. Not for pickles, not for JSON,
not for its own format. There is nothing for a model file to attack, because
there is nothing that reads one. The readers for the four file formats a Punkt
model has historically been stored in live in tools/ and
are deliberately not shipped: they exist to produce the compiled modules, and
to bring in a model you already have.
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")
A trained model is a value you can use immediately. To keep one, render it as a Python module — the same representation the bundled models use, so there is no second format that only your models are stored in:
Path("danish.py").write_text(punkt.render_model(params, "danish"))
from danish import MODEL
punkt.PunktSentenceTokenizer(MODEL)
Rendering only ever writes literals, and verifies its own output by parsing it and rejecting anything that is not data — see Security.
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.py -l english
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 |
Models you already have in nltk_data
The 19 stock languages are bundled, so you need nothing. For a model you
trained yourself, or a punkt_tab directory you want to bring across, clone
this repository and use the converter — it is not part of the installed
package, by design:
python tools/convert_model.py --list # what nltk_data has
python tools/convert_model.py ~/nltk_data/tokenizers/punkt_tab/portuguese \
-o portuguese.py
from portuguese import MODEL
punkt.PunktSentenceTokenizer(MODEL)
.pickle inputs are parsed, never unpickled. --all converts a whole
directory, and --format punkt_tab writes a directory NLTK can load. See
tools/README.md.
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-8to its null probability, matching NLTK's development branch. Without it a corpus containing no period-final tokens raisesValueErrorfromlog(0). - No mutation on read. NLTK stores
ortho_contextin adefaultdict(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_initialand friends returnbool, not a truthyre.Match. The orthographic heuristic returns a three-valued enum instead ofTrue/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
The shape of the thing
The package does not read model files. Every model it can load is a Python
module inside the wheel, so punkt.load_model is an import: there is no path
to resolve, no bytes to parse, no format to confuse, and no limit to exceed.
Whole categories of attack are absent rather than defended against.
There is also no search path. Earlier releases consulted $PUNKT_MODEL_PATH
and ~/.punkt/models before the compiled-in data, which let a file dropped in
the right directory decide what your program tokenized with. Names now resolve
to bundled modules and nothing else.
The one place code runs
Importing a module executes it. That is the 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. What made CVE-2024-39705 a vulnerability was not that NLTK's models were code; it was that they were downloaded at run time and then unpickled. Nothing here downloads anything.
Two tests enforce it: one walks the package AST asserting no unpickling call exists, the other walks the generated data modules asserting they contain nothing but assignments.
Rendering a model as Python
punkt.render_model writes Python source that you then import, and some of what
it writes can come from a model read out of a file somebody else wrote. That is
a code-execution vector if any value reaches the output unquoted — and one did.
The model name was interpolated into the generated module's docstring without
repr, so a name containing a triple quote closed the docstring and everything
after it became live code. tools/convert_model.py derives that name from the
input file's name, which made a maliciously named file arbitrary code
execution at import time.
Three things now stand in the way:
- The name must be a Python identifier — the right constraint, since it becomes a module name, and a complete one.
- Every value must be a
stror anint, checked before rendering, and each is emitted throughrepr. - The finished source is parsed and verified: the AST may contain only
literals, tuple and dict displays, and calls to
frozenset, and must assign exactly the ten expected names. Anything else raises rather than being written.
The third is the guarantee that does not rest on the quoting being right.
tests/test_render.py attacks all three through the name, the metadata and each
learned container, and asserts a fresh interpreter importing the result has no
side effects.
Reading other people's files
The converters under tools/ do parse files, and they carry
the defences that implies. They are not shipped and the library never calls
them; you run them deliberately, on input you chose.
| Attack | Defence |
|---|---|
Code execution via pickle |
Nothing calls pickle.load. 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 before
the parser is invoked at all, because anything checked after json.loads
returns is checked too late.
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()
A name resolves to a bundled module and nothing else — there is no search path
and no way for a file on disk to shadow one. To use a model of your own, build
it (punkt.train) or convert it (tools/convert_model.py) and import it
directly.
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
git clone https://github.com/alvations/nltk_punkt
cd nltk_punkt
pip install -e ".[dev]"
pytest # 405 tests: the package
pytest tools/tests # 163 tests: the converters
ruff check src tools tests
mypy src/punkt
Two suites, because there are two things. tests/ covers what the wheel
contains, and must pass with tools/ absent — which is how it arrives in an
sdist. tools/tests/ covers the model-file converters, which are published in
no artefact at all and run only from a checkout.
Everything that builds, converts or scores lives in tools/.
See the tools README for regenerating the bundled data, converting models out of
nltk_data, and why writing Python from an untrusted file is safe here.
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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file nltk_punkt_tokenize-2.0.0.tar.gz.
File metadata
- Download URL: nltk_punkt_tokenize-2.0.0.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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0567f7f1dda276acf3f7fb85362a04429894d43e0d8b12ec7344d6a74bfa0726
|
|
| MD5 |
b968bc21b30bfdfeaf05f24fdcffb74e
|
|
| BLAKE2b-256 |
46256e28eadf3a98580ca08a64d1c1507072616de2962b76277191de76258794
|
File details
Details for the file nltk_punkt_tokenize-2.0.0-py3-none-any.whl.
File metadata
- Download URL: nltk_punkt_tokenize-2.0.0-py3-none-any.whl
- Upload date:
- Size: 5.4 MB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.9.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a0cb5d99e2e667716e7425a25b456b567cfdc6a110c9f6c42dfe33b0eeaab229
|
|
| MD5 |
968976f242bcefef6cea8d05815437b5
|
|
| BLAKE2b-256 |
9a46d9750813ba478e3d9095cd6613bcc8ae86130ffa9abe3c0977b651c877eb
|