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/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