Upload 2 files
Browse files- training/cpt_gemma4_sinhala.py +229 -0
- training/eval_cpt_success.py +325 -0
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()
|