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.
@@ -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)