File size: 6,568 Bytes
7d3eb8f | 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 105 106 107 108 109 110 111 112 113 114 115 | """Surface syllables, v2: scripts/syl_align.py with two alignment bugs fixed (FIX 4, 2026-09-28 data audit).
v1 stays untouched so every model trained on it remains reproducible; models trained with --seg aligned2
use this module.
Bug (a): TLTK writes the velar nasal onset ง as "N", v1's tables said "ng", so no syllable starting with ง
or หง could ever be matched; every multi-syllable word containing one (ทำงาน, สงบ, เหงาหงอย) failed to
align and the whole บท fell out of training at the count filter (poems with ทำงาน were dropped 96.5% of the
time against 14.5% overall).
Bug (b): v1 cut at the FIRST letter spelling the next syllable's onset. When the previous syllable ends in
a consonant that is the same letter as the next onset (วันนี้, สัญญา, จากกัน, ศรัทธา) the cut landed one
letter early: วั|นนี้, สั|ญญา, จา|กกัน - count-correct, so the filter kept them, and the vocabulary learned
fake units (วั, นนี้, กกัน). v2 first requires the piece before a cut to contain a letter that can spell the
previous syllable's coda (or a vowel spelling that carries it: เ-า = w, ไ/ใ = j, -ำ = m); if no cut
satisfies that for the whole word (a letter shared by coda and onset, e.g. ชลบุรี), it falls back to v1's
rule for that word, so v2 never aligns fewer words than v1.
ๆ / ฯ are not changed here: klon8.prosody drops them from the pronunciation, so a line containing ๆ has a
cell count that differs from its pronounced count whichever way the mark is handled; changing that belongs
to the prosody checker, not to the segmenter (one change class per experiment).
"""
import sys, re, functools
sys.path.insert(0, "."); sys.path.insert(0, "scripts")
from klon8 import prosody as P
import syl_align as V1
LEAD_VOWEL = V1.LEAD_VOWEL
ONSET = dict(V1.ONSET, **{"ง": "N"})
DIGRAPH = dict(V1.DIGRAPH, **{"หง": "N"})
# อ นำ ย (อย่า อยู่ อย่าง อยาก) is a digraph only before ่ ู or า; elsewhere อ is a vowel (หรือ|ยัง, ขอ|ยืม)
AY_NEXT = set("ู่า")
# letters that can spell each coda phone TLTK writes
CODA_LETTERS = {
"k": set("กขคฆ"), "t": set("ดตทธฎฏฐฑฒถจชซศษส"), "p": set("บปพฟภ"),
"n": set("นณญรลฬ"), "m": set("ม"), "N": set("ง"), "j": set("ย"), "w": set("ว"),
}
def onset_at(w, i):
out = []
if w[i:i + 2] == "อย" and i + 2 < len(w) and w[i + 2] in AY_NEXT: out.append(("j", 2))
if i + 1 < len(w) and w[i:i + 2] in DIGRAPH: out.append((DIGRAPH[w[i:i + 2]], 2))
if w[i] in ONSET: out.append((ONSET[w[i]], 1))
return out
def spells_coda(piece, coda):
"""Can this written piece carry the coda phone? The first letter is the onset and does not count."""
if not coda or coda == "?": return True
q = 1 if piece and piece[0] in LEAD_VOWEL else 0 # skip a leading vowel
q += 2 if (piece[q:q + 2] in DIGRAPH or piece[q:q + 2] == "อย") else 1 # then the whole onset cluster
body = piece[q:]
if CODA_LETTERS.get(coda, set()) & set(body): return True
if coda == "w" and "เ" in piece and "า" in piece: return True # เ-า
if coda == "j" and (piece[:1] in "ไใ"): return True # ไ, ใ
if coda == "m" and "ำ" in piece: return True # -ำ
return False
def _align(w, onsets, codas, strict):
starts, k, i = [0], 1, 1
while i < len(w) and k < len(onsets):
for cand, n in onset_at(w, i):
if cand != onsets[k]: continue
begin = i - 1 if (i and w[i - 1] in LEAD_VOWEL) else i
if begin <= starts[-1]: continue
if codas[k - 1] and begin - starts[-1] < 2: continue
if strict and not spells_coda(w[starts[-1]:begin], codas[k - 1]): continue
starts.append(begin); k += 1; i = begin + n
break
else:
i += 1
continue
if len(starts) != len(onsets): return ()
return tuple(w[a:b] for a, b in zip(starts, starts[1:] + [len(w)]))
@functools.lru_cache(maxsize=200000)
def split_word(w):
syls = P.g2p_line(w)
if not syls: return ()
onsets = [x.onset for x in syls]; codas = [x.coda for x in syls]
if len(onsets) <= 1: return (w,)
return _align(w, onsets, codas, True) or _align(w, onsets, codas, False)
def syls_aligned(line, limit=None):
from pythainlp.tokenize import word_tokenize
out = []
for w in word_tokenize(line, engine="newmm"):
if not re.search(r"[ก-๙]", w): continue
got = split_word(w)
out.extend(got if got else [w])
return out[:limit] if limit else out
CASES = { # word -> hand-checked split
"ทำงาน": "ทำ|งาน", "สงบ": "ส|งบ", "เหงาหงอย": "เหงา|หงอย", "วันนี้": "วัน|นี้", "ความหมาย": "ความ|หมาย",
"สัญญา": "สัญ|ญา", "จากกัน": "จาก|กัน", "ศรัทธา": "ศรัท|ธา", "บ้านนอก": "บ้าน|นอก", "เนิ่นนาน": "เนิ่น|นาน",
"ชีวิต": "ชี|วิต", "เสมอ": "เส|มอ", "ออดอ้อน": "ออด|อ้อน", "ถวิล": "ถ|วิล", "พันธนา": "พัน|ธ|นา",
"ปรารถนา": "ปราร|ถ|นา", "อารมณ์": "อา|รมณ์", "เลิกกัน": "เลิก|กัน", "กลับบ้าน": "กลับ|บ้าน",
"หลายอย่าง": "หลาย|อย่าง", "อ้อยอิ่ง": "อ้อย|อิ่ง", "สงวน": "ส|งวน", "ฉงน": "ฉ|งน",
"หรือยัง": "หรือ|ยัง", "ขอยืม": "ขอ|ยืม", "ยื้อยุด": "ยื้อ|ยุด", "ถือยศ": "ถือ|ยศ", "ปรนนิบัติ": "ปรน|นิ|บัติ",
"กลืนน้ำ": "กลืน|น้ำ",
}
if __name__ == "__main__":
ok = 0
for w, want in CASES.items():
got = "|".join(split_word(w)); old = "|".join(V1.split_word(w))
ok += got == want
print(f" {'OK ' if got == want else 'BAD'} {w:10s} v2 {got:14s} v1 {old:14s} want {want}")
print(f"{ok}/{len(CASES)} hand cases")
|