pyroid.nltk: drop-in NLTK tokenizers¶
pyroid.nltk is a Rust port of the sentence and word tokenizers of NLTK
3.10.4 (Apache-2.0): Punkt (sent_tokenize, PunktSentenceTokenizer, PunktTokenizer),
word_tokenize, the Treebank tokenizers and detokenizer, and the common
regular-expression tokenizers. It returns the same sentences,
tokens and offsets as NLTK 3.10.4 for every input (parity), several
times faster on one thread, and batches run in parallel.
Change one import¶
-import nltk
+import pyroid.nltk as nltk
nltk.sent_tokenize("Mr. Smith went to Washington. He liked it.")
# ['Mr. Smith went to Washington.', 'He liked it.']
nltk.word_tokenize("They'll pay $3.88 (roughly).")
# ['They', "'ll", 'pay', '$', '3.88', '(', 'roughly', ')', '.']
Or keep import nltk and call pyroid.patch("nltk") once: NLTK's own
nltk.sent_tokenize, nltk.word_tokenize and the tokenizer classes are then pyroid's, and
pyroid.unpatch("nltk") puts them back.
The Punkt models¶
Punkt needs NLTK's pretrained punkt_tab models. pyroid does not bundle them, because
nltk_data publishes them without a licence. Install them as for NLTK:
python -m nltk.downloader punkt_tab # or, in Python: nltk.download("punkt_tab")
With NLTK installed, pyroid finds and reads the model with NLTK's own nltk.data.find and
load_punkt_params, so nltk.data.path, NLTK_DATA and NLTK's checks apply as usual. NLTK is
not needed otherwise. Without it, pyroid looks where NLTK looks by default (NLTK_DATA,
~/nltk_data, and the nltk_data directories under sys.prefix, /usr/share,
/usr/local/share, /usr/lib and /usr/local/lib) for an unpacked
tokenizers/punkt_tab/<language>/ or for tokenizers/punkt_tab.zip. A missing model raises
NLTK's LookupError.
All 19 models of punkt_tab work: czech, danish, dutch, english, estonian, finnish, french,
german, greek, italian, malayalam, norwegian, polish, portuguese, russian, slovene, spanish,
swedish and turkish.
Sentences¶
import pyroid.nltk as nltk
nltk.sent_tokenize("Dr. Müller kam um 9 Uhr. Er war müde.", language="german")
# ['Dr. Müller kam um 9 Uhr.', 'Er war müde.']
tok = nltk.PunktTokenizer("english")
list(tok.span_tokenize("One. Two.")) # [(0, 4), (5, 9)]
tok.tokenize('"Go." He went.') # ['"Go."', 'He went.']: the quote stays with its sentence
tok.tokenize('"Go." He went.', realign_boundaries=False) # ['"Go.', '" He went.']
PunktSentenceTokenizer takes a PunktParameters, as in NLTK. Its sets can be changed in place,
and the tokenizer sees the change at its next call:
tok = nltk.PunktTokenizer()
tok.tokenize("Sales rose approx. 4%. The dept. agreed.")
# ['Sales rose approx.', '4%.', 'The dept.', 'agreed.']
tok._params.abbrev_types.update({"approx", "dept"})
tok.tokenize("Sales rose approx. 4%. The dept. agreed.")
# ['Sales rose approx. 4%.', 'The dept. agreed.']
span_tokenize, sentences_from_text, text_contains_sentbreak, sentences_from_tokens,
sentences_from_text_legacy and debug_decisions are there too. A PunktLanguageVars subclass
that changes sent_end_chars (for example to add "…") still runs in Rust.
ROUGE in pyroid.metrics uses this port for split_summaries=True, so rougeLsum's
sentence split runs in Rust too.
Words¶
word_tokenize splits the Punkt sentences with NLTKWordTokenizer, NLTK's improved Treebank
rules. With preserve_line=True it skips the sentence split, and needs no model.
import pyroid.nltk as nltk
nltk.word_tokenize("I can't go to «Paris» -- it's 3:30.")
# ['I', 'ca', "n't", 'go', 'to', '«', 'Paris', '»', '--', 'it', "'s", '3:30', '.']
nltk.TreebankWordTokenizer().tokenize("Good muffins cost $3.88\nin New York.")
# ['Good', 'muffins', 'cost', '$', '3.88', 'in', 'New', 'York', '.']
list(nltk.NLTKWordTokenizer().span_tokenize("Hello, world.")) # [(0, 5), (5, 6), (7, 12), (12, 13)]
nltk.TreebankWordDetokenizer().detokenize(["I", "ca", "n't", "go", "."]) # "I can't go."
convert_parentheses=True gives the Penn Treebank symbols (-LRB-, -RRB-, ...), as in NLTK.
align_tokens(tokens, sentence) gives the offsets of tokens found one after another.
Regular-expression tokenizers¶
RegexpTokenizer, WhitespaceTokenizer, BlanklineTokenizer, WordPunctTokenizer,
regexp_tokenize, wordpunct_tokenize and blankline_tokenize are NLTK's, with gaps,
discard_empty and span_tokenize:
import pyroid.nltk as nltk
nltk.wordpunct_tokenize("Good muffins cost $3.88\nin New York.")
# ['Good', 'muffins', 'cost', '$', '3', '.', '88', 'in', 'New', 'York', '.']
nltk.RegexpTokenizer(r"\w+|\$[\d\.]+|\S+").tokenize("Good muffins cost $3.88\nin New York.")
# ['Good', 'muffins', 'cost', '$3.88', 'in', 'New', 'York', '.']
These patterns run in Rust when spelled exactly so, with no flags beyond NLTK's default
re.UNICODE | re.MULTILINE | re.DOTALL: \w+, \w+|[^\w\s]+, \w+|\$[\d\.]+|\S+, [\w']+,
\S+, \s+, [a-zA-Z]+ and BlanklineTokenizer's. Any other pattern runs in NLTK.
Many documents¶
A list or tuple of str, or an Arrow string column, gives one list per document, computed in
parallel on pyroid's shared pool. Arrow nulls give None.
import pyroid.nltk as nltk
docs = ["One. Two.", "Three."]
nltk.sent_tokenize(docs) # [['One.', 'Two.'], ['Three.']]
nltk.word_tokenize(docs, threads=8) # at most 8 tasks; threads=1 runs on the calling thread
nltk.PunktTokenizer("french").span_tokenize(docs) # [[(0, 4), (5, 9)], [(0, 6)]]
threads= is keyword-only and follows the threading conventions. On
free-threaded CPython the GIL stays off.
What NLTK still does¶
These are handed to NLTK 3.10.4, which must then be installed. The first call emits a
pyroid.FallbackWarning; without NLTK it raises
pyroid.PyroidError.
- Training:
PunktTrainer, andPunktSentenceTokenizer(train_text)with a str. The trained parameters then tokenize in Rust. - A
PunktSentenceTokenizerwith a customtoken_cls, or with aPunktLanguageVarsthat overrides more thansent_end_chars; parameters that are not sets of str. dump,PunktTokenizer.save_paramsandsave_dir.- A Treebank tokenizer or detokenizer whose pattern lists were edited or overridden.
- A
RegexpTokenizerpattern that is not one of the native ones.
The other NLTK tokenizers (TweetTokenizer, ToktokTokenizer, ...) are not in pyroid.nltk;
keep using NLTK's.
Differences from NLTK¶
- A
strwith a lone surrogate ("\ud800") raisesUnicodeEncodeError, as everywhere in pyroid. - NLTK runs every pattern under a 5-second timeout (
nltk.redos) and raisesTimeoutErrorwhen a match takes longer; pyroid has no such limit. - NLTK's patterns run on the
regexmodule. pyroid reproducesregex2026.9.29's character classes. With anotherregexrelease, NLTK can differ on characters whose Unicode properties changed between the two releases. - Tokenizing does not add a 0 entry to
PunktParameters.ortho_contextfor each unseen word, as NLTK'sdefaultdictdoes. - An NLTK
PunktParametersgiven to a tokenizer is copied, so edit it throughtok._params; later edits of NLTK's object are not seen. - NLTK compiles a
PunktLanguageVarsobject's patterns at first use and keeps the oldsent_end_charsin part of the algorithm after they change; pyroid uses the new ones throughout.
Parity¶
Every check found 0 differences from NLTK 3.10.4:
| Check | Comparisons |
|---|---|
Parity suite against live NLTK, every document: Punkt in all 19 models on FineWeb, FineWeb-2 and the adversarial set, word_tokenize, both Treebank tokenizers and their spans, detokenize round trips, the regexp tokenizers, the batch paths |
14,174,241 |
All 19 punkt_tab models on 39,618 FineWeb and FineWeb-2 documents (848,037 sentences) |
178,282 |
| The Treebank tokenizers on 600,372 sentences and documents of FineWeb and 27 FineWeb-2 languages | 5,713,962 |
| Every code point, in templates around the Treebank, detokenizer and regexp rules | 149,016,576 |
| Recorded NLTK outputs replayed in the Rust tests: NLTK's own examples, adversarial text, fuzz, and the case rules of CPython 3.12 to 3.15 | 77,535 |
Hypothesis properties also compare every function against live NLTK, on CPython 3.13 and 3.15t, as do NLTK's doctest examples and every code point alone. See Compatibility.
Speed¶
Docker/OrbStack VM on Apple Silicon (12 vCPU, load 3 to 5), indicative. These are the first 1,000 documents of the FineWeb dev slice (3,018 characters each on average), with release wheels and the English model. Medians of 5 runs (NLTK: 2), with every document's output checked against NLTK's first. µs per document:
| Run | sent_tokenize, 3.13 |
word_tokenize, 3.13 |
sent_tokenize, 3.15t |
word_tokenize, 3.15t |
|---|---|---|---|---|
| NLTK, one thread | 651 | 3,576 | 592 | 3,513 |
| NLTK, 8 threads | 4,102 | 25,633 | 259 | 1,244 |
| NLTK, 8 processes | 197 | 593 | 117 | 724 |
| pyroid, one call per document | 10.9 (60×) | 117 (31×) | 11.2 (53×) | 105 (33×) |
pyroid, batch, threads=1 |
10.4 (63×) | 92 (39×) | 10.5 (56×) | 96 (37×) |
pyroid, batch, threads=8 |
5.6 (116×) | 44 (82×) | 5.0 (118×) | 43 (81×) |
On 8 threads the batch gains about 2× over one thread: building the Python lists of sentences
and tokens runs on the calling thread. Against NLTK on 8 processes, pyroid on 8 threads is 35×
faster for sent_tokenize and 14× for word_tokenize on CPython 3.13. The first sentence in a
fresh interpreter takes about as long as NLTK's (177 ms against 203 ms on 3.13), because pyroid
imports NLTK to find the model when NLTK is installed. Source:
bench/results/orbstack-aarch64-12cpu/2026-11-09-nltk/.
API: pyroid.nltk reference.