Repository navigation
stt_wer: per-language normalization fixes (de, nl, fr, fr-CA, es, it, hi, ko, ja) and Hindi↔English code-switching - #245
Draft
fanny-riols wants to merge 23 commits into
Draft
fanny-riols wants to merge 23 commits into
fanny-riols wants to merge 23 commits into
Conversation
Add three LanguageConfig fields that a language can enable in its JSON config: char_folds (treat two spellings of a character as equal), join_hyphenated_compounds (Anmelde-Konto == Anmeldekonto) and spoken_symbols / spoken_symbols_code_only (a spoken "trait d'union" in a spelled ID matches the written "-"). No language enables them in this commit, so normalization output is unchanged for every language. Following commits turn them on per language.
- ä/ö/ü/ß and ae/oe/ue/ss are the same spelling (Krüger == Krueger). - A hyphenated compound matches the closed form (Anmelde-Konto == Anmeldekonto). Acronym compounds (HR-Fragen) keep the boundary, since STT often writes them open. - Spoken "Bindestrich", "Unterstrich", "Schrägstrich", "Klammeraffe" in a spelled ID match the written symbol; "Strich" only next to a code.
- A hyphenated compound matches the closed form (zorg-verlof == zorgverlof); acronym compounds keep the boundary. - Spoken "koppelteken", "laag/liggend streepje", "schuine streep", "apenstaartje" in a spelled ID match the written symbol; "streepje" only next to a code.
- Spoken "trait d'union", "tiret", "tiret bas", "barre oblique",
"arobase" in a spelled ID match the written symbol; "barre" only next
to a code.
- The letter M in a spelled code was rewritten to "monsieur" on one side
only ("M 4 B" -> "monsieur 4 b"). Only "M." followed by a word now
expands. The pronoun "me" is no longer rewritten to "maitre".
Same changes as fr: spoken "trait d'union" / "tiret" in spelled IDs match the written symbol, and the letter M in a code and the pronoun "me" are no longer rewritten to "monsieur" / "maitre". Curly apostrophes in "trait d'union" are accepted.
- Spoken "guion", "guion bajo", "barra", "barra oblicua", "arroba" in a
spelled ID match the written symbol; "guion" and "barra" only next to a
code.
- The letter D in a code was rewritten to "don", and the verb "da" to
"dona" ("me da igual" -> "me dona igual"). Only "D." followed by a word
now expands to "don".
Spoken "trattino", "trattino basso", "chiocciola" match the written symbol
("1995 trattino 04 trattino 18" == "1995-04-18"); "barra" only next to a
code.
…anwords - Nukta and plain spellings are equal (ज़रूर == जरूर), and chandrabindu equals anusvara (हाँ == हां). - A hyphenated compound matches the closed form (ई-मेल == ईमेल); reduplications (बार-बार) keep the boundary. - English loanwords written in Latin script match their Devanagari form (march == मार्च, code == कोड), through a 157-entry lexicon.
The Sino-Korean number converter read any number syllable as a digit: - the day counter 일 became the digit 1 (삼월 이십오일 -> 삼월 21), - surnames became numbers (천민수 -> 1000민수, 백지수 -> 100지수), - ordinary words were corrupted (만나서 -> 10000나서, 인터넷 -> 인터4). It now converts a span only when it is a well-formed number in a numeric context (next to a digit or followed by a counter such as 월, 일, 원, 시, 명). Also: irregular months 유월/시월, 만-grouping (1만 2천 원 == 12,000원), spoken digit strings (공일공 일이삼사), native counters (열두 시 == 12시, 스무 살 == 20살), and fillers 음/어 removed only as whole words (음식, 다음 and 영어 are kept).
…ames - Katakana letter names and kana digit readings inside a spelled code match Latin letters and digits (イー・エム・ピー、さん == EMP3). Only runs of two or more code tokens are mapped, so ordinary words (エース, ビーフ, ワイシャツ) are kept. - Kanji/kana and okurigana variants of the same word are equal (何とか == なんとか, 振替 == 振り替え, 名字 == 苗字). - Product and system names are compared in katakana: the Latin name (Confluence) and listed katakana spellings map to one standard spelling (コンフルエンス). A near-miss STT spelling such as コンフュエンス now gets partial credit instead of a 100% character error. - Fillers are removed only at a word start: なんとか was turned into なか.
Japanese has no word spacing, but normalize_text re-joins comma-separated chunks with spaces, so every comma counted as a character error. For ja, whitespace is now removed after normalization. Korean keeps word spaces. Bump STTWERMetric.version to v0.2 and metrics_version to 2.2.5: the per-language normalization changes in this series change stt_wer scores for de, nl, fr, fr-CA, es, it, hi, ko and ja. English is unchanged.
fanny-riols
marked this pull request as draft
October 28, 2026 14:09
fr-CA.json was a near copy of fr.json with small, non-Québec-specific
drift (filler list, the mgr/st/ste abbreviations, "dot" as a thousands
separator). Canadian French is now normalized exactly like French:
- fr-CA.json is removed; a regional variant without its own config falls
back to its base language (fr-CA -> fr.json), like services.py does.
- fr.json gains the one filler only fr-CA had ("mm").
- The French tests now run for both fr and fr-CA.
…hed words
Hindi speakers mix English words in, and the two transcripts often write
them differently ("seven बजे" vs "7 बजे", "I'm" vs "I am"). The new
code_switch module aligns with the Hindi rules as before, then re-checks
each error region with an English view of the raw text (Latin runs
normalized as English, Devanagari runs as Hindi). A region is rescued only
when both English views are identical and the reference is itself English,
so reference "7" vs STT "seven" stays an error.
Enabled for Hindi through the new code_switch_languages config field;
every other language is unchanged.
…r rates For languages with code_switch_languages (Hindi), the main score is now the reconciled WER: correctly transcribed English words no longer count as errors. The Hindi-only WER stays in details.strict_wer so runs remain comparable with earlier scores. New details: code_switch (code-switched and rescued word counts, rescues per matcher, WER and reference words per language) and per_turn_code_switch_rescues for audit. New sub-metrics: wer_hi, wer_en (omitted when the run has no English reference words), code_switch_word_rate and code_switch_rescue_rate. Substitutions and deletions count against the reference word's language, insertions against the inserted word's. Other languages take the existing code path unchanged.
…evanagari The lexicon can't list every English word. With code_switch_phonetic, a Devanagari reference word whose Latin STT word sounds the same is rescued (नेटवर्क vs network, both reduced to the consonant key "ntwrk") and tagged English. It is deliberately conservative: 1:1 words only, at least three consonants on both sides, and a stoplist of Hindi words that sound like English ones (कल/call, बस/bus, मैं/main, पर/par). Off by default and in every config, until native-speaker labeling of its matches shows it is precise enough.
The comments tied switching it on to a labeling round that is no longer planned.
English words written in Devanagari in the intended text and in Latin by the STT (नेटवर्क / network, कन्फर्म / confirm) now count as correct in the Hindi score. On existing Hindi runs this is where nearly all code-switch rescues come from: the intended text rarely has English in Latin script.
Add en underscore (and code-only slash), it barra obliqua, and hi डैश / अंडरस्कोर / underscore / स्लैश (and code-only slash). English at and Hindi ऐट stay out: the code-neighbour rule cannot tell them apart in an email address. On the English datasets only the 7 strings that spell an ID with "underscore" change.
fanny-riols
commented
Oct 29, 2026
…icon to what it can't handle Phonetic keys: soft g and intervocalic s get both readings (manager = मैनेजर, business = बिज़नेस), and anusvara before a labial is m (नंबर = number). The matcher now works in both directions (intended booking vs STT बुकिंग). hi_loanwords.json drops the 107 of 157 pairs the matcher now matches; it keeps words with fewer than three consonants (code, time) and january / february. On the 147-record Hindi sample: reconciled WER 0.303 -> 0.302, strict WER 0.315 -> 0.337, phonetic rescues 462 -> 1158. Tests: merge the two stt_wer code-switch tests that used the same turns.
The counter must end the word or be followed by a particle, so 네 개인정보
("yes, personal info"), 한 번호를 and 두 대리님 are left alone; 세대
(generation) is kept as a word. 네 개, 두 개를, 세 명이요 still convert.
…eal errors Phonetic matcher: consonant keys must be identical (no edit tolerance), a consonant repeated across a vowel counts twice (refunded vs रिफंड), the first vowels must be compatible (booking vs backing), anusvara before फ stays n, and native Hindi look-alikes are stoplisted (कमरा/camera, मिलना/million). Loanword pairs the stricter matcher no longer covers go back into hi_loanwords.json (50 -> 54). English view: the STT side of a rescued region must come from Latin-script text, so intended "seven" vs STT सात (a translation) stays an error. On the 147-record Hindi sample the reconciled WER stays 0.302; phonetic rescues go from 1158 to 1146.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Native-speaker linguists reviewed EVA simulated conversations in 9 non-English languages and checked every metric. For
stt_wer, most of what they flagged was not an STT error. For example, the normalizer treated two correct ways of writing the same spoken content as different words, so a correct transcript was scored as wrong.This PR fixes the normalization for de, nl, fr, fr-CA, es, it, hi, ko and ja, and makes Hindi scoring aware of English words mixed into Hindi speech (code-switching). English output is unchanged except for one fix:
normalize_text(..., "en")gives identical results on the strings in the three datasets, before and after, apart from the 7 that spell an ID with the word "underscore" (last commit). Those now match the written ID.Changes by file
Shared:
engine.py,wer_utils.py,configs/README.mdThree new optional fields in a language config. A field does nothing unless a language turns it on, so this commit changes no output.
test_wer_normalization_hooks.pychecks that English and every language that does not opt in are unaffected.char_folds: treat two spellings of a character as the same, on both the reference and the transcript (Hindi ज़ = ज, German ü = ue). The folds are also applied to the number-word vocabulary, so German "fünf" still parses as 5.join_hyphenated_compounds: treat a hyphenated compound and its closed form as one word (Anmelde-Konto=Anmeldekonto). The hyphen is kept next to a number word, next to an acronym (HR-Fragen, which STT often writes open as "HR Fragen"), and in reduplications (Hindiबार-बार).spoken_symbols/spoken_symbols_code_only: when a user spells an ID aloud, they say the name of the symbol, such as "A B C trait d'union 1 2 3". The STT writesABC-123. Before, only the English word "dash" was recognized, so every other language counted the spoken word as an error. Symbol words that are also ordinary words (Spanish "barra", German "Strich") are only replaced next to something that looks like a code. That way "una barra de pan" is left alone. The last commit makes every language cover-,_and/(English gets "underscore" and, next to a code only, "slash"). English "at" and Hindi "ऐट" are left out for@: they are ordinary words, and the code rule can't recognize them in an email address.Per Language
German:
configs/de.jsonAnmelde-Konto, the TTS said one word, and the STT wroteAnmeldekonto. That counted as two errors.-,_,/and@. The intended text "read underscore only" was counted as an error when the STT wrote "Read only".Krüger=Krueger,Straße=Strasse.Kruger) used to matchKrüger. With ü = ue it no longer does. We think that is correct, because "Kruger" is a misspelling, but it is a scoring change.Dutch:
configs/nl.jsonzorg-verlof=zorgverlof,e-mailadres=emailadres).French:
configs/fr.jsonM 4 Bwas normalized tomonsieur 4 bon one side andm4bon the other. Only "M." followed by a word (M. Dupont) now expands.Canadian French:
configs/fr-CA.json(removed),wer_utils.pyCanadian French is now normalized exactly like French, from the same
fr.json.fr-CA.jsonwas a near copy offr.json, and it had drifted in ways that are not Québec-specific:mgr,standsteexpanded to monseigneur, saint and sainte only in fr1.000) only in frChanges:
fr-CA.jsonis removed._make_normalizernow falls back from a regional variant to its base language when the variant has no config of its own (fr-CA→fr.json), the same ruleservices.pyalready uses for the STT/TTS language. Among EVA's languages this affects only fr-CA.fr.jsongains the one filler only fr-CA had ("mm").frandfr-CA, including curly apostrophes in "trait d’union".As a result, fr-CA now also gets the
st→ saint expansion. That rule can fire on codes containing "ST" in both languages; it is listed under "Not in this PR".Spanish:
configs/es.jsonItalian:
configs/it.json"trattino", "trattino basso" and "chiocciola" match their symbols. For example, a date of birth spoken as "1995 trattino 04 trattino 18" now matches
1995-04-18. "barra obliqua" matches/; plain "barra" is replaced only next to a code.Korean:
cjk.py(Korean section)The number converter turned any syllable that can be a number into a digit:
It now converts a span only when it is a well-formed number in a numeric context, meaning next to a digit or followed by a counter such as 월, 일, 원, 시 or 명. Also added:
1만 2천 원=12,000원)공일공 일이삼사=010 1234)스무 살=20살). After 한/두/세/네 the counter must end the word or be followed by a particle, so네 개인정보("yes, personal info") is left aloneThe fillers 음 and 어 are now removed only as whole words; before, they were also cut out of 음식, 다음 and 영어. The tests include a list of surnames and ordinary words that must not change.
Japanese:
cjk.py(Japanese section),configs/ja_loanwords.json(new)イー・エム・ピー、さんnow matchesEMP3, and三、一、八、五、ゼロ、四matches318504. Only runs of two or more code tokens are mapped, so ordinary words such as エース, ビーフ and ワイシャツ are left alone.何とか/なんとか,振替/振り替え,名字/苗字,合って/あって.モデルandDellwoodare unchanged.なんとかwas turned intoなか, because the fillerんとwas matched inside the word. Fillers are now removed only at the start of a word.stt_wer.py: whitespace in Japanese CERJapanese has no word spacing, but
normalize_textre-joins comma-separated chunks with spaces, so every comma in a turn added character errors. Reviewers: "User Turn 1, Turn 4, Turn 8 does not contain any errors, not sure what WER is saying was added. Maybe a space or the comma?" Forja, whitespace is now removed after normalization. Korean keeps its word spaces.Hindi:
configs/hi.json,configs/hi_loanwords.json(new)code→कोड,time→टाइम). It was built from the reviewers' examples and the scenario data, with 157 entries at first. Later commits drop the 103 that the phonetic matcher (below) handles (march/मार्च,password/पासवर्ड). The ones kept have fewer than three consonants, too few for the matcher, a Hindi form that differs (january/जनवरी,september/सितंबर), or an unusually spelled first vowel (business/बिज़नेस).ज़रूर/जरूरandहाँ/हांare the same word spelled two ways. Both spellings appear in our own scenario data (ऑफ़िस/ऑफिस).ई-मेल=ईमेल. Reduplications such asबार-बारkeep the hyphen.डैश,अंडरस्कोर/underscoreandस्लैशmatch-,_and/(A B C डैश एक दो तीन=ABC-123). Latinslashis replaced only next to a code.Hindi code-switching:
code_switch.py(new),stt_wer.pyHindi speakers mix English words into Hindi all the time ("मेरा flight कल है"). The intended text and the STT transcript often write those words differently: in different forms (
seven बजेvs7 बजे,I'mvsI am), or in different scripts (मार्चvsmarch). The loanword lexicon above and the phonetic matcher below handle the second case. The first case still counted correct English words as errors, because both sides were normalized with Hindi rules only.How it works. The Hindi alignment is unchanged. Then each error region (a run of mismatched words, up to 4 words per side) is re-checked with an English view of the same text: Latin-script parts are normalized with the English rules ("twenty five" → 25, "I'm" → "i am"), and Devanagari parts keep the Hindi rules. A region stops counting as an error only if:
The second condition stops it from hiding real errors. If the intended text has the digit
7and the STT writes "seven", the user may have said सात, so it stays an error. The STT side must be English too: intended "seven" vs STT सात is a translation and stays an error, even though both normalize to 7. Errors can only go down, never up.Examples
Two neighbouring errors form one region, and that region is only rescued as a whole. For example,
flight sevenvsfight 7stays two errors. This loses a little recall but never hides the real error next to the rescued word.Output (Hindi only, so other languages produce exactly the same result as before):
score/details.wer: the code-switch-aware WER.per_turn_wer,per_turn_errorsanderror_summaryuse it too.details.strict_wer: the Hindi-only WER as computed before, so runs stay comparable.details.code_switch: number of English words in the intended text, number of words rescued, and the WER and word count per language (hi,en). Substitutions and deletions count against the intended word's language, insertions against the inserted word's.details.per_turn_code_switch_rescues: every rescued pair, for audit.wer_hi,wer_en(omitted when a run has no English words),code_switch_word_rate(share of intended words that are English) andcode_switch_rescue_rate.docs/metrics/stt_wer.mdhas a new code-switching section with a Hindi example output and the meaning of each new field.Words are tagged English when they are in Latin script, a Devanagari loanword from the lexicon (कोड), or a word the phonetic matcher rescued. Other English words written in Devanagari are counted as Hindi, so the English share is an undercount.
The behaviour is turned on by a new config field,
code_switch_languages: ["en"], set only inhi.json.Phonetic matcher. The lexicon can't list every English word. Commit 15 adds a matcher,
code_switch_phonetic, for an English word written in Devanagari on one side and in Latin on the other (in either direction since the last commit). Both are reduced to a consonant key and compared:The keys must be identical. The Latin key tries both readings of a soft g (
manager= मैनेजर,target= टारगेट) and of an s between vowels (reason= रीज़न,basic= बेसिक). Anusvara before प/ब/भ/म counts as m (नंबर =number). A consonant repeated across a vowel counts twice, so another form of the word doesn't match (refunded/ रिफंड,numbers/ नंबर). The first vowels must be compatible (बुकिंग matchesbooking, notbacking); words that differ only in a later vowel still match (कन्फर्म /conform). It only compares single words, needs at least three consonants, and skips native Hindi words with an English look-alike (कल/call, बस/bus, मैं/main, पर/par, कमरा/camera, मिलना/million). Commit 17 turns it on for Hindi ("code_switch_phonetic": trueinhi.json); it is off for every other language. On existing Hindi runs it is where nearly all code-switch rescues come from, because the simulated user writes English words in Devanagari while the STT writes them in Latin (for example कन्फर्म / confirm, डॉलर / dollar, ट्रांसफर / transfer, स्वैप / swap).Version bump
STTWERMetric.version(stt_wer.py)metrics_version(src/eva/__init__.py)48704b24).tests/fixtures/metric_signatures.jsonSTTWERMetricentry changes. Regenerated again in commits 14 and 15.The code-switching commits keep v0.2 and 2.2.5: neither version is released yet.
Note for future changes: the drift test and the version-bump hook only watch
src/eva/metrics/andjudge.yaml. Neither coverssrc/eva/utils/wer_normalization/, so a normalizer change alone would not trigger them. Here the bump is triggered by thestt_wer.pychange, but the language commits before it change scores too. A follow-up could include the normalizer code and configs inSTTWERMetric's signature.Testing
test_wer_code_switch.pyand 11 intest_stt_wer_metric.py. They cover every example above, the cases that must not be rescued, and the per-language counts.foxcrot/ फॉक्सट्रॉट,information/ कन्फर्मेशन,management/ अमेंडमेंट,permanent/ परमिनिट,complete/ कमपीट).normalize_textoutput on 18,868 dataset strings. After the code-switching commits,normalize_textand the fullstt_werresult foren(score, details, sub-metrics) are also identical to before on every string indata/*_dataset.json. The spoken-symbol commit changes exactly 7 strings, all IDs spelled with "underscore" (mag underscore mutualnow matchesmag_mutual).Not in this PR
add_user_language_directive). It needs asimulation_versionbump and Japanese runs to be re-simulated.mr/hr/ds, dehr/fr, frst).