confound-controls 0.3.2__tar.gz → 0.4.0__tar.gz
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-0.3.2 → confound_controls-0.4.0}/PKG-INFO +44 -1
- {confound_controls-0.3.2 → confound_controls-0.4.0}/README.md +43 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/__init__.py +7 -2
- confound_controls-0.4.0/confound_controls/balance.py +174 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/battery.py +55 -4
- confound_controls-0.4.0/confound_controls/matching.py +259 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/PKG-INFO +44 -1
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/SOURCES.txt +2 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/pyproject.toml +1 -1
- confound_controls-0.4.0/tests/test_balance.py +199 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_battery.py +17 -3
- confound_controls-0.3.2/confound_controls/matching.py +0 -154
- {confound_controls-0.3.2 → confound_controls-0.4.0}/LICENSE +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/ablation.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/incremental.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/metrics.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/sequence.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/dependency_links.txt +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/requires.txt +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/top_level.txt +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/setup.cfg +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_ablation.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_incremental.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_matching.py +0 -0
- {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_sequence.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confound-controls
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Confound-matched controls for classifier claims: 1:1 matching, bootstrap AUROC, anchored verdicts, and a refusal when the control is vacuous.
|
|
5
5
|
Author: Jaret Arnold
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -81,6 +81,49 @@ comparison equals the unmatched one and will report the confound as ruled out.
|
|
|
81
81
|
Pass `require_selective_match=False` if you mean it; the result then carries
|
|
82
82
|
`match_selective=False` and `format_battery` prints `[VACUOUS CONTROL]`.
|
|
83
83
|
|
|
84
|
+
## The same failure, one size up
|
|
85
|
+
|
|
86
|
+
A pool bigger than the positive set passes that check and can fail the same
|
|
87
|
+
way. Greedy matching spends the negatives that sit near the positives first;
|
|
88
|
+
the rest of the positives are paired with whatever is left. The match is
|
|
89
|
+
complete and selective, and still confounded. Through 0.3.2 this library
|
|
90
|
+
scored it anyway: 300 positives, 900 negatives, a score computed from nothing
|
|
91
|
+
but GC, matched on GC — verdict `confound-robust`, AUROC 0.807, battery PASS.
|
|
92
|
+
|
|
93
|
+
`evaluate_confound` now measures what the match achieved and refuses when it
|
|
94
|
+
achieved too little:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
ValueError: gc: matching did not balance 'gc': |SMD| = 0.987 after matching
|
|
98
|
+
(was 2.010) exceeds 0.1. The matched set is still confounded, so a classifier
|
|
99
|
+
that separates it has not been shown to be robust to this confound.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The standardized mean difference is the difference in group means in units of
|
|
103
|
+
a standard deviation, measured against the unmatched groups' spread before and
|
|
104
|
+
after. `max_smd=0.1` is the conventional line; `max_smd=None` reports instead of
|
|
105
|
+
raising (`result.balance`, and `[IMBALANCED]` in `format_battery`).
|
|
106
|
+
|
|
107
|
+
Balance is bought with a **caliper**: a positive with no negative within
|
|
108
|
+
`caliper` standard deviations is left unmatched and reported, rather than paired
|
|
109
|
+
with something that does not resemble it. Only matched positives are scored.
|
|
110
|
+
The same data with `caliper=0.1`:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
gc AUROC=0.5015 CI[0.4350,0.5652] recovery=0.00 npos=148 -> confound-driven
|
|
114
|
+
SMD before 2.010 SMD after 0.005 var ratio 1.017
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
With many confounds every sample is far from every other, and nearest-neighbour
|
|
118
|
+
matching stops finding anyone. `method="propensity"` matches on the logit of
|
|
119
|
+
P(positive | confounds) instead, hardest positives first, with the caliper in
|
|
120
|
+
standard deviations of that logit (0.2 is the usual width). On the `lalonde`
|
|
121
|
+
data it fits the same logits as R's MatchIt (to 1e-7), picks the same 184 pairs
|
|
122
|
+
up to exact ties, and reports the same post-match SMDs to six decimals;
|
|
123
|
+
`tests/data/` holds MatchIt's output and `tests/test_balance.py` compares
|
|
124
|
+
against it. `standardized_mean_differences`, `balance_report` and
|
|
125
|
+
`propensity_logit` are usable on their own.
|
|
126
|
+
|
|
84
127
|
## Verdicts
|
|
85
128
|
|
|
86
129
|
| verdict | meaning |
|
|
@@ -46,6 +46,49 @@ comparison equals the unmatched one and will report the confound as ruled out.
|
|
|
46
46
|
Pass `require_selective_match=False` if you mean it; the result then carries
|
|
47
47
|
`match_selective=False` and `format_battery` prints `[VACUOUS CONTROL]`.
|
|
48
48
|
|
|
49
|
+
## The same failure, one size up
|
|
50
|
+
|
|
51
|
+
A pool bigger than the positive set passes that check and can fail the same
|
|
52
|
+
way. Greedy matching spends the negatives that sit near the positives first;
|
|
53
|
+
the rest of the positives are paired with whatever is left. The match is
|
|
54
|
+
complete and selective, and still confounded. Through 0.3.2 this library
|
|
55
|
+
scored it anyway: 300 positives, 900 negatives, a score computed from nothing
|
|
56
|
+
but GC, matched on GC — verdict `confound-robust`, AUROC 0.807, battery PASS.
|
|
57
|
+
|
|
58
|
+
`evaluate_confound` now measures what the match achieved and refuses when it
|
|
59
|
+
achieved too little:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
ValueError: gc: matching did not balance 'gc': |SMD| = 0.987 after matching
|
|
63
|
+
(was 2.010) exceeds 0.1. The matched set is still confounded, so a classifier
|
|
64
|
+
that separates it has not been shown to be robust to this confound.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The standardized mean difference is the difference in group means in units of
|
|
68
|
+
a standard deviation, measured against the unmatched groups' spread before and
|
|
69
|
+
after. `max_smd=0.1` is the conventional line; `max_smd=None` reports instead of
|
|
70
|
+
raising (`result.balance`, and `[IMBALANCED]` in `format_battery`).
|
|
71
|
+
|
|
72
|
+
Balance is bought with a **caliper**: a positive with no negative within
|
|
73
|
+
`caliper` standard deviations is left unmatched and reported, rather than paired
|
|
74
|
+
with something that does not resemble it. Only matched positives are scored.
|
|
75
|
+
The same data with `caliper=0.1`:
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
gc AUROC=0.5015 CI[0.4350,0.5652] recovery=0.00 npos=148 -> confound-driven
|
|
79
|
+
SMD before 2.010 SMD after 0.005 var ratio 1.017
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
With many confounds every sample is far from every other, and nearest-neighbour
|
|
83
|
+
matching stops finding anyone. `method="propensity"` matches on the logit of
|
|
84
|
+
P(positive | confounds) instead, hardest positives first, with the caliper in
|
|
85
|
+
standard deviations of that logit (0.2 is the usual width). On the `lalonde`
|
|
86
|
+
data it fits the same logits as R's MatchIt (to 1e-7), picks the same 184 pairs
|
|
87
|
+
up to exact ties, and reports the same post-match SMDs to six decimals;
|
|
88
|
+
`tests/data/` holds MatchIt's output and `tests/test_balance.py` compares
|
|
89
|
+
against it. `standardized_mean_differences`, `balance_report` and
|
|
90
|
+
`propensity_logit` are usable on their own.
|
|
91
|
+
|
|
49
92
|
## Verdicts
|
|
50
93
|
|
|
51
94
|
| verdict | meaning |
|
|
@@ -14,6 +14,7 @@ from .ablation import (
|
|
|
14
14
|
ablation_control,
|
|
15
15
|
assert_ablation_changed_input,
|
|
16
16
|
)
|
|
17
|
+
from .balance import BalanceReport, balance_report, standardized_mean_differences
|
|
17
18
|
from .battery import (
|
|
18
19
|
ConfoundResult,
|
|
19
20
|
battery_passes,
|
|
@@ -30,7 +31,7 @@ from .incremental import (
|
|
|
30
31
|
incremental_validity,
|
|
31
32
|
paired_delta_auroc,
|
|
32
33
|
)
|
|
33
|
-
from .matching import MatchResult, match_negatives
|
|
34
|
+
from .matching import MatchResult, match_negatives, propensity_logit
|
|
34
35
|
from .metrics import (
|
|
35
36
|
DRIVEN,
|
|
36
37
|
INCONCLUSIVE,
|
|
@@ -56,7 +57,7 @@ from .sequence import (
|
|
|
56
57
|
sample_control_span,
|
|
57
58
|
)
|
|
58
59
|
|
|
59
|
-
__version__ = "0.
|
|
60
|
+
__version__ = "0.4.0"
|
|
60
61
|
|
|
61
62
|
__all__ = [
|
|
62
63
|
"ABLATION_INCONCLUSIVE",
|
|
@@ -75,6 +76,7 @@ __all__ = [
|
|
|
75
76
|
"UNDERPOWERED",
|
|
76
77
|
"AblationResult",
|
|
77
78
|
"AurocCI",
|
|
79
|
+
"BalanceReport",
|
|
78
80
|
"ConfoundResult",
|
|
79
81
|
"ControlSpan",
|
|
80
82
|
"DeltaResult",
|
|
@@ -83,6 +85,7 @@ __all__ = [
|
|
|
83
85
|
"ShuffleResult",
|
|
84
86
|
"ablation_control",
|
|
85
87
|
"assert_ablation_changed_input",
|
|
88
|
+
"balance_report",
|
|
86
89
|
"battery_passes",
|
|
87
90
|
"bootstrap_auroc",
|
|
88
91
|
"confirm_knockout",
|
|
@@ -95,8 +98,10 @@ __all__ = [
|
|
|
95
98
|
"knockout_span",
|
|
96
99
|
"match_negatives",
|
|
97
100
|
"paired_delta_auroc",
|
|
101
|
+
"propensity_logit",
|
|
98
102
|
"recovery",
|
|
99
103
|
"run_battery",
|
|
100
104
|
"sample_control_span",
|
|
105
|
+
"standardized_mean_differences",
|
|
101
106
|
"verdict",
|
|
102
107
|
]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Did the match balance the confound, or only pair things up?
|
|
2
|
+
|
|
3
|
+
`MatchResult.complete` and `.selective` answer "did every positive get its own
|
|
4
|
+
negative" and "did matching choose". Neither asks the question matching exists
|
|
5
|
+
to answer. Greedy 1:1 matching without replacement spends the negatives that
|
|
6
|
+
sit near the positives first; whoever is left is paired with whatever is left.
|
|
7
|
+
The result is a complete, selective match that is still confounded -- and a
|
|
8
|
+
classifier that still separates it is then reported as having survived the
|
|
9
|
+
control. On a classifier that was NOTHING BUT the confound, 0.3.2 returned
|
|
10
|
+
"confound-robust".
|
|
11
|
+
|
|
12
|
+
The standardized mean difference, with the variance ratio for continuous
|
|
13
|
+
confounds, is the usual check on a matched sample (Austin 2009,
|
|
14
|
+
doi:10.1002/sim.3697): the difference in group means in units of a standard
|
|
15
|
+
deviation, which unlike a t-test does not shrink just because matching threw
|
|
16
|
+
samples away. |SMD| < 0.1 is the conventional line for "balanced"; it is a
|
|
17
|
+
convention, and `max_smd` is a parameter.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
import numpy as np
|
|
25
|
+
|
|
26
|
+
_DENOMINATORS = ("pooled", "positives")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_matrix(x) -> np.ndarray:
|
|
30
|
+
a = np.asarray(x, dtype=float)
|
|
31
|
+
if a.ndim == 1:
|
|
32
|
+
a = a.reshape(-1, 1)
|
|
33
|
+
if a.ndim != 2:
|
|
34
|
+
raise ValueError(f"expected a 2-D (n_samples, n_confounds) array, got {a.ndim}-D")
|
|
35
|
+
return a
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_binary(*columns: np.ndarray) -> bool:
|
|
39
|
+
return bool(np.isin(np.concatenate(columns), (0.0, 1.0)).all())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _variance(col: np.ndarray, binary: bool) -> float:
|
|
43
|
+
"""Variance of one group's column. A 0/1 column uses p(1-p): its spread is a
|
|
44
|
+
function of its mean, and the sample estimate only adds noise to that."""
|
|
45
|
+
if binary:
|
|
46
|
+
p = float(col.mean())
|
|
47
|
+
return p * (1.0 - p)
|
|
48
|
+
return float(col.var(ddof=1)) if len(col) > 1 else 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def standardized_mean_differences(pos, neg, *, denominator: str = "pooled", reference=None) -> list:
|
|
52
|
+
"""Per-confound (mean_pos - mean_neg) / sd.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
pos, neg: (n, n_confounds) confound values of the two groups compared.
|
|
56
|
+
denominator: "pooled" -- sqrt((var_pos + var_neg) / 2), symmetric in the
|
|
57
|
+
groups; or "positives" -- the positives' SD, which is
|
|
58
|
+
what MatchIt's `summary()` reports for 1:1 matching to a fixed group.
|
|
59
|
+
reference: optional (pos, neg) pair the SD is taken FROM. Pass the
|
|
60
|
+
unmatched groups when measuring a matched sample, so that before and
|
|
61
|
+
after share one yardstick; otherwise matching can "improve" an SMD
|
|
62
|
+
merely by changing the spread it is divided by.
|
|
63
|
+
|
|
64
|
+
A confound with no spread gives 0.0 when the means agree and inf when they
|
|
65
|
+
do not: the groups differ by something no amount of standardising can scale.
|
|
66
|
+
"""
|
|
67
|
+
if denominator not in _DENOMINATORS:
|
|
68
|
+
raise ValueError(f"denominator must be one of {_DENOMINATORS}, got {denominator!r}")
|
|
69
|
+
pos, neg = _as_matrix(pos), _as_matrix(neg)
|
|
70
|
+
if pos.shape[1] != neg.shape[1]:
|
|
71
|
+
raise ValueError(f"confound count differs: {pos.shape[1]} vs {neg.shape[1]}")
|
|
72
|
+
if not len(pos) or not len(neg):
|
|
73
|
+
raise ValueError(f"need both groups; got {len(pos)} positives and {len(neg)} negatives")
|
|
74
|
+
ref_pos, ref_neg = (pos, neg) if reference is None else map(_as_matrix, reference)
|
|
75
|
+
out = []
|
|
76
|
+
for j in range(pos.shape[1]):
|
|
77
|
+
binary = _is_binary(ref_pos[:, j], ref_neg[:, j])
|
|
78
|
+
v_pos = _variance(ref_pos[:, j], binary)
|
|
79
|
+
v_neg = _variance(ref_neg[:, j], binary)
|
|
80
|
+
sd = np.sqrt((v_pos + v_neg) / 2.0) if denominator == "pooled" else np.sqrt(v_pos)
|
|
81
|
+
diff = float(pos[:, j].mean() - neg[:, j].mean())
|
|
82
|
+
if sd == 0:
|
|
83
|
+
out.append(0.0 if diff == 0 else float(np.copysign(np.inf, diff)))
|
|
84
|
+
else:
|
|
85
|
+
out.append(diff / float(sd))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class BalanceReport:
|
|
91
|
+
"""Confound balance before and after matching, one entry per column."""
|
|
92
|
+
|
|
93
|
+
columns: list
|
|
94
|
+
smd_before: list
|
|
95
|
+
smd_after: list
|
|
96
|
+
variance_ratio_after: list
|
|
97
|
+
n_positives_after: int
|
|
98
|
+
n_negatives_after: int
|
|
99
|
+
denominator: str = "pooled"
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def max_abs_smd_before(self) -> float:
|
|
103
|
+
return float(np.max(np.abs(self.smd_before)))
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def max_abs_smd_after(self) -> float:
|
|
107
|
+
return float(np.max(np.abs(self.smd_after)))
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def worst_column(self):
|
|
111
|
+
return self.columns[int(np.argmax(np.abs(self.smd_after)))]
|
|
112
|
+
|
|
113
|
+
def balanced(self, threshold: float = 0.1) -> bool:
|
|
114
|
+
return self.max_abs_smd_after <= threshold
|
|
115
|
+
|
|
116
|
+
def require_balanced(self, threshold: float = 0.1, *, name: str = "match") -> BalanceReport:
|
|
117
|
+
if not self.balanced(threshold):
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"{name}: matching did not balance '{self.worst_column}': "
|
|
120
|
+
f"|SMD| = {self.max_abs_smd_after:.3f} after matching "
|
|
121
|
+
f"(was {self.max_abs_smd_before:.3f}) exceeds {threshold}. The matched "
|
|
122
|
+
f"set is still confounded, so a classifier that separates it has not "
|
|
123
|
+
f"been shown to be robust to this confound. Pass caliper=... to drop "
|
|
124
|
+
f"positives with no close negative (try 0.2), supply a larger negative "
|
|
125
|
+
f"pool, or pass max_smd=None to inspect the result anyway."
|
|
126
|
+
)
|
|
127
|
+
return self
|
|
128
|
+
|
|
129
|
+
def format(self) -> str:
|
|
130
|
+
width = max(len(str(c)) for c in self.columns)
|
|
131
|
+
lines = [f" {'confound':{width}s} SMD before SMD after var ratio"]
|
|
132
|
+
for c, b, a, v in zip(
|
|
133
|
+
self.columns, self.smd_before, self.smd_after, self.variance_ratio_after
|
|
134
|
+
):
|
|
135
|
+
ratio = " -" if np.isnan(v) else f"{v:7.3f}"
|
|
136
|
+
lines.append(f" {c!s:{width}s} {b:10.3f} {a:9.3f} {ratio}")
|
|
137
|
+
return "\n".join(lines)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def balance_report(
|
|
141
|
+
pos, neg, matched_pos, matched_neg, *, columns=None, denominator: str = "pooled"
|
|
142
|
+
) -> BalanceReport:
|
|
143
|
+
"""Balance of `matched_pos` vs `matched_neg`, against the unmatched `pos` vs `neg`.
|
|
144
|
+
|
|
145
|
+
Both SMDs are standardised by the UNMATCHED groups' spread. The variance
|
|
146
|
+
ratio (positives / negatives, after matching) is nan for a 0/1 column, where
|
|
147
|
+
it is a restatement of the mean difference and reads as extra evidence.
|
|
148
|
+
"""
|
|
149
|
+
names = list(columns) if columns is not None else list(getattr(pos, "columns", []))
|
|
150
|
+
pos, neg = _as_matrix(pos), _as_matrix(neg)
|
|
151
|
+
matched_pos, matched_neg = _as_matrix(matched_pos), _as_matrix(matched_neg)
|
|
152
|
+
if not names:
|
|
153
|
+
names = [f"x{j}" for j in range(pos.shape[1])]
|
|
154
|
+
if len(names) != pos.shape[1]:
|
|
155
|
+
raise ValueError(f"{len(names)} column names for {pos.shape[1]} confounds")
|
|
156
|
+
ratios = []
|
|
157
|
+
for j in range(pos.shape[1]):
|
|
158
|
+
if _is_binary(pos[:, j], neg[:, j]):
|
|
159
|
+
ratios.append(float("nan"))
|
|
160
|
+
continue
|
|
161
|
+
v_neg = _variance(matched_neg[:, j], False)
|
|
162
|
+
v_pos = _variance(matched_pos[:, j], False)
|
|
163
|
+
ratios.append(v_pos / v_neg if v_neg > 0 else float("inf" if v_pos > 0 else "nan"))
|
|
164
|
+
return BalanceReport(
|
|
165
|
+
columns=names,
|
|
166
|
+
smd_before=standardized_mean_differences(pos, neg, denominator=denominator),
|
|
167
|
+
smd_after=standardized_mean_differences(
|
|
168
|
+
matched_pos, matched_neg, denominator=denominator, reference=(pos, neg)
|
|
169
|
+
),
|
|
170
|
+
variance_ratio_after=ratios,
|
|
171
|
+
n_positives_after=len(matched_pos),
|
|
172
|
+
n_negatives_after=len(matched_neg),
|
|
173
|
+
denominator=denominator,
|
|
174
|
+
)
|
|
@@ -25,6 +25,7 @@ from dataclasses import dataclass, field
|
|
|
25
25
|
import numpy as np
|
|
26
26
|
import pandas as pd
|
|
27
27
|
|
|
28
|
+
from .balance import BalanceReport, balance_report
|
|
28
29
|
from .matching import match_negatives
|
|
29
30
|
from .metrics import AurocCI, bootstrap_auroc, recovery, verdict
|
|
30
31
|
|
|
@@ -40,6 +41,8 @@ class ConfoundResult:
|
|
|
40
41
|
match_complete: bool
|
|
41
42
|
match_selective: bool
|
|
42
43
|
unmatched_positions: list = field(default_factory=list)
|
|
44
|
+
# None only on results built by hand; `evaluate_confound` always fills it.
|
|
45
|
+
balance: BalanceReport | None = None
|
|
43
46
|
|
|
44
47
|
|
|
45
48
|
def evaluate_confound(
|
|
@@ -52,12 +55,32 @@ def evaluate_confound(
|
|
|
52
55
|
id_column: str = "id",
|
|
53
56
|
label_column: str = "label",
|
|
54
57
|
recovery_threshold: float = 0.70,
|
|
55
|
-
|
|
58
|
+
method: str = "nearest",
|
|
59
|
+
caliper: float | None = None,
|
|
60
|
+
max_smd: float | None = 0.1,
|
|
61
|
+
smd_denominator: str = "pooled",
|
|
62
|
+
require_complete_match: bool | None = None,
|
|
56
63
|
require_selective_match: bool = True,
|
|
57
64
|
bootstrap_n: int = 2000,
|
|
58
65
|
seed: int = 42,
|
|
59
66
|
) -> ConfoundResult:
|
|
60
|
-
"""Match negatives to positives on `columns`, then re-score on the
|
|
67
|
+
"""Match negatives to positives on `columns`, then re-score on the matched pairs.
|
|
68
|
+
|
|
69
|
+
`max_smd` is the balance the match must reach before its AUROC means
|
|
70
|
+
anything: above it the matched set is still confounded, and a classifier
|
|
71
|
+
that separates it has survived nothing. None reports the imbalance in
|
|
72
|
+
`.balance` instead of raising. A `caliper` is how balance is bought -- by
|
|
73
|
+
dropping the positives that have no close negative -- so with one set an
|
|
74
|
+
incomplete match is the design, not a failure: `require_complete_match`
|
|
75
|
+
defaults to True without a caliper and False with one.
|
|
76
|
+
|
|
77
|
+
Only matched positives are scored. 0.3.2 scored every positive against the
|
|
78
|
+
fewer matched negatives, which put the unmatched positives -- the ones in the
|
|
79
|
+
region the pool could not cover -- back into a comparison that claimed to be
|
|
80
|
+
1:1 matched.
|
|
81
|
+
"""
|
|
82
|
+
if require_complete_match is None:
|
|
83
|
+
require_complete_match = caliper is None
|
|
61
84
|
missing = [c for c in list(columns) + [id_column, label_column] if c not in df.columns]
|
|
62
85
|
if missing:
|
|
63
86
|
raise ValueError(
|
|
@@ -85,13 +108,34 @@ def evaluate_confound(
|
|
|
85
108
|
pos[list(columns)].to_numpy(),
|
|
86
109
|
neg[id_column].tolist(),
|
|
87
110
|
neg[list(columns)].to_numpy(),
|
|
111
|
+
method=method,
|
|
112
|
+
caliper=caliper,
|
|
88
113
|
)
|
|
89
114
|
if require_complete_match:
|
|
90
115
|
match.require_complete()
|
|
91
116
|
if require_selective_match:
|
|
92
117
|
match.require_selective()
|
|
118
|
+
if not match.matched_ids:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"{name}: no positive found a negative"
|
|
121
|
+
+ (f" within caliper {caliper}" if caliper is not None else "")
|
|
122
|
+
+ "; there is nothing to evaluate"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
matched_pos = pos.iloc[match.matched_positions]
|
|
126
|
+
matched_neg = neg.set_index(id_column, drop=False).loc[match.matched_ids]
|
|
127
|
+
balance = balance_report(
|
|
128
|
+
pos[list(columns)].to_numpy(),
|
|
129
|
+
neg[list(columns)].to_numpy(),
|
|
130
|
+
matched_pos[list(columns)].to_numpy(),
|
|
131
|
+
matched_neg[list(columns)].to_numpy(),
|
|
132
|
+
columns=list(columns),
|
|
133
|
+
denominator=smd_denominator,
|
|
134
|
+
)
|
|
135
|
+
if max_smd is not None:
|
|
136
|
+
balance.require_balanced(max_smd, name=name)
|
|
93
137
|
|
|
94
|
-
eval_ids = set(
|
|
138
|
+
eval_ids = set(matched_pos[id_column].tolist()) | set(match.matched_ids)
|
|
95
139
|
sub = df[df[id_column].isin(eval_ids)]
|
|
96
140
|
|
|
97
141
|
unknown = [g for g in sub[id_column] if g not in prob_map]
|
|
@@ -115,6 +159,7 @@ def evaluate_confound(
|
|
|
115
159
|
match_complete=match.complete,
|
|
116
160
|
match_selective=match.selective,
|
|
117
161
|
unmatched_positions=list(match.unmatched_positions),
|
|
162
|
+
balance=balance,
|
|
118
163
|
)
|
|
119
164
|
|
|
120
165
|
|
|
@@ -153,9 +198,15 @@ def battery_passes(results: Mapping[str, ConfoundResult]) -> bool:
|
|
|
153
198
|
def format_battery(results: Mapping[str, ConfoundResult], anchor: float) -> str:
|
|
154
199
|
lines = [f"confound battery (anchor AUROC={anchor:.4f})", ""]
|
|
155
200
|
for name, r in results.items():
|
|
156
|
-
flag =
|
|
201
|
+
flag = (
|
|
202
|
+
""
|
|
203
|
+
if r.match_complete
|
|
204
|
+
else f" [INCOMPLETE MATCH: {len(r.unmatched_positions)} positives left out]"
|
|
205
|
+
)
|
|
157
206
|
if not r.match_selective:
|
|
158
207
|
flag += " [VACUOUS CONTROL: whole pool used]"
|
|
208
|
+
if r.balance is not None and not r.balance.balanced():
|
|
209
|
+
flag += f" [IMBALANCED: max |SMD| = {r.balance.max_abs_smd_after:.3f}]"
|
|
159
210
|
lines.append(
|
|
160
211
|
f" {name:18s} AUROC={r.ci.point:.4f} "
|
|
161
212
|
f"CI[{r.ci.lo:.4f},{r.ci.hi:.4f}] "
|
|
@@ -0,0 +1,259 @@
|
|
|
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, field
|
|
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
|
+
# Parallel to `matched_ids`: which positive each negative was matched to, and
|
|
45
|
+
# how far apart they are in the space that was matched on (standardised
|
|
46
|
+
# confounds, or the propensity logit). Without these a caller cannot rebuild
|
|
47
|
+
# the pairs, and so cannot check what the match achieved.
|
|
48
|
+
matched_positions: list = field(default_factory=list)
|
|
49
|
+
distances: list = field(default_factory=list)
|
|
50
|
+
# The caliper actually applied, in the units of `distances`; None = none.
|
|
51
|
+
caliper: float | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def complete(self) -> bool:
|
|
55
|
+
return not self.unmatched_positions
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def n_matched(self) -> int:
|
|
59
|
+
return len(self.matched_ids)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def selective(self) -> bool:
|
|
63
|
+
"""Did matching actually CHOOSE, or take the whole pool?
|
|
64
|
+
|
|
65
|
+
1:1 matching without replacement from a pool the same size as the
|
|
66
|
+
positive set consumes every negative, whatever the confound values are.
|
|
67
|
+
The "matched" comparison is then byte-identical to the unmatched one,
|
|
68
|
+
so the control is vacuous -- and a vacuous control does not report
|
|
69
|
+
"inconclusive", it reports the original result, which reads as the
|
|
70
|
+
confound having been ruled out.
|
|
71
|
+
"""
|
|
72
|
+
return self.n_matched < self.n_pool
|
|
73
|
+
|
|
74
|
+
def require_complete(self) -> MatchResult:
|
|
75
|
+
if not self.complete:
|
|
76
|
+
raise ValueError(
|
|
77
|
+
f"matched only {self.n_matched} of {self.n_positives} positives; "
|
|
78
|
+
f"the negative pool ran out. Unmatched positive positions: "
|
|
79
|
+
f"{self.unmatched_positions[:10]}"
|
|
80
|
+
f"{'...' if len(self.unmatched_positions) > 10 else ''}"
|
|
81
|
+
)
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def require_selective(self) -> MatchResult:
|
|
85
|
+
if not self.selective:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"matching consumed the entire negative pool "
|
|
88
|
+
f"({self.n_matched} matched from {self.n_pool} available), so it "
|
|
89
|
+
f"selected nothing and the control is vacuous -- the matched "
|
|
90
|
+
f"comparison equals the unmatched one and will report the "
|
|
91
|
+
f"confound as ruled out. Supply more negatives than positives, "
|
|
92
|
+
f"or pass require_selective_match=False if you intend this."
|
|
93
|
+
)
|
|
94
|
+
return self
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
METHODS = ("nearest", "propensity")
|
|
98
|
+
_NEWTON_MAX_ITER = 100
|
|
99
|
+
# |logit| this large is a fitted probability within 1e-13 of 0 or 1: separation.
|
|
100
|
+
_LOGIT_LIMIT = 30.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def propensity_logit(pos_vecs, neg_vecs) -> tuple[np.ndarray, np.ndarray]:
|
|
104
|
+
"""Logit of P(positive | confounds) for each positive and each negative.
|
|
105
|
+
|
|
106
|
+
An unpenalised logistic regression fitted by Newton's method, i.e. what R's
|
|
107
|
+
`glm(..., binomial)` fits (it agrees with MatchIt's to 1e-7 on the lalonde
|
|
108
|
+
data). Not scikit-learn's: its default L2 penalty shrinks the coefficients by
|
|
109
|
+
an amount that depends on the units the confounds happen to be in. The logit
|
|
110
|
+
rather than the probability because it is roughly normal, so a distance (and
|
|
111
|
+
a caliper) means the same thing in the tails as in the middle.
|
|
112
|
+
|
|
113
|
+
Raises if the confounds separate the classes perfectly. No propensity score
|
|
114
|
+
exists then -- the likelihood has no maximum -- and no negative resembles
|
|
115
|
+
any positive, which is an answer about the data and not something to match
|
|
116
|
+
through.
|
|
117
|
+
"""
|
|
118
|
+
pos_vecs = np.asarray(pos_vecs, dtype=float)
|
|
119
|
+
neg_vecs = np.asarray(neg_vecs, dtype=float)
|
|
120
|
+
x = np.vstack([pos_vecs, neg_vecs])
|
|
121
|
+
y = np.r_[np.ones(len(pos_vecs)), np.zeros(len(neg_vecs))]
|
|
122
|
+
if not len(pos_vecs) or not len(neg_vecs):
|
|
123
|
+
raise ValueError("a propensity model needs both positives and negatives")
|
|
124
|
+
mu, sd = x.mean(0), x.std(0)
|
|
125
|
+
keep = sd > 0 # a constant column carries no information and is collinear with the intercept
|
|
126
|
+
design = np.column_stack([np.ones(len(x)), (x[:, keep] - mu[keep]) / sd[keep]])
|
|
127
|
+
beta = np.zeros(design.shape[1])
|
|
128
|
+
for _ in range(_NEWTON_MAX_ITER):
|
|
129
|
+
eta = design @ beta
|
|
130
|
+
p = 1.0 / (1.0 + np.exp(-eta))
|
|
131
|
+
w = p * (1.0 - p)
|
|
132
|
+
hessian = design.T @ (design * w[:, None])
|
|
133
|
+
try:
|
|
134
|
+
step = np.linalg.solve(hessian, design.T @ (y - p))
|
|
135
|
+
except np.linalg.LinAlgError:
|
|
136
|
+
step = None
|
|
137
|
+
if step is None or not np.isfinite(step).all() or np.abs(eta).max() > _LOGIT_LIMIT:
|
|
138
|
+
break
|
|
139
|
+
beta = beta + step
|
|
140
|
+
if np.abs(step).max() < 1e-10:
|
|
141
|
+
logit = design @ beta
|
|
142
|
+
return logit[: len(pos_vecs)], logit[len(pos_vecs) :]
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"the propensity model did not converge: the confounds separate positives "
|
|
145
|
+
"from negatives (almost) perfectly, or are collinear. With no overlap "
|
|
146
|
+
"there is no negative that resembles a positive on these confounds, so "
|
|
147
|
+
"matching cannot control for them."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def match_negatives(
|
|
152
|
+
pos_vecs, neg_ids, neg_vecs, *, method: str = "nearest", caliper: float | None = None
|
|
153
|
+
) -> MatchResult:
|
|
154
|
+
"""Greedy 1:1 nearest-neighbour match without replacement.
|
|
155
|
+
|
|
156
|
+
`method="nearest"` measures distance on the standardised confound columns
|
|
157
|
+
(standardised over positives and negatives jointly, so a confound on a wide
|
|
158
|
+
scale does not dominate through its units). `method="propensity"` collapses
|
|
159
|
+
the confounds to one number first -- the logit of the propensity score --
|
|
160
|
+
which is what keeps matching workable once there are many confounds and
|
|
161
|
+
every sample is far from every other. Positives are then taken in order of
|
|
162
|
+
decreasing propensity: the hardest to match choose first, while the
|
|
163
|
+
negatives that could serve them are still available.
|
|
164
|
+
|
|
165
|
+
A `caliper` refuses a pairing further apart than that many standard
|
|
166
|
+
deviations -- of the standardised columns (i.e. raw units of distance) for
|
|
167
|
+
"nearest", of the propensity logit for "propensity", where 0.2 standard
|
|
168
|
+
deviations of the logit is the recommended width (Austin 2011,
|
|
169
|
+
doi:10.1002/pst.433). A positive with no unused
|
|
170
|
+
negative inside the caliper is left unmatched and reported, not paired with
|
|
171
|
+
something that does not resemble it.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
pos_vecs: (n_pos, n_confounds) confound values for the positives.
|
|
175
|
+
neg_ids: identifiers for the negatives, parallel to `neg_vecs`.
|
|
176
|
+
neg_vecs: (n_neg, n_confounds) confound values for the negatives.
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
MatchResult -- inspect `.complete` before trusting a downstream metric,
|
|
180
|
+
or call `.require_complete()` to make an incomplete match an error.
|
|
181
|
+
"""
|
|
182
|
+
pos_vecs = np.asarray(pos_vecs, dtype=float)
|
|
183
|
+
neg_vecs = np.asarray(neg_vecs, dtype=float)
|
|
184
|
+
neg_ids = list(neg_ids)
|
|
185
|
+
|
|
186
|
+
if pos_vecs.ndim != 2 or neg_vecs.ndim != 2:
|
|
187
|
+
raise ValueError("pos_vecs and neg_vecs must both be 2-D")
|
|
188
|
+
if pos_vecs.shape[1] != neg_vecs.shape[1]:
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"confound count differs: positives have {pos_vecs.shape[1]}, "
|
|
191
|
+
f"negatives have {neg_vecs.shape[1]}"
|
|
192
|
+
)
|
|
193
|
+
if len(neg_ids) != neg_vecs.shape[0]:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
f"neg_ids has {len(neg_ids)} entries but neg_vecs has {neg_vecs.shape[0]} rows"
|
|
196
|
+
)
|
|
197
|
+
if method not in METHODS:
|
|
198
|
+
raise ValueError(f"method must be one of {METHODS}, got {method!r}")
|
|
199
|
+
if caliper is not None and not caliper > 0:
|
|
200
|
+
raise ValueError(f"caliper must be positive, got {caliper!r}")
|
|
201
|
+
if not len(neg_ids):
|
|
202
|
+
return MatchResult([], list(range(len(pos_vecs))), len(pos_vecs), 0)
|
|
203
|
+
if not len(pos_vecs):
|
|
204
|
+
return MatchResult([], [], 0, len(neg_ids))
|
|
205
|
+
|
|
206
|
+
order = range(len(pos_vecs))
|
|
207
|
+
limit = caliper
|
|
208
|
+
if method == "propensity":
|
|
209
|
+
pos_logit, neg_logit = propensity_logit(pos_vecs, neg_vecs)
|
|
210
|
+
pos_s, neg_s = pos_logit.reshape(-1, 1), neg_logit.reshape(-1, 1)
|
|
211
|
+
order = np.argsort(-pos_logit, kind="stable")
|
|
212
|
+
if caliper is not None:
|
|
213
|
+
# In SDs of the logit over every sample, positives and negatives
|
|
214
|
+
# together -- the scale MatchIt's `std.caliper` uses.
|
|
215
|
+
limit = float(caliper * np.r_[pos_logit, neg_logit].std(ddof=1))
|
|
216
|
+
else:
|
|
217
|
+
allv = np.vstack([pos_vecs, neg_vecs])
|
|
218
|
+
mu, sd = allv.mean(0), allv.std(0)
|
|
219
|
+
# A confound that is constant across every sample carries no information;
|
|
220
|
+
# dividing by its zero spread would produce nan distances and match at
|
|
221
|
+
# random, which looks exactly like a successful match.
|
|
222
|
+
sd = np.where(sd == 0, 1.0, sd)
|
|
223
|
+
pos_s = (pos_vecs - mu) / sd
|
|
224
|
+
neg_s = (neg_vecs - mu) / sd
|
|
225
|
+
|
|
226
|
+
tree = cKDTree(neg_s)
|
|
227
|
+
used: set[int] = set()
|
|
228
|
+
picks: dict[int, tuple[int, float]] = {}
|
|
229
|
+
|
|
230
|
+
for position in order:
|
|
231
|
+
p = pos_s[position]
|
|
232
|
+
k = 1
|
|
233
|
+
pick = None
|
|
234
|
+
while True:
|
|
235
|
+
k = min(k, len(neg_ids))
|
|
236
|
+
dists, idxs = tree.query(p, k=k)
|
|
237
|
+
dists, idxs = np.atleast_1d(dists), np.atleast_1d(idxs)
|
|
238
|
+
pick = next(((int(i), float(d)) for d, i in zip(dists, idxs) if i not in used), None)
|
|
239
|
+
# Neighbours come back nearest first, so once the furthest one seen
|
|
240
|
+
# is outside the caliper no unseen one can be inside it.
|
|
241
|
+
beyond = limit is not None and dists[-1] > limit
|
|
242
|
+
if pick is not None or k >= len(neg_ids) or beyond:
|
|
243
|
+
break
|
|
244
|
+
k *= 2
|
|
245
|
+
if pick is not None and (limit is None or pick[1] <= limit):
|
|
246
|
+
used.add(pick[0])
|
|
247
|
+
picks[int(position)] = pick
|
|
248
|
+
|
|
249
|
+
# Report in the positives' own order whatever order they were matched in.
|
|
250
|
+
positions = sorted(picks)
|
|
251
|
+
return MatchResult(
|
|
252
|
+
matched_ids=[neg_ids[picks[i][0]] for i in positions],
|
|
253
|
+
unmatched_positions=[i for i in range(len(pos_s)) if i not in picks],
|
|
254
|
+
n_positives=len(pos_s),
|
|
255
|
+
n_pool=len(neg_ids),
|
|
256
|
+
matched_positions=positions,
|
|
257
|
+
distances=[picks[i][1] for i in positions],
|
|
258
|
+
caliper=limit,
|
|
259
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: confound-controls
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Confound-matched controls for classifier claims: 1:1 matching, bootstrap AUROC, anchored verdicts, and a refusal when the control is vacuous.
|
|
5
5
|
Author: Jaret Arnold
|
|
6
6
|
License: GPL-3.0-or-later
|
|
@@ -81,6 +81,49 @@ comparison equals the unmatched one and will report the confound as ruled out.
|
|
|
81
81
|
Pass `require_selective_match=False` if you mean it; the result then carries
|
|
82
82
|
`match_selective=False` and `format_battery` prints `[VACUOUS CONTROL]`.
|
|
83
83
|
|
|
84
|
+
## The same failure, one size up
|
|
85
|
+
|
|
86
|
+
A pool bigger than the positive set passes that check and can fail the same
|
|
87
|
+
way. Greedy matching spends the negatives that sit near the positives first;
|
|
88
|
+
the rest of the positives are paired with whatever is left. The match is
|
|
89
|
+
complete and selective, and still confounded. Through 0.3.2 this library
|
|
90
|
+
scored it anyway: 300 positives, 900 negatives, a score computed from nothing
|
|
91
|
+
but GC, matched on GC — verdict `confound-robust`, AUROC 0.807, battery PASS.
|
|
92
|
+
|
|
93
|
+
`evaluate_confound` now measures what the match achieved and refuses when it
|
|
94
|
+
achieved too little:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
ValueError: gc: matching did not balance 'gc': |SMD| = 0.987 after matching
|
|
98
|
+
(was 2.010) exceeds 0.1. The matched set is still confounded, so a classifier
|
|
99
|
+
that separates it has not been shown to be robust to this confound.
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The standardized mean difference is the difference in group means in units of
|
|
103
|
+
a standard deviation, measured against the unmatched groups' spread before and
|
|
104
|
+
after. `max_smd=0.1` is the conventional line; `max_smd=None` reports instead of
|
|
105
|
+
raising (`result.balance`, and `[IMBALANCED]` in `format_battery`).
|
|
106
|
+
|
|
107
|
+
Balance is bought with a **caliper**: a positive with no negative within
|
|
108
|
+
`caliper` standard deviations is left unmatched and reported, rather than paired
|
|
109
|
+
with something that does not resemble it. Only matched positives are scored.
|
|
110
|
+
The same data with `caliper=0.1`:
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
gc AUROC=0.5015 CI[0.4350,0.5652] recovery=0.00 npos=148 -> confound-driven
|
|
114
|
+
SMD before 2.010 SMD after 0.005 var ratio 1.017
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
With many confounds every sample is far from every other, and nearest-neighbour
|
|
118
|
+
matching stops finding anyone. `method="propensity"` matches on the logit of
|
|
119
|
+
P(positive | confounds) instead, hardest positives first, with the caliper in
|
|
120
|
+
standard deviations of that logit (0.2 is the usual width). On the `lalonde`
|
|
121
|
+
data it fits the same logits as R's MatchIt (to 1e-7), picks the same 184 pairs
|
|
122
|
+
up to exact ties, and reports the same post-match SMDs to six decimals;
|
|
123
|
+
`tests/data/` holds MatchIt's output and `tests/test_balance.py` compares
|
|
124
|
+
against it. `standardized_mean_differences`, `balance_report` and
|
|
125
|
+
`propensity_logit` are usable on their own.
|
|
126
|
+
|
|
84
127
|
## Verdicts
|
|
85
128
|
|
|
86
129
|
| verdict | meaning |
|
|
@@ -3,6 +3,7 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
confound_controls/__init__.py
|
|
5
5
|
confound_controls/ablation.py
|
|
6
|
+
confound_controls/balance.py
|
|
6
7
|
confound_controls/battery.py
|
|
7
8
|
confound_controls/incremental.py
|
|
8
9
|
confound_controls/matching.py
|
|
@@ -14,6 +15,7 @@ confound_controls.egg-info/dependency_links.txt
|
|
|
14
15
|
confound_controls.egg-info/requires.txt
|
|
15
16
|
confound_controls.egg-info/top_level.txt
|
|
16
17
|
tests/test_ablation.py
|
|
18
|
+
tests/test_balance.py
|
|
17
19
|
tests/test_battery.py
|
|
18
20
|
tests/test_incremental.py
|
|
19
21
|
tests/test_matching.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "confound-controls"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Confound-matched controls for classifier claims: 1:1 matching, bootstrap AUROC, anchored verdicts, and a refusal when the control is vacuous."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Balance diagnostics, calipers and propensity matching.
|
|
2
|
+
|
|
3
|
+
The defect these exist for: 0.3.2 reported a match as `complete` and
|
|
4
|
+
`selective` and never asked whether it had BALANCED anything. Greedy 1:1
|
|
5
|
+
matching without replacement runs out of nearby negatives; the remaining
|
|
6
|
+
positives are paired with whatever is left. The matched set is still confounded,
|
|
7
|
+
the classifier still separates it, and the battery reads that as the signal
|
|
8
|
+
having survived the control -- "confound-robust" -- for a classifier that is
|
|
9
|
+
nothing but the confound.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import pandas as pd
|
|
18
|
+
import pytest
|
|
19
|
+
|
|
20
|
+
from confound_controls import (
|
|
21
|
+
DRIVEN,
|
|
22
|
+
balance_report,
|
|
23
|
+
evaluate_confound,
|
|
24
|
+
format_battery,
|
|
25
|
+
match_negatives,
|
|
26
|
+
propensity_logit,
|
|
27
|
+
run_battery,
|
|
28
|
+
standardized_mean_differences,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
DATA = Path(__file__).parent / "data"
|
|
32
|
+
COVARIATES = ["age", "educ", "married", "nodegree", "re74", "re75"]
|
|
33
|
+
|
|
34
|
+
# summary(matchit(...)) from MatchIt 4.5.5, copied from the R console.
|
|
35
|
+
MATCHIT_SMD_ALL = [-0.309445, 0.054965, -0.826309, 0.244970, -0.721084, -0.290263]
|
|
36
|
+
MATCHIT_SMD_MATCHED = [0.037219, -0.116229, 0.000000, 0.095634, -0.070019, -0.003952]
|
|
37
|
+
MATCHIT_VARRATIO_ALL = {"age": 0.439995, "educ": 0.495893, "re74": 0.518128, "re75": 0.956293}
|
|
38
|
+
MATCHIT_CALIPER = 0.2015685002
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture(scope="module")
|
|
42
|
+
def lalonde():
|
|
43
|
+
df = pd.read_csv(DATA / "lalonde.csv")
|
|
44
|
+
return df[df.treat == 1], df[df.treat == 0], df
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def confounded_frame(n_pos=300, n_neg=900, shift=2.0, seed=0):
|
|
48
|
+
rng = np.random.default_rng(seed)
|
|
49
|
+
gc = np.r_[rng.normal(shift, 1, n_pos), rng.normal(0, 1, n_neg)]
|
|
50
|
+
df = pd.DataFrame(
|
|
51
|
+
{
|
|
52
|
+
"id": [f"s{i}" for i in range(n_pos + n_neg)],
|
|
53
|
+
"label": [1] * n_pos + [0] * n_neg,
|
|
54
|
+
"gc": gc,
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
prob = dict(zip(df.id, 1 / (1 + np.exp(-gc)))) # the classifier IS the confound
|
|
58
|
+
y = df.label.to_numpy()
|
|
59
|
+
p = np.array([prob[i] for i in df.id])
|
|
60
|
+
from sklearn.metrics import roc_auc_score
|
|
61
|
+
|
|
62
|
+
return df, prob, float(roc_auc_score(y, p))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_smd_and_variance_ratio_agree_with_matchit(lalonde):
|
|
66
|
+
pos, neg, _ = lalonde
|
|
67
|
+
smd = standardized_mean_differences(pos[COVARIATES], neg[COVARIATES], denominator="positives")
|
|
68
|
+
assert smd == pytest.approx(MATCHIT_SMD_ALL, abs=5e-6)
|
|
69
|
+
report = balance_report(pos[COVARIATES], neg[COVARIATES], pos[COVARIATES], neg[COVARIATES])
|
|
70
|
+
for col, want in MATCHIT_VARRATIO_ALL.items():
|
|
71
|
+
assert report.variance_ratio_after[COVARIATES.index(col)] == pytest.approx(want, abs=5e-6)
|
|
72
|
+
# A variance ratio is not defined for a 0/1 column; reporting one invites reading it.
|
|
73
|
+
assert np.isnan(report.variance_ratio_after[COVARIATES.index("married")])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_pooled_denominator_is_the_mean_of_the_two_variances():
|
|
77
|
+
pos = np.array([[0.0], [2.0], [4.0]]) # var 4
|
|
78
|
+
neg = np.array([[0.0], [1.0], [2.0]]) # var 1, mean diff 1
|
|
79
|
+
assert standardized_mean_differences(pos, neg) == pytest.approx([1 / np.sqrt(2.5)])
|
|
80
|
+
assert standardized_mean_differences(pos, neg, denominator="positives") == pytest.approx([0.5])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_constant_column_is_zero_when_equal_and_infinite_when_not():
|
|
84
|
+
same = standardized_mean_differences(np.ones((3, 1)), np.ones((4, 1)))
|
|
85
|
+
apart = standardized_mean_differences(np.ones((3, 1)), np.zeros((4, 1)))
|
|
86
|
+
assert same == [0.0]
|
|
87
|
+
assert apart == [np.inf]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_propensity_logit_agrees_with_matchit_glm(lalonde):
|
|
91
|
+
pos, neg, _ = lalonde
|
|
92
|
+
ref = pd.read_csv(DATA / "lalonde_matchit_logit.csv").set_index("id").logit
|
|
93
|
+
pos_logit, neg_logit = propensity_logit(pos[COVARIATES], neg[COVARIATES])
|
|
94
|
+
assert np.max(np.abs(pos_logit - ref.loc[pos.id].to_numpy())) < 1e-6
|
|
95
|
+
assert np.max(np.abs(neg_logit - ref.loc[neg.id].to_numpy())) < 1e-6
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_perfectly_separated_confound_has_no_propensity_score():
|
|
99
|
+
with pytest.raises(ValueError, match="did not converge: the confounds separate"):
|
|
100
|
+
propensity_logit(np.array([[5.0], [6.0], [7.0]]), np.array([[0.0], [1.0], [2.0]]))
|
|
101
|
+
# Control: overlapping groups fit, and positives score higher on average.
|
|
102
|
+
rng = np.random.default_rng(1)
|
|
103
|
+
pos_logit, neg_logit = propensity_logit(rng.normal(1, 1, (60, 2)), rng.normal(0, 1, (90, 2)))
|
|
104
|
+
assert pos_logit.mean() > neg_logit.mean()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_propensity_caliper_match_reproduces_matchit_pairs(lalonde):
|
|
108
|
+
pos, neg, _ = lalonde
|
|
109
|
+
ref = pd.read_csv(DATA / "lalonde_matchit_pairs.csv").dropna()
|
|
110
|
+
_, neg_logit = propensity_logit(pos[COVARIATES], neg[COVARIATES])
|
|
111
|
+
result = match_negatives(
|
|
112
|
+
pos[COVARIATES].to_numpy(), neg.id.tolist(), neg[COVARIATES].to_numpy(),
|
|
113
|
+
method="propensity", caliper=0.2,
|
|
114
|
+
) # fmt: skip
|
|
115
|
+
assert result.caliper == pytest.approx(MATCHIT_CALIPER, rel=1e-7)
|
|
116
|
+
assert (result.n_matched, len(result.unmatched_positions)) == (184, 1)
|
|
117
|
+
ours = {pos.id.iloc[i]: c for i, c in zip(result.matched_positions, result.matched_ids)}
|
|
118
|
+
theirs = dict(zip(ref.treated, ref.control))
|
|
119
|
+
assert set(ours) == set(theirs)
|
|
120
|
+
# lalonde has duplicated rows, so several controls share a logit exactly and
|
|
121
|
+
# either is "the nearest". Every pair is MatchIt's, or a tie with MatchIt's.
|
|
122
|
+
logit_of = dict(zip(neg.id, neg_logit))
|
|
123
|
+
differing = [t for t in theirs if ours[t] != theirs[t]]
|
|
124
|
+
assert len(differing) <= 10
|
|
125
|
+
for t in differing:
|
|
126
|
+
assert logit_of[ours[t]] == pytest.approx(logit_of[theirs[t]], abs=1e-9), t
|
|
127
|
+
matched_pos = pos.iloc[result.matched_positions][COVARIATES]
|
|
128
|
+
matched_neg = neg.set_index("id").loc[result.matched_ids][COVARIATES]
|
|
129
|
+
smd = standardized_mean_differences(
|
|
130
|
+
matched_pos, matched_neg, denominator="positives", reference=(pos[COVARIATES], neg[COVARIATES])
|
|
131
|
+
) # fmt: skip
|
|
132
|
+
assert smd == pytest.approx(MATCHIT_SMD_MATCHED, abs=5e-6)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_caliper_leaves_far_positives_unmatched_and_reports_them():
|
|
136
|
+
pos = np.array([[0.0], [0.1], [9.0]])
|
|
137
|
+
neg = np.array([[0.05], [0.2], [0.3], [0.4]])
|
|
138
|
+
loose = match_negatives(pos, list("abcd"), neg)
|
|
139
|
+
assert (loose.complete, loose.matched_positions) == (True, [0, 1, 2])
|
|
140
|
+
tight = match_negatives(pos, list("abcd"), neg, caliper=0.5)
|
|
141
|
+
assert (tight.matched_positions, tight.unmatched_positions) == ([0, 1], [2])
|
|
142
|
+
assert tight.matched_ids == ["a", "b"]
|
|
143
|
+
assert max(tight.distances) <= 0.5
|
|
144
|
+
with pytest.raises(ValueError, match="caliper must be positive"):
|
|
145
|
+
match_negatives(pos, list("abcd"), neg, caliper=0)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_classifier_that_is_the_confound_is_not_called_robust():
|
|
149
|
+
df, prob, anchor = confounded_frame()
|
|
150
|
+
with pytest.raises(ValueError, match=r"gc: .*\|SMD\| = 0\.\d+ .*exceeds 0\.1") as err:
|
|
151
|
+
evaluate_confound(df, prob, ["gc"], anchor, name="gc", bootstrap_n=200)
|
|
152
|
+
assert "caliper" in str(err.value)
|
|
153
|
+
# Positive control: the same data, matched within a caliper, IS evaluable --
|
|
154
|
+
# and the verdict is the true one.
|
|
155
|
+
result = evaluate_confound(df, prob, ["gc"], anchor, name="gc", caliper=0.1, bootstrap_n=200)
|
|
156
|
+
assert result.balance.max_abs_smd_after < 0.1
|
|
157
|
+
assert result.verdict == DRIVEN
|
|
158
|
+
assert 0 < result.n_positives < 300
|
|
159
|
+
assert result.n_positives == result.n_matched_negatives
|
|
160
|
+
assert result.n_positives + len(result.unmatched_positions) == 300
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_imbalance_can_be_inspected_instead_of_raised():
|
|
164
|
+
df, prob, anchor = confounded_frame()
|
|
165
|
+
results = run_battery(df, prob, {"gc": ["gc"]}, anchor, max_smd=None, bootstrap_n=200)
|
|
166
|
+
assert results["gc"].balance.max_abs_smd_after > 0.1
|
|
167
|
+
text = format_battery(results, anchor)
|
|
168
|
+
assert "[IMBALANCED: max |SMD| = 0." in text
|
|
169
|
+
# Control: a balanced battery carries no such flag.
|
|
170
|
+
balanced = run_battery(df, prob, {"gc": ["gc"]}, anchor, caliper=0.1, bootstrap_n=200)
|
|
171
|
+
text = format_battery(balanced, anchor)
|
|
172
|
+
assert "IMBALANCED" not in text
|
|
173
|
+
dropped = len(balanced["gc"].unmatched_positions)
|
|
174
|
+
assert dropped > 0 and f"[INCOMPLETE MATCH: {dropped} positives left out]" in text
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_incomplete_match_is_scored_as_the_one_to_one_design_it_claims():
|
|
178
|
+
# 0.3.2 scored ALL positives against the fewer matched negatives.
|
|
179
|
+
df, prob, anchor = confounded_frame(n_pos=40, n_neg=30, shift=1.0, seed=3)
|
|
180
|
+
result = evaluate_confound(
|
|
181
|
+
df, prob, ["gc"], anchor, require_complete_match=False,
|
|
182
|
+
require_selective_match=False, max_smd=None, bootstrap_n=100,
|
|
183
|
+
) # fmt: skip
|
|
184
|
+
assert (result.n_positives, result.n_matched_negatives) == (30, 30)
|
|
185
|
+
assert len(result.unmatched_positions) == 10
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@pytest.mark.parametrize("method", ["nearest", "propensity"])
|
|
189
|
+
def test_no_positives_means_no_caliper_was_applied(method):
|
|
190
|
+
# `caliper` reports the width APPLIED, in distance units. With nothing to
|
|
191
|
+
# match there is no propensity model to scale it by, so both methods say None
|
|
192
|
+
# rather than one echoing the argument and the other not.
|
|
193
|
+
neg = np.array([[0.0], [1.0], [2.0]])
|
|
194
|
+
empty = match_negatives(np.empty((0, 1)), list("abc"), neg, method=method, caliper=0.2)
|
|
195
|
+
assert (empty.matched_ids, empty.unmatched_positions, empty.n_pool) == ([], [], 3)
|
|
196
|
+
assert empty.caliper is None
|
|
197
|
+
# Control: with positives present the applied caliper is reported.
|
|
198
|
+
some = match_negatives(np.array([[0.1], [1.2]]), list("abc"), neg, method=method, caliper=0.2)
|
|
199
|
+
assert some.caliper is not None and some.caliper > 0
|
|
@@ -138,10 +138,17 @@ def test_incomplete_match_raises_by_default_and_is_reported_when_allowed():
|
|
|
138
138
|
with pytest.raises(ValueError, match="matched only"):
|
|
139
139
|
evaluate_confound(small, probs, ["other"], anchor)
|
|
140
140
|
res = evaluate_confound(
|
|
141
|
-
small,
|
|
141
|
+
small,
|
|
142
|
+
probs,
|
|
143
|
+
["other"],
|
|
144
|
+
anchor,
|
|
145
|
+
require_complete_match=False,
|
|
146
|
+
require_selective_match=False,
|
|
147
|
+
max_smd=None,
|
|
142
148
|
)
|
|
143
149
|
assert not res.match_complete
|
|
144
|
-
|
|
150
|
+
# Only the matched positives are scored: the design reported is the 1:1 one.
|
|
151
|
+
assert (res.n_positives, res.n_matched_negatives) == (5, 5)
|
|
145
152
|
|
|
146
153
|
|
|
147
154
|
def test_format_battery_flags_an_incomplete_match_in_its_text():
|
|
@@ -156,6 +163,7 @@ def test_format_battery_flags_an_incomplete_match_in_its_text():
|
|
|
156
163
|
name="other",
|
|
157
164
|
require_complete_match=False,
|
|
158
165
|
require_selective_match=False,
|
|
166
|
+
max_smd=None,
|
|
159
167
|
)
|
|
160
168
|
}
|
|
161
169
|
assert "INCOMPLETE MATCH" in format_battery(results, 0.9)
|
|
@@ -205,7 +213,13 @@ def test_an_equal_sized_pool_is_refused_as_a_vacuous_control():
|
|
|
205
213
|
|
|
206
214
|
# Opting out reproduces the original silent behaviour, and it is WRONG in
|
|
207
215
|
# exactly the way described: a pure-GC signal survives GC matching.
|
|
208
|
-
|
|
216
|
+
# Opting out of the selectivity check alone no longer gets there: a match that
|
|
217
|
+
# selected nothing balanced nothing, and the balance check says so.
|
|
218
|
+
with pytest.raises(ValueError, match="did not balance 'gc'"):
|
|
219
|
+
evaluate_confound(df, probs, ["gc"], anchor, name="gc", require_selective_match=False)
|
|
220
|
+
res = evaluate_confound(
|
|
221
|
+
df, probs, ["gc"], anchor, name="gc", require_selective_match=False, max_smd=None
|
|
222
|
+
)
|
|
209
223
|
assert not res.match_selective
|
|
210
224
|
assert res.verdict == ROBUST, (
|
|
211
225
|
"if this is no longer ROBUST the vacuity demonstration has changed"
|
|
@@ -1,154 +0,0 @@
|
|
|
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))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
{confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|