Download parser/train.py from Ericu950/Stoicheia-code: direct link, hf CLI and curl.
- Browser
- Download file 12.3 kB
-
https://huggingface.co/Ericu950/Stoicheia-code/resolve/main/parser/train.py
- Command line
-
hf download hf://Ericu950/Stoicheia-code/parser/train.py
-
curl -L -o train.py https://huggingface.co/Ericu950/Stoicheia-code/resolve/main/parser/train.py
12.3 kB
| """Train a deep-biaffine dependency parser over the Stoicheia char arm with a learned | |
| scalar mix. With finetune=true the backbone trains too (enc_lr) alongside the scalar-mix + | |
| biaffine head (lr), like morphparse. | |
| Single-process OR torchrun/DDP (data-parallel over sentences; the treebank is small, every | |
| rank holds it all in RAM and takes an equal disjoint slice of the shuffled sentences each | |
| epoch — equal slice sizes keep step counts identical across ranks so the all-reduce never | |
| deadlocks). Dev/test LAS/UAS are computed in-house over the ENCODABLE (Greek-letter-bearing) | |
| token subset only. | |
| python -m parser.train --config configs/parser_char.json # 1 GPU | |
| torchrun --nproc_per_node=4 -m parser.train --config configs/parser_char.json # 4 GPU | |
| NOTE: this release only supports arm="char". The original 3-arm ablation (char / lemma / | |
| fused) also trained "lemma" and "fused" arms over a second, LemmaDiff-grc encoder; that | |
| encoder is a separate, unpublished side-repo, so those two arms — and the build_lemma_arm | |
| loader that fed them — were dropped here (see parser/model.py's module docstring). The | |
| published joint model (parser/joint_train.py) supersedes all three of these single-arm | |
| specialists on test LAS, so this standalone char-arm trainer is kept mainly for reference / | |
| ablation reproduction rather than as the recommended training path. | |
| """ | |
| from __future__ import annotations | |
| import argparse, datetime, json, math, os, time | |
| from pathlib import Path | |
| import torch | |
| import torch.distributed as dist | |
| from tagger.backbone import load_backbone | |
| from tagger.conllu import read_conllu | |
| from parser.biaffine import ParserConfig, BiaffineHead | |
| from parser.labels import DeprelVocab | |
| from parser.model import CharArm, SyntaxModel | |
| def build_char_arm(device, attn="sdpa", finetune=False): | |
| """Load the frozen Stoicheia backbone (formerly parser/build.py, trimmed of the | |
| LemmaDiff-grc-dependent build_lemma_arm sibling that lived alongside it).""" | |
| ckpt = os.path.expandvars(os.environ["STOICHEIA_CKPT"]) | |
| model, pcfg = load_backbone(ckpt, device, attn_impl=attn) | |
| n_layers = pcfg["depth"] + 1 | |
| return CharArm(model, n_layers, finetune=finetune).to(device), pcfg["d_model"] | |
| def ddp_setup(): | |
| """-> (rank, world, local_rank, is_ddp). No-op (0,1,0,False) outside torchrun.""" | |
| if "RANK" in os.environ: | |
| dist.init_process_group("nccl", timeout=datetime.timedelta(minutes=60)) | |
| rank = dist.get_rank(); world = dist.get_world_size() | |
| local = rank % torch.cuda.device_count() | |
| torch.cuda.set_device(local) | |
| return rank, world, local, True | |
| return 0, 1, 0, False | |
| def eval_counts(core, sents, deprel_vocab, T, W, device, micro, decode="greedy"): | |
| """UAS/LAS *counts* (not ratios) over `sents` — caller reduces across ranks then divides. | |
| decode='mst' uses Chu-Liu-Edmonds (valid-tree) instead of greedy per-token argmax.""" | |
| core.eval() | |
| uas_c = las_c = n_c = 0 | |
| for i in range(0, len(sents), micro): | |
| batch = sents[i:i + micro] | |
| arc_scores, rel_scores, heads, labels, mask = core(batch, deprel_vocab, T, W, device) | |
| if arc_scores is None: | |
| continue | |
| if decode == "mst": | |
| from parser.mst import mst_heads_labels | |
| pred_heads, pred_labels = mst_heads_labels(arc_scores, rel_scores, mask) | |
| else: | |
| pred_heads, pred_labels = core.head.decode(arc_scores, rel_scores, mask) | |
| gh, gl, m = heads.cpu(), labels.cpu(), mask.cpu() | |
| valid = (gh != -100) & m | |
| uas_c += int(((pred_heads == gh) & valid).sum()) | |
| las_c += int(((pred_heads == gh) & (pred_labels == gl) & valid).sum()) | |
| n_c += int(valid.sum()) | |
| core.train() | |
| return uas_c, las_c, n_c | |
| def evaluate(model, sents, deprel_vocab, T, W, device, micro, decode="greedy"): | |
| """Ratio form for single-process eval (parser/evaluate.py on the test split).""" | |
| uc, lc, nc = eval_counts(model, sents, deprel_vocab, T, W, device, micro, decode) | |
| return uc / max(nc, 1), lc / max(nc, 1), nc | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--config", required=True) | |
| a = ap.parse_args() | |
| cfg = json.loads(Path(a.config).read_text()) | |
| for k in ("out_dir", "kfold_dir"): | |
| cfg[k] = os.path.expandvars(cfg[k]) | |
| rank, world, local, is_ddp = ddp_setup() | |
| device = torch.device(f"cuda:{local}" if torch.cuda.is_available() else "cpu") | |
| torch.manual_seed(cfg.get("seed", 0)) | |
| out = Path(cfg["out_dir"]) | |
| if rank == 0: | |
| out.mkdir(parents=True, exist_ok=True) | |
| if is_ddp: | |
| dist.barrier() | |
| def log0(*args, **kw): | |
| if rank == 0: | |
| print(*args, **kw, flush=True) | |
| arm = cfg["arm"] | |
| if arm != "char": | |
| raise NotImplementedError( | |
| f'arm={arm!r} not supported in this release — only "char" is (the "lemma" and ' | |
| '"fused" arms depended on the unpublished LemmaDiff-grc side-repo; see parser/model.py).') | |
| kdir = Path(cfg["kfold_dir"]); fold = cfg.get("fold", 0) | |
| train_sents = list(read_conllu(kdir / f"train{fold}.conllu")) | |
| dev_sents = list(read_conllu(kdir / f"dev{fold}.conllu")) | |
| if cfg.get("max_sents"): | |
| train_sents = train_sents[:cfg["max_sents"]] | |
| dev_sents = dev_sents[:max(cfg["max_sents"] // 4, 8)] | |
| deprel_vocab = DeprelVocab.build(train_sents) | |
| if rank == 0: | |
| deprel_vocab.save(out / "deprel_vocab.json") | |
| log0(f"arm={arm} train={len(train_sents)} dev={len(dev_sents)} " | |
| f"n_labels={len(deprel_vocab.rels)} world={world}") | |
| attn = cfg.get("attn", "sdpa") | |
| ft = cfg.get("finetune", False) | |
| lemma_arm = None # always None in this release (see arm check above) | |
| char_arm, d_in = build_char_arm(device, attn, finetune=ft) | |
| log0(f"char arm loaded, d={d_in}") | |
| pcfg = ParserConfig(d_arc=cfg.get("d_arc", 500), d_rel=cfg.get("d_rel", 150), | |
| dropout=cfg.get("dropout", 0.33), n_labels=len(deprel_vocab.rels)) | |
| head = BiaffineHead(d_in, pcfg).to(device) | |
| core = SyntaxModel(arm, char_arm, lemma_arm, head).to(device) | |
| # two LR groups: head + scalar-mix (fast) vs fine-tuned backbone (slow), like morphparse. | |
| head_mix = list(head.parameters()) | |
| if char_arm is not None: | |
| head_mix += list(char_arm.mix.parameters()) | |
| if lemma_arm is not None: | |
| head_mix += list(lemma_arm.mix.parameters()) | |
| enc_params = [] | |
| if ft: | |
| if char_arm is not None: | |
| enc_params += [p for p in char_arm.model.parameters() if p.requires_grad] | |
| if lemma_arm is not None: | |
| enc_params += [p for p in lemma_arm.model.parameters() if p.requires_grad] | |
| lr_head = cfg.get("lr", 8e-4); lr_enc = cfg.get("enc_lr", 2e-5) | |
| groups = [{"params": head_mix, "lr": lr_head, "base": lr_head}] | |
| if enc_params: | |
| groups.append({"params": enc_params, "lr": lr_enc, "base": lr_enc}) | |
| trainable = head_mix + enc_params | |
| log0(f"trainable params: {sum(p.numel() for p in trainable)} " | |
| f"(head+mix {sum(p.numel() for p in head_mix)}, enc {sum(p.numel() for p in enc_params)}, " | |
| f"finetune={ft})") | |
| opt = torch.optim.AdamW(groups, weight_decay=cfg.get("wd", 0.0)) | |
| # Wrap for DDP AFTER building the optimizer on the raw params (DDP only adds grad hooks, | |
| # it does not replace the param tensors). find_unused_parameters=True: the backbone's | |
| # LM/unembedding head is never touched by feature extraction (return_layers), so a handful | |
| # of its params get no grad each step — harmless (AdamW skips grad=None), but DDP must be | |
| # told to expect it or it raises "param did not receive grad". | |
| model = core | |
| if is_ddp: | |
| from torch.nn.parallel import DistributedDataParallel as DDP | |
| model = DDP(core, device_ids=[local], find_unused_parameters=True) | |
| T, W = cfg.get("T", 2048), cfg.get("W", 384) | |
| micro = cfg.get("micro_batch", 16) | |
| epochs = cfg["epochs"] | |
| per_rank = max((len(train_sents) // world // micro) * micro, micro) # equal shard/rank | |
| steps_per_epoch = per_rank // micro | |
| total_steps = steps_per_epoch * epochs | |
| warmup = max(int(total_steps * cfg.get("warmup_frac", 0.1)), 1) | |
| log0(f"per_rank={per_rank} steps/epoch={steps_per_epoch} total_steps={total_steps} " | |
| f"eff_batch={micro * world}") | |
| def lr_scale(step): | |
| if step < warmup: | |
| return step / warmup | |
| f = (step - warmup) / max(total_steps - warmup, 1) | |
| return 0.5 * (1 + math.cos(math.pi * min(f, 1.0))) | |
| metrics_f = out / "metrics.jsonl" | |
| best_las, best_epoch, step = -1.0, -1, 0 | |
| t0 = time.time() | |
| for epoch in range(epochs): | |
| model.train() | |
| order = torch.randperm(len(train_sents), generator=torch.Generator().manual_seed( | |
| cfg.get("seed", 0) * 1000 + epoch)).tolist() # SAME order on every rank | |
| shuffled = [train_sents[i] for i in order] | |
| shard = shuffled[rank * per_rank:(rank + 1) * per_rank] # disjoint, equal length | |
| for i in range(0, per_rank, micro): | |
| batch_sents = shard[i:i + micro] | |
| arc_scores, rel_scores, heads, labels, mask = model(batch_sents, deprel_vocab, T, W, device) | |
| if arc_scores is None: | |
| continue # unreachable for real Greek batches (every sentence is encodable) | |
| loss, logs = core.head.loss(arc_scores, rel_scores, heads, labels, mask) | |
| opt.zero_grad(set_to_none=True) | |
| loss.backward() | |
| gnorm = torch.nn.utils.clip_grad_norm_(trainable, cfg.get("clip", 5.0)) | |
| for pg in opt.param_groups: | |
| pg["lr"] = pg["base"] * lr_scale(step) # per-group base LR (enc vs head) | |
| if torch.isfinite(gnorm): | |
| opt.step() | |
| if rank == 0 and step % cfg.get("log_every", 20) == 0: | |
| rec = dict(epoch=epoch, step=step, loss=round(loss.item(), 4), | |
| gnorm=round(float(gnorm), 3), **logs) | |
| print(" " + " ".join(f"{k}={v}" for k, v in rec.items()), flush=True) | |
| with open(metrics_f, "a") as mf: | |
| mf.write(json.dumps(rec) + "\n") | |
| step += 1 | |
| # dev eval: each rank its shard, reduce counts -> identical LAS everywhere -> consistent stop | |
| uc, lc, nc = eval_counts(core, dev_sents[rank::world], deprel_vocab, T, W, device, | |
| cfg.get("eval_micro", micro)) | |
| if is_ddp: | |
| t = torch.tensor([uc, lc, nc], dtype=torch.long, device=device) | |
| dist.all_reduce(t) | |
| uc, lc, nc = t.tolist() | |
| uas, las = uc / max(nc, 1), lc / max(nc, 1) | |
| log0(f" EVAL epoch={epoch} uas={uas:.4f} las={las:.4f} n={nc}") | |
| if rank == 0: | |
| with open(out / "eval.jsonl", "a") as ef: | |
| ef.write(json.dumps(dict(epoch=epoch, uas=uas, las=las, n=nc)) + "\n") | |
| if las > best_las: | |
| best_las, best_epoch = las, epoch | |
| if rank == 0: | |
| state = dict(head=head.state_dict(), arm=arm, cfg=cfg, epoch=epoch, uas=uas, las=las, | |
| deprel_vocab=deprel_vocab.rels, d_in=d_in, finetune=ft) | |
| if char_arm is not None: | |
| state["char_mix"] = char_arm.mix.state_dict() | |
| if ft: # persist fine-tuned backbone, else eval reloads the ORIGINAL | |
| state["char_backbone"] = char_arm.model.state_dict() | |
| if lemma_arm is not None: | |
| state["lemma_mix"] = lemma_arm.mix.state_dict() | |
| if ft: | |
| state["lemma_backbone"] = lemma_arm.model.state_dict() | |
| tmp = out / "best.pt.tmp" | |
| torch.save(state, tmp); os.replace(tmp, out / "best.pt") | |
| log0(f" new best.pt (las={las:.4f})") | |
| if epoch - best_epoch >= cfg.get("patience", 8): | |
| log0(f"EARLY STOP epoch={epoch} best_epoch={best_epoch} best_las={best_las:.4f}") | |
| break | |
| log0(f"DONE arm={arm} best_las={best_las:.4f} best_epoch={best_epoch} " | |
| f"({(time.time()-t0)/60:.1f} min)") | |
| if is_ddp: | |
| dist.barrier() | |
| dist.destroy_process_group() | |
| if __name__ == "__main__": | |
| main() | |