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,154 @@
1
+ """Match negatives to positives on confounds, and say so when you cannot.
2
+
3
+ Extracted from an internal matched-negatives script (also present
4
+ byte-identical in a second internal project -- the code was copied between two
5
+ independent repos, which is what marked it as worth extracting).
6
+
7
+ The matching itself is unchanged. What changed is that it now reports whether
8
+ it achieved the 1:1 match it advertises. The original looped
9
+
10
+ while True:
11
+ ...
12
+ if pick is not None: ...; break
13
+ if k >= len(neg_ids): break # <- silently gives up on this positive
14
+ k *= 2
15
+
16
+ so once the negative pool was exhausted it returned FEWER negatives than
17
+ positives and said nothing. A caller comparing AUROC before and after matching
18
+ could not tell a clean 1:1 design from one that quietly dropped a third of its
19
+ positives -- and the drop is not random, it hits the positives in the densest
20
+ region of confound space, exactly where matching matters most.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from dataclasses import dataclass
26
+
27
+ import numpy as np
28
+ from scipy.spatial import cKDTree
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class MatchResult:
33
+ """Outcome of a matching attempt.
34
+
35
+ `complete` is the question a caller actually needs answered: did every
36
+ positive get its own negative? `unmatched_positions` gives the indices of
37
+ the positives that did not, so the caller can inspect rather than guess.
38
+ """
39
+
40
+ matched_ids: list
41
+ unmatched_positions: list
42
+ n_positives: int
43
+ n_pool: int = 0
44
+
45
+ @property
46
+ def complete(self) -> bool:
47
+ return not self.unmatched_positions
48
+
49
+ @property
50
+ def n_matched(self) -> int:
51
+ return len(self.matched_ids)
52
+
53
+ @property
54
+ def selective(self) -> bool:
55
+ """Did matching actually CHOOSE, or take the whole pool?
56
+
57
+ 1:1 matching without replacement from a pool the same size as the
58
+ positive set consumes every negative, whatever the confound values are.
59
+ The "matched" comparison is then byte-identical to the unmatched one,
60
+ so the control is vacuous -- and a vacuous control does not report
61
+ "inconclusive", it reports the original result, which reads as the
62
+ confound having been ruled out.
63
+ """
64
+ return self.n_matched < self.n_pool
65
+
66
+ def require_complete(self) -> MatchResult:
67
+ if not self.complete:
68
+ raise ValueError(
69
+ f"matched only {self.n_matched} of {self.n_positives} positives; "
70
+ f"the negative pool ran out. Unmatched positive positions: "
71
+ f"{self.unmatched_positions[:10]}"
72
+ f"{'...' if len(self.unmatched_positions) > 10 else ''}"
73
+ )
74
+ return self
75
+
76
+ def require_selective(self) -> MatchResult:
77
+ if not self.selective:
78
+ raise ValueError(
79
+ f"matching consumed the entire negative pool "
80
+ f"({self.n_matched} matched from {self.n_pool} available), so it "
81
+ f"selected nothing and the control is vacuous -- the matched "
82
+ f"comparison equals the unmatched one and will report the "
83
+ f"confound as ruled out. Supply more negatives than positives, "
84
+ f"or pass require_selective_match=False if you intend this."
85
+ )
86
+ return self
87
+
88
+
89
+ def match_negatives(pos_vecs, neg_ids, neg_vecs) -> MatchResult:
90
+ """Greedy 1:1 nearest-neighbour match without replacement, on standardised columns.
91
+
92
+ Columns are standardised over positives and negatives jointly so that a
93
+ confound measured on a wide scale does not dominate the distance purely
94
+ through its units.
95
+
96
+ Args:
97
+ pos_vecs: (n_pos, n_confounds) confound values for the positives.
98
+ neg_ids: identifiers for the negatives, parallel to `neg_vecs`.
99
+ neg_vecs: (n_neg, n_confounds) confound values for the negatives.
100
+
101
+ Returns:
102
+ MatchResult -- inspect `.complete` before trusting a downstream metric,
103
+ or call `.require_complete()` to make an incomplete match an error.
104
+ """
105
+ pos_vecs = np.asarray(pos_vecs, dtype=float)
106
+ neg_vecs = np.asarray(neg_vecs, dtype=float)
107
+ neg_ids = list(neg_ids)
108
+
109
+ if pos_vecs.ndim != 2 or neg_vecs.ndim != 2:
110
+ raise ValueError("pos_vecs and neg_vecs must both be 2-D")
111
+ if pos_vecs.shape[1] != neg_vecs.shape[1]:
112
+ raise ValueError(
113
+ f"confound count differs: positives have {pos_vecs.shape[1]}, "
114
+ f"negatives have {neg_vecs.shape[1]}"
115
+ )
116
+ if len(neg_ids) != neg_vecs.shape[0]:
117
+ raise ValueError(
118
+ f"neg_ids has {len(neg_ids)} entries but neg_vecs has {neg_vecs.shape[0]} rows"
119
+ )
120
+ if not len(neg_ids):
121
+ return MatchResult([], list(range(len(pos_vecs))), len(pos_vecs), 0)
122
+
123
+ allv = np.vstack([pos_vecs, neg_vecs])
124
+ mu, sd = allv.mean(0), allv.std(0)
125
+ # A confound that is constant across every sample carries no information;
126
+ # dividing by its zero spread would produce nan distances and match at
127
+ # random, which looks exactly like a successful match.
128
+ sd = np.where(sd == 0, 1.0, sd)
129
+ pos_s = (pos_vecs - mu) / sd
130
+ neg_s = (neg_vecs - mu) / sd
131
+
132
+ tree = cKDTree(neg_s)
133
+ used: set[int] = set()
134
+ matched: list = []
135
+ unmatched: list[int] = []
136
+
137
+ for position, p in enumerate(pos_s):
138
+ k = 1
139
+ pick = None
140
+ while True:
141
+ k = min(k, len(neg_ids))
142
+ _, idxs = tree.query(p, k=k)
143
+ idxs = np.atleast_1d(idxs)
144
+ pick = next((int(i) for i in idxs if i not in used), None)
145
+ if pick is not None or k >= len(neg_ids):
146
+ break
147
+ k *= 2
148
+ if pick is None:
149
+ unmatched.append(position)
150
+ else:
151
+ used.add(pick)
152
+ matched.append(neg_ids[pick])
153
+
154
+ return MatchResult(matched, unmatched, len(pos_s), len(neg_ids))
@@ -0,0 +1,126 @@
1
+ """AUROC with a bootstrap interval, and the anchored verdict built on it.
2
+
3
+ Extracted from an internal probe-common module (bootstrap)
4
+ and aim3_matched_negatives.py (recovery/verdict). The maths is unchanged; the
5
+ study constants are gone.
6
+
7
+ The original had
8
+
9
+ ANCHOR = 0.629
10
+
11
+ as a module-level constant -- one study's own baseline AUROC, compiled into the
12
+ library. Every verdict the module produced was relative to it, and importing
13
+ the module anywhere else silently scored against that number. It is now a
14
+ required argument, because there is no defensible default for "what does good
15
+ look like in your problem".
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+ from sklearn.metrics import roc_auc_score
24
+
25
+ # Verdict vocabulary. `INCONCLUSIVE` has no counterpart in the source, which
26
+ # had no way to say "this interval is too wide to decide" and folded those
27
+ # cases into `confound-driven` -- reporting an absent measurement as a finding.
28
+ ROBUST = "confound-robust"
29
+ DRIVEN = "confound-driven"
30
+ PARTIAL = "partial"
31
+ INCONCLUSIVE = "inconclusive"
32
+ INVERTED = "inverted" # the interval sits entirely BELOW chance
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class AurocCI:
37
+ point: float
38
+ lo: float
39
+ hi: float
40
+
41
+ @property
42
+ def excludes_chance(self) -> bool:
43
+ return self.lo > 0.5 or self.hi < 0.5
44
+
45
+
46
+ def bootstrap_auroc(y, p, n: int = 2000, seed: int = 42) -> AurocCI:
47
+ """Percentile bootstrap CI for AUROC.
48
+
49
+ Resamples that end up single-class are skipped -- AUROC is undefined for
50
+ them. If too many are skipped the interval is not trustworthy, so this
51
+ raises rather than returning a confident-looking number computed from a
52
+ handful of replicates.
53
+ """
54
+ y = np.asarray(y)
55
+ p = np.asarray(p)
56
+ if y.shape[0] != p.shape[0]:
57
+ raise ValueError(f"y has {y.shape[0]} entries, p has {p.shape[0]}")
58
+ if len(np.unique(y)) < 2:
59
+ raise ValueError("AUROC needs both classes present in y")
60
+
61
+ point = roc_auc_score(y, p)
62
+ rng = np.random.RandomState(seed)
63
+ idx = np.arange(len(y))
64
+ stats = []
65
+ for _ in range(n):
66
+ b = rng.choice(idx, len(idx), replace=True)
67
+ if len(np.unique(y[b])) < 2:
68
+ continue
69
+ stats.append(roc_auc_score(y[b], p[b]))
70
+
71
+ if len(stats) < n // 2:
72
+ raise ValueError(
73
+ f"only {len(stats)} of {n} bootstrap resamples contained both "
74
+ f"classes; the interval would be built from too few replicates to "
75
+ f"mean anything (n_pos={int((y == 1).sum())}, "
76
+ f"n_neg={int((y == 0).sum())})"
77
+ )
78
+ lo, hi = np.percentile(stats, [2.5, 97.5])
79
+ return AurocCI(float(point), float(lo), float(hi))
80
+
81
+
82
+ def recovery(auroc: float, anchor: float) -> float:
83
+ """How much of the anchor's above-chance signal survived, in [0, 1]-ish.
84
+
85
+ `anchor` is the unmatched/uncontrolled AUROC this result is being compared
86
+ against. An anchor at or below chance makes the ratio meaningless rather
87
+ than merely large, so it is refused.
88
+ """
89
+ if anchor <= 0.5:
90
+ raise ValueError(
91
+ f"anchor must be above chance to divide by its signal; got {anchor}. "
92
+ f"An anchor at or below 0.5 has no above-chance signal to recover, "
93
+ f"so the ratio is a non-quantity, not a small number."
94
+ )
95
+ return (auroc - 0.5) / (anchor - 0.5)
96
+
97
+
98
+ def verdict(
99
+ ci: AurocCI,
100
+ anchor: float,
101
+ recovery_threshold: float = 0.70,
102
+ max_ci_width: float | None = None,
103
+ ) -> str:
104
+ """Classify a controlled result against its anchor.
105
+
106
+ - ROBUST: enough of the anchor's signal survived AND the interval clears chance
107
+ - DRIVEN: the interval straddles chance -- the control removed the signal
108
+ - PARTIAL: signal is real but diminished
109
+ - INVERTED: the interval clears chance from BELOW -- the ranking reversed
110
+ - INCONCLUSIVE: the interval is too wide to support any of the above
111
+
112
+ `max_ci_width` is opt-in because the source had no such concept, and adding
113
+ it silently would reclassify existing results.
114
+ """
115
+ if max_ci_width is not None and (ci.hi - ci.lo) > max_ci_width:
116
+ return INCONCLUSIVE
117
+ if recovery(ci.point, anchor) >= recovery_threshold and ci.excludes_chance:
118
+ return ROBUST
119
+ if not ci.excludes_chance:
120
+ return DRIVEN
121
+ if ci.hi < 0.5:
122
+ # Excludes chance, but from below: the control did not diminish the
123
+ # signal, it REVERSED it. Calling that "partial" ("signal is real but
124
+ # diminished") reports a pathology as a weak positive.
125
+ return INVERTED
126
+ return PARTIAL
@@ -0,0 +1,323 @@
1
+ """Sequence-level ablation: knock out a span, keep composition, say whether it worked.
2
+
3
+ Extracted from an internal project (byte-identical in a second one):
4
+ `aim3_shuffle_promoters.dinucl_shuffle`, `ig_knockout_ism.knockout_instance` /
5
+ `delta_ci`, and `cg_ism._nonoverlap_start`. `cg_ism`'s own docstring says it
6
+ "reuses ig_knockout_ism (knockout_instance, delta_ci)", so the reuse was
7
+ already explicit -- it just had nowhere to live.
8
+
9
+ ## What changed: three silent fallbacks, all in the same direction
10
+
11
+ Each of these returned a plausible value on failure, and each failure makes an
12
+ ablation weaker or absent -- which reads as the model SURVIVING it, the most
13
+ reassuring answer available.
14
+
15
+ 1. `dinucl_shuffle` ended with
16
+
17
+ # deterministic fallback: identity preserves counts (rare; logged by caller)
18
+ return seq
19
+
20
+ An un-shuffleable sequence came back UNCHANGED. Downstream that is a
21
+ knockout that knocked nothing out, indistinguishable from a real one.
22
+ "Logged by caller" is a promise, not a mechanism.
23
+
24
+ 2. `_nonoverlap_start` tried 50 times to place a length-matched control span
25
+ clear of the real motif spans, then returned `rng.randint(0, hi)` regardless
26
+ -- a control span that may OVERLAP the motifs, i.e. a partial real knockout
27
+ masquerading as the negative control it is being compared against.
28
+
29
+ 3. `delta_ci` resampled groups with no check that there were enough distinct
30
+ groups for the interval to mean anything.
31
+
32
+ The dinucleotide-shuffle algorithm itself (Altschul-Erikson) is ported
33
+ unchanged, including the determinism fix its author found the hard way -- see
34
+ `_vertices`.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import random
40
+ from collections import defaultdict
41
+ from dataclasses import dataclass
42
+
43
+ import numpy as np
44
+
45
+
46
+ def dinucleotide_counts(seq: str) -> dict:
47
+ c: dict = defaultdict(int)
48
+ for i in range(len(seq) - 1):
49
+ c[seq[i : i + 2]] += 1
50
+ return dict(c)
51
+
52
+
53
+ def _vertices(graph):
54
+ """`list(graph)`, never `set(graph)`.
55
+
56
+ dict key order is insertion order since 3.7, so it is derived from the
57
+ sequence. Set iteration order depends on the per-process randomized hash of
58
+ the string keys, which made `rng.choice`/`rng.shuffle` consume the PRNG in a
59
+ different order across process invocations -- so the same `(seq, seed)`
60
+ produced DIFFERENT shuffles run to run despite the PRNG being correctly
61
+ seeded. Preserved verbatim from the source, which carries the same warning.
62
+ """
63
+ return list(graph)
64
+
65
+
66
+ def _connected_to_last(last, last_edges, verts) -> bool:
67
+ """Altschul-Erikson connectivity: every vertex with out-edges reaches `last`."""
68
+ intree = {v: False for v in verts}
69
+ intree[last] = True
70
+ changed = True
71
+ while changed:
72
+ changed = False
73
+ for v in verts:
74
+ if not intree[v] and v in last_edges and intree.get(last_edges[v], False):
75
+ intree[v] = True
76
+ changed = True
77
+ return all(intree[v] for v in verts)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class ShuffleResult:
82
+ """A shuffle attempt, and whether it actually changed anything.
83
+
84
+ `changed` is the question the source could not answer. A shuffle that
85
+ returns its input is not a shuffle, and every ablation built on it is inert.
86
+ """
87
+
88
+ sequence: str
89
+ changed: bool
90
+ attempts: int
91
+
92
+ def require_changed(self) -> ShuffleResult:
93
+ if not self.changed:
94
+ raise ValueError(
95
+ f"dinucleotide shuffle returned the input unchanged after "
96
+ f"{self.attempts} attempts, so any ablation built on it is "
97
+ f"inert and will report the model as surviving an ablation "
98
+ f"that never happened. Short, low-complexity or homopolymeric "
99
+ f"sequences often cannot be reshuffled at all."
100
+ )
101
+ return self
102
+
103
+
104
+ def dinucleotide_shuffle(seq: str, seed: int, attempts: int = 50) -> ShuffleResult:
105
+ """Altschul-Erikson shuffle preserving exact dinucleotide counts.
106
+
107
+ Composition and dinucleotide frequency are held fixed while order is
108
+ destroyed, which is what makes it a control for "did the model read order,
109
+ or just composition?".
110
+
111
+ Returns a ShuffleResult -- check `.changed` (or call `.require_changed()`)
112
+ before using the output as an ablation.
113
+ """
114
+ if len(seq) < 3:
115
+ return ShuffleResult(seq, False, 0)
116
+
117
+ rng = random.Random(seed)
118
+ last = seq[-1]
119
+ graph = defaultdict(list)
120
+ for i in range(len(seq) - 1):
121
+ graph[seq[i]].append(seq[i + 1])
122
+ verts = _vertices(graph)
123
+
124
+ for attempt in range(attempts):
125
+ last_edges = {}
126
+ ok = True
127
+ for v in verts:
128
+ if v == last:
129
+ continue
130
+ if not graph[v]:
131
+ ok = False
132
+ break
133
+ last_edges[v] = rng.choice(graph[v])
134
+ if not ok or not _connected_to_last(last, last_edges, verts):
135
+ rng = random.Random(seed + 7919 * (attempt + 1))
136
+ continue
137
+
138
+ edges = {v: list(succ) for v, succ in graph.items()}
139
+ for v, w in last_edges.items():
140
+ edges[v].remove(w)
141
+ rng.shuffle(edges[v])
142
+ edges[v].append(w) # the chosen last-edge departs last
143
+ for v, succ_edges in edges.items():
144
+ if v not in last_edges:
145
+ rng.shuffle(succ_edges)
146
+
147
+ result = [seq[0]]
148
+ cur = seq[0]
149
+ for _ in range(len(seq) - 1):
150
+ if not edges.get(cur):
151
+ ok = False
152
+ break
153
+ nxt = edges[cur].pop(0)
154
+ result.append(nxt)
155
+ cur = nxt
156
+ out = "".join(result)
157
+ if ok and dinucleotide_counts(out) == dinucleotide_counts(seq):
158
+ return ShuffleResult(out, out != seq, attempt + 1)
159
+ rng = random.Random(seed + 7919 * (attempt + 1))
160
+
161
+ # The source returned `seq` here silently. It is still the only
162
+ # count-preserving answer available -- but it is reported, not disguised.
163
+ #
164
+ # NOT COVERED BY THE SUITE, and deliberately marked so rather than left
165
+ # looking tested: reaching this line needs 50 consecutive attempts to fail
166
+ # connectivity or the count check, and a probe over 8 sequences x 30 seeds
167
+ # (homopolymers, dinucleotide repeats, 2-4mers included) reached it zero
168
+ # times. The unshuffleable cases users actually hit -- "AAAAAAAA", or a
169
+ # 12-mer under an unlucky seed -- exit through the success path above with
170
+ # `out == seq`, which IS covered. A mutation planted on this line survives
171
+ # the suite; that is a true coverage gap on an unreachable backstop, not a
172
+ # missing test.
173
+ return ShuffleResult(seq, False, attempts)
174
+
175
+
176
+ def knockout_span(seq: str, start: int, end: int, seed: int) -> ShuffleResult:
177
+ """Scramble `seq[start:end]` in place; flanks stay byte-identical."""
178
+ if not 0 <= start < end <= len(seq):
179
+ raise ValueError(f"span [{start}, {end}) is not inside a sequence of length {len(seq)}")
180
+ inner = dinucleotide_shuffle(seq[start:end], seed)
181
+ return ShuffleResult(seq[:start] + inner.sequence + seq[end:], inner.changed, inner.attempts)
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class ControlSpan:
186
+ start: int
187
+ length: int
188
+ disjoint: bool
189
+
190
+ @property
191
+ def end(self) -> int:
192
+ return self.start + self.length
193
+
194
+ def require_disjoint(self) -> ControlSpan:
195
+ if not self.disjoint:
196
+ raise ValueError(
197
+ f"could not place a length-{self.length} control span clear of "
198
+ f"the real spans; the returned start {self.start} OVERLAPS them, "
199
+ f"so the 'random control' is a partial real knockout and will be "
200
+ f"compared against itself. Use a longer sequence, a shorter "
201
+ f"total, or accept it explicitly."
202
+ )
203
+ return self
204
+
205
+
206
+ def _draw_start(rng, lo: int, hi: int) -> int:
207
+ """Draw an integer in [lo, hi] INCLUSIVE, whichever PRNG flavour was passed.
208
+
209
+ The three PRNGs this package already uses disagree about the upper bound:
210
+ numpy's RandomState.randint and Generator.integers EXCLUDE it, stdlib
211
+ random.Random.randint INCLUDES it -- and Generator has no `.randint` at all.
212
+ `rng.randint(0, hi)` therefore meant a different range for different callers
213
+ and, under numpy, could never draw the last legal start.
214
+ """
215
+ if hasattr(rng, "integers"): # numpy Generator
216
+ return int(rng.integers(lo, hi + 1))
217
+ if hasattr(rng, "random_sample"): # numpy RandomState
218
+ return int(rng.randint(lo, hi + 1))
219
+ return int(rng.randint(lo, hi)) # stdlib random.Random
220
+
221
+
222
+ def sample_control_span(length: int, total: int, spans, rng, attempts: int = 50) -> ControlSpan:
223
+ """Place a length-matched control span avoiding every real span.
224
+
225
+ The source returned a random start after 50 failures with no indication,
226
+ so a control that overlapped the motifs it was controlling for looked
227
+ identical to one that did not.
228
+ """
229
+ if total <= 0:
230
+ raise ValueError(f"control span length must be positive, got {total}")
231
+ if total > length:
232
+ raise ValueError(
233
+ f"control span length {total} is longer than the sequence ({length}), "
234
+ f"so no placement fits; the source clamped the range and returned a "
235
+ f"span running off the end, flagged as if it were valid."
236
+ )
237
+ spans = list(spans)
238
+ max_start = length - total # INCLUSIVE: a span may sit flush at the end
239
+
240
+ def _clear(st: int) -> bool:
241
+ return all(st + total <= s or st >= e for s, e in spans)
242
+
243
+ for _ in range(attempts):
244
+ st = _draw_start(rng, 0, max_start)
245
+ if _clear(st):
246
+ return ControlSpan(st, total, True)
247
+ # Report what the fallback draw ACTUALLY is. The source hardcoded False, so a
248
+ # span that happened to land clear was reported as overlapping, and callers
249
+ # discarded a usable control (or trusted require_disjoint's error over the data).
250
+ st = _draw_start(rng, 0, max_start)
251
+ return ControlSpan(st, total, _clear(st))
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class GroupedDelta:
256
+ mean: float
257
+ lo: float
258
+ hi: float
259
+ n_groups: int
260
+
261
+ @property
262
+ def excludes_zero(self) -> bool:
263
+ return self.lo > 0 or self.hi < 0
264
+
265
+
266
+ def grouped_delta_ci(
267
+ real, ko, groups, n: int = 2000, seed: int = 42, min_groups: int = 5
268
+ ) -> GroupedDelta:
269
+ """Paired CLUSTER bootstrap on mean(real - ko), resampling whole groups.
270
+
271
+ Resampling groups rather than rows is the point: promoters from one paralog
272
+ family are not independent observations, and a row-level bootstrap would
273
+ report an interval far narrower than the data supports.
274
+
275
+ `min_groups` guards the case the source did not check -- with a handful of
276
+ clusters the percentile interval is a number, not evidence.
277
+ """
278
+ real = np.asarray(real, dtype=float)
279
+ ko = np.asarray(ko, dtype=float)
280
+ groups = np.asarray(groups)
281
+ if not (real.shape == ko.shape == groups.shape):
282
+ raise ValueError(f"shapes differ: real={real.shape}, ko={ko.shape}, groups={groups.shape}")
283
+ if real.size == 0:
284
+ raise ValueError("no observations")
285
+
286
+ d = real - ko
287
+ uniq = np.unique(groups)
288
+ if len(uniq) < min_groups:
289
+ raise ValueError(
290
+ f"only {len(uniq)} distinct group(s); a cluster bootstrap resamples "
291
+ f"groups, so the interval would be drawn from at most {len(uniq)} "
292
+ f"distinct values. Pass min_groups=1 to override if you know what "
293
+ f"that interval means."
294
+ )
295
+
296
+ rng = np.random.RandomState(seed)
297
+ index_of = {g: np.where(groups == g)[0] for g in uniq}
298
+ stats = []
299
+ for _ in range(n):
300
+ gs = rng.choice(uniq, len(uniq), replace=True)
301
+ idx = np.concatenate([index_of[g] for g in gs])
302
+ stats.append(float(d[idx].mean()))
303
+ lo, hi = np.percentile(stats, [2.5, 97.5])
304
+ return GroupedDelta(float(d.mean()), float(lo), float(hi), len(uniq))
305
+
306
+
307
+ CONFIRMED = "confirmed"
308
+ NOT_CONFIRMED = "not-confirmed"
309
+
310
+
311
+ def confirm_knockout(pooled: GroupedDelta, control: GroupedDelta) -> str:
312
+ """The source's confirmation criterion, unchanged.
313
+
314
+ Confirmed iff the pooled knockout drops the score, its interval clears
315
+ zero, AND it drops more than a length-matched control knockout does. The
316
+ third clause is what stops "any perturbation hurts" being read as "this
317
+ element matters".
318
+ """
319
+ return (
320
+ CONFIRMED
321
+ if (pooled.mean > 0 and pooled.lo > 0 and pooled.mean > control.mean)
322
+ else NOT_CONFIRMED
323
+ )