Skip to content

Commit a86d128

Browse files
apocryphxclaudepcuenca
authored
Strip Japanese voiced-kana marks in BasicTokenizer (#352, Bug 2) (#354)
* Strip Japanese voiced-kana marks in BasicTokenizer (#352, 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> * Address review: widen BertNormalizer.stripAccents to all Mn; add direct-tokenizer test Per @pcuenca's review on #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> * Apply suggestions from code review Co-authored-by: Pedro Cuenca <pedro@huggingface.co> * lint --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Pedro Cuenca <pedro@huggingface.co>
1 parent 03e7404 commit a86d128

4 files changed

Lines changed: 64 additions & 5 deletions

File tree

Sources/Tokenizers/BertTokenizer.swift

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,19 @@ final class BasicTokenizer: Sendable {
240240

241241
func maybeStripAccents(_ text: String) -> String {
242242
guard doLowerCase else { return text }
243-
return text.folding(options: .diacriticInsensitive, locale: nil)
243+
// Equivalent of HF Python's `_run_strip_accents`: NFD-decompose then drop every
244+
// nonspacing-mark scalar (Unicode general category Mn). The standard Foundation
245+
// API `.folding(options: .diacriticInsensitive, locale: nil)` strips Latin
246+
// diacritics but does NOT strip Japanese voiced-kana combining marks.
247+
// Conceptual reference:
248+
// https://github.com/huggingface/transformers/blob/542e65fae2fe9cc7ddeb816e540162bc5a8bff77/src/transformers/models/bert/tokenization_bert.py#L382
249+
return String(
250+
String.UnicodeScalarView(
251+
text.decomposedStringWithCanonicalMapping.unicodeScalars.filter {
252+
$0.properties.generalCategory != .nonspacingMark
253+
}
254+
)
255+
)
244256
}
245257

246258
func maybeLowercase(_ text: String) -> String {

Sources/Tokenizers/Normalizer.swift

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,18 @@ class BertNormalizer: Normalizer {
236236
}
237237

238238
private func stripAccents(text: String) -> String {
239-
// This might be the same as `text.folding(options: .diacriticInsensitive, locale: nil)`
239+
// Equivalent of HF Python's `_run_strip_accents`: NFD-decompose then drop every
240+
// nonspacing-mark scalar (Unicode general category Mn). Filtering only
241+
// U+0300..U+036F handled Latin diacritics but missed marks in other blocks —
242+
// notably the Japanese voiced-kana combining marks U+3099/U+309A.
243+
// This matches Rust `tokenizers` and HF Python.
240244
String(
241-
text.decomposedStringWithCanonicalMapping.unicodeScalars.filter { scalar in
242-
!(scalar.value >= 0x0300 && scalar.value <= 0x036F)
243-
})
245+
String.UnicodeScalarView(
246+
text.decomposedStringWithCanonicalMapping.unicodeScalars.filter {
247+
$0.properties.generalCategory != .nonspacingMark
248+
}
249+
)
250+
)
244251
}
245252
}
246253

Tests/TokenizersTests/BertTokenizerTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,26 @@ struct BertTokenizerTests {
219219
}
220220
}
221221

222+
/// Regression for https://github.com/huggingface/swift-transformers/issues/352 (Bug 2).
223+
/// Exercises `BertTokenizer` directly (bypassing `AutoTokenizer.from(pretrained:)`)
224+
/// to verify the `BasicTokenizer.maybeStripAccents` defensive fallback strips Japanese
225+
/// voiced-kana dakuten even when a `BertNormalizer` pass hasn't already run. The
226+
/// upstream-fix is in `BertNormalizer.stripAccents`; this test guards the direct path.
227+
@Test("Direct BertTokenizer strips Japanese voiced-kana dakuten")
228+
func bertTokenizerStripsDakuten() {
229+
let vocab: [String: Int] = [
230+
"[UNK]": 0,
231+
"[CLS]": 1,
232+
"[SEP]": 2,
233+
"": 3,
234+
"##サ": 4,
235+
]
236+
let tokenizer = BertTokenizer(vocab: vocab, merges: nil)
237+
238+
#expect(tokenizer.tokenize(text: "") == [""])
239+
#expect(tokenizer.tokenize(text: "ザザ") == ["", "##サ"])
240+
}
241+
222242
@Test("BERT encoder/decoder round-trip")
223243
func encoderDecoder() {
224244
let text = """

Tests/TokenizersTests/TokenizerTests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,26 @@ struct TokenizerTests {
376376
#expect(tokenizer.encode(text: "") == [101, 1463, 30006, 30021, 102])
377377
}
378378

379+
@Test
380+
func bertJapaneseDakuten() async throws {
381+
// Regression for https://github.com/huggingface/swift-transformers/issues/352 (Bug 2).
382+
let tokenizerOpt = try await AutoTokenizer.from(pretrained: "BAAI/bge-small-en-v1.5") as? PreTrainedTokenizer
383+
#expect(tokenizerOpt != nil)
384+
let tokenizer = tokenizerOpt!
385+
386+
// Minimal repro: a single voiced-kana character.
387+
#expect(tokenizer.encode(text: "") == [101, 1705, 102])
388+
389+
// Long form covering hiragana, ideographs, punctuation, and katakana with
390+
// multiple voiced kana (`ザ` and `で`).
391+
#expect(
392+
tokenizer.encode(text: "こんにちは、世界。トークナイザーのテストです。") == [
393+
101, 1655, 30217, 30194, 30188, 30198, 1635, 1745, 100, 1636,
394+
1714, 30265, 30228, 30241, 30221, 30231, 30265, 30197, 30239,
395+
30233, 30240, 30191, 30184, 1636, 102,
396+
])
397+
}
398+
379399
@Test
380400
func robertaEncodeDecode() async throws {
381401
let tokenizerOpt = try await AutoTokenizer.from(pretrained: "FacebookAI/roberta-base") as? PreTrainedTokenizer

0 commit comments

Comments
 (0)