ruling 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ruling/corpus.py ADDED
@@ -0,0 +1,202 @@
1
+ """Openly licensed labeled datasets, rewritten as typed-question records.
2
+
3
+ Only sources whose licenses permit training and redistribution of derived
4
+ weights are used: WANLI (CC-BY-4.0), SNLI (CC-BY-SA-4.0), CLINC150
5
+ (CC-BY-3.0), BoolQ (CC-BY-SA-3.0), GoEmotions (Apache-2.0), and Civil Comments
6
+ (CC0-1.0). Every download is pinned to a Hugging Face commit. Where a dataset
7
+ records how many raters chose each answer, the rater fraction becomes a soft
8
+ `target`, which is the only ground truth for calibration that does not come
9
+ from a model.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections import defaultdict
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+ import pyarrow.parquet as pq
20
+ from huggingface_hub import hf_hub_download
21
+
22
+ from ruling.evals import Record
23
+
24
+ NLI_OPTIONS = {
25
+ "supported": "The evidence establishes the claim",
26
+ "insufficient": "The evidence does not establish either",
27
+ "contradicted": "The evidence establishes the opposite",
28
+ }
29
+ NLI_GOLD = {"entailment": "supported", "neutral": "insufficient", "contradiction": "contradicted"}
30
+ SNLI_LABELS = {0: "entailment", 1: "neutral", 2: "contradiction"}
31
+ NONE_OPTION = "none_of_these"
32
+ CLINC_OUT_OF_SCOPE = "oos"
33
+
34
+ SOURCES = {
35
+ "wanli": ("alisawuffles/WANLI", "61c95318fd71c55b6ba355d76253254615f387ec", {"train": ["train.jsonl"]}),
36
+ "snli": ("stanfordnlp/snli", "cdb5c3d5eed6ead6e5a341c8e56e669bb666725b",
37
+ {"train": ["plain_text/train-00000-of-00001.parquet"], "dev": ["plain_text/validation-00000-of-00001.parquet"]}),
38
+ "clinc": ("clinc/clinc_oos", "155b9c710419136e17307b80d0a13e68cd46b4ec",
39
+ {"train": ["plus/train-00000-of-00001.parquet"], "dev": ["plus/validation-00000-of-00001.parquet"]}),
40
+ "boolq": ("google/boolq", "35b264d03638db9f4ce671b711558bf7ff0f80d5",
41
+ {"train": ["data/train-00000-of-00001.parquet"], "dev": ["data/validation-00000-of-00001.parquet"]}),
42
+ "emotions": ("google-research-datasets/go_emotions", "add492243ff905527e67aeb8b80c082af02207c3",
43
+ {"train": ["raw/train-00000-of-00001.parquet"]}),
44
+ "civil": ("google/civil_comments", "f2970eb3a55777454c94069077cc8d9b5866312d",
45
+ {"train": ["data/train-00000-of-00002.parquet", "data/train-00001-of-00002.parquet"],
46
+ "dev": ["data/validation-00000-of-00001.parquet"]}),
47
+ }
48
+ """Pinned source files per split. `dev` uses validation splits only, so test splits stay untouched and no
49
+ evaluation set in `datasets/` shares rows with it; WANLI's test split and GoEmotions have no suitable dev split."""
50
+
51
+
52
+ def nli_record(family: str, premise: str, hypothesis: str, gold: str) -> Record:
53
+ verdict = NLI_GOLD[gold]
54
+ return Record(
55
+ family=family,
56
+ state=premise,
57
+ questions={
58
+ "verdict": {"type": "choice", "instructions": "Assess the claim using only the supplied evidence: " + hypothesis,
59
+ "criteria": NLI_OPTIONS},
60
+ "supported": {"type": "noul", "instructions": "The evidence establishes this claim: " + hypothesis},
61
+ },
62
+ labels={"verdict": verdict, "supported": verdict == "supported"},
63
+ )
64
+
65
+
66
+ def clinc_record(text: str, gold: str | None, intents: list[str], rng: np.random.Generator) -> Record:
67
+ """A routing question over a random subset of intents, with an explicit none option.
68
+
69
+ A quarter of in-scope rows drop the gold intent from the offered options, so
70
+ the right answer becomes `none_of_these`; that teaches abstention.
71
+ """
72
+ size = int(rng.integers(3, 13))
73
+ pool = [i for i in intents if i != gold]
74
+ offered = list(rng.choice(pool, size=size, replace=False))
75
+ label = NONE_OPTION
76
+ if gold is not None and rng.random() >= 0.25:
77
+ offered[int(rng.integers(size))] = gold
78
+ label = gold
79
+ criteria = {str(i): str(i).replace("_", " ") for i in offered}
80
+ criteria[NONE_OPTION] = "The request matches none of the other options"
81
+ return Record(
82
+ family="clinc",
83
+ state=text,
84
+ questions={"intent": {"type": "choice", "instructions": "Which request type does this user message belong to?",
85
+ "criteria": criteria}},
86
+ labels={"intent": label},
87
+ )
88
+
89
+
90
+ def boolq_record(passage: str, question: str, answer: bool) -> Record:
91
+ return Record(
92
+ family="boolq",
93
+ state=passage,
94
+ questions={"answer": {"type": "noul", "instructions": question[0].upper() + question[1:] + "?"}},
95
+ labels={"answer": bool(answer)},
96
+ )
97
+
98
+
99
+ def emotions_records(rows: list[dict], emotions: list[str], rng: np.random.Generator, questions_per_text: int) -> list[Record]:
100
+ by_text: dict[str, list[dict]] = defaultdict(list)
101
+ for row in rows:
102
+ by_text[row["id"]].append(row)
103
+ records = []
104
+ for raters in by_text.values():
105
+ fractions = {e: float(np.mean([r[e] for r in raters])) for e in emotions}
106
+ present = [e for e in emotions if fractions[e] > 0]
107
+ absent = [e for e in emotions if fractions[e] == 0]
108
+ chosen = present[:questions_per_text]
109
+ if len(chosen) < questions_per_text and absent:
110
+ chosen += list(rng.choice(absent, size=min(questions_per_text - len(chosen), len(absent)), replace=False))
111
+ questions = {e: {"type": "noul", "instructions": f"The text expresses {e.replace('_', ' ')}."} for e in chosen}
112
+ records.append(Record(
113
+ family="emotions",
114
+ state=raters[0]["text"],
115
+ questions=questions,
116
+ labels={e: fractions[e] >= 0.5 for e in chosen},
117
+ references={e: {"target": {"yes": fractions[e], "no": 1.0 - fractions[e]}} for e in chosen},
118
+ ))
119
+ return records
120
+
121
+
122
+ CIVIL_QUESTIONS = {
123
+ "toxic": ("toxicity", "The comment is rude, disrespectful, or unreasonable."),
124
+ "insult": ("insult", "The comment insults someone."),
125
+ "threat": ("threat", "The comment threatens someone."),
126
+ }
127
+
128
+
129
+ def civil_record(row: dict) -> Record:
130
+ return Record(
131
+ family="civil",
132
+ state=row["text"],
133
+ questions={qid: {"type": "noul", "instructions": text} for qid, (_, text) in CIVIL_QUESTIONS.items()},
134
+ labels={qid: float(row[column]) >= 0.5 for qid, (column, _) in CIVIL_QUESTIONS.items()},
135
+ references={qid: {"target": {"yes": float(row[column]), "no": 1.0 - float(row[column])}}
136
+ for qid, (column, _) in CIVIL_QUESTIONS.items()},
137
+ )
138
+
139
+
140
+ def _rows(name: str, split: str) -> list[dict]:
141
+ repo, revision, splits = SOURCES[name]
142
+ rows = []
143
+ for file in splits[split]:
144
+ path = hf_hub_download(repo, file, repo_type="dataset", revision=revision)
145
+ if path.endswith(".jsonl"):
146
+ rows.extend(json.loads(line) for line in Path(path).read_text().splitlines() if line.strip())
147
+ else:
148
+ rows.extend(pq.read_table(path).to_pylist())
149
+ return rows
150
+
151
+
152
+ def build(per_source: int, seed: int, split: str = "train") -> list[Record]:
153
+ """Up to `per_source` records from each source that has `split`, sampled with a fixed seed."""
154
+ rng = np.random.default_rng(seed)
155
+ available = {name for name, (_, _, splits) in SOURCES.items() if split in splits}
156
+
157
+ def sample(rows):
158
+ return [rows[i] for i in rng.permutation(len(rows))[:per_source]]
159
+
160
+ records: list[Record] = []
161
+ if "wanli" in available:
162
+ records += [nli_record("wanli", r["premise"], r["hypothesis"], r["gold"]) for r in sample(_rows("wanli", split))]
163
+ records += [nli_record("snli", r["premise"], r["hypothesis"], SNLI_LABELS[r["label"]])
164
+ for r in sample([r for r in _rows("snli", split) if r["label"] in SNLI_LABELS])]
165
+
166
+ names = _clinc_names()
167
+ in_scope = [name for name in names.values() if name != CLINC_OUT_OF_SCOPE]
168
+ records += [clinc_record(r["text"], None if names[r["intent"]] == CLINC_OUT_OF_SCOPE else names[r["intent"]], in_scope, rng)
169
+ for r in sample(_rows("clinc", split))]
170
+
171
+ records += [boolq_record(r["passage"], r["question"], r["answer"]) for r in sample(_rows("boolq", split))]
172
+
173
+ if "emotions" in available:
174
+ raw = _rows("emotions", split)
175
+ emotions = [c for c in raw[0] if c not in {"text", "id", "author", "subreddit", "link_id", "parent_id",
176
+ "created_utc", "rater_id", "example_very_unclear", "neutral"}]
177
+ wanted = set(sample(sorted({r["id"] for r in raw})))
178
+ records += emotions_records([r for r in raw if r["id"] in wanted and not r["example_very_unclear"]], emotions, rng, 3)
179
+
180
+ civil = _rows("civil", split)
181
+ toxic = [r for r in civil if r["toxicity"] >= 0.2]
182
+ calm = [r for r in civil if r["toxicity"] < 0.2]
183
+ half = per_source // 2
184
+ records += [civil_record(r) for r in [toxic[i] for i in rng.permutation(len(toxic))[:half]]
185
+ + [calm[i] for i in rng.permutation(len(calm))[:per_source - half]]]
186
+ return records
187
+
188
+
189
+ def _clinc_names() -> dict[int, str]:
190
+ """Intent names for the `plus` configuration, read from the dataset card at the pinned revision."""
191
+ repo, revision, _ = SOURCES["clinc"]
192
+ lines = Path(hf_hub_download(repo, "README.md", repo_type="dataset", revision=revision)).read_text().splitlines()
193
+ start = next(i for i, line in enumerate(lines) if line.strip().removeprefix("- ") == "config_name: plus")
194
+ names: dict[int, str] = {}
195
+ for line in lines[next(i for i in range(start, len(lines)) if lines[i].strip() == "names:") + 1:]:
196
+ key, sep, value = line.strip().partition(":")
197
+ if not (sep and key.strip("'\"").isdigit()):
198
+ break
199
+ names[int(key.strip("'\""))] = value.strip().strip("'\"")
200
+ if CLINC_OUT_OF_SCOPE not in names.values():
201
+ raise ValueError("CLINC150 card lacks the out-of-scope intent")
202
+ return names
ruling/decision.py ADDED
@@ -0,0 +1,246 @@
1
+ """A decision encoder: every typed question answered in one bidirectional pass.
2
+
3
+ The state and every question go into a single sequence. Each option gets a
4
+ `[MASK]` slot followed by its text, and a linear head scores the slot's hidden
5
+ state; a softmax over one question's slots is that question's distribution.
6
+ Attention is restricted so every question sees the state and its own text,
7
+ every option sees only itself, its question, and the state, and all options of
8
+ a question start at the same position. Answers are therefore identical
9
+ whatever other questions are asked, in any order, with options in any order.
10
+ Nothing is decoded and there are no letter codes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ from collections.abc import Callable
18
+ from dataclasses import asdict, dataclass
19
+ from pathlib import Path
20
+
21
+ import mlx.core as mx
22
+ import mlx.nn as nn
23
+ import numpy as np
24
+ from mlx.utils import tree_flatten
25
+
26
+ from ruling.calibration import Calibration, Provenance
27
+ from ruling.engine import InputTooLong, RawScore, Scored, answer
28
+ from ruling.modernbert import Config, Layer, MaskedLM, load
29
+ from ruling.questions import (
30
+ Choice,
31
+ MAX_CHOICE_OPTIONS,
32
+ Noul,
33
+ option_keys,
34
+ question_statement,
35
+ render_text,
36
+ Score,
37
+ State,
38
+ SystemOneRequest,
39
+ SystemOneResponse,
40
+ Usage,
41
+ )
42
+
43
+ CHECKPOINT_FILE = "decision.json"
44
+ WEIGHTS_FILE = "weights.safetensors"
45
+
46
+ __all__ = ["InputTooLong", "Special", "Packed", "pack", "attention_mask", "DecisionModel", "DecisionEngine"]
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Special:
51
+ cls: int
52
+ sep: int
53
+ mask: int
54
+ pad: int
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Packed:
59
+ tokens: list[int]
60
+ positions: list[int]
61
+ segments: list[tuple[int, int]]
62
+ """Per token: (0, 0) for the state, (q, 0) for question q's text, (q, o) for its option o."""
63
+ slots: dict[str, list[tuple[str, int]]]
64
+ question_index: dict[str, int]
65
+
66
+
67
+ def question_text(question: Choice | Score | Noul) -> str:
68
+ instructions = question_statement(question)
69
+ if isinstance(question, Choice):
70
+ return f"Choose the best option. {instructions}"
71
+ if isinstance(question, Score):
72
+ return f"Choose the level that fits, from lowest to highest. {instructions}"
73
+ return f"Is this statement true? {instructions}"
74
+
75
+
76
+ def option_text(question: Choice | Score | Noul, key: str) -> str:
77
+ if isinstance(question, Choice):
78
+ description = question.criteria[key]
79
+ return key if description is None else f"{key}: {render_text(description)}"
80
+ if isinstance(question, Score):
81
+ index = int(key)
82
+ return f"level {index + 1} of {len(question.criteria)}: {render_text(question.criteria[index])}"
83
+ criteria = question.criteria
84
+ if key == "yes":
85
+ return "true" if criteria is None or criteria.true is None else f"true: {render_text(criteria.true)}"
86
+ return "false" if criteria is None or criteria.false is None else f"false: {render_text(criteria.false)}"
87
+
88
+
89
+ def pack(state: State, questions: dict[str, Choice | Score | Noul], encode: Callable[[str], list[int]],
90
+ special: Special, max_tokens: int) -> Packed:
91
+ body = state if isinstance(state, str) else json.dumps(state, indent=2, ensure_ascii=False)
92
+ tokens = [special.cls, *encode(body), special.sep]
93
+ positions = list(range(len(tokens)))
94
+ segments = [(0, 0)] * len(tokens)
95
+ state_length = len(tokens)
96
+ slots: dict[str, list[tuple[str, int]]] = {}
97
+ index: dict[str, int] = {}
98
+ for q, (qid, question) in enumerate(questions.items(), start=1):
99
+ if isinstance(question, Choice) and len(question.criteria) > MAX_CHOICE_OPTIONS:
100
+ raise ValueError(f"question {qid!r} has more than {MAX_CHOICE_OPTIONS} options")
101
+ index[qid] = q
102
+ text = [*encode(question_text(question)), special.sep]
103
+ tokens += text
104
+ positions += range(state_length, state_length + len(text))
105
+ segments += [(q, 0)] * len(text)
106
+ start = state_length + len(text)
107
+ slots[qid] = []
108
+ for o, key in enumerate(option_keys(question), start=1):
109
+ option = [special.mask, *encode(option_text(question, key)), special.sep]
110
+ slots[qid].append((key, len(tokens)))
111
+ tokens += option
112
+ positions += range(start, start + len(option))
113
+ segments += [(q, o)] * len(option)
114
+ if len(tokens) > max_tokens:
115
+ raise InputTooLong(f"request is {len(tokens)} tokens, over the maximum context length of {max_tokens}")
116
+ return Packed(tokens, positions, segments, slots, index)
117
+
118
+
119
+ def attention_mask(segment_lists: list[list[tuple[int, int]]]) -> np.ndarray:
120
+ """Boolean [batch, width, width]; padding positions neither attend nor are attended to."""
121
+ width = max(len(s) for s in segment_lists)
122
+ mask = np.zeros((len(segment_lists), width, width), dtype=bool)
123
+ for b, segments in enumerate(segment_lists):
124
+ seg = np.array(segments)
125
+ q, o = seg[:, 0], seg[:, 1]
126
+ sees_state = (q == 0)[None, :]
127
+ own_question_text = (q[:, None] == q[None, :]) & (o == 0)[None, :]
128
+ own_option = (q[:, None] == q[None, :]) & (o[:, None] == o[None, :])
129
+ n = len(segments)
130
+ mask[b, :n, :n] = sees_state | own_question_text | own_option
131
+ return mask
132
+
133
+
134
+ class DecisionModel(nn.Module):
135
+ def __init__(self, config: Config):
136
+ super().__init__()
137
+ self.lm = MaskedLM(config)
138
+ self.score = nn.Linear(config.hidden_size, 1)
139
+ self.score.weight = mx.zeros_like(self.score.weight)
140
+ self.score.bias = mx.zeros_like(self.score.bias)
141
+
142
+ def __call__(self, tokens: mx.array, positions: mx.array, mask: mx.array, rows: mx.array, cols: mx.array) -> mx.array:
143
+ """One score per requested slot, where slot n is at (rows[n], cols[n])."""
144
+ hidden = self.lm.encoder(tokens, positions, mask)
145
+ return self.score(self.lm.transform(hidden[rows, cols]))[:, 0].astype(mx.float32)
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class Checkpoint:
150
+ base: str
151
+ revision: str
152
+
153
+ def save(self, directory: Path, model: DecisionModel, extra: dict) -> None:
154
+ directory.mkdir(parents=True, exist_ok=True)
155
+ mx.save_safetensors(str(directory / WEIGHTS_FILE), dict(tree_flatten(model.parameters())))
156
+ (directory / CHECKPOINT_FILE).write_text(json.dumps({**asdict(self), **extra}, indent=2) + "\n")
157
+
158
+
159
+ def checkpoint_layers(model: DecisionModel) -> None:
160
+ """Recompute each encoder layer's activations in the backward pass instead of storing them."""
161
+ call = Layer.__call__
162
+
163
+ def checkpointed(layer, *args):
164
+ def inner(params, *args):
165
+ layer.update(params)
166
+ return call(layer, *args)
167
+
168
+ return mx.checkpoint(inner)(layer.trainable_parameters(), *args)
169
+
170
+ Layer.__call__ = checkpointed
171
+
172
+
173
+ def is_checkpoint(model: str) -> bool:
174
+ return (Path(model) / CHECKPOINT_FILE).exists()
175
+
176
+
177
+ def load_model(base: str, revision: str, checkpoint: Path | None):
178
+ """The base encoder with a fresh zero-initialized head, or a trained checkpoint on top of it."""
179
+ lm, tokenizer, config = load(base, revision)
180
+ model = DecisionModel(config)
181
+ model.lm = lm
182
+ if checkpoint is not None:
183
+ model.load_weights(str(checkpoint / WEIGHTS_FILE), strict=True)
184
+ mx.eval(model.parameters())
185
+ special = Special(cls=tokenizer.cls_token_id, sep=tokenizer.sep_token_id, mask=tokenizer.mask_token_id, pad=tokenizer.pad_token_id)
186
+ return model, tokenizer, special
187
+
188
+
189
+ def batch_inputs(packs: list[Packed], pad: int) -> tuple[mx.array, mx.array, mx.array]:
190
+ width = max(len(p.tokens) for p in packs)
191
+ tokens = mx.array([p.tokens + [pad] * (width - len(p.tokens)) for p in packs])
192
+ positions = mx.array([p.positions + [0] * (width - len(p.positions)) for p in packs])
193
+ return tokens, positions, mx.array(attention_mask([p.segments for p in packs]))
194
+
195
+
196
+ class DecisionEngine:
197
+ """Serves a trained decision checkpoint through the same interface as the decoder engine."""
198
+
199
+ rotations = 1
200
+ prior_debias = False
201
+ max_options = MAX_CHOICE_OPTIONS
202
+ reads_images = False
203
+
204
+ def __init__(self, checkpoint: Path, calibration: Calibration, max_input_tokens: int):
205
+ meta = json.loads((checkpoint / CHECKPOINT_FILE).read_text())
206
+ self.model_id = str(checkpoint)
207
+ self.revision = meta["revision"]
208
+ self.adapter = hashlib.sha256((checkpoint / WEIGHTS_FILE).read_bytes()).hexdigest()[:16]
209
+ self.model, self.tokenizer, self.special = load_model(meta["base"], meta["revision"], checkpoint)
210
+ self.max_input_tokens = min(max_input_tokens, self.model.lm.encoder.config.max_position_embeddings)
211
+ # The encoder packs state and every question into one sequence, so a branch is the whole input.
212
+ self.max_branch_tokens = self.max_input_tokens
213
+ self.model.eval()
214
+ calibration.check(self.provenance)
215
+ self.calibration = calibration
216
+
217
+ @property
218
+ def provenance(self) -> Provenance:
219
+ return Provenance(self.model_id, self.revision, self.rotations, self.prior_debias, self.adapter)
220
+
221
+ def encode(self, text: str) -> list[int]:
222
+ return self.tokenizer.encode(text, add_special_tokens=False)
223
+
224
+ def score(self, state: State, questions: dict[str, Choice | Score | Noul]) -> Scored:
225
+ packed = pack(state, questions, self.encode, self.special, self.max_input_tokens)
226
+ tokens, positions, mask = batch_inputs([packed], self.special.pad)
227
+ order = [(qid, key, col) for qid, slots in packed.slots.items() for key, col in slots]
228
+ scores = self.model(tokens, positions, mask, mx.zeros((len(order),), dtype=mx.int32),
229
+ mx.array([col for _, _, col in order]))
230
+ values = np.array(scores, dtype=np.float64)
231
+ raw = {}
232
+ cursor = 0
233
+ for qid, question in questions.items():
234
+ count = len(packed.slots[qid])
235
+ logits = values[cursor:cursor + count]
236
+ raw[qid] = RawScore(keys=option_keys(question), logits=logits - np.log(np.exp(logits - logits.max()).sum()) - logits.max())
237
+ cursor += count
238
+ return Scored(raw=raw, input_tokens=len(packed.tokens))
239
+
240
+ def evaluate(self, request: SystemOneRequest) -> SystemOneResponse:
241
+ scored = self.score(request.state, request.questions)
242
+ answers = {qid: answer(request.questions[qid], scored.raw[qid], self.calibration) for qid in request.questions}
243
+ return SystemOneResponse(model=self.model_id, answers=answers, usage=Usage(input_tokens=scored.input_tokens))
244
+
245
+ def answer(self, question, raw: RawScore):
246
+ return answer(question, raw, self.calibration)
@@ -0,0 +1,208 @@
1
+ """Full fine-tuning of a decision encoder on typed-question records.
2
+
3
+ Each training example is a whole record: its state and every question that
4
+ has a target, packed and scored in one pass exactly as the engine serves it.
5
+ Answers do not depend on option order or on which other questions are asked,
6
+ so there is nothing to augment. The loss is cross-entropy against each
7
+ question's target distribution, a proper scoring rule, averaged over the
8
+ questions in a batch. The new scoring head starts at zero, so the untrained
9
+ model answers uniformly, and learns at a higher rate than the pretrained
10
+ encoder.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import time
18
+ from dataclasses import asdict, dataclass
19
+ from pathlib import Path
20
+
21
+ import mlx.core as mx
22
+ import mlx.nn as nn
23
+ import mlx.optimizers as optim
24
+ import numpy as np
25
+ from mlx.utils import tree_flatten
26
+
27
+ from ruling.calibration import expected_calibration_error
28
+ from ruling.decision import Checkpoint, InputTooLong, checkpoint_layers, Packed, batch_inputs, load_model, pack
29
+ from ruling.evals import Record, load_dataset
30
+ from ruling.train import holdout_overlap, split_records, target_vector
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class DecisionTrainConfig:
35
+ base: str
36
+ revision: str
37
+ data: list[Path]
38
+ holdout: list[Path]
39
+ output: Path
40
+ valid_fraction: float = 0.02
41
+ steps: int = 3000
42
+ batch_tokens: int = 8192
43
+ learning_rate: float = 3e-5
44
+ head_learning_rate: float = 1e-3
45
+ weight_decay: float = 0.01
46
+ warmup: int = 150
47
+ max_tokens: int = 2048
48
+ eval_every: int = 250
49
+ valid_records: int = 800
50
+ grad_checkpoint: bool = True
51
+ seed: int = 0
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Example:
56
+ packed: Packed
57
+ targets: list[list[float]]
58
+
59
+
60
+ class DecisionTrainer:
61
+ def __init__(self, config: DecisionTrainConfig):
62
+ self.config = config
63
+ self.rng = np.random.default_rng(config.seed)
64
+ records = [r for path in config.data for r in load_dataset(path)]
65
+ held_out = [r for path in config.holdout for r in load_dataset(path)]
66
+ overlap = holdout_overlap(records, held_out)
67
+ if overlap:
68
+ raise ValueError(f"{overlap} training states also appear in held-out evaluation data")
69
+ train_records, valid_records = split_records(records, config.valid_fraction)
70
+ mx.random.seed(config.seed)
71
+ self.model, self.tokenizer, self.special = load_model(config.base, config.revision, None)
72
+ if config.grad_checkpoint:
73
+ checkpoint_layers(self.model)
74
+ self.train = self._examples(train_records)
75
+ valid = self._examples(valid_records)
76
+ self.valid = [valid[i] for i in self.rng.permutation(len(valid))[: config.valid_records]]
77
+ if not self.train or not self.valid:
78
+ raise ValueError("training and validation both need records with labeled questions")
79
+
80
+ def _examples(self, records: list[Record]) -> list[Example]:
81
+ encode = lambda text: self.tokenizer.encode(text, add_special_tokens=False)
82
+ examples = []
83
+ for record in records:
84
+ questions = {qid: q for qid, q in record.questions.items() if target_vector(record, qid) is not None}
85
+ if not questions:
86
+ continue
87
+ try:
88
+ packed = pack(record.state, questions, encode, self.special, self.config.max_tokens)
89
+ except InputTooLong:
90
+ continue
91
+ targets = [[target_vector(record, qid)[key] for key, _ in packed.slots[qid]] for qid in questions]
92
+ examples.append(Example(packed, targets))
93
+ return examples
94
+
95
+ def _batch(self, examples: list[Example]):
96
+ tokens, positions, mask = batch_inputs([e.packed for e in examples], self.special.pad)
97
+ rows, cols, slot_index, targets = [], [], [], []
98
+ width = max(len(t) for e in examples for t in e.targets)
99
+ for b, example in enumerate(examples):
100
+ for slots, target in zip(example.packed.slots.values(), example.targets):
101
+ start = len(rows)
102
+ for _, col in slots:
103
+ rows.append(b)
104
+ cols.append(col)
105
+ slot_index.append([start + i for i in range(len(slots))] + [0] * (width - len(slots)))
106
+ targets.append(target + [0.0] * (width - len(target)))
107
+ valid = [[i < len(e_t) for i in range(width)] for e in examples for e_t in e.targets]
108
+ return (tokens, positions, mask, mx.array(rows), mx.array(cols), mx.array(slot_index),
109
+ mx.array(valid), mx.array(targets, dtype=mx.float32))
110
+
111
+ @staticmethod
112
+ def _log_probs(model, tokens, positions, mask, rows, cols, slot_index, valid):
113
+ scores = model(tokens, positions, mask, rows, cols)
114
+ logits = mx.where(valid, scores[slot_index], -mx.inf)
115
+ return logits - mx.logsumexp(logits, axis=-1, keepdims=True)
116
+
117
+ @classmethod
118
+ def loss(cls, model, tokens, positions, mask, rows, cols, slot_index, valid, targets):
119
+ log_probs = cls._log_probs(model, tokens, positions, mask, rows, cols, slot_index, valid)
120
+ return -mx.where(valid, targets * log_probs, 0.0).sum(axis=-1).mean()
121
+
122
+ def _groups(self, examples: list[Example], shuffle: bool) -> list[list[Example]]:
123
+ order = self.rng.permutation(len(examples)) if shuffle else np.arange(len(examples))
124
+ groups = []
125
+ for start in range(0, len(order), 512):
126
+ chunk = sorted((examples[i] for i in order[start:start + 512]), key=lambda e: len(e.packed.tokens))
127
+ current: list[Example] = []
128
+ for example in chunk:
129
+ width = max([len(e.packed.tokens) for e in current] + [len(example.packed.tokens)])
130
+ if current and width * (len(current) + 1) > self.config.batch_tokens:
131
+ groups.append(current)
132
+ current = []
133
+ current.append(example)
134
+ if current:
135
+ groups.append(current)
136
+ if shuffle:
137
+ groups = [groups[i] for i in self.rng.permutation(len(groups))]
138
+ return groups
139
+
140
+ def validate(self) -> dict:
141
+ self.model.eval()
142
+ losses, top, correct = [], [], []
143
+ for group in self._groups(self.valid, shuffle=False):
144
+ batch = self._batch(group)
145
+ log_probs = self._log_probs(self.model, *batch[:7])
146
+ valid, targets = np.array(batch[6]), np.array(batch[7])
147
+ probs = np.exp(np.where(valid, np.array(log_probs), -np.inf))
148
+ for row, v, target in zip(probs, valid, targets):
149
+ row, target = row[v], target[v]
150
+ losses.append(float(-(target * np.log(np.maximum(row, 1e-12))).sum()))
151
+ top.append(float(row.max()))
152
+ correct.append(int(row.argmax()) == int(target.argmax()))
153
+ self.model.train()
154
+ return {"loss": float(np.mean(losses)), "accuracy": float(np.mean(correct)),
155
+ "expected_calibration_error": expected_calibration_error(top, correct), "questions": len(losses)}
156
+
157
+ def save(self, history: list[dict]) -> None:
158
+ data = [{"path": str(p), "sha256": hashlib.sha256(p.read_bytes()).hexdigest()} for p in self.config.data]
159
+ extra = {"training": {**asdict(self.config), "data": data, "output": str(self.config.output),
160
+ "holdout": [str(p) for p in self.config.holdout]},
161
+ "train_records": len(self.train), "valid_records": len(self.valid), "history": history}
162
+ Checkpoint(self.config.base, self.config.revision).save(self.config.output, self.model, extra)
163
+
164
+ def run(self, log=print) -> list[dict]:
165
+ config = self.config
166
+
167
+ def schedule(peak):
168
+ return optim.join_schedules([optim.linear_schedule(peak * 0.01, peak, config.warmup),
169
+ optim.cosine_decay(peak, max(config.steps - config.warmup, 1), peak * 0.05)],
170
+ [config.warmup])
171
+
172
+ optimizer = optim.MultiOptimizer(
173
+ [optim.AdamW(learning_rate=schedule(config.head_learning_rate), weight_decay=0.0),
174
+ optim.AdamW(learning_rate=schedule(config.learning_rate), weight_decay=config.weight_decay)],
175
+ [lambda path, _: path.startswith("score.")],
176
+ )
177
+ self.model.train()
178
+ step_fn = nn.value_and_grad(self.model, self.loss)
179
+ log(f"{sum(v.size for _, v in tree_flatten(self.model.trainable_parameters())):,} parameters; "
180
+ f"{len(self.train):,} train and {len(self.valid):,} validation records")
181
+ history = [{"step": 0, **self.validate()}]
182
+ log(json.dumps(history[-1]))
183
+ best = history[-1]["loss"]
184
+ step, tokens_seen, started, window = 0, 0, time.perf_counter(), []
185
+ while step < config.steps:
186
+ for group in self._groups(self.train, shuffle=True):
187
+ step += 1
188
+ batch = self._batch(group)
189
+ loss, grads = step_fn(self.model, *batch)
190
+ grads, _ = optim.clip_grad_norm(grads, 1.0)
191
+ optimizer.update(self.model, grads)
192
+ mx.eval(self.model.parameters(), optimizer.state, loss)
193
+ window.append(float(loss))
194
+ tokens_seen += batch[0].size
195
+ if step % config.eval_every == 0 or step == config.steps:
196
+ entry = {"step": step, "train_loss": float(np.mean(window)),
197
+ "tokens_per_second": tokens_seen / (time.perf_counter() - started), **self.validate()}
198
+ window = []
199
+ history.append(entry)
200
+ log(json.dumps(entry))
201
+ if entry["loss"] < best:
202
+ best = entry["loss"]
203
+ self.save(history)
204
+ if step == config.steps:
205
+ break
206
+ if best == history[0]["loss"]:
207
+ self.save(history)
208
+ return history