Spaces:
Running
Running
File size: 11,319 Bytes
037f340 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 | """Ship a trained checkpoint to the Hub, with a rollback point and gates.
python -m backend.src.deploy_model models/production_v2/final # dry run
python -m backend.src.deploy_model models/production_v2/final --confirm # ship it
Production reads one repo, VivDubs/eumora-emotion-classifier, from two places:
the Vercel /api/emotion route (user text, via the HF inference router) and the
HF Space's /api/score (lyrics, loaded at container start). Pushing to that repo
therefore changes both, so this refuses to ship anything that fails the checks
this project learned the hard way:
- the checkpoint must not be collapsed. Training reports a representation
health check; a model that emits the same output for every input looks like
underfitting in the metrics and would be invisible in production.
- it must carry folded calibration, because the Space only disables the
legacy sarcasm hacks in predict.py for calibrated checkpoints. Shipping an
uncalibrated model would silently re-enable them.
- the label space must match the eight classes both callers map by name.
Before uploading it tags the current head as a rollback point, and afterwards it
restarts the Space so /api/score loads the new weights instead of the ones it
cached at startup.
Needs HF_TOKEN with write access to the repo, read from the environment or .env.
Never pass the token on the command line.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
MODEL_REPO = os.environ.get("HF_MODEL_REPO", "VivDubs/eumora-emotion-classifier")
SPACE_REPO = os.environ.get("HF_SPACE_REPO", "VivDubs/eumora-api")
EXPECTED_LABELS = ["sadness", "joy", "love", "anger", "fear", "surprise", "neutral", "sarcasm"]
# Only the files a stock sequence classifier needs. Keeps training_results.json
# and anything else local out of the public-facing model card area.
SHIP_FILES = {
"config.json", "model.safetensors", "tokenizer.json", "tokenizer_config.json",
"special_tokens_map.json", "spm.model", "added_tokens.json",
}
def load_env() -> None:
for candidate in (Path(__file__).resolve().parents[2] / ".env",
Path(__file__).resolve().parents[1] / ".env"):
if candidate.exists():
for line in candidate.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip("\"'"))
def check_checkpoint(path: Path) -> list:
"""Return a list of blocking problems; empty means shippable."""
problems = []
cfg_path = path / "config.json"
if not cfg_path.exists():
return [f"{cfg_path} not found"]
cfg = json.loads(cfg_path.read_text())
labels = [cfg.get("id2label", {}).get(str(i)) for i in range(len(cfg.get("id2label", {})))]
if labels != EXPECTED_LABELS:
problems.append(f"label space {labels} != {EXPECTED_LABELS}")
if not (cfg.get("calibration") or {}).get("folded"):
problems.append("calibration is not folded into the weights β the Space would "
"re-apply the legacy sarcasm hacks on top of it")
results = path / "training_results.json"
if not results.exists():
problems.append("training_results.json missing β cannot verify representation health")
else:
health = json.loads(results.read_text()).get("representation_health") or {}
if health.get("collapsed", True):
problems.append(f"representation health says collapsed or unknown: {health}")
if not (path / "model.safetensors").exists():
problems.append("model.safetensors missing")
return problems
def check_ranking(candidate_json: Path, live_json: Path) -> list:
"""Refuse a model that does not beat the live one on the product metric.
Classifier metrics do not predict this. The 20k improved checkpoint scored
68.5% in-domain macro-F1 and still tied the live model on ranking AUC
(0.779 vs 0.785, paired difference -0.006 [-0.120, +0.093]) while losing the
confidence signal the UI depends on. Ship on ranking, not on F1.
"""
import json as _json
problems = []
for path in (candidate_json, live_json):
if not path.exists():
problems.append(f"{path} not found β produce it with bench_production --ranking")
if problems:
return problems
cand = _json.loads(candidate_json.read_text()).get("weighted_auc")
live = _json.loads(live_json.read_text()).get("weighted_auc")
if cand is None or live is None:
return ["ranking results are missing weighted_auc"]
if cand <= live:
problems.append(f"candidate ranking AUC {cand:.3f} does not beat live {live:.3f} β "
f"deploying would not improve what listeners see")
return problems
def check_system(compare_json: Path) -> list:
"""Accept a full-system comparison (train_fusion compare) as evidence.
Since the fusion (FINDINGS section 9), lyric scores reach listeners only
through the stacked VAD match, so lyric-only ranking is no longer the right
test. `compare` runs the whole system with the candidate text model against
the live one on 4Q, PMEmo and gold and records whether its ship rule held.
"""
if not compare_json.exists():
return [f"{compare_json} not found β run `train_fusion lyrics` then `train_fusion compare`"]
report = json.loads(compare_json.read_text())
if not report.get("ship"):
return [f"system comparison says do not ship: {report}"]
return []
def main() -> int:
parser = argparse.ArgumentParser(description="Deploy a checkpoint to the production repo")
parser.add_argument("checkpoint")
parser.add_argument("--confirm", action="store_true", help="Actually upload (default: dry run)")
parser.add_argument("--no-restart", action="store_true", help="Do not restart the Space")
parser.add_argument("--ranking", type=str, default=None,
help="bench_production --ranking --local JSON for this checkpoint")
parser.add_argument("--live-ranking", type=str, default="results/ranking_live.json",
help="bench_production --ranking JSON for the live model")
parser.add_argument("--override-ranking", action="store_true",
help="Ship despite no ranking improvement (state why in the commit)")
parser.add_argument("--system", type=str, default=None,
help="results/fusion_compare_<tag>.json from train_fusion compare; replaces "
"--ranking as evidence and installs configs/fusion_<tag>.json with the model")
args = parser.parse_args()
load_env()
ckpt = Path(args.checkpoint)
print(f"checkpoint : {ckpt}")
print(f"model repo : {MODEL_REPO}")
print(f"space : {SPACE_REPO}\n")
problems = check_checkpoint(ckpt)
fusion_swap = None
if args.system:
problems += check_system(Path(args.system))
tag = Path(args.system).stem.replace("fusion_compare_", "")
fusion_swap = Path(__file__).resolve().parents[1] / "configs" / f"fusion_{tag}.json"
if not fusion_swap.exists():
problems.append(f"{fusion_swap} missing β the fusion must be refit on this model's lyric scores")
elif not args.override_ranking:
if not args.ranking:
problems.append("no ranking evidence β run `bench_production --ranking --local "
f"{ckpt} --json <out>` and pass it with --ranking")
else:
problems += check_ranking(Path(args.ranking), Path(args.live_ranking))
if problems:
print("REFUSING TO DEPLOY:")
for p in problems:
print(f" - {p}")
return 1
results = json.loads((ckpt / "training_results.json").read_text())
tm, probe = results.get("test_metrics", {}), results.get("probe", {})
print("checks passed")
print(f" test macro-F1 {tm.get('test_f1_macro')} probe overall {probe.get('probe_overall')}")
print(f" health {results.get('representation_health')}\n")
token = os.environ.get("HF_TOKEN") or os.environ.get("HF_API_TOKEN")
if not token:
print("No HF_TOKEN in the environment or .env. Add a token with *write* access to")
print(f"{MODEL_REPO} to .env as HF_TOKEN=... (do not paste it into chat), then re-run.")
return 1
from huggingface_hub import HfApi
api = HfApi(token=token)
who = api.whoami()
role = (who.get("auth", {}).get("accessToken", {}) or {}).get("role")
print(f"authenticated as {who.get('name')} (token role: {role})")
files = sorted(f.name for f in ckpt.iterdir() if f.name in SHIP_FILES)
tag = "pre-" + datetime.now().strftime("%Y%m%d-%H%M")
print(f"will tag current head as '{tag}' (rollback point)")
print(f"will upload: {', '.join(files)}")
print(f"will restart space: {not args.no_restart}")
if not args.confirm:
print("\ndry run β nothing changed. Re-run with --confirm to deploy.")
return 0
head = api.model_info(MODEL_REPO).sha
api.create_tag(MODEL_REPO, tag=tag, revision=head,
tag_message=f"State before deploying {ckpt.name}")
print(f"\ntagged {head[:10]} as {tag}")
commit = api.upload_folder(
folder_path=str(ckpt), repo_id=MODEL_REPO, repo_type="model",
allow_patterns=files,
commit_message=f"Deploy calibrated checkpoint ({ckpt.parent.name})",
)
print(f"uploaded: {commit.commit_url if hasattr(commit, 'commit_url') else commit}")
# Verify what the repo now serves, rather than trusting the upload.
from huggingface_hub import hf_hub_download
served = json.loads(Path(hf_hub_download(MODEL_REPO, "config.json", token=token,
force_download=True)).read_text())
if not (served.get("calibration") or {}).get("folded"):
print("WARNING: served config does not show folded calibration β investigate")
return 1
print("verified: served config carries folded calibration")
if fusion_swap is not None:
live_cfg = fusion_swap.parent / "fusion.json"
backup = fusion_swap.parent / f"fusion.before-{tag}.json"
if live_cfg.exists():
backup.write_text(live_cfg.read_text())
live_cfg.write_text(fusion_swap.read_text())
print(f"installed {fusion_swap.name} as configs/fusion.json (previous kept as {backup.name}).")
print("Commit and push it now: the push rebuilds the Space, which then loads the new weights")
print("and the matching fusion together. Until then the Space runs the new text model with the")
print("old fusion, so do not restart it separately.")
elif not args.no_restart:
api.restart_space(SPACE_REPO)
print(f"restarted {SPACE_REPO} so /api/score loads the new weights")
print(f"\nrollback: python -c \"from huggingface_hub import HfApi; "
f"HfApi().upload_folder(...)\" from tag '{tag}', or in the Hub UI revert to {head[:10]}")
return 0
if __name__ == "__main__":
sys.exit(main())
|