JEV / code /scripts /export.py
cloudyu's picture
v0.8.0 dual-head bundle: pristine Qwen3.5-9B backbone + lm_head (bit-identical, HumanEval 70.7% = base) + unmerged LoRA decision adapter + 24-slot head; card: one backbone two heads
448ef61 verified
Raw
History Blame Contribute Delete
5.62 kB
#!/usr/bin/env python3
"""Export a serving bundle (DESIGN §9): merged text-only backbone (bf16 safetensors, HF layout of
Qwen3_5ForCausalLM without lm_head) + head.safetensors + judge_config.json + calibration.json + tokenizer.
python3 scripts/export.py --checkpoint checkpoints/s2_seed42/best --calibration checkpoints/s2_seed42/best/calibration.json \
--out exports/jev-judge-qwen35-9b --name jev-judge-qwen35-9b --version 0.7.0
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import sys
import torch
from safetensors.torch import save_file
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "src"))
from jev_judge import __version__ # noqa: E402
from jev_judge.calibration import load as load_calibration # noqa: E402
from jev_judge.checkpointing import load_checkpoint # noqa: E402
def save_sharded(state: dict[str, torch.Tensor], out_dir: str, max_shard_bytes: int = 4 * 1024**3) -> None:
shards: list[dict[str, torch.Tensor]] = [{}]
sizes = [0]
for k, v in state.items():
nbytes = v.numel() * v.element_size()
if sizes[-1] + nbytes > max_shard_bytes and shards[-1]:
shards.append({}); sizes.append(0)
shards[-1][k] = v
sizes[-1] += nbytes
n = len(shards)
weight_map = {}
for i, sh in enumerate(shards):
name = f"model-{i+1:05d}-of-{n:05d}.safetensors"
save_file({k: t.contiguous() for k, t in sh.items()}, os.path.join(out_dir, name), metadata={"format": "pt"})
weight_map.update({k: name for k in sh})
with open(os.path.join(out_dir, "model.safetensors.index.json"), "w") as f:
json.dump({"metadata": {"total_size": sum(sizes)}, "weight_map": weight_map}, f, indent=2)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--checkpoint", required=True)
ap.add_argument("--calibration", required=True, help="calibration.json (fail-fast at serve time if missing)")
ap.add_argument("--out", required=True)
ap.add_argument("--name", default="jev-judge")
ap.add_argument("--version", default=__version__)
ap.add_argument("--include-lm-head", action="store_true",
help="also ship the base model's lm_head.weight so the bundle can do ordinary AR generation")
ap.add_argument("--mode", choices=["merged", "unmerged"], default="unmerged",
help="merged: LoRA folded into the shipped backbone (decision-only bundle); "
"unmerged: ship the *pristine* base backbone + adapter/ (dual-mode: AR generation is exactly the base model)")
args = ap.parse_args()
table = load_calibration(args.calibration) # validates schema
judge, cfg = load_checkpoint(args.checkpoint, device="cuda")
lm = judge.lm
os.makedirs(args.out, exist_ok=True)
if args.mode == "unmerged":
if not hasattr(lm, "peft_config"):
raise SystemExit("checkpoint has no LoRA adapter; use --mode merged")
lm.save_pretrained(os.path.join(args.out, "adapter")) # peft adapter (fp32 master weights)
lm = lm.unload() # drop adapter layers -> pristine base weights
args.include_lm_head = True # dual-mode bundles always ship the vocab head
elif hasattr(lm, "merge_and_unload"):
lm = lm.merge_and_unload()
lm.lm_head = None
state = {k: v.detach().cpu() for k, v in lm.state_dict().items() if not k.startswith("lm_head")} # dtypes as loaded (bf16 / fp32)
if args.include_lm_head:
# the judge dropped lm_head at load time; take the pristine rows from the base checkpoint
import glob
from safetensors import safe_open
found = False
for shard in sorted(glob.glob(os.path.join(cfg["base_model_path"], "*.safetensors"))):
with safe_open(shard, framework="pt", device="cpu") as f:
if "lm_head.weight" in f.keys():
state["lm_head.weight"] = f.get_tensor("lm_head.weight").to(torch.bfloat16)
found = True
break
if not found:
raise SystemExit("lm_head.weight not found in base checkpoint")
save_sharded(state, args.out)
# text-only config in Qwen3_5ForCausalLM layout
tcfg = lm.config.to_dict()
tcfg["architectures"] = ["Qwen3_5ForCausalLM"]
with open(os.path.join(args.out, "config.json"), "w") as f:
json.dump(tcfg, f, indent=2)
save_file({k: v.detach().to(torch.float32).cpu().contiguous() for k, v in judge.head.state_dict().items()},
os.path.join(args.out, "head.safetensors"))
jc = judge.judge_config({"model_name": args.name, "model_version": args.version, "lm_head_included": bool(args.include_lm_head),
"weights_mode": args.mode, "adapter_subfolder": "adapter" if args.mode == "unmerged" else None,
"lora": cfg.get("lora"), "trained_stage": cfg.get("stage"),
"source_checkpoint": os.path.abspath(args.checkpoint), "softcap": judge.head.softcap})
with open(os.path.join(args.out, "judge_config.json"), "w") as f:
json.dump(jc, f, indent=2)
shutil.copy(args.calibration, os.path.join(args.out, "calibration.json"))
judge.tokenizer.save_pretrained(args.out)
total = sum(v.numel() * v.element_size() for v in state.values())
print(f"exported [{args.mode}] {len(state)} tensors ({total/1e9:.1f} GB) + head + calibration + tokenizer"
f"{' + adapter/' if args.mode == 'unmerged' else ''} -> {args.out}")
if __name__ == "__main__":
main()