| """Convert a raw Stoicheia training checkpoint ({model, opt, step, cfg}) into a clean, |
| HF-Hub-ready model repo (config.json + model.safetensors + modeling/configuration/ |
| processing .py files + model card), dropping optimizer state and training-only config. |
| |
| python convert_checkpoint_to_hf.py --kind backbone \ |
| --ckpt $STOICHEIA_DATA/runs/stoicheia_doc_clean/best.pt \ |
| --out hf_release/Stoicheia-doc_clean \ |
| --name "Stoicheia (documentary-clean)" \ |
| --metrics-json $STOICHEIA_DATA/runs/stoicheia_doc_clean/eval.jsonl |
| |
| python convert_checkpoint_to_hf.py --kind tagger_parser \ |
| --ckpt $STOICHEIA_DATA/parser_data/runs/joint_docclean_f3_s0/best.pt \ |
| --out hf_release/Stoicheia-tagger-parser \ |
| --vocab-json $STOICHEIA_DATA/parser_data/runs/joint_docclean_f3_s0/vocab.json \ |
| --deprel-vocab $STOICHEIA_DATA/parser_data/runs/joint_docclean_f3_s0/deprel_vocab.json |
| """ |
| from __future__ import annotations |
|
|
| import argparse |
| import json |
| import os |
| import shutil |
| from pathlib import Path |
|
|
| import torch |
| from safetensors.torch import save_file |
|
|
| |
| |
| |
| _ARCH_FIELDS = [ |
| "n_alpha", "mask_id", "blank_id", "pad_id", "n_char_ids", |
| "n_boundary", "n_dia", "n_punct", "d_model", "n_heads", "depth", |
| "char_window", "attn_impl", "qk_norm", |
| ] |
| _ARCH_DEFAULTS = dict( |
| n_alpha=24, mask_id=24, blank_id=25, pad_id=26, n_char_ids=27, |
| n_boundary=4, n_dia=49, n_punct=7, n_heads=16, char_window=256, |
| attn_impl="sdpa", qk_norm=True, |
| ) |
|
|
| _THIS_DIR = Path(__file__).resolve().parent.parent / "hf_release" |
|
|
|
|
| def _arch_config_from_raw_cfg(raw_cfg: dict) -> dict: |
| """raw_cfg is the full training config embedded in a checkpoint (mixes architecture |
| + training-only keys, sometimes missing fields that have a fixed architectural |
| default). Extract just the CharBertConfig-shape subset.""" |
| out = dict(_ARCH_DEFAULTS) |
| for k in _ARCH_FIELDS: |
| if k in raw_cfg: |
| out[k] = raw_cfg[k] |
| |
| if "d_model" in raw_cfg: |
| out["d_model"] = raw_cfg["d_model"] |
| out.setdefault("n_heads", raw_cfg["d_model"] // 64) |
| out["n_heads"] = raw_cfg.get("n_heads", raw_cfg["d_model"] // 64) |
| if "attn" in raw_cfg and "attn_impl" not in raw_cfg: |
| out["attn_impl"] = "sdpa" |
| return out |
|
|
|
|
| def _joint_config_from_ckpt(sd: dict) -> dict: |
| """Build a CharBertJointConfig-shape dict for a JointModel checkpoint (tagger.* + |
| biaffine.* state dict), reading every label-space size straight off real tensor shapes |
| rather than trusting the training cfg dicts (which record loss weights, not architecture).""" |
| state_dict = sd["model"] |
| pretrain_cfg = sd.get("pretrain_cfg", {}) |
| tcfg = sd.get("tcfg", {}) |
| pcfg = sd.get("pcfg", {}) |
|
|
| d_model = state_dict["tagger.encoder.e_char.weight"].shape[1] |
| depth = 1 + max( |
| int(k.split(".")[3]) for k in state_dict if k.startswith("tagger.encoder.blocks.")) |
| n_xpos_classes = [] |
| p = 0 |
| while f"tagger.xpos_heads.{p}.weight" in state_dict: |
| n_xpos_classes.append(state_dict[f"tagger.xpos_heads.{p}.weight"].shape[0]) |
| p += 1 |
|
|
| use_cap = "tagger.encoder.cap_emb.weight" in state_dict |
| use_flat = "tagger.head_flat.weight" in state_dict |
| n_script = state_dict["tagger.head_script.weight"].shape[0] |
| n_upos = state_dict["tagger.head_upos.weight"].shape[0] |
| n_flat_tags = state_dict["tagger.head_flat.weight"].shape[0] if use_flat else 0 |
| n_labels = state_dict["biaffine.rel_biaf.W"].shape[0] |
| d_arc = state_dict["biaffine.arc_dep.lin.weight"].shape[0] |
| d_rel = state_dict["biaffine.rel_dep.lin.weight"].shape[0] |
|
|
| config = dict( |
| n_alpha=24, mask_id=24, blank_id=25, pad_id=26, n_char_ids=27, |
| n_boundary=4, n_dia=49, n_punct=7, |
| d_model=d_model, n_heads=pretrain_cfg.get("d_model", d_model) // 64, depth=depth, |
| char_window=pretrain_cfg.get("char_window", 256), attn_impl="sdpa", |
| qk_norm=pretrain_cfg.get("qk_norm", True), |
| use_cap=use_cap, |
| pool=tcfg.get("pool", "mean"), head_dropout=tcfg.get("head_dropout", 0.1), |
| scalar_mix=tcfg.get("scalar_mix", False), xpos_len=len(n_xpos_classes), |
| n_xpos_classes=n_xpos_classes, n_script=n_script, n_upos=n_upos, |
| use_flat=use_flat, n_flat_tags=n_flat_tags, |
| d_arc=d_arc, d_rel=d_rel, n_labels=n_labels, |
| parse_dropout=pcfg.get("dropout", 0.33), |
| max_chars=sd.get("T", 2048), max_words=sd.get("W", 384), |
| ) |
| return config |
|
|
|
|
| def _meter_config_from_ckpt(sd: dict) -> dict: |
| """Build a CharBertMeterConfig-shape dict for a MeterModel checkpoint |
| (encoder.* + head_mac/head_scan/mix_w state dict), reading architecture-defining |
| fields off real tensor shapes / key presence rather than trusting mcfg (which |
| also carries loss-only fields like head_dropout/class weights).""" |
| state_dict = sd["model"] |
| pretrain_cfg = sd.get("pretrain_cfg", {}) |
|
|
| d_model = state_dict["encoder.e_char.weight"].shape[1] |
| depth = 1 + max( |
| int(k.split(".")[2]) for k in state_dict if k.startswith("encoder.blocks.")) |
| use_cap = "encoder.cap_emb.weight" in state_dict |
| scalar_mix = "mix_w" in state_dict |
|
|
| return dict( |
| n_alpha=24, mask_id=24, blank_id=25, pad_id=26, n_char_ids=27, |
| n_boundary=4, n_dia=49, n_punct=7, |
| d_model=d_model, n_heads=pretrain_cfg.get("d_model", d_model) // 64, depth=depth, |
| char_window=pretrain_cfg.get("char_window", 256), attn_impl="sdpa", |
| qk_norm=pretrain_cfg.get("qk_norm", True), |
| use_cap=use_cap, scalar_mix=scalar_mix, |
| ) |
|
|
|
|
| def convert(ckpt_path: str, out_dir: str, kind: str, vocab_json: str | None = None, |
| deprel_vocab: str | None = None, name: str | None = None): |
| out = Path(out_dir) |
| out.mkdir(parents=True, exist_ok=True) |
|
|
| sd = torch.load(ckpt_path, map_location="cpu", weights_only=False) |
| state_dict = sd["model"] |
|
|
| if kind == "tagger_parser": |
| assert vocab_json and deprel_vocab, "tagger_parser conversion needs --vocab-json and --deprel-vocab" |
| assert "tagger.encoder.e_char.weight" in state_dict and "biaffine.root" in state_dict, \ |
| f"unexpected checkpoint shape for kind=tagger_parser: {sorted(state_dict.keys())[:5]}..." |
|
|
| config = _joint_config_from_ckpt(sd) |
| config["model_type"] = "char_bert_joint" |
| config["auto_map"] = { |
| "AutoConfig": "configuration_char_bert_joint.CharBertJointConfig", |
| "AutoModel": "modeling_char_bert_joint.CharBertForTaggingAndParsing", |
| } |
| (out / "config.json").write_text(json.dumps(config, indent=2)) |
|
|
| training_meta = { |
| "cfg": sd.get("cfg"), "tcfg": sd.get("tcfg"), "pcfg": sd.get("pcfg"), |
| "pretrain_cfg": sd.get("pretrain_cfg"), "epoch": sd.get("epoch"), "dev": sd.get("dev"), |
| "_source_checkpoint": Path(ckpt_path).name, |
| } |
| (out / "training_metadata.json").write_text(json.dumps(_scrub_paths(training_meta), indent=2, default=str)) |
|
|
| clean_sd = {k: v.contiguous() for k, v in state_dict.items()} |
| save_file(clean_sd, str(out / "model.safetensors")) |
|
|
| for fname in ("configuration_char_bert_joint.py", "modeling_char_bert_joint.py", |
| "processing_char_bert_joint.py"): |
| shutil.copy(_THIS_DIR / fname, out / fname) |
| shutil.copy(vocab_json, out / "vocab.json") |
| shutil.copy(deprel_vocab, out / "deprel_vocab.json") |
|
|
| print(f"converted {ckpt_path} -> {out} (kind={kind}, d_model={config['d_model']}, " |
| f"depth={config['depth']}, n_labels={config['n_labels']}, " |
| f"params={sum(v.numel() for v in clean_sd.values()):,})") |
| return out |
|
|
| if kind == "meter": |
| assert "encoder.e_char.weight" in state_dict and "head_mac.weight" in state_dict, \ |
| f"unexpected checkpoint shape for kind=meter: {sorted(state_dict.keys())[:5]}..." |
|
|
| config = _meter_config_from_ckpt(sd) |
| config["model_type"] = "char_bert_meter" |
| config["auto_map"] = { |
| "AutoConfig": "configuration_char_bert_meter.CharBertMeterConfig", |
| "AutoModel": "modeling_char_bert_meter.CharBertMeterModel", |
| } |
| (out / "config.json").write_text(json.dumps(config, indent=2)) |
|
|
| training_meta = { |
| "cfg": sd.get("cfg"), "mcfg": sd.get("mcfg"), "pretrain_cfg": sd.get("pretrain_cfg"), |
| "epoch": sd.get("epoch"), "dev": sd.get("dev"), "T": sd.get("T"), |
| "_source_checkpoint": Path(ckpt_path).name, |
| } |
| (out / "training_metadata.json").write_text(json.dumps(_scrub_paths(training_meta), indent=2, default=str)) |
|
|
| |
| |
| |
| clean_sd = {k: v.contiguous() for k, v in state_dict.items() if k not in ("mac_w", "scan_w")} |
| save_file(clean_sd, str(out / "model.safetensors")) |
|
|
| for fname in ("configuration_char_bert_meter.py", "modeling_char_bert_meter.py", |
| "processing_char_bert_meter.py"): |
| shutil.copy(_THIS_DIR / fname, out / fname) |
|
|
| print(f"converted {ckpt_path} -> {out} (kind={kind}, d_model={config['d_model']}, " |
| f"depth={config['depth']}, params={sum(v.numel() for v in clean_sd.values()):,})") |
| return out |
|
|
| assert "model" in sd and "cfg" in sd, f"unexpected checkpoint shape: {list(sd.keys())}" |
| raw_cfg = sd["cfg"] |
|
|
| arch_cfg = _arch_config_from_raw_cfg(raw_cfg) |
|
|
| |
| if "e_char.weight" in state_dict and "head_char.weight" in state_dict: |
| assert state_dict["e_char.weight"].data_ptr() != state_dict["head_char.weight"].data_ptr(), \ |
| "unexpected weight tying between e_char and head_char -- conversion assumes untied weights" |
|
|
| config = dict(arch_cfg) |
| config["model_type"] = "char_bert" |
| config["auto_map"] = { |
| "AutoConfig": "configuration_char_bert.CharBertConfig", |
| "AutoModel": "modeling_char_bert.CharBertModel", |
| } |
| (out / "config.json").write_text(json.dumps(config, indent=2)) |
|
|
| |
| training_meta = {k: v for k, v in raw_cfg.items() if k not in _ARCH_FIELDS} |
| training_meta["_source_checkpoint"] = Path(ckpt_path).name |
| training_meta["_source_step"] = sd.get("step") |
| (out / "training_metadata.json").write_text(json.dumps(_scrub_paths(training_meta), indent=2, default=str)) |
|
|
| |
| clean_sd = {k: v.contiguous() for k, v in state_dict.items()} |
| save_file(clean_sd, str(out / "model.safetensors")) |
|
|
| for fname in ("configuration_char_bert.py", "modeling_char_bert.py", "processing_char_bert.py"): |
| shutil.copy(_THIS_DIR / fname, out / fname) |
|
|
| print(f"converted {ckpt_path} -> {out} (kind={kind}, d_model={arch_cfg['d_model']}, " |
| f"depth={arch_cfg['depth']}, params={sum(v.numel() for v in clean_sd.values()):,})") |
| return out |
|
|
|
|
| def _scrub_paths(obj): |
| """Absolute cluster paths in a training cfg would identify the machine (and its owner), |
| so reduce every path-like value to its basename before it reaches the sidecar.""" |
| import re as _re |
| if isinstance(obj, dict): |
| return {k: _scrub_paths(v) for k, v in obj.items()} |
| if isinstance(obj, list): |
| return [_scrub_paths(v) for v in obj] |
| if isinstance(obj, str): |
| if ("/" in obj) and _re.search(r"^(/|\$|~)", obj): |
| obj = obj.rsplit("/", 1)[-1] |
| |
| |
| obj = obj.rsplit("/", 1)[-1] |
| return obj |
| return obj |
|
|
|
|
| def main(): |
| ap = argparse.ArgumentParser() |
| ap.add_argument("--kind", required=True, choices=["backbone", "restoration", "tagger_parser", "meter"]) |
| ap.add_argument("--ckpt", required=True) |
| ap.add_argument("--out", required=True) |
| ap.add_argument("--vocab-json", default=None) |
| ap.add_argument("--deprel-vocab", default=None) |
| ap.add_argument("--name", default=None) |
| a = ap.parse_args() |
| convert(os.path.expandvars(a.ckpt), a.out, a.kind, |
| vocab_json=os.path.expandvars(a.vocab_json) if a.vocab_json else None, |
| deprel_vocab=os.path.expandvars(a.deprel_vocab) if a.deprel_vocab else None, |
| name=a.name) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|