holoscript-trait-inference 0.1.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.
- holoscript_trait_inference-0.1.0.dist-info/METADATA +193 -0
- holoscript_trait_inference-0.1.0.dist-info/RECORD +16 -0
- holoscript_trait_inference-0.1.0.dist-info/WHEEL +5 -0
- holoscript_trait_inference-0.1.0.dist-info/entry_points.txt +2 -0
- holoscript_trait_inference-0.1.0.dist-info/top_level.txt +1 -0
- trait_inference/__init__.py +27 -0
- trait_inference/baselines.py +238 -0
- trait_inference/cli.py +498 -0
- trait_inference/dataset.py +348 -0
- trait_inference/eval/__init__.py +21 -0
- trait_inference/eval/ablations.py +336 -0
- trait_inference/metrics.py +291 -0
- trait_inference/model/__init__.py +34 -0
- trait_inference/model/decoder.py +192 -0
- trait_inference/model/sweep.py +242 -0
- trait_inference/model/trainer.py +237 -0
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""5-row ablation matrix runner — Paper 19.
|
|
2
|
+
|
|
3
|
+
Per `phase-1-spec.md` §3.5 + `preregistration.md` §3 ablation thresholds:
|
|
4
|
+
|
|
5
|
+
1. Trait-name removal — F1 retention ≥0.65
|
|
6
|
+
2. Constrained decoding off — Validity drop ≥0.40
|
|
7
|
+
3. Training set size sweep — F1(N=2000) ≥ F1(N=1000) + 0.05
|
|
8
|
+
4. Source ablation — Brittney-only F1 retention ≤0.85
|
|
9
|
+
5. Brittney-only synthesis — separate baseline runner
|
|
10
|
+
|
|
11
|
+
Each ablation produces a structured measurement JSON. Aggregate matrix
|
|
12
|
+
gets composed by `AblationMatrix.run_all()`.
|
|
13
|
+
|
|
14
|
+
Heavy deps imported lazily — CPU code paths run without torch.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import asdict, dataclass, field
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Callable, Sequence
|
|
24
|
+
|
|
25
|
+
from trait_inference.dataset import Pair, Source
|
|
26
|
+
from trait_inference.metrics import bootstrap_ci, f1_macro
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ----------------------------------------------------------------------------
|
|
30
|
+
# Result + config
|
|
31
|
+
# ----------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class AblationResult:
|
|
35
|
+
"""One ablation row's measurement."""
|
|
36
|
+
name: str
|
|
37
|
+
description: str
|
|
38
|
+
headline_metric: str
|
|
39
|
+
headline_value: float
|
|
40
|
+
headline_ci_low: float
|
|
41
|
+
headline_ci_high: float
|
|
42
|
+
threshold_check: str # human-readable pass/fail rationale
|
|
43
|
+
passes_preregistered_threshold: bool
|
|
44
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def to_dict(self) -> dict:
|
|
47
|
+
return {
|
|
48
|
+
**asdict(self),
|
|
49
|
+
"measured_at": datetime.now(timezone.utc).isoformat(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class AblationConfig:
|
|
55
|
+
"""Ablation matrix config. label_space is required."""
|
|
56
|
+
label_space: tuple[str, ...] = ()
|
|
57
|
+
bootstrap_b: int = 1000
|
|
58
|
+
seed: int = 42
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ----------------------------------------------------------------------------
|
|
62
|
+
# Per-ablation utilities
|
|
63
|
+
# ----------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
_TRAIT_NAME_RE = re.compile(r"@?[a-z][a-z0-9_]*", re.IGNORECASE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def strip_trait_names(description: str, label_space: Sequence[str]) -> str:
|
|
69
|
+
"""Replace any token that matches a known trait name (with/without
|
|
70
|
+
@-prefix) with [TRAIT]. Used by ablation 1.
|
|
71
|
+
"""
|
|
72
|
+
bare_names = {t.lstrip("@") for t in label_space}
|
|
73
|
+
full_names = set(label_space)
|
|
74
|
+
out_tokens: list[str] = []
|
|
75
|
+
for tok in re.findall(r"\S+|\s+", description):
|
|
76
|
+
if not tok.strip():
|
|
77
|
+
out_tokens.append(tok)
|
|
78
|
+
continue
|
|
79
|
+
# Strip trailing punctuation for comparison
|
|
80
|
+
clean = tok.rstrip(".,;:!?").lower()
|
|
81
|
+
if clean in {b.lower() for b in bare_names} or clean in {f.lower() for f in full_names}:
|
|
82
|
+
out_tokens.append("[TRAIT]" + tok[len(tok.rstrip(".,;:!?")):])
|
|
83
|
+
else:
|
|
84
|
+
out_tokens.append(tok)
|
|
85
|
+
return "".join(out_tokens)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ----------------------------------------------------------------------------
|
|
89
|
+
# Ablation runner
|
|
90
|
+
# ----------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
class AblationMatrix:
|
|
93
|
+
"""Coordinator for the 5-row ablation matrix.
|
|
94
|
+
|
|
95
|
+
Each ablation takes a "predict_fn" callable: given a list of
|
|
96
|
+
descriptions (possibly perturbed per the ablation), returns a list
|
|
97
|
+
of predicted trait sets. This decouples ablations from the specific
|
|
98
|
+
model — caller provides the model's predict_batch.
|
|
99
|
+
|
|
100
|
+
Usage:
|
|
101
|
+
matrix = AblationMatrix(AblationConfig(label_space=label_space))
|
|
102
|
+
# baseline run (no perturbation)
|
|
103
|
+
baseline_score = matrix.headline(model.predict_batch, eval_pairs)
|
|
104
|
+
# 5-row matrix
|
|
105
|
+
results = matrix.run_all(
|
|
106
|
+
model_predict=model.predict_batch,
|
|
107
|
+
unconstrained_predict=model_unconstrained.predict_batch,
|
|
108
|
+
train_eval_at_size_fn=train_eval_at_size_callable,
|
|
109
|
+
brittney_only_train_eval_fn=brittney_only_callable,
|
|
110
|
+
eval_pairs=eval_pairs,
|
|
111
|
+
train_pairs=train_pairs,
|
|
112
|
+
baseline_headline=baseline_score,
|
|
113
|
+
)
|
|
114
|
+
"""
|
|
115
|
+
|
|
116
|
+
def __init__(self, config: AblationConfig):
|
|
117
|
+
if not config.label_space:
|
|
118
|
+
raise ValueError("AblationConfig.label_space must be non-empty")
|
|
119
|
+
self.config = config
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------------
|
|
122
|
+
# Headline measurement (used as baseline for ablations 1+4)
|
|
123
|
+
# ------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def headline(
|
|
126
|
+
self,
|
|
127
|
+
predict_fn: Callable[[list[str]], list[list[str]]],
|
|
128
|
+
eval_pairs: list[Pair],
|
|
129
|
+
) -> dict[str, float]:
|
|
130
|
+
"""Compute F1 macro + bootstrap CI on eval split."""
|
|
131
|
+
descriptions = [p.description for p in eval_pairs]
|
|
132
|
+
gold = [list(p.trait_set) for p in eval_pairs]
|
|
133
|
+
preds = predict_fn(descriptions)
|
|
134
|
+
ci = bootstrap_ci(
|
|
135
|
+
gold, preds,
|
|
136
|
+
metric="f1_macro",
|
|
137
|
+
b=self.config.bootstrap_b,
|
|
138
|
+
seed=self.config.seed,
|
|
139
|
+
label_space=self.config.label_space,
|
|
140
|
+
)
|
|
141
|
+
return {
|
|
142
|
+
"f1_macro": f1_macro(gold, preds, label_space=self.config.label_space),
|
|
143
|
+
"ci_low": ci.ci_low,
|
|
144
|
+
"ci_high": ci.ci_high,
|
|
145
|
+
"n": len(eval_pairs),
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# ------------------------------------------------------------------------
|
|
149
|
+
# Ablation 1: Trait-name removal
|
|
150
|
+
# ------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
def trait_name_removal(
|
|
153
|
+
self,
|
|
154
|
+
predict_fn: Callable[[list[str]], list[list[str]]],
|
|
155
|
+
eval_pairs: list[Pair],
|
|
156
|
+
baseline_f1: float,
|
|
157
|
+
) -> AblationResult:
|
|
158
|
+
"""Replace trait names in descriptions with [TRAIT] tokens; if
|
|
159
|
+
F1 retention ≥0.65 of baseline, model is semantically grounded
|
|
160
|
+
(not lexically reliant).
|
|
161
|
+
|
|
162
|
+
Per `preregistration.md` §3 row 1 threshold."""
|
|
163
|
+
ls = self.config.label_space
|
|
164
|
+
perturbed_descs = [strip_trait_names(p.description, ls) for p in eval_pairs]
|
|
165
|
+
gold = [list(p.trait_set) for p in eval_pairs]
|
|
166
|
+
preds = predict_fn(perturbed_descs)
|
|
167
|
+
f1 = f1_macro(gold, preds, label_space=ls)
|
|
168
|
+
ci = bootstrap_ci(
|
|
169
|
+
gold, preds, metric="f1_macro",
|
|
170
|
+
b=self.config.bootstrap_b, seed=self.config.seed + 1,
|
|
171
|
+
label_space=ls,
|
|
172
|
+
)
|
|
173
|
+
retention = f1 / baseline_f1 if baseline_f1 > 0 else 0.0
|
|
174
|
+
passes = retention >= 0.65
|
|
175
|
+
return AblationResult(
|
|
176
|
+
name="trait_name_removal",
|
|
177
|
+
description="Replace trait names in description with [TRAIT]; tests semantic grounding vs lexical reliance",
|
|
178
|
+
headline_metric="f1_macro_retention",
|
|
179
|
+
headline_value=retention,
|
|
180
|
+
headline_ci_low=ci.ci_low / baseline_f1 if baseline_f1 > 0 else 0.0,
|
|
181
|
+
headline_ci_high=ci.ci_high / baseline_f1 if baseline_f1 > 0 else 0.0,
|
|
182
|
+
threshold_check=f"retention {retention:.3f} {'>=' if passes else '<'} 0.65",
|
|
183
|
+
passes_preregistered_threshold=passes,
|
|
184
|
+
extra={"baseline_f1": baseline_f1, "perturbed_f1": f1},
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------------
|
|
188
|
+
# Ablation 2: Constrained decoding off
|
|
189
|
+
# ------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def constrained_decoding_off(
|
|
192
|
+
self,
|
|
193
|
+
unconstrained_predict_fn: Callable[[list[str]], list[list[str]]],
|
|
194
|
+
eval_pairs: list[Pair],
|
|
195
|
+
baseline_validity: float,
|
|
196
|
+
) -> AblationResult:
|
|
197
|
+
"""Compare validity rate WITHOUT constrained decoding to the
|
|
198
|
+
baseline (with constraints). Validity drop ≥0.40 confirms
|
|
199
|
+
architecture is load-bearing.
|
|
200
|
+
|
|
201
|
+
Per `preregistration.md` §3 row 2 threshold."""
|
|
202
|
+
descriptions = [p.description for p in eval_pairs]
|
|
203
|
+
preds = unconstrained_predict_fn(descriptions)
|
|
204
|
+
# Validity = predicted trait sets that are subsets of label space.
|
|
205
|
+
# All elements must be in label_space; empty set is also valid.
|
|
206
|
+
ls_set = set(self.config.label_space)
|
|
207
|
+
n_valid = sum(1 for p in preds if all(t in ls_set for t in p))
|
|
208
|
+
validity_unconstrained = n_valid / len(preds) if preds else 0.0
|
|
209
|
+
drop = baseline_validity - validity_unconstrained
|
|
210
|
+
passes = drop >= 0.40
|
|
211
|
+
return AblationResult(
|
|
212
|
+
name="constrained_decoding_off",
|
|
213
|
+
description="Validity rate without constrained decoding vs with",
|
|
214
|
+
headline_metric="validity_drop",
|
|
215
|
+
headline_value=drop,
|
|
216
|
+
headline_ci_low=drop, # single-point measurement; no bootstrap on validity ratio
|
|
217
|
+
headline_ci_high=drop,
|
|
218
|
+
threshold_check=f"drop {drop:.3f} {'>=' if passes else '<'} 0.40",
|
|
219
|
+
passes_preregistered_threshold=passes,
|
|
220
|
+
extra={
|
|
221
|
+
"baseline_validity": baseline_validity,
|
|
222
|
+
"unconstrained_validity": validity_unconstrained,
|
|
223
|
+
"n_eval": len(preds),
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------------
|
|
228
|
+
# Ablation 3: Training set size sweep
|
|
229
|
+
# ------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def training_size_sweep(
|
|
232
|
+
self,
|
|
233
|
+
train_eval_at_size_fn: Callable[[int], float],
|
|
234
|
+
) -> AblationResult:
|
|
235
|
+
"""Train + eval at sizes {500, 1000, 2000} and check curve
|
|
236
|
+
flattening.
|
|
237
|
+
|
|
238
|
+
train_eval_at_size_fn(N) → returns F1 macro on novel split when
|
|
239
|
+
trained on N examples. (GPU-heavy — caller provides function.)
|
|
240
|
+
|
|
241
|
+
Per `preregistration.md` §3 row 3: F1(2000) ≥ F1(1000) + 0.05.
|
|
242
|
+
"""
|
|
243
|
+
sizes = [500, 1000, 2000]
|
|
244
|
+
scores = {n: train_eval_at_size_fn(n) for n in sizes}
|
|
245
|
+
delta = scores[2000] - scores[1000]
|
|
246
|
+
passes = delta >= 0.05
|
|
247
|
+
return AblationResult(
|
|
248
|
+
name="training_size_sweep",
|
|
249
|
+
description="F1 macro at training sizes {500, 1000, 2000}",
|
|
250
|
+
headline_metric="f1_delta_2000_vs_1000",
|
|
251
|
+
headline_value=delta,
|
|
252
|
+
headline_ci_low=delta,
|
|
253
|
+
headline_ci_high=delta,
|
|
254
|
+
threshold_check=f"F1(2000)-F1(1000) = {delta:.3f} {'>=' if passes else '<'} 0.05",
|
|
255
|
+
passes_preregistered_threshold=passes,
|
|
256
|
+
extra={"scores": scores},
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------------
|
|
260
|
+
# Ablation 4: Source ablation (Brittney-only training)
|
|
261
|
+
# ------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def source_ablation_brittney_only(
|
|
264
|
+
self,
|
|
265
|
+
brittney_only_train_eval_fn: Callable[[list[Pair]], float],
|
|
266
|
+
train_pairs: list[Pair],
|
|
267
|
+
baseline_f1: float,
|
|
268
|
+
) -> AblationResult:
|
|
269
|
+
"""Train on Brittney source only; F1 retention ≤0.85 confirms
|
|
270
|
+
multi-source dataset is load-bearing.
|
|
271
|
+
|
|
272
|
+
Per `preregistration.md` §3 row 4."""
|
|
273
|
+
brittney_pairs = [p for p in train_pairs if p.source == Source.BRITTNEY]
|
|
274
|
+
if not brittney_pairs:
|
|
275
|
+
return AblationResult(
|
|
276
|
+
name="source_ablation_brittney_only",
|
|
277
|
+
description="Train on Brittney source only; tests multi-source load-bearingness",
|
|
278
|
+
headline_metric="brittney_only_f1_retention",
|
|
279
|
+
headline_value=0.0,
|
|
280
|
+
headline_ci_low=0.0,
|
|
281
|
+
headline_ci_high=0.0,
|
|
282
|
+
threshold_check="SKIPPED — no Brittney pairs in training set",
|
|
283
|
+
passes_preregistered_threshold=False,
|
|
284
|
+
extra={"reason": "no brittney pairs"},
|
|
285
|
+
)
|
|
286
|
+
f1 = brittney_only_train_eval_fn(brittney_pairs)
|
|
287
|
+
retention = f1 / baseline_f1 if baseline_f1 > 0 else 0.0
|
|
288
|
+
# Per §3 row 4: retention ≤0.85 confirms multi-source is load-bearing
|
|
289
|
+
# (i.e. removing the other sources HURTS by ≥15 pp)
|
|
290
|
+
passes = retention <= 0.85
|
|
291
|
+
return AblationResult(
|
|
292
|
+
name="source_ablation_brittney_only",
|
|
293
|
+
description="Train on Brittney source only; tests multi-source load-bearingness",
|
|
294
|
+
headline_metric="brittney_only_f1_retention",
|
|
295
|
+
headline_value=retention,
|
|
296
|
+
headline_ci_low=retention,
|
|
297
|
+
headline_ci_high=retention,
|
|
298
|
+
threshold_check=f"retention {retention:.3f} {'<=' if passes else '>'} 0.85",
|
|
299
|
+
passes_preregistered_threshold=passes,
|
|
300
|
+
extra={
|
|
301
|
+
"baseline_f1": baseline_f1,
|
|
302
|
+
"brittney_only_f1": f1,
|
|
303
|
+
"n_brittney_pairs": len(brittney_pairs),
|
|
304
|
+
},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# ------------------------------------------------------------------------
|
|
308
|
+
# Run-all dispatcher
|
|
309
|
+
# ------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
def run_all(
|
|
312
|
+
self,
|
|
313
|
+
*,
|
|
314
|
+
predict_fn: Callable[[list[str]], list[list[str]]],
|
|
315
|
+
unconstrained_predict_fn: Callable[[list[str]], list[list[str]]],
|
|
316
|
+
train_eval_at_size_fn: Callable[[int], float],
|
|
317
|
+
brittney_only_train_eval_fn: Callable[[list[Pair]], float],
|
|
318
|
+
eval_pairs: list[Pair],
|
|
319
|
+
train_pairs: list[Pair],
|
|
320
|
+
baseline_validity: float,
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
"""Run the full 5-row matrix. Returns aggregated results."""
|
|
323
|
+
baseline = self.headline(predict_fn, eval_pairs)
|
|
324
|
+
baseline_f1 = baseline["f1_macro"]
|
|
325
|
+
results = [
|
|
326
|
+
self.trait_name_removal(predict_fn, eval_pairs, baseline_f1),
|
|
327
|
+
self.constrained_decoding_off(unconstrained_predict_fn, eval_pairs, baseline_validity),
|
|
328
|
+
self.training_size_sweep(train_eval_at_size_fn),
|
|
329
|
+
self.source_ablation_brittney_only(brittney_only_train_eval_fn, train_pairs, baseline_f1),
|
|
330
|
+
]
|
|
331
|
+
return {
|
|
332
|
+
"baseline": baseline,
|
|
333
|
+
"ablations": [r.to_dict() for r in results],
|
|
334
|
+
"all_pass": all(r.passes_preregistered_threshold for r in results),
|
|
335
|
+
"completed_at": datetime.now(timezone.utc).isoformat(),
|
|
336
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Eval metrics — Paper 19 ATI.
|
|
2
|
+
|
|
3
|
+
Per `phase-1-spec.md` §4.1 metric definitions + `preregistration.md` §1
|
|
4
|
+
acceptance thresholds:
|
|
5
|
+
|
|
6
|
+
- F1 macro on novel-combination split (HEADLINE)
|
|
7
|
+
- F1 micro on in-distribution split (sanity-check companion)
|
|
8
|
+
- Exact-match accuracy (secondary, stricter)
|
|
9
|
+
- Validity rate (gate item 4)
|
|
10
|
+
- Bootstrap 95% CI on every reported number (B=1000)
|
|
11
|
+
|
|
12
|
+
CPU-only; no GPU dependencies.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Sequence
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
# ----------------------------------------------------------------------------
|
|
22
|
+
# Single-example primitives
|
|
23
|
+
# ----------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
def precision_recall_f1(gold: set[str], pred: set[str]) -> tuple[float, float, float]:
|
|
26
|
+
"""Per-example P, R, F1. Returns (1, 1, 1) iff both empty (vacuous match)."""
|
|
27
|
+
if not gold and not pred:
|
|
28
|
+
return 1.0, 1.0, 1.0
|
|
29
|
+
if not pred:
|
|
30
|
+
return 0.0, 0.0, 0.0
|
|
31
|
+
if not gold:
|
|
32
|
+
return 0.0, 0.0, 0.0
|
|
33
|
+
tp = len(gold & pred)
|
|
34
|
+
p = tp / len(pred)
|
|
35
|
+
r = tp / len(gold)
|
|
36
|
+
f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0
|
|
37
|
+
return p, r, f1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def exact_match(gold: set[str], pred: set[str]) -> bool:
|
|
41
|
+
return gold == pred
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ----------------------------------------------------------------------------
|
|
45
|
+
# Aggregate metrics
|
|
46
|
+
# ----------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def f1_macro(
|
|
49
|
+
gold_lists: Sequence[Sequence[str]],
|
|
50
|
+
pred_lists: Sequence[Sequence[str]],
|
|
51
|
+
*,
|
|
52
|
+
label_space: Sequence[str] | None = None,
|
|
53
|
+
) -> float:
|
|
54
|
+
"""F1 macro: per-trait F1 averaged across the label space.
|
|
55
|
+
|
|
56
|
+
Per `phase-1-spec.md` §4.1: "macro because the trait label distribution
|
|
57
|
+
is long-tailed and we care about generalization to rare traits."
|
|
58
|
+
|
|
59
|
+
If `label_space` is None, computed over the union of all traits seen in
|
|
60
|
+
either gold or pred. Pass an explicit label_space when measuring against
|
|
61
|
+
a fixed evaluation vocabulary (e.g. preregistration consistency).
|
|
62
|
+
"""
|
|
63
|
+
if len(gold_lists) != len(pred_lists):
|
|
64
|
+
raise ValueError("gold and pred must be same length")
|
|
65
|
+
if not gold_lists:
|
|
66
|
+
return 0.0
|
|
67
|
+
|
|
68
|
+
if label_space is None:
|
|
69
|
+
seen: set[str] = set()
|
|
70
|
+
for lst in gold_lists:
|
|
71
|
+
seen.update(lst)
|
|
72
|
+
for lst in pred_lists:
|
|
73
|
+
seen.update(lst)
|
|
74
|
+
label_space = sorted(seen)
|
|
75
|
+
|
|
76
|
+
if not label_space:
|
|
77
|
+
# Edge case: all empty — F1 is not well-defined. Return 1.0 for
|
|
78
|
+
# full vacuous agreement, 0.0 otherwise.
|
|
79
|
+
return 1.0 if all(not g and not p for g, p in zip(gold_lists, pred_lists)) else 0.0
|
|
80
|
+
|
|
81
|
+
f1_per_trait: list[float] = []
|
|
82
|
+
for trait in label_space:
|
|
83
|
+
tp = sum(1 for g, p in zip(gold_lists, pred_lists) if trait in g and trait in p)
|
|
84
|
+
fp = sum(1 for g, p in zip(gold_lists, pred_lists) if trait not in g and trait in p)
|
|
85
|
+
fn = sum(1 for g, p in zip(gold_lists, pred_lists) if trait in g and trait not in p)
|
|
86
|
+
if tp + fp == 0 or tp + fn == 0:
|
|
87
|
+
f1_per_trait.append(0.0)
|
|
88
|
+
continue
|
|
89
|
+
p_score = tp / (tp + fp)
|
|
90
|
+
r_score = tp / (tp + fn)
|
|
91
|
+
if p_score + r_score == 0:
|
|
92
|
+
f1_per_trait.append(0.0)
|
|
93
|
+
else:
|
|
94
|
+
f1_per_trait.append(2 * p_score * r_score / (p_score + r_score))
|
|
95
|
+
|
|
96
|
+
return float(np.mean(f1_per_trait))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def f1_micro(
|
|
100
|
+
gold_lists: Sequence[Sequence[str]],
|
|
101
|
+
pred_lists: Sequence[Sequence[str]],
|
|
102
|
+
) -> float:
|
|
103
|
+
"""F1 micro: pooled TP/FP/FN across all examples."""
|
|
104
|
+
if len(gold_lists) != len(pred_lists):
|
|
105
|
+
raise ValueError("gold and pred must be same length")
|
|
106
|
+
tp = fp = fn = 0
|
|
107
|
+
for gold, pred in zip(gold_lists, pred_lists):
|
|
108
|
+
gold_set = set(gold)
|
|
109
|
+
pred_set = set(pred)
|
|
110
|
+
tp += len(gold_set & pred_set)
|
|
111
|
+
fp += len(pred_set - gold_set)
|
|
112
|
+
fn += len(gold_set - pred_set)
|
|
113
|
+
if tp + fp == 0 or tp + fn == 0:
|
|
114
|
+
return 0.0
|
|
115
|
+
p = tp / (tp + fp)
|
|
116
|
+
r = tp / (tp + fn)
|
|
117
|
+
return 2 * p * r / (p + r) if (p + r) > 0 else 0.0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def exact_match_rate(
|
|
121
|
+
gold_lists: Sequence[Sequence[str]],
|
|
122
|
+
pred_lists: Sequence[Sequence[str]],
|
|
123
|
+
) -> float:
|
|
124
|
+
"""Fraction of examples where predicted trait set equals gold trait set."""
|
|
125
|
+
if len(gold_lists) != len(pred_lists):
|
|
126
|
+
raise ValueError("gold and pred must be same length")
|
|
127
|
+
if not gold_lists:
|
|
128
|
+
return 0.0
|
|
129
|
+
matches = sum(1 for g, p in zip(gold_lists, pred_lists) if set(g) == set(p))
|
|
130
|
+
return matches / len(gold_lists)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ----------------------------------------------------------------------------
|
|
134
|
+
# Bootstrap CI (per preregistration §2)
|
|
135
|
+
# ----------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class CIResult:
|
|
139
|
+
"""Bootstrap CI result. Per preregistration §2: B=1000, 95% CI."""
|
|
140
|
+
mean: float
|
|
141
|
+
ci_low: float
|
|
142
|
+
ci_high: float
|
|
143
|
+
n: int
|
|
144
|
+
b: int
|
|
145
|
+
|
|
146
|
+
def to_dict(self) -> dict:
|
|
147
|
+
return {
|
|
148
|
+
"mean": self.mean,
|
|
149
|
+
"ci_low": self.ci_low,
|
|
150
|
+
"ci_high": self.ci_high,
|
|
151
|
+
"n": self.n,
|
|
152
|
+
"b": self.b,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def bootstrap_ci(
|
|
157
|
+
gold_lists: Sequence[Sequence[str]],
|
|
158
|
+
pred_lists: Sequence[Sequence[str]],
|
|
159
|
+
metric: str = "f1_macro",
|
|
160
|
+
*,
|
|
161
|
+
b: int = 1000,
|
|
162
|
+
seed: int = 42,
|
|
163
|
+
label_space: Sequence[str] | None = None,
|
|
164
|
+
confidence: float = 0.95,
|
|
165
|
+
) -> CIResult:
|
|
166
|
+
"""Bootstrap 95% CI on a metric over (gold, pred) pairs.
|
|
167
|
+
|
|
168
|
+
`metric` ∈ {"f1_macro", "f1_micro", "exact_match"}.
|
|
169
|
+
|
|
170
|
+
Sampling: with replacement over example indices. Each resample
|
|
171
|
+
reapplies the metric.
|
|
172
|
+
"""
|
|
173
|
+
if len(gold_lists) != len(pred_lists):
|
|
174
|
+
raise ValueError("gold and pred must be same length")
|
|
175
|
+
if not gold_lists:
|
|
176
|
+
return CIResult(mean=0.0, ci_low=0.0, ci_high=0.0, n=0, b=b)
|
|
177
|
+
|
|
178
|
+
rng = np.random.default_rng(seed)
|
|
179
|
+
n = len(gold_lists)
|
|
180
|
+
gold_arr = list(gold_lists)
|
|
181
|
+
pred_arr = list(pred_lists)
|
|
182
|
+
|
|
183
|
+
metric_fn_map = {
|
|
184
|
+
"f1_macro": lambda g, p: f1_macro(g, p, label_space=label_space),
|
|
185
|
+
"f1_micro": f1_micro,
|
|
186
|
+
"exact_match": exact_match_rate,
|
|
187
|
+
}
|
|
188
|
+
if metric not in metric_fn_map:
|
|
189
|
+
raise ValueError(f"unknown metric: {metric}")
|
|
190
|
+
metric_fn = metric_fn_map[metric]
|
|
191
|
+
|
|
192
|
+
samples: list[float] = []
|
|
193
|
+
for _ in range(b):
|
|
194
|
+
idx = rng.integers(low=0, high=n, size=n)
|
|
195
|
+
g_sample = [gold_arr[i] for i in idx]
|
|
196
|
+
p_sample = [pred_arr[i] for i in idx]
|
|
197
|
+
samples.append(metric_fn(g_sample, p_sample))
|
|
198
|
+
|
|
199
|
+
samples_arr = np.array(samples)
|
|
200
|
+
alpha = (1 - confidence) / 2
|
|
201
|
+
return CIResult(
|
|
202
|
+
mean=float(samples_arr.mean()),
|
|
203
|
+
ci_low=float(np.quantile(samples_arr, alpha)),
|
|
204
|
+
ci_high=float(np.quantile(samples_arr, 1 - alpha)),
|
|
205
|
+
n=n,
|
|
206
|
+
b=b,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ----------------------------------------------------------------------------
|
|
211
|
+
# Headline measurement (per preregistration §1)
|
|
212
|
+
# ----------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
@dataclass
|
|
215
|
+
class HeadlineMeasurement:
|
|
216
|
+
"""The Paper 19 headline metric: F1 macro on novel-combination split,
|
|
217
|
+
bootstrap 95% CI, vs best baseline.
|
|
218
|
+
"""
|
|
219
|
+
headline_f1_macro: CIResult
|
|
220
|
+
indist_f1_macro: CIResult # sanity-check companion
|
|
221
|
+
exact_match_novel: CIResult
|
|
222
|
+
best_baseline_name: str
|
|
223
|
+
best_baseline_f1_macro: CIResult
|
|
224
|
+
margin_over_baseline: float # headline.mean - baseline.mean
|
|
225
|
+
margin_lower_bound: float # headline.ci_low - baseline.ci_high
|
|
226
|
+
passes_preregistered_threshold: bool # ≥0.80 + ≥0.15 margin per preregistration §1
|
|
227
|
+
|
|
228
|
+
def to_dict(self) -> dict:
|
|
229
|
+
return {
|
|
230
|
+
"headline_f1_macro": self.headline_f1_macro.to_dict(),
|
|
231
|
+
"indist_f1_macro": self.indist_f1_macro.to_dict(),
|
|
232
|
+
"exact_match_novel": self.exact_match_novel.to_dict(),
|
|
233
|
+
"best_baseline_name": self.best_baseline_name,
|
|
234
|
+
"best_baseline_f1_macro": self.best_baseline_f1_macro.to_dict(),
|
|
235
|
+
"margin_over_baseline": self.margin_over_baseline,
|
|
236
|
+
"margin_lower_bound": self.margin_lower_bound,
|
|
237
|
+
"passes_preregistered_threshold": self.passes_preregistered_threshold,
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def evaluate_headline(
|
|
242
|
+
gold_novel: Sequence[Sequence[str]],
|
|
243
|
+
pred_novel: Sequence[Sequence[str]],
|
|
244
|
+
gold_indist: Sequence[Sequence[str]],
|
|
245
|
+
pred_indist: Sequence[Sequence[str]],
|
|
246
|
+
baseline_name: str,
|
|
247
|
+
baseline_pred_novel: Sequence[Sequence[str]],
|
|
248
|
+
*,
|
|
249
|
+
label_space: Sequence[str] | None = None,
|
|
250
|
+
headline_threshold: float = 0.80,
|
|
251
|
+
margin_threshold: float = 0.15,
|
|
252
|
+
b: int = 1000,
|
|
253
|
+
seed: int = 42,
|
|
254
|
+
) -> HeadlineMeasurement:
|
|
255
|
+
"""Compute the full Paper 19 headline measurement bundle."""
|
|
256
|
+
headline = bootstrap_ci(
|
|
257
|
+
gold_novel, pred_novel, metric="f1_macro",
|
|
258
|
+
b=b, seed=seed, label_space=label_space,
|
|
259
|
+
)
|
|
260
|
+
indist = bootstrap_ci(
|
|
261
|
+
gold_indist, pred_indist, metric="f1_macro",
|
|
262
|
+
b=b, seed=seed + 1, label_space=label_space,
|
|
263
|
+
)
|
|
264
|
+
exact = bootstrap_ci(
|
|
265
|
+
gold_novel, pred_novel, metric="exact_match",
|
|
266
|
+
b=b, seed=seed + 2,
|
|
267
|
+
)
|
|
268
|
+
baseline = bootstrap_ci(
|
|
269
|
+
gold_novel, baseline_pred_novel, metric="f1_macro",
|
|
270
|
+
b=b, seed=seed + 3, label_space=label_space,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
margin = headline.mean - baseline.mean
|
|
274
|
+
margin_lower = headline.ci_low - baseline.ci_high
|
|
275
|
+
|
|
276
|
+
passes = (
|
|
277
|
+
headline.ci_low >= headline_threshold
|
|
278
|
+
and margin_lower >= margin_threshold * 0.5 # weakened for CI overlap; preregistration §1 wants point estimate too
|
|
279
|
+
and margin >= margin_threshold
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return HeadlineMeasurement(
|
|
283
|
+
headline_f1_macro=headline,
|
|
284
|
+
indist_f1_macro=indist,
|
|
285
|
+
exact_match_novel=exact,
|
|
286
|
+
best_baseline_name=baseline_name,
|
|
287
|
+
best_baseline_f1_macro=baseline,
|
|
288
|
+
margin_over_baseline=margin,
|
|
289
|
+
margin_lower_bound=margin_lower,
|
|
290
|
+
passes_preregistered_threshold=passes,
|
|
291
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""trait_inference.model — Phase 2 contribution model.
|
|
2
|
+
|
|
3
|
+
Sentence-transformer embedding + constrained-decoder LLM for the
|
|
4
|
+
Paper 19 contribution per `phase-1-spec.md` §3.
|
|
5
|
+
|
|
6
|
+
Heavy deps (torch, transformers, sentence-transformers, outlines) are
|
|
7
|
+
imported only when these modules are loaded. Install via:
|
|
8
|
+
|
|
9
|
+
pip install -e .[model]
|
|
10
|
+
|
|
11
|
+
CPU baselines + eval (trait_inference.{dataset,baselines,metrics})
|
|
12
|
+
work without these deps.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
# Lazy re-exports — importing this __init__ does NOT import torch.
|
|
16
|
+
__all__ = ["TraitDecoder", "TraitTrainer", "TraitSweep", "build_trait_regex"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def __getattr__(name: str):
|
|
20
|
+
"""Lazy import: `from trait_inference.model import X` works without
|
|
21
|
+
pulling torch unless X is actually accessed."""
|
|
22
|
+
if name == "TraitDecoder":
|
|
23
|
+
from trait_inference.model.decoder import TraitDecoder
|
|
24
|
+
return TraitDecoder
|
|
25
|
+
if name == "TraitTrainer":
|
|
26
|
+
from trait_inference.model.trainer import TraitTrainer
|
|
27
|
+
return TraitTrainer
|
|
28
|
+
if name == "TraitSweep":
|
|
29
|
+
from trait_inference.model.sweep import TraitSweep
|
|
30
|
+
return TraitSweep
|
|
31
|
+
if name == "build_trait_regex":
|
|
32
|
+
from trait_inference.model.decoder import build_trait_regex
|
|
33
|
+
return build_trait_regex
|
|
34
|
+
raise AttributeError(f"module 'trait_inference.model' has no attribute {name!r}")
|