team-qbits / training /eval_cpt_success.py
ovinduG's picture
Upload 2 files
0670db3 verified
Raw History Blame Contribute Delete
15.5 kB
#!/usr/bin/env python
"""
Did the Sinhala CPT work? Compare the BASE model with the CPT model on identical test material.
Three steps (each model is scored in its own process so GPU memory is clean):
python eval_cpt_success.py score --model unsloth/gemma-4-E4B --tag base
python eval_cpt_success.py score --model YOURNAME/gemma4-e4b-sinhala-cpt --tag cpt
python eval_cpt_success.py compare --base base_scores.json --cpt cpt_scores.json
Tests (all automatic; none needs me to have written Sinhala answer keys):
1. Held-out bits-per-character (BPC) on Sinhala sentences the model NEVER saw in training
(the rows not in the 1M training sample), overall and per domain, with bootstrap CIs.
2. Grammar / word-order probes: real sentence vs. a corrupted copy. Does the model give the real one
the higher likelihood? (adjacent-word swap, full word shuffle, one wrong consonant, word replaced
by a random corpus word). Chance = 50%.
3. English forgetting check: BPC on a few English sentences (CPT should not get much worse).
4. Qualitative: greedy continuations of Sinhala prompts, side by side (for a native speaker to read).
Use the SAME --max_sentences / --seed / --val_sentences as the training run so "unseen" is really unseen.
"""
import argparse
import json
import math
import os
import random
import sys
import numpy as np
ENGLISH = [
"The committee met on Tuesday to discuss the budget for the coming year.",
"Water boils at one hundred degrees Celsius at sea level.",
"She opened the window and listened to the rain falling on the roof.",
"Researchers have developed a new method for detecting early signs of disease.",
"The train from the capital arrives at the station every morning at seven.",
"He has been studying computer science at the university for three years.",
"Many farmers in the region depend on the monsoon for their harvest.",
"The museum is open to visitors from Monday to Saturday.",
"A balanced diet includes vegetables, fruit, protein and whole grains.",
"The company announced that it would open two new offices next spring.",
"Please send me the report as soon as you have finished reviewing it.",
"The river flooded after several days of heavy rain in the hills.",
"Learning a second language can improve memory and problem solving skills.",
"The doctor advised him to rest and drink plenty of fluids.",
"Students must submit their assignments before the end of the month.",
"The old bridge was repaired last year and now carries both cars and pedestrians.",
"Prices of essential goods have risen steadily over the past six months.",
"The film tells the story of a young girl who moves to a small coastal town.",
"Electric cars are becoming more common as charging stations spread across the country.",
"After the meeting, everyone agreed that the plan needed more time.",
]
PROMPTS = [
"ශ්‍රී ලංකාව", "ඊයේ පාසලේදී", "ජනාධිපතිවරයා පැවසුවේ", "ගොවීන්ට පොහොර",
"බුදුන් වහන්සේ", "කොළඹ නගරයේ", "දරුවන්ගේ අධ්‍යාපනය", "වෛද්‍යවරුන්ට අනුව",
]
KINDS = ["swap", "shuffle", "charsub", "wordsub"]
CONSONANTS = [chr(c) for c in range(0x0D9A, 0x0DC7)]
# ------------------------------------------------------------------ corruptions
def c_swap(text, rng, vocab=None):
ws = text.split(" ")
if len(ws) < 5:
return None
for _ in range(10):
i = rng.randrange(len(ws) - 1)
if ws[i] != ws[i + 1]:
w = ws[:]
w[i], w[i + 1] = w[i + 1], w[i]
return " ".join(w)
return None
def c_shuffle(text, rng, vocab=None):
ws = text.split(" ")
if len(ws) < 5:
return None
for _ in range(10):
w = ws[:]
rng.shuffle(w)
if w != ws:
return " ".join(w)
return None
def c_charsub(text, rng, vocab=None):
ws = text.split(" ")
cand = [i for i, w in enumerate(ws) if len(w) >= 4 and any(ch in CONSONANTS for ch in w)]
if not cand:
return None
i = rng.choice(cand)
w = ws[i]
pos = [k for k, ch in enumerate(w) if ch in CONSONANTS]
k = rng.choice(pos)
new = rng.choice([c for c in CONSONANTS if c != w[k]])
ws[i] = w[:k] + new + w[k + 1:]
return " ".join(ws)
def c_wordsub(text, rng, vocab):
ws = text.split(" ")
if len(ws) < 5 or not vocab:
return None
i = rng.randrange(len(ws))
same_len = [v for v in vocab if abs(len(v) - len(ws[i])) <= 1 and v != ws[i]]
if not same_len:
return None
ws[i] = rng.choice(same_len)
return " ".join(ws)
CORRUPT = {"swap": c_swap, "shuffle": c_shuffle, "charsub": c_charsub, "wordsub": c_wordsub}
# ------------------------------------------------------------------ data
def build_data(args):
import pandas as pd
from huggingface_hub import hf_hub_download
path = hf_hub_download(args.repo_id, args.data_file, repo_type="dataset")
print(f"[data] reading {path}")
df = pd.read_csv(path, usecols=["sentence", "predicted_domain"])
# identical cleaning to cpt_gemma4_sinhala.py
df = df.dropna(subset=["sentence"])
df["sentence"] = df["sentence"].astype(str).str.strip()
df = df[df["sentence"].str.len() >= 15]
df = df.drop_duplicates(subset="sentence")
total = min(args.max_sentences + args.val_sentences, len(df))
sampled = df.sample(n=total, random_state=args.seed) # exactly the training sample
unseen = df.drop(sampled.index)
unseen = unseen[unseen["sentence"].str.len() <= 300]
unseen = unseen.sample(n=min(args.n, len(unseen)), random_state=args.seed + 1)
print(f"[data] training sample {len(sampled):,}; unseen pool {len(df) - len(sampled):,}; using {len(unseen):,}")
heldout = [{"text": s, "domain": d} for s, d in zip(unseen["sentence"], unseen["predicted_domain"])]
rng = random.Random(args.seed)
vocab = sorted({w for s in unseen["sentence"].head(3000) for w in s.split(" ") if 2 <= len(w) <= 14})
pairs = {k: [] for k in KINDS}
order = list(range(len(heldout)))
for k in KINDS:
rng.shuffle(order)
for i in order:
if len(pairs[k]) >= args.n_pairs:
break
nw = len(heldout[i]["text"].split(" "))
if not (5 <= nw <= 40):
continue
bad = CORRUPT[k](heldout[i]["text"], rng, vocab)
if bad and bad != heldout[i]["text"]:
pairs[k].append({"idx": i, "bad": bad})
print(f"[data] pairs[{k}] = {len(pairs[k])}")
data = {"heldout": heldout, "pairs": pairs, "english": ENGLISH, "prompts": PROMPTS,
"meta": {"max_sentences": args.max_sentences, "seed": args.seed}}
json.dump(data, open(args.data, "w"), ensure_ascii=False)
print(f"[data] saved {args.data}")
return data
# ------------------------------------------------------------------ scoring
def score_texts(model, tok, texts, bs=16, max_len=256):
"""Sum of negative log-likelihood (nats) per text, first real token conditioned on BOS."""
import torch
import torch.nn.functional as F
tok.padding_side = "right"
out = [0.0] * len(texts)
order = sorted(range(len(texts)), key=lambda i: len(texts[i]))
for s in range(0, len(order), bs):
idx = order[s:s + bs]
enc = tok([texts[i] for i in idx], return_tensors="pt", padding=True,
truncation=True, max_length=max_len).to("cuda")
with torch.no_grad():
logits = model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]).logits
lg = logits[:, :-1].float()
tg = enc["input_ids"][:, 1:]
m = enc["attention_mask"][:, 1:].bool()
nll = F.cross_entropy(lg.reshape(-1, lg.size(-1)), tg.reshape(-1), reduction="none").view(tg.shape)
nll = (nll * m).sum(1).tolist()
for j, i in enumerate(idx):
out[i] = nll[j]
if (s // bs) % 25 == 0:
print(f" scored {min(s + bs, len(order))}/{len(order)}", flush=True)
return out
def run_score(args):
data = json.load(open(args.data)) if os.path.exists(args.data) else build_data(args)
from unsloth import FastModel # must be imported before transformers
print(f"[model] loading {args.model}")
model, tokenizer = FastModel.from_pretrained(model_name=args.model, max_seq_length=512, dtype=None,
load_in_4bit=False, full_finetuning=False)
tok = getattr(tokenizer, "tokenizer", tokenizer)
model.eval()
res = {"tag": args.tag, "model": args.model}
print("[score] held-out Sinhala")
res["heldout_nll"] = score_texts(model, tok, [x["text"] for x in data["heldout"]])
print("[score] English")
res["english_nll"] = score_texts(model, tok, data["english"])
res["pairs"] = {}
for k in KINDS:
print(f"[score] pairs: {k}")
res["pairs"][k] = score_texts(model, tok, [p["bad"] for p in data["pairs"][k]])
print("[generate] prompts")
res["gens"] = {}
for p in data["prompts"]:
enc = tok(p, return_tensors="pt").to("cuda")
try:
out = model.generate(**enc, max_new_tokens=60, do_sample=False, repetition_penalty=1.15)
res["gens"][p] = tok.decode(out[0], skip_special_tokens=True)
except Exception as e: # noqa: BLE001
res["gens"][p] = f"<generation failed: {type(e).__name__}: {e}>"
json.dump(res, open(args.out or f"{args.tag}_scores.json", "w"), ensure_ascii=False)
print(f"[done] wrote {args.out or args.tag + '_scores.json'}")
# ------------------------------------------------------------------ compare
def boot_ci(fn, n, B=2000, seed=0):
rng = np.random.default_rng(seed)
vals = [fn(rng.integers(0, n, n)) for _ in range(B)]
return np.percentile(vals, [2.5, 97.5])
def run_compare(args):
data = json.load(open(args.data))
A, C = json.load(open(args.base)), json.load(open(args.cpt))
lines = []
def out(s=""):
print(s)
lines.append(s)
chars = np.array([len(x["text"]) for x in data["heldout"]], float)
a, c = np.array(A["heldout_nll"]), np.array(C["heldout_nll"])
bpc = lambda nll, ch: nll.sum() / math.log(2) / ch.sum() # noqa: E731
rel = lambda idx: (a[idx].sum() - c[idx].sum()) / a[idx].sum() # noqa: E731
lo, hi = boot_ci(rel, len(a))
out(f"# CPT success report: {C['model']} vs {A['model']}\n")
out("## 1. Held-out Sinhala bits per character (lower is better)")
out(f"- sentences: {len(a)} (never seen in training)")
out(f"- base: {bpc(a, chars):.4f} cpt: {bpc(c, chars):.4f}")
out(f"- relative improvement: {rel(np.arange(len(a))) * 100:.1f}% (95% CI {lo * 100:.1f}% to {hi * 100:.1f}%)\n")
out("| domain | n | base BPC | cpt BPC | improvement |")
out("|---|---|---|---|---|")
doms = np.array([x["domain"] for x in data["heldout"]])
for d in sorted(set(doms)):
m = doms == d
out(f"| {d} | {m.sum()} | {bpc(a[m], chars[m]):.4f} | {bpc(c[m], chars[m]):.4f} | "
f"{(a[m].sum() - c[m].sum()) / a[m].sum() * 100:.1f}% |")
ech = np.array([len(t) for t in data["english"]], float)
ea, ec = np.array(A["english_nll"]), np.array(C["english_nll"])
erel = (ec.sum() - ea.sum()) / ea.sum()
elo, ehi = boot_ci(lambda i: (ec[i].sum() - ea[i].sum()) / ea[i].sum(), len(ea))
out("\n## 2. English forgetting (BPC; lower is better)")
out(f"- base: {bpc(ea, ech):.4f} cpt: {bpc(ec, ech):.4f} change: {erel * 100:+.1f}% "
f"(95% CI {elo * 100:+.1f}% to {ehi * 100:+.1f}%) [only {len(ea)} sentences: noisy]")
out("\n## 3. Grammar / word-choice probes: % where the REAL sentence beats the corrupted one (chance 50%)")
out("| probe | n | base | cpt | change (pp) | 95% CI |")
out("|---|---|---|---|---|---|")
diffs = []
descr = {"swap": "two adjacent words swapped", "shuffle": "all words shuffled",
"charsub": "one consonant replaced", "wordsub": "one word replaced by a random word"}
for k in KINDS:
items = data["pairs"][k]
if not items:
continue
idx = np.array([p["idx"] for p in items])
ba, bc = np.array(A["pairs"][k]), np.array(C["pairs"][k])
wa, wc = (a[idx] < ba).astype(float), (c[idx] < bc).astype(float)
d = lambda i: (wc[i].mean() - wa[i].mean()) * 100 # noqa: E731
plo, phi = boot_ci(d, len(idx))
diffs.append(d(np.arange(len(idx))))
out(f"| {descr[k]} | {len(idx)} | {wa.mean() * 100:.1f}% | {wc.mean() * 100:.1f}% | "
f"{d(np.arange(len(idx))):+.1f} | {plo:+.1f} to {phi:+.1f} |")
out("\n## 4. Verdict (my rules of thumb, not a standard)")
ok1 = lo > 0 and rel(np.arange(len(a))) >= 0.03
ok2 = erel <= 0.10
ok3 = bool(diffs) and float(np.mean(diffs)) >= 0
out(f"- [{'PASS' if ok1 else 'FAIL'}] Sinhala BPC improved by >= 3% with CI above 0")
out(f"- [{'PASS' if ok2 else 'FAIL'}] English BPC got worse by <= 10%")
out(f"- [{'PASS' if ok3 else 'FAIL'}] grammar probes did not get worse on average")
out("- Perplexity gains show the model fits Sinhala text better. They do not by themselves prove better "
"exam-style reasoning: confirm with the shared-task dev set accuracy.")
out("\n## 5. Continuations (greedy, 60 tokens). Have a native speaker judge fluency and sense.")
for p in data["prompts"]:
out(f"\n**Prompt:** {p}\n\n- base: {A['gens'].get(p, '').replace(chr(10), ' ')}\n- cpt: {C['gens'].get(p, '').replace(chr(10), ' ')}")
open(args.report, "w").write("\n".join(lines))
print(f"\n[done] report written to {args.report}")
# ------------------------------------------------------------------ cli
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
for name in ("prepare", "score"):
p = sub.add_parser(name)
p.add_argument("--repo_id", default="Minuri/diverse_sinhala_dataset")
p.add_argument("--data_file", default="high_confidence_7_7M.csv")
p.add_argument("--max_sentences", type=int, default=1_000_000, help="same as training")
p.add_argument("--val_sentences", type=int, default=10_000, help="same as training")
p.add_argument("--seed", type=int, default=3407, help="same as training")
p.add_argument("--n", type=int, default=4000, help="unseen Sinhala sentences to score")
p.add_argument("--n_pairs", type=int, default=1000, help="pairs per probe type")
p.add_argument("--data", default="eval_data.json")
if name == "score":
p.add_argument("--model", required=True, help="HF repo or local folder (base, adapter or merged)")
p.add_argument("--tag", required=True, help="base or cpt")
p.add_argument("--out", default="")
p = sub.add_parser("compare")
p.add_argument("--base", default="base_scores.json")
p.add_argument("--cpt", default="cpt_scores.json")
p.add_argument("--data", default="eval_data.json")
p.add_argument("--report", default="cpt_report.md")
args = ap.parse_args()
{"prepare": build_data, "score": run_score, "compare": run_compare}[args.cmd](args)
if __name__ == "__main__":
main()