Skip to content

Strip Japanese voiced-kana marks in BasicTokenizer (#352, Bug 2)#354

Merged
pcuenca merged 4 commits into
huggingface:mainfrom
apocryphx:fix/strip-japanese-voiced-kana
May 16, 2026
Merged

Strip Japanese voiced-kana marks in BasicTokenizer (#352, Bug 2)#354
pcuenca merged 4 commits into
huggingface:mainfrom
apocryphx:fix/strip-japanese-voiced-kana

Conversation

@apocryphx

Copy link
Copy Markdown
Contributor

Addresses Bug 2 of #352.

BasicTokenizer.maybeStripAccents used .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).
  • New test bertJapaneseDakuten() uses BAAI/bge-small-en-v1.5 and a multi-script Japanese input that exercises hiragana, ideographs, and katakana including the voiced kana and . The expected IDs match HF Python transformers==4.57.1 byte-for-byte.

🤖 Generated with Claude Code

…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>
@apocryphx

Copy link
Copy Markdown
Contributor Author

One observation for your review: there's an alternative location for this fix that may be architecturally cleaner — widening BertNormalizer.stripAccents in Normalizer.swift:238-244 from "filter U+0300..U+036F" to "filter all Mn." That single change handles both the Japanese voiced-kana case in this PR and the Hangul case that #353 addresses from a different angle (the Hangul jamo are Lo, not Mn, so they survive the widened filter and reach WordPiece exactly as needed).

Concretely, the alternative would be:

private func stripAccents(text: String) -> String {
    String(
        String.UnicodeScalarView(
            text.decomposedStringWithCanonicalMapping.unicodeScalars.filter {
                $0.properties.generalCategory != .nonspacingMark
            }
        )
    )
}

…and BasicTokenizer.maybeStripAccents could keep using .folding(options: .diacriticInsensitive) (or be removed entirely as redundant — the BertNormalizer pass runs first when tokenizer.json declares a BertNormalizer, which it does for BAAI/bge-small-en-v1.5 and most BERT-family models).

The current PR fixes the bug in BasicTokenizer.maybeStripAccents because that mirrors HF Python's BertTokenizer.basic_tokenizer._run_strip_accents — which is the right reference for the slow Python tokenizer. The fast (Rust) tokenizer that transformers==4.57.1 actually loads under use_fast=True uses the normalizer pipeline only, no separate BasicTokenizer step, so for the byte-identity contract against AutoTokenizer.from_pretrained(...), fixing BertNormalizer.stripAccents is arguably the more on-target location.

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 pcuenca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Sources/Tokenizers/BertTokenizer.swift
Comment thread Tests/TokenizersTests/TokenizerTests.swift
…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>
apocryphx added a commit to apocryphx/swift-transformers that referenced this pull request May 16, 2026
…on to huggingface#356

Verified locally on a combined branch (huggingface#354huggingface#355huggingface#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>
Comment thread Sources/Tokenizers/BertTokenizer.swift Outdated
Comment thread Sources/Tokenizers/Normalizer.swift Outdated
Comment thread Tests/TokenizersTests/TokenizerTests.swift Outdated
pcuenca added 2 commits May 16, 2026 11:55
Co-authored-by: Pedro Cuenca <pedro@huggingface.co>
@pcuenca

pcuenca commented May 16, 2026

Copy link
Copy Markdown
Member

Fixed the linter whinings, merging now. Thanks a lot @apocryphx!

@pcuenca pcuenca merged commit a86d128 into huggingface:main May 16, 2026
4 checks passed
apocryphx added a commit to apocryphx/swift-transformers that referenced this pull request May 16, 2026
…on to huggingface#356

Verified locally on a combined branch (huggingface#354huggingface#355huggingface#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>
apocryphx added a commit to apocryphx/swift-transformers that referenced this pull request May 16, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants