plumbify 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.
- plumbify/__init__.py +20 -0
- plumbify/artifact.py +102 -0
- plumbify/branch.py +370 -0
- plumbify/calibration.py +109 -0
- plumbify/cli.py +61 -0
- plumbify/core/__init__.py +6 -0
- plumbify/core/answers.py +75 -0
- plumbify/core/decision_head.py +178 -0
- plumbify/core/render.py +107 -0
- plumbify/core/row.py +45 -0
- plumbify/core/suffix_lora.py +124 -0
- plumbify/core/taps.py +83 -0
- plumbify/core/targets.py +161 -0
- plumbify/core/tool.py +61 -0
- plumbify/metrics.py +73 -0
- plumbify/plumbed.py +390 -0
- plumbify/py.typed +0 -0
- plumbify/serving/__init__.py +1 -0
- plumbify/serving/vllm/__init__.py +89 -0
- plumbify/serving/vllm/client.py +419 -0
- plumbify/serving/vllm/decisions.py +103 -0
- plumbify/serving/vllm/hooks.py +78 -0
- plumbify/serving/vllm/model.py +364 -0
- plumbify/serving/vllm/openai.py +384 -0
- plumbify/system1.py +277 -0
- plumbify/training/__init__.py +1 -0
- plumbify/training/data.py +37 -0
- plumbify/training/eval_head.py +142 -0
- plumbify/training/train.py +84 -0
- plumbify/training/train_head.py +264 -0
- plumbify-0.2.0.dist-info/METADATA +212 -0
- plumbify-0.2.0.dist-info/RECORD +36 -0
- plumbify-0.2.0.dist-info/WHEEL +5 -0
- plumbify-0.2.0.dist-info/entry_points.txt +5 -0
- plumbify-0.2.0.dist-info/licenses/LICENSE +202 -0
- plumbify-0.2.0.dist-info/top_level.txt +1 -0
plumbify/core/answers.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Option probabilities -> typed answers (choice / noul / score) with a confidence and, optionally, a conformal set."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from .row import Row
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def state_text(state) -> str:
|
|
11
|
+
"""Objects and arrays become labeled text; strings pass through."""
|
|
12
|
+
if isinstance(state, str):
|
|
13
|
+
return state
|
|
14
|
+
if isinstance(state, dict):
|
|
15
|
+
return "\n".join(
|
|
16
|
+
f"{k}: {v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}"
|
|
17
|
+
for k, v in state.items()
|
|
18
|
+
)
|
|
19
|
+
return json.dumps(state, ensure_ascii=False)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def choice_confidence(probs: list[float]) -> float:
|
|
23
|
+
"""How far the leading option stands above uniform: (p_max - 1/K) / (1 - 1/K); 1 for a single option."""
|
|
24
|
+
if len(probs) <= 1:
|
|
25
|
+
return 1.0
|
|
26
|
+
u = 1.0 / len(probs)
|
|
27
|
+
return (max(probs) - u) / (1.0 - u)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def score_confidence(probs: list[float]) -> float:
|
|
31
|
+
"""Concentration around the modal level: max(0, 1 - E|level - mode| / D), D = uniform mean absolute deviation."""
|
|
32
|
+
n = len(probs)
|
|
33
|
+
if n <= 1:
|
|
34
|
+
return 1.0
|
|
35
|
+
mode = max(range(n), key=probs.__getitem__)
|
|
36
|
+
spread = sum(p * abs(i - mode) for i, p in enumerate(probs))
|
|
37
|
+
center = (n - 1) / 2
|
|
38
|
+
d_uniform = sum(abs(i - center) for i in range(n)) / n
|
|
39
|
+
return max(0.0, 1.0 - spread / d_uniform)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def answer(row: Row, probs: list[float], qhat: float | None = None) -> dict:
|
|
43
|
+
"""Probabilities over ``row.options`` (in order) -> the answer object for ``row.qtype``. With a conformal
|
|
44
|
+
threshold ``qhat``, the answer also carries ``set``: the options that cannot be ruled out."""
|
|
45
|
+
out = _typed(row, probs)
|
|
46
|
+
if qhat is not None:
|
|
47
|
+
from ..calibration import prediction_set
|
|
48
|
+
|
|
49
|
+
out["set"] = [row.options[k].name for k in prediction_set(probs, qhat)]
|
|
50
|
+
return out
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _typed(row: Row, probs: list[float]) -> dict:
|
|
54
|
+
named = {o.name: round(p, 6) for o, p in zip(row.options, probs, strict=True)}
|
|
55
|
+
if row.qtype == "noul":
|
|
56
|
+
return {
|
|
57
|
+
"type": "noul",
|
|
58
|
+
"noul": named["yes"],
|
|
59
|
+
"probabilities": named,
|
|
60
|
+
"confidence": round(choice_confidence(probs), 6),
|
|
61
|
+
}
|
|
62
|
+
if row.qtype == "choice":
|
|
63
|
+
return {
|
|
64
|
+
"type": "choice",
|
|
65
|
+
"choice": max(named, key=named.get),
|
|
66
|
+
"probabilities": named,
|
|
67
|
+
"confidence": round(choice_confidence(probs), 6),
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
"type": "score",
|
|
71
|
+
"score": round(sum(i * p for i, p in enumerate(probs)), 4),
|
|
72
|
+
"legend": {o.name: o.desc for o in row.options},
|
|
73
|
+
"probabilities": named,
|
|
74
|
+
"confidence": round(score_confidence(probs), 6),
|
|
75
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""DecisionHead: a small transformer that reads a FROZEN trunk and returns a distribution over the supplied options.
|
|
2
|
+
|
|
3
|
+
Input per question: the trunk's hidden states at the decision suffix only (question, option and DECIDE tokens), taken
|
|
4
|
+
from several layers ("taps"). The state itself is never re-read here: the trunk already attended over it, and not
|
|
5
|
+
touching state positions is what lets a decision reuse a generator's KV cache.
|
|
6
|
+
|
|
7
|
+
taps [S, T, d] --per-tap LayerNorm, concat, Linear--> x [S, d_h] (+ segment embedding: question/option/decide)
|
|
8
|
+
--token blocks (bidirectional attention over the suffix)-->
|
|
9
|
+
--pool each option: Linear([last ; mean])--> o_k, q = x[DECIDE]
|
|
10
|
+
--set blocks over {q, o_1..o_K} (no positions: order-invariant)-->
|
|
11
|
+
z_k = <W_q q, W_o o_k> / sqrt(d_proj) + u . o_k
|
|
12
|
+
|
|
13
|
+
Dense on purpose: the head sees a few hundred tokens and costs ~1% of a trunk forward, so capacity is cheap and exact
|
|
14
|
+
attention beats linear-attention or MoE variants at this length.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import asdict, dataclass
|
|
21
|
+
|
|
22
|
+
import torch
|
|
23
|
+
import torch.nn as nn
|
|
24
|
+
import torch.nn.functional as F
|
|
25
|
+
|
|
26
|
+
SEG_QUESTION, SEG_OPTION, SEG_DECIDE, SEG_OTHER = 0, 1, 2, 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class HeadConfig:
|
|
31
|
+
d: int # trunk hidden size
|
|
32
|
+
n_taps: int # number of trunk layers read
|
|
33
|
+
d_h: int = 1024
|
|
34
|
+
n_heads: int = 16
|
|
35
|
+
token_blocks: int = 1
|
|
36
|
+
set_blocks: int = 2
|
|
37
|
+
d_proj: int = 512
|
|
38
|
+
dropout: float = 0.1
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Block(nn.Module):
|
|
45
|
+
"""Pre-norm transformer block with a key-padding mask (bidirectional)."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, d: int, n_heads: int, dropout: float):
|
|
48
|
+
super().__init__()
|
|
49
|
+
self.n1, self.n2 = nn.LayerNorm(d), nn.LayerNorm(d)
|
|
50
|
+
self.qkv = nn.Linear(d, 3 * d)
|
|
51
|
+
self.out = nn.Linear(d, d)
|
|
52
|
+
self.mlp = nn.Sequential(nn.Linear(d, 4 * d), nn.GELU(), nn.Linear(4 * d, d))
|
|
53
|
+
self.h, self.p = n_heads, dropout
|
|
54
|
+
|
|
55
|
+
def forward(self, x: torch.Tensor, keep: torch.Tensor) -> torch.Tensor:
|
|
56
|
+
"""x [B, S, d]; keep [B, S] bool (False = padding)."""
|
|
57
|
+
B, S, d = x.shape
|
|
58
|
+
q, k, v = self.qkv(self.n1(x)).view(B, S, 3, self.h, d // self.h).permute(2, 0, 3, 1, 4)
|
|
59
|
+
mask = keep[:, None, None, :] # attend only to real positions
|
|
60
|
+
a = F.scaled_dot_product_attention(
|
|
61
|
+
q, k, v, attn_mask=mask, dropout_p=self.p if self.training else 0.0
|
|
62
|
+
)
|
|
63
|
+
x = x + self.out(a.transpose(1, 2).reshape(B, S, d))
|
|
64
|
+
return x + self.mlp(self.n2(x))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class SuffixBatch:
|
|
69
|
+
"""Padded decision suffixes. feats [B, S, T, d]; seg [B, S] segment ids; keep [B, S];
|
|
70
|
+
opt_spans per row [(start, end)] within the suffix; decide per row (index within the suffix)."""
|
|
71
|
+
|
|
72
|
+
feats: torch.Tensor
|
|
73
|
+
seg: torch.Tensor
|
|
74
|
+
keep: torch.Tensor
|
|
75
|
+
opt_spans: list[list[tuple[int, int]]]
|
|
76
|
+
decide: list[int]
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def collate(items: list[dict], device=None) -> SuffixBatch:
|
|
80
|
+
"""items: {"feats": [S, T, d] tensor, "opt_spans": [...], "decide": int} with spans relative to the suffix."""
|
|
81
|
+
B, S = len(items), max(it["feats"].shape[0] for it in items)
|
|
82
|
+
T, d = items[0]["feats"].shape[1:]
|
|
83
|
+
dev = device or items[0]["feats"].device
|
|
84
|
+
feats = torch.zeros(B, S, T, d, dtype=items[0]["feats"].dtype, device=dev)
|
|
85
|
+
seg = torch.full((B, S), SEG_OTHER, dtype=torch.long, device=dev)
|
|
86
|
+
keep = torch.zeros(B, S, dtype=torch.bool, device=dev)
|
|
87
|
+
for b, it in enumerate(items):
|
|
88
|
+
n = it["feats"].shape[0]
|
|
89
|
+
feats[b, :n] = it["feats"].to(dev)
|
|
90
|
+
keep[b, :n] = True
|
|
91
|
+
seg[b, :n] = SEG_QUESTION
|
|
92
|
+
for s, e in it["opt_spans"]:
|
|
93
|
+
seg[b, s:e] = SEG_OPTION
|
|
94
|
+
seg[b, it["decide"]] = SEG_DECIDE
|
|
95
|
+
return SuffixBatch(
|
|
96
|
+
feats, seg, keep, [it["opt_spans"] for it in items], [it["decide"] for it in items]
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DecisionHead(nn.Module):
|
|
101
|
+
def __init__(self, cfg: HeadConfig):
|
|
102
|
+
super().__init__()
|
|
103
|
+
self.cfg = cfg
|
|
104
|
+
self.tap_norm = nn.ModuleList(nn.LayerNorm(cfg.d) for _ in range(cfg.n_taps))
|
|
105
|
+
self.inp = nn.Linear(cfg.n_taps * cfg.d, cfg.d_h)
|
|
106
|
+
self.seg = nn.Embedding(4, cfg.d_h)
|
|
107
|
+
self.tok_blocks = nn.ModuleList(
|
|
108
|
+
Block(cfg.d_h, cfg.n_heads, cfg.dropout) for _ in range(cfg.token_blocks)
|
|
109
|
+
)
|
|
110
|
+
self.pool = nn.Linear(2 * cfg.d_h, cfg.d_h)
|
|
111
|
+
self.set_type = nn.Embedding(2, cfg.d_h) # 0 = decide, 1 = option
|
|
112
|
+
self.set_blocks = nn.ModuleList(
|
|
113
|
+
Block(cfg.d_h, cfg.n_heads, cfg.dropout) for _ in range(cfg.set_blocks)
|
|
114
|
+
)
|
|
115
|
+
self.norm = nn.LayerNorm(cfg.d_h)
|
|
116
|
+
self.wq = nn.Linear(cfg.d_h, cfg.d_proj, bias=False)
|
|
117
|
+
self.wo = nn.Linear(cfg.d_h, cfg.d_proj, bias=False)
|
|
118
|
+
self.u = nn.Linear(cfg.d_h, 1, bias=False)
|
|
119
|
+
nn.init.zeros_(self.u.weight)
|
|
120
|
+
self.scale = 1.0 / math.sqrt(cfg.d_proj)
|
|
121
|
+
self.register_buffer("temperature", torch.ones(()))
|
|
122
|
+
|
|
123
|
+
def forward(self, b: SuffixBatch) -> torch.Tensor:
|
|
124
|
+
"""Returns logits [B, K_max] in fp32, -inf where a row has fewer options."""
|
|
125
|
+
with torch.autocast(b.feats.device.type, enabled=False):
|
|
126
|
+
f = b.feats.float()
|
|
127
|
+
x = self.inp(
|
|
128
|
+
torch.cat([n(f[:, :, t]) for t, n in enumerate(self.tap_norm)], -1)
|
|
129
|
+
) + self.seg(b.seg)
|
|
130
|
+
for blk in self.tok_blocks:
|
|
131
|
+
x = blk(x, b.keep)
|
|
132
|
+
B, K = x.shape[0], max(len(s) for s in b.opt_spans)
|
|
133
|
+
# option pooling: [last ; mean] over each span, gathered into a [B, 1 + K] set (decide first)
|
|
134
|
+
last = x.new_zeros(B, K, x.shape[-1])
|
|
135
|
+
mean = x.new_zeros(B, K, x.shape[-1])
|
|
136
|
+
okeep = torch.zeros(B, 1 + K, dtype=torch.bool, device=x.device)
|
|
137
|
+
okeep[:, 0] = True
|
|
138
|
+
for i, spans in enumerate(b.opt_spans): # K is small; spans are ragged
|
|
139
|
+
for k, (s, e) in enumerate(spans):
|
|
140
|
+
last[i, k] = x[i, e - 1]
|
|
141
|
+
mean[i, k] = x[i, s:e].mean(0)
|
|
142
|
+
okeep[i, 1 + k] = True
|
|
143
|
+
o = self.pool(torch.cat([last, mean], -1)) + self.set_type.weight[1]
|
|
144
|
+
q = (
|
|
145
|
+
x[torch.arange(B, device=x.device), torch.tensor(b.decide, device=x.device)]
|
|
146
|
+
+ self.set_type.weight[0]
|
|
147
|
+
)
|
|
148
|
+
s = torch.cat([q[:, None], o], 1)
|
|
149
|
+
for blk in self.set_blocks:
|
|
150
|
+
s = blk(s, okeep)
|
|
151
|
+
s = self.norm(s)
|
|
152
|
+
q, o = s[:, 0], s[:, 1:]
|
|
153
|
+
z = (self.wo(o) * self.wq(q)[:, None]).sum(-1) * self.scale + self.u(o).squeeze(-1)
|
|
154
|
+
return (z / self.temperature).masked_fill(~okeep[:, 1:], float("-inf"))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def decision_loss(logits: torch.Tensor, gold, teacher, lam: float = 0.5) -> torch.Tensor:
|
|
158
|
+
"""CE on gold when present; + lam * KL(teacher || p) when a teacher distribution is present.
|
|
159
|
+
logits [B,K] (-inf padded). gold: list[int|None]. teacher: list[list[float]|None]."""
|
|
160
|
+
logp = F.log_softmax(logits, -1)
|
|
161
|
+
B, K = logits.shape
|
|
162
|
+
terms = []
|
|
163
|
+
has_gold = [b for b in range(B) if gold[b] is not None]
|
|
164
|
+
if has_gold:
|
|
165
|
+
idx = torch.tensor(has_gold, device=logits.device)
|
|
166
|
+
g = torch.tensor([gold[b] for b in has_gold], device=logits.device)
|
|
167
|
+
terms.append(-logp[idx, g])
|
|
168
|
+
has_t = [b for b in range(B) if teacher[b] is not None]
|
|
169
|
+
if has_t:
|
|
170
|
+
t = logits.new_zeros((len(has_t), K))
|
|
171
|
+
for i, b in enumerate(has_t):
|
|
172
|
+
t[i, : len(teacher[b])] = torch.tensor(teacher[b], dtype=t.dtype)
|
|
173
|
+
t = t / t.sum(-1, keepdim=True).clamp_min(1e-9)
|
|
174
|
+
lp = logp[torch.tensor(has_t, device=logits.device)].masked_fill(t == 0, 0.0)
|
|
175
|
+
terms.append(lam * (t * (t.clamp_min(1e-9).log() - lp)).sum(-1))
|
|
176
|
+
if not terms:
|
|
177
|
+
return logits.sum() * 0.0
|
|
178
|
+
return torch.cat(terms).sum() / len(torch.cat(terms))
|
plumbify/core/render.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Render v2: a decision is a user turn in the model's own chat format, appended after the context.
|
|
2
|
+
|
|
3
|
+
<context: a conversation, or a state> <user turn: question, options, instruction> <assistant-open>
|
|
4
|
+
'------------ prefix (cacheable) -----''---------------- decision suffix ---------------------------'
|
|
5
|
+
|
|
6
|
+
The frozen trunk reads the question in the format it was trained on, and the prefix is exactly what a generator has
|
|
7
|
+
already cached for the same conversation, so a decision only prefills its suffix. The head reads the suffix only:
|
|
8
|
+
option spans, and DECIDE = the last token of the assistant-open (the position where the model would start answering).
|
|
9
|
+
|
|
10
|
+
Pieces are tokenised separately (prefix, question, each option, closing) so option spans are exact; training and
|
|
11
|
+
serving both go through ``render_decision``, so the token ids always agree.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
from .row import Row
|
|
19
|
+
|
|
20
|
+
SENTINEL = "\x00PLUMB_DECISION\x00"
|
|
21
|
+
LEADS = {
|
|
22
|
+
"choice": ("Options:", "Answer with exactly one option."),
|
|
23
|
+
"noul": ("Options:", "Answer yes or no."),
|
|
24
|
+
"score": ("Levels, lowest to highest:", "Answer with exactly one level."),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Rendered:
|
|
30
|
+
input_ids: list[int]
|
|
31
|
+
suffix_start: int # first token of the decision suffix; everything before is shared context
|
|
32
|
+
opt_spans: list[tuple[int, int]] # absolute positions, presented order
|
|
33
|
+
decide_pos: int
|
|
34
|
+
perm: list[int] # presented position -> original option index
|
|
35
|
+
gold: int | None
|
|
36
|
+
teacher: list[float] | None
|
|
37
|
+
qtype: str
|
|
38
|
+
id: str
|
|
39
|
+
|
|
40
|
+
def suffix(self) -> dict:
|
|
41
|
+
"""Spans relative to the suffix, as the head consumes them."""
|
|
42
|
+
s = self.suffix_start
|
|
43
|
+
return {
|
|
44
|
+
"opt_spans": [(a - s, b - s) for a, b in self.opt_spans],
|
|
45
|
+
"decide": self.decide_pos - s,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _ids(tok, text: str) -> list[int]:
|
|
50
|
+
return tok(text, add_special_tokens=False).input_ids
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _template(tok, messages: list[dict], **kw) -> tuple[str, str]:
|
|
54
|
+
"""Chat-template text before and after the decision turn's content."""
|
|
55
|
+
text = tok.apply_chat_template(
|
|
56
|
+
[*messages, {"role": "user", "content": SENTINEL}],
|
|
57
|
+
tokenize=False,
|
|
58
|
+
add_generation_prompt=True,
|
|
59
|
+
**kw,
|
|
60
|
+
)
|
|
61
|
+
head, tail = text.split(SENTINEL)
|
|
62
|
+
return head, tail
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def render_decision(
|
|
66
|
+
tok,
|
|
67
|
+
row: Row,
|
|
68
|
+
context: list[dict] | None = None,
|
|
69
|
+
perm: list[int] | None = None,
|
|
70
|
+
template_kwargs: dict | None = None,
|
|
71
|
+
) -> Rendered:
|
|
72
|
+
"""Token ids + positions for one decision.
|
|
73
|
+
|
|
74
|
+
``context``: prior chat messages to decide over (the state is then ``row.state`` if non-empty, placed at the top
|
|
75
|
+
of the decision turn). Without ``context`` the state opens the decision turn. ``perm`` presents options in a
|
|
76
|
+
given order (training shuffles; never for score).
|
|
77
|
+
"""
|
|
78
|
+
kw = {"enable_thinking": False, **(template_kwargs or {})}
|
|
79
|
+
head, tail = _template(tok, list(context or []), **kw)
|
|
80
|
+
K = len(row.options)
|
|
81
|
+
perm = list(range(K)) if perm is None or row.qtype == "score" else perm
|
|
82
|
+
lead, instruction = LEADS[row.qtype]
|
|
83
|
+
from ..core.answers import state_text
|
|
84
|
+
|
|
85
|
+
state = f"{state_text(row.state)}\n\n" if row.state else ""
|
|
86
|
+
ids = _ids(tok, head + state) # prefix: template head (+ state), tokenised as one run
|
|
87
|
+
suffix_start = len(ids)
|
|
88
|
+
ids += _ids(tok, f"Decision: {state_text(row.question)}\n{lead}\n")
|
|
89
|
+
spans = []
|
|
90
|
+
for j in perm:
|
|
91
|
+
o = row.options[j]
|
|
92
|
+
s = len(ids)
|
|
93
|
+
ids += _ids(tok, f"- {o.name}: {o.desc}\n" if o.desc else f"- {o.name}\n")
|
|
94
|
+
spans.append((s, len(ids)))
|
|
95
|
+
ids += _ids(tok, instruction + tail)
|
|
96
|
+
inv = {orig: pos for pos, orig in enumerate(perm)}
|
|
97
|
+
return Rendered(
|
|
98
|
+
ids,
|
|
99
|
+
suffix_start,
|
|
100
|
+
spans,
|
|
101
|
+
len(ids) - 1,
|
|
102
|
+
perm,
|
|
103
|
+
None if row.gold is None else inv[row.gold],
|
|
104
|
+
None if row.teacher is None else [row.teacher[j] for j in perm],
|
|
105
|
+
row.qtype,
|
|
106
|
+
row.id,
|
|
107
|
+
)
|
plumbify/core/row.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""One decision: a state, a question, typed options, and (for training) a gold index or a teacher distribution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
QTYPES = ("choice", "noul", "score")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Option:
|
|
14
|
+
name: str
|
|
15
|
+
desc: str = ""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Row:
|
|
20
|
+
id: str
|
|
21
|
+
state: str
|
|
22
|
+
question: str
|
|
23
|
+
qtype: str # choice | noul | score
|
|
24
|
+
options: list[Option] # noul: exactly [yes, no]; score: ordered levels low -> high
|
|
25
|
+
gold: int | None = None # index into options, or None (teacher-only row)
|
|
26
|
+
teacher: list[float] | None = None # distribution over options in the ORIGINAL order, or None
|
|
27
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
28
|
+
|
|
29
|
+
def __post_init__(self):
|
|
30
|
+
assert self.qtype in QTYPES, self.qtype
|
|
31
|
+
if self.qtype == "noul":
|
|
32
|
+
assert len(self.options) == 2
|
|
33
|
+
if self.gold is not None:
|
|
34
|
+
assert 0 <= self.gold < len(self.options)
|
|
35
|
+
if self.teacher is not None:
|
|
36
|
+
assert len(self.teacher) == len(self.options)
|
|
37
|
+
|
|
38
|
+
def to_json(self) -> str:
|
|
39
|
+
return json.dumps(asdict(self), ensure_ascii=False)
|
|
40
|
+
|
|
41
|
+
@staticmethod
|
|
42
|
+
def from_json(s: str) -> Row:
|
|
43
|
+
d = json.loads(s)
|
|
44
|
+
d["options"] = [Option(**o) for o in d["options"]]
|
|
45
|
+
return Row(**d)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Suffix-only LoRA: the adapter acts on the decision suffix tokens and nothing else.
|
|
2
|
+
|
|
3
|
+
Every adapted linear layer computes ``base(x) + mask * B(A(x))`` with ``mask`` = 1 on decision-suffix tokens (question,
|
|
4
|
+
options, DECIDE) and 0 on context tokens (conversation / state). So:
|
|
5
|
+
|
|
6
|
+
- context tokens are processed by the untouched base weights: their KV and recurrent state are exactly the base model's,
|
|
7
|
+
shareable with a generator that has already read the same conversation;
|
|
8
|
+
- generation (no suffix) is bit-identical to the base model; the adapter is never merged, and with no mask set it
|
|
9
|
+
contributes nothing (System1 clears the mask after every inference forward);
|
|
10
|
+
- decision tokens get a trained trunk, reading the base model's context through adapted projections: the computation a
|
|
11
|
+
frozen trunk plus head cannot learn.
|
|
12
|
+
|
|
13
|
+
The mask is applied by a forward hook on every ``lora_B`` module, so it holds through gradient checkpointing (the hook
|
|
14
|
+
reruns on recompute with the same mask). Adapters are injected in place (no PeftModel wrapper), so layer paths and tap
|
|
15
|
+
hooks are unchanged. Files: ``suffix_adapter.json`` (LoRA config) + ``suffix_adapter.safetensors`` (weights).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
import torch
|
|
24
|
+
from torch import nn
|
|
25
|
+
|
|
26
|
+
from .targets import coverage, select_targets
|
|
27
|
+
|
|
28
|
+
CONFIG, WEIGHTS = "suffix_adapter.json", "suffix_adapter.safetensors"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SuffixLoRA:
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
trunk: nn.Module,
|
|
35
|
+
r: int = 32,
|
|
36
|
+
alpha: int = 64,
|
|
37
|
+
dropout: float = 0.05,
|
|
38
|
+
targets: list[str] | None = None,
|
|
39
|
+
):
|
|
40
|
+
from peft import LoraConfig, inject_adapter_in_model
|
|
41
|
+
|
|
42
|
+
mtype = getattr(trunk.config, "model_type", None)
|
|
43
|
+
self.targets = list(targets or select_targets(trunk, mtype))
|
|
44
|
+
cov = coverage(trunk, self.targets)
|
|
45
|
+
if cov["_layers_total"] and cov["_layers_adapted"] < 0.9 * cov["_layers_total"]:
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"LoRA targets touch {cov['_layers_adapted']}/{cov['_layers_total']} layers"
|
|
48
|
+
)
|
|
49
|
+
self.cfg = {
|
|
50
|
+
"r": r,
|
|
51
|
+
"lora_alpha": alpha,
|
|
52
|
+
"lora_dropout": dropout,
|
|
53
|
+
"target_modules": self.targets,
|
|
54
|
+
}
|
|
55
|
+
inject_adapter_in_model(LoraConfig(bias="none", **self.cfg), trunk)
|
|
56
|
+
self.trunk = trunk
|
|
57
|
+
self.mask: torch.Tensor | None = (
|
|
58
|
+
None # [B, L] for a decision forward; None = adapter off (base model)
|
|
59
|
+
)
|
|
60
|
+
self.lora_B = [m for n, m in trunk.named_modules() if n.endswith("lora_B.default")]
|
|
61
|
+
if not self.lora_B:
|
|
62
|
+
raise ValueError(f"no LoRA modules were injected (targets={self.targets})")
|
|
63
|
+
self._hooks = [m.register_forward_hook(self._gate) for m in self.lora_B]
|
|
64
|
+
dev = next(trunk.parameters()).device
|
|
65
|
+
for p in self.parameters(): # injected in fp32 on CPU by default; live next to the trunk
|
|
66
|
+
p.data = p.data.to(dev)
|
|
67
|
+
|
|
68
|
+
def _gate(self, _mod, _args, out):
|
|
69
|
+
m = self.mask
|
|
70
|
+
if m is None: # no decision in flight: the base model, e.g. for generation
|
|
71
|
+
return out * 0
|
|
72
|
+
if out.dim() == 3 and out.shape[:2] == m.shape:
|
|
73
|
+
return out * m.unsqueeze(-1).to(out.dtype)
|
|
74
|
+
if out.dim() == 2 and out.shape[0] == m.numel():
|
|
75
|
+
return out * m.reshape(-1, 1).to(out.dtype)
|
|
76
|
+
raise RuntimeError(
|
|
77
|
+
f"suffix mask {tuple(m.shape)} does not fit adapter output {tuple(out.shape)}"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def parameters(self) -> list[nn.Parameter]:
|
|
81
|
+
return [p for n, p in self.trunk.named_parameters() if ".lora_" in n]
|
|
82
|
+
|
|
83
|
+
def trainable(self, on: bool = True) -> SuffixLoRA:
|
|
84
|
+
for p in self.parameters():
|
|
85
|
+
p.requires_grad_(on)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def suffix_mask(suffix_starts: list[int], length: int, device) -> torch.Tensor:
|
|
90
|
+
pos = torch.arange(length, device=device)
|
|
91
|
+
return (pos[None, :] >= torch.tensor(suffix_starts, device=device)[:, None]).float()
|
|
92
|
+
|
|
93
|
+
def save(self, out: str) -> None:
|
|
94
|
+
from safetensors.torch import save_file
|
|
95
|
+
|
|
96
|
+
sd = {
|
|
97
|
+
n: p.detach().contiguous().cpu()
|
|
98
|
+
for n, p in self.trunk.named_parameters()
|
|
99
|
+
if ".lora_" in n
|
|
100
|
+
}
|
|
101
|
+
save_file(sd, os.path.join(out, WEIGHTS))
|
|
102
|
+
with open(os.path.join(out, CONFIG), "w") as f:
|
|
103
|
+
json.dump({**self.cfg, "scope": "suffix"}, f, indent=1)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def load(cls, trunk: nn.Module, plumb: str) -> SuffixLoRA:
|
|
107
|
+
from safetensors.torch import load_file
|
|
108
|
+
|
|
109
|
+
from ..artifact import fetch
|
|
110
|
+
|
|
111
|
+
cfg = json.load(open(fetch(plumb, CONFIG)))
|
|
112
|
+
lora = cls(trunk, cfg["r"], cfg["lora_alpha"], cfg["lora_dropout"], cfg["target_modules"])
|
|
113
|
+
sd = load_file(fetch(plumb, WEIGHTS), device=str(next(trunk.parameters()).device))
|
|
114
|
+
params = dict(trunk.named_parameters())
|
|
115
|
+
missing = [n for n in params if ".lora_" in n and n not in sd]
|
|
116
|
+
if missing or any(n not in params for n in sd):
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"suffix adapter mismatch: {len(missing)} missing, "
|
|
119
|
+
f"{sum(n not in params for n in sd)} unexpected"
|
|
120
|
+
)
|
|
121
|
+
with torch.no_grad():
|
|
122
|
+
for n, t in sd.items():
|
|
123
|
+
params[n].copy_(t)
|
|
124
|
+
return lora.trainable(False)
|
plumbify/core/taps.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Read a frozen trunk at several layers, at the decision suffix only.
|
|
2
|
+
|
|
3
|
+
A tap is the residual stream after decoder layer ``i`` (0-based); tap ``-1`` is the trunk's final, normed output. That
|
|
4
|
+
is the same quantity vLLM exposes as auxiliary hidden states (EAGLE3) plus the pooler input, so the head sees the same
|
|
5
|
+
features in training (transformers) and in serving (vLLM).
|
|
6
|
+
|
|
7
|
+
Hooks slice positions >= ``suffix_start`` inside the forward, so a 32k-token context never materialises 32k x taps
|
|
8
|
+
hidden states.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import torch
|
|
14
|
+
from torch import nn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def decoder_layers(trunk: nn.Module) -> nn.ModuleList:
|
|
18
|
+
for name in ("layers", "h", "blocks"):
|
|
19
|
+
layers = getattr(trunk, name, None)
|
|
20
|
+
if isinstance(layers, nn.ModuleList):
|
|
21
|
+
return layers
|
|
22
|
+
raise ValueError(f"cannot find the decoder layer list on {type(trunk).__name__}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def default_taps(n_layers: int, fractions=(0.5, 0.625, 0.75, 0.875)) -> list[int]:
|
|
26
|
+
"""Four mid-to-upper layers plus the final output: decision-relevant features peak mid-depth in frozen LMs,
|
|
27
|
+
and the final layer is specialised for next-token prediction."""
|
|
28
|
+
return [*sorted({min(n_layers - 2, round(f * n_layers) - 1) for f in fractions}), -1]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TapReader:
|
|
32
|
+
"""``reader.run(input_ids, mask, suffix_starts, lengths)`` -> per-row suffix features ``[S_b, T, d]``.
|
|
33
|
+
|
|
34
|
+
``suffix_starts[b]`` is the first suffix position of row b *in this forward's input* (0 when the input is the
|
|
35
|
+
suffix itself, e.g. a continuation over a cached prefix); ``lengths[b]`` excludes right padding.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, trunk: nn.Module, taps: list[int]):
|
|
39
|
+
self.trunk, self.taps = trunk, list(taps)
|
|
40
|
+
self.layers = decoder_layers(trunk)
|
|
41
|
+
n = len(self.layers)
|
|
42
|
+
bad = [t for t in self.taps if t != -1 and not 0 <= t < n]
|
|
43
|
+
if bad:
|
|
44
|
+
raise ValueError(f"taps {bad} out of range for {n} layers")
|
|
45
|
+
|
|
46
|
+
def run(
|
|
47
|
+
self,
|
|
48
|
+
input_ids: torch.Tensor,
|
|
49
|
+
attention_mask: torch.Tensor | None,
|
|
50
|
+
suffix_starts: list[int],
|
|
51
|
+
lengths: list[int],
|
|
52
|
+
detach: bool = True,
|
|
53
|
+
**trunk_kw,
|
|
54
|
+
) -> list[torch.Tensor]:
|
|
55
|
+
lo = min(suffix_starts) # keep only positions any row needs, inside the hook
|
|
56
|
+
grabbed: dict[int, torch.Tensor] = {}
|
|
57
|
+
|
|
58
|
+
def hook(t):
|
|
59
|
+
def f(_m, _args, out):
|
|
60
|
+
h = (out[0] if isinstance(out, tuple) else out)[:, lo:]
|
|
61
|
+
grabbed[t] = h.detach() if detach else h
|
|
62
|
+
|
|
63
|
+
return f
|
|
64
|
+
|
|
65
|
+
handles = [self.layers[t].register_forward_hook(hook(t)) for t in self.taps if t != -1]
|
|
66
|
+
try:
|
|
67
|
+
final = self.trunk(
|
|
68
|
+
input_ids=input_ids, attention_mask=attention_mask, **trunk_kw
|
|
69
|
+
).last_hidden_state
|
|
70
|
+
finally:
|
|
71
|
+
for h in handles:
|
|
72
|
+
h.remove()
|
|
73
|
+
missing = [t for t in self.taps if t != -1 and t not in grabbed]
|
|
74
|
+
if missing:
|
|
75
|
+
raise RuntimeError(f"taps {missing} were not reached in the forward")
|
|
76
|
+
dev = final.device
|
|
77
|
+
stack = [
|
|
78
|
+
grabbed[t].to(dev) if t != -1 else final[:, lo:] for t in self.taps
|
|
79
|
+
] # T x [B, L - lo, d]
|
|
80
|
+
return [
|
|
81
|
+
torch.stack([x[b, suffix_starts[b] - lo : lengths[b] - lo] for x in stack], 1)
|
|
82
|
+
for b in range(input_ids.shape[0])
|
|
83
|
+
]
|