File size: 7,181 Bytes
580cc4b | 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 | """JevLite: one encoder pass, a logit per candidate label.
The schema is data, not architecture. Every candidate label is written into the
input sequence behind a marker token; the head is a single Linear(d, 1) applied
at each marker position. Adding a question, a label, or a whole new workflow at
inference time requires no retraining and no new parameters.
Compare to a fixed-head classifier, which needs one nn.Linear per question and
can only ever answer the questions it was built with.
"""
from __future__ import annotations
import os
import sys
import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer
from td_data import add_markers
from typed_schema import TYPES
DEFAULT_ENCODER = "answerdotai/ModernBERT-base" # bidirectional, 8192 ctx, 150M
NEG = -1e4 # fp16-safe masking value
def grouped_softmax(logits, group, label_mask, n_groups):
"""Softmax within each question's own label set. -> probs [B, M]"""
probs = torch.zeros_like(logits)
for q in range(n_groups):
m = (group == q) & label_mask
if not m.any():
continue
masked = logits.masked_fill(~m, NEG)
p = torch.softmax(masked, dim=-1)
probs = probs + p * m
return probs
class JevLite(nn.Module):
def __init__(self, encoder_name=DEFAULT_ENCODER, tokenizer=None, dropout=0.1):
super().__init__()
self.encoder_name = encoder_name
self.tok = tokenizer or AutoTokenizer.from_pretrained(encoder_name)
_, self.qid, self.lid = add_markers(self.tok)
self.encoder = AutoModel.from_pretrained(encoder_name)
self.encoder.resize_token_embeddings(len(self.tok))
d = self.encoder.config.hidden_size
self.drop = nn.Dropout(dropout)
self.score = nn.Linear(d, 1)
# One temperature per primitive type. Jev's measured failure is that its
# types miscalibrate in opposite directions, so a single global T cannot
# fix both. These are frozen during training and fitted in 03_calibrate.py.
self.log_temp = nn.Parameter(torch.zeros(len(TYPES)), requires_grad=False)
def logits(self, input_ids, attention_mask, label_pos):
h = self.encoder(input_ids=input_ids,
attention_mask=attention_mask).last_hidden_state
d = h.size(-1)
picked = h.gather(1, label_pos.unsqueeze(-1).expand(-1, -1, d))
return self.score(self.drop(picked)).squeeze(-1) # [B, M]
def token_logits(self, input_ids, attention_mask):
"""Score head applied at every position. -> [B, L]
Linear(d, 1) is per-token, so scoring first and gathering label
positions afterwards is the same math as logits() - and it is what
lets the whole encoder + head live in one Core ML program while the
dynamic label gather stays on CPU.
"""
h = self.encoder(input_ids=input_ids,
attention_mask=attention_mask).last_hidden_state
return self.score(self.drop(h)).squeeze(-1) # [B, L]
def forward(self, batch, apply_temperature=False):
lg = self.logits(batch["input_ids"], batch["attention_mask"], batch["label_pos"])
if apply_temperature:
lg = lg / self.temp_per_label(batch)
n_groups = batch["qtype"].size(1)
probs = grouped_softmax(lg.float(), batch["group"], batch["label_mask"], n_groups)
return lg, probs
def temp_per_label(self, batch):
"""Map each label slot to its question's temperature. -> [B, M]"""
g = batch["group"].clamp(min=0)
t = batch["qtype"].clamp(min=0).gather(1, g) # type id per label
return self.log_temp.exp()[t]
def decision_loss(probs, batch, brier_weight=1.0):
"""Soft cross-entropy against the consensus distribution, plus Brier.
The dataset gives full annotator distributions, so training on the hard argmax
throws away the signal that makes confidence mean something. Brier is a proper
scoring rule and is what pushes probabilities toward honesty rather than
toward whichever label happens to win.
"""
p = probs.clamp_min(1e-8)
t = batch["target"]
m = batch["label_mask"].float()
n_q = batch["qmask"].sum().clamp(min=1)
ce = -(t * p.log() * m).sum() / n_q
brier = (((probs - t) ** 2) * m).sum() / n_q
return ce + brier_weight * brier, ce.detach(), brier.detach()
def argmax_per_question(probs, batch):
"""-> [B, Q] index of the winning label *within* each question, -1 if absent."""
B, Q = batch["qtype"].shape
out = torch.full((B, Q), -1, dtype=torch.long, device=probs.device)
conf = torch.zeros((B, Q), device=probs.device)
for q in range(Q):
m = (batch["group"] == q) & batch["label_mask"]
if not m.any():
continue
scores = probs.masked_fill(~m, -1.0)
best = scores.argmax(dim=-1)
first = torch.where(m.any(-1), m.float().argmax(dim=-1), torch.zeros_like(best))
has = m.any(-1)
out[:, q] = torch.where(has, best - first, torch.full_like(best, -1))
conf[:, q] = torch.where(has, scores.max(dim=-1).values,
torch.zeros_like(conf[:, q]))
return out, conf
def require_ckpt(path):
"""Load a checkpoint, or say plainly which earlier step never finished."""
if not os.path.exists(path):
sys.exit(
f"'{path}' does not exist - 02_train.py has not completed. "
"Scroll up and fix the training step; every step after it depends "
"on the checkpoint it writes.")
return torch.load(path, map_location="cpu")
@torch.no_grad()
def predict_distributions(model, rows, max_len, device, batch_size=8,
slots="lead"):
"""Per-question probability distributions with label names attached.
-> [{qname: {"labels": [...], "probs": [...]}}] in the same order as `rows`.
argmax_per_question is enough for accuracy; an ensemble needs the whole
distribution keyed by label so it can be combined with another model that
orders its classes differently.
"""
from td_data import TypedDecisions, collate
from torch.utils.data import DataLoader
ds = TypedDecisions(rows, model.tok, max_len, model.qid, model.lid,
slots=slots)
dl = DataLoader(ds, batch_size=batch_size,
collate_fn=lambda b: collate(b, model.tok.pad_token_id))
model.eval()
out, at = [], 0
for b in dl:
n = b["input_ids"].size(0)
b = {k: v.to(device) for k, v in b.items()}
_, probs = model(b, apply_temperature=True)
probs = probs.cpu()
for i in range(n):
enc = ds[at + i]
row, cursor = {}, 0
for gi, qname in enumerate(enc["qnames"]):
labels = enc["labels"][gi]
row[qname] = {"labels": labels,
"probs": probs[i, cursor:cursor + len(labels)].tolist()}
cursor += len(labels)
out.append(row)
at += n
return out
|