polyglot-tutor / training /train_cefr.py
Arthur_Diaz
feat(ml): CEFR dataset builder and XLM-R training pipeline with MLflow tracking (#2)
14e67ea unverified
Raw
History Blame Contribute Delete
9.99 kB
"""Config-driven fine-tuning of the CEFR classifier (runs on the local GPU box).
Usage (from the repo root):
uv run --group train python training/train_cefr.py --config <arm>.toml --dry-run
uv run --group train python training/train_cefr.py --config training/configs/en_only.toml
--dry-run builds the dataset, prints the split summary and runs the leakage
check without importing torch — use it to validate a config cheaply.
Comparability across experiment arms: the split is seeded per (corpus|level)
stratum, so the English test documents are identical whether or not the
multilingual subsets are present (encoded as a unit test in
tests/test_cefr_splitting.py).
"""
import argparse
import json
import math
import tomllib
from collections import Counter
from pathlib import Path
from data_loading import load_passages
from evaluation import evaluate_views, lang_filtered, predict_probs
from tutor.ml.cefr.metrics import classification_report
from tutor.ml.cefr.preprocessing import CANONICAL_LEVELS, Passage
from tutor.ml.cefr.splitting import SPLITS, assign_splits
MODELS_DIR = Path("models/cefr")
def load_config(path: Path) -> dict:
with path.open("rb") as handle:
return tomllib.load(handle)
def build_parts(config: dict) -> dict[str, list[Passage]]:
data = config["data"]
passages = load_passages(
data["subsets"],
chunking=data.get("chunking", True),
target_words=data.get("target_words", 200),
max_words=data.get("max_words", 300),
)
doc_strata = {p.doc_id: f"{p.corpus}|{p.level}" for p in passages}
assignment = assign_splits(
doc_strata,
ratios=tuple(data.get("ratios", [0.8, 0.1, 0.1])),
seed=config["run"].get("seed", 13),
)
parts = {split: [p for p in passages if assignment[p.doc_id] == split] for split in SPLITS}
# Generalization-audit arms (ADR 0003 arm 5): the excluded corpora vanish
# from train/val but keep their canonical test docs, so numbers stay comparable.
excluded = set(data.get("exclude_corpora_from_train", []))
if excluded:
for split in ("train", "val"):
parts[split] = [p for p in parts[split] if p.corpus not in excluded]
doc_ids = {split: {p.doc_id for p in parts[split]} for split in SPLITS}
for first in SPLITS:
for second in SPLITS:
if first < second and doc_ids[first] & doc_ids[second]:
msg = f"document leakage between {first} and {second}"
raise AssertionError(msg)
return parts
def summarize(parts: dict[str, list[Passage]]) -> str:
lines = ["", "split | passages | docs | per-level passage counts", "---|---|---|---"]
for split in SPLITS:
passages = parts[split]
levels = Counter(p.level for p in passages)
level_cells = ", ".join(f"{lvl}:{levels.get(lvl, 0)}" for lvl in CANONICAL_LEVELS)
lines.append(
f"{split} | {len(passages)} | {len({p.doc_id for p in passages})} | {level_cells}"
)
corpora = Counter((p.corpus, parts_split) for parts_split in SPLITS for p in parts[parts_split])
lines += ["", "corpus x split:"]
for corpus in sorted({c for c, _ in corpora}):
cells = ", ".join(f"{split}:{corpora.get((corpus, split), 0)}" for split in SPLITS)
lines.append(f" {corpus}: {cells}")
return "\n".join(lines)
def class_weight_values(train_passages: list[Passage]) -> list[float]:
"""Inverse-frequency weights, normalised to mean 1 (absent classes get weight 0)."""
counts = Counter(p.level for p in train_passages)
raw = [1.0 / counts[lvl] if counts.get(lvl) else 0.0 for lvl in CANONICAL_LEVELS]
present = [w for w in raw if w > 0]
mean = sum(present) / len(present)
return [w / mean for w in raw]
def run_training(parts: dict[str, list[Passage]], config: dict, config_path: Path) -> None:
# Heavy imports kept local so --dry-run works without torch installed.
import mlflow
import torch
from datasets import Dataset
from transformers import (
AutoModelForSequenceClassification,
AutoTokenizer,
DataCollatorWithPadding,
Trainer,
TrainingArguments,
)
run_cfg, model_cfg, train_cfg = config["run"], config["model"], config["train"]
tracking = config.get("tracking", {})
run_name = run_cfg["name"]
out_dir = MODELS_DIR / run_name
max_length = model_cfg.get("max_length", 512)
tokenizer = AutoTokenizer.from_pretrained(model_cfg["base"])
model = AutoModelForSequenceClassification.from_pretrained(
model_cfg["base"],
num_labels=len(CANONICAL_LEVELS),
id2label=dict(enumerate(CANONICAL_LEVELS)),
label2id={lvl: i for i, lvl in enumerate(CANONICAL_LEVELS)},
)
rank = {lvl: i for i, lvl in enumerate(CANONICAL_LEVELS)}
def to_dataset(passages: list[Passage]) -> Dataset:
dataset = Dataset.from_dict(
{"text": [p.text for p in passages], "labels": [rank[p.level] for p in passages]}
)
return dataset.map(
lambda batch: tokenizer(batch["text"], truncation=True, max_length=max_length),
batched=True,
remove_columns=["text"],
)
weights = None
if train_cfg.get("class_weights", True):
weights = torch.tensor(class_weight_values(parts["train"]), dtype=torch.float)
class WeightedTrainer(Trainer):
def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
labels = inputs.pop("labels")
outputs = model(**inputs)
loss = torch.nn.functional.cross_entropy(
outputs.logits,
labels,
weight=weights.to(outputs.logits.device) if weights is not None else None,
)
return (loss, outputs) if return_outputs else loss
def compute_metrics(eval_pred):
logits, labels = eval_pred
report = classification_report(list(labels), list(logits.argmax(axis=-1)))
return {
key: value
for key, value in report.items()
if key != "n" and not key.startswith("support_")
}
effective_batch = train_cfg.get("batch_size", 8) * train_cfg.get("grad_accum", 2)
steps_per_epoch = math.ceil(len(parts["train"]) / effective_batch)
warmup_steps = int(
steps_per_epoch * train_cfg.get("epochs", 4) * train_cfg.get("warmup_ratio", 0.1)
)
arguments = TrainingArguments(
output_dir=str(out_dir / "checkpoints"),
seed=run_cfg.get("seed", 13),
learning_rate=train_cfg.get("lr", 2e-5),
num_train_epochs=train_cfg.get("epochs", 4),
per_device_train_batch_size=train_cfg.get("batch_size", 8),
per_device_eval_batch_size=train_cfg.get("batch_size", 8) * 2,
gradient_accumulation_steps=train_cfg.get("grad_accum", 2),
warmup_steps=warmup_steps,
weight_decay=train_cfg.get("weight_decay", 0.01),
fp16=train_cfg.get("fp16", True),
eval_strategy="epoch",
save_strategy="epoch",
save_total_limit=1,
load_best_model_at_end=True,
metric_for_best_model="macro_f1",
greater_is_better=True,
logging_steps=50,
report_to="none",
)
trainer = WeightedTrainer(
model=model,
args=arguments,
train_dataset=to_dataset(parts["train"]),
eval_dataset=to_dataset(parts["val"]),
data_collator=DataCollatorWithPadding(tokenizer),
compute_metrics=compute_metrics,
)
mlflow.set_tracking_uri(tracking.get("uri", "sqlite:///mlflow.db"))
mlflow.set_experiment(tracking.get("experiment", "cefr-classifier"))
with mlflow.start_run(run_name=run_name):
for section, values in config.items():
for key, value in values.items():
mlflow.log_param(f"{section}.{key}", str(value))
for split in SPLITS:
mlflow.log_param(f"n_{split}_passages", len(parts[split]))
if weights is not None:
mlflow.log_param("class_weight_values", [round(w, 3) for w in weights.tolist()])
trainer.train()
probs = predict_probs(trainer.model, tokenizer, parts["test"], max_length=max_length)
en_passages, en_probs = lang_filtered(parts["test"], probs, "en")
results = {
"en": evaluate_views(en_passages, en_probs), # headline (the product target)
"all": evaluate_views(parts["test"], probs),
}
for scope, views in results.items():
for view, report in views.items():
mlflow.log_metrics({f"test_{scope}_{view}_{k}": v for k, v in report.items()})
out_dir.mkdir(parents=True, exist_ok=True)
trainer.save_model(str(out_dir / "model"))
tokenizer.save_pretrained(str(out_dir / "model"))
meta = {
"levels": list(CANONICAL_LEVELS),
"max_length": max_length,
"chunking": config["data"].get("chunking", True),
"target_words": config["data"].get("target_words", 200),
"max_words": config["data"].get("max_words", 300),
}
(out_dir / "preprocessing.json").write_text(json.dumps(meta, indent=2))
mlflow.log_artifact(str(config_path))
mlflow.log_dict(results, "test_metrics.json")
print(json.dumps(results, indent=2))
print(f"\nModel saved to {out_dir / 'model'}")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, required=True)
parser.add_argument("--dry-run", action="store_true", help="build + split + checks, no GPU")
args = parser.parse_args()
config = load_config(args.config)
parts = build_parts(config)
print(summarize(parts))
print("\nLeakage check: OK (document ids are disjoint across splits)")
if args.dry_run:
return
run_training(parts, config, args.config)
if __name__ == "__main__":
main()