stability-matched 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: stability-matched
3
+ Version: 1.0.0
4
+ Summary: Feature-selection stability (Nogueira's estimator) with its exact chance level for candidate pools chosen inside each resample
5
+ Author: Hassan Nasiraee, Hajar Abbaszadeh, Parvin Farzanegi
6
+ License: MIT
7
+ Keywords: feature selection,stability,reproducibility,chance correction,bioinformatics
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: numpy>=1.22
15
+ Requires-Dist: scipy>=1.8
16
+ Provides-Extra: test
17
+ Requires-Dist: pytest; extra == "test"
18
+ Provides-Extra: examples
19
+ Requires-Dist: scikit-learn>=1.2; extra == "examples"
20
+ Dynamic: license-file
21
+
22
+ # stability-matched
23
+
24
+ Feature-selection stability with its **exact chance level when the candidate
25
+ pool is chosen inside each resample** (for example a variance or univariate
26
+ filter fitted on the training rows).
27
+
28
+ Nogueira's estimator (Nogueira, Sechidis & Brown, JMLR 2018) corrects for
29
+ chance as if every feature could be selected in every resample. When each
30
+ resample first filters its own pool, features that pass every filter are
31
+ favoured even by a selector that picks at random inside its pool, and part of
32
+ the reported stability belongs to the filter. This package gives, in closed
33
+ form and without simulation, the expected value of the estimator under random
34
+ selection of the observed sizes from the observed pools, and the excess over
35
+ it, together with a Monte Carlo test, a variant that keeps co-expression
36
+ modules, and an exact chance level for the agreement between two selectors.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ pip install stability-matched
42
+ ```
43
+
44
+ Only NumPy and SciPy are required.
45
+
46
+ ## Use
47
+
48
+ ```python
49
+ from stability_matched import matrices_from_indices, phi_matched, report
50
+
51
+ # supports[i]: indices selected in resample i; pools[i]: its candidate pool
52
+ Z, P = matrices_from_indices(supports, pools, d)
53
+ print(report(Z, P)) # phi, interval, chance level, excess, p
54
+ res = phi_matched(Z, P, B=999) # the same as a dictionary
55
+ ```
56
+
57
+ From the command line, with `Z` and `P` saved as `.npy`, `.csv`, or one
58
+ `.npz` holding arrays `Z` and `P`:
59
+
60
+ ```bash
61
+ stability-matched selections.npz
62
+ ```
63
+
64
+ `examples/example_sklearn.py` runs the whole workflow for a cross-validated
65
+ lasso with a variance filter fitted inside each resample.
66
+
67
+ ## Functions
68
+
69
+ | Function | Returns |
70
+ |---|---|
71
+ | `phi_stability(Z)` | Nogueira's estimator, asymptotic variance, 95% interval |
72
+ | `phi0_matched(Z, P)` | exact expected estimator under random selection within each resample's pool |
73
+ | `phi0_stratified(Z, P, groups)` | the same, keeping how many features each resample takes from every group (e.g. co-expression module) |
74
+ | `matched_null_test(Z, P, B)` | Monte Carlo p-value against B complete null matrices |
75
+ | `agreement_exact(A, B, P)` | mean Jaccard between two selectors in the same resample and its exact hypergeometric chance level |
76
+ | `phi_matched(Z, P, B)` | all of the above for one matrix |
77
+ | `matrices_from_indices`, `report`, `main` | helpers and command line |
78
+
79
+ Empty supports are kept (they lower stability); a matrix with no defined
80
+ stability returns NaN. Tests: `pytest tests/`.
81
+
82
+ ## Cite
83
+
84
+ H. Nasiraee, H. Abbaszadeh, P. Farzanegi. How much of feature-selection
85
+ stability is chance? An exact correction, a re-evaluation of two published
86
+ tools, and a multi-tissue audit. Code: DOI 10.5281/zenodo.22662385. MIT
87
+ licence.
@@ -0,0 +1,7 @@
1
+ stability_matched.py,sha256=Ql4nrLtr5EYCBPBnUyIuMv8mQD5lomUOiVNKOKKd77k,11189
2
+ stability_matched-1.0.0.dist-info/licenses/LICENSE,sha256=yG5ruXPBTy8xMFSPdBVtLCYVmHsudlvdxtKY6Q0h12Y,1111
3
+ stability_matched-1.0.0.dist-info/METADATA,sha256=uYSreZO7ECagU9zBgpbY_XBRiSDNI8vYHiYjzjhb1qQ,3535
4
+ stability_matched-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
5
+ stability_matched-1.0.0.dist-info/entry_points.txt,sha256=SxdBUn0lvxICJQvkPSTn1Qq868yLqqzTmsv07VAtdm8,61
6
+ stability_matched-1.0.0.dist-info/top_level.txt,sha256=yqdWzJMMAx5A34IW4rK6XbD7gngz1inJP-2PYu8bN0w,18
7
+ stability_matched-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ stability-matched = stability_matched:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hassan Nasiraee, Hajar Abbaszadeh and Parvin Farzanegi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ stability_matched
stability_matched.py ADDED
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env python3
2
+ # =============================================================================
3
+ # stability_matched.py -- stand-alone stability toolkit
4
+ #
5
+ # For a binary selection matrix Z (M resamples x d features) and the
6
+ # candidate-pool matrix P (same shape; P[i, f] = 1 if feature f could be
7
+ # selected in resample i):
8
+ #
9
+ # phi_stability(Z) Nogueira, Sechidis & Brown (2018) estimator,
10
+ # its asymptotic variance and 95% interval
11
+ # phi0_matched(Z, P) exact E[phi] under the matched null: row i a
12
+ # uniform random k_i-subset of pool P_i, rows
13
+ # independent (Proposition 1 of the paper);
14
+ # conditional on the observed k_i and P_i
15
+ # phi0_stratified(Z, P, g) exact E[phi] when row i keeps how many genes
16
+ # it selects from every group g (e.g. a
17
+ # co-expression module) but chooses WHICH genes
18
+ # of that group (within its pool) at random;
19
+ # Proposition 1 with pi_if = c_ig / |g n P_i|
20
+ # matched_null_test(Z, P) Monte Carlo p-value against B complete null
21
+ # matrices, with null quantiles
22
+ # agreement_exact(A, B, P) mean Jaccard between two selectors' supports
23
+ # in the same resample, and its exact
24
+ # hypergeometric chance level
25
+ # phi_matched(Z, P) all of the above in one call
26
+ # matrices_from_indices(supports, pools, d)
27
+ # build Z and P from per-resample index lists
28
+ # report(Z, P) a plain-text report of the above
29
+ #
30
+ # Command line: stability-matched Z.npy P.npy (or python -m
31
+ # stability_matched; also accepts one .npz with arrays Z and P, and .csv)
32
+ #
33
+ # Empty supports are kept as empty rows (they lower p_f and kbar). A matrix
34
+ # in which every row is empty, or every row selects every feature, has no
35
+ # defined stability (zero denominator): NaN is returned, never a number.
36
+ # Tests: test_stability_matched.py (pytest).
37
+ # =============================================================================
38
+ from __future__ import annotations
39
+
40
+ import numpy as np
41
+ from scipy import stats
42
+
43
+ __version__ = "1.0.0"
44
+
45
+
46
+ def phi_stability(Z):
47
+ """Nogueira's estimator with its asymptotic variance (Thm 9)."""
48
+ Z = np.asarray(Z, dtype=float)
49
+ M, d = Z.shape
50
+ k = Z.sum(axis=1)
51
+ kbar = k.mean()
52
+ p = Z.mean(axis=0)
53
+ s2 = M / (M - 1) * p * (1 - p)
54
+ den = (kbar / d) * (1 - kbar / d)
55
+ if den <= 0:
56
+ return {"phi": np.nan, "var": np.nan, "lo": np.nan, "hi": np.nan}
57
+ phi = 1 - s2.mean() / den
58
+ # asymptotic variance, Nogueira et al. (2018), Theorem 9
59
+ phis = np.empty(M)
60
+ for i in range(M):
61
+ phis[i] = (1 / den) * (np.mean(Z[i] * p) - k[i] * kbar / d ** 2
62
+ + phi / 2 * (2 * k[i] * kbar / d ** 2
63
+ - k[i] / d - kbar / d + 1))
64
+ var = 4 / M ** 2 * np.sum((phis - phis.mean()) ** 2)
65
+ se = np.sqrt(var)
66
+ return {"phi": float(phi), "var": float(var),
67
+ "lo": float(phi - 1.96 * se), "hi": float(phi + 1.96 * se)}
68
+
69
+
70
+ def phi0_matched(Z, P):
71
+ """Exact expectation of phi under the matched null (Proposition 1)."""
72
+ Z = np.asarray(Z, dtype=float)
73
+ P = np.asarray(P, dtype=bool)
74
+ M, d = Z.shape
75
+ k = Z.sum(axis=1)
76
+ m = P.sum(axis=1)
77
+ pi = np.where(P, (k / np.maximum(m, 1))[:, None], 0.0)
78
+ mu = pi.mean(axis=0)
79
+ v = (pi * (1 - pi)).sum(axis=0) / M ** 2
80
+ es2 = M / (M - 1) * (mu - mu ** 2 - v)
81
+ kb = k.mean()
82
+ den = (kb / d) * (1 - kb / d)
83
+ return float(1 - es2.mean() / den) if den > 0 else np.nan
84
+
85
+
86
+ def _phi0_from_pi(pi, kbar):
87
+ M, d = pi.shape
88
+ mu = pi.mean(axis=0)
89
+ v = (pi * (1 - pi)).sum(axis=0) / M ** 2
90
+ es2 = M / (M - 1) * (mu - mu ** 2 - v)
91
+ den = (kbar / d) * (1 - kbar / d)
92
+ return float(1 - es2.mean() / den) if den > 0 else np.nan
93
+
94
+
95
+ def phi0_stratified(Z, P, groups):
96
+ """Exact expectation of phi when, in every row, the number of selected
97
+ features in each group is kept and the features within the group (and
98
+ within the row's pool) are chosen uniformly at random. groups: integer
99
+ label per feature (a feature alone in its group is kept as selected)."""
100
+ Z = np.asarray(Z, dtype=float)
101
+ P = np.asarray(P, dtype=bool)
102
+ g = np.asarray(groups, dtype=int)
103
+ if np.any((Z > 0) & ~P):
104
+ raise ValueError("a selected feature lies outside its pool")
105
+ M, d = Z.shape
106
+ G = g.max() + 1
107
+ pi = np.zeros((M, d))
108
+ for i in range(M):
109
+ a = np.flatnonzero(P[i])
110
+ n_g = np.bincount(g[a], minlength=G)
111
+ c_g = np.bincount(g[a], weights=Z[i, a], minlength=G)
112
+ pi[i, a] = c_g[g[a]] / n_g[g[a]]
113
+ return _phi0_from_pi(pi, Z.sum(axis=1).mean())
114
+
115
+
116
+ def stratified_null_draws(Z, P, groups, B=999, rng=None):
117
+ rng = np.random.default_rng(rng)
118
+ Z = np.asarray(Z, dtype=bool)
119
+ P = np.asarray(P, dtype=bool)
120
+ g = np.asarray(groups, dtype=int)
121
+ M, d = Z.shape
122
+ out = np.empty(B)
123
+ for b in range(B):
124
+ R = np.zeros((M, d), dtype=np.uint8)
125
+ for i in range(M):
126
+ for grp in np.unique(g[Z[i]]):
127
+ cell = np.flatnonzero((g == grp) & P[i])
128
+ R[i, rng.choice(cell, int((Z[i] & (g == grp)).sum()),
129
+ replace=False)] = 1
130
+ out[b] = phi_stability(R)["phi"]
131
+ return out
132
+
133
+
134
+ def matched_null_draws(Z, P, B=999, rng=None):
135
+ rng = np.random.default_rng(rng)
136
+ Z = np.asarray(Z, dtype=bool)
137
+ P = np.asarray(P, dtype=bool)
138
+ M, d = Z.shape
139
+ k = Z.sum(axis=1)
140
+ pools = [np.flatnonzero(P[i]) for i in range(M)]
141
+ if np.any(Z & ~P):
142
+ raise ValueError("a selected feature lies outside its pool")
143
+ out = np.empty(B)
144
+ for b in range(B):
145
+ R = np.zeros((M, d), dtype=np.uint8)
146
+ for i in range(M):
147
+ if k[i]:
148
+ R[i, rng.choice(pools[i], k[i], replace=False)] = 1
149
+ out[b] = phi_stability(R)["phi"]
150
+ return out
151
+
152
+
153
+ def matched_null_test(Z, P, B=999, rng=None):
154
+ obs = phi_stability(Z)["phi"]
155
+ nd = matched_null_draws(Z, P, B, rng)
156
+ return {"phi": obs, "p_mc": float((1 + np.sum(nd >= obs)) / (B + 1)),
157
+ "null_mean": float(nd.mean()), "null_sd": float(nd.std(ddof=1)),
158
+ "null_q95": float(np.quantile(nd, 0.95)),
159
+ "null_q99": float(np.quantile(nd, 0.99)), "B": B}
160
+
161
+
162
+ def _hyper_jaccard(ka, kb, m):
163
+ x = np.arange(max(0, ka + kb - m), min(ka, kb) + 1)
164
+ p = stats.hypergeom.pmf(x, m, ka, kb)
165
+ return float(np.sum(p * x / (ka + kb - x)))
166
+
167
+
168
+ def agreement_exact(A, B, P):
169
+ """Mean Jaccard of two supports per resample and its exact chance level
170
+ (resamples where either support is empty are skipped)."""
171
+ obs, ch = [], []
172
+ for a, b, p in zip(np.asarray(A, bool), np.asarray(B, bool),
173
+ np.asarray(P, bool)):
174
+ ka, kb = int(a.sum()), int(b.sum())
175
+ if ka == 0 or kb == 0:
176
+ continue
177
+ obs.append((a & b).sum() / (a | b).sum())
178
+ ch.append(_hyper_jaccard(ka, kb, int(p.sum())))
179
+ if not obs:
180
+ return {"agree": np.nan, "agree0": np.nan, "n": 0}
181
+ return {"agree": float(np.mean(obs)), "agree0": float(np.mean(ch)),
182
+ "agree_x": float(np.mean(obs) - np.mean(ch)), "n": len(obs)}
183
+
184
+
185
+ def phi_matched(Z, P, B=999, rng=None):
186
+ """Phi, its interval, the exact matched chance level, the excess and the
187
+ Monte Carlo test, in one call."""
188
+ st = phi_stability(Z)
189
+ p0 = phi0_matched(Z, P)
190
+ out = {"phi": st["phi"], "phi_lo": st["lo"], "phi_hi": st["hi"],
191
+ "phi0": p0, "phi_x": st["phi"] - p0,
192
+ "empty_share": float(np.mean(np.asarray(Z).sum(axis=1) == 0))}
193
+ if B:
194
+ t = matched_null_test(Z, P, B, rng)
195
+ out.update({"p_mc": t["p_mc"], "null_sd": t["null_sd"],
196
+ "null_q99": t["null_q99"]})
197
+ return out
198
+
199
+
200
+ def matrices_from_indices(supports, pools=None, d=None):
201
+ """Selection matrix Z and pool matrix P (M x d, 0/1) from per-resample
202
+ lists of selected feature indices and of candidate-pool indices. With
203
+ pools=None every feature is a candidate in every resample."""
204
+ supports = [np.asarray(s, dtype=int) for s in supports]
205
+ if d is None:
206
+ alls = supports + ([np.asarray(p, dtype=int) for p in pools]
207
+ if pools is not None else [])
208
+ d = int(max((a.max() for a in alls if a.size), default=-1)) + 1
209
+ M = len(supports)
210
+ Z = np.zeros((M, d), dtype=np.uint8)
211
+ P = np.ones((M, d), dtype=np.uint8) if pools is None else \
212
+ np.zeros((M, d), dtype=np.uint8)
213
+ for i, s in enumerate(supports):
214
+ Z[i, s] = 1
215
+ if pools is not None:
216
+ P[i, np.asarray(pools[i], dtype=int)] = 1
217
+ return Z, P
218
+
219
+
220
+ def report(Z, P, B=999, rng=0):
221
+ """Plain-text summary: phi with interval, matched chance level, excess
222
+ and Monte Carlo test; empty supports and support sizes."""
223
+ r = phi_matched(Z, P, B=B, rng=rng)
224
+ k = np.asarray(Z).sum(axis=1)
225
+ m = np.asarray(P).sum(axis=1)
226
+ lines = [
227
+ f"resamples M = {len(k)}, features d = {np.asarray(Z).shape[1]}",
228
+ f"support size: mean {k.mean():.1f}, range {k.min()}-{k.max()}; "
229
+ f"empty supports {100 * r['empty_share']:.0f}%",
230
+ f"candidate pool size: mean {m.mean():.1f}, range {m.min()}-{m.max()}",
231
+ f"phi (Nogueira) {r['phi']:.4f} "
232
+ f"[{r['phi_lo']:.4f}, {r['phi_hi']:.4f}]",
233
+ f"matched chance level {r['phi0']:.4f}",
234
+ f"excess over chance {r['phi_x']:+.4f} "
235
+ f"[{r['phi_lo'] - r['phi0']:+.4f}, {r['phi_hi'] - r['phi0']:+.4f}]"]
236
+ if B:
237
+ lines.append(f"Monte Carlo p (B = {B}) {r['p_mc']:.4f} "
238
+ f"(null SD {r['null_sd']:.4f})")
239
+ return "\n".join(lines)
240
+
241
+
242
+ def _load(path, key=None):
243
+ path = str(path)
244
+ if path.endswith(".npz"):
245
+ z = np.load(path)
246
+ return z[key] if key else z
247
+ if path.endswith(".csv"):
248
+ return np.loadtxt(path, delimiter=",")
249
+ return np.load(path)
250
+
251
+
252
+ def main(argv=None):
253
+ import argparse
254
+ ap = argparse.ArgumentParser(
255
+ prog="stability-matched",
256
+ description="Selection stability with its exact matched chance level")
257
+ ap.add_argument("Z", help="selection matrix (resamples x features, 0/1): "
258
+ ".npy, .csv, or .npz holding arrays Z and P")
259
+ ap.add_argument("P", nargs="?", default=None,
260
+ help="pool matrix of the same shape (omit if every "
261
+ "feature is a candidate in every resample)")
262
+ ap.add_argument("-B", type=int, default=999,
263
+ help="null matrices for the Monte Carlo test (0: none)")
264
+ ap.add_argument("--seed", type=int, default=0)
265
+ a = ap.parse_args(argv)
266
+ if a.Z.endswith(".npz") and a.P is None:
267
+ z = _load(a.Z)
268
+ Z = z["Z"]
269
+ P = z["P"] if "P" in z.files else np.ones_like(Z)
270
+ else:
271
+ Z = _load(a.Z)
272
+ P = _load(a.P) if a.P else np.ones_like(Z)
273
+ print(report(Z, P, B=a.B, rng=a.seed))
274
+
275
+
276
+ if __name__ == "__main__":
277
+ main()