File size: 9,644 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 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 | """Load LocalLLaMA/typed-decisions and turn it into label-position tensors.
One example = one state + every question about it. The encoder sees all of them
in a single sequence; each candidate label gets a marker token whose hidden state
becomes that label's logit.
"""
from __future__ import annotations
import hashlib
import json
import random
import torch
from torch.utils.data import Dataset
from typed_schema import iter_labels, state_to_text, label_text, TYPE_ID
DATASET = "LocalLLaMA/typed-decisions"
Q_TOKEN, L_TOKEN = "<<q>>", "<<l>>"
MAX_Q = 8
# slots inserts fixed, never-gathered `<<l>>` blocks so gathered markers stop
# seeing position-dependent left context (BASE_MODEL_POSITION_AUDIT.md):
# "lead" - one dummy block before each question's first real label
# "pair" - one dummy block before EVERY label: each gathered marker has the
# identical `<<l>> SLOT_TEXT` predecessor
# "none" - original flat layout (first marker follows instruction text)
LEAD_SLOT_TEXT = "-: options"
def _parse(r):
state = r["state"]
return {
"id": r["id"],
"workflow": r["workflow"],
"state": json.loads(state) if state.lstrip()[:1] in "{[" else state,
"questions": json.loads(r["questions"]),
"gold": json.loads(r["gold"]) if r.get("gold") else {},
}
def _load_http(split, config, limit):
"""Fallback for environments without the `datasets` package (and for Colab
cold starts, where the rows endpoint is faster than a parquet download)."""
import time, urllib.error, urllib.parse, urllib.request
def get(url, tries=5):
# The anonymous rows endpoint rate-limits, and it does so exactly when
# you are iterating quickly. Back off rather than dying mid-download.
for i in range(tries):
try:
with urllib.request.urlopen(url, timeout=60) as f:
return json.load(f)
except urllib.error.HTTPError as e:
if e.code not in (429, 502, 503, 504) or i == tries - 1:
raise
wait = 2 ** i * 5
print(f" HTTP {e.code} from the dataset server, retrying in {wait}s "
f"({i+1}/{tries-1})")
time.sleep(wait)
rows, offset = [], 0
while limit is None or len(rows) < limit:
n = 100 if limit is None else min(100, limit - len(rows))
q = urllib.parse.urlencode({"dataset": DATASET, "config": config,
"split": split, "offset": offset, "length": n})
page = get(f"https://datasets-server.huggingface.co/rows?{q}")
got = [_parse(x["row"]) for x in page["rows"]]
rows += got
offset += len(got)
if not got or offset >= page.get("num_rows_total", offset):
break
return rows[:limit] if limit else rows
def load_split(split: str, config: str = "all", limit: int | None = None):
try:
from datasets import load_dataset
except ImportError:
return _load_http(split, config, limit)
ds = load_dataset(DATASET, config, split=split)
rows = []
for r in ds:
rows.append(_parse(r))
if limit and len(rows) >= limit:
break
return rows
def add_markers(tok):
"""Register the two marker tokens. Caller must resize the model embeddings."""
n = tok.add_tokens([Q_TOKEN, L_TOKEN], special_tokens=True)
return n, tok.convert_tokens_to_ids(Q_TOKEN), tok.convert_tokens_to_ids(L_TOKEN)
def encode(row, tok, max_len, qid, lid, with_gold=True, slots="lead",
rng=None):
"""-> dict of python lists. Labels are laid out question by question.
slots: "lead" emits a fixed lead `<<l>>` slot ahead of each question's
labels; "pair" emits one before EVERY label; "none" is the original flat
layout. Dummy slots are never gathered into label_pos.
rng: when given, shuffle each question's label emission order (all types
except `score`, whose order is semantic). Gold follows by label name, so
this is safe augmentation for training only - pass None at inference."""
if slots not in ("none", "lead", "pair"):
raise ValueError(f"slots={slots!r} - use 'none', 'lead' or 'pair'")
qnames = sorted(row["questions"])[:MAX_Q]
slot = [lid] + tok.encode(LEAD_SLOT_TEXT, add_special_tokens=False)
tail, label_pos, group, target, qtypes, labels = [], [], [], [], [], []
for gi, qname in enumerate(qnames):
q = row["questions"][qname]
qtypes.append(TYPE_ID[q["type"]])
tail += [qid] + tok.encode(q["instructions"], add_special_tokens=False)
gold = row.get("gold", {}).get(qname, {}) if with_gold else {}
probs = gold.get("probabilities", {})
labs = iter_labels(q)
if rng is not None and q["type"] != "score":
labs = list(labs)
rng.shuffle(labs)
labels.append([lab for lab, _ in labs])
if slots == "lead":
tail += slot
for lab, desc in labs:
if slots == "pair":
tail += slot
label_pos.append(len(tail)) # index of the <<l>> marker
group.append(gi)
target.append(float(probs.get(lab, 0.0)))
tail += [lid] + tok.encode(label_text(lab, desc), add_special_tokens=False)
if slots == "pair":
tail += slot # trailing dummy: uniform right neighbour too
# The questions are the part we must never truncate; the state absorbs the cut.
budget = max_len - len(tail) - 2
if budget < 32:
raise ValueError(f"max_len={max_len} too small for {len(tail)} question tokens")
head = [tok.cls_token_id] + tok.encode(
state_to_text(row["state"]), add_special_tokens=False)[:budget]
off = len(head)
ids = head + tail + [tok.sep_token_id]
return {
"input_ids": ids,
"label_pos": [p + off for p in label_pos],
"group": group,
"target": target,
"qtype": qtypes,
"qnames": qnames,
# .get chain: rows built for inference carry no gold at all, and
# predict_distributions used to die on them with a bare KeyError.
"gold_label": [row.get("gold", {}).get(q, {}).get("label") for q in qnames],
"labels": labels,
}
class TypedDecisions(Dataset):
def __init__(self, rows, tok, max_len, qid, lid, permute=False,
slots="lead"):
self.rows, self.tok, self.max_len = rows, tok, max_len
self.qid, self.lid = qid, lid
self.permute, self.slots = permute, slots
self.resample(0)
def resample(self, seed):
"""Re-encode every row. With permute=True each call draws a fresh
label order per question (training augmentation; gold follows by
name, so only the sequence position moves)."""
rng = random.Random(seed) if self.permute else None
self.enc = [encode(r, self.tok, self.max_len, self.qid, self.lid,
rng=rng, slots=self.slots)
for r in self.rows]
def __len__(self):
return len(self.enc)
def __getitem__(self, i):
return self.enc[i]
def collate(batch, pad_id):
B = len(batch)
L = max(len(b["input_ids"]) for b in batch)
M = max(len(b["label_pos"]) for b in batch)
Q = max(len(b["qtype"]) for b in batch)
ids = torch.full((B, L), pad_id, dtype=torch.long)
att = torch.zeros((B, L), dtype=torch.long)
pos = torch.zeros((B, M), dtype=torch.long)
grp = torch.full((B, M), -1, dtype=torch.long)
tgt = torch.zeros((B, M), dtype=torch.float)
lmask = torch.zeros((B, M), dtype=torch.bool)
qtype = torch.full((B, Q), -1, dtype=torch.long)
qmask = torch.zeros((B, Q), dtype=torch.bool)
for i, b in enumerate(batch):
n, m, q = len(b["input_ids"]), len(b["label_pos"]), len(b["qtype"])
ids[i, :n] = torch.tensor(b["input_ids"])
att[i, :n] = 1
pos[i, :m] = torch.tensor(b["label_pos"])
grp[i, :m] = torch.tensor(b["group"])
tgt[i, :m] = torch.tensor(b["target"])
lmask[i, :m] = True
qtype[i, :q] = torch.tensor(b["qtype"])
qmask[i, :q] = True
return {"input_ids": ids, "attention_mask": att, "label_pos": pos,
"group": grp, "target": tgt, "label_mask": lmask,
"qtype": qtype, "qmask": qmask}
def val_split(rows, frac=0.15):
"""Deterministic train/val carve-out of the 1200-case train split.
Hash-based on id so the same cases stay in validation when you add data or
rerun on another machine - temperature fitted on a moving val set is not a
calibration, it is a lottery.
"""
def h(r):
return int(hashlib.sha1(r["id"].encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
return [r for r in rows if h(r) >= frac], [r for r in rows if h(r) < frac]
def pad_cat(tensors, pad_value=0):
"""Concatenate [B, M] tensors whose M differs, padding to the widest.
Batches are collated independently, so their label dimension is only as
wide as their own widest example. A plain torch.cat across batches works
right up until two batches contain different workflows - which is why this
only shows up on the full dataset and never on a single-workflow slice.
"""
width = max(t.size(1) for t in tensors)
out = []
for t in tensors:
if t.size(1) < width:
pad = torch.full((t.size(0), width - t.size(1)), pad_value,
dtype=t.dtype, device=t.device)
t = torch.cat([t, pad], dim=1)
out.append(t)
return torch.cat(out, dim=0)
|