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.
Files changed (25) hide show
  1. {confound_controls-0.3.2 → confound_controls-0.4.0}/PKG-INFO +44 -1
  2. {confound_controls-0.3.2 → confound_controls-0.4.0}/README.md +43 -0
  3. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/__init__.py +7 -2
  4. confound_controls-0.4.0/confound_controls/balance.py +174 -0
  5. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/battery.py +55 -4
  6. confound_controls-0.4.0/confound_controls/matching.py +259 -0
  7. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/PKG-INFO +44 -1
  8. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/SOURCES.txt +2 -0
  9. {confound_controls-0.3.2 → confound_controls-0.4.0}/pyproject.toml +1 -1
  10. confound_controls-0.4.0/tests/test_balance.py +199 -0
  11. {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_battery.py +17 -3
  12. confound_controls-0.3.2/confound_controls/matching.py +0 -154
  13. {confound_controls-0.3.2 → confound_controls-0.4.0}/LICENSE +0 -0
  14. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/ablation.py +0 -0
  15. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/incremental.py +0 -0
  16. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/metrics.py +0 -0
  17. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls/sequence.py +0 -0
  18. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/dependency_links.txt +0 -0
  19. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/requires.txt +0 -0
  20. {confound_controls-0.3.2 → confound_controls-0.4.0}/confound_controls.egg-info/top_level.txt +0 -0
  21. {confound_controls-0.3.2 → confound_controls-0.4.0}/setup.cfg +0 -0
  22. {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_ablation.py +0 -0
  23. {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_incremental.py +0 -0
  24. {confound_controls-0.3.2 → confound_controls-0.4.0}/tests/test_matching.py +0 -0
  25. {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.2
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.3.2"
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
- require_complete_match: bool = True,
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 subset."""
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(pos[id_column].tolist()) | set(match.matched_ids)
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 = "" if r.match_complete else " [INCOMPLETE MATCH]"
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.2
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.3.2"
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, probs, ["other"], anchor, require_complete_match=False, require_selective_match=False
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
- assert res.n_matched_negatives == 5
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
- res = evaluate_confound(df, probs, ["gc"], anchor, name="gc", require_selective_match=False)
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))