Skip to main content

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 your own model

Punkt learns from unannotated text — no labelled sentence boundaries required. There are three steps, and the middle one is the only thing that changed in 2.0.

1. Train. The result is a value you can use immediately.

import punkt

params = punkt.train(corpus_text, language="danish")
punkt.PunktSentenceTokenizer(params).tokenize(text)

For a corpus too large to hold in memory, feed it in chunks:

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

To keep what an existing model already knows, seed the trainer with it. Training on top of the bundled English model adds to its 156 abbreviations rather than starting from nothing:

trainer = punkt.PunktTrainer(initial_params=punkt.load_model("english"))
trainer.train(my_corpus, finalize=False)

2. Save. Render the model as a Python module — the same representation the bundled models use, so there is no second format that only your models live in:

from pathlib import Path

Path("danish.py").write_text(punkt.render_model(params, "danish"))

The name must be a Python identifier, because it becomes a module name. Rendering only ever writes literals, and verifies its own output by parsing it and rejecting anything that is not data — see Security.

3. Load. It is a Python module, so if it is importable, import it:

from danish import MODEL

punkt.PunktSentenceTokenizer(MODEL)

Ship it inside your own package (from myapp.models.danish import MODEL) and that is the whole story — it is your code, imported like any other.

For a file you did not generate, do not import it. Read it:

params = punkt.read_model_file("models/danish.py")
tokenizer = punkt.PunktSentenceTokenizer(params)

read_model_file parses the file, checks every AST node against a whitelist, and rebuilds the values with ast.literal_eval. It never imports, never produces a code object, and never reads or writes bytecode — see Security.

There is no punkt.load_model("some/path.py"), and that is on purpose: a library that imports a path on your behalf is the trust boundary this release removed.

Making it stick

from danish import MODEL works only when danish.py happens to be importable — fine in a script, fragile everywhere else. Three durable options, all of which are just "where do you put a Python module":

Inside your own package. The usual answer. The model is versioned with your code, installs with it, and works from any working directory.

myapp/
  __init__.py
  tokenize.py
  models/
    __init__.py
    danish.py        <- punkt.render_model wrote this
# myapp/tokenize.py
import punkt
from myapp.models.danish import MODEL

TOKENIZER = punkt.PunktSentenceTokenizer(MODEL)

As its own distribution, if you want to share it or pin it separately. A model package is a pyproject.toml and one generated file:

[project]
name = "punkt-model-danish"
version = "1.0.0"
dependencies = ["nltk-punkt-tokenize>=2.0"]
from punkt_model_danish import MODEL

This is exactly how the bundled models work — they are Python modules in an installed distribution, and yours is too.

In a directory you control, loaded by path with the load() helper above:

MODELS = Path.home() / ".myapp" / "models"
tokenizer = punkt.PunktSentenceTokenizer(load(MODELS / "danish.py"))

Whichever you pick, CPython caches the compiled form in __pycache__ on first import, so a model costs its parse once and is an unmarshal thereafter — 4.1 ms cold, 0.5 ms warm for a small model, and that cache survives across processes and reboots. Nothing needs to be re-rendered; the .py is the persistent artefact.

Note that punkt.load_model("danish") will not find any of these. It resolves bundled names only, deliberately — so importing your model stays something you wrote down rather than something a search path decided.

From the command line, the same three steps:

cat corpus.txt | punkt train - -o danish.py -l danish
python -c "from danish import MODEL; import punkt; print(punkt.PunktSentenceTokenizer(MODEL).tokenize(open('x.txt').read()))"

Extending a model you already have

Two different things get called "continue training", and they behave differently.

Within one trainer, counts accumulate and it is genuinely incremental. Feed as many chunks as you like before finalizing:

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

Seeding from a finished model carries its conclusions, not its evidence:

trainer = punkt.PunktTrainer(initial_params=punkt.load_model("english"))
trainer.train(my_corpus, finalize=False)
params = trainer.get_params()      # everything English knew, plus what yours adds

Nothing English had learned is lost — its 156 abbreviations, collocations and sentence starters are all carried through. But a seeded trainer starts at zero tokens, because a model stores decisions and not the frequency table they came from. Evidence that was spread across two sessions is therefore not pooled:

Ing. per half one pass over A+B seeded: train A, then B
1 not learned not learned
2 learned not learned
3 learned not learned
4 learned learned

At two or three occurrences each, one pass finds it and the seeded pass does not. So seed when you are adding a domain to a model that is already good, and retrain over the whole corpus when you can — Punkt is unsupervised and cheap, so retraining is usually the honest option.

Adding non-breaking words

An abbreviation is a word that does not end a sentence. Add one by putting it in abbrev_types, lower-case and without its final period:

params.abbrev_types.add("ing")     # "Ing. Rossi arrived." stays one sentence
params.abbrev_types.update({"e.g", "i.e", "z.b"})   # inner dots stay, last one goes

For a whole curated list at once:

params = punkt.with_nonbreaking_prefixes(params, "english")      # 156 -> 243
params = punkt.with_nonbreaking_prefixes(params, "english", include_always=False)

The second form adds only the numeric-only entries, and is the conservative choice: those cannot suppress a break the model would otherwise have found.

You can add your own numeric-only entries too. They abbreviate before a number and do not otherwise, which a flat list cannot express:

params.numeric_abbrev_types.add("no")
# "See No. 5 on the list."      -> one sentence
# "The answer was no. Everyone" -> two

Remember the cost: every abbreviation is a boundary that can no longer be found. The opposite lever pushes the other way — sent_starters are words that frequently begin a sentence, and they let the second pass break after a known abbreviation:

params.sent_starters.add("nobody")
# "Everyone left the dept. Nobody stayed behind." -> two sentences

Learning the conditional ones

There is a class of abbreviation the published algorithm cannot represent, and training makes it worse rather than better.

No. is the case. "See No. 5 on the list" is not a sentence break and "The answer was no." is, and one flat entry cannot be right about both. Now train on ordinary text: every No. in a corpus is period-final, so the likelihood test dutifully promotes no to a plain abbreviation, and the trained model stops breaking after the ordinary word. Training on text that uses No. correctly leaves you worse off than not training at all.

This is fixed, and on by default since 2.1.0:

params = punkt.train(corpus)          # learn_numeric_abbrevs=True

The trainer looks at what actually followed each period-final type and files the ones that are almost always followed by a number into numeric_abbrev_types, where they abbreviate before a number and break otherwise.

The candidates are every period-final type, not just the ones that passed the abbreviation test — which is the point. That test penalises a type for also occurring without a period, so no, vol, ch and ref are all rejected as abbreviations while being followed by a number 61%, 91%, 94% and 88% of the time in real text. The penalty is right for a flat entry, which would swallow every genuine break after the ordinary word, and wrong for a conditional one, which cannot: it only ever fires before a number.

On 60 MB of English web text it learns 40 entries, including things no curated list had — the months, Hwy., Rs., US bill references like H.R. and S.Res., and legal citation forms like R.S.C.:

punkt.PunktSentenceTokenizer(params).tokenize("It happened on Jan. 5 last year.")
# ['It happened on Jan. 5 last year.']
punkt.PunktSentenceTokenizer(params).tokenize("We met in Jan. The weather was cold.")
# ['We met in Jan.', 'The weather was cold.']

Both directions, learned rather than curated, at no measured cost: join, break, sanity and UD English F1 are all unchanged by adding the learned set.

It departs from the published algorithm and from NLTK, and is on anyway: the published behaviour is not merely incomplete here but actively harmful, and a rule that only fires before a number, only for types with real evidence, is the smaller cost. Pass learn_numeric_abbrevs=False to reproduce the published algorithm exactly.

All of this is ordinary mutation of a PunktParameters, so it survives render_model and is part of the module you import. Use debug_decisions to see which rule actually fired:

for decision in tokenizer.debug_decisions("Ing. Rossi arrived."):
    print(punkt.format_decision(decision))

Your corpus has to be real

Punkt decides an abbreviation is an abbreviation because it keeps appearing before a period. On a small or repetitive corpus that is trivially satisfied by ordinary words. Training on "Dr. Smith went home. He was tired." repeated 200 times learns:

['dr', 'home', 'late', 'left', 'mr', 'p.m', 'prof', 'tired']

home, late, left and tired are not abbreviations; they are just words that ended every sentence. The algorithm is working — the corpus is not. Use tens of megabytes of varied prose, and check what came out:

print(sorted(params.abbrev_types))
print(sorted(params.sent_starters))

Two settings help on real-world text:

# Scraped text: refuse "abbreviations" like $40000, !m and 3c% .
punkt.TrainerConfig(require_alphabetic_abbrevs=True)

# Thresholds, if you have a reason to move them.
punkt.TrainerConfig(abbrev_threshold=0.5, include_all_collocations=True)

And the curated abbreviation list applies to your model just as it does to a bundled one:

params = punkt.with_nonbreaking_prefixes(params, "english")

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 is the one NLTK redistributes, 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. Some prefixes are 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-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

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:

  1. The name must be a Python identifier — the right constraint, since it becomes a module name, and a complete one.
  2. Every value must be a str or an int, checked before rendering, and each is emitted through repr.
  3. 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 a model file

render_model writes a model as Python source, and the obvious way to read one back is to import it. That is fine for a file you generated and unsafe for one you did not, in the ordinary way that running someone's Python is unsafe.

The part that is not obvious is that verifying the source and then importing it does not fix this. import prefers __pycache__/<name>.cpython-XX.pyc whenever that file's header records the same mtime and size as the source, and it does not recompile to check. An attacker ships an innocent .py beside a .pyc compiled from different code, with the header forged to match: you verify the text you can read, and the interpreter runs the bytecode you cannot. A hash-based .pyc marked unchecked is worse — the source is never consulted at all.

So punkt.read_model_file does not import. It reads the bytes, parses them, whitelists every AST node, and rebuilds the values with ast.literal_eval. No code object is produced and no bytecode is consulted, so the __pycache__ trap has nothing to attach to.

What survives ruling out execution is resource exhaustion, and the expensive part happens inside ast.parse before anything could inspect the tree — converting a decimal literal to int is quadratic, so a million-digit number costs over twenty seconds. Python 3.11 added sys.set_int_max_str_digits for exactly this (CVE-2020-10735); this package supports 3.9, which has no such ceiling. The bounds are therefore applied to the raw text first:

punkt.read_model_file(path, limits=punkt.ReadLimits(max_bytes=8 << 20))
Bound Default Why
max_bytes 32 MB ~4.5x the largest bundled model
max_int_digits 4300 CPython's own 3.11 ceiling; real models use 3 digits
max_string_length 1 MB no word type is remotely this long
max_elements 4,000,000 headroom over english_web
max_depth 12 real models nest three deep

A BOM, an encoding declaration, a null byte, invalid UTF-8 or a directory are all refused, so the bytes that are checked are the bytes that are used.

tests/test_reader_exploits.py is the harness: 48 tests covering every injection shape, each resource bound, the encoding tricks, and the forged __pycache__ entry — which is asserted to still defeat verify-then-import, so that the reason this design exists cannot quietly disappear.

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 NLTK punkt 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

nltk_punkt_tokenize-2.2.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-2.2.1-py3-none-any.whl (5.4 MB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: nltk_punkt_tokenize-2.2.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-2.2.1.tar.gz
Algorithm Hash digest
SHA256 71c7c3494a16359b29c009eb0e00996f5dce01021269bd36754831905b4e8d1a
MD5 dcf8d0463b9f9b4224c05f1dc373145d
BLAKE2b-256 730a3d5a9ee36528ed8e6c23f8338c179c90d19ea38688c73e70db9da6bfe285

See more details on using hashes here.

File details

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

File metadata

File hashes

Hashes for nltk_punkt_tokenize-2.2.1-py3-none-any.whl
Algorithm Hash digest
SHA256 658381fc09b88169549720ad20f7af2f2bb987f6eb148b0681c6b30dc8646d1b
MD5 d07a4cea4cafcef1b901da5f3bf5eece
BLAKE2b-256 a6bf9c90ffafe4705d81ff2ecf47af45bc47728725451c0d129922d03c9a88ca

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