Repository navigation
Description
Describe the bug
When a BpeTokenizer is created with BpeOptions.ByteLevel = true but without setting BpeOptions.PreTokenizer, the tokenizer silently drops spaces and newlines.
No exception is thrown and no warning is logged. Decode cannot recover the original text, and the resulting token IDs are structurally valid but semantically wrong — which makes the problem very hard to notice in a downstream LLM pipeline.
To Reproduce
// vocab : Dictionary<string,int> parsed from tokenizer.json -> model.vocab
// merges : IEnumerable<string> parsed from tokenizer.json -> model.merges
var options = new BpeOptions(vocab)
{
Merges = merges,
ByteLevel = true, // enables the byte <-> unicode mapping
UnknownToken = "<unk>",
FuseUnknownTokens = true,
// NOTE: PreTokenizer is intentionally NOT set here
};
var tokenizer = BpeTokenizer.Create(options);
string[] samples =
{
"Hello, world!",
"Tokenizer 是一个开源的中文分词器。",
"<|im_start|>system\nYou are a helpful assistant.<|im_end|>",
};
foreach (string s in samples)
{
IReadOnlyList<int> ids = tokenizer.EncodeToIds(s);
string? back = tokenizer.Decode(ids);
Console.WriteLine($"in : {s}");
Console.WriteLine($"out: {back}");
Console.WriteLine(s == back ? "OK" : "LOST DATA");
}Actual result — spaces and newlines are silently gone:
in : Hello, world!
out: Hello,world! <- LOST DATA (space)
in : Tokenizer 是一个开源的中文分词器。
out: Tokenizer是一个开源的中文分词器。 <- LOST DATA (space)
in : <|im_start|>system\nYou are a helpful assistant.<|im_end|>
out: <|im_start|>systemYou are a helpful assistant.<|im_end|> <- LOST DATA (newline)
Lossless round-trip rate with this configuration: 1 / 5.
Expected behavior
Enabling ByteLevel alone should not produce a tokenizer that corrupts its input. One of the following would be enough:
BpeTokenizer.Createthrows (or logs a warning) whenByteLevel == trueandPreTokenizer == null, or- the documentation for
BpeOptions.ByteLevelstates explicitly that aPreTokenizermust also be configured.
Setting a PreTokenizer fixes it completely
With RegexPreTokenizer built from the GPT-2 pattern, every sample above round-trips losslessly:
options.ByteLevel = true;
options.PreTokenizer = new RegexPreTokenizer(
new Regex(@"'s|'t|'re|'ve|'m|'ll|'d| ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+"),
specialTokens); // second argument: the added_tokens mapResult with PreTokenizer set: 6 / 6 samples match the HuggingFace reference token-for-token, 5 / 5 lossless round-trip.
Measured configuration matrix
Reference implementation: HuggingFace tokenizers (Rust), loading the same tokenizer.json.
ByteLevel |
PreTokenizer |
Match HF (6 samples) | Lossless round-trip |
|---|---|---|---|
true |
(not set) | 3 / 6 | 1 / 5 |
true |
RobertaPreTokenizer |
5 / 6 | — |
true |
RegexPreTokenizer (GPT-2 pattern) |
6 / 6 | 5 / 5 |
false |
RobertaPreTokenizer |
0 / 6 | — |
false |
RegexPreTokenizer (GPT-2 pattern) |
0 / 6 | — |
Additional context — two documentation points
-
The current docs for
BpeOptions.ByteLevelonly describe the byte <-> unicode mapping (e.g.Space -> 'Ġ'). They do not mention that aPreTokenizeris required for correct whitespace handling. -
The BPE sample in https://learn.microsoft.com/dotnet/ai/how-to/use-tokenizers contains this note:
"BpeTokenizer might not always decode IDs to the exact original text as it can remove spaces during tokenization depending on the model configuration."
Based on the measurements above, the behaviour is not dependent on the model configuration. It depends solely on whether a
PreTokenizeris configured. Once a proper pre-tokenizer is set, decoding is exact for every input tested.
Why this matters for HuggingFace models
tokenizer.json carries the full pipeline configuration — pre_tokenizer, added_tokens, normalizer, post_processor, decoder. The BpeTokenizer.Create(vocab, merges) entry point consumes only the vocabulary data and cannot see any of it. Users following the current guidance (download vocab.json + merges.txt from HuggingFace) therefore end up with a tokenizer that is missing its pipeline configuration, and the failure mode is silent.
Environment
Microsoft.ML.Tokenizers2.0.0 (stable)- .NET 10
- OS: Windows
Activity
github-actions commentedon Sep 14, 2026
🔍 No similar issues found with high confidence.
AI-powered similar issue detection
Document that BpeOptions.ByteLevel requires a PreTokenizer
matouskozak commentedon Sep 23, 2026
Triage: We will take a look
Document that BpeOptions.ByteLevel requires a PreTokenizer (#7716)
svick commentedon Oct 13, 2026
This behavior is not specific to
ByteLevel=trueand I think it's not actually a bug. Documenting the behavior should be enough, so I have opened #7789 which builds on #7716 and should address this issue.Regarding easily importing existing models, I agree that it would be nice if ML.NET could read
tokenizer.json. That request is tracked by #7735.Correct BPE pre-tokenization whitespace documentation (dotnet#7789)