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