PEFT
Safetensors
File size: 12,627 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
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
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
"""Gemma 4 text decoder with LoRA and one linear head per field (needs torch; runs on the pod and on node12).

Classifier-plan §1, §4. The checkpoints (``google/gemma-4-12B`` and ``-it``) are
``Gemma4UnifiedForConditionalGeneration`` (model_type ``gemma4_unified``): text decoder plus light image/audio
projections. Only the text decoder is loaded, as ``Gemma4UnifiedForCausalLM`` with the text config and a
``key_mapping`` that renames the checkpoint's ``model.language_model.*`` to ``model.*`` (without it every weight is
reported missing and the model would train from random weights; the yt sibling found this on 2026-10-18). Image and
audio weights are ignored, the tied LM head is dropped: ``HeadsModel`` keeps only ``lm.model`` (the final-norm
hidden states) and one ``Linear(hidden -> K_f)`` per taxonomy field.

Pooling (the §4.4 screen): ``last`` = the hidden state of each row's last real token (the ``### LABELS`` suffix);
``mean`` = the mean of final-norm states over the row's real tokens. Rows are right-padded with an attention mask.
Gemma needs its BOS token: inputs are built by ``training.formats.encode`` with ``bos_id(tok)``.

The loss (``step_loss``) is normalised per optimiser step: per field, the summed cross-entropy over unmasked rows
divided by that step's unmasked count, fields weighted by ``Taxonomy.weights()`` (direction 2.0). Splitting a step
into micro-batches therefore leaves its gradient unchanged. Optional per-option class weights
(``Taxonomy.class_weight_vectors()``; nsb-v2: pnl breakeven 3.0) scale each row's cross-entropy by the weight of its
gold option; the divisor stays the unweighted count, so the split invariance holds.
"""
from __future__ import annotations

import json
import os
import random
import shutil
from pathlib import Path

import torch
import torch.nn.functional as F
from torch import nn

LORA_TARGETS = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
# model_type -> text-only class and checkpoint key renames.
BACKBONES = {
    "gemma4_unified": dict(name="gemma4", cls="Gemma4UnifiedForCausalLM",
                           key_mapping={r"^model\.language_model\.": "model."}, bos=True),
    "gemma4_unified_text": dict(name="gemma4", cls="Gemma4UnifiedForCausalLM", key_mapping=None, bos=True),
}
HUB_IDS = {"base": "google/gemma-4-12B", "it": "google/gemma-4-12B-it"}
POOLS = ("last", "mean")


def backbone(path) -> dict:
    from transformers import AutoConfig

    model_type = AutoConfig.from_pretrained(path).model_type
    if model_type not in BACKBONES:
        raise ValueError(f"unsupported backbone {model_type!r}; known: {sorted(BACKBONES)}")
    return BACKBONES[model_type]


def text_config(path):
    from transformers import AutoConfig

    config = AutoConfig.from_pretrained(path)
    return getattr(config, "text_config", config)


def load_backbone(path, device=None, dtype=torch.bfloat16, attn: str = "sdpa"):
    """Text-only causal LM; refuses a checkpoint with missing text weights (they would be randomly initialised).

    Returns (lm, loading_info)."""
    import transformers

    spec = backbone(path)
    kw = {}
    if device is not None and torch.device(device).type == "cuda":
        kw["device_map"] = {"": str(device)}
    if spec["key_mapping"]:
        kw["key_mapping"] = spec["key_mapping"]
    cls = getattr(transformers, spec["cls"])
    lm, info = cls.from_pretrained(str(path), config=text_config(path), dtype=dtype, attn_implementation=attn,
                                   output_loading_info=True, **kw)
    missing = sorted(k for k in info["missing_keys"] if not k.startswith("lm_head"))
    if missing:
        raise RuntimeError(f"missing weights in {path}: {missing[:5]} ({len(missing)} total)")
    return lm, info


def load_tokenizer(path):
    from transformers import AutoTokenizer

    tok = AutoTokenizer.from_pretrained(str(path))
    tok.padding_side = "right"
    if tok.pad_token_id is None:
        tok.pad_token = tok.eos_token
    return tok


def tokenizer_fn(tok):
    """Plain ids: no added special tokens, and special-token strings in post text stay ordinary text."""
    def tokenize(text: str) -> list[int]:
        return tok(text, add_special_tokens=False, split_special_tokens=True)["input_ids"]
    return tokenize


def bos_id(tok) -> int | None:
    """Gemma degrades badly without BOS; ``encode`` prepends exactly one."""
    return tok.bos_token_id


def add_lora(module, r: int, alpha: int, dropout: float):
    from peft import LoraConfig, get_peft_model

    return get_peft_model(module, LoraConfig(r=r, lora_alpha=alpha, lora_dropout=dropout,
                                             target_modules=LORA_TARGETS, bias="none"))


def pad(rows: list[list[int]], pad_id: int, device):
    """Right-padded ids, attention mask and each row's last real index."""
    width = max(len(r) for r in rows)
    ids = torch.full((len(rows), width), pad_id, dtype=torch.long)
    mask = torch.zeros((len(rows), width), dtype=torch.long)
    for i, r in enumerate(rows):
        ids[i, :len(r)] = torch.tensor(r, dtype=torch.long)
        mask[i, :len(r)] = 1
    last = mask.sum(1) - 1
    return ids.to(device), mask.to(device), last.to(device)


class HeadsModel(nn.Module):
    """LoRA text decoder; pooled final-norm hidden state -> one linear head per field."""

    def __init__(self, backbone, hidden: int, sizes: list[int], pool: str = "last", dropout: float = 0.1):
        super().__init__()
        if pool not in POOLS:
            raise ValueError(f"pool must be one of {POOLS}")
        self.backbone = backbone
        self.pool = pool
        self.dropout = nn.Dropout(dropout)
        self.heads = nn.ModuleList([nn.Linear(hidden, k) for k in sizes])

    def pooled(self, ids, mask, last) -> torch.Tensor:
        h = self.backbone(input_ids=ids, attention_mask=mask, use_cache=False).last_hidden_state
        if self.pool == "last":
            return h[torch.arange(h.shape[0], device=h.device), last].float()
        m = mask.unsqueeze(-1).to(h.dtype)
        return ((h * m).sum(1) / m.sum(1).clamp_min(1)).float()

    def forward(self, ids, mask, last) -> list[torch.Tensor]:
        pooled = self.dropout(self.pooled(ids, mask, last))
        return [head(pooled) for head in self.heads]


def step_loss(logits: list[torch.Tensor], labels: torch.Tensor, counts: list[int], weights: list[float] | None = None,
              class_weights: list[torch.Tensor | None] | None = None):
    """Sum over fields of weight x (summed CE over this micro-batch's unmasked rows) / (step's unmasked count).

    ``labels`` is [rows, fields] with -1 for a masked target. Summing this over a step's micro-batches gives, per
    field, the mean over the step's unmasked rows, whatever the split. ``class_weights`` (per field a [K] tensor or
    None) multiplies each row's CE by its gold option's weight. Returns (loss, per-field CE sums)."""
    weights = weights or [1.0] * len(logits)
    class_weights = class_weights or [None] * len(logits)
    total = logits[0].sum() * 0.0
    per_field = []
    for i, lg in enumerate(logits):
        y = labels[:, i]
        keep = y >= 0
        if bool(keep.any()):
            cw = class_weights[i]
            ce = F.cross_entropy(lg[keep].float(), y[keep], reduction="sum",
                                 weight=None if cw is None else cw.to(device=lg.device, dtype=torch.float32))
            total = total + weights[i] * ce / counts[i]
            per_field.append(float(ce.detach()))
        else:
            per_field.append(0.0)
    return total, per_field


def build(cfg: dict, sizes: list[int], device, *, train: bool, adapter_dir=None, heads_path=None) -> HeadsModel:
    """HeadsModel from a base checkpoint dir (``cfg["model"]``); a saved adapter and heads when given."""
    device = torch.device(device)
    lm, _ = load_backbone(cfg["model"], device, dtype=getattr(torch, cfg.get("dtype", "bfloat16")),
                          attn=cfg.get("attn", "sdpa"))
    text = lm.model
    if train and cfg.get("gradient_checkpointing", True):
        text.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
    text.config.use_cache = False
    if adapter_dir:
        from peft import PeftModel

        text = PeftModel.from_pretrained(text, str(adapter_dir), is_trainable=train)
    else:
        text = add_lora(text, cfg["lora_r"], cfg["lora_alpha"], cfg["lora_dropout"])
    hidden = lm.config.hidden_size
    del lm  # drops the tied LM head; the text decoder lives on in ``text``
    model = HeadsModel(text, hidden, sizes, cfg.get("pool", "last"), cfg.get("head_dropout", 0.1))
    if heads_path:
        load_heads(model, heads_path)
    model = model.to(device)
    # from_pretrained returns modules in eval mode, and gradient checkpointing only runs in training mode.
    model.train(train)
    return model


# ---------------------------------------------------------------------- checkpoints (classifier-plan §3.3)
def heads_state(model: HeadsModel, fields: list[str]) -> dict[str, torch.Tensor]:
    sd = model.heads.state_dict()
    out = {}
    for i, f in enumerate(fields):
        out[f"{f}.weight"] = sd[f"{i}.weight"].detach().cpu().contiguous()
        out[f"{f}.bias"] = sd[f"{i}.bias"].detach().cpu().contiguous()
    return out


def save_heads(model: HeadsModel, fields: list[str], path: Path) -> None:
    from safetensors.torch import save_file

    save_file(heads_state(model, fields), str(path), metadata={"fields": json.dumps(fields), "pool": model.pool})


def load_heads(model: HeadsModel, path) -> list[str]:
    """Load ``heads.safetensors``; its field list and pooling must match the model."""
    from safetensors import safe_open
    from safetensors.torch import load_file

    with safe_open(str(path), framework="pt") as fh:
        meta = fh.metadata() or {}
    fields = json.loads(meta["fields"])
    if meta.get("pool", model.pool) != model.pool:
        raise ValueError(f"heads were trained with pool={meta.get('pool')}, model has pool={model.pool}")
    if len(fields) != len(model.heads):
        raise ValueError(f"{len(fields)} heads in {path}, model has {len(model.heads)}")
    tensors = load_file(str(path))
    sd = {}
    for i, f in enumerate(fields):
        sd[f"{i}.weight"] = tensors[f"{f}.weight"]
        sd[f"{i}.bias"] = tensors[f"{f}.bias"]
    model.heads.load_state_dict(sd)
    return fields


def rng_state() -> dict:
    out = {"python": random.getstate(), "torch": torch.get_rng_state()}
    if torch.cuda.is_available():
        out["cuda"] = torch.cuda.get_rng_state_all()
    return out


def set_rng_state(state: dict) -> None:
    random.setstate(state["python"])
    torch.set_rng_state(state["torch"])
    if "cuda" in state and torch.cuda.is_available():
        torch.cuda.set_rng_state_all(state["cuda"])


def save_checkpoint(model: HeadsModel, target: Path, fields: list[str], state: dict | None = None,
                    trainer: dict | None = None) -> None:
    """Atomic directory write: adapter/, heads.safetensors, state.json and (resume points) trainer.pt."""
    target = Path(target)
    tmp = target.with_name(target.name + ".tmp")
    shutil.rmtree(tmp, ignore_errors=True)
    tmp.mkdir(parents=True)
    model.backbone.save_pretrained(str(tmp / "adapter"))
    save_heads(model, fields, tmp / "heads.safetensors")
    if trainer is not None:
        torch.save(trainer, tmp / "trainer.pt")
    (tmp / "state.json").write_text(json.dumps(state or {}, indent=1))
    old = target.with_name(target.name + ".old")
    shutil.rmtree(old, ignore_errors=True)
    if target.exists():
        os.replace(target, old)
    os.replace(tmp, target)
    shutil.rmtree(old, ignore_errors=True)


@torch.no_grad()
def predict(model: HeadsModel, encoded: list[list[int]], pad_id: int, batches: list[list[int]], device,
            field_names: list[str]) -> dict[int, dict[str, list[float]]]:
    """Raw logits per field for each encoded row index (dropout off)."""
    device = torch.device(device)
    was_training = model.training
    model.eval()
    out = {}
    amp = dict(device_type=device.type, dtype=torch.bfloat16, enabled=device.type == "cuda")
    for b in batches:
        ids, mask, last = pad([encoded[i] for i in b], pad_id, device)
        with torch.autocast(**amp):
            logits = model(ids, mask, last)
        values = [lg.float().cpu().tolist() for lg in logits]
        for j, i in enumerate(b):
            out[i] = {f: values[k][j] for k, f in enumerate(field_names)}
    if was_training:
        model.train()
    return out