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,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,,
|