Stoicheia-code / parser /train.py
anonymous-stoicheia's picture
Upload folder using huggingface_hub
7ed86c3 verified
Raw
History Blame Contribute Delete
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
@torch.no_grad()
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
@torch.no_grad()
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()