Strip Japanese voiced-kana marks in BasicTokenizer (#352, Bug 2)#354
Conversation
…Bug 2) `BasicTokenizer.maybeStripAccents` used `.folding(options: .diacriticInsensitive, locale: nil)`, which strips Latin diacritics but not the U+3099/U+309A combining sound marks that Japanese voiced kana carry. Precomposed `ザ` (U+30B6) and `で` (U+3067) therefore reached WordPiece intact, but BERT-family vocabularies only contain the dakuten-stripped forms (`##サ`, `##て`) — `##ザ`/`##で` are missing. A single missing continuation forced WordPiece's greedy match to fall back to `[UNK]` for the entire word, so long Japanese inputs containing any voiced kana returned essentially nothing useful. Switch to NFD-decompose-then-Mn-filter, matching HF Python's `_run_strip_accents`. This strips every nonspacing-mark scalar regardless of block, so Latin diacritics and Japanese dakuten/handakuten are handled identically. Adds a regression test using BAAI/bge-small-en-v1.5 over a multi-script Japanese input that exercises both hiragana and katakana including voiced kana. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
One observation for your review: there's an alternative location for this fix that may be architecturally cleaner — widening Concretely, the alternative would be: private func stripAccents(text: String) -> String {
String(
String.UnicodeScalarView(
text.decomposedStringWithCanonicalMapping.unicodeScalars.filter {
$0.properties.generalCategory != .nonspacingMark
}
)
)
}…and The current PR fixes the bug in Either fix passes the regression test. Let me know which location you'd prefer and I'll repush. For what it's worth, the pure-Foundation Obj-C port of this same tokenizer pipeline (https://github.com/apocryphx/ObjCTokenizer) consolidates this into a single Mn-filter pass at the BertNormalizer layer (OCTNormalizer.m:234-282) and produces byte-identical output to HF Python on the same multilingual corpus shipped at https://github.com/apocryphx/ObjCTokenizer/tree/main/Conformance — happy data point if you want to compare architecture choices. |
pcuenca
left a comment
There was a problem hiding this comment.
Very nice, thanks for the in-depth explanations and breadcrumbs.
I verified the Rust implementation and it indeed relies on the normalizer fix alone.
I would suggest we apply the fix to the Normalizer, as you pointed out, but also keep maybeStripAccents as you already pushed here. This would be a defensive case against someone using BertTokenizer directly – I think it's rare, but possible. For example, a test like the following added to BertTokenizerTests would expose the problem:
@Test("Direct BertTokenizer strips Japanese voiced-kana dakuten")
func bertTokenizerStripsDakuten() {
let vocab: [String: Int] = [
"[UNK]": 0,
"[CLS]": 1,
"[SEP]": 2,
"サ": 3,
"##サ": 4,
]
let tokenizer = BertTokenizer(vocab: vocab, merges: nil)
#expect(tokenizer.tokenize(text: "ザ") == ["サ"])
#expect(tokenizer.tokenize(text: "ザザ") == ["サ", "##サ"])
}Going forward, I think we should probably deprecate public use of BertTokenizer like above, and just rely on the from(pretrained:) path as a parallel to the "fast" tokenizers path, which is the maintained approach from transformers v5.
…ct-tokenizer test Per @pcuenca's review on huggingface#354: - Widen `BertNormalizer.stripAccents` in Normalizer.swift from filtering only U+0300..U+036F to filtering all nonspacing marks (Unicode general category Mn). This is the architecturally correct location, matching the Rust HF tokenizers `normalizers/bert.rs#strip_accents` path used by the fast Python tokenizer. Keeps `BasicTokenizer.maybeStripAccents` as defensive coverage for callers using `BertTokenizer` directly without `AutoTokenizer.from(pretrained:)`. - Add `bertTokenizerStripsDakuten` to BertTokenizerTests exercising the direct path (no `from(pretrained:)`), per @pcuenca's suggested test. - Add a single-character `#expect(tokenizer.encode(text: "ザ") == [101, 1705, 102])` to the existing `bertJapaneseDakuten` test for clarity, per @pcuenca's request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on to huggingface#356 Verified locally on a combined branch (huggingface#354 ⊕ huggingface#355 ⊕ huggingface#356 ⊕ this PR): all three T5 divergences are fixed by huggingface#356's scalar-iteration switch. Two were originally listed as `fixedBy: 0` ("Unigram TM trademark / VS-16 segmentation" and "Unigram ZWJ-after-text edge"); the combined-branch run fired cleanup hints for both, alongside the emoji-keycap-and-flags hint. Root cause is the same as the keycap case: a vocab-relevant scalar (TM glyph U+2122, ZWJ U+200D) is hidden inside a grapheme cluster that the old `Character`-based Unigram lattice never decomposed. Moving the iteration unit to `Unicode.Scalar` exposes it. `expectedDivergences` count drops from 10 pending-investigation entries to 8 (5 TinyLlama Metaspace whitespace + 3 Qwen2.5 BPE merge-ordering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Pedro Cuenca <pedro@huggingface.co>
|
Fixed the linter whinings, merging now. Thanks a lot @apocryphx! |
…on to huggingface#356 Verified locally on a combined branch (huggingface#354 ⊕ huggingface#355 ⊕ huggingface#356 ⊕ this PR): all three T5 divergences are fixed by huggingface#356's scalar-iteration switch. Two were originally listed as `fixedBy: 0` ("Unigram TM trademark / VS-16 segmentation" and "Unigram ZWJ-after-text edge"); the combined-branch run fired cleanup hints for both, alongside the emoji-keycap-and-flags hint. Root cause is the same as the keycap case: a vocab-relevant scalar (TM glyph U+2122, ZWJ U+200D) is hidden inside a grapheme cluster that the old `Character`-based Unigram lattice never decomposed. Moving the iteration unit to `Unicode.Scalar` exposes it. `expectedDivergences` count drops from 10 pending-investigation entries to 8 (5 TinyLlama Metaspace whitespace + 3 Qwen2.5 BPE merge-ordering). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e#355 / huggingface#356 merged) The three bug-fix PRs landed in main, simplifying the table from 35 entries down to 8. The struct loses its `fixedBy` field — all surviving entries are the two new bug clusters this corpus surfaces and that have no PR yet, so the table is now just `{modelId, inputId, note}`. The cleanup-hint message prints `note` instead of `fixedBy`. What remains is two clusters under huggingface#352 worth filing as follow-up issues: - 5 entries on TinyLlama: SentencePiece-BPE leading-whitespace runs collapsing to single `▁` tokens instead of producing a multi-space vocab entry (e.g. `▁▁▁▁` id 268). - 3 entries on Qwen2.5-0.5B: byte-level BPE picks a different merge ordering than HF Python on Thai byte sequences. The same corpus runs in the companion Obj-C port (https://github.com/apocryphx/ObjCTokenizer/tree/main/Conformance) and hits 7/7 byte-identity on these inputs — so both clusters look like upstream-only bugs and the Obj-C source is a usable reference for the follow-up fixes. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Daisuke Majima <rockyshikoku@gmail.com>
Addresses Bug 2 of #352.
BasicTokenizer.maybeStripAccentsused.folding(options: .diacriticInsensitive, locale: nil), which strips Latin diacritics but does not strip the U+3099 / U+309A combining sound marks that Japanese voiced kana carry. Precomposedザ(U+30B6) andで(U+3067) therefore reached WordPiece intact, while BERT-family vocabularies only contain the dakuten-stripped forms (##サ,##て) —##ザand##でare missing entries. A single missing continuation forced the WordPiece greedy match to fall back to[UNK]for the entire word, so long Japanese inputs containing any voiced kana returned essentially nothing useful (the issue document's "long katakana → single[UNK]" symptom is this root cause).Switch to NFD-decompose-then-Mn-filter, matching HF Python's
_run_strip_accents. This strips every nonspacing-mark scalar regardless of Unicode block, so Latin diacritics and Japanese dakuten/handakuten are handled identically.Test plan
swift test --filter TokenizerTests— all 46 tests pass (no regressions).bertJapaneseDakuten()usesBAAI/bge-small-en-v1.5and a multi-script Japanese input that exercises hiragana, ideographs, and katakana including the voiced kanaザandで. The expected IDs match HF Pythontransformers==4.57.1byte-for-byte.🤖 Generated with Claude Code