sysone 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.
sysone/evaluation.py ADDED
@@ -0,0 +1,167 @@
1
+ """Evaluation: score predictions against labels, baselines, and side-by-side comparison.
2
+
3
+ Labels are read exactly as `fit` reads them (`expected`, a `label` inside a question, or soft
4
+ `gold`), and each becomes a target distribution over the question's options. So one set of
5
+ metrics covers hard and soft labels alike:
6
+
7
+ - **accuracy**: the predicted top option is a top option of the target. For a hard label this is
8
+ ordinary accuracy; for soft labels it is agreement with the raters' majority. When several
9
+ predicted options tie for the top, the share of them that are right counts, so a uniform guess
10
+ scores exactly 1/k.
11
+ - **distance**: total variation distance between predicted and target distributions
12
+ (0 = identical, 1 = completely different).
13
+ - **log_loss**: cross-entropy of the predictions against the target.
14
+ - **score_error**: for score questions, the absolute gap between predicted and target expected level.
15
+ - **auc**: for noul questions, how well P(true) ranks true above false targets.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from collections import Counter, defaultdict
22
+ from collections.abc import Mapping, Sequence
23
+ from typing import Any
24
+
25
+ from .data import Example, examples_from
26
+ from .questions import QUESTION_TYPES
27
+
28
+ METRICS = ("accuracy", "distance", "log_loss", "score_error", "auc")
29
+ _TIE = 1e-9
30
+ _LOG_FLOOR = 1e-12
31
+
32
+
33
+ def question_key(example: Example) -> str:
34
+ """How questions are grouped in `by_question`: `category/qid`, or `qid` without a category."""
35
+ return "%s/%s" % (example.category, example.qid) if example.category is not None else example.qid
36
+
37
+
38
+ def _auc(scores: Sequence[float], positives: Sequence[bool]) -> float | None:
39
+ pos = [s for s, y in zip(scores, positives) if y]
40
+ neg = [s for s, y in zip(scores, positives) if not y]
41
+ if not pos or not neg:
42
+ return None
43
+ wins = sum((p > n) + 0.5 * (p == n) for p in pos for n in neg)
44
+ return wins / (len(pos) * len(neg))
45
+
46
+
47
+ def _summarize(items: list[dict]) -> dict:
48
+ out: dict[str, Any] = {"n": len(items)}
49
+ out["accuracy"] = sum(it["hit"] for it in items) / len(items)
50
+ out["distance"] = sum(it["distance"] for it in items) / len(items)
51
+ out["log_loss"] = sum(it["log_loss"] for it in items) / len(items)
52
+ gaps = [it["level_gap"] for it in items if it["level_gap"] is not None]
53
+ if gaps:
54
+ out["score_error"] = sum(gaps) / len(gaps)
55
+ noul = [it for it in items if it["type"] == "noul" and it["noul_label"] is not None]
56
+ auc = _auc([it["p_true"] for it in noul], [it["noul_label"] for it in noul])
57
+ if auc is not None:
58
+ out["auc"] = auc
59
+ return out
60
+
61
+
62
+ def score(examples: Sequence[Example], probabilities: Sequence[Sequence[float]],
63
+ skipped: Mapping | None = None) -> dict:
64
+ """Score each example's predicted option probabilities against its target.
65
+
66
+ Returns a plain dict: `n_questions`, `overall`, `by_type`, `by_category`, `by_question`, and
67
+ `skipped` (questions without labels). Each group holds `n` and the metrics that apply to it.
68
+ """
69
+ items = []
70
+ for ex, p in zip(examples, probabilities):
71
+ t = ex.target
72
+ p = [float(v) for v in p[: len(t)]]
73
+ best = max(t)
74
+ # The prediction's top options; if several tie, credit the share of them that are right,
75
+ # which is the expected accuracy of breaking the tie at random (so a uniform guess scores 1/k).
76
+ top = max(p)
77
+ picked = [i for i, v in enumerate(p) if v >= top - _TIE]
78
+ item = {
79
+ "type": ex.question.type,
80
+ "category": ex.category,
81
+ "question": question_key(ex),
82
+ "hit": sum(t[i] >= best - _TIE for i in picked) / len(picked),
83
+ "distance": 0.5 * sum(abs(a - b) for a, b in zip(p, t)),
84
+ "log_loss": -sum(b * math.log(max(a, _LOG_FLOOR)) for a, b in zip(p, t) if b > 0),
85
+ "level_gap": None,
86
+ "p_true": None,
87
+ "noul_label": None,
88
+ }
89
+ if ex.question.type == "score":
90
+ item["level_gap"] = abs(sum(i * a for i, a in enumerate(p)) - sum(i * b for i, b in enumerate(t)))
91
+ if ex.question.type == "noul":
92
+ item["p_true"] = p[1]
93
+ if abs(t[1] - t[0]) > _TIE: # a tied soft label has no side
94
+ item["noul_label"] = t[1] > t[0]
95
+ items.append(item)
96
+
97
+ report: dict[str, Any] = {"n_questions": len(items), "skipped": dict(skipped or {})}
98
+ if not items:
99
+ report.update(overall=None, by_type={}, by_category={}, by_question={})
100
+ return report
101
+ report["overall"] = _summarize(items)
102
+ for name, field in (("by_type", "type"), ("by_category", "category"), ("by_question", "question")):
103
+ groups: dict = defaultdict(list)
104
+ for it in items:
105
+ if it[field] is not None:
106
+ groups[it[field]].append(it)
107
+ order = QUESTION_TYPES if field == "type" else sorted(groups, key=str)
108
+ report[name] = {key: _summarize(groups[key]) for key in order if key in groups}
109
+ return report
110
+
111
+
112
+ def baselines(data, train=None, questions: Mapping | None = None) -> dict[str, dict]:
113
+ """Reports for simple guessers on the labelled `data`, to compare a model against.
114
+
115
+ - **random guess**: every option equally likely.
116
+ - **average answer** (when `train` is given): for each question (`category/qid`, same number
117
+ of options), the average target over the training data. Where a question never appears in
118
+ `train`, it falls back to the random guess.
119
+ """
120
+ examples, skipped = examples_from(data, questions, labeled=True)
121
+ uniform = [[1.0 / ex.question.k] * ex.question.k for ex in examples]
122
+ out = {"random guess": score(examples, uniform, skipped)}
123
+ if train is not None:
124
+ train_examples, _ = examples_from(train, questions, labeled=True)
125
+ sums: dict = {}
126
+ counts: Counter = Counter()
127
+ for ex in train_examples:
128
+ key = (question_key(ex), ex.question.k)
129
+ acc = sums.setdefault(key, [0.0] * ex.question.k)
130
+ for i, v in enumerate(ex.target):
131
+ acc[i] += v
132
+ counts[key] += 1
133
+ prior = []
134
+ for ex in examples:
135
+ key = (question_key(ex), ex.question.k)
136
+ prior.append([v / counts[key] for v in sums[key]] if key in sums else [1.0 / ex.question.k] * ex.question.k)
137
+ out["average answer"] = score(examples, prior, skipped)
138
+ return out
139
+
140
+
141
+ def compare(reports: Mapping[str, dict], by: str = "type", metric: str = "accuracy"):
142
+ """A pandas table comparing reports side by side, one row per report.
143
+
144
+ - `by="overall"`: every metric as a column.
145
+ - `by="type"`, `"category"` or `"question"`: one column per group, plus `overall`, showing
146
+ `metric` (default `"accuracy"`).
147
+ """
148
+ try:
149
+ import pandas as pd
150
+ except ImportError as exc:
151
+ raise ImportError("compare() needs pandas: pip install sysone[pandas]") from exc
152
+ if by == "overall":
153
+ rows = {name: {m: r["overall"].get(m) for m in METRICS if r["overall"] and m in r["overall"]}
154
+ for name, r in reports.items()}
155
+ return pd.DataFrame(rows).T
156
+ key = {"type": "by_type", "category": "by_category", "question": "by_question"}.get(by)
157
+ if key is None:
158
+ raise ValueError("by must be 'overall', 'type', 'category' or 'question', got %r" % (by,))
159
+ if metric not in METRICS:
160
+ raise ValueError("metric must be one of %s, got %r" % (", ".join(METRICS), metric))
161
+ rows = {}
162
+ for name, r in reports.items():
163
+ row = {group: stats.get(metric) for group, stats in r[key].items()}
164
+ if r["overall"]:
165
+ row["overall"] = r["overall"].get(metric)
166
+ rows[name] = row
167
+ return pd.DataFrame(rows).T
sysone/features.py ADDED
@@ -0,0 +1,98 @@
1
+ """Option and summary vectors from a frozen backbone.
2
+
3
+ With `tuning="head"` nothing in the backbone learns, so each example's vectors never change: they
4
+ are computed once and cached on the CPU, and every epoch trains the head on the cache.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable, Sequence
10
+ from dataclasses import dataclass
11
+
12
+ import torch
13
+
14
+ from ._util import autocast, empty_device_cache, is_out_of_memory
15
+ from .architectures.base import Architecture
16
+ from .layout import Encoded, collate
17
+
18
+ #: Vectors are cached in bf16: the same exponent range as fp32 at half the memory.
19
+ CACHE_DTYPE = torch.bfloat16
20
+ _START_BATCH = 32
21
+
22
+
23
+ @dataclass
24
+ class Features:
25
+ options: torch.Tensor # [k, H]
26
+ summary: torch.Tensor # [H]
27
+
28
+
29
+ def read_vectors(hidden: torch.Tensor, batch: dict[str, torch.Tensor]) -> tuple[torch.Tensor, torch.Tensor]:
30
+ """Gather option vectors `[B, K, H]` and summary vectors `[B, H]` from hidden states."""
31
+ rows = torch.arange(hidden.shape[0], device=hidden.device)
32
+ options = hidden[rows[:, None], batch["read_positions"]]
33
+ summary = hidden[rows, batch["summary_positions"]]
34
+ return options, summary
35
+
36
+
37
+ def run_in_batches(items: Sequence, fn: Callable[[list], list], start: int = _START_BATCH,
38
+ length: Callable | None = None) -> list:
39
+ """Apply `fn` to batches of `items` and return results in input order.
40
+
41
+ Items are batched by similar length, so little compute goes to padding. If a batch runs out of
42
+ memory, the batch size is halved and the batch retried.
43
+ """
44
+ order = sorted(range(len(items)), key=(lambda i: -length(items[i])) if length else None)
45
+ results: list = [None] * len(items)
46
+ size, i = start, 0
47
+ while i < len(order):
48
+ chunk = order[i: i + size]
49
+ try:
50
+ outputs = fn([items[j] for j in chunk])
51
+ except (RuntimeError, torch.OutOfMemoryError) as exc:
52
+ if not is_out_of_memory(exc) or size == 1:
53
+ raise
54
+ size = max(1, size // 2)
55
+ empty_device_cache()
56
+ continue
57
+ for j, out in zip(chunk, outputs):
58
+ results[j] = out
59
+ i += len(chunk)
60
+ return results
61
+
62
+
63
+ @torch.no_grad()
64
+ def extract(arch: Architecture, backbone, encoded: Sequence[Encoded], pad_id: int,
65
+ device: torch.device, autocast_dtype: torch.dtype | None = None) -> list[Features]:
66
+ """Run the backbone, without gradients, over `encoded` and return each example's vectors on
67
+ the CPU. `autocast_dtype` is needed when trained weights are kept in fp32 next to frozen
68
+ half-precision ones."""
69
+ backbone.eval()
70
+
71
+ def batch_fn(chunk: list[Encoded]) -> list[Features]:
72
+ batch = {k: v.to(device) for k, v in collate(chunk, pad_id).items()}
73
+ with autocast(device, autocast_dtype):
74
+ hidden = arch.hidden_states(backbone, batch)
75
+ options, summary = read_vectors(hidden, batch)
76
+ options, summary = options.to("cpu", CACHE_DTYPE), summary.to("cpu", CACHE_DTYPE)
77
+ return [Features(options[b, : len(e.read_positions)].clone(), summary[b].clone())
78
+ for b, e in enumerate(chunk)]
79
+
80
+ return run_in_batches(encoded, batch_fn, length=lambda e: len(e.input_ids))
81
+
82
+
83
+ def collate_features(items: Sequence[Features], device: torch.device) -> tuple[torch.Tensor, ...]:
84
+ """Pad cached vectors into `(options [B, K, H], option_mask [B, K], summary [B, H])`."""
85
+ n = len(items)
86
+ k = max(f.options.shape[0] for f in items)
87
+ hidden = items[0].summary.shape[-1]
88
+ options = torch.zeros(n, k, hidden, dtype=torch.float32)
89
+ mask = torch.zeros(n, k, dtype=torch.bool)
90
+ for i, f in enumerate(items):
91
+ options[i, : f.options.shape[0]] = f.options.float()
92
+ mask[i, : f.options.shape[0]] = True
93
+ summary = torch.stack([f.summary.float() for f in items])
94
+ return options.to(device), mask.to(device), summary.to(device)
95
+
96
+
97
+ def cache_bytes(n_vectors: int, hidden: int) -> int:
98
+ return n_vectors * hidden * torch.finfo(CACHE_DTYPE).bits // 8
sysone/heads.py ADDED
@@ -0,0 +1,66 @@
1
+ """Decision heads: turn option vectors and a summary vector into one logit per option."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+ from torch import nn
9
+
10
+ from .questions import QUESTION_TYPES
11
+
12
+ #: Logit for padded option slots. Finite, so a zero target times this stays zero in the loss.
13
+ MASKED_LOGIT = -1e4
14
+
15
+
16
+ def _num_heads(width: int) -> int:
17
+ heads = max(1, width // 64)
18
+ while width % heads:
19
+ heads -= 1
20
+ return heads
21
+
22
+
23
+ class CompactHead(nn.Module):
24
+ """Works on `[type embedding, option vectors…, summary vector]`, not the whole sequence.
25
+
26
+ The vectors are projected to `head_dim`, passed through `head_layers` pre-norm self-attention
27
+ blocks with no positional encoding (so the options are treated as a set), and each option is
28
+ scored as the scaled dot product of a projection of the summary and a projection of the
29
+ option. This works for any number of options. The head always runs in fp32.
30
+ """
31
+
32
+ def __init__(self, hidden_size: int, head_dim: int, head_layers: int = 2, dropout: float = 0.0):
33
+ super().__init__()
34
+ self.head_dim = head_dim
35
+ self.input_proj = nn.Linear(hidden_size, head_dim)
36
+ self.type_embedding = nn.Embedding(len(QUESTION_TYPES), head_dim)
37
+ self.input_norm = nn.LayerNorm(head_dim)
38
+ if head_layers > 0:
39
+ layer = nn.TransformerEncoderLayer(head_dim, _num_heads(head_dim), 4 * head_dim, dropout,
40
+ activation="gelu", batch_first=True, norm_first=True)
41
+ self.blocks = nn.TransformerEncoder(layer, head_layers, enable_nested_tensor=False)
42
+ else:
43
+ self.blocks = None
44
+ self.final_norm = nn.LayerNorm(head_dim)
45
+ self.summary_proj = nn.Linear(head_dim, head_dim)
46
+ self.option_proj = nn.Linear(head_dim, head_dim)
47
+
48
+ def forward(self, options: torch.Tensor, option_mask: torch.Tensor, summary: torch.Tensor,
49
+ qtype: torch.Tensor) -> torch.Tensor:
50
+ """options `[B, K, H]`, option_mask `[B, K]` (True = real), summary `[B, H]`, qtype `[B]`
51
+ -> logits `[B, K]`, with padded slots at `MASKED_LOGIT`."""
52
+ with torch.autocast(device_type=options.device.type, enabled=False):
53
+ options, summary = options.float(), summary.float()
54
+ n = options.shape[0]
55
+ x = torch.cat([self.type_embedding(qtype)[:, None], self.input_proj(options),
56
+ self.input_proj(summary)[:, None]], dim=1)
57
+ x = self.input_norm(x)
58
+ always = torch.zeros(n, 1, dtype=torch.bool, device=options.device)
59
+ padding = torch.cat([always, ~option_mask, always], dim=1) # True = ignore
60
+ if self.blocks is not None:
61
+ x = self.blocks(x, src_key_padding_mask=padding)
62
+ x = self.final_norm(x)
63
+ s = self.summary_proj(x[:, -1])
64
+ o = self.option_proj(x[:, 1:-1])
65
+ logits = torch.einsum("bd,bkd->bk", s, o) / math.sqrt(self.head_dim)
66
+ return logits.masked_fill(~option_mask, MASKED_LOGIT)
sysone/layout.py ADDED
@@ -0,0 +1,129 @@
1
+ """Token layouts and attention masks shared by every architecture.
2
+
3
+ An architecture describes a sequence as `Pieces`: shared tokens before the options (`prefix`),
4
+ one token list per option, and shared tokens after them (`suffix`). `build` lays the pieces out:
5
+
6
+ - **parallel**: every option starts at the same position id, the first after the prefix. Tokens
7
+ after the options continue from the position after the longest option. With `attention_masks`
8
+ blocking option-to-option attention, reordering the options only reorders their vectors.
9
+ - **sequential**: ordinary positions and full attention between options.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+ import torch
17
+
18
+ LAYOUTS = ("parallel", "sequential")
19
+
20
+
21
+ @dataclass
22
+ class Pieces:
23
+ prefix: list[int]
24
+ options: list[list[int]]
25
+ option_read: list[int] # index, within each option's tokens, of the token its vector is read from
26
+ suffix: list[int]
27
+ summary: tuple[str, int] # ("prefix" | "suffix", index into that piece; negative counts from the end)
28
+
29
+
30
+ @dataclass
31
+ class Encoded:
32
+ input_ids: list[int]
33
+ position_ids: list[int]
34
+ option_ids: list[int] # 0 for shared tokens, s + 1 for tokens of the option in slot s
35
+ read_positions: list[int]
36
+ summary_position: int
37
+
38
+
39
+ def build(pieces: Pieces, layout: str) -> Encoded:
40
+ if layout not in LAYOUTS:
41
+ raise ValueError("layout must be one of %s, got %r" % (", ".join(LAYOUTS), layout))
42
+ if not pieces.options:
43
+ raise ValueError("a question needs at least one option")
44
+ ids = list(pieces.prefix)
45
+ start = len(ids)
46
+ positions = list(range(start))
47
+ option_ids = [0] * start
48
+ read = []
49
+ for slot, (tokens, offset) in enumerate(zip(pieces.options, pieces.option_read)):
50
+ if not tokens:
51
+ raise ValueError("option %d has no tokens" % slot)
52
+ read.append(len(ids) + (offset % len(tokens)))
53
+ if layout == "parallel":
54
+ positions.extend(range(start, start + len(tokens)))
55
+ else:
56
+ positions.extend(range(len(ids), len(ids) + len(tokens)))
57
+ option_ids.extend([slot + 1 if layout == "parallel" else 0] * len(tokens))
58
+ ids.extend(tokens)
59
+ after = positions[-1] + 1 if layout == "sequential" else start + max(len(t) for t in pieces.options)
60
+ suffix_start = len(ids)
61
+ ids.extend(pieces.suffix)
62
+ positions.extend(range(after, after + len(pieces.suffix)))
63
+ option_ids.extend([0] * len(pieces.suffix))
64
+
65
+ where, index = pieces.summary
66
+ if where == "prefix":
67
+ summary = index % len(pieces.prefix)
68
+ elif where == "suffix":
69
+ summary = suffix_start + index % len(pieces.suffix)
70
+ else:
71
+ raise ValueError("summary must be in the 'prefix' or the 'suffix', got %r" % (where,))
72
+ return Encoded(ids, positions, option_ids, read, summary)
73
+
74
+
75
+ def collate(encoded: list[Encoded], pad_id: int) -> dict[str, torch.Tensor]:
76
+ """Pad a list of encodings into batch tensors."""
77
+ n = len(encoded)
78
+ length = max(len(e.input_ids) for e in encoded)
79
+ k = max(len(e.read_positions) for e in encoded)
80
+ input_ids = torch.full((n, length), pad_id, dtype=torch.long)
81
+ position_ids = torch.zeros((n, length), dtype=torch.long)
82
+ option_ids = torch.zeros((n, length), dtype=torch.long)
83
+ real = torch.zeros((n, length), dtype=torch.bool)
84
+ read = torch.zeros((n, k), dtype=torch.long)
85
+ option_mask = torch.zeros((n, k), dtype=torch.bool)
86
+ summary = torch.zeros(n, dtype=torch.long)
87
+ for i, e in enumerate(encoded):
88
+ m = len(e.input_ids)
89
+ input_ids[i, :m] = torch.tensor(e.input_ids)
90
+ position_ids[i, :m] = torch.tensor(e.position_ids)
91
+ option_ids[i, :m] = torch.tensor(e.option_ids)
92
+ real[i, :m] = True
93
+ read[i, : len(e.read_positions)] = torch.tensor(e.read_positions)
94
+ option_mask[i, : len(e.read_positions)] = True
95
+ summary[i] = e.summary_position
96
+ return {
97
+ "input_ids": input_ids,
98
+ "position_ids": position_ids,
99
+ "option_ids": option_ids,
100
+ "real": real,
101
+ "read_positions": read,
102
+ "option_mask": option_mask,
103
+ "summary_positions": summary,
104
+ }
105
+
106
+
107
+ def attention_mask(real: torch.Tensor, position_ids: torch.Tensor, option_ids: torch.Tensor, *,
108
+ causal: bool, sliding_window: int | None = None) -> torch.Tensor:
109
+ """A boolean `[batch, 1, query, key]` mask, True where attention is allowed.
110
+
111
+ A token may attend to every real token, except:
112
+ - tokens of a different option;
113
+ - later tokens, when `causal`;
114
+ - tokens more than `sliding_window` positions away, measured in position ids (options share
115
+ positions, so a sequence-index window would let one option see more context than another).
116
+
117
+ Padded query rows attend only to themselves, so no row is fully masked.
118
+ """
119
+ n, length = real.shape
120
+ allowed = real[:, None, :].expand(n, length, length)
121
+ if causal:
122
+ allowed = allowed & torch.ones(length, length, dtype=torch.bool, device=real.device).tril()
123
+ q_opt, k_opt = option_ids[:, :, None], option_ids[:, None, :]
124
+ allowed = allowed & ~((q_opt > 0) & (k_opt > 0) & (q_opt != k_opt))
125
+ if sliding_window is not None:
126
+ allowed = allowed & ((position_ids[:, :, None] - position_ids[:, None, :]).abs() <= sliding_window)
127
+ eye = torch.eye(length, dtype=torch.bool, device=real.device)
128
+ allowed = (allowed & real[:, :, None]) | (eye & ~real[:, :, None])
129
+ return allowed[:, None]
sysone/losses.py ADDED
@@ -0,0 +1,17 @@
1
+ """Training objectives over each row's own options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+
7
+ LOSSES = ("soft-ce",)
8
+
9
+
10
+ def soft_ce(logits: torch.Tensor, target: torch.Tensor, option_mask: torch.Tensor,
11
+ reduction: str = "mean") -> torch.Tensor:
12
+ """Cross-entropy against a target distribution. For one-hot targets this is ordinary
13
+ cross-entropy. Padded option slots carry no target mass. `reduction="none"` gives one loss
14
+ per row."""
15
+ logp = torch.log_softmax(logits.float(), dim=-1)
16
+ rows = -(target * logp * option_mask).sum(-1)
17
+ return rows if reduction == "none" else rows.mean()
sysone/models.py ADDED
@@ -0,0 +1,24 @@
1
+ """The public model classes. They share every parameter; only a few defaults differ."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import SystemOneModel
6
+
7
+
8
+ class EncoderModel(SystemOneModel):
9
+ """A decision model on a bidirectional encoder, ModernBERT by default.
10
+
11
+ Trains every layer by default (`tuning="full"`): adapters barely moved ModernBERT, which was
12
+ pretrained only to fill in masked words, and a model this size is affordable to train fully.
13
+ """
14
+
15
+ def __init__(self, model_name: str = "answerdotai/ModernBERT-base", *, num_layers="all", tuning="full",
16
+ **kwargs):
17
+ super().__init__(model_name, num_layers=num_layers, tuning=tuning, **kwargs)
18
+
19
+
20
+ class DecoderModel(SystemOneModel):
21
+ """A decision model on a causal decoder, Qwen3 by default."""
22
+
23
+ def __init__(self, model_name: str = "Qwen/Qwen3-0.6B", *, num_layers=0.7, **kwargs):
24
+ super().__init__(model_name, num_layers=num_layers, **kwargs)
sysone/prompts.py ADDED
@@ -0,0 +1,60 @@
1
+ """Prompt formats: the text of each piece of a question.
2
+
3
+ A model trained on one wording reads another as a different input, so the format is saved with
4
+ the model and reused at prediction time.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import asdict, dataclass, field
10
+
11
+ from .questions import Question
12
+
13
+
14
+ def _default_task_lines() -> dict:
15
+ return {
16
+ "choice": "Choose the best option.",
17
+ "score": "Rate it on the given scale.",
18
+ "noul": "Answer yes or no.",
19
+ }
20
+
21
+
22
+ @dataclass
23
+ class PromptFormat:
24
+ task_lines: dict = field(default_factory=_default_task_lines)
25
+ question: str = "Question: {instructions}"
26
+ choice_option: str = "{label}: {description}"
27
+ score_option: str = "{index}: {level}"
28
+ noul_labels: tuple = ("No", "Yes")
29
+
30
+ def head(self, question: Question) -> str:
31
+ """The task line and the question, as one piece of text."""
32
+ return "%s\n%s" % (self.task_lines[question.type], self.question.format(instructions=question.instructions))
33
+
34
+ def options(self, question: Question) -> list[str]:
35
+ """The text of each option, in the question's option order."""
36
+ if question.type == "choice":
37
+ return [
38
+ str(key) if text is None else self.choice_option.format(label=key, description=text)
39
+ for key, text in zip(question.keys, question.texts)
40
+ ]
41
+ if question.type == "score":
42
+ # The index must be in the text: in the parallel layout every option shares position
43
+ # ids, so the text is the only place the order of the levels survives.
44
+ return [self.score_option.format(index=i, level=text) for i, text in enumerate(question.texts)]
45
+ return [
46
+ label if text is None else "%s: %s" % (label, text)
47
+ for label, text in zip(self.noul_labels, question.texts)
48
+ ]
49
+
50
+ def to_dict(self) -> dict:
51
+ d = asdict(self)
52
+ d["noul_labels"] = list(self.noul_labels)
53
+ return d
54
+
55
+ @classmethod
56
+ def from_dict(cls, d: dict) -> PromptFormat:
57
+ d = dict(d)
58
+ if "noul_labels" in d:
59
+ d["noul_labels"] = tuple(d["noul_labels"])
60
+ return cls(**d)