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/__init__.py +25 -0
- ruling/against.py +50 -0
- ruling/build.py +257 -0
- ruling/calibration.py +182 -0
- ruling/cascade.py +83 -0
- ruling/cli.py +397 -0
- ruling/codes.py +30 -0
- ruling/config.py +120 -0
- ruling/corpus.py +202 -0
- ruling/decision.py +246 -0
- ruling/decision_train.py +208 -0
- ruling/distill.py +131 -0
- ruling/engine.py +518 -0
- ruling/evals.py +459 -0
- ruling/failover.py +62 -0
- ruling/history.html +78 -0
- ruling/hosted.py +394 -0
- ruling/ledger.py +120 -0
- ruling/metrics.py +66 -0
- ruling/modernbert.py +163 -0
- ruling/peft.py +144 -0
- ruling/playground.html +217 -0
- ruling/prompt.py +134 -0
- ruling/questions.py +280 -0
- ruling/registry.py +195 -0
- ruling/retrain.py +125 -0
- ruling/sdk.py +52 -0
- ruling/server.py +198 -0
- ruling/states.py +200 -0
- ruling/thinking.py +75 -0
- ruling/train.py +481 -0
- ruling/vision.py +100 -0
- ruling/worlds.py +134 -0
- ruling-0.2.0.dist-info/METADATA +83 -0
- ruling-0.2.0.dist-info/RECORD +38 -0
- ruling-0.2.0.dist-info/WHEEL +4 -0
- ruling-0.2.0.dist-info/entry_points.txt +2 -0
- ruling-0.2.0.dist-info/licenses/LICENSE +21 -0
ruling/train.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""LoRA fine-tuning of the answer-letter readout on typed-question records.
|
|
2
|
+
|
|
3
|
+
Keep the learning rate near 1e-5. At 1e-4 the letter readout collapses toward
|
|
4
|
+
uniform answers: loss falls while accuracy drops below the untrained model.
|
|
5
|
+
|
|
6
|
+
Train a pure-attention base. Hybrid linear-attention models such as Qwen3.5
|
|
7
|
+
fall back to a step-by-step recurrence when training in mlx-lm, which is one
|
|
8
|
+
to two orders of magnitude slower on this hardware.
|
|
9
|
+
|
|
10
|
+
Every labeled question becomes one example rendered exactly as the engine
|
|
11
|
+
renders it at inference, with the answer order randomized each time it is
|
|
12
|
+
seen: Choice options are shuffled, Noul and Score take either direction. The
|
|
13
|
+
loss is cross-entropy between the model's distribution over the allowed answer
|
|
14
|
+
letters and the target distribution, which is a proper scoring rule, so its
|
|
15
|
+
minimum is the true probability. Targets are soft where annotator fractions or
|
|
16
|
+
designed ambiguity exist (a `target` reference) and one-hot otherwise.
|
|
17
|
+
|
|
18
|
+
Three optional terms move the objective toward the calibrated-decision recipes
|
|
19
|
+
TypeSafe and Cloudflare describe, all off by default:
|
|
20
|
+
|
|
21
|
+
- label smoothing mixes a little of the uniform distribution into the target,
|
|
22
|
+
so the model is never trained to put all its mass on one option;
|
|
23
|
+
- a Brier term, the squared distance between the predicted and target
|
|
24
|
+
distributions, which punishes confident errors less steeply than log loss
|
|
25
|
+
and so pulls the hard, wrong answers back toward the middle;
|
|
26
|
+
- a confidence-ranking term, which within each batch penalizes any wrong
|
|
27
|
+
answer reported more confidently than a right one. This targets the
|
|
28
|
+
ordering of confidences, which is what coverage at a fixed error budget
|
|
29
|
+
measures and what a temperature cannot change.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import json
|
|
36
|
+
import math
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import asdict, dataclass
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
import mlx.core as mx
|
|
42
|
+
import mlx.nn as nn
|
|
43
|
+
import mlx.optimizers as optim
|
|
44
|
+
import numpy as np
|
|
45
|
+
from mlx.utils import tree_flatten, tree_unflatten
|
|
46
|
+
from mlx_lm.tuner.trainer import grad_checkpoint
|
|
47
|
+
from mlx_lm.tuner.utils import linear_to_lora_layers
|
|
48
|
+
|
|
49
|
+
from ruling.calibration import Calibration, coverage_at_error, expected_calibration_error
|
|
50
|
+
from ruling.engine import Engine, option_logits
|
|
51
|
+
from ruling.evals import TARGET, Record, label_index, load_dataset
|
|
52
|
+
from ruling.prompt import render_question
|
|
53
|
+
from ruling.questions import Choice, Noul, Score, option_keys, render_text, state_images
|
|
54
|
+
from ruling.vision import VISION_PREFIX, VisionEngine
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class TrainConfig:
|
|
59
|
+
model: str
|
|
60
|
+
data: list[Path]
|
|
61
|
+
output: Path
|
|
62
|
+
holdout: list[Path]
|
|
63
|
+
valid_fraction: float = 0.02
|
|
64
|
+
steps: int = 2000
|
|
65
|
+
batch_size: int = 8
|
|
66
|
+
learning_rate: float = 1e-5
|
|
67
|
+
max_grad_norm: float = 1.0
|
|
68
|
+
warmup: int = 100
|
|
69
|
+
lora_layers: int = 16
|
|
70
|
+
lora_rank: int = 8
|
|
71
|
+
lora_scale: float = 20.0
|
|
72
|
+
lora_dropout: float = 0.0
|
|
73
|
+
max_tokens: int = 1024
|
|
74
|
+
eval_every: int = 250
|
|
75
|
+
valid_examples: int = 1500
|
|
76
|
+
grad_checkpoint: bool = True
|
|
77
|
+
seed: int = 0
|
|
78
|
+
label_smoothing: float = 0.0
|
|
79
|
+
brier_weight: float = 0.0
|
|
80
|
+
ranking_weight: float = 0.0
|
|
81
|
+
ranking_margin: float = 0.05
|
|
82
|
+
from_adapter: Path | None = None
|
|
83
|
+
max_image_tokens: int = 1024
|
|
84
|
+
"""Tokens one image may become on a `vision:` model; keep it equal to the server's RULING_MAX_IMAGE_TOKENS."""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
CHECKPOINT = "checkpoint"
|
|
88
|
+
# Rendering happens in chunks of this many batches, which are then sorted by length and cut into batches.
|
|
89
|
+
CHUNK_BATCHES = 64
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True)
|
|
93
|
+
class Example:
|
|
94
|
+
record: int
|
|
95
|
+
question_id: str
|
|
96
|
+
weight: float
|
|
97
|
+
"""The record's weight: its share of a batch's update relative to the other rows."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def target_vector(record: Record, question_id: str) -> dict[str, float] | None:
|
|
101
|
+
"""The training target over canonical keys: a soft `target` reference, else the hard label."""
|
|
102
|
+
soft = record.references.get(question_id, {}).get(TARGET)
|
|
103
|
+
if soft is not None:
|
|
104
|
+
return soft
|
|
105
|
+
if question_id not in record.labels:
|
|
106
|
+
return None
|
|
107
|
+
question = record.questions[question_id]
|
|
108
|
+
keys = option_keys(question)
|
|
109
|
+
gold = label_index(question, record.labels[question_id])
|
|
110
|
+
return {key: float(i == gold) for i, key in enumerate(keys)}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def permuted(question: Choice | Score | Noul, rng: np.random.Generator) -> tuple[Choice | Score | Noul, int]:
|
|
114
|
+
"""A random presentation of the question: shuffled Choice options, or a random direction."""
|
|
115
|
+
if isinstance(question, Choice):
|
|
116
|
+
order = list(rng.permutation(list(question.criteria)))
|
|
117
|
+
return Choice(instructions=question.instructions, criteria={k: question.criteria[k] for k in order}), 0
|
|
118
|
+
return question, int(rng.integers(2))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def objective(log_probs: mx.array, mask: mx.array, targets: mx.array, label_smoothing: float = 0.0,
|
|
122
|
+
brier_weight: float = 0.0, ranking_weight: float = 0.0, ranking_margin: float = 0.05,
|
|
123
|
+
weights: mx.array | None = None) -> mx.array:
|
|
124
|
+
"""Cross-entropy against the (optionally smoothed) target, plus the optional Brier and ranking terms.
|
|
125
|
+
|
|
126
|
+
Rows are averaged by `weights` (one per row, summing to more than zero; uniform when None), so the update
|
|
127
|
+
is the weighted mean of the batch. With every term off this is exactly the original soft-target
|
|
128
|
+
cross-entropy. The ranking term compares rows pairwise and takes no weights.
|
|
129
|
+
"""
|
|
130
|
+
if weights is None:
|
|
131
|
+
weights = mx.ones(log_probs.shape[0])
|
|
132
|
+
|
|
133
|
+
def weighted_mean(per_row: mx.array) -> mx.array:
|
|
134
|
+
return (weights * per_row).sum() / weights.sum()
|
|
135
|
+
|
|
136
|
+
options = mask.sum(axis=-1, keepdims=True).astype(mx.float32)
|
|
137
|
+
smoothed = (1.0 - label_smoothing) * targets + mx.where(mask, label_smoothing / options, 0.0)
|
|
138
|
+
total = weighted_mean(-mx.where(mask, smoothed * log_probs, 0.0).sum(axis=-1))
|
|
139
|
+
if brier_weight == 0.0 and ranking_weight == 0.0:
|
|
140
|
+
return total
|
|
141
|
+
probs = mx.where(mask, mx.exp(log_probs), 0.0)
|
|
142
|
+
if brier_weight:
|
|
143
|
+
total = total + brier_weight * weighted_mean(((probs - targets) ** 2).sum(axis=-1))
|
|
144
|
+
if ranking_weight:
|
|
145
|
+
confidence = probs.max(axis=-1)
|
|
146
|
+
right = mx.stop_gradient((probs.argmax(axis=-1) == targets.argmax(axis=-1)).astype(mx.float32))
|
|
147
|
+
# pairs[i, j] is 1 where answer i was right and answer j was wrong.
|
|
148
|
+
pairs = right[:, None] * (1.0 - right)[None, :]
|
|
149
|
+
violations = mx.maximum(confidence[None, :] - confidence[:, None] + ranking_margin, 0.0) * pairs
|
|
150
|
+
total = total + ranking_weight * violations.sum() / mx.maximum(pairs.sum(), 1.0)
|
|
151
|
+
return total
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def split_records(records: list[Record], valid_fraction: float) -> tuple[list[Record], list[Record]]:
|
|
155
|
+
"""Deterministic split by a hash of the state, so reruns and reorderings agree."""
|
|
156
|
+
train, valid = [], []
|
|
157
|
+
for record in records:
|
|
158
|
+
digest = hashlib.sha256(_state_key(record.state).encode()).digest()
|
|
159
|
+
(valid if int.from_bytes(digest[:8], "big") / 2**64 < valid_fraction else train).append(record)
|
|
160
|
+
return train, valid
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def holdout_overlap(training: list[Record], held_out: list[Record]) -> int:
|
|
164
|
+
"""How many training states also appear, up to whitespace, in held-out evaluation data."""
|
|
165
|
+
held = {_state_key(r.state) for r in held_out}
|
|
166
|
+
return sum(1 for r in training if _state_key(r.state) in held)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _state_key(state) -> str:
|
|
170
|
+
return " ".join(render_text(state).split())
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def config_json(config: TrainConfig) -> dict:
|
|
174
|
+
"""The config as JSON-ready values, paths as strings."""
|
|
175
|
+
return {**asdict(config), "data": [str(p) for p in config.data], "holdout": [str(p) for p in config.holdout],
|
|
176
|
+
"output": str(config.output), "from_adapter": None if config.from_adapter is None else str(config.from_adapter)}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def config_mismatch(saved: dict, requested: dict) -> list[str]:
|
|
180
|
+
"""The fields a resumed run would change, other than `steps`, which may be raised to continue a finished run."""
|
|
181
|
+
return sorted(k for k in set(saved) | set(requested) if k != "steps" and saved.get(k) != requested.get(k))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def save_checkpoint(directory: Path, weights: dict[str, mx.array], optimizer_state: dict, state: dict) -> None:
|
|
185
|
+
"""Everything a run needs to continue from its next step: LoRA weights, optimizer state and the trainer's bookkeeping."""
|
|
186
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
mx.save_safetensors(str(directory / "adapters.safetensors"), weights)
|
|
188
|
+
mx.save_safetensors(str(directory / "optimizer.safetensors"), dict(tree_flatten(optimizer_state)))
|
|
189
|
+
(directory / "state.json").write_text(json.dumps(state, indent=2) + "\n")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def load_checkpoint(directory: Path) -> tuple[dict[str, mx.array], dict, dict]:
|
|
193
|
+
if not (directory / "state.json").exists():
|
|
194
|
+
raise FileNotFoundError(f"no checkpoint to resume in {directory}")
|
|
195
|
+
weights = dict(mx.load(str(directory / "adapters.safetensors")))
|
|
196
|
+
optimizer_state = tree_unflatten(list(mx.load(str(directory / "optimizer.safetensors")).items()))
|
|
197
|
+
return weights, optimizer_state, json.loads((directory / "state.json").read_text())
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class BatchStream:
|
|
201
|
+
"""Endless shuffled batches, grouped by length to limit padding, resumable to the exact batch.
|
|
202
|
+
|
|
203
|
+
An epoch is one permutation of the examples, rendered a chunk at a time; each chunk is sorted by length, cut into
|
|
204
|
+
batches, and the batches shuffled. Augmentation draws from the same generator, so the generator's state at the
|
|
205
|
+
start of the epoch and of the current chunk, with the chunk's offset and how many of its batches went out,
|
|
206
|
+
determine every later batch exactly.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
def __init__(self, count: int, size: int, rng: np.random.Generator, render, batch):
|
|
210
|
+
self.count, self.size, self.rng, self.render, self.batch = count, size, rng, render, batch
|
|
211
|
+
self.order = None
|
|
212
|
+
self.epoch_rng = self.chunk_rng = None
|
|
213
|
+
self.start = self.cursor = 0
|
|
214
|
+
self.batches = []
|
|
215
|
+
|
|
216
|
+
def __next__(self):
|
|
217
|
+
while self.cursor >= len(self.batches):
|
|
218
|
+
width = self.size * CHUNK_BATCHES
|
|
219
|
+
if self.order is None or self.start + width >= len(self.order):
|
|
220
|
+
self.epoch_rng = self.rng.bit_generator.state
|
|
221
|
+
self.order = self.rng.permutation(self.count)
|
|
222
|
+
self.start = 0
|
|
223
|
+
else:
|
|
224
|
+
self.start += width
|
|
225
|
+
self.chunk_rng = self.rng.bit_generator.state
|
|
226
|
+
self._render_chunk()
|
|
227
|
+
batch = self.batches[self.cursor]
|
|
228
|
+
self.cursor += 1
|
|
229
|
+
return batch
|
|
230
|
+
|
|
231
|
+
def _render_chunk(self) -> None:
|
|
232
|
+
chunk = [self.render(i) for i in self.order[self.start:self.start + self.size * CHUNK_BATCHES]]
|
|
233
|
+
chunk = sorted((c for c in chunk if c is not None), key=lambda c: len(c[0]))
|
|
234
|
+
groups = [chunk[i:i + self.size] for i in range(0, len(chunk) - self.size + 1, self.size)]
|
|
235
|
+
self.batches = [self.batch(groups[g]) for g in self.rng.permutation(len(groups))]
|
|
236
|
+
self.cursor = 0
|
|
237
|
+
|
|
238
|
+
def state(self) -> dict:
|
|
239
|
+
return {"epoch_rng": self.epoch_rng, "chunk_rng": self.chunk_rng, "start": self.start, "cursor": self.cursor}
|
|
240
|
+
|
|
241
|
+
def restore(self, state: dict) -> None:
|
|
242
|
+
self.epoch_rng, self.chunk_rng, self.start = state["epoch_rng"], state["chunk_rng"], state["start"]
|
|
243
|
+
self.rng.bit_generator.state = self.epoch_rng
|
|
244
|
+
self.order = self.rng.permutation(self.count)
|
|
245
|
+
self.rng.bit_generator.state = self.chunk_rng
|
|
246
|
+
self._render_chunk()
|
|
247
|
+
self.cursor = state["cursor"]
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Trainer:
|
|
251
|
+
def __init__(self, config: TrainConfig, resume: bool = False):
|
|
252
|
+
self.config = config
|
|
253
|
+
self.checkpoint = None
|
|
254
|
+
if resume:
|
|
255
|
+
self.checkpoint = load_checkpoint(config.output / CHECKPOINT)
|
|
256
|
+
_, _, state = self.checkpoint
|
|
257
|
+
mismatch = config_mismatch(state["config"], config_json(config))
|
|
258
|
+
if mismatch:
|
|
259
|
+
raise ValueError(f"cannot resume {config.output}: the checkpoint was trained with different {', '.join(mismatch)}")
|
|
260
|
+
self.rng = np.random.default_rng(config.seed)
|
|
261
|
+
records = [r for path in config.data for r in load_dataset(path)]
|
|
262
|
+
held_out = [r for path in config.holdout for r in load_dataset(path)]
|
|
263
|
+
overlap = holdout_overlap(records, held_out)
|
|
264
|
+
if overlap:
|
|
265
|
+
raise ValueError(f"{overlap} training states also appear in held-out evaluation data")
|
|
266
|
+
if not config.model.startswith(VISION_PREFIX) and any(state_images(r.state) for r in records):
|
|
267
|
+
raise ValueError(f"the records hold images and {config.model} reads text only; "
|
|
268
|
+
f"train a {VISION_PREFIX} model on them")
|
|
269
|
+
self.train_records, self.valid_records = split_records(records, config.valid_fraction)
|
|
270
|
+
|
|
271
|
+
self.lora = {"rank": config.lora_rank, "scale": config.lora_scale, "dropout": config.lora_dropout}
|
|
272
|
+
if config.from_adapter is not None:
|
|
273
|
+
self._check_adapter(config.from_adapter)
|
|
274
|
+
|
|
275
|
+
mx.random.seed(config.seed)
|
|
276
|
+
if config.model.startswith(VISION_PREFIX):
|
|
277
|
+
self.engine = VisionEngine(config.model, Calibration(), config.max_tokens, 1, False, config.max_image_tokens)
|
|
278
|
+
else:
|
|
279
|
+
self.engine = Engine(config.model, Calibration(), config.max_tokens, rotations=1, prior_debias=False)
|
|
280
|
+
model = self.engine.model
|
|
281
|
+
model.freeze()
|
|
282
|
+
linear_to_lora_layers(model, config.lora_layers, self.lora)
|
|
283
|
+
if config.grad_checkpoint:
|
|
284
|
+
grad_checkpoint(model.layers[0])
|
|
285
|
+
if config.from_adapter is not None:
|
|
286
|
+
model.load_weights(str(config.from_adapter / "adapters.safetensors"), strict=False)
|
|
287
|
+
mx.eval(model.parameters())
|
|
288
|
+
|
|
289
|
+
self.train_examples = self._examples(self.train_records)
|
|
290
|
+
valid = self._examples(self.valid_records)
|
|
291
|
+
self.valid_examples = [valid[i] for i in self.rng.permutation(len(valid))[: config.valid_examples]]
|
|
292
|
+
if not self.train_examples or not self.valid_examples:
|
|
293
|
+
raise ValueError("training and validation both need labeled questions")
|
|
294
|
+
|
|
295
|
+
def _check_adapter(self, adapter: Path) -> None:
|
|
296
|
+
"""An adapter to start from must have been trained with the requested LoRA settings, or its weights fit nothing."""
|
|
297
|
+
saved = json.loads((adapter / "adapter_config.json").read_text())
|
|
298
|
+
wanted = {"fine_tune_type": "lora", "num_layers": self.config.lora_layers, "lora_parameters": self.lora}
|
|
299
|
+
if saved != wanted:
|
|
300
|
+
raise ValueError(f"{adapter} was trained with {saved}, not the requested {wanted}")
|
|
301
|
+
|
|
302
|
+
def _examples(self, records: list[Record]) -> list[Example]:
|
|
303
|
+
"""One example per labeled question the model can answer; a weightless record would not move the update."""
|
|
304
|
+
examples = []
|
|
305
|
+
for index, record in enumerate(records):
|
|
306
|
+
if record.weight == 0:
|
|
307
|
+
continue
|
|
308
|
+
for qid, question in record.questions.items():
|
|
309
|
+
cardinality = len(option_keys(question))
|
|
310
|
+
if cardinality <= self.engine.codes.max_options and target_vector(record, qid) is not None:
|
|
311
|
+
examples.append(Example(index, qid, record.weight))
|
|
312
|
+
return examples
|
|
313
|
+
|
|
314
|
+
def _render(self, records: list[Record], example: Example, augment: bool):
|
|
315
|
+
"""The example's tokens, its target over the presented options, its weight, and its state when the
|
|
316
|
+
state holds images (read again when its batch runs, so a chunk of batches never holds their pixels)."""
|
|
317
|
+
record = records[example.record]
|
|
318
|
+
question = record.questions[example.question_id]
|
|
319
|
+
presented, rotation = permuted(question, self.rng) if augment else (question, 0)
|
|
320
|
+
rendered = render_question(presented, self.engine.codes.codes, rotation)
|
|
321
|
+
prefix = self.engine._prefix(record.state)
|
|
322
|
+
tokens = prefix.tokens + self.engine._encode(rendered.text + prefix.tail)
|
|
323
|
+
if len(tokens) > self.config.max_tokens:
|
|
324
|
+
return None
|
|
325
|
+
target = target_vector(record, example.question_id)
|
|
326
|
+
seen = record.state if prefix.vision_inputs is not None else None
|
|
327
|
+
return tokens, [target[key] for key in rendered.keys], example.weight, seen
|
|
328
|
+
|
|
329
|
+
def _batch(self, rendered: list[tuple[list[int], list[float], float, object]]):
|
|
330
|
+
width = math.ceil(max(len(t) for t, _, _, _ in rendered) / 32) * 32
|
|
331
|
+
options = max(len(target) for _, target, _, _ in rendered)
|
|
332
|
+
pad = self.engine._pad_id
|
|
333
|
+
tokens = mx.array([t + [pad] * (width - len(t)) for t, _, _, _ in rendered])
|
|
334
|
+
last = mx.array([len(t) - 1 for t, _, _, _ in rendered])
|
|
335
|
+
ids = mx.array([self.engine.codes.token_ids[:options] for _ in rendered])
|
|
336
|
+
mask = mx.array([[i < len(target) for i in range(options)] for _, target, _, _ in rendered])
|
|
337
|
+
targets = mx.array([target + [0.0] * (options - len(target)) for _, target, _, _ in rendered], dtype=mx.float32)
|
|
338
|
+
weights = mx.array([weight for _, _, weight, _ in rendered], dtype=mx.float32)
|
|
339
|
+
return tokens, last, ids, mask, targets, weights, [seen for _, _, _, seen in rendered]
|
|
340
|
+
|
|
341
|
+
def _vision(self, tokens: mx.array, last: mx.array, states: list) -> tuple[mx.array | None, mx.array | None]:
|
|
342
|
+
"""Input embeddings with each row's image features in place, and every row's multimodal positions, on a
|
|
343
|
+
vision model; (None, None) on a text model. A batch without images keeps its token embeddings. The vision
|
|
344
|
+
tower is frozen: nothing here takes a gradient."""
|
|
345
|
+
if not self.engine.reads_images:
|
|
346
|
+
return None, None
|
|
347
|
+
width = tokens.shape[1]
|
|
348
|
+
positions = mx.broadcast_to(mx.arange(width)[None, None, :], (3, tokens.shape[0], width))
|
|
349
|
+
if all(state is None for state in states):
|
|
350
|
+
# A vision model's attention takes explicit positions whenever no cache supplies them.
|
|
351
|
+
return None, positions
|
|
352
|
+
vlm = self.engine.vlm
|
|
353
|
+
embeds = vlm.language_model.model.embed_tokens(tokens)
|
|
354
|
+
rows, places = [], []
|
|
355
|
+
for row, state in enumerate(states):
|
|
356
|
+
if state is None:
|
|
357
|
+
rows.append(embeds[row])
|
|
358
|
+
places.append(positions[:, row])
|
|
359
|
+
continue
|
|
360
|
+
length = int(last[row]) + 1
|
|
361
|
+
images = self.engine._prefix(state).vision_inputs
|
|
362
|
+
ids = tokens[row:row + 1, :length]
|
|
363
|
+
features = vlm.get_input_embeddings(ids, images["pixel_values"], image_grid_thw=images["image_grid_thw"],
|
|
364
|
+
mask=mx.ones(ids.shape, dtype=mx.int32))
|
|
365
|
+
# Padding sits after the last real token, so its embeddings and positions are never read.
|
|
366
|
+
rows.append(mx.concatenate([features.inputs_embeds[0], embeds[row, length:]], axis=0))
|
|
367
|
+
row_positions = features.position_ids[:, 0]
|
|
368
|
+
tail = row_positions.max() + 1 + mx.arange(width - length)
|
|
369
|
+
places.append(mx.concatenate([row_positions, mx.broadcast_to(tail[None], (3, width - length))], axis=1))
|
|
370
|
+
return mx.stop_gradient(mx.stack(rows)), mx.stack(places, axis=1)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _log_probs(model, tokens, last, ids, mask, embeds=None, positions=None):
|
|
374
|
+
logits = mx.where(mask, option_logits(model, tokens, None, last, ids, positions, embeds), -mx.inf)
|
|
375
|
+
return logits - mx.logsumexp(logits, axis=-1, keepdims=True)
|
|
376
|
+
|
|
377
|
+
def loss(self, model, tokens, last, ids, mask, targets, weights, embeds=None, positions=None):
|
|
378
|
+
log_probs = self._log_probs(model, tokens, last, ids, mask, embeds, positions)
|
|
379
|
+
config = self.config
|
|
380
|
+
return objective(log_probs, mask, targets, config.label_smoothing, config.brier_weight,
|
|
381
|
+
config.ranking_weight, config.ranking_margin, weights)
|
|
382
|
+
|
|
383
|
+
def _batches(self) -> BatchStream:
|
|
384
|
+
return BatchStream(len(self.train_examples), self.config.batch_size, self.rng,
|
|
385
|
+
lambda i: self._render(self.train_records, self.train_examples[i], augment=True), self._batch)
|
|
386
|
+
|
|
387
|
+
def validate(self) -> dict:
|
|
388
|
+
model = self.engine.model
|
|
389
|
+
model.eval()
|
|
390
|
+
losses, top, correct = [], [], []
|
|
391
|
+
size = self.config.batch_size
|
|
392
|
+
rendered = [r for r in (self._render(self.valid_records, e, augment=False) for e in self.valid_examples) if r is not None]
|
|
393
|
+
rendered.sort(key=lambda c: len(c[0]))
|
|
394
|
+
for start in range(0, len(rendered), size):
|
|
395
|
+
group = rendered[start:start + size]
|
|
396
|
+
tokens, last, ids, mask, targets, _, states = self._batch(group)
|
|
397
|
+
log_probs = self._log_probs(model, tokens, last, ids, mask, *self._vision(tokens, last, states))
|
|
398
|
+
ce = -mx.where(mask, targets * log_probs, 0.0).sum(axis=-1)
|
|
399
|
+
mx.eval(ce, log_probs)
|
|
400
|
+
probs = np.exp(np.where(np.array(mask), np.array(log_probs), -np.inf))
|
|
401
|
+
for row, (_, target, _, _), loss in zip(probs, group, np.array(ce)):
|
|
402
|
+
row = row[: len(target)]
|
|
403
|
+
losses.append(float(loss))
|
|
404
|
+
top.append(float(row.max()))
|
|
405
|
+
correct.append(int(row.argmax()) == int(np.argmax(target)))
|
|
406
|
+
model.train()
|
|
407
|
+
return {"loss": float(np.mean(losses)), "accuracy": float(np.mean(correct)),
|
|
408
|
+
"expected_calibration_error": expected_calibration_error(top, correct),
|
|
409
|
+
"coverage_at_5pct_error": coverage_at_error(top, correct, 0.05), "count": len(losses)}
|
|
410
|
+
|
|
411
|
+
def save(self, directory: Path, history: list[dict]) -> None:
|
|
412
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
weights = dict(tree_flatten(self.engine.model.trainable_parameters()))
|
|
414
|
+
mx.save_safetensors(str(directory / "adapters.safetensors"), weights)
|
|
415
|
+
(directory / "adapter_config.json").write_text(json.dumps(
|
|
416
|
+
{"fine_tune_type": "lora", "num_layers": self.config.lora_layers, "lora_parameters": self.lora}, indent=2) + "\n")
|
|
417
|
+
data = [{"path": str(p), "sha256": hashlib.sha256(p.read_bytes()).hexdigest()} for p in self.config.data]
|
|
418
|
+
meta = {"config": {**config_json(self.config), "data": data},
|
|
419
|
+
"base_revision": self.engine.revision, "train_records": len(self.train_records),
|
|
420
|
+
"train_examples": len(self.train_examples), "valid_examples": len(self.valid_examples), "history": history}
|
|
421
|
+
(directory / "training.json").write_text(json.dumps(meta, indent=2) + "\n")
|
|
422
|
+
|
|
423
|
+
def run(self, log=print) -> list[dict]:
|
|
424
|
+
config = self.config
|
|
425
|
+
model = self.engine.model
|
|
426
|
+
model.train()
|
|
427
|
+
schedule = optim.join_schedules(
|
|
428
|
+
[optim.linear_schedule(1e-7, config.learning_rate, config.warmup),
|
|
429
|
+
optim.cosine_decay(config.learning_rate, max(config.steps - config.warmup, 1), config.learning_rate * 0.05)],
|
|
430
|
+
[config.warmup],
|
|
431
|
+
)
|
|
432
|
+
optimizer = optim.AdamW(learning_rate=schedule, weight_decay=0.0)
|
|
433
|
+
step_fn = nn.value_and_grad(model, self.loss)
|
|
434
|
+
trainable = sum(v.size for _, v in tree_flatten(model.trainable_parameters()))
|
|
435
|
+
log(f"trainable parameters {trainable:,}; {len(self.train_examples):,} train and {len(self.valid_examples):,} validation examples")
|
|
436
|
+
|
|
437
|
+
batches = self._batches()
|
|
438
|
+
if self.checkpoint is None:
|
|
439
|
+
history = [{"step": 0, **self.validate()}]
|
|
440
|
+
log(json.dumps(history[-1]))
|
|
441
|
+
self.save(config.output, history)
|
|
442
|
+
first, tokens_seen, elapsed = 1, 0, 0.0
|
|
443
|
+
else:
|
|
444
|
+
weights, optimizer_state, state = self.checkpoint
|
|
445
|
+
model.load_weights(list(weights.items()), strict=False)
|
|
446
|
+
optimizer.state = optimizer_state
|
|
447
|
+
mx.eval(model.parameters(), optimizer.state)
|
|
448
|
+
batches.restore(state["stream"])
|
|
449
|
+
history, tokens_seen, elapsed = state["history"], state["tokens_seen"], state["elapsed"]
|
|
450
|
+
first = state["step"] + 1
|
|
451
|
+
log(f"resuming from step {state['step']}")
|
|
452
|
+
best = min(entry["loss"] for entry in history)
|
|
453
|
+
started, window = time.perf_counter() - elapsed, []
|
|
454
|
+
for step in range(first, config.steps + 1):
|
|
455
|
+
# Dropout draws from a per-step key, so a resumed run masks exactly as the uninterrupted one would.
|
|
456
|
+
mx.random.seed(int(np.random.SeedSequence((config.seed, step)).generate_state(1)[0]))
|
|
457
|
+
tokens, last, ids, mask, targets, weights, states = next(batches)
|
|
458
|
+
loss, grads = step_fn(model, tokens, last, ids, mask, targets, weights, *self._vision(tokens, last, states))
|
|
459
|
+
grads, _ = optim.clip_grad_norm(grads, config.max_grad_norm)
|
|
460
|
+
optimizer.update(model, grads)
|
|
461
|
+
mx.eval(model.trainable_parameters(), optimizer.state, loss)
|
|
462
|
+
window.append(float(loss))
|
|
463
|
+
tokens_seen += tokens.size
|
|
464
|
+
if step % config.eval_every == 0 or step == config.steps:
|
|
465
|
+
elapsed = time.perf_counter() - started
|
|
466
|
+
entry = {"step": step, "train_loss": float(np.mean(window)), "tokens_per_second": tokens_seen / elapsed,
|
|
467
|
+
**self.validate()}
|
|
468
|
+
window = []
|
|
469
|
+
history.append(entry)
|
|
470
|
+
log(json.dumps(entry))
|
|
471
|
+
if entry["loss"] < best:
|
|
472
|
+
best = entry["loss"]
|
|
473
|
+
self.save(config.output, history)
|
|
474
|
+
else:
|
|
475
|
+
meta = json.loads((config.output / "training.json").read_text())
|
|
476
|
+
meta["history"] = history
|
|
477
|
+
(config.output / "training.json").write_text(json.dumps(meta, indent=2) + "\n")
|
|
478
|
+
save_checkpoint(config.output / CHECKPOINT, dict(tree_flatten(model.trainable_parameters())), optimizer.state,
|
|
479
|
+
{"step": step, "tokens_seen": tokens_seen, "elapsed": elapsed, "stream": batches.state(),
|
|
480
|
+
"history": history, "config": config_json(config)})
|
|
481
|
+
return history
|
ruling/vision.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Local vision-language models: the images in a state are read with its text, once per request.
|
|
2
|
+
|
|
3
|
+
`vision:<MLX model>` loads the checkpoint through mlx-vlm, vision tower
|
|
4
|
+
included, where the text engine's mlx-lm loads only the language half. The
|
|
5
|
+
state's text names each image `<image 1>`, `<image 2>`, …; the images follow it
|
|
6
|
+
in the prompt under those names. The state and its images are encoded once
|
|
7
|
+
into the prefix cache and every question is scored against it, as for text.
|
|
8
|
+
Images shorten the multimodal positions (a grid of tokens takes fewer
|
|
9
|
+
positions than tokens), so each question's positions continue from where the
|
|
10
|
+
prefix's left off rather than from its token count.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import io
|
|
17
|
+
|
|
18
|
+
import mlx.core as mx
|
|
19
|
+
from mlx_lm.models.cache import make_prompt_cache
|
|
20
|
+
|
|
21
|
+
from ruling.calibration import Calibration
|
|
22
|
+
from ruling.engine import Engine, _Prefix
|
|
23
|
+
from ruling.prompt import SPLIT_MARKER
|
|
24
|
+
from ruling.questions import IMAGE_REFERENCE, Image, State, state_images
|
|
25
|
+
|
|
26
|
+
VISION_PREFIX = "vision:"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class VisionEngine(Engine):
|
|
30
|
+
def __init__(self, model_id: str, calibration: Calibration, max_input_tokens: int, rotations: int,
|
|
31
|
+
prior_debias: bool, max_image_tokens: int, max_branch_tokens: int | None = None):
|
|
32
|
+
self.max_image_tokens = max_image_tokens
|
|
33
|
+
super().__init__(model_id.removeprefix(VISION_PREFIX), Calibration(), max_input_tokens, rotations,
|
|
34
|
+
prior_debias, None, max_branch_tokens)
|
|
35
|
+
self.model_id = VISION_PREFIX + self.model_id
|
|
36
|
+
self.reads_images = True
|
|
37
|
+
calibration.check(self.provenance)
|
|
38
|
+
self.calibration = calibration
|
|
39
|
+
|
|
40
|
+
def _load(self, model_id: str, adapter: None):
|
|
41
|
+
try:
|
|
42
|
+
from mlx_vlm import load
|
|
43
|
+
except ImportError as exc:
|
|
44
|
+
raise ImportError("vision: models need the vision extra: pip install 'ruling[vision]'") from exc
|
|
45
|
+
self.vlm, self.processor = load(model_id)
|
|
46
|
+
# A full-size photo would otherwise become tens of thousands of tokens; one token covers a
|
|
47
|
+
# (patch × merge)² square of pixels, so this caps each image at max_image_tokens.
|
|
48
|
+
images = self.processor.image_processor
|
|
49
|
+
side = images.patch_size * images.merge_size
|
|
50
|
+
images.max_pixels = self.max_image_tokens * side * side
|
|
51
|
+
images.min_pixels = min(images.min_pixels, images.max_pixels)
|
|
52
|
+
return self.vlm.language_model, self.processor.tokenizer
|
|
53
|
+
|
|
54
|
+
def _user_content(self, state: State, rendered_state: str) -> str | list[dict]:
|
|
55
|
+
images = state_images(state)
|
|
56
|
+
if not images:
|
|
57
|
+
return super()._user_content(state, rendered_state)
|
|
58
|
+
parts = [{"type": "text", "text": rendered_state}]
|
|
59
|
+
for number in range(1, len(images) + 1):
|
|
60
|
+
parts += [{"type": "text", "text": f"{IMAGE_REFERENCE.format(number)}:"}, {"type": "image"}]
|
|
61
|
+
return parts + [{"type": "text", "text": SPLIT_MARKER}]
|
|
62
|
+
|
|
63
|
+
def _prefix(self, state: State) -> _Prefix:
|
|
64
|
+
images = state_images(state)
|
|
65
|
+
if not images:
|
|
66
|
+
return super()._prefix(state)
|
|
67
|
+
from mlx_vlm.utils import prepare_inputs
|
|
68
|
+
|
|
69
|
+
prefix_text, tail = self.split_prompt(state)
|
|
70
|
+
inputs = prepare_inputs(self.processor, images=[_picture(n, image) for n, image in enumerate(images, 1)],
|
|
71
|
+
prompts=prefix_text, image_token_index=self.vlm.config.image_token_index)
|
|
72
|
+
ids, grid = inputs["input_ids"], inputs["image_grid_thw"]
|
|
73
|
+
_, deltas = self.vlm.language_model.get_rope_index(ids, grid, None, inputs["attention_mask"])
|
|
74
|
+
return _Prefix(tokens=ids[0].tolist(), tail=tail,
|
|
75
|
+
vision_inputs={"pixel_values": inputs["pixel_values"], "image_grid_thw": grid},
|
|
76
|
+
position_delta=int(deltas.reshape(-1)[0]))
|
|
77
|
+
|
|
78
|
+
def _encode_prefix(self, prefix: _Prefix):
|
|
79
|
+
if prefix.vision_inputs is None:
|
|
80
|
+
return super()._encode_prefix(prefix)
|
|
81
|
+
ids = mx.array([prefix.tokens])
|
|
82
|
+
features = self.vlm.get_input_embeddings(ids, prefix.vision_inputs["pixel_values"],
|
|
83
|
+
image_grid_thw=prefix.vision_inputs["image_grid_thw"],
|
|
84
|
+
mask=mx.ones(ids.shape, dtype=mx.int32))
|
|
85
|
+
cache = make_prompt_cache(self.model)
|
|
86
|
+
# Hidden states only: the output head never runs over the prefix.
|
|
87
|
+
self.model.model(ids, inputs_embeds=features.inputs_embeds, cache=cache, position_ids=features.position_ids)
|
|
88
|
+
mx.eval([c.state for c in cache])
|
|
89
|
+
return cache
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _picture(number: int, image: Image):
|
|
93
|
+
# Pillow comes with the vision extra, so it is imported only where an image is read.
|
|
94
|
+
from PIL import Image as Picture
|
|
95
|
+
from PIL import UnidentifiedImageError
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
return Picture.open(io.BytesIO(base64.b64decode(image.data))).convert("RGB")
|
|
99
|
+
except UnidentifiedImageError as exc:
|
|
100
|
+
raise ValueError(f"{IMAGE_REFERENCE.format(number)} is not a readable {image.media_type}") from exc
|