File size: 7,634 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
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
"""Bug fixes for the canvas data path (runs/CSEL/FIXED_BASE_PREREG.md), imported by the patched scripts/canvas_bot.py.

1. ๆ / ฯ (data audit 2026-09-28: 1,137+ training poems dropped). klon8.prosody deletes ๆ, ฯ and ๏ before G2P, but
   the segmenter keeps them as cells (or glues ๆ to its word), so the cell count never matched the pronounced count
   and the poem was filtered out - while a poet counts ไม้ยมก as a repeated syllable (เก่งๆ = เก่ง เก่ง). prep_line
   drops ฯ / ๏ (end marks, not pronounced); expand_yamok writes ๆ out as a copy of the syllable before it, so the
   cells, the joined text the checker reads, and the pronunciation agree. Approximation (stated): ๆ repeats the
   preceding SYLLABLE; for a multi-syllable word it repeats only its last syllable.
2. Rime / tone labels in context (data audit: canvas_mlm.phon labels each vocabulary type by G2P of the syllable
   ALONE - ส of สมอง gets the letter name, วั no label, มอ of เสมอ the wrong rime; 3.9% of cells mislabelled,
   2.1% rime / 2.8% tone at line ends where the rhyme and tone masks act). ctx_phon_table labels each training cell
   by G2P of its whole line (cells align 1:1 with the line's G2P syllables: load_poems keeps only lines where they
   do) and gives every type its MAJORITY in-context (rime, tone); types never seen in a poem line keep the old
   out-of-context label. apply_phon rebuilds V.rime / V.tone / V.fam / V.n_rime from that table deterministically.
"""
import collections, sys
if "." not in sys.path: sys.path.insert(0, ".")   # run as a script from the repo root
from klon8 import prosody as P


def prep_line(line):
    """ฯ: 896 of 1,130 in the train split follow a word (mostly line/poem-end marks after names and เทอญ; ข้าฯ
    ~34), 225 stand alone, 9 are ฯลฯ. Choice (stated in the prereg): drop ฯ and read the short form (ข้าฯ = ข้า,
    กรุงเทพฯ = กรุงเทพ), NOT ข้าพเจ้า and not a token of its own - klon8.prosody pronounces the short form, and
    the count filter keeps only poems whose metre agrees with that reading. ฯลฯ is dropped whole."""
    return line.replace("ฯลฯ", "").replace("ฯ", "").replace("๏", "")


def expand_yamok(cells):
    """ๆ repeats the syllable before it, wherever it sits in a cell: ดีๆ -> ดี ดี; ๆคน after ดี -> ดี คน
    (review: a leading ๆ repeats the PREVIOUS cell, not the rest of its own cell); ดีๆๆ -> ดี ดี ดี."""
    out = []
    for c in cells:
        for j, part in enumerate(c.split("ๆ")):
            if j and out: out.append(out[-1])
            if part: out.append(part)
    return out


def contract_yamok(line, seg_fn, words=None):
    """Output side (review): write reduplication back as ๆ for the judge, since the human anchors use ๆ.
    Adjacent equal cells X X -> Xๆ (X X X -> Xๆๆ), the exact inverse of expand_yamok, except where XX is itself
    a dictionary word written without ๆ (นานา, จะจะ; pythainlp thai_words). The pipeline measures stutters on the
    written-out text BEFORE contraction, so a contracted stutter is still counted by the gates."""
    if words is None:
        from pythainlp.corpus import thai_words
        words = thai_words()
    out = []
    for c in seg_fn(line):
        if out and out[-1].rstrip("ๆ") == c and c + c not in words: out[-1] += "ๆ"
        else: out.append(c)
    return "".join(out)


def redup_syllables(path="data/modern_bot_train.jsonl"):
    """Syllables the training poems write with ไม้ยมก (the last aligned2 syllable of the word before ๆ)."""
    import re, json, syl_align_v2
    out = set()
    for l in open(path, encoding="utf-8"):
        for w in re.findall(r"([ก-ฮะ-ฺเ-ๅ็-์]+)\s*ๆ", json.loads(l)["poem"]):
            s = syl_align_v2.syls_aligned(w, 12)
            if s: out.add(s[-1])
    return out


def title_cells(seg_fn, text, tl):
    """Title / topic cells for a --yamok model: same ๆ/ฯ handling as the poem lines."""
    return expand_yamok(seg_fn(prep_line(text), tl))[:tl]


def _key(s):
    return (P._MERGED_RIME.get(s.rime, s.rime), s.tone)


def _majority(votes, fallback):
    """Most frequent label; a tie keeps the out-of-context label if it is among the tied, else the smallest by
    repr - so the table does not depend on the order of the poems (train() shuffles them)."""
    top = max(votes.values()); tied = [k for k, n in votes.items() if n == top]
    return fallback if fallback in tied else min(tied, key=repr)


def ctx_phon_table(poems, phon):
    """{syllable: (rime_key, tone)} = majority in-context label; also returns the measured label error rates."""
    votes = collections.defaultdict(collections.Counter)
    cells = []                      # (syllable, in-context label) for the rate measurement
    for p in poems:
        for g in p["grid"]:
            syl = P.g2p_line("".join(g))
            if len(syl) != len(g): continue
            for c, s in zip(g, syl):
                k = _key(s); votes[c][k] += 1; cells.append((c, k))
    table = {c: _majority(v, phon(c)) for c, v in votes.items()}
    old = sum(1 for c, k in cells if phon(c) != k)
    new = sum(1 for c, k in cells if table[c] != k)
    return table, {"cells": len(cells), "out_of_context_mismatch": old / max(len(cells), 1),
                   "type_majority_mismatch": new / max(len(cells), 1), "types": len(table)}


def apply_phon(V, table, phon):
    """V.rime/V.tone/V.fam/V.n_rime from table (fallback: out-of-context phon). Same id order as load_model."""
    rimes = {}; V.rime, V.tone, V.fam = [0] * len(V.itos), [5] * len(V.itos), {}
    for i, t in enumerate(V.itos):
        if i < 4: continue
        r, tn = table.get(t) or phon(t)
        if r is None: continue
        rimes.setdefault(r, len(rimes) + 1); V.rime[i], V.tone[i] = rimes[r], tn
        V.fam.setdefault(rimes[r], []).append(i)
    V.n_rime = len(rimes) + 1


def dump_table(V, table, phon):
    """JSON-safe table over the whole vocabulary (rime keys are tuples)."""
    out = {}
    for t in V.itos[4:]:
        r, tn = table.get(t) or phon(t)
        out[t] = None if r is None else [list(r) if isinstance(r, tuple) else r, tn]
    return out


def load_table(d):
    return {t: ((tuple(v[0]) if isinstance(v[0], list) else v[0]), v[1]) for t, v in d.items() if v is not None}


def freq_table(poems):
    """{"total", "count"} over the poem cells, in the same format as data/syl_freq_train.json (which counts the
    v1 segmenter's units, so most aligned2 syllables would fall back to the floor in --lm-head-init's bias)."""
    cnt = collections.Counter(c for p in poems for g in p["grid"] for c in g)
    return {"total": sum(cnt.values()), "count": dict(cnt)}


if __name__ == "__main__":
    # fixbase_data.py freq DATA OUT: unigram table of the FIXED training cells (aligned2 + ๆ/ฯ), from the patched
    # scripts/canvas_bot.py load_poems, so the table and the model's units cannot disagree
    import json
    assert sys.argv[1] == "freq", __doc__
    sys.path[:0] = [".", "scripts"]
    import canvas_bot as CB
    assert "clean_titles" in CB.load_poems.__code__.co_varnames, "canvas_bot.py is not patched with patch_fixed_base.py"
    d = freq_table(CB.load_poems(sys.argv[2], seg="aligned2", clean_titles=True, yamok=True))
    json.dump(d, open(sys.argv[3], "w", encoding="utf-8"), ensure_ascii=False)
    print(f"  {sys.argv[3]}: {d['total']:,} cells, {len(d['count']):,} types")