PEFT
Safetensors
File size: 8,577 Bytes
210f9af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Package a trained run for the private HF repo and (only on request) push it (classifier-plan §3.5).

Run on this host: ``python -m training.export_hf RUN_DIR --out DIR [--metrics F ...] [--push --yes]``.

The package holds: ``adapter/`` (LoRA; ``base_model_name_or_path`` rewritten to the hub id), ``heads.safetensors``,
``taxonomy.json``, ``temperatures.json``, ``config.json`` (training settings without local paths), the metrics and
``REPORT.md`` files given, a generated model card ``README.md`` and ``code/`` (the training package and its
requirements). Never data, predictions or the ledger: the package is checked for ``*.jsonl`` and ``ledger*`` files
before anything is written or pushed. The push needs both ``--push`` and ``--yes``, reads ``HF_TOKEN`` from the
environment (never printed) and creates the repo private; the HF commit becomes ``model_revision`` (§6.3).
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
from pathlib import Path
from typing import Any, Callable

DEFAULT_REPO = "echoforest/naver-psy-classifier-gemma4-12b"
BASES = {"base": "google/gemma-4-12B", "it": "google/gemma-4-12B-it"}
PATH_KEYS = ("data", "model", "out", "resume_from")
FORBIDDEN_SUFFIXES = (".jsonl", ".jsonl.tmp", ".pt", ".tar", ".age")
HERE = Path(__file__).resolve().parent


def base_model_id(cfg: dict) -> str:
    if cfg.get("base_model"):
        return cfg["base_model"]
    return BASES[cfg.get("checkpoint", "base")]


def clean_config(cfg: dict) -> dict:
    """Training settings without local paths (keys named in PATH_KEYS or absolute-path string values)."""
    out = {}
    for k, v in cfg.items():
        if k in PATH_KEYS or (isinstance(v, str) and (v.startswith("/") or v.startswith("~"))):
            continue
        out[k] = v
    out["base_model"] = base_model_id(cfg)
    return out


def forbidden(folder: Path) -> list[str]:
    """Files that must never be in a package: data, predictions, optimizer state, the ledger."""
    bad = []
    for p in folder.rglob("*"):
        name = p.name.lower()
        ledger = name.startswith("ledger") and not name.endswith(".py")  # the ledger file, not code/ledger.py
        if p.is_file() and (name.endswith(FORBIDDEN_SUFFIXES) or ledger or "predictions" in p.relative_to(folder).parts):
            bad.append(str(p.relative_to(folder)))
    return sorted(bad)


def model_card(cfg: dict, metrics: dict[str, Any], repo: str) -> str:
    lines = [
        "---", "license: apache-2.0", f"base_model: {base_model_id(cfg)}", "library_name: peft", "private: true", "---",
        "", f"# {repo.split('/')[-1]}", "",
        "LoRA adapter and per-field linear heads on the Gemma 4 12B text decoder for the "
        f"`{cfg.get('taxonomy_version') or 'nsb-v1'}` taxonomy of",
        "Naver 종목토론실 posts (naver-psy, docs/classifier-plan.md). Private; not for redistribution.", "",
        "- Agreement figures are agreement with the teacher labels, not accuracy against ground truth.",
        "- Input: `nsb-input-v1` render, segments joined with newlines, suffix `### LABELS`, one BOS.",
        "- Output: raw logits per field; calibrated with `temperatures.json`, then gated (training/taxonomy.py).",
        "- No training data, predictions or post text are in this repository.", "",
        "| Setting | Value |", "|---|---|",
    ]
    for k in ("arm", "checkpoint", "pool", "epochs", "max_len", "lora_r", "lora_alpha", "lr_lora", "lr_heads", "seed",
              "data_version", "data_manifest_sha256"):
        if k in cfg:
            lines.append(f"| {k} | `{cfg[k]}` |")
    if metrics:
        lines += ["", "## Metrics", ""]
        for name, value in metrics.items():
            lines += [f"### {name}", "", "```json", json.dumps(value, indent=1, ensure_ascii=False)[:4000], "```", ""]
    return "\n".join(lines) + "\n"


def copy_code(dst: Path) -> None:
    dst.mkdir(parents=True)
    for p in sorted(HERE.glob("*.py")) + sorted(HERE.glob("requirements*.txt")):
        shutil.copy(p, dst / p.name)
    runpod = HERE / "runpod"
    if runpod.is_dir():
        (dst / "runpod").mkdir()
        for p in sorted(runpod.iterdir()):
            if p.is_file() and not p.name.endswith(".pyc"):
                shutil.copy(p, dst / "runpod" / p.name)


def package(run: Path, out: Path, metrics: list[Path] = (), which: str = "best", repo: str = DEFAULT_REPO) -> Path:
    run, out = Path(run), Path(out)
    if out.exists():
        raise SystemExit(f"refusing to overwrite {out}")
    src = run / which
    cfg = json.loads((run / "config.json").read_text())
    for need in (src / "adapter" / "adapter_config.json", src / "adapter" / "adapter_model.safetensors",
                 src / "heads.safetensors", run / "taxonomy.json", run / "temperatures.json"):
        if not need.exists():
            raise SystemExit(f"missing {need}")
    tmp = out.with_name(out.name + ".tmp")
    shutil.rmtree(tmp, ignore_errors=True)
    (tmp / "adapter").mkdir(parents=True)
    shutil.copy(src / "adapter" / "adapter_model.safetensors", tmp / "adapter" / "adapter_model.safetensors")
    adapter_cfg = json.loads((src / "adapter" / "adapter_config.json").read_text())
    adapter_cfg["base_model_name_or_path"] = base_model_id(cfg)
    (tmp / "adapter" / "adapter_config.json").write_text(json.dumps(adapter_cfg, indent=2) + "\n")
    shutil.copy(src / "heads.safetensors", tmp / "heads.safetensors")
    shutil.copy(run / "taxonomy.json", tmp / "taxonomy.json")
    shutil.copy(run / "temperatures.json", tmp / "temperatures.json")
    settings = clean_config(cfg)
    state = src / "state.json"
    if state.exists():
        settings["checkpoint_step"] = json.loads(state.read_text()).get("step")
    (tmp / "config.json").write_text(json.dumps(settings, indent=1, ensure_ascii=False) + "\n")
    loaded = {}
    for m in [Path(x) for x in metrics]:
        if m.suffix == ".json":
            loaded[m.stem] = json.loads(m.read_text())
        shutil.copy(m, tmp / m.name)
    if (run / "REPORT.md").exists() and not (tmp / "REPORT.md").exists():
        shutil.copy(run / "REPORT.md", tmp / "REPORT.md")
    (tmp / "README.md").write_text(model_card(settings, loaded, repo))
    copy_code(tmp / "code")
    bad = forbidden(tmp)
    if bad:
        shutil.rmtree(tmp)
        raise SystemExit(f"refusing to package data/predictions/ledger files: {bad[:5]}")
    tmp.rename(out)
    return out


def push(folder: Path, repo: str = DEFAULT_REPO, api_factory: Callable[..., Any] | None = None) -> str:
    """Upload to the private repo; returns ``repo@<commit>`` (the ``model_revision``)."""
    folder = Path(folder)
    bad = forbidden(folder)
    if bad:
        raise SystemExit(f"refusing to push data/predictions/ledger files: {bad[:5]}")
    token = os.environ.get("HF_TOKEN")
    if not token:
        raise SystemExit("HF_TOKEN is not set")
    if api_factory is None:
        from huggingface_hub import HfApi as api_factory
    api = api_factory(token=token)
    api.create_repo(repo, private=True, exist_ok=True, repo_type="model")
    info = api.upload_folder(folder_path=str(folder), repo_id=repo, repo_type="model",
                             commit_message="Upload naver-psy classifier (adapter, heads, calibration)")
    oid = getattr(info, "oid", None) or getattr(info, "commit_id", None)
    if not oid or len(oid) != 40:
        raise SystemExit("upload returned no 40-hex commit id")
    return f"{repo}@{oid}"


def main(argv=None) -> int:
    p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    p.add_argument("run")
    p.add_argument("--out", required=True)
    p.add_argument("--which", default="best", choices=["best", "last"])
    p.add_argument("--metrics", nargs="*", default=[])
    p.add_argument("--repo", default=DEFAULT_REPO)
    p.add_argument("--push", action="store_true", help="Upload to the private HF repo (also needs --yes)")
    p.add_argument("--yes", action="store_true")
    args = p.parse_args(argv)
    if args.push and not args.yes:
        p.error("--push also needs --yes")
    out = package(Path(args.run), Path(args.out), [Path(m) for m in args.metrics], args.which, args.repo)
    print(json.dumps({"packaged": str(out), "files": sorted(str(x.relative_to(out)) for x in out.rglob("*")
                                                            if x.is_file() and "code" not in x.parts)}))
    if args.push:
        revision = push(out, args.repo)
        print(json.dumps({"model_revision": revision}))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())