PEFT
Safetensors
echoforest's picture
Upload naver-psy classifier (adapter, heads, calibration)
210f9af verified
Raw History Blame Contribute Delete
12.6 kB
"""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