Download src/scripts/twolayer_data.py from voraseth/klon8-base: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/voraseth/klon8-base/resolve/main/src/scripts/twolayer_data.py
- Command line
-
hf download hf://voraseth/klon8-base/src/scripts/twolayer_data.py
-
curl -L -o twolayer_data.py https://huggingface.co/voraseth/klon8-base/resolve/main/src/scripts/twolayer_data.py
15.1 kB
| """Two-layer canvas data (user idea 2026-09-29): a SOUND layer of pronounced syllables and a WORD layer of written | |
| words spanning 1+ sound cells. | |
| User: "There got to be two layers. Syllable layer that are pronounciation of วรรค such as ไฉน => ฉะ | ไหน, | |
| สลาย => สะ | หลาย, อภัย => อะ | ไพ. Rhyme cleanness should focus on this layer. Naturalness and misspell layer | |
| should be judged on other layer." | |
| Units. A วรรค is cut into written words by newmm and each word is read by TLTK g2p word by word - exactly the path | |
| klon8.prosody.g2p_line takes, so the sound cells ARE the syllables the form checker counts, rhymes and tones. | |
| One cell = one pronounced syllable. Every cell carries | |
| sound: the TLTK syllable string (e.g. cha1, naj4) -> onset / rime family / tone, exact, in context | |
| word: the written word it belongs to, and its index k inside that word (0..n-1) | |
| The model's token at a cell is the pair (word, k) - "word-slot" - so a word is never cut into written pieces: | |
| ไฉน is two cells (ไฉน,0)(ไฉน,1) with sounds cha1 | naj4, never ไฉ | น. | |
| ๆ repeats the whole preceding word (ดีๆ -> ดี ดี); ฯ / ๏ are dropped (fixbase_data.prep_line, same choice as FIXED). | |
| Commands (CPU only, no GPU): | |
| twolayer_data.py measure DATA [--n N] numbers for the design (fragments, inventories, coverage, drops) | |
| twolayer_data.py build DATA OUT_DIR [--words 30000] poems.jsonl + lexicon.json for the prototype | |
| twolayer_data.py test round trip / label / hand cases | |
| """ | |
| import os, sys, re, json, collections, functools, argparse | |
| sys.path[:0] = [".", "scripts"] | |
| from klon8 import prosody as P | |
| C, TL, L = 10, 12, 4 # cells per วรรค, title cells, วรรค per บท (same grid as the canvas) | |
| THAI = re.compile(r"[ก-๙]") | |
| def prep_line(line): | |
| """ฯ / ๏ are not pronounced (FIXED's stated choice, fixbase_data.prep_line).""" | |
| return line.replace("ฯลฯ", "").replace("ฯ", "").replace("๏", "") | |
| def rime_key(s): | |
| return P._MERGED_RIME.get(s.rime, s.rime) | |
| def word_sounds(w): | |
| """TLTK syllables of one written word, parsed; () if any piece does not parse (the word is unusable).""" | |
| raw = P.g2p_word(w) | |
| if not raw: return () | |
| out = [] | |
| for ps in raw.split("~"): | |
| s = P._parse_syl(w, ps) | |
| if s is None: return () | |
| out.append(s) | |
| return tuple(out) | |
| def line_words(line): | |
| """newmm words of one วรรค, Thai only, with ๆ expanded to a copy of the previous WORD (ดีๆ -> ดี ดี).""" | |
| from pythainlp.tokenize import word_tokenize | |
| line = prep_line(line) | |
| clean = P._CLEAN.sub(" ", line) | |
| words = [] | |
| for w in word_tokenize(re.sub(r"\s+", " ", clean).strip(), engine="newmm"): | |
| w = w.strip() | |
| if not w: continue | |
| parts = w.split("ๆ") | |
| for j, part in enumerate(parts): | |
| if j and words: words.append(words[-1]) | |
| if part and THAI.search(part): words.append(part) | |
| return words | |
| def line_cells(line): | |
| """[(word, k, n, Syl)] one per pronounced syllable, or None if a word has no parseable reading.""" | |
| cells = [] | |
| for w in line_words(line): | |
| ss = word_sounds(w) | |
| if not ss: return None | |
| for k, s in enumerate(ss): cells.append((w, k, len(ss), s)) | |
| return cells | |
| def checker_count(words): | |
| """What klon8.prosody counts for the JOINED words (the model writes words with no spaces; newmm may re-cut).""" | |
| return len(P.g2p_line("".join(words))) | |
| def checker_phons(words): | |
| return [s.phon for s in P.g2p_line("".join(words))] | |
| def poem_record(row, title_clean_fn=None): | |
| """One บท -> {"title": [cells], "grid": [[cells] x 4]} with cell = [word, k, n, phon], or (None, reason).""" | |
| lines = row["poem"].split("\n") | |
| if len(lines) != L: return None, "not4" | |
| grid = [] | |
| for ln in lines: | |
| cs = line_cells(ln) | |
| if cs is None: return None, "g2p_unparsed" | |
| if not 7 <= len(cs) <= C: return None, "count" | |
| words = [w for (w, k, n, s) in cs if k == 0] | |
| # the checker re-cuts the JOINED words with newmm; its syllables must be ours exactly (not just their number: | |
| # ฝาก|ว่า rejoined reads ฝา|กว่า - same count, different sounds; test 2026-09-29) | |
| if checker_phons(words) != [s.phon for (w, k, n, s) in cs]: return None, "rejoin" | |
| grid.append([[w, k, n, s.phon] for (w, k, n, s) in cs]) | |
| t = row.get("title", "") or "" | |
| if title_clean_fn: t = title_clean_fn(t) | |
| tc = line_cells(t) or [] | |
| # title: whole words only, up to TL sound cells (a word that would cross cell 12 is dropped, not cut) | |
| title = [] | |
| for c in tc: | |
| if c[1] == 0 and len(title) + c[2] > TL: break | |
| title.append([c[0], c[1], c[2], c[3].phon]) | |
| return {"title": title, "grid": grid}, "ok" | |
| # ------------------------------------------------------------------ measurement | |
| def measure(path, n_max=0): | |
| import syl_align_v2 as A2 | |
| from pythainlp.tokenize import word_tokenize | |
| rows = [json.loads(l) for l in open(path, encoding="utf-8") if l.strip()] | |
| if n_max: rows = rows[:n_max] | |
| st = collections.Counter() | |
| word_tok = collections.Counter(); end_word = collections.Counter(); phon_tok = collections.Counter() | |
| reasons = collections.Counter() | |
| a2_keep = 0 | |
| for r in rows: | |
| lines = r["poem"].split("\n") | |
| if len(lines) != L: reasons["not4"] += 1; continue | |
| # ---- the FIXED base's cells (aligned2 + ๆ/ฯ), grouped by newmm word, as fixbase load_poems builds them | |
| a2_ok = True; a2_lines = [] | |
| for ln in lines: | |
| ln2 = prep_line(ln) | |
| per_word = [] | |
| for w in word_tokenize(ln2, engine="newmm"): | |
| if not re.search(r"[ก-๙]", w): continue | |
| got = A2.split_word(w) or (w,) | |
| per_word.append((w, got)) | |
| cells = [p for _, g in per_word for p in g] | |
| if "ๆ" in ln2 or not (7 <= len(cells) <= C) or len(cells) != len(P.g2p_line("".join(cells))): | |
| a2_ok = False; break | |
| a2_lines.append(per_word) | |
| if a2_ok: | |
| a2_keep += 1 | |
| for per_word in a2_lines: | |
| last_multi = False | |
| for wi, (w, pieces) in enumerate(per_word): | |
| ws = word_sounds(w) | |
| for k, pc in enumerate(pieces): | |
| st["a2_cells"] += 1 | |
| multi = len(pieces) > 1 | |
| st["a2_in_multi"] += multi | |
| # misread: the piece read ALONE (how a per-type label sees it) != its in-context syllable | |
| alone = P.g2p_line(pc) | |
| if ws and len(ws) == len(pieces): | |
| st["a2_cmp"] += 1 | |
| ctx = ws[k] | |
| bad = (len(alone) != 1 or alone[0].phon != ctx.phon) | |
| st["a2_misread"] += bad | |
| st["a2_misread_rt"] += (len(alone) != 1 or (rime_key(alone[0]), alone[0].tone) != (rime_key(ctx), ctx.tone)) | |
| if wi == len(per_word) - 1: | |
| st["a2_final"] += 1; st["a2_final_multi"] += len(pieces) > 1 | |
| # ---- two-layer cells | |
| rec, why = poem_record(r) | |
| reasons[why] += 1 | |
| if rec is None: continue | |
| for g in rec["grid"]: | |
| for (w, k, n, ph) in g: | |
| st["tl_cells"] += 1; phon_tok[ph] += 1 | |
| if k == 0: word_tok[w] += 1 | |
| w, k, n, ph = g[-1] | |
| end_word[w] += 1; st["tl_final_multi"] += n > 1; st["tl_final"] += 1 | |
| kept = reasons["ok"] | |
| out = {"rows": len(rows), "a2_kept_poems": a2_keep, "twolayer_kept_poems": kept, "drop_reasons": dict(reasons)} | |
| out["a2_cells_in_multisyllable_word"] = st["a2_in_multi"] / max(st["a2_cells"], 1) | |
| out["a2_cells_misread_alone_phon"] = st["a2_misread"] / max(st["a2_cmp"], 1) | |
| out["a2_cells_misread_alone_rime_tone"] = st["a2_misread_rt"] / max(st["a2_cmp"], 1) | |
| out["a2_linefinal_in_multisyllable_word"] = st["a2_final_multi"] / max(st["a2_final"], 1) | |
| out["tl_linefinal_multisyllable_word"] = st["tl_final_multi"] / max(st["tl_final"], 1) | |
| tot_p = sum(phon_tok.values()) | |
| out["phon_types"] = len(phon_tok) | |
| for k in (1000, 2000, 3000): | |
| out[f"phon_cov_top{k}"] = sum(c for _, c in phon_tok.most_common(k)) / tot_p | |
| out["phon_types_ge3"] = sum(1 for c in phon_tok.values() if c >= 3) | |
| tot_w = sum(word_tok.values()); tot_e = sum(end_word.values()) | |
| out["word_types"] = len(word_tok); out["word_tokens"] = tot_w | |
| out["word_types_ge2"] = sum(1 for c in word_tok.values() if c >= 2) | |
| out["word_types_ge3"] = sum(1 for c in word_tok.values() if c >= 3) | |
| for k in (20000, 30000, 50000): | |
| top = dict(word_tok.most_common(k)) | |
| out[f"word_unk_tok_{k}"] = 1 - sum(top.values()) / tot_w | |
| out[f"end_word_unk_{k}"] = 1 - sum(c for w, c in end_word.items() if w in top) / tot_e | |
| # poems with >= 1 UNK word | |
| out["syl_per_word_tokens"] = st["tl_cells"] / max(tot_w, 1) | |
| nsyl = collections.Counter(len(word_sounds(w)) for w in word_tok.elements()) | |
| out["word_len_dist_tokens"] = {k: v / tot_w for k, v in sorted(nsyl.items())} | |
| return out, word_tok, phon_tok | |
| KMAX = 8 # longest word the model represents (syllables); the longest training-poem word has 8 | |
| def extra_words(): | |
| """Words beyond the poems (user 2026-09-29: the pretrained LM knows far more words than the poems use): | |
| pythainlp thai_words + WangchanBERTa vocabulary pieces that are whole Thai words. A piece counts as a word when it is | |
| Thai only, starts with a consonant or a leading vowel, and newmm keeps it as one token. Returns {word: source}.""" | |
| from pythainlp.corpus import thai_words | |
| from pythainlp.tokenize import word_tokenize | |
| from transformers import AutoTokenizer | |
| out = {w: "dict" for w in thai_words()} | |
| tk = AutoTokenizer.from_pretrained("airesearch/wangchanberta-base-att-spm-uncased") | |
| for p in tk.get_vocab(): | |
| p = p.replace("▁", "") | |
| if p and re.fullmatch(r"[ก-๛]+", p) and re.match(r"[ก-ฮเแโใไ]", p) and word_tokenize(p, engine="newmm") == [p]: | |
| out[p] = "wc" if p not in out else "dict+wc" | |
| return out | |
| def usable(w): | |
| """a word the sound layer can carry: Thai only, g2p reads every syllable, at most KMAX syllables, and newmm reads it | |
| back as itself (so the checker re-cuts it as one word when it stands alone)""" | |
| from pythainlp.tokenize import word_tokenize | |
| if not re.fullmatch(r"[ก-๛]+", w) or "ๆ" in w or "ฯ" in w: return False | |
| ss = word_sounds(w) | |
| return bool(ss) and len(ss) <= KMAX and word_tokenize(w, engine="newmm") == [w] | |
| def build(path, out_dir, n_words=0, clean_titles=True, vocab="union"): | |
| import title_clean | |
| os.makedirs(out_dir, exist_ok=True) | |
| recs, reasons = [], collections.Counter() | |
| for l in open(path, encoding="utf-8"): | |
| if not l.strip(): continue | |
| r = json.loads(l) | |
| rec, why = poem_record(r, title_clean.clean_title if clean_titles else None) | |
| reasons[why] += 1 | |
| if rec: rec["id"] = r.get("id"); recs.append(rec) | |
| cnt = collections.Counter(c[0] for rec in recs for g in rec["grid"] for c in g if c[1] == 0) | |
| # vocab=poems: the POEM words only. vocab=union (default, user 2026-09-29): poem words + thai_words + whole-word | |
| # WangchanBERTa pieces, filtered by usable(); poem words first (by count), then the rest sorted. A title word outside | |
| # the lexicon is read as UNK plus its exact sound channels. | |
| words = [w for w, _ in (cnt.most_common(n_words) if n_words else cnt.most_common())] | |
| src = {w: "poems" for w in words} | |
| if vocab == "union": | |
| ex = extra_words(); seen = set(words); n0 = len(words) | |
| for w in sorted(ex): | |
| if w not in seen and usable(w): words.append(w); seen.add(w); src[w] = ex[w] | |
| print(f" lexicon: {n0:,} poem words + {len(words) - n0:,} unseen words " | |
| f"({collections.Counter(src[w] for w in words[n0:])})") | |
| lex = {w: [s.phon for s in word_sounds(w)] for w in words} | |
| with open(os.path.join(out_dir, "poems.jsonl"), "w", encoding="utf-8") as f: | |
| for rec in recs: f.write(json.dumps(rec, ensure_ascii=False) + "\n") | |
| json.dump({"words": words, "sounds": lex, "count": {w: cnt[w] for w in words}, "source": src}, | |
| open(os.path.join(out_dir, "lexicon.json"), "w", encoding="utf-8"), ensure_ascii=False) | |
| print(f" {len(recs):,} poems kept of {sum(reasons.values()):,} {dict(reasons)}; lexicon {len(words):,} words") | |
| return recs, words | |
| # ------------------------------------------------------------------ tests | |
| HAND = {"ไฉน": ["cha1", "naj4"], "สลาย": ["sa1", "laaj4"], "อภัย": ["?a1", "phaj0"], "ขออภัย": ["khOO4", "?a1", "phaj0"], | |
| "ทำงาน": ["tham0", "Naan0"], "สงบ": ["sa1", "Nop1"], "วันนี้": ["wan0", "nii3"]} | |
| def test(): | |
| fails = 0 | |
| for w, want in HAND.items(): | |
| got = [s.phon for s in word_sounds(w)] | |
| ok = got == want | |
| fails += not ok | |
| print(f" {'OK ' if ok else 'BAD'} {w} -> {'|'.join(got)} (want {'|'.join(want)})") | |
| # round trip: word -> sound cells -> words -> text; checker agrees with the cell count | |
| for ln in ["ไฉนเลยใจสลายไม่อภัย", "ดีๆ ใจเราเฝ้ารอคอยเธอ", "แว่วแว่วมาว่าใครส่งใจหา"]: | |
| cs = line_cells(ln) | |
| words = [w for (w, k, n, s) in cs if k == 0] | |
| text = "".join(words) | |
| ok = (text == prep_line(ln).replace(" ", "").replace("ๆ", "") or "ๆ" in ln) and checker_count(words) == len(cs) | |
| fails += not ok | |
| print(f" {'OK ' if ok else 'BAD'} {ln} -> {' | '.join(f'{w}:{s.phon}' for w, k, n, s in cs)}") | |
| print("PASS" if not fails else f"FAIL {fails}") | |
| return fails | |
| if __name__ == "__main__": | |
| ap = argparse.ArgumentParser(); sub = ap.add_subparsers(dest="cmd") | |
| m = sub.add_parser("measure"); m.add_argument("data"); m.add_argument("--n", type=int, default=0); m.add_argument("--out", default=None) | |
| b = sub.add_parser("build"); b.add_argument("data"); b.add_argument("out_dir"); b.add_argument("--words", type=int, default=0, help="cap on poem words (0 = all)") | |
| b.add_argument("--vocab", choices=["poems", "union"], default="union") | |
| sub.add_parser("test") | |
| a = ap.parse_args() | |
| if a.cmd == "measure": | |
| res, wt, pt = measure(a.data, a.n) | |
| print(json.dumps(res, ensure_ascii=False, indent=1)) | |
| if a.out: json.dump({"summary": res, "word_count": dict(wt), "phon_count": dict(pt)}, open(a.out, "w", encoding="utf-8"), ensure_ascii=False) | |
| elif a.cmd == "build": | |
| build(a.data, a.out_dir, a.words, vocab=a.vocab) | |
| elif a.cmd == "test": | |
| sys.exit(1 if test() else 0) | |
| P.save_cache() | |