extended-wer-normalizer 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""extended-wer-normalizer: jiwer-compatible WER normalization for voice AI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import jiwer
|
|
6
|
+
|
|
7
|
+
from .transforms import (
|
|
8
|
+
CollapseRepetitions,
|
|
9
|
+
DigitWordsToChars,
|
|
10
|
+
ExpandAbbreviations,
|
|
11
|
+
ExpandDigitRuns,
|
|
12
|
+
FinalDigitWordCleanup,
|
|
13
|
+
NormalizeCurrency,
|
|
14
|
+
NormalizeEmails,
|
|
15
|
+
NormalizeOrdinals,
|
|
16
|
+
NormalizePercentages,
|
|
17
|
+
NormalizeSymbols,
|
|
18
|
+
NormalizeURLs,
|
|
19
|
+
RemoveFillerWords,
|
|
20
|
+
WhisperBasicNormalize,
|
|
21
|
+
WhisperEnglishNormalize,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"normalize_for_wer",
|
|
26
|
+
"english_wer_pipeline",
|
|
27
|
+
"CollapseRepetitions",
|
|
28
|
+
"DigitWordsToChars",
|
|
29
|
+
"ExpandAbbreviations",
|
|
30
|
+
"ExpandDigitRuns",
|
|
31
|
+
"FinalDigitWordCleanup",
|
|
32
|
+
"NormalizeCurrency",
|
|
33
|
+
"NormalizeEmails",
|
|
34
|
+
"NormalizeOrdinals",
|
|
35
|
+
"NormalizePercentages",
|
|
36
|
+
"NormalizeSymbols",
|
|
37
|
+
"NormalizeURLs",
|
|
38
|
+
"RemoveFillerWords",
|
|
39
|
+
"WhisperBasicNormalize",
|
|
40
|
+
"WhisperEnglishNormalize",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
english_wer_pipeline = jiwer.Compose([
|
|
44
|
+
# Pre-WhisperEN: handle patterns WhisperEN would mangle or remove
|
|
45
|
+
NormalizeEmails(), # must be first: WhisperEN strips @ and dots in emails
|
|
46
|
+
NormalizeURLs(), # WhisperEN strips slashes and scheme prefixes
|
|
47
|
+
NormalizeSymbols(), # WhisperEN removes & and + as punctuation
|
|
48
|
+
ExpandAbbreviations(), # expand Dr./Mr. before WhisperEN lowercases
|
|
49
|
+
# Core Whisper normalization (lowercase, punctuation, contractions, compound numbers)
|
|
50
|
+
ExpandDigitRuns(), # preserve leading zeros before WhisperEN sees e.g. "0176"
|
|
51
|
+
DigitWordsToChars(), # "zero one seven" → "0 1 7" before compound resolution
|
|
52
|
+
WhisperEnglishNormalize(),
|
|
53
|
+
# Post-WhisperEN: handle patterns WhisperEN preserves as-is or converts to compact form
|
|
54
|
+
# (WhisperEN preserves $5.99, 50%, 1st; and converts spoken forms back to compact)
|
|
55
|
+
ExpandDigitRuns(), # split any remaining digit runs (e.g. from compound numbers)
|
|
56
|
+
FinalDigitWordCleanup(), # final digit-word sweep — must run before currency/% so
|
|
57
|
+
# it doesn't convert "nine" inside "ninety nine cents"
|
|
58
|
+
NormalizeCurrency(), # $5.99 → five dollars ninety nine cents
|
|
59
|
+
NormalizePercentages(), # 50% → fifty percent
|
|
60
|
+
NormalizeOrdinals(), # 1st → first
|
|
61
|
+
RemoveFillerWords(),
|
|
62
|
+
CollapseRepetitions(),
|
|
63
|
+
])
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def normalize_for_wer(text: str, language: str = "en") -> str:
|
|
67
|
+
"""Normalize text for WER comparison.
|
|
68
|
+
|
|
69
|
+
English pipeline applies, in order:
|
|
70
|
+
email → URL → symbol → currency → percentage → ordinal → abbreviation
|
|
71
|
+
→ digit-run expansion → digit-word-to-char → Whisper EnglishTextNormalizer
|
|
72
|
+
→ digit-run expansion → final digit cleanup → filler removal → repetition collapse
|
|
73
|
+
|
|
74
|
+
All number representations collapse to space-separated single digits, so
|
|
75
|
+
"0176", "zero one seven six", and "21" all compare correctly.
|
|
76
|
+
|
|
77
|
+
For non-English text, Whisper's BasicTextNormalizer is applied instead.
|
|
78
|
+
"""
|
|
79
|
+
if language == "en":
|
|
80
|
+
result = english_wer_pipeline([text])
|
|
81
|
+
return result[0].strip()
|
|
82
|
+
from whisper_normalizer.basic import BasicTextNormalizer
|
|
83
|
+
return BasicTextNormalizer()(text).strip()
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""jiwer-compatible AbstractTransform subclasses for WER normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from jiwer import AbstractTransform
|
|
8
|
+
from whisper_normalizer.basic import BasicTextNormalizer
|
|
9
|
+
from whisper_normalizer.english import EnglishTextNormalizer
|
|
10
|
+
|
|
11
|
+
# ---------------------------------------------------------------------------
|
|
12
|
+
# Private singletons
|
|
13
|
+
# ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
_basic_normalizer = BasicTextNormalizer()
|
|
16
|
+
_english_normalizer = EnglishTextNormalizer()
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Number normalization helpers (ported from aicpy)
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
_DIGIT_WORDS: dict[str, str] = {
|
|
23
|
+
"zero": "0", "one": "1", "two": "2", "three": "3", "four": "4",
|
|
24
|
+
"five": "5", "six": "6", "seven": "7", "eight": "8", "nine": "9",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_COMPOSITIONAL_NUMBER_WORDS: frozenset[str] = frozenset({
|
|
28
|
+
"eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen",
|
|
29
|
+
"seventeen", "eighteen", "nineteen", "twenty", "thirty", "forty",
|
|
30
|
+
"fifty", "sixty", "seventy", "eighty", "ninety",
|
|
31
|
+
"hundred", "thousand", "million", "billion", "trillion",
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
_DIGIT_WORD_RE = re.compile(
|
|
35
|
+
r"\b(" + "|".join(_DIGIT_WORDS) + r")\b",
|
|
36
|
+
re.IGNORECASE,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExpandDigitRuns(AbstractTransform):
|
|
41
|
+
"""Split runs of 2+ consecutive digits into space-separated single digits.
|
|
42
|
+
|
|
43
|
+
Preserves leading zeros that EnglishTextNormalizer would otherwise drop:
|
|
44
|
+
"0176" → "0 1 7 6"
|
|
45
|
+
"$5.99" → unchanged (decimal digits not split to preserve currency patterns)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def process_string(self, s: str) -> str:
|
|
49
|
+
# Lookbehind: skip runs following a decimal point or currency symbol.
|
|
50
|
+
# Lookahead: skip runs immediately before % or ordinal suffixes (st/nd/rd/th),
|
|
51
|
+
# which are handled by NormalizePercentages / NormalizeOrdinals later.
|
|
52
|
+
return re.sub(
|
|
53
|
+
r"(?<![.$€£¥₹])\d{2,}(?![%]|(?:st|nd|rd|th)\b)",
|
|
54
|
+
lambda m: " ".join(m.group()),
|
|
55
|
+
s,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DigitWordsToChars(AbstractTransform):
|
|
60
|
+
"""Convert isolated single-digit words (zero–nine) to digit characters.
|
|
61
|
+
|
|
62
|
+
Leaves digit words adjacent to compositional number words (twenty, hundred…)
|
|
63
|
+
so EnglishTextNormalizer can compose compound numbers correctly.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def process_string(self, s: str) -> str:
|
|
67
|
+
words = s.split()
|
|
68
|
+
out: list[str] = []
|
|
69
|
+
for i, word in enumerate(words):
|
|
70
|
+
bare = word.lower().rstrip(".,!?;:")
|
|
71
|
+
if bare in _DIGIT_WORDS:
|
|
72
|
+
left = words[i - 1].lower().rstrip(".,!?;:") if i > 0 else ""
|
|
73
|
+
right = words[i + 1].lower().rstrip(".,!?;:") if i < len(words) - 1 else ""
|
|
74
|
+
if left in _COMPOSITIONAL_NUMBER_WORDS or right in _COMPOSITIONAL_NUMBER_WORDS:
|
|
75
|
+
out.append(word)
|
|
76
|
+
else:
|
|
77
|
+
punct = word[len(word.rstrip(".,!?;:")):]
|
|
78
|
+
out.append(_DIGIT_WORDS[bare] + punct)
|
|
79
|
+
else:
|
|
80
|
+
out.append(word)
|
|
81
|
+
return " ".join(out)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class WhisperEnglishNormalize(AbstractTransform):
|
|
85
|
+
"""Apply OpenAI Whisper's EnglishTextNormalizer.
|
|
86
|
+
|
|
87
|
+
Handles lowercase, punctuation removal, contraction expansion, and
|
|
88
|
+
compound number words ("twenty one" → "21").
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def process_string(self, s: str) -> str:
|
|
92
|
+
return _english_normalizer(s)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class WhisperBasicNormalize(AbstractTransform):
|
|
96
|
+
"""Apply OpenAI Whisper's BasicTextNormalizer (language-agnostic)."""
|
|
97
|
+
|
|
98
|
+
def process_string(self, s: str) -> str:
|
|
99
|
+
return _basic_normalizer(s)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class FinalDigitWordCleanup(AbstractTransform):
|
|
103
|
+
"""Unconditional regex sweep: convert any residual digit word to a digit.
|
|
104
|
+
|
|
105
|
+
Safe to use after EnglishTextNormalizer has resolved all compound numbers.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
def process_string(self, s: str) -> str:
|
|
109
|
+
return _DIGIT_WORD_RE.sub(lambda m: _DIGIT_WORDS[m.group().lower()], s)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Email normalization
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
# Match RFC-5321-ish local parts and domains before the normalizer lowercases
|
|
117
|
+
_EMAIL_RE = re.compile(
|
|
118
|
+
r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _email_to_words(match: re.Match) -> str: # type: ignore[type-arg]
|
|
123
|
+
email = match.group()
|
|
124
|
+
local, domain = email.split("@", 1)
|
|
125
|
+
|
|
126
|
+
def _expand(part: str) -> str:
|
|
127
|
+
part = re.sub(r"\.", " dot ", part)
|
|
128
|
+
part = re.sub(r"-", " dash ", part)
|
|
129
|
+
part = re.sub(r"\+", " plus ", part)
|
|
130
|
+
part = re.sub(r"_", " underscore ", part)
|
|
131
|
+
return part
|
|
132
|
+
|
|
133
|
+
return _expand(local) + " at " + _expand(domain)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class NormalizeEmails(AbstractTransform):
|
|
137
|
+
"""Convert email addresses to spoken-word form.
|
|
138
|
+
|
|
139
|
+
Example: user.name+tag@sub.example.com
|
|
140
|
+
→ "user dot name plus tag at sub dot example dot com"
|
|
141
|
+
"""
|
|
142
|
+
|
|
143
|
+
def process_string(self, s: str) -> str:
|
|
144
|
+
return _EMAIL_RE.sub(_email_to_words, s)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
# URL normalization
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
_URL_RE = re.compile(
|
|
152
|
+
r"https?://(?:www\.)?([A-Za-z0-9.\-]+)(?:/[^\s]*)?",
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _url_to_words(match: re.Match) -> str: # type: ignore[type-arg]
|
|
157
|
+
domain = match.group(1)
|
|
158
|
+
domain = re.sub(r"\.", " dot ", domain)
|
|
159
|
+
domain = re.sub(r"-", " dash ", domain)
|
|
160
|
+
return domain.strip()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class NormalizeURLs(AbstractTransform):
|
|
164
|
+
"""Convert URLs to their spoken domain form, dropping scheme and path.
|
|
165
|
+
|
|
166
|
+
Example: "https://www.example-site.com/some/path?q=1" → "example dash site dot com"
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
def process_string(self, s: str) -> str:
|
|
170
|
+
return _URL_RE.sub(_url_to_words, s)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
# Filler words & stutter/repetition
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
_FILLER_RE = re.compile(
|
|
178
|
+
r"\b(um+|uh+|hmm+|hm+|mm+|er+|ah+|eh+|mhm+|erm+)\b",
|
|
179
|
+
re.IGNORECASE,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
_MULTI_SPACE_RE = re.compile(r" {2,}")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class RemoveFillerWords(AbstractTransform):
|
|
186
|
+
"""Remove spoken filler words (um, uh, hmm, er, ah, …).
|
|
187
|
+
|
|
188
|
+
Common in spontaneous speech and often transcribed by ASR models.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def process_string(self, s: str) -> str:
|
|
192
|
+
s = _FILLER_RE.sub("", s)
|
|
193
|
+
return _MULTI_SPACE_RE.sub(" ", s).strip()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class CollapseRepetitions(AbstractTransform):
|
|
197
|
+
"""Collapse consecutive identical words to a single occurrence.
|
|
198
|
+
|
|
199
|
+
Models ASR output stuttering: "I I I think" → "I think".
|
|
200
|
+
Case-insensitive comparison; preserves the first occurrence's casing.
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def process_string(self, s: str) -> str:
|
|
204
|
+
words = s.split()
|
|
205
|
+
if not words:
|
|
206
|
+
return s
|
|
207
|
+
out = [words[0]]
|
|
208
|
+
for w in words[1:]:
|
|
209
|
+
if w.lower() != out[-1].lower():
|
|
210
|
+
out.append(w)
|
|
211
|
+
return " ".join(out)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ---------------------------------------------------------------------------
|
|
215
|
+
# Abbreviation expansion
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
_ABBREVIATIONS: dict[str, str] = {
|
|
219
|
+
"dr.": "doctor",
|
|
220
|
+
"mr.": "mister",
|
|
221
|
+
"mrs.": "missus",
|
|
222
|
+
"ms.": "miss",
|
|
223
|
+
"prof.": "professor",
|
|
224
|
+
"sr.": "senior",
|
|
225
|
+
"jr.": "junior",
|
|
226
|
+
"vs.": "versus",
|
|
227
|
+
"etc.": "et cetera",
|
|
228
|
+
"approx.": "approximately",
|
|
229
|
+
"dept.": "department",
|
|
230
|
+
"est.": "established",
|
|
231
|
+
"inc.": "incorporated",
|
|
232
|
+
"ltd.": "limited",
|
|
233
|
+
"corp.": "corporation",
|
|
234
|
+
"ave.": "avenue",
|
|
235
|
+
"blvd.": "boulevard",
|
|
236
|
+
"st.": "street",
|
|
237
|
+
"jan.": "january",
|
|
238
|
+
"feb.": "february",
|
|
239
|
+
"mar.": "march",
|
|
240
|
+
"apr.": "april",
|
|
241
|
+
"aug.": "august",
|
|
242
|
+
"sep.": "september",
|
|
243
|
+
"oct.": "october",
|
|
244
|
+
"nov.": "november",
|
|
245
|
+
"dec.": "december",
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Build a single regex that matches any abbreviation (case-insensitive, word boundary on left)
|
|
249
|
+
_ABBREV_RE = re.compile(
|
|
250
|
+
r"(?<!\w)(" + "|".join(re.escape(k) for k in _ABBREVIATIONS) + r")",
|
|
251
|
+
re.IGNORECASE,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class ExpandAbbreviations(AbstractTransform):
|
|
256
|
+
"""Expand common spoken abbreviations to their full form.
|
|
257
|
+
|
|
258
|
+
Examples: "Dr. Smith" → "doctor Smith", "vs." → "versus"
|
|
259
|
+
"""
|
|
260
|
+
|
|
261
|
+
def process_string(self, s: str) -> str:
|
|
262
|
+
return _ABBREV_RE.sub(lambda m: _ABBREVIATIONS[m.group().lower()], s)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# Symbol normalization
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
# Order matters: longer tokens first, and don't match inside emails/URLs
|
|
270
|
+
_SYMBOL_MAP: list[tuple[str, str]] = [
|
|
271
|
+
("&", "and"),
|
|
272
|
+
("&", "and"),
|
|
273
|
+
(" + ", " plus "),
|
|
274
|
+
(" @ ", " at "),
|
|
275
|
+
(" # ", " number "),
|
|
276
|
+
(" * ", " times "),
|
|
277
|
+
(" = ", " equals "),
|
|
278
|
+
(" > ", " greater than "),
|
|
279
|
+
(" < ", " less than "),
|
|
280
|
+
("...", " "),
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
class NormalizeSymbols(AbstractTransform):
|
|
285
|
+
"""Replace common written symbols with their spoken equivalents.
|
|
286
|
+
|
|
287
|
+
Only replaces symbols that appear as standalone tokens (surrounded by spaces
|
|
288
|
+
or text boundaries) to avoid corrupting emails and URLs.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def process_string(self, s: str) -> str:
|
|
292
|
+
for symbol, word in _SYMBOL_MAP:
|
|
293
|
+
s = s.replace(symbol, word)
|
|
294
|
+
return s
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
# Currency normalization
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
_CURRENCY_SYMBOLS: dict[str, tuple[str, str]] = {
|
|
302
|
+
"$": ("dollar", "cent"),
|
|
303
|
+
"€": ("euro", "cent"),
|
|
304
|
+
"£": ("pound", "penny"),
|
|
305
|
+
"¥": ("yen", ""),
|
|
306
|
+
"₹": ("rupee", "paisa"),
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_CURRENCY_RE = re.compile(
|
|
310
|
+
r"([$€£¥₹])\s*(\d+)(?:\.(\d{1,2}))?",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_IRREGULAR_PLURALS: dict[str, str] = {"penny": "pennies", "paisa": "paise"}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _pluralize(unit: str, count: int) -> str:
|
|
318
|
+
if count == 1:
|
|
319
|
+
return unit
|
|
320
|
+
return _IRREGULAR_PLURALS.get(unit, unit + "s")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _currency_to_words(match: re.Match) -> str: # type: ignore[type-arg]
|
|
324
|
+
from num2words import num2words
|
|
325
|
+
|
|
326
|
+
symbol = match.group(1)
|
|
327
|
+
major = int(match.group(2))
|
|
328
|
+
minor_str = match.group(3)
|
|
329
|
+
unit_major, unit_minor = _CURRENCY_SYMBOLS.get(symbol, ("dollar", "cent"))
|
|
330
|
+
|
|
331
|
+
def n2w(n: int) -> str:
|
|
332
|
+
return num2words(n).replace("-", " ")
|
|
333
|
+
|
|
334
|
+
parts = [f"{n2w(major)} {_pluralize(unit_major, major)}"]
|
|
335
|
+
if minor_str:
|
|
336
|
+
minor = int(minor_str.ljust(2, "0"))
|
|
337
|
+
if minor > 0 and unit_minor:
|
|
338
|
+
parts.append(f"{n2w(minor)} {_pluralize(unit_minor, minor)}")
|
|
339
|
+
return " ".join(parts)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
class NormalizeCurrency(AbstractTransform):
|
|
343
|
+
"""Convert currency amounts to spoken form.
|
|
344
|
+
|
|
345
|
+
Examples:
|
|
346
|
+
"$5" → "five dollars"
|
|
347
|
+
"$5.99" → "five dollars ninety nine cents"
|
|
348
|
+
"€3.50" → "three euros fifty cents"
|
|
349
|
+
"""
|
|
350
|
+
|
|
351
|
+
def process_string(self, s: str) -> str:
|
|
352
|
+
return _CURRENCY_RE.sub(_currency_to_words, s)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
# Percentage normalization
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
_PERCENT_RE = re.compile(r"(\d+(?:\.\d+)?)\s*%")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class NormalizePercentages(AbstractTransform):
|
|
363
|
+
"""Convert percentage notation to spoken form.
|
|
364
|
+
|
|
365
|
+
Examples: "50%" → "fifty percent", "3.5%" → "three point five percent"
|
|
366
|
+
"""
|
|
367
|
+
|
|
368
|
+
def process_string(self, s: str) -> str:
|
|
369
|
+
from num2words import num2words
|
|
370
|
+
|
|
371
|
+
def _replace(m: re.Match) -> str: # type: ignore[type-arg]
|
|
372
|
+
val_str = m.group(1)
|
|
373
|
+
val = float(val_str)
|
|
374
|
+
if val == int(val):
|
|
375
|
+
return f"{num2words(int(val))} percent"
|
|
376
|
+
return f"{num2words(val)} percent"
|
|
377
|
+
|
|
378
|
+
return _PERCENT_RE.sub(_replace, s)
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Ordinal normalization
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
_ORDINAL_RE = re.compile(r"\b(\d+)(st|nd|rd|th)\b", re.IGNORECASE)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class NormalizeOrdinals(AbstractTransform):
|
|
389
|
+
"""Convert ordinal numerals to spoken word form.
|
|
390
|
+
|
|
391
|
+
Examples: "1st" → "first", "2nd" → "second", "15th" → "fifteenth"
|
|
392
|
+
"""
|
|
393
|
+
|
|
394
|
+
def process_string(self, s: str) -> str:
|
|
395
|
+
from num2words import num2words
|
|
396
|
+
|
|
397
|
+
def _replace(m: re.Match) -> str: # type: ignore[type-arg]
|
|
398
|
+
return num2words(int(m.group(1)), to="ordinal")
|
|
399
|
+
|
|
400
|
+
return _ORDINAL_RE.sub(_replace, s)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: extended-wer-normalizer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: jiwer-compatible WER normalizer with number, email, URL, filler, and symbol normalization for voice AI evaluation
|
|
5
|
+
Project-URL: Homepage, https://github.com/ai-coustics/extended-wer-normalizer
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: asr,jiwer,normalization,speech recognition,voice ai,wer
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Science/Research
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Speech
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: jiwer>=3.0
|
|
20
|
+
Requires-Dist: num2words>=0.5
|
|
21
|
+
Requires-Dist: whisper-normalizer>=0.1.12
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# extended-wer-normalizer
|
|
25
|
+
|
|
26
|
+
jiwer-compatible text normalizer for Word Error Rate (WER) evaluation in voice AI.
|
|
27
|
+
|
|
28
|
+
Extends [jiwer](https://github.com/jitsi/jiwer)'s built-in transforms with normalizations that matter for real-world ASR evaluation: phone numbers, emails, URLs, currency, percentages, ordinals, filler words, and stuttering.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install extended-wer-normalizer
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from extended_wer_normalizer import normalize_for_wer
|
|
40
|
+
|
|
41
|
+
normalize_for_wer("Call 0176 or email info@example.com, it costs $5.99")
|
|
42
|
+
# → "call 0 1 7 6 or email info at example dot com it costs five dollars ninety nine cents"
|
|
43
|
+
|
|
44
|
+
normalize_for_wer("Um, 1st place goes to Dr. Smith with 50% accuracy")
|
|
45
|
+
# → "first place goes to doctor smith with fifty percent accuracy"
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## jiwer integration
|
|
49
|
+
|
|
50
|
+
Every normalization is a `jiwer.AbstractTransform` subclass — compose them freely:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import jiwer
|
|
54
|
+
from extended_wer_normalizer.transforms import NormalizeEmails, ExpandDigitRuns
|
|
55
|
+
|
|
56
|
+
pipeline = jiwer.Compose([
|
|
57
|
+
NormalizeEmails(),
|
|
58
|
+
ExpandDigitRuns(),
|
|
59
|
+
jiwer.ToLowerCase(),
|
|
60
|
+
jiwer.RemovePunctuation(),
|
|
61
|
+
jiwer.ReduceToListOfListOfWords(),
|
|
62
|
+
])
|
|
63
|
+
|
|
64
|
+
wer = jiwer.wer("info at example dot com", "info@example.com", hypothesis_transform=pipeline)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Use the pre-built pipeline directly with `jiwer.wer`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
import jiwer
|
|
71
|
+
from extended_wer_normalizer import english_wer_pipeline
|
|
72
|
+
|
|
73
|
+
wer = jiwer.wer(reference, hypothesis, reference_transform=english_wer_pipeline, hypothesis_transform=english_wer_pipeline)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Available transforms
|
|
77
|
+
|
|
78
|
+
| Transform | Example |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `ExpandDigitRuns` | `"0176"` → `"0 1 7 6"` |
|
|
81
|
+
| `DigitWordsToChars` | `"zero one seven"` → `"0 1 7"` |
|
|
82
|
+
| `WhisperEnglishNormalize` | lowercase, punctuation, contractions, compound numbers |
|
|
83
|
+
| `WhisperBasicNormalize` | language-agnostic basic normalization |
|
|
84
|
+
| `FinalDigitWordCleanup` | residual digit-word sweep after compound resolution |
|
|
85
|
+
| `NormalizeEmails` | `"user@example.com"` → `"user at example dot com"` |
|
|
86
|
+
| `NormalizeURLs` | `"https://example.com/path"` → `"example dot com"` |
|
|
87
|
+
| `NormalizeCurrency` | `"$5.99"` → `"five dollars ninety nine cents"` |
|
|
88
|
+
| `NormalizePercentages` | `"50%"` → `"fifty percent"` |
|
|
89
|
+
| `NormalizeOrdinals` | `"1st"` → `"first"`, `"15th"` → `"fifteenth"` |
|
|
90
|
+
| `ExpandAbbreviations` | `"Dr."` → `"doctor"`, `"vs."` → `"versus"` |
|
|
91
|
+
| `NormalizeSymbols` | `"cats & dogs"` → `"cats and dogs"` |
|
|
92
|
+
| `RemoveFillerWords` | removes `um`, `uh`, `hmm`, `er`, `ah`, … |
|
|
93
|
+
| `CollapseRepetitions` | `"I I I think"` → `"I think"` |
|
|
94
|
+
|
|
95
|
+
## Pipeline design
|
|
96
|
+
|
|
97
|
+
The English pipeline applies transforms in an order that ensures idempotence and correct interaction with Whisper's `EnglishTextNormalizer`:
|
|
98
|
+
|
|
99
|
+
1. **Pre-Whisper**: email, URL, symbol, abbreviation (patterns Whisper would mangle)
|
|
100
|
+
2. **Digit preparation**: expand digit runs, convert digit words
|
|
101
|
+
3. **Core**: `WhisperEnglishNormalize` (lowercase, punctuation, contractions, compound numbers → digits)
|
|
102
|
+
4. **Post-Whisper**: digit run expansion, digit word cleanup, currency, percentage, ordinal (patterns Whisper preserves or compacts)
|
|
103
|
+
5. **Cleanup**: filler words, repetition collapse
|
|
104
|
+
|
|
105
|
+
## Non-English
|
|
106
|
+
|
|
107
|
+
For non-English text, pass `language` to get Whisper's `BasicTextNormalizer`:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
normalize_for_wer("Das kostet fünf Euro", language="de")
|
|
111
|
+
```
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
extended_wer_normalizer/__init__.py,sha256=Kxqe_6iY2PNxjl_7L3M8op_xuG5dVp6qMHohKf4ZGSs,3175
|
|
2
|
+
extended_wer_normalizer/transforms.py,sha256=uHXHL9y8ywgZHQKBykg9IHLpdNn2vSkp3u5LeB2e3cU,12547
|
|
3
|
+
extended_wer_normalizer-0.1.0.dist-info/METADATA,sha256=ifw5GxTG555K6IwxocPHxkopFpHhFFfXfigwia3d1Sw,4377
|
|
4
|
+
extended_wer_normalizer-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
5
|
+
extended_wer_normalizer-0.1.0.dist-info/RECORD,,
|