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/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)
|
ruling/decision_train.py
ADDED
|
@@ -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
|