File size: 4,984 Bytes
0a9a98f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
"""Numeric equivalence for Thai ASR scoring.  SCORING ONLY -- never touch training targets.

WHY
---
FLEURS-th references write numbers as Arabic DIGITS in 21.5% of utterances.
Whisper-family models do inverse text normalisation and emit digits too
(Pathumma 19.2%, distill-whisper 20.1%, faster-whisper 25.8%, Qwen3 21.5%),
while NeMo RNNT models emit SPOKEN THAI (ChindaASR ~0%, typhoon-asr-realtime 0.0%).
Raw scoring therefore rewards output CONVENTION, not recognition.  Measured on
v3-arm/FLEURS: CER 20.34 on digit-bearing refs vs 8.49 on the rest.

CRITICAL IMPLEMENTATION NOTE
----------------------------
Thai has no word delimiters, so REGEX SUBSTRING MATCHING IS CATASTROPHIC here.
A first version matched number words inside ordinary words --
  สามารถ(able)->'3ารถ'  พันธุ์(species)->'1000ธุ์'  โกหก(lie)->'โก6'
  สัมพันธ์(relationship)->'สัม1000ธ์'
and made 239/1021 utterances WORSE.  Conversion MUST run on word-segmenter
tokens and only on tokens that are EXACTLY numerals.  pythainlp newmm keeps
สามารถ / พันธุ์พืช / โกหก / สัมพันธ์ whole while splitting สี่|หมื่น|คน.
"""
import re
from pythainlp.tokenize import word_tokenize

_D = {'ศูนย์':0,'หนึ่ง':1,'เอ็ด':1,'สอง':2,'สาม':3,'สี่':4,'ห้า':5,
      'หก':6,'เจ็ด':7,'แปด':8,'เก้า':9}
_M = {'สิบ':10,'ร้อย':100,'พัน':1000,'หมื่น':10000,'แสน':100000,'ล้าน':1000000}
# newmm emits numeral COMPOUNDS as single tokens (ยี่สิบ, หนึ่งร้อย, สิบเอ็ด...).
# A token counts as numeric only if numeral morphemes cover it ENTIRELY -- that
# full-match requirement is what makes สามารถ / พันธุ์ / โกหก / สัมพันธ์ safe.
_MORPH = sorted(list(_D) + list(_M) + ['ยี่'], key=len, reverse=True)
_FULL = re.compile('^(?:' + '|'.join(map(re.escape, _MORPH)) + ')+$')
_SPLIT = re.compile('(' + '|'.join(map(re.escape, _MORPH)) + ')')
_THAI_DIGIT = str.maketrans('๐๑๒๓๔๕๖๗๘๙', '0123456789')


def _is_num(tok):
    return bool(_FULL.match(tok))


def _morphs(tok):
    return [m for m in _SPLIT.split(tok) if m]


def _seq(tokens):
    """Parse a run of numeral tokens -> canonical digit string.
    positional  สี่ หมื่น        -> 40000
    digit-wise  สอง ศูนย์ ศูนย์ หก -> 2006  (years/IDs: no multiplier, >=2 digits)
    """
    tokens = [m for t in tokens for m in _morphs(t)]
    tokens = ['สอง' if t == 'ยี่' else t for t in tokens]
    if not tokens:
        return ''
    if not any(t in _M for t in tokens):
        if len(tokens) >= 2:
            return ''.join(str(_D[t]) for t in tokens)
        return str(_D[tokens[0]])
    total = 0; cur = 0; last = None
    for t in tokens:
        if t in _D:
            cur = _D[t]; last = 'd'
        else:
            m = _M[t]
            if m == 1000000:
                total = (total + max(cur, 1)) * m; cur = 0
            else:
                total += (cur if cur else 1) * m; cur = 0
            last = 'm'
    return str(total + cur)


def normalize_numbers(text):
    text = text.translate(_THAI_DIGIT)
    toks = word_tokenize(text, engine='newmm')
    out = []; run = []
    for t in toks:
        if _is_num(t):
            run.append(t)
        else:
            if run:
                out.append(_seq(run)); run = []
            out.append(t)
    if run:
        out.append(_seq(run))
    return ''.join(out)


if __name__ == '__main__':
    CASES = [
        ('สี่หมื่น','40000'), ('ยี่สิบ','20'), ('สองศูนย์ศูนย์หก','2006'),
        ('หนึ่งร้อยยี่สิบสาม','123'), ('สิบเอ็ด','11'), ('หนึ่งร้อย','100'), ('สองพันยี่สิบห้า','2025'), ('ห้าล้าน','5000000'),
        ('สามพันสองร้อย','3200'), ('เก้า','9'), ('๑๒๓','123'), ('40000','40000'),
        # REGRESSION GUARDS -- these broke the regex version
        ('สามารถตรวจจับ','สามารถตรวจจับ'), ('พันธุ์พืช','พันธุ์พืช'),
        ('โกหกหรือไม่','โกหกหรือไม่'), ('ความสัมพันธ์','ความสัมพันธ์'),
        ('ไม่มีตัวเลข','ไม่มีตัวเลข'),
    ]
    bad = 0
    for src, want in CASES:
        got = normalize_numbers(src); ok = got == want; bad += not ok
        print(f"{'ok ' if ok else 'FAIL'} {src!r} -> {got!r}" + ('' if ok else f"  want {want!r}"))
    print(f"\n{len(CASES)-bad}/{len(CASES)} pass")
    raise SystemExit(1 if bad else 0)