ovinduG commited on
Commit
0670db3
·
verified ·
1 Parent(s): 4b2ea75

Upload 2 files

Browse files
training/cpt_gemma4_sinhala.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Continued pretraining (CPT) of Gemma 4 E4B (base, text-only) on the
4
+ Minuri/diverse_sinhala_dataset corpus with Unsloth + LoRA. Target: 1x A40 (48 GB).
5
+
6
+ Install (non-Colab, mirrors the Unsloth Gemma 4 notebook):
7
+ pip install unsloth
8
+ pip install --no-deps transformers==5.5.0 "tokenizers>=0.22.0,<=0.23.0"
9
+ pip install "huggingface_hub>=1.5.0,<2.0" hf_transfer pandas
10
+ pip install --no-deps --upgrade timm
11
+
12
+ huggingface-cli login # if the model is gated for you
13
+
14
+ Smoke test first (a few minutes, tells you tokens/s and VRAM):
15
+ python cpt_gemma4_sinhala.py --max_sentences 20000 --max_steps 30
16
+
17
+ Full run (inside tmux / nohup):
18
+ python cpt_gemma4_sinhala.py --max_sentences 1000000 --epochs 1
19
+
20
+ Resume after a pod restart:
21
+ python cpt_gemma4_sinhala.py --max_sentences 1000000 --epochs 1 --resume
22
+ (use the same --seed and --max_sentences so the data order is identical)
23
+ """
24
+ import argparse
25
+ import os
26
+ import random
27
+
28
+ os.environ.setdefault("HF_HUB_ENABLE_HF_TRANSFER", "1")
29
+
30
+ # Unsloth must be imported before transformers / trl / peft
31
+ from unsloth import FastModel # noqa: E402
32
+ import pandas as pd # noqa: E402
33
+ import torch # noqa: E402
34
+ from datasets import Dataset # noqa: E402
35
+ from huggingface_hub import hf_hub_download # noqa: E402
36
+ from trl import SFTConfig, SFTTrainer # noqa: E402
37
+
38
+
39
+ def parse_args():
40
+ p = argparse.ArgumentParser()
41
+ # ---- data ----
42
+ p.add_argument("--repo_id", default="Minuri/diverse_sinhala_dataset")
43
+ p.add_argument("--data_file", default="high_confidence_7_7M.csv",
44
+ help="Final high-confidence corpus (has predicted_domain + confidence).")
45
+ p.add_argument("--max_sentences", type=int, default=1_000_000,
46
+ help="Sentences to train on (sampled from the 7.7M). 0 = use all.")
47
+ p.add_argument("--sampling", choices=["random", "domain_balanced"], default="random",
48
+ help="random = natural distribution; domain_balanced = equal sentences per domain.")
49
+ p.add_argument("--min_confidence", type=float, default=0.0,
50
+ help="Extra confidence filter on top of the high-confidence file.")
51
+ p.add_argument("--min_chars", type=int, default=15)
52
+ p.add_argument("--sentences_per_doc", type=int, default=16,
53
+ help="Sentences (same domain) joined into one pseudo-document before packing.")
54
+ p.add_argument("--val_sentences", type=int, default=10_000)
55
+ # ---- model ----
56
+ p.add_argument("--model", default="unsloth/gemma-4-E4B")
57
+ p.add_argument("--max_seq_length", type=int, default=2048)
58
+ p.add_argument("--load_in_4bit", action="store_true", help="QLoRA fallback if you hit OOM.")
59
+ p.add_argument("--lora_r", type=int, default=128)
60
+ p.add_argument("--lora_alpha", type=int, default=32)
61
+ p.add_argument("--train_embeddings", action="store_true",
62
+ help="Also train embed_tokens/lm_head (more VRAM; untested on Gemma 4 here).")
63
+ # ---- training ----
64
+ p.add_argument("--batch_size", type=int, default=4)
65
+ p.add_argument("--grad_accum", type=int, default=16)
66
+ p.add_argument("--lr", type=float, default=5e-5)
67
+ p.add_argument("--embedding_lr", type=float, default=5e-6)
68
+ p.add_argument("--epochs", type=float, default=1.0)
69
+ p.add_argument("--max_steps", type=int, default=-1, help=">0 overrides epochs (smoke test).")
70
+ p.add_argument("--save_steps", type=int, default=500)
71
+ p.add_argument("--eval_steps", type=int, default=500)
72
+ p.add_argument("--seed", type=int, default=3407)
73
+ p.add_argument("--output_dir", default="/workspace/outputs/gemma4_e4b_sinhala_cpt")
74
+ p.add_argument("--resume", action="store_true")
75
+ p.add_argument("--merge_16bit", action="store_true", help="Also save merged bf16 weights at the end.")
76
+ p.add_argument("--hub_repo", default="", help="Optional HF repo to push the LoRA adapter to.")
77
+ return p.parse_args()
78
+
79
+
80
+ def load_sentences(args):
81
+ path = hf_hub_download(args.repo_id, args.data_file, repo_type="dataset")
82
+ print(f"[data] reading {path}")
83
+ df = pd.read_csv(path, usecols=["sentence", "predicted_domain", "confidence"])
84
+ print(f"[data] rows read: {len(df):,}")
85
+
86
+ df = df.dropna(subset=["sentence"])
87
+ df["sentence"] = df["sentence"].astype(str).str.strip()
88
+ df = df[df["sentence"].str.len() >= args.min_chars]
89
+ if args.min_confidence > 0:
90
+ df = df[df["confidence"] >= args.min_confidence]
91
+ df = df.drop_duplicates(subset="sentence")
92
+ print(f"[data] after cleaning: {len(df):,}")
93
+ print("[data] domain distribution:\n", df["predicted_domain"].value_counts().to_string())
94
+
95
+ total = args.max_sentences + args.val_sentences if args.max_sentences > 0 else len(df)
96
+ total = min(total, len(df))
97
+ if args.sampling == "domain_balanced" and args.max_sentences > 0:
98
+ per = total // df["predicted_domain"].nunique()
99
+ df = (df.groupby("predicted_domain", group_keys=False)
100
+ .apply(lambda g: g.sample(min(len(g), per), random_state=args.seed)))
101
+ elif total < len(df):
102
+ df = df.sample(n=total, random_state=args.seed)
103
+ df = df.sample(frac=1.0, random_state=args.seed).reset_index(drop=True)
104
+ print(f"[data] sampled: {len(df):,}")
105
+
106
+ val = df.iloc[: args.val_sentences]
107
+ train = df.iloc[args.val_sentences:]
108
+ return train, val
109
+
110
+
111
+ def build_docs(df, k, eos, seed):
112
+ """Group k sentences of the same domain into one pseudo-document, then shuffle docs.
113
+ Sentences are standalone, so this only gives a little local topical context."""
114
+ df = df.sort_values("predicted_domain", kind="stable")
115
+ docs = []
116
+ for _, g in df.groupby("predicted_domain", sort=False):
117
+ s = g["sentence"].tolist()
118
+ for i in range(0, len(s), k):
119
+ docs.append("\n".join(s[i:i + k]) + eos)
120
+ random.Random(seed).shuffle(docs)
121
+ return Dataset.from_dict({"text": docs})
122
+
123
+
124
+ def main():
125
+ args = parse_args()
126
+ random.seed(args.seed)
127
+
128
+ print(f"[model] loading {args.model}")
129
+ model, tokenizer = FastModel.from_pretrained(
130
+ model_name=args.model,
131
+ dtype=None, # bf16 on A40
132
+ max_seq_length=args.max_seq_length,
133
+ load_in_4bit=args.load_in_4bit, # A40 has room for bf16 LoRA; 4-bit only as fallback
134
+ full_finetuning=False,
135
+ )
136
+
137
+ peft_kwargs = dict(
138
+ finetune_vision_layers=False, # text only
139
+ finetune_language_layers=True,
140
+ finetune_attention_modules=True,
141
+ finetune_mlp_modules=True,
142
+ r=args.lora_r,
143
+ lora_alpha=args.lora_alpha,
144
+ lora_dropout=0,
145
+ bias="none",
146
+ use_rslora=True,
147
+ random_state=args.seed,
148
+ )
149
+ if args.train_embeddings:
150
+ peft_kwargs["target_modules"] = [
151
+ "q_proj", "k_proj", "v_proj", "o_proj",
152
+ "gate_proj", "up_proj", "down_proj",
153
+ "embed_tokens", "lm_head",
154
+ ]
155
+ model = FastModel.get_peft_model(model, **peft_kwargs)
156
+
157
+ # ---- data ----
158
+ train_df, val_df = load_sentences(args)
159
+ eos = tokenizer.eos_token or ""
160
+
161
+ # Tokenizer fertility check on Sinhala (informational)
162
+ sample = train_df["sentence"].head(5000).tolist()
163
+ n_tok = sum(len(tokenizer(s, add_special_tokens=False)["input_ids"]) for s in sample)
164
+ n_chr = sum(len(s) for s in sample)
165
+ print(f"[tokenizer] Sinhala: {n_tok / len(sample):.1f} tokens/sentence, "
166
+ f"{n_tok / n_chr:.3f} tokens/char (5k-sentence sample)")
167
+ print(f"[tokenizer] est. training tokens: {n_tok / len(sample) * len(train_df) / 1e6:.1f}M")
168
+
169
+ train_ds = build_docs(train_df, args.sentences_per_doc, eos, args.seed)
170
+ val_ds = build_docs(val_df, args.sentences_per_doc, eos, args.seed)
171
+ print(f"[data] train docs: {len(train_ds):,} | val docs: {len(val_ds):,}")
172
+
173
+ # ---- trainer ----
174
+ cfg = SFTConfig(
175
+ output_dir=args.output_dir,
176
+ dataset_text_field="text",
177
+ max_length=args.max_seq_length,
178
+ packing=True, # pack short docs into full-length blocks
179
+ per_device_train_batch_size=args.batch_size,
180
+ per_device_eval_batch_size=args.batch_size,
181
+ gradient_accumulation_steps=args.grad_accum,
182
+ learning_rate=args.lr,
183
+ lr_scheduler_type="cosine",
184
+ warmup_ratio=0.03,
185
+ weight_decay=0.01,
186
+ max_grad_norm=1.0,
187
+ num_train_epochs=args.epochs,
188
+ max_steps=args.max_steps,
189
+ bf16=not args.load_in_4bit or torch.cuda.is_bf16_supported(),
190
+ optim="adamw_8bit",
191
+ logging_steps=10,
192
+ eval_strategy="steps",
193
+ eval_steps=args.eval_steps,
194
+ save_strategy="steps",
195
+ save_steps=args.save_steps,
196
+ save_total_limit=3,
197
+ seed=args.seed,
198
+ report_to="none", # or "wandb"
199
+ )
200
+ trainer = SFTTrainer(
201
+ model=model,
202
+ tokenizer=tokenizer,
203
+ train_dataset=train_ds,
204
+ eval_dataset=val_ds,
205
+ args=cfg,
206
+ )
207
+ # NOTE: no train_on_responses_only here: CPT trains on every token.
208
+
209
+ gpu = torch.cuda.get_device_properties(0)
210
+ print(f"[gpu] {gpu.name}, {gpu.total_memory / 1024**3:.1f} GB")
211
+
212
+ stats = trainer.train(resume_from_checkpoint=True if args.resume else None)
213
+ print(f"[done] runtime {stats.metrics['train_runtime'] / 60:.1f} min; "
214
+ f"peak reserved {torch.cuda.max_memory_reserved() / 1024**3:.1f} GB")
215
+
216
+ # ---- save ----
217
+ adapter_dir = os.path.join(args.output_dir, "lora_adapter")
218
+ model.save_pretrained(adapter_dir)
219
+ tokenizer.save_pretrained(adapter_dir)
220
+ print(f"[save] LoRA adapter -> {adapter_dir}")
221
+ if args.merge_16bit:
222
+ model.save_pretrained_merged(os.path.join(args.output_dir, "merged_16bit"), tokenizer)
223
+ if args.hub_repo:
224
+ model.push_to_hub(args.hub_repo)
225
+ tokenizer.push_to_hub(args.hub_repo)
226
+
227
+
228
+ if __name__ == "__main__":
229
+ main()
training/eval_cpt_success.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Did the Sinhala CPT work? Compare the BASE model with the CPT model on identical test material.
4
+
5
+ Three steps (each model is scored in its own process so GPU memory is clean):
6
+ python eval_cpt_success.py score --model unsloth/gemma-4-E4B --tag base
7
+ python eval_cpt_success.py score --model YOURNAME/gemma4-e4b-sinhala-cpt --tag cpt
8
+ python eval_cpt_success.py compare --base base_scores.json --cpt cpt_scores.json
9
+
10
+ Tests (all automatic; none needs me to have written Sinhala answer keys):
11
+ 1. Held-out bits-per-character (BPC) on Sinhala sentences the model NEVER saw in training
12
+ (the rows not in the 1M training sample), overall and per domain, with bootstrap CIs.
13
+ 2. Grammar / word-order probes: real sentence vs. a corrupted copy. Does the model give the real one
14
+ the higher likelihood? (adjacent-word swap, full word shuffle, one wrong consonant, word replaced
15
+ by a random corpus word). Chance = 50%.
16
+ 3. English forgetting check: BPC on a few English sentences (CPT should not get much worse).
17
+ 4. Qualitative: greedy continuations of Sinhala prompts, side by side (for a native speaker to read).
18
+ Use the SAME --max_sentences / --seed / --val_sentences as the training run so "unseen" is really unseen.
19
+ """
20
+ import argparse
21
+ import json
22
+ import math
23
+ import os
24
+ import random
25
+ import sys
26
+
27
+ import numpy as np
28
+
29
+ ENGLISH = [
30
+ "The committee met on Tuesday to discuss the budget for the coming year.",
31
+ "Water boils at one hundred degrees Celsius at sea level.",
32
+ "She opened the window and listened to the rain falling on the roof.",
33
+ "Researchers have developed a new method for detecting early signs of disease.",
34
+ "The train from the capital arrives at the station every morning at seven.",
35
+ "He has been studying computer science at the university for three years.",
36
+ "Many farmers in the region depend on the monsoon for their harvest.",
37
+ "The museum is open to visitors from Monday to Saturday.",
38
+ "A balanced diet includes vegetables, fruit, protein and whole grains.",
39
+ "The company announced that it would open two new offices next spring.",
40
+ "Please send me the report as soon as you have finished reviewing it.",
41
+ "The river flooded after several days of heavy rain in the hills.",
42
+ "Learning a second language can improve memory and problem solving skills.",
43
+ "The doctor advised him to rest and drink plenty of fluids.",
44
+ "Students must submit their assignments before the end of the month.",
45
+ "The old bridge was repaired last year and now carries both cars and pedestrians.",
46
+ "Prices of essential goods have risen steadily over the past six months.",
47
+ "The film tells the story of a young girl who moves to a small coastal town.",
48
+ "Electric cars are becoming more common as charging stations spread across the country.",
49
+ "After the meeting, everyone agreed that the plan needed more time.",
50
+ ]
51
+
52
+ PROMPTS = [
53
+ "ශ්‍රී ලංකාව", "ඊයේ පාසලේදී", "ජනාධිපතිවරයා පැවසුවේ", "ගොවීන්ට පොහොර",
54
+ "බුදුන් වහන්සේ", "කොළඹ නගරයේ", "දරුවන්ගේ අධ්‍යාපනය", "වෛද්‍යවරුන්ට අනුව",
55
+ ]
56
+
57
+ KINDS = ["swap", "shuffle", "charsub", "wordsub"]
58
+ CONSONANTS = [chr(c) for c in range(0x0D9A, 0x0DC7)]
59
+
60
+
61
+ # ------------------------------------------------------------------ corruptions
62
+ def c_swap(text, rng, vocab=None):
63
+ ws = text.split(" ")
64
+ if len(ws) < 5:
65
+ return None
66
+ for _ in range(10):
67
+ i = rng.randrange(len(ws) - 1)
68
+ if ws[i] != ws[i + 1]:
69
+ w = ws[:]
70
+ w[i], w[i + 1] = w[i + 1], w[i]
71
+ return " ".join(w)
72
+ return None
73
+
74
+
75
+ def c_shuffle(text, rng, vocab=None):
76
+ ws = text.split(" ")
77
+ if len(ws) < 5:
78
+ return None
79
+ for _ in range(10):
80
+ w = ws[:]
81
+ rng.shuffle(w)
82
+ if w != ws:
83
+ return " ".join(w)
84
+ return None
85
+
86
+
87
+ def c_charsub(text, rng, vocab=None):
88
+ ws = text.split(" ")
89
+ cand = [i for i, w in enumerate(ws) if len(w) >= 4 and any(ch in CONSONANTS for ch in w)]
90
+ if not cand:
91
+ return None
92
+ i = rng.choice(cand)
93
+ w = ws[i]
94
+ pos = [k for k, ch in enumerate(w) if ch in CONSONANTS]
95
+ k = rng.choice(pos)
96
+ new = rng.choice([c for c in CONSONANTS if c != w[k]])
97
+ ws[i] = w[:k] + new + w[k + 1:]
98
+ return " ".join(ws)
99
+
100
+
101
+ def c_wordsub(text, rng, vocab):
102
+ ws = text.split(" ")
103
+ if len(ws) < 5 or not vocab:
104
+ return None
105
+ i = rng.randrange(len(ws))
106
+ same_len = [v for v in vocab if abs(len(v) - len(ws[i])) <= 1 and v != ws[i]]
107
+ if not same_len:
108
+ return None
109
+ ws[i] = rng.choice(same_len)
110
+ return " ".join(ws)
111
+
112
+
113
+ CORRUPT = {"swap": c_swap, "shuffle": c_shuffle, "charsub": c_charsub, "wordsub": c_wordsub}
114
+
115
+
116
+ # ------------------------------------------------------------------ data
117
+ def build_data(args):
118
+ import pandas as pd
119
+ from huggingface_hub import hf_hub_download
120
+
121
+ path = hf_hub_download(args.repo_id, args.data_file, repo_type="dataset")
122
+ print(f"[data] reading {path}")
123
+ df = pd.read_csv(path, usecols=["sentence", "predicted_domain"])
124
+ # identical cleaning to cpt_gemma4_sinhala.py
125
+ df = df.dropna(subset=["sentence"])
126
+ df["sentence"] = df["sentence"].astype(str).str.strip()
127
+ df = df[df["sentence"].str.len() >= 15]
128
+ df = df.drop_duplicates(subset="sentence")
129
+ total = min(args.max_sentences + args.val_sentences, len(df))
130
+ sampled = df.sample(n=total, random_state=args.seed) # exactly the training sample
131
+ unseen = df.drop(sampled.index)
132
+ unseen = unseen[unseen["sentence"].str.len() <= 300]
133
+ unseen = unseen.sample(n=min(args.n, len(unseen)), random_state=args.seed + 1)
134
+ print(f"[data] training sample {len(sampled):,}; unseen pool {len(df) - len(sampled):,}; using {len(unseen):,}")
135
+
136
+ heldout = [{"text": s, "domain": d} for s, d in zip(unseen["sentence"], unseen["predicted_domain"])]
137
+
138
+ rng = random.Random(args.seed)
139
+ vocab = sorted({w for s in unseen["sentence"].head(3000) for w in s.split(" ") if 2 <= len(w) <= 14})
140
+ pairs = {k: [] for k in KINDS}
141
+ order = list(range(len(heldout)))
142
+ for k in KINDS:
143
+ rng.shuffle(order)
144
+ for i in order:
145
+ if len(pairs[k]) >= args.n_pairs:
146
+ break
147
+ nw = len(heldout[i]["text"].split(" "))
148
+ if not (5 <= nw <= 40):
149
+ continue
150
+ bad = CORRUPT[k](heldout[i]["text"], rng, vocab)
151
+ if bad and bad != heldout[i]["text"]:
152
+ pairs[k].append({"idx": i, "bad": bad})
153
+ print(f"[data] pairs[{k}] = {len(pairs[k])}")
154
+ data = {"heldout": heldout, "pairs": pairs, "english": ENGLISH, "prompts": PROMPTS,
155
+ "meta": {"max_sentences": args.max_sentences, "seed": args.seed}}
156
+ json.dump(data, open(args.data, "w"), ensure_ascii=False)
157
+ print(f"[data] saved {args.data}")
158
+ return data
159
+
160
+
161
+ # ------------------------------------------------------------------ scoring
162
+ def score_texts(model, tok, texts, bs=16, max_len=256):
163
+ """Sum of negative log-likelihood (nats) per text, first real token conditioned on BOS."""
164
+ import torch
165
+ import torch.nn.functional as F
166
+ tok.padding_side = "right"
167
+ out = [0.0] * len(texts)
168
+ order = sorted(range(len(texts)), key=lambda i: len(texts[i]))
169
+ for s in range(0, len(order), bs):
170
+ idx = order[s:s + bs]
171
+ enc = tok([texts[i] for i in idx], return_tensors="pt", padding=True,
172
+ truncation=True, max_length=max_len).to("cuda")
173
+ with torch.no_grad():
174
+ logits = model(input_ids=enc["input_ids"], attention_mask=enc["attention_mask"]).logits
175
+ lg = logits[:, :-1].float()
176
+ tg = enc["input_ids"][:, 1:]
177
+ m = enc["attention_mask"][:, 1:].bool()
178
+ nll = F.cross_entropy(lg.reshape(-1, lg.size(-1)), tg.reshape(-1), reduction="none").view(tg.shape)
179
+ nll = (nll * m).sum(1).tolist()
180
+ for j, i in enumerate(idx):
181
+ out[i] = nll[j]
182
+ if (s // bs) % 25 == 0:
183
+ print(f" scored {min(s + bs, len(order))}/{len(order)}", flush=True)
184
+ return out
185
+
186
+
187
+ def run_score(args):
188
+ data = json.load(open(args.data)) if os.path.exists(args.data) else build_data(args)
189
+ from unsloth import FastModel # must be imported before transformers
190
+ print(f"[model] loading {args.model}")
191
+ model, tokenizer = FastModel.from_pretrained(model_name=args.model, max_seq_length=512, dtype=None,
192
+ load_in_4bit=False, full_finetuning=False)
193
+ tok = getattr(tokenizer, "tokenizer", tokenizer)
194
+ model.eval()
195
+
196
+ res = {"tag": args.tag, "model": args.model}
197
+ print("[score] held-out Sinhala")
198
+ res["heldout_nll"] = score_texts(model, tok, [x["text"] for x in data["heldout"]])
199
+ print("[score] English")
200
+ res["english_nll"] = score_texts(model, tok, data["english"])
201
+ res["pairs"] = {}
202
+ for k in KINDS:
203
+ print(f"[score] pairs: {k}")
204
+ res["pairs"][k] = score_texts(model, tok, [p["bad"] for p in data["pairs"][k]])
205
+
206
+ print("[generate] prompts")
207
+ res["gens"] = {}
208
+ for p in data["prompts"]:
209
+ enc = tok(p, return_tensors="pt").to("cuda")
210
+ try:
211
+ out = model.generate(**enc, max_new_tokens=60, do_sample=False, repetition_penalty=1.15)
212
+ res["gens"][p] = tok.decode(out[0], skip_special_tokens=True)
213
+ except Exception as e: # noqa: BLE001
214
+ res["gens"][p] = f"<generation failed: {type(e).__name__}: {e}>"
215
+ json.dump(res, open(args.out or f"{args.tag}_scores.json", "w"), ensure_ascii=False)
216
+ print(f"[done] wrote {args.out or args.tag + '_scores.json'}")
217
+
218
+
219
+ # ------------------------------------------------------------------ compare
220
+ def boot_ci(fn, n, B=2000, seed=0):
221
+ rng = np.random.default_rng(seed)
222
+ vals = [fn(rng.integers(0, n, n)) for _ in range(B)]
223
+ return np.percentile(vals, [2.5, 97.5])
224
+
225
+
226
+ def run_compare(args):
227
+ data = json.load(open(args.data))
228
+ A, C = json.load(open(args.base)), json.load(open(args.cpt))
229
+ lines = []
230
+
231
+ def out(s=""):
232
+ print(s)
233
+ lines.append(s)
234
+
235
+ chars = np.array([len(x["text"]) for x in data["heldout"]], float)
236
+ a, c = np.array(A["heldout_nll"]), np.array(C["heldout_nll"])
237
+ bpc = lambda nll, ch: nll.sum() / math.log(2) / ch.sum() # noqa: E731
238
+ rel = lambda idx: (a[idx].sum() - c[idx].sum()) / a[idx].sum() # noqa: E731
239
+ lo, hi = boot_ci(rel, len(a))
240
+ out(f"# CPT success report: {C['model']} vs {A['model']}\n")
241
+ out("## 1. Held-out Sinhala bits per character (lower is better)")
242
+ out(f"- sentences: {len(a)} (never seen in training)")
243
+ out(f"- base: {bpc(a, chars):.4f} cpt: {bpc(c, chars):.4f}")
244
+ out(f"- relative improvement: {rel(np.arange(len(a))) * 100:.1f}% (95% CI {lo * 100:.1f}% to {hi * 100:.1f}%)\n")
245
+ out("| domain | n | base BPC | cpt BPC | improvement |")
246
+ out("|---|---|---|---|---|")
247
+ doms = np.array([x["domain"] for x in data["heldout"]])
248
+ for d in sorted(set(doms)):
249
+ m = doms == d
250
+ out(f"| {d} | {m.sum()} | {bpc(a[m], chars[m]):.4f} | {bpc(c[m], chars[m]):.4f} | "
251
+ f"{(a[m].sum() - c[m].sum()) / a[m].sum() * 100:.1f}% |")
252
+
253
+ ech = np.array([len(t) for t in data["english"]], float)
254
+ ea, ec = np.array(A["english_nll"]), np.array(C["english_nll"])
255
+ erel = (ec.sum() - ea.sum()) / ea.sum()
256
+ elo, ehi = boot_ci(lambda i: (ec[i].sum() - ea[i].sum()) / ea[i].sum(), len(ea))
257
+ out("\n## 2. English forgetting (BPC; lower is better)")
258
+ out(f"- base: {bpc(ea, ech):.4f} cpt: {bpc(ec, ech):.4f} change: {erel * 100:+.1f}% "
259
+ f"(95% CI {elo * 100:+.1f}% to {ehi * 100:+.1f}%) [only {len(ea)} sentences: noisy]")
260
+
261
+ out("\n## 3. Grammar / word-choice probes: % where the REAL sentence beats the corrupted one (chance 50%)")
262
+ out("| probe | n | base | cpt | change (pp) | 95% CI |")
263
+ out("|---|---|---|---|---|---|")
264
+ diffs = []
265
+ descr = {"swap": "two adjacent words swapped", "shuffle": "all words shuffled",
266
+ "charsub": "one consonant replaced", "wordsub": "one word replaced by a random word"}
267
+ for k in KINDS:
268
+ items = data["pairs"][k]
269
+ if not items:
270
+ continue
271
+ idx = np.array([p["idx"] for p in items])
272
+ ba, bc = np.array(A["pairs"][k]), np.array(C["pairs"][k])
273
+ wa, wc = (a[idx] < ba).astype(float), (c[idx] < bc).astype(float)
274
+ d = lambda i: (wc[i].mean() - wa[i].mean()) * 100 # noqa: E731
275
+ plo, phi = boot_ci(d, len(idx))
276
+ diffs.append(d(np.arange(len(idx))))
277
+ out(f"| {descr[k]} | {len(idx)} | {wa.mean() * 100:.1f}% | {wc.mean() * 100:.1f}% | "
278
+ f"{d(np.arange(len(idx))):+.1f} | {plo:+.1f} to {phi:+.1f} |")
279
+
280
+ out("\n## 4. Verdict (my rules of thumb, not a standard)")
281
+ ok1 = lo > 0 and rel(np.arange(len(a))) >= 0.03
282
+ ok2 = erel <= 0.10
283
+ ok3 = bool(diffs) and float(np.mean(diffs)) >= 0
284
+ out(f"- [{'PASS' if ok1 else 'FAIL'}] Sinhala BPC improved by >= 3% with CI above 0")
285
+ out(f"- [{'PASS' if ok2 else 'FAIL'}] English BPC got worse by <= 10%")
286
+ out(f"- [{'PASS' if ok3 else 'FAIL'}] grammar probes did not get worse on average")
287
+ out("- Perplexity gains show the model fits Sinhala text better. They do not by themselves prove better "
288
+ "exam-style reasoning: confirm with the shared-task dev set accuracy.")
289
+
290
+ out("\n## 5. Continuations (greedy, 60 tokens). Have a native speaker judge fluency and sense.")
291
+ for p in data["prompts"]:
292
+ out(f"\n**Prompt:** {p}\n\n- base: {A['gens'].get(p, '').replace(chr(10), ' ')}\n- cpt: {C['gens'].get(p, '').replace(chr(10), ' ')}")
293
+ open(args.report, "w").write("\n".join(lines))
294
+ print(f"\n[done] report written to {args.report}")
295
+
296
+
297
+ # ------------------------------------------------------------------ cli
298
+ def main():
299
+ ap = argparse.ArgumentParser()
300
+ sub = ap.add_subparsers(dest="cmd", required=True)
301
+ for name in ("prepare", "score"):
302
+ p = sub.add_parser(name)
303
+ p.add_argument("--repo_id", default="Minuri/diverse_sinhala_dataset")
304
+ p.add_argument("--data_file", default="high_confidence_7_7M.csv")
305
+ p.add_argument("--max_sentences", type=int, default=1_000_000, help="same as training")
306
+ p.add_argument("--val_sentences", type=int, default=10_000, help="same as training")
307
+ p.add_argument("--seed", type=int, default=3407, help="same as training")
308
+ p.add_argument("--n", type=int, default=4000, help="unseen Sinhala sentences to score")
309
+ p.add_argument("--n_pairs", type=int, default=1000, help="pairs per probe type")
310
+ p.add_argument("--data", default="eval_data.json")
311
+ if name == "score":
312
+ p.add_argument("--model", required=True, help="HF repo or local folder (base, adapter or merged)")
313
+ p.add_argument("--tag", required=True, help="base or cpt")
314
+ p.add_argument("--out", default="")
315
+ p = sub.add_parser("compare")
316
+ p.add_argument("--base", default="base_scores.json")
317
+ p.add_argument("--cpt", default="cpt_scores.json")
318
+ p.add_argument("--data", default="eval_data.json")
319
+ p.add_argument("--report", default="cpt_report.md")
320
+ args = ap.parse_args()
321
+ {"prepare": build_data, "score": run_score, "compare": run_compare}[args.cmd](args)
322
+
323
+
324
+ if __name__ == "__main__":
325
+ main()