confound-controls 0.3.2__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.
- confound_controls/__init__.py +102 -0
- confound_controls/ablation.py +165 -0
- confound_controls/battery.py +166 -0
- confound_controls/incremental.py +175 -0
- confound_controls/matching.py +154 -0
- confound_controls/metrics.py +126 -0
- confound_controls/sequence.py +323 -0
- confound_controls-0.3.2.dist-info/METADATA +260 -0
- confound_controls-0.3.2.dist-info/RECORD +12 -0
- confound_controls-0.3.2.dist-info/WHEEL +5 -0
- confound_controls-0.3.2.dist-info/licenses/LICENSE +674 -0
- confound_controls-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Controls for "did my classifier learn the signal, or a confound?"
|
|
2
|
+
|
|
3
|
+
Extracted from the aim3 control battery shared byte-identically between
|
|
4
|
+
two internal research projects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .ablation import (
|
|
8
|
+
INCONCLUSIVE as ABLATION_INCONCLUSIVE,
|
|
9
|
+
)
|
|
10
|
+
from .ablation import (
|
|
11
|
+
STRUCTURE_DEPENDENT,
|
|
12
|
+
STRUCTURE_INDEPENDENT,
|
|
13
|
+
AblationResult,
|
|
14
|
+
ablation_control,
|
|
15
|
+
assert_ablation_changed_input,
|
|
16
|
+
)
|
|
17
|
+
from .battery import (
|
|
18
|
+
ConfoundResult,
|
|
19
|
+
battery_passes,
|
|
20
|
+
evaluate_confound,
|
|
21
|
+
format_battery,
|
|
22
|
+
run_battery,
|
|
23
|
+
)
|
|
24
|
+
from .incremental import (
|
|
25
|
+
ADDS,
|
|
26
|
+
HARMS,
|
|
27
|
+
NO_EFFECT,
|
|
28
|
+
UNDERPOWERED,
|
|
29
|
+
DeltaResult,
|
|
30
|
+
incremental_validity,
|
|
31
|
+
paired_delta_auroc,
|
|
32
|
+
)
|
|
33
|
+
from .matching import MatchResult, match_negatives
|
|
34
|
+
from .metrics import (
|
|
35
|
+
DRIVEN,
|
|
36
|
+
INCONCLUSIVE,
|
|
37
|
+
INVERTED,
|
|
38
|
+
PARTIAL,
|
|
39
|
+
ROBUST,
|
|
40
|
+
AurocCI,
|
|
41
|
+
bootstrap_auroc,
|
|
42
|
+
recovery,
|
|
43
|
+
verdict,
|
|
44
|
+
)
|
|
45
|
+
from .sequence import (
|
|
46
|
+
CONFIRMED,
|
|
47
|
+
NOT_CONFIRMED,
|
|
48
|
+
ControlSpan,
|
|
49
|
+
GroupedDelta,
|
|
50
|
+
ShuffleResult,
|
|
51
|
+
confirm_knockout,
|
|
52
|
+
dinucleotide_counts,
|
|
53
|
+
dinucleotide_shuffle,
|
|
54
|
+
grouped_delta_ci,
|
|
55
|
+
knockout_span,
|
|
56
|
+
sample_control_span,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
__version__ = "0.3.2"
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"ABLATION_INCONCLUSIVE",
|
|
63
|
+
"ADDS",
|
|
64
|
+
"CONFIRMED",
|
|
65
|
+
"DRIVEN",
|
|
66
|
+
"HARMS",
|
|
67
|
+
"INCONCLUSIVE",
|
|
68
|
+
"INVERTED",
|
|
69
|
+
"NOT_CONFIRMED",
|
|
70
|
+
"NO_EFFECT",
|
|
71
|
+
"PARTIAL",
|
|
72
|
+
"ROBUST",
|
|
73
|
+
"STRUCTURE_DEPENDENT",
|
|
74
|
+
"STRUCTURE_INDEPENDENT",
|
|
75
|
+
"UNDERPOWERED",
|
|
76
|
+
"AblationResult",
|
|
77
|
+
"AurocCI",
|
|
78
|
+
"ConfoundResult",
|
|
79
|
+
"ControlSpan",
|
|
80
|
+
"DeltaResult",
|
|
81
|
+
"GroupedDelta",
|
|
82
|
+
"MatchResult",
|
|
83
|
+
"ShuffleResult",
|
|
84
|
+
"ablation_control",
|
|
85
|
+
"assert_ablation_changed_input",
|
|
86
|
+
"battery_passes",
|
|
87
|
+
"bootstrap_auroc",
|
|
88
|
+
"confirm_knockout",
|
|
89
|
+
"dinucleotide_counts",
|
|
90
|
+
"dinucleotide_shuffle",
|
|
91
|
+
"evaluate_confound",
|
|
92
|
+
"format_battery",
|
|
93
|
+
"grouped_delta_ci",
|
|
94
|
+
"incremental_validity",
|
|
95
|
+
"knockout_span",
|
|
96
|
+
"match_negatives",
|
|
97
|
+
"paired_delta_auroc",
|
|
98
|
+
"recovery",
|
|
99
|
+
"run_battery",
|
|
100
|
+
"sample_control_span",
|
|
101
|
+
"verdict",
|
|
102
|
+
]
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Score an ablated input with the same model and see what survives.
|
|
2
|
+
|
|
3
|
+
Extracted from an internal grammar-shuffle script
|
|
4
|
+
(byte-identical in a second internal project). There the ablation was a
|
|
5
|
+
dinucleotide-preserving shuffle of promoter sequences, applied zero-shot to a
|
|
6
|
+
frozen probe: if AUROC collapses toward chance, the model was reading order
|
|
7
|
+
rather than composition.
|
|
8
|
+
|
|
9
|
+
Nothing about that argument is specific to sequences. The general shape is:
|
|
10
|
+
take a model's scores on real input, take its scores on input with the
|
|
11
|
+
structure of interest destroyed but the nuisance properties preserved, and ask
|
|
12
|
+
whether the difference clears zero.
|
|
13
|
+
|
|
14
|
+
The source got one thing right that is worth keeping and generalising -- it
|
|
15
|
+
refused to run when the ablation had not changed anything:
|
|
16
|
+
|
|
17
|
+
assert not np.allclose(shuffled, real), \\
|
|
18
|
+
"shuffled embeddings identical to real - shuffle/embeds broken"
|
|
19
|
+
|
|
20
|
+
Without that check, a broken ablation produces scores identical to the real
|
|
21
|
+
ones, the delta is exactly zero, the interval hugs zero, and the module reports
|
|
22
|
+
"the model survives ablation" -- the strongest possible result, obtained by
|
|
23
|
+
doing nothing. That failure is silent, and it is the same shape as the vacuous
|
|
24
|
+
matched control in `matching.py`: an inert control returns the original answer,
|
|
25
|
+
which reads as the hypothesis surviving.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
import numpy as np
|
|
33
|
+
from sklearn.metrics import roc_auc_score
|
|
34
|
+
|
|
35
|
+
# What survived the ablation.
|
|
36
|
+
STRUCTURE_DEPENDENT = "structure-dependent" # ablation collapsed it to chance
|
|
37
|
+
STRUCTURE_INDEPENDENT = "structure-independent" # ablated scores still separate
|
|
38
|
+
PARTIAL = "partial"
|
|
39
|
+
INCONCLUSIVE = "inconclusive"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class AblationResult:
|
|
44
|
+
auroc_real: float
|
|
45
|
+
auroc_ablated: float
|
|
46
|
+
delta: float
|
|
47
|
+
delta_lo: float
|
|
48
|
+
delta_hi: float
|
|
49
|
+
ablated_lo: float
|
|
50
|
+
ablated_hi: float
|
|
51
|
+
verdict: str
|
|
52
|
+
n_resamples_used: int
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ablated_includes_chance(self) -> bool:
|
|
56
|
+
return self.ablated_lo <= 0.5 <= self.ablated_hi
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def assert_ablation_changed_input(real, ablated, *, name: str = "ablation") -> None:
|
|
60
|
+
"""Refuse an ablation that did not alter its input.
|
|
61
|
+
|
|
62
|
+
Call this on the ABLATED REPRESENTATION (sequences, embeddings, features) --
|
|
63
|
+
not on the scores. Identical scores can also arise from a model that
|
|
64
|
+
genuinely ignores the ablated structure, which is a finding; identical
|
|
65
|
+
inputs are a broken pipeline, and the two must not be confused.
|
|
66
|
+
"""
|
|
67
|
+
real = np.asarray(real)
|
|
68
|
+
ablated = np.asarray(ablated)
|
|
69
|
+
if real.shape != ablated.shape:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"{name}: shapes differ, real={real.shape} ablated={ablated.shape}; "
|
|
72
|
+
f"these are meant to be the same inputs with structure destroyed"
|
|
73
|
+
)
|
|
74
|
+
if real.dtype.kind in "fc" or ablated.dtype.kind in "fc":
|
|
75
|
+
# equal_nan=True: NaN != NaN by default, so two IDENTICAL arrays
|
|
76
|
+
# containing NaN slipped past the guard built to catch identical input.
|
|
77
|
+
identical = np.allclose(real, ablated, equal_nan=True)
|
|
78
|
+
else:
|
|
79
|
+
identical = np.array_equal(real, ablated)
|
|
80
|
+
if identical:
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"{name}: the ablated input is identical to the real input, so the "
|
|
83
|
+
f"ablation did nothing. Every downstream comparison would report "
|
|
84
|
+
f"the model as surviving it -- the strongest possible result, "
|
|
85
|
+
f"obtained by doing nothing."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def ablation_control(
|
|
90
|
+
y,
|
|
91
|
+
p_real,
|
|
92
|
+
p_ablated,
|
|
93
|
+
*,
|
|
94
|
+
n: int = 2000,
|
|
95
|
+
seed: int = 42,
|
|
96
|
+
max_ci_width: float | None = None,
|
|
97
|
+
) -> AblationResult:
|
|
98
|
+
"""Compare real vs ablated scores with a paired bootstrap.
|
|
99
|
+
|
|
100
|
+
Both the delta interval and the ablated AUROC's own interval are reported,
|
|
101
|
+
because they answer different questions: the delta says the ablation
|
|
102
|
+
changed something, the ablated interval says whether anything is left.
|
|
103
|
+
"""
|
|
104
|
+
y = np.asarray(y)
|
|
105
|
+
p_real = np.asarray(p_real)
|
|
106
|
+
p_ablated = np.asarray(p_ablated)
|
|
107
|
+
if not (len(y) == len(p_real) == len(p_ablated)):
|
|
108
|
+
raise ValueError(
|
|
109
|
+
f"lengths differ: y={len(y)}, real={len(p_real)}, ablated={len(p_ablated)}"
|
|
110
|
+
)
|
|
111
|
+
if len(np.unique(y)) < 2:
|
|
112
|
+
raise ValueError("AUROC needs both classes present in y")
|
|
113
|
+
|
|
114
|
+
real_auc = roc_auc_score(y, p_real)
|
|
115
|
+
abl_auc = roc_auc_score(y, p_ablated)
|
|
116
|
+
|
|
117
|
+
rng = np.random.RandomState(seed)
|
|
118
|
+
idx = np.arange(len(y))
|
|
119
|
+
deltas, ablated_aucs = [], []
|
|
120
|
+
for _ in range(n):
|
|
121
|
+
b = rng.choice(idx, len(idx), replace=True)
|
|
122
|
+
if len(np.unique(y[b])) < 2:
|
|
123
|
+
continue
|
|
124
|
+
a = roc_auc_score(y[b], p_ablated[b])
|
|
125
|
+
deltas.append(roc_auc_score(y[b], p_real[b]) - a)
|
|
126
|
+
ablated_aucs.append(a)
|
|
127
|
+
|
|
128
|
+
if len(deltas) < n // 2:
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"only {len(deltas)} of {n} resamples contained both classes; the "
|
|
131
|
+
f"intervals would rest on too few replicates to mean anything"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
d_lo, d_hi = np.percentile(deltas, [2.5, 97.5])
|
|
135
|
+
a_lo, a_hi = np.percentile(ablated_aucs, [2.5, 97.5])
|
|
136
|
+
delta_excludes_zero = d_lo > 0 or d_hi < 0
|
|
137
|
+
# Separation is DISTANCE from chance, not height above it: a perfectly
|
|
138
|
+
# reversed ranking (auroc 0) discriminates exactly as well as auroc 1. The
|
|
139
|
+
# source asked `a_lo > 0.5`, so an ablation that changed nothing at all read
|
|
140
|
+
# as a partial collapse whenever the scores ran the other way.
|
|
141
|
+
ablated_excludes_chance = a_lo > 0.5 or a_hi < 0.5
|
|
142
|
+
ablated_includes_chance = not ablated_excludes_chance
|
|
143
|
+
|
|
144
|
+
if max_ci_width is not None and (d_hi - d_lo) > max_ci_width:
|
|
145
|
+
verdict = INCONCLUSIVE
|
|
146
|
+
elif ablated_includes_chance and delta_excludes_zero:
|
|
147
|
+
verdict = STRUCTURE_DEPENDENT
|
|
148
|
+
elif ablated_excludes_chance and not delta_excludes_zero:
|
|
149
|
+
# Ablated scores still separate, and the drop is not distinguishable
|
|
150
|
+
# from zero: the structure was never what the model was using.
|
|
151
|
+
verdict = STRUCTURE_INDEPENDENT
|
|
152
|
+
else:
|
|
153
|
+
verdict = PARTIAL
|
|
154
|
+
|
|
155
|
+
return AblationResult(
|
|
156
|
+
auroc_real=float(real_auc),
|
|
157
|
+
auroc_ablated=float(abl_auc),
|
|
158
|
+
delta=float(real_auc - abl_auc),
|
|
159
|
+
delta_lo=float(d_lo),
|
|
160
|
+
delta_hi=float(d_hi),
|
|
161
|
+
ablated_lo=float(a_lo),
|
|
162
|
+
ablated_hi=float(a_hi),
|
|
163
|
+
verdict=verdict,
|
|
164
|
+
n_resamples_used=len(deltas),
|
|
165
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Run a confound battery: for each confound, match on it and re-score.
|
|
2
|
+
|
|
3
|
+
Extracted from an internal matched-negatives script, whose
|
|
4
|
+
main() hardcoded the study in four separate places:
|
|
5
|
+
|
|
6
|
+
CONFOUNDS = os.path.join(REPO, "results/aim3_control/confounds.tsv")
|
|
7
|
+
ANCHOR = 0.629
|
|
8
|
+
KMER_COLS = [f"kmer3_{i}" for i in range(64)]
|
|
9
|
+
CONFOUNDS_SPEC = {"gc": ["gc"], "expression": ["log_basemean"], ...}
|
|
10
|
+
|
|
11
|
+
-- input paths derived from __file__, the study's own baseline AUROC, and the
|
|
12
|
+
study's column schema. None of it was reachable from the command line, so the
|
|
13
|
+
module ran against exactly one dataset in exactly one repo layout. The logic in
|
|
14
|
+
between was general the whole time.
|
|
15
|
+
|
|
16
|
+
Here the caller passes the frame, the probabilities, the spec, and the anchor.
|
|
17
|
+
Nothing is read from disk and nothing is written to it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd
|
|
27
|
+
|
|
28
|
+
from .matching import match_negatives
|
|
29
|
+
from .metrics import AurocCI, bootstrap_auroc, recovery, verdict
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ConfoundResult:
|
|
34
|
+
name: str
|
|
35
|
+
ci: AurocCI
|
|
36
|
+
recovery: float
|
|
37
|
+
verdict: str
|
|
38
|
+
n_positives: int
|
|
39
|
+
n_matched_negatives: int
|
|
40
|
+
match_complete: bool
|
|
41
|
+
match_selective: bool
|
|
42
|
+
unmatched_positions: list = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def evaluate_confound(
|
|
46
|
+
df: pd.DataFrame,
|
|
47
|
+
prob_map: Mapping,
|
|
48
|
+
columns: Sequence[str],
|
|
49
|
+
anchor: float,
|
|
50
|
+
*,
|
|
51
|
+
name: str = "confound",
|
|
52
|
+
id_column: str = "id",
|
|
53
|
+
label_column: str = "label",
|
|
54
|
+
recovery_threshold: float = 0.70,
|
|
55
|
+
require_complete_match: bool = True,
|
|
56
|
+
require_selective_match: bool = True,
|
|
57
|
+
bootstrap_n: int = 2000,
|
|
58
|
+
seed: int = 42,
|
|
59
|
+
) -> ConfoundResult:
|
|
60
|
+
"""Match negatives to positives on `columns`, then re-score on the subset."""
|
|
61
|
+
missing = [c for c in list(columns) + [id_column, label_column] if c not in df.columns]
|
|
62
|
+
if missing:
|
|
63
|
+
raise ValueError(
|
|
64
|
+
f"{name}: columns not in frame: {missing}. Present: {list(df.columns)[:12]}"
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
pos = df[df[label_column] == 1]
|
|
68
|
+
neg = df[df[label_column] == 0]
|
|
69
|
+
if pos.empty or neg.empty:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"{name}: need both classes; got {len(pos)} positive and {len(neg)} negative rows"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
dupes = df[id_column][df[id_column].duplicated()].unique()
|
|
75
|
+
if len(dupes):
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"{name}: {len(dupes)} duplicate id(s) in column '{id_column}' "
|
|
78
|
+
f"(e.g. {list(dupes[:3])}). An id must name exactly one row: "
|
|
79
|
+
f"probabilities are looked up by id and the evaluated set is rebuilt "
|
|
80
|
+
f"by id, so a duplicate silently drags its twin into the evaluation "
|
|
81
|
+
f"and the reported 1:1 design is not 1:1."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
match = match_negatives(
|
|
85
|
+
pos[list(columns)].to_numpy(),
|
|
86
|
+
neg[id_column].tolist(),
|
|
87
|
+
neg[list(columns)].to_numpy(),
|
|
88
|
+
)
|
|
89
|
+
if require_complete_match:
|
|
90
|
+
match.require_complete()
|
|
91
|
+
if require_selective_match:
|
|
92
|
+
match.require_selective()
|
|
93
|
+
|
|
94
|
+
eval_ids = set(pos[id_column].tolist()) | set(match.matched_ids)
|
|
95
|
+
sub = df[df[id_column].isin(eval_ids)]
|
|
96
|
+
|
|
97
|
+
unknown = [g for g in sub[id_column] if g not in prob_map]
|
|
98
|
+
if unknown:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"{name}: {len(unknown)} evaluated ids have no probability "
|
|
101
|
+
f"(e.g. {unknown[:3]}). Scoring them as anything would invent data."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
y = sub[label_column].to_numpy()
|
|
105
|
+
p = np.array([prob_map[g] for g in sub[id_column]])
|
|
106
|
+
ci = bootstrap_auroc(y, p, n=bootstrap_n, seed=seed)
|
|
107
|
+
|
|
108
|
+
return ConfoundResult(
|
|
109
|
+
name=name,
|
|
110
|
+
ci=ci,
|
|
111
|
+
recovery=recovery(ci.point, anchor),
|
|
112
|
+
verdict=verdict(ci, anchor, recovery_threshold),
|
|
113
|
+
n_positives=int((y == 1).sum()),
|
|
114
|
+
n_matched_negatives=int((y == 0).sum()),
|
|
115
|
+
match_complete=match.complete,
|
|
116
|
+
match_selective=match.selective,
|
|
117
|
+
unmatched_positions=list(match.unmatched_positions),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_battery(
|
|
122
|
+
df: pd.DataFrame,
|
|
123
|
+
prob_map: Mapping,
|
|
124
|
+
spec: Mapping[str, Sequence[str]],
|
|
125
|
+
anchor: float,
|
|
126
|
+
**kwargs,
|
|
127
|
+
) -> dict[str, ConfoundResult]:
|
|
128
|
+
"""Evaluate every confound in `spec`. Keys name the confound, values its columns."""
|
|
129
|
+
if not spec:
|
|
130
|
+
raise ValueError("spec is empty; there is nothing to control for")
|
|
131
|
+
return {
|
|
132
|
+
name: evaluate_confound(df, prob_map, cols, anchor, name=name, **kwargs)
|
|
133
|
+
for name, cols in spec.items()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def battery_passes(results: Mapping[str, ConfoundResult]) -> bool:
|
|
138
|
+
"""True only if every confound came back robust.
|
|
139
|
+
|
|
140
|
+
The source computed this over a hardcoded subset of its own confound names
|
|
141
|
+
(`univariate + ["joint"]`), so a confound added to the spec was scored,
|
|
142
|
+
printed, and then left out of the pass/fail decision. Here every result in
|
|
143
|
+
the battery counts -- a confound you bothered to declare cannot be quietly
|
|
144
|
+
excluded from the verdict it exists to inform.
|
|
145
|
+
"""
|
|
146
|
+
if not results:
|
|
147
|
+
raise ValueError("no results; an empty battery cannot pass")
|
|
148
|
+
from .metrics import ROBUST
|
|
149
|
+
|
|
150
|
+
return all(r.verdict == ROBUST for r in results.values())
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def format_battery(results: Mapping[str, ConfoundResult], anchor: float) -> str:
|
|
154
|
+
lines = [f"confound battery (anchor AUROC={anchor:.4f})", ""]
|
|
155
|
+
for name, r in results.items():
|
|
156
|
+
flag = "" if r.match_complete else " [INCOMPLETE MATCH]"
|
|
157
|
+
if not r.match_selective:
|
|
158
|
+
flag += " [VACUOUS CONTROL: whole pool used]"
|
|
159
|
+
lines.append(
|
|
160
|
+
f" {name:18s} AUROC={r.ci.point:.4f} "
|
|
161
|
+
f"CI[{r.ci.lo:.4f},{r.ci.hi:.4f}] "
|
|
162
|
+
f"recovery={r.recovery:.2f} npos={r.n_positives} "
|
|
163
|
+
f"-> {r.verdict}{flag}"
|
|
164
|
+
)
|
|
165
|
+
lines += ["", f"BATTERY PASS: {battery_passes(results)}"]
|
|
166
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Does the new feature add anything beyond the confounds?
|
|
2
|
+
|
|
3
|
+
Extracted from an internal incremental-validity script
|
|
4
|
+
(byte-identical in a second internal project). Fit a confound-only model and a
|
|
5
|
+
confound+feature model, evaluate both on the same held-out rows, and put a
|
|
6
|
+
paired bootstrap interval on the AUROC difference.
|
|
7
|
+
|
|
8
|
+
Paired is the point. Two independent intervals on two AUROCs overlap far more
|
|
9
|
+
often than the interval on their difference excludes zero, because the pair is
|
|
10
|
+
computed on the SAME resampled rows and the shared variance cancels. Comparing
|
|
11
|
+
two separately-reported AUROCs by eye is the error the paired delta exists to
|
|
12
|
+
prevent.
|
|
13
|
+
|
|
14
|
+
The source's verdict was two-valued:
|
|
15
|
+
|
|
16
|
+
verdict = "FM-adds-signal" if lo > 0 else "no-added-signal"
|
|
17
|
+
|
|
18
|
+
so an interval straddling zero, an interval too wide to say anything, and an
|
|
19
|
+
interval lying entirely BELOW zero -- the augmented model actively worse --
|
|
20
|
+
all reported as "no-added-signal". The last of those is not an absence of
|
|
21
|
+
signal, it is a finding, and it was being discarded.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
from sklearn.linear_model import LogisticRegression
|
|
30
|
+
from sklearn.metrics import roc_auc_score
|
|
31
|
+
from sklearn.preprocessing import StandardScaler
|
|
32
|
+
|
|
33
|
+
ADDS = "adds-signal"
|
|
34
|
+
HARMS = "harms"
|
|
35
|
+
NO_EFFECT = "no-added-signal"
|
|
36
|
+
UNDERPOWERED = "underpowered"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class DeltaResult:
|
|
41
|
+
auroc_base: float
|
|
42
|
+
auroc_augmented: float
|
|
43
|
+
delta: float
|
|
44
|
+
lo: float
|
|
45
|
+
hi: float
|
|
46
|
+
p_delta_le_0: float
|
|
47
|
+
verdict: str
|
|
48
|
+
n_resamples_used: int
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def excludes_zero(self) -> bool:
|
|
52
|
+
return self.lo > 0 or self.hi < 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def paired_delta_auroc(
|
|
56
|
+
y,
|
|
57
|
+
p_base,
|
|
58
|
+
p_augmented,
|
|
59
|
+
n: int = 2000,
|
|
60
|
+
seed: int = 42,
|
|
61
|
+
max_ci_width: float | None = None,
|
|
62
|
+
) -> DeltaResult:
|
|
63
|
+
"""Bootstrap the AUROC difference on paired resamples.
|
|
64
|
+
|
|
65
|
+
`max_ci_width` opts in to reporting UNDERPOWERED rather than NO_EFFECT when
|
|
66
|
+
the interval is too wide to distinguish the two. Off by default, because
|
|
67
|
+
turning it on silently would reclassify existing results.
|
|
68
|
+
"""
|
|
69
|
+
y = np.asarray(y)
|
|
70
|
+
p_base = np.asarray(p_base)
|
|
71
|
+
p_augmented = np.asarray(p_augmented)
|
|
72
|
+
if not (len(y) == len(p_base) == len(p_augmented)):
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"lengths differ: y={len(y)}, base={len(p_base)}, augmented={len(p_augmented)}"
|
|
75
|
+
)
|
|
76
|
+
if len(np.unique(y)) < 2:
|
|
77
|
+
raise ValueError("AUROC needs both classes present in y")
|
|
78
|
+
|
|
79
|
+
base = roc_auc_score(y, p_base)
|
|
80
|
+
aug = roc_auc_score(y, p_augmented)
|
|
81
|
+
|
|
82
|
+
rng = np.random.RandomState(seed)
|
|
83
|
+
idx = np.arange(len(y))
|
|
84
|
+
deltas = []
|
|
85
|
+
for _ in range(n):
|
|
86
|
+
b = rng.choice(idx, len(idx), replace=True)
|
|
87
|
+
if len(np.unique(y[b])) < 2:
|
|
88
|
+
continue
|
|
89
|
+
# Same rows for both models -- that is what makes it paired.
|
|
90
|
+
deltas.append(roc_auc_score(y[b], p_augmented[b]) - roc_auc_score(y[b], p_base[b]))
|
|
91
|
+
|
|
92
|
+
if len(deltas) < n // 2:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
f"only {len(deltas)} of {n} resamples contained both classes; the "
|
|
95
|
+
f"interval would rest on too few replicates to mean anything "
|
|
96
|
+
f"(n_pos={int((y == 1).sum())}, n_neg={int((y == 0).sum())})"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
deltas_arr = np.asarray(deltas)
|
|
100
|
+
lo, hi = np.percentile(deltas_arr, [2.5, 97.5])
|
|
101
|
+
p_le0 = float(np.mean(deltas_arr <= 0))
|
|
102
|
+
|
|
103
|
+
if lo > 0:
|
|
104
|
+
verdict = ADDS
|
|
105
|
+
elif hi < 0:
|
|
106
|
+
# Not "no signal" -- the augmented model is reliably WORSE, which the
|
|
107
|
+
# source folded into its no-signal branch and never surfaced.
|
|
108
|
+
verdict = HARMS
|
|
109
|
+
elif max_ci_width is not None and (hi - lo) > max_ci_width:
|
|
110
|
+
verdict = UNDERPOWERED
|
|
111
|
+
else:
|
|
112
|
+
verdict = NO_EFFECT
|
|
113
|
+
|
|
114
|
+
return DeltaResult(
|
|
115
|
+
auroc_base=float(base),
|
|
116
|
+
auroc_augmented=float(aug),
|
|
117
|
+
delta=float(aug - base),
|
|
118
|
+
lo=float(lo),
|
|
119
|
+
hi=float(hi),
|
|
120
|
+
p_delta_le_0=p_le0,
|
|
121
|
+
verdict=verdict,
|
|
122
|
+
n_resamples_used=len(deltas),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _fit_predict(X_train, y_train, X_test, seed: int = 42):
|
|
127
|
+
scaler = StandardScaler().fit(X_train)
|
|
128
|
+
model = LogisticRegression(class_weight="balanced", max_iter=2000, random_state=seed)
|
|
129
|
+
model.fit(scaler.transform(X_train), y_train)
|
|
130
|
+
return model.predict_proba(scaler.transform(X_test))[:, 1]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def incremental_validity(
|
|
134
|
+
X_train_confounds,
|
|
135
|
+
X_test_confounds,
|
|
136
|
+
feature_train,
|
|
137
|
+
feature_test,
|
|
138
|
+
y_train,
|
|
139
|
+
y_test,
|
|
140
|
+
*,
|
|
141
|
+
seed: int = 42,
|
|
142
|
+
n: int = 2000,
|
|
143
|
+
max_ci_width: float | None = None,
|
|
144
|
+
) -> DeltaResult:
|
|
145
|
+
"""Confound-only vs confound+feature, compared with a paired delta.
|
|
146
|
+
|
|
147
|
+
`feature_train` must be out-of-fold. Fitting the feature on the same rows
|
|
148
|
+
the confound model is evaluated against leaks the label into the augmented
|
|
149
|
+
model and manufactures the very lift this control is testing for -- the
|
|
150
|
+
source computed it with GroupKFold for exactly that reason.
|
|
151
|
+
"""
|
|
152
|
+
X_train_confounds = np.asarray(X_train_confounds, dtype=float)
|
|
153
|
+
X_test_confounds = np.asarray(X_test_confounds, dtype=float)
|
|
154
|
+
feature_train = np.asarray(feature_train, dtype=float).reshape(-1, 1)
|
|
155
|
+
feature_test = np.asarray(feature_test, dtype=float).reshape(-1, 1)
|
|
156
|
+
|
|
157
|
+
if X_train_confounds.shape[0] != feature_train.shape[0]:
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"train rows differ: confounds={X_train_confounds.shape[0]}, "
|
|
160
|
+
f"feature={feature_train.shape[0]}"
|
|
161
|
+
)
|
|
162
|
+
if X_test_confounds.shape[0] != feature_test.shape[0]:
|
|
163
|
+
raise ValueError(
|
|
164
|
+
f"test rows differ: confounds={X_test_confounds.shape[0]}, "
|
|
165
|
+
f"feature={feature_test.shape[0]}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
p_base = _fit_predict(X_train_confounds, y_train, X_test_confounds, seed)
|
|
169
|
+
p_aug = _fit_predict(
|
|
170
|
+
np.column_stack([X_train_confounds, feature_train]),
|
|
171
|
+
y_train,
|
|
172
|
+
np.column_stack([X_test_confounds, feature_test]),
|
|
173
|
+
seed,
|
|
174
|
+
)
|
|
175
|
+
return paired_delta_auroc(y_test, p_base, p_aug, n=n, seed=seed, max_ci_width=max_ci_width)
|