forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathactive_sampler.py
More file actions
305 lines (267 loc) · 11.4 KB
/
Copy pathactive_sampler.py
File metadata and controls
305 lines (267 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
"""Active-learning sampler — surface uncertain prod traces for review.
v0.63.0 Part C — picks the rows the model is *least confident* about so
humans only review what the policy itself thinks is borderline. Reduces
human-eval cost by 5-10x in practice.
Three modes via the input data shape:
1. Single reward-model score (``rm_score``) — uncertainty via max-entropy:
``1 - |2 * score - 1|``. Score 0.5 -> uncertainty 1.0 (peak),
scores 0.0 or 1.0 -> uncertainty 0.0.
2. Two reward-model scores (``rm_scores: [s1, s2]``) — disagreement via
``|s1 - s2|``. Bigger gap -> higher uncertainty.
3. K reward-model scores (``rm_scores: [s1, ..., sK]``) for ``3 <= K <= 32``
— population variance scaled by 4 (max disagreement = 1.0 when half the
RMs score 0 and half score 1). Monotone-correct: adding a fresh RM score
equal to the running mean *decreases* the score (the new contribution to
sum-of-squares is zero while the denominator grows), so consensus on
redundant evidence can never spike uncertainty. Replaces the v0.63.0
``max(scores) - min(scores)`` fallback which was monotone-broken — closes
#206.
Composes with v0.19 human eval (the output JSONL is a drop-in human-eval
prompt set) and v0.58 ``soup loop watch`` (which can run this nightly).
"""
from __future__ import annotations
import json
import math
import os
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
from typing import Final
from soup_cli.utils.paths import is_under_cwd
_MAX_BUDGET: Final[int] = 100_000
_MAX_INPUT_ROWS: Final[int] = 10_000_000 # 10M — production-scale day of traces
# Cap on K reward-model scores per row. 32 is generous (most ensembles use
# 3-8 RMs) and bounds the inner O(K) variance loop at well under a
# microsecond per row. K>32 raises ValueError (DoS defence) — closes #206.
# Final[int] so a caller cannot silently disable the cap by rebinding it.
_MAX_RM_SCORES: Final[int] = 32
@dataclass(frozen=True)
class ActiveLearningPlan:
"""Result of an active-learning pass."""
rows_in: int
rows_selected: int
budget: int
mean_uncertainty: float
def __post_init__(self) -> None:
if self.rows_in < 0:
raise ValueError("rows_in must be >= 0")
if self.rows_selected < 0:
raise ValueError("rows_selected must be >= 0")
if self.budget < 1:
raise ValueError("budget must be >= 1")
if self.rows_selected > self.rows_in:
raise ValueError(
f"rows_selected ({self.rows_selected}) cannot exceed "
f"rows_in ({self.rows_in})"
)
# NaN/Inf guard on the mean (code-review LOW fix v0.63.0 — matches
# project-wide finite-only policy for every other numeric field).
if not math.isfinite(self.mean_uncertainty):
raise ValueError("mean_uncertainty must be finite (no NaN / Inf)")
def validate_budget(value: object) -> int:
"""Validate ``budget`` is a positive int within sane bounds.
Mirrors v0.41.0 / v0.62.0 numeric validator policy: bool-first
rejection, non-int -> TypeError, range -> ValueError.
"""
if isinstance(value, bool):
raise TypeError("budget must be int, not bool")
if not isinstance(value, int):
raise TypeError(
f"budget must be int, got {type(value).__name__}"
)
if value < 1:
raise ValueError(f"budget must be >= 1, got {value}")
if value > _MAX_BUDGET:
raise ValueError(
f"budget must be <= {_MAX_BUDGET}, got {value}"
)
return value
def _validate_score(score: object, *, idx: int) -> float:
if isinstance(score, bool):
raise TypeError(f"scores[{idx}] must be number, not bool")
if not isinstance(score, (int, float)):
raise TypeError(
f"scores[{idx}] must be number, got {type(score).__name__}"
)
f_score = float(score)
if not math.isfinite(f_score):
raise ValueError(f"scores[{idx}] must be finite (no NaN / Inf)")
if not (0.0 <= f_score <= 1.0):
raise ValueError(
f"scores[{idx}] must be in [0.0, 1.0], got {f_score}"
)
return f_score
def score_uncertainty(*, scores: Sequence[float | int]) -> float:
"""Compute uncertainty from K reward-model scores (``1 <= K <= 32``).
- K=1: max-entropy distance from 0.5 (peak at 0.5 -> uncertainty 1.0)
- K=2: pairwise disagreement (``|s1 - s2|``)
- K>=3: population variance scaled by 4, clamped to ``[0, 1]``
For scores in ``[0, 1]`` the population variance is bounded by 0.25
(achieved when half the RMs score 0 and half score 1), so the 4x scale
keeps the K>=3 path inside the unit interval and consistent with the
K<=2 forms.
Validation rejects bool-as-int, non-finite values (NaN / +/-Inf), and
out-of-range scores at every K — see ``_validate_score``.
"""
if not isinstance(scores, Sequence) or isinstance(scores, str):
raise TypeError(
f"scores must be a sequence, got {type(scores).__name__}"
)
if len(scores) == 0:
return 0.0
if len(scores) > _MAX_RM_SCORES:
raise ValueError(
f"score_uncertainty supports at most {_MAX_RM_SCORES} RM scores, "
f"got {len(scores)} (DoS cap)"
)
validated = [_validate_score(s, idx=i) for i, s in enumerate(scores)]
k = len(validated)
if k == 1:
# 1 - |2*s - 1| -> peak at s=0.5
return 1.0 - abs(2.0 * validated[0] - 1.0)
if k == 2:
# Disagreement closed-form. Preserved verbatim for K=2 even though
# variance gives the same answer up to scaling — existing operator
# dashboards / thresholds depend on the |s1 - s2| value.
return abs(validated[0] - validated[1])
# K>=3: 4 * population variance, clamped into the unit interval.
# Population variance (divide by N, not N-1) keeps the bound tight at
# 0.25 for scores in [0, 1] and gives the monotonicity invariant
# described in the module docstring. ``math.fsum`` uses compensated
# summation to keep accumulated rounding error sub-ULP even at K=32 —
# the variance formula is sensitive to it because we square the deltas.
mean = math.fsum(validated) / k
var = math.fsum((s - mean) ** 2 for s in validated) / k
# max(0.0, ...) is defensive: floating-point subtraction can yield -epsilon
# even though population variance is mathematically non-negative.
return max(0.0, min(1.0, 4.0 * var))
def _row_uncertainty(row: Mapping[str, object]) -> float:
"""Compute uncertainty for a single row.
Priority: explicit ``uncertainty`` field > ``rm_scores`` list >
``rm_score`` scalar > 0.0.
"""
if not isinstance(row, Mapping):
raise TypeError(
f"row must be a Mapping, got {type(row).__name__}"
)
explicit = row.get("uncertainty")
if isinstance(explicit, (int, float)) and not isinstance(explicit, bool):
f_val = float(explicit)
if not math.isfinite(f_val):
return 0.0
return max(0.0, min(1.0, f_val))
scores_field = row.get("rm_scores")
if isinstance(scores_field, Sequence) and not isinstance(scores_field, str):
scores_list: list[float] = []
try:
for i, s in enumerate(scores_field):
scores_list.append(_validate_score(s, idx=i))
except (TypeError, ValueError):
return 0.0
# Route every K through ``score_uncertainty`` so the variance path
# (K>=3) and the closed forms (K=1, K=2) share a single source of
# truth. K>32 raises ValueError on the cap — isolate the row by
# returning 0.0 instead of breaking the whole batch (matches the
# existing _validate_score isolation policy two lines above).
try:
return score_uncertainty(scores=scores_list)
except (TypeError, ValueError):
return 0.0
scalar = row.get("rm_score")
if isinstance(scalar, (int, float)) and not isinstance(scalar, bool):
try:
validated = _validate_score(scalar, idx=0)
except (TypeError, ValueError):
return 0.0
return score_uncertainty(scores=[validated])
return 0.0
def pick_top_uncertain(
rows: Iterable[Mapping[str, object]],
*,
budget: int,
) -> list[Mapping[str, object]]:
"""Pick the top-N rows by uncertainty.
Stable on ties — earlier rows win to make the output deterministic.
"""
n_budget = validate_budget(budget)
materialised: list[Mapping[str, object]] = []
for row in rows:
if not isinstance(row, Mapping):
raise TypeError(
f"rows must yield Mapping, got {type(row).__name__}"
)
materialised.append(row)
if len(materialised) >= _MAX_INPUT_ROWS:
break
if not materialised:
return []
scored = [
(idx, _row_uncertainty(row), row) for idx, row in enumerate(materialised)
]
# Sort: highest uncertainty first, ties broken by original order.
scored.sort(key=lambda triple: (-triple[1], triple[0]))
return [row for (_, _, row) in scored[:n_budget]]
def sample_uncertain_rows(
input_path: str,
*,
output_path: str,
budget: int,
) -> ActiveLearningPlan:
"""Read JSONL, pick top-uncertainty rows, write out, return summary."""
n_budget = validate_budget(budget)
if not isinstance(input_path, str):
raise TypeError(
f"input_path must be str, got {type(input_path).__name__}"
)
if not isinstance(output_path, str):
raise TypeError(
f"output_path must be str, got {type(output_path).__name__}"
)
if not input_path or not output_path:
raise ValueError("input/output paths must be non-empty")
if "\x00" in input_path or "\x00" in output_path:
raise ValueError("paths must not contain null bytes")
if not is_under_cwd(input_path):
raise ValueError(f"input_path {input_path!r} is outside cwd")
if not is_under_cwd(output_path):
raise ValueError(f"output_path {output_path!r} is outside cwd")
if not os.path.isfile(input_path):
raise FileNotFoundError(input_path)
rows: list[Mapping[str, object]] = []
with open(input_path, encoding="utf-8") as fh:
for line in fh:
stripped = line.strip()
if not stripped:
continue
try:
obj = json.loads(stripped)
except json.JSONDecodeError:
continue
if isinstance(obj, dict):
rows.append(obj)
if len(rows) >= _MAX_INPUT_ROWS:
break
top = pick_top_uncertain(rows, budget=n_budget)
# Compute mean uncertainty of selected rows for the report.
mean_unc = 0.0
if top:
total = sum(_row_uncertainty(r) for r in top)
mean_unc = total / len(top)
# Atomic, cwd-contained, symlink-rejecting write (the project helper) —
# the previous plain open(..., "w") followed a pre-placed symlink at
# output_path and could clobber its target.
from soup_cli.utils.paths import atomic_write_text
body = "".join(json.dumps(row, ensure_ascii=False) + "\n" for row in top)
atomic_write_text(body, output_path)
return ActiveLearningPlan(
rows_in=len(rows),
rows_selected=len(top),
budget=n_budget,
mean_uncertainty=mean_unc,
)
__all__ = [
"ActiveLearningPlan",
"pick_top_uncertain",
"sample_uncertain_rows",
"score_uncertainty",
"validate_budget",
]