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,260 @@
1
+ Metadata-Version: 2.4
2
+ Name: confound-controls
3
+ Version: 0.3.2
4
+ Summary: Confound-matched controls for classifier claims: 1:1 matching, bootstrap AUROC, anchored verdicts, and a refusal when the control is vacuous.
5
+ Author: Jaret Arnold
6
+ License: GPL-3.0-or-later
7
+ Project-URL: Homepage, https://github.com/musharna/confound-controls
8
+ Project-URL: Repository, https://github.com/musharna/confound-controls
9
+ Project-URL: Changelog, https://github.com/musharna/confound-controls/blob/master/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/musharna/confound-controls/issues
11
+ Keywords: confounding,matching,auroc,bootstrap,classifier-evaluation,research-integrity
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering
21
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Requires-Dist: numpy>=1.24
26
+ Requires-Dist: pandas>=2.0
27
+ Requires-Dist: scipy>=1.10
28
+ Requires-Dist: scikit-learn>=1.3
29
+ Provides-Extra: test
30
+ Requires-Dist: pytest>=7; extra == "test"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=7; extra == "dev"
33
+ Requires-Dist: ruff>=0.6; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # confound-controls
37
+
38
+ Did your classifier learn the signal, or a confound?
39
+
40
+ For each confound, restrict the negatives to a subset matched 1:1 to the
41
+ positives on that confound, then recompute AUROC. If the signal survives
42
+ matching, it is not that confound. If it collapses to chance, it was.
43
+
44
+ ```python
45
+ from confound_controls import run_battery, format_battery, bootstrap_auroc
46
+
47
+ anchor = bootstrap_auroc(df["label"], probs).point # uncontrolled baseline
48
+
49
+ results = run_battery(
50
+ df,
51
+ prob_map,
52
+ spec={"gc": ["gc"], "expression": ["log_expr"], "joint": ["gc", "log_expr"]},
53
+ anchor=anchor,
54
+ )
55
+ print(format_battery(results, anchor))
56
+ ```
57
+
58
+ Nothing is read from disk and nothing is written to it. The frame, the
59
+ probabilities, the confound spec and the anchor are all arguments.
60
+
61
+ ## The failure this exists to prevent
62
+
63
+ **1:1 matching without replacement from a negative pool the same size as the
64
+ positive set consumes every negative, whatever the confound values are.** The
65
+ matched comparison is then identical to the unmatched one — and a vacuous
66
+ control does not announce itself as inconclusive. It returns the original
67
+ result, which reads as the confound having been ruled out.
68
+
69
+ This is not hypothetical. It is how the first version of this library's own
70
+ test suite behaved: 150 positives against 150 negatives, a score computed from
71
+ nothing but GC, matched on GC, verdict `confound-robust`, AUROC 0.9312.
72
+
73
+ So `evaluate_confound` refuses by default:
74
+
75
+ ```
76
+ ValueError: matching consumed the entire negative pool (150 matched from 150
77
+ available), so it selected nothing and the control is vacuous -- the matched
78
+ comparison equals the unmatched one and will report the confound as ruled out.
79
+ ```
80
+
81
+ Pass `require_selective_match=False` if you mean it; the result then carries
82
+ `match_selective=False` and `format_battery` prints `[VACUOUS CONTROL]`.
83
+
84
+ ## Verdicts
85
+
86
+ | verdict | meaning |
87
+ | ----------------- | ----------------------------------------------------------------------------------- |
88
+ | `confound-robust` | enough of the anchor's above-chance signal survived, and the interval clears chance |
89
+ | `confound-driven` | the interval straddles chance — matching removed the signal |
90
+ | `partial` | signal is real but diminished |
91
+ | `inconclusive` | the interval is too wide to support any of the above (opt in via `max_ci_width`) |
92
+ | `inverted` | the interval clears chance from BELOW — the control reversed the ranking |
93
+
94
+ `inconclusive` has no counterpart in the source this came from, which folded
95
+ those cases into `confound-driven` — reporting an absent measurement as a
96
+ finding.
97
+
98
+ `inverted` likewise had no counterpart: an interval entirely below chance
99
+ _excludes_ chance, so it fell through to `partial` — reporting a control that
100
+ reliably reversed the ranking as a diminished-but-real signal.
101
+
102
+ ## Two more controls
103
+
104
+ **Incremental validity** — does the new feature add anything _beyond_ the
105
+ confounds? Fit confound-only and confound+feature models, evaluate both on the
106
+ same held-out rows, and put a **paired** bootstrap interval on the AUROC
107
+ difference.
108
+
109
+ Paired is the point. Two independent intervals on two AUROCs overlap far more
110
+ often than the interval on their difference excludes zero, because the pair is
111
+ computed on the same resampled rows and the shared variance cancels. Comparing
112
+ two separately-reported AUROCs by eye is exactly the error this prevents.
113
+
114
+ ```python
115
+ from confound_controls import incremental_validity
116
+
117
+ res = incremental_validity(conf_train, conf_test, feat_train, feat_test, y_train, y_test)
118
+ res.verdict # adds-signal | harms | no-added-signal | underpowered
119
+ ```
120
+
121
+ `feat_train` must be out-of-fold. Fitting the feature on the rows the confound
122
+ model is evaluated against leaks the label into the augmented model and
123
+ manufactures the very lift the control is testing for.
124
+
125
+ The source's verdict was two-valued (`"FM-adds-signal" if lo > 0 else
126
+ "no-added-signal"`), so an interval straddling zero, an interval too wide to
127
+ say anything, and an interval lying entirely _below_ zero all reported the
128
+ same. The last of those is an augmented model that is reliably **worse** — a
129
+ finding, not an absence of one.
130
+
131
+ **Ablation** — destroy the structure of interest, keep the nuisance properties,
132
+ score with the same model, and see what survives.
133
+
134
+ ```python
135
+ from confound_controls import assert_ablation_changed_input, ablation_control
136
+
137
+ assert_ablation_changed_input(real_inputs, ablated_inputs) # do this first
138
+ res = ablation_control(y, p_real, p_ablated)
139
+ res.verdict # structure-dependent | structure-independent | partial | inconclusive
140
+ ```
141
+
142
+ That first call is not optional politeness. **A broken ablation leaves the
143
+ input unchanged, so the scores match exactly, the delta is zero, and the
144
+ control reports the model as surviving** — the strongest possible result,
145
+ obtained by doing nothing. No score-level statistic can catch it, which is why
146
+ the check belongs on the input. The source had this instinct
147
+ (`assert not np.allclose(shuffled, real)`); here it is a first-class function
148
+ that also handles non-float inputs like sequences, where `np.allclose` raises.
149
+
150
+ This is the same shape as the vacuous matched control above: an inert control
151
+ returns the original answer, which reads as the hypothesis surviving.
152
+
153
+ ## What changed from the source
154
+
155
+ Extracted from an internal matched-negatives script, present byte-identically
156
+ in a second internal project. The code had been copied between two independent
157
+ repositories, which is what marked it as worth extracting:
158
+ duplication across repos is revealed reuse demand rather than a proxy for it.
159
+
160
+ The matching and the statistics are unchanged. What changed:
161
+
162
+ - **`ANCHOR = 0.629` was a module-level constant** — one study's own baseline
163
+ AUROC compiled into the library, against which every imported use silently
164
+ scored. Now a required argument, because there is no defensible default for
165
+ "what does good look like in your problem".
166
+ - **Input paths were derived from `__file__`** and the confound columns
167
+ (`gc`, `log_basemean`, `og_size`, `n_frac`, 64 `kmer3_*`) were hardcoded, so
168
+ the module ran against one dataset in one repo layout. Both are arguments.
169
+ - **Incomplete matches were silent.** When the pool ran out the original
170
+ returned fewer negatives than positives and said nothing — and the drop is
171
+ not random, it hits the positives in the densest region of confound space.
172
+ `MatchResult.complete` now answers this, and it is enforced by default.
173
+ - **The pass flag counted a hardcoded subset** of the study's own confound
174
+ names, so a confound added to the spec was scored, printed, and then left out
175
+ of the decision it existed to inform. `battery_passes` counts every result.
176
+ - **`recovery()` divided by `anchor - 0.5` without checking it.** An anchor at
177
+ or below chance makes that ratio a non-quantity rather than a large number;
178
+ it is now refused.
179
+ - **The bootstrap silently skipped single-class resamples.** With few positives
180
+ most resamples are skipped and the interval comes from a handful of
181
+ replicates. It now raises rather than returning a confident-looking number.
182
+
183
+ ## Sequence ablation
184
+
185
+ For sequence models, the ablation is a knockout: scramble a span, keep its
186
+ dinucleotide composition, re-score, and ask whether the drop is real and larger
187
+ than a length-matched control knockout elsewhere.
188
+
189
+ ```python
190
+ from confound_controls import (
191
+ knockout_span,
192
+ sample_control_span,
193
+ grouped_delta_ci,
194
+ confirm_knockout,
195
+ )
196
+
197
+ ko = knockout_span(seq, start, end, seed=1).require_changed()
198
+ ctl = sample_control_span(len(seq), end - start, motif_spans, rng).require_disjoint()
199
+
200
+ pooled = grouped_delta_ci(real_scores, ko_scores, family_ids)
201
+ control = grouped_delta_ci(real_scores, ctl_scores, family_ids)
202
+ confirm_knockout(pooled, control) # confirmed | not-confirmed
203
+ ```
204
+
205
+ **`grouped_delta_ci` resamples whole groups, not rows.** Promoters from one
206
+ paralog family are not independent observations; a row-level bootstrap treats
207
+ 96 correlated rows as 96 independent ones and reports an interval far narrower
208
+ than the data supports. Measured on the bundled fixture: grouped 0.189 wide vs
209
+ row-level 0.071.
210
+
211
+ **Three silent fallbacks, all pointing the same way.** Each returned a
212
+ plausible value on failure, and each failure makes the ablation weaker or
213
+ absent — which reads as the model _surviving_ it:
214
+
215
+ - the dinucleotide shuffle ended `return seq` on failure, so an un-shuffleable
216
+ span came back **unchanged** — a knockout that knocked nothing out. The
217
+ comment called this "rare"; measured on a real 29-mer, knocking out `[8:20]`
218
+ leaves it unchanged for **2 of the first 8 seeds**.
219
+ - the control-span sampler tried 50 times to avoid the real motif spans, then
220
+ returned an overlapping start regardless — a partial real knockout posing as
221
+ the negative control it is compared against.
222
+ - the cluster bootstrap never checked it had enough distinct groups to resample.
223
+
224
+ All three now report. `ShuffleResult.changed`, `ControlSpan.disjoint`, and a
225
+ `min_groups` floor, each with a `require_*` that raises.
226
+
227
+ The Altschul-Erikson shuffle itself is ported unchanged — **including its
228
+ determinism fix**. Using `set(graph)` for the vertex list made `rng.choice`
229
+ consume the PRNG in a different order per process (set iteration depends on the
230
+ randomized hash of string keys), so the same `(seq, seed)` produced different
231
+ shuffles run to run. A same-process test cannot catch that; the suite runs four
232
+ subprocesses and compares.
233
+
234
+ ## Not extracted
235
+
236
+ `aim3_confound_table.py` builds the confound table itself from FASTA, DE
237
+ results and orthogroup sizes. That is study data-prep rather than a control,
238
+ and its sequence-composition code would duplicate what `seqbench` already
239
+ does — so it stays where it is.
240
+
241
+ ## The source repos are untouched
242
+
243
+ Both source projects are active. This is
244
+ extract-and-leave-source-alone: nothing was refactored there, and neither repo
245
+ imports this package.
246
+
247
+ ## Install
248
+
249
+ ```bash
250
+ pip install confound-controls
251
+ ```
252
+
253
+ From a checkout, for development:
254
+
255
+ ```bash
256
+ pip install -e ".[dev]"
257
+ pytest -q
258
+ ```
259
+
260
+ Requires numpy, pandas, scipy, scikit-learn.
@@ -0,0 +1,12 @@
1
+ confound_controls/__init__.py,sha256=TGtQxhrcFJQM8IA0H3MTJkkt4mFSHHvp5jEzhJYVhrQ,2027
2
+ confound_controls/ablation.py,sha256=Viqms9RlBVmdcPoE6je-9dmee5J_OUGcOquc65jIzsY,6363
3
+ confound_controls/battery.py,sha256=45c5tp_4c0O2JI4IOnoV6XtCxRywewVe5BX2Why9bDg,5848
4
+ confound_controls/incremental.py,sha256=BibhwWoHPQNPY3i94OXulrxbF3AYHpGRDVIz1nuuxT0,5991
5
+ confound_controls/matching.py,sha256=1ai3TwOZeTGt3DPtyUQ1WWj3Q3DGAx1HIO6aWHduGik,5867
6
+ confound_controls/metrics.py,sha256=UHCZe0AoKUUQ-sNB2NKvGI93zGRTCA8BfbmXCRIeBBM,4579
7
+ confound_controls/sequence.py,sha256=CSIXzMeqHtOBtzvBdX_1xoXEVmfonv5jWJnfWD631QQ,12491
8
+ confound_controls-0.3.2.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
9
+ confound_controls-0.3.2.dist-info/METADATA,sha256=w1mS8qReyAqPLjUOUhj4urTBYYjBrmlOe_dobpb2ovg,11876
10
+ confound_controls-0.3.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
11
+ confound_controls-0.3.2.dist-info/top_level.txt,sha256=pxprmo5FVXQ4pxdppaJqRPe7BMugAGr4noeOZgBru6g,18
12
+ confound_controls-0.3.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+