SPB Git

spb/wpai0002_uqo

Public
7.3 KB · 121 lines python
1#!/usr/bin/env python32"""Train an HTR model (M1/M2/M3 or ablations) on validated pairs with leakage-safe splits."""3from __future__ import annotations45import argparse6import json7import shutil8import sys9from pathlib import Path1011ROOT = Path(__file__).resolve().parents[1]12sys.path.insert(0, str(ROOT / "src"))13import torch  # noqa: E4021415from htr.models.config import make_config  # noqa: E40216from htr.models.losses import LossWeights  # noqa: E40217from htr.models.tokenizer import HTRTokenizer  # noqa: E40218from htr.models.transformer import HTRModel  # noqa: E40219from htr.training.data import PairDataset, StyleLabelMap, collate, make_loader  # noqa: E40220from htr.training.trainer import TrainConfig, Trainer, overfit_one_batch  # noqa: E40221from htr.utils.io import read_jsonl, write_json  # noqa: E40222from htr.utils.provenance import run_header  # noqa: E4022324PRESETS = {25    "m1": dict(arch="m1", loss=dict()),26    "m2": dict(arch="m2", loss=dict()),27    "m3": dict(arch="m3", loss=dict(style=0.3, adv=0.3, con=0.2, sem=0.1, orth=0.1, fact=0.5)),28    "m3_tokenonly": dict(arch="m3", loss=dict()),29    "m3_nostyle": dict(arch="m3", loss=dict(con=0.2, sem=0.1, fact=0.5)),30    "m3_noadv": dict(arch="m3", loss=dict(style=0.3, con=0.2, sem=0.1, orth=0.1, fact=0.5)),31    "m3_nofact": dict(arch="m3", loss=dict(style=0.3, adv=0.3, con=0.2, sem=0.1, orth=0.1)),32    "m3_nocon": dict(arch="m3", loss=dict(style=0.3, adv=0.3, orth=0.1, fact=0.5)),33    # single-term diagnostics (pilot: every M3 variant with auxiliary losses generalised far worse than token-only)34    "m1_fact": dict(arch="m1", loss=dict(fact=0.5)),35    "m3_factonly": dict(arch="m3", loss=dict(fact=0.5)),36    "m3_cononly": dict(arch="m3", loss=dict(con=0.2)),37    "m3_semonly": dict(arch="m3", loss=dict(sem=0.1)),38}394041def main() -> None:42    ap = argparse.ArgumentParser()43    ap.add_argument("--pairs", required=True)44    ap.add_argument("--splits", required=True)45    ap.add_argument("--tokenizer", required=True, help="dir with tokenizer.model")46    ap.add_argument("--run", required=True, help="run name under artifacts/runs/")47    ap.add_argument("--preset", default="m1", choices=sorted(PRESETS))48    ap.add_argument("--size", default="tiny")49    ap.add_argument("--max-steps", type=int, default=5000)50    ap.add_argument("--eval-every", type=int, default=250)51    ap.add_argument("--lr", type=float, default=3e-4)52    ap.add_argument("--warmup", type=int, default=300)53    ap.add_argument("--max-tokens", type=int, default=8192)54    ap.add_argument("--max-batch", type=int, default=48)55    ap.add_argument("--accum", type=int, default=1)56    ap.add_argument("--patience", type=int, default=6)57    ap.add_argument("--seed", type=int, default=1)58    ap.add_argument("--device", default="auto")59    ap.add_argument("--amp", default="auto")60    ap.add_argument("--variants", default=None, help="restrict training variants, e.g. A,B,C,D (hold out E)")61    ap.add_argument("--limit", type=int, default=None)62    ap.add_argument("--text-meta-prefix", action="store_true", help="prepend <disc:..><era:..> tokens to the source text (M1 metadata control)")63    ap.add_argument("--overfit-test", action="store_true")64    ap.add_argument("--resume", action="store_true")65    ap.add_argument("--no-copy", action="store_true")66    ap.add_argument("--init-from", default=None, help="checkpoint dir (e.g. a denoising-pretrained run's best/) to initialise weights from; shapes must match")67    a = ap.parse_args()68    torch.manual_seed(a.seed)69    preset = PRESETS[a.preset]70    run_dir = ROOT / "artifacts/runs" / a.run71    run_dir.mkdir(parents=True, exist_ok=True)72    tok = HTRTokenizer(Path(a.tokenizer) / "tokenizer.model")73    splits = {s["doc_id"]: s["split"] for s in read_jsonl(a.splits)}74    tr_docs = {d for d, s in splits.items() if s == "train"}75    va_docs = {d for d, s in splits.items() if s == "val"}76    variants = set(a.variants.split(",")) if a.variants else None77    all_train_pairs = [p for p in read_jsonl(a.pairs) if p["doc_id"] in tr_docs and (variants is None or p["variant"] in variants)]78    style_map = StyleLabelMap.from_pairs(all_train_pairs) if preset["arch"] == "m3" else None79    if style_map:80        style_map.save(run_dir / "style_classes.json")81    ds_tr = PairDataset(Path(a.pairs), tok, style_map=style_map, text_meta_prefix=a.text_meta_prefix, limit=a.limit, split_doc_ids=tr_docs, variants=variants)82    ds_va = PairDataset(Path(a.pairs), tok, style_map=style_map, text_meta_prefix=a.text_meta_prefix, limit=(a.limit // 5 if a.limit else None), split_doc_ids=va_docs, variants=variants)83    cfg = make_config(a.size, preset["arch"], tok.vocab_size, n_styles=(len(style_map.classes) if style_map else 0), use_copy=not a.no_copy)84    model = HTRModel(cfg)85    init_info = None86    if a.init_from:87        from safetensors.torch import load_file8889        state = load_file(str(Path(a.init_from) / "model.safetensors"))90        own = model.state_dict()91        loadable = {k: v for k, v in state.items() if k in own and own[k].shape == v.shape}92        missing = [k for k in own if k not in loadable and k != "out.weight"]93        model.load_state_dict({**own, **loadable})94        init_info = {"init_from": a.init_from, "loaded": len(loadable), "not_loaded": missing[:20], "n_not_loaded": len(missing)}95        print(f"initialised {len(loadable)} tensors from {a.init_from}; {len(missing)} left at random init (e.g. new style heads)")96    print(f"model {a.preset}/{a.size}: {model.n_params/1e6:.1f}M params; train pairs {len(ds_tr)} val pairs {len(ds_va)}; style classes {len(style_map.classes) if style_map else 0}")97    shutil.copy(Path(a.tokenizer) / "tokenizer.model", run_dir / "tokenizer.model")98    if a.overfit_test:99        batch = collate([ds_tr[i] for i in range(min(8, len(ds_tr)))], tok.pad_id)100        res = overfit_one_batch(model, batch, steps=150, device=a.device, loss_w=LossWeights(**preset["loss"], label_smoothing=0.0))101        write_json(run_dir / "overfit_test.json", {**run_header(), **res, "preset": a.preset, "size": a.size})102        print("overfit test:", res)103        return104    tcfg = TrainConfig(lr=a.lr, warmup_steps=a.warmup, max_steps=a.max_steps, accum=a.accum, eval_every=a.eval_every, patience=a.patience, amp=a.amp, seed=a.seed, max_tokens=a.max_tokens, max_batch=a.max_batch, loss=LossWeights(**preset["loss"]), device=a.device)105    tl = make_loader(ds_tr, a.max_tokens, a.max_batch, a.seed, True)106    vl = make_loader(ds_va, a.max_tokens, a.max_batch, 0, False)107    write_json(run_dir / "manifest.json", {**run_header({"run": a.run}), "args": vars(a), "preset": preset, "init": init_info, "model_config": cfg.to_dict(), "n_params": model.n_params, "n_train_pairs": len(ds_tr), "n_val_pairs": len(ds_va), "train_docs": len(tr_docs), "val_docs": len(va_docs), "style_classes": style_map.classes if style_map else None})108    trainer = Trainer(model, tcfg, run_dir, tl, vl)109    if a.resume and trainer.resume():110        print(f"resumed at step {trainer.step}")111    summary = trainer.train()112    for tag in ("best", "last"):113        if (run_dir / tag).exists():114            shutil.copy(Path(a.tokenizer) / "tokenizer.model", run_dir / tag / "tokenizer.model")115            write_json(run_dir / tag / "release_meta.json", {"name": f"htr-{a.preset}-{a.size}", "text_meta_prefix": a.text_meta_prefix, "style_classes": style_map.classes if style_map else None, "run": a.run})116    print("summary:", summary)117118119if __name__ == "__main__":120    main()121
Files · 0bfd2d4f2bd3612415cfd6a8d68f31806d44a179
Menu

Theme