stably 0.3.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.
stably-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Byrne, The University of Manchester
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.
stably-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,313 @@
1
+ Metadata-Version: 2.4
2
+ Name: stably
3
+ Version: 0.3.0
4
+ Summary: ElasticNet stability selection with the Shah & Samworth (2013) r-concave PFER bound, for protein-level biomarker discovery from DIA-NN output.
5
+ Author: Daniel Byrne
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Daniel Byrne, The University of Manchester
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.10
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: numpy>=1.24
32
+ Requires-Dist: pandas>=2.0
33
+ Requires-Dist: scipy>=1.10
34
+ Requires-Dist: scikit-learn>=1.3
35
+ Requires-Dist: joblib>=1.3
36
+ Requires-Dist: matplotlib>=3.7
37
+ Requires-Dist: seaborn>=0.12
38
+ Requires-Dist: pyyaml>=6.0
39
+ Requires-Dist: openpyxl>=3.1
40
+ Provides-Extra: test
41
+ Requires-Dist: pytest>=7.4; extra == "test"
42
+ Dynamic: license-file
43
+
44
+ # stably
45
+
46
+ ElasticNet stability selection with the **Shah & Samworth (2013) r-concave PFER bound**, for protein-level biomarker discovery from DIA-NN output.
47
+
48
+ The selection-probability threshold π is derived per-run from the r-concave tail bound (S&S 2013, equation 8) using the data-dependent quantity θ = q/p. This is tighter than the Meinshausen & Buhlmann (2010) worst-case bound and so retains more features for the same PFER budget. The package operates on **protein-level** DIA-NN matrices (`pg_matrix`); peptide-level input is rejected explicitly.
49
+
50
+ ---
51
+
52
+ ## Installation
53
+
54
+ Requires Python ≥ 3.10.
55
+
56
+ ```bash
57
+ pip install git+https://github.com/byrnedaniel5-eng/stably.git
58
+ ```
59
+
60
+ To also install test dependencies:
61
+
62
+ ```bash
63
+ pip install "stably[test] @ git+https://github.com/byrnedaniel5-eng/stably.git"
64
+ ```
65
+
66
+ Or for an editable install when developing the package itself:
67
+
68
+ ```bash
69
+ git clone https://github.com/byrnedaniel5-eng/stably.git
70
+ pip install -e ./stably
71
+ ```
72
+
73
+ Verify the install:
74
+
75
+ ```bash
76
+ python -c "import stably; print(stably.__version__)"
77
+ # 0.3.0
78
+ ```
79
+
80
+ ### Dependencies
81
+
82
+ Pulled in automatically by `pip install`:
83
+ `numpy`, `pandas`, `scipy`, `scikit-learn`, `joblib`, `matplotlib`, `seaborn`, `pyyaml`, `openpyxl`.
84
+
85
+ ---
86
+
87
+ ## Input data
88
+
89
+ Two files are required:
90
+
91
+ ### 1. Protein-level abundance matrix (`pg_matrix`)
92
+
93
+ A CSV produced by DIA-NN. The first 6 columns are protein-level metadata (`Protein.Group`, `Protein.Ids`, `Protein.Names`, `Genes`, `First.Protein.Description`, `N.Proteotypic.Sequences`); every column from index 6 onwards is a sample, named by the run/biobank ID.
94
+
95
+ Peptide-level matrices (`pr_matrix`, detectable by a `Stripped.Sequence` column) are rejected at load time — see the docstring on [io.py](stably/io.py) for why.
96
+
97
+ ### 2. Sample-label spreadsheet
98
+
99
+ An `.xlsx` file with at least:
100
+ - a column matching the sample IDs used as column headers in the pg_matrix (e.g. `Biobank Number`)
101
+ - a group column with two values matching `case_name` and `control_name` from the config (e.g. `Group` containing `PDAC` / `Non-cancer`)
102
+
103
+ Samples present in the pg_matrix but missing from the label file are dropped with a warning.
104
+
105
+ ---
106
+
107
+ ## Configuration
108
+
109
+ Runs are driven by a YAML config that you supply per-analysis. The most important keys:
110
+
111
+ | Key | Meaning |
112
+ | --- | --- |
113
+ | `data_file`, `label_file` | Paths to the two input files |
114
+ | `case_name`, `control_name` | Labels in the group column to map to y=1 / y=0 |
115
+ | `group_column`, `sample_id_column` | Column names in the label file |
116
+ | `min_proteotypic_peptides` | Drops proteins with `N.Proteotypic.Sequences` below this (default 2) |
117
+ | `max_missing` | Per-protein NaN fraction allowed after sample alignment |
118
+ | `imputation_strategy` | `knn`, `minimum`, `mean`, or `median` |
119
+ | `log_transform` | Apply log2(x+1) before scaling |
120
+ | `max_candidates` (q) | Number of top-|β| coefficients retained per subsample |
121
+ | `stability_iterations` | 2 × B; **S&S §3.4.1 recommends B ≤ 50** so leave at 100 unless you understand the r-concavity assumption |
122
+ | `stability_pfer` | Absolute PFER budget E[V] |
123
+ | `l1_ratio` | ElasticNet mixing parameter (1 = lasso, 0 = ridge) |
124
+ | `output_dir` | Results destination |
125
+
126
+ The threshold π is **not** specified directly; it is derived per-run from `(q, p, B, PFER)` via the r-concave bound after preprocessing fixes p.
127
+
128
+ ---
129
+
130
+ ## Running on real data
131
+
132
+ From the directory containing your config (and data files referenced by it):
133
+
134
+ ```bash
135
+ python -m stably --config config_stably.yaml
136
+ ```
137
+
138
+ This prints class balance, q/p, B, the r-concave vs M&B threshold comparison, calibrated `C_ref`, the top-20 stable features by selection probability π̂, and writes results to `output_dir`. Relative paths in the config are resolved from the current working directory.
139
+
140
+ ---
141
+
142
+ ## Toy example with synthetic data
143
+
144
+ Use this to sanity-check the install on a tiny fabricated dataset (no DIA-NN run required). It produces a `pg_matrix`-shaped CSV, an Excel label file, and a config, then runs the pipeline.
145
+
146
+ Save as `toy_run.py` next to the package and run with `python toy_run.py`:
147
+
148
+ ```python
149
+ """Minimal end-to-end smoke test for stably on synthetic data."""
150
+ import numpy as np
151
+ import pandas as pd
152
+ import yaml
153
+ from pathlib import Path
154
+
155
+ rng = np.random.default_rng(0)
156
+ n_samples_per_group = 20
157
+ n_proteins = 200
158
+ n_true_signal = 10 # proteins with a real case/control difference
159
+
160
+ work = Path("toy_workspace")
161
+ work.mkdir(exist_ok=True)
162
+
163
+ # 1. Build a synthetic pg_matrix-shaped CSV.
164
+ sample_ids = [f"S{i:03d}" for i in range(2 * n_samples_per_group)]
165
+ y = np.array([0] * n_samples_per_group + [1] * n_samples_per_group)
166
+
167
+ X = rng.normal(loc=20.0, scale=1.0, size=(n_proteins, len(sample_ids)))
168
+ # Inject a real signal into the first n_true_signal proteins (up in cases).
169
+ X[:n_true_signal, y == 1] += rng.normal(loc=2.0, scale=0.3,
170
+ size=(n_true_signal, n_samples_per_group))
171
+
172
+ metadata = pd.DataFrame({
173
+ "Protein.Group": [f"P{i:05d}" for i in range(n_proteins)],
174
+ "Protein.Ids": [f"P{i:05d}" for i in range(n_proteins)],
175
+ "Protein.Names": [f"PROT{i}_HUMAN" for i in range(n_proteins)],
176
+ "Genes": [f"GENE{i}" for i in range(n_proteins)],
177
+ "First.Protein.Description": [f"Synthetic protein {i}" for i in range(n_proteins)],
178
+ "N.Proteotypic.Sequences": rng.integers(2, 8, size=n_proteins),
179
+ })
180
+ pg_matrix = pd.concat([metadata, pd.DataFrame(X, columns=sample_ids)], axis=1)
181
+ pg_matrix.to_csv(work / "toy.pg_matrix.csv", index=False)
182
+
183
+ # 2. Build a label spreadsheet matching the sample IDs.
184
+ labels = pd.DataFrame({
185
+ "Biobank Number": sample_ids,
186
+ "Group": ["Non-cancer" if yi == 0 else "PDAC" for yi in y],
187
+ })
188
+ labels.to_excel(work / "toy_labels.xlsx", index=False)
189
+
190
+ # 3. Write a config.
191
+ cfg = {
192
+ "data_file": str(work / "toy.pg_matrix.csv"),
193
+ "label_file": str(work / "toy_labels.xlsx"),
194
+ "control_name": "Non-cancer",
195
+ "case_name": "PDAC",
196
+ "group_column": "Group",
197
+ "sample_id_column": "Biobank Number",
198
+ "random_state": 42,
199
+ "max_sample_missing": 0.7,
200
+ "max_missing": 0.0,
201
+ "log_transform": False, # data is already on a log-like scale
202
+ "correlation_threshold": 0.95,
203
+ "imputation_strategy": "knn",
204
+ "knn_neighbors": 5,
205
+ "min_proteotypic_peptides": 2,
206
+ "max_iterations": 5000,
207
+ "max_candidates": 20,
208
+ "stability_iterations": 100, # 2 × B = 2 × 50
209
+ "stability_pfer": 5,
210
+ "l1_ratio": 0.3,
211
+ "n_jobs": -1,
212
+ "output_dir": str(work / "toy_results"),
213
+ }
214
+ cfg_path = work / "toy_config.yaml"
215
+ with open(cfg_path, "w") as f:
216
+ yaml.safe_dump(cfg, f)
217
+
218
+ # 4. Run the pipeline programmatically.
219
+ from stably import (
220
+ Config, load_data, save_results, create_visualizations,
221
+ full_dataset_stability_elasticnet,
222
+ )
223
+
224
+ config = Config.from_yaml(cfg_path)
225
+ np.random.seed(config.RANDOM_STATE)
226
+
227
+ X_raw, y, sample_ids, protein_metadata = load_data(config)
228
+ results = full_dataset_stability_elasticnet(X_raw, y, config)
229
+
230
+ if results is None:
231
+ print("No stable features found.")
232
+ else:
233
+ save_results(results, protein_metadata, config)
234
+ create_visualizations(results, protein_metadata, config)
235
+
236
+ sel_probs = results["feature_stability"]["selection_probabilities"]
237
+ stable = results["feature_stability"]["stable_features"]
238
+ top = sorted(stable, key=lambda f: sel_probs[f], reverse=True)[:10]
239
+ print("\nTop stable features:")
240
+ for f in top:
241
+ gene = protein_metadata.loc[f, "Genes"]
242
+ print(f" {gene:>10s} π̂ = {sel_probs[f]:.3f}")
243
+ ```
244
+
245
+ If the install is healthy you should see most of the first 10 injected proteins (`GENE0`–`GENE9`) show up in the stable list. The toy dataset is small enough to finish in a few seconds on a laptop.
246
+
247
+ ---
248
+
249
+ ## Outputs
250
+
251
+ `output_dir` will contain four timestamped artefacts per run:
252
+
253
+ | File | Contents |
254
+ | --- | --- |
255
+ | `stable_features_<ts>.csv` | One row per *measured* protein with `selection_probability`, `cohens_d`, `direction`, `n_case`, `n_control`, `imputation_rate`. Sorted by π̂. The "stable" subset is everything with π̂ ≥ threshold (recorded in the manifest). |
256
+ | `stable_features_<ts>.schema.json` | Per-column descriptions for the CSV. |
257
+ | `full_results_<ts>.pkl` | Pickled `results` dict (feature_stability, stability_selection, preprocessing, sample_counts). |
258
+ | `run_manifest_<ts>.json` | Reproducibility manifest: package + dependency versions, input SHA-256 hashes, upstream DIA-NN log provenance, resolved config, derived parameters (`C_ref`, threshold, M&B threshold, θ, B, PFER), preprocessing summary, convergence stats. |
259
+
260
+ `create_visualizations` additionally writes selection-probability and threshold-comparison plots to the same directory.
261
+
262
+ ---
263
+
264
+ ## Programmatic API
265
+
266
+ The CLI is just a thin wrapper. The same pipeline from Python:
267
+
268
+ ```python
269
+ from stably import (
270
+ Config, load_data, save_results, create_visualizations,
271
+ full_dataset_stability_elasticnet,
272
+ )
273
+
274
+ config = Config.from_yaml("config_stably.yaml")
275
+ X_raw, y, sample_ids, protein_metadata = load_data(config)
276
+ results = full_dataset_stability_elasticnet(X_raw, y, config)
277
+ save_results(results, protein_metadata, config)
278
+ create_visualizations(results, protein_metadata, config)
279
+ ```
280
+
281
+ Lower-level entry points exposed at the package root:
282
+
283
+ - `Preprocessor(config).fit_transform(X, y)` — leakage-safe missing-filter + log + impute + z-score; reusable on held-out data via `.transform`.
284
+ - `stability_selection_elasticnet(X, y, config)` — returns `(stable_features, threshold, selection_probs, C_ref, threshold_info)`.
285
+ - `compute_rconcave_threshold(q, p, B, pfer)` and `compute_D(...)` — the threshold maths in isolation.
286
+ - `calculate_cohens_d(X, y, features)` — signed effect size on the preprocessed matrix.
287
+
288
+ ---
289
+
290
+ ## Tests
291
+
292
+ ```bash
293
+ pytest stably/tests # fast suite
294
+ pytest stably/tests --runslow # includes the empirical PFER calibration test
295
+ ```
296
+
297
+ The suite covers the r-concave bound (`test_rconcave.py`), an empirical PFER check (`test_pfer_empirical.py`), Cohen's d edge cases, peptide-input rejection, preprocessing leakage guarantees, and the end-to-end CSV/manifest schema.
298
+
299
+ ---
300
+
301
+ ## Theory — one paragraph
302
+
303
+ For each subsample the procedure fits an ElasticNet logistic regression at a fixed regularisation `C_ref` (calibrated once on the full data so each subsample yields ≤ q non-zero coefficients), then records the top-q features by |β|. With B complementary pairs (2B subsamples) the selection probability π̂ for each feature is the empirical proportion of subsamples in which it appeared. A feature is called stable when π̂ ≥ π, where π is the smallest threshold for which the **r-concave bound** (Shah & Samworth 2013, eq. 8)
304
+
305
+ > E[V] ≤ min{ D(θ², 2τ-1, B, -½), D(θ, τ, 2B, -¼) } × p
306
+
307
+ is below the user-specified PFER budget. θ = q/p is data-dependent and so π must be computed *after* preprocessing has fixed p. The bound reduces to M&B's 1/(2τ-1) bound when r-concavity is dropped, hence the manifest reports both for diagnostic purposes.
308
+
309
+ ---
310
+
311
+ ## Citation
312
+
313
+ Shah, R. D. & Samworth, R. J. (2013). *Variable selection with error control: another look at stability selection.* JRSS B, 75(1):55–80.
stably-0.3.0/README.md ADDED
@@ -0,0 +1,270 @@
1
+ # stably
2
+
3
+ ElasticNet stability selection with the **Shah & Samworth (2013) r-concave PFER bound**, for protein-level biomarker discovery from DIA-NN output.
4
+
5
+ The selection-probability threshold π is derived per-run from the r-concave tail bound (S&S 2013, equation 8) using the data-dependent quantity θ = q/p. This is tighter than the Meinshausen & Buhlmann (2010) worst-case bound and so retains more features for the same PFER budget. The package operates on **protein-level** DIA-NN matrices (`pg_matrix`); peptide-level input is rejected explicitly.
6
+
7
+ ---
8
+
9
+ ## Installation
10
+
11
+ Requires Python ≥ 3.10.
12
+
13
+ ```bash
14
+ pip install git+https://github.com/byrnedaniel5-eng/stably.git
15
+ ```
16
+
17
+ To also install test dependencies:
18
+
19
+ ```bash
20
+ pip install "stably[test] @ git+https://github.com/byrnedaniel5-eng/stably.git"
21
+ ```
22
+
23
+ Or for an editable install when developing the package itself:
24
+
25
+ ```bash
26
+ git clone https://github.com/byrnedaniel5-eng/stably.git
27
+ pip install -e ./stably
28
+ ```
29
+
30
+ Verify the install:
31
+
32
+ ```bash
33
+ python -c "import stably; print(stably.__version__)"
34
+ # 0.3.0
35
+ ```
36
+
37
+ ### Dependencies
38
+
39
+ Pulled in automatically by `pip install`:
40
+ `numpy`, `pandas`, `scipy`, `scikit-learn`, `joblib`, `matplotlib`, `seaborn`, `pyyaml`, `openpyxl`.
41
+
42
+ ---
43
+
44
+ ## Input data
45
+
46
+ Two files are required:
47
+
48
+ ### 1. Protein-level abundance matrix (`pg_matrix`)
49
+
50
+ A CSV produced by DIA-NN. The first 6 columns are protein-level metadata (`Protein.Group`, `Protein.Ids`, `Protein.Names`, `Genes`, `First.Protein.Description`, `N.Proteotypic.Sequences`); every column from index 6 onwards is a sample, named by the run/biobank ID.
51
+
52
+ Peptide-level matrices (`pr_matrix`, detectable by a `Stripped.Sequence` column) are rejected at load time — see the docstring on [io.py](stably/io.py) for why.
53
+
54
+ ### 2. Sample-label spreadsheet
55
+
56
+ An `.xlsx` file with at least:
57
+ - a column matching the sample IDs used as column headers in the pg_matrix (e.g. `Biobank Number`)
58
+ - a group column with two values matching `case_name` and `control_name` from the config (e.g. `Group` containing `PDAC` / `Non-cancer`)
59
+
60
+ Samples present in the pg_matrix but missing from the label file are dropped with a warning.
61
+
62
+ ---
63
+
64
+ ## Configuration
65
+
66
+ Runs are driven by a YAML config that you supply per-analysis. The most important keys:
67
+
68
+ | Key | Meaning |
69
+ | --- | --- |
70
+ | `data_file`, `label_file` | Paths to the two input files |
71
+ | `case_name`, `control_name` | Labels in the group column to map to y=1 / y=0 |
72
+ | `group_column`, `sample_id_column` | Column names in the label file |
73
+ | `min_proteotypic_peptides` | Drops proteins with `N.Proteotypic.Sequences` below this (default 2) |
74
+ | `max_missing` | Per-protein NaN fraction allowed after sample alignment |
75
+ | `imputation_strategy` | `knn`, `minimum`, `mean`, or `median` |
76
+ | `log_transform` | Apply log2(x+1) before scaling |
77
+ | `max_candidates` (q) | Number of top-|β| coefficients retained per subsample |
78
+ | `stability_iterations` | 2 × B; **S&S §3.4.1 recommends B ≤ 50** so leave at 100 unless you understand the r-concavity assumption |
79
+ | `stability_pfer` | Absolute PFER budget E[V] |
80
+ | `l1_ratio` | ElasticNet mixing parameter (1 = lasso, 0 = ridge) |
81
+ | `output_dir` | Results destination |
82
+
83
+ The threshold π is **not** specified directly; it is derived per-run from `(q, p, B, PFER)` via the r-concave bound after preprocessing fixes p.
84
+
85
+ ---
86
+
87
+ ## Running on real data
88
+
89
+ From the directory containing your config (and data files referenced by it):
90
+
91
+ ```bash
92
+ python -m stably --config config_stably.yaml
93
+ ```
94
+
95
+ This prints class balance, q/p, B, the r-concave vs M&B threshold comparison, calibrated `C_ref`, the top-20 stable features by selection probability π̂, and writes results to `output_dir`. Relative paths in the config are resolved from the current working directory.
96
+
97
+ ---
98
+
99
+ ## Toy example with synthetic data
100
+
101
+ Use this to sanity-check the install on a tiny fabricated dataset (no DIA-NN run required). It produces a `pg_matrix`-shaped CSV, an Excel label file, and a config, then runs the pipeline.
102
+
103
+ Save as `toy_run.py` next to the package and run with `python toy_run.py`:
104
+
105
+ ```python
106
+ """Minimal end-to-end smoke test for stably on synthetic data."""
107
+ import numpy as np
108
+ import pandas as pd
109
+ import yaml
110
+ from pathlib import Path
111
+
112
+ rng = np.random.default_rng(0)
113
+ n_samples_per_group = 20
114
+ n_proteins = 200
115
+ n_true_signal = 10 # proteins with a real case/control difference
116
+
117
+ work = Path("toy_workspace")
118
+ work.mkdir(exist_ok=True)
119
+
120
+ # 1. Build a synthetic pg_matrix-shaped CSV.
121
+ sample_ids = [f"S{i:03d}" for i in range(2 * n_samples_per_group)]
122
+ y = np.array([0] * n_samples_per_group + [1] * n_samples_per_group)
123
+
124
+ X = rng.normal(loc=20.0, scale=1.0, size=(n_proteins, len(sample_ids)))
125
+ # Inject a real signal into the first n_true_signal proteins (up in cases).
126
+ X[:n_true_signal, y == 1] += rng.normal(loc=2.0, scale=0.3,
127
+ size=(n_true_signal, n_samples_per_group))
128
+
129
+ metadata = pd.DataFrame({
130
+ "Protein.Group": [f"P{i:05d}" for i in range(n_proteins)],
131
+ "Protein.Ids": [f"P{i:05d}" for i in range(n_proteins)],
132
+ "Protein.Names": [f"PROT{i}_HUMAN" for i in range(n_proteins)],
133
+ "Genes": [f"GENE{i}" for i in range(n_proteins)],
134
+ "First.Protein.Description": [f"Synthetic protein {i}" for i in range(n_proteins)],
135
+ "N.Proteotypic.Sequences": rng.integers(2, 8, size=n_proteins),
136
+ })
137
+ pg_matrix = pd.concat([metadata, pd.DataFrame(X, columns=sample_ids)], axis=1)
138
+ pg_matrix.to_csv(work / "toy.pg_matrix.csv", index=False)
139
+
140
+ # 2. Build a label spreadsheet matching the sample IDs.
141
+ labels = pd.DataFrame({
142
+ "Biobank Number": sample_ids,
143
+ "Group": ["Non-cancer" if yi == 0 else "PDAC" for yi in y],
144
+ })
145
+ labels.to_excel(work / "toy_labels.xlsx", index=False)
146
+
147
+ # 3. Write a config.
148
+ cfg = {
149
+ "data_file": str(work / "toy.pg_matrix.csv"),
150
+ "label_file": str(work / "toy_labels.xlsx"),
151
+ "control_name": "Non-cancer",
152
+ "case_name": "PDAC",
153
+ "group_column": "Group",
154
+ "sample_id_column": "Biobank Number",
155
+ "random_state": 42,
156
+ "max_sample_missing": 0.7,
157
+ "max_missing": 0.0,
158
+ "log_transform": False, # data is already on a log-like scale
159
+ "correlation_threshold": 0.95,
160
+ "imputation_strategy": "knn",
161
+ "knn_neighbors": 5,
162
+ "min_proteotypic_peptides": 2,
163
+ "max_iterations": 5000,
164
+ "max_candidates": 20,
165
+ "stability_iterations": 100, # 2 × B = 2 × 50
166
+ "stability_pfer": 5,
167
+ "l1_ratio": 0.3,
168
+ "n_jobs": -1,
169
+ "output_dir": str(work / "toy_results"),
170
+ }
171
+ cfg_path = work / "toy_config.yaml"
172
+ with open(cfg_path, "w") as f:
173
+ yaml.safe_dump(cfg, f)
174
+
175
+ # 4. Run the pipeline programmatically.
176
+ from stably import (
177
+ Config, load_data, save_results, create_visualizations,
178
+ full_dataset_stability_elasticnet,
179
+ )
180
+
181
+ config = Config.from_yaml(cfg_path)
182
+ np.random.seed(config.RANDOM_STATE)
183
+
184
+ X_raw, y, sample_ids, protein_metadata = load_data(config)
185
+ results = full_dataset_stability_elasticnet(X_raw, y, config)
186
+
187
+ if results is None:
188
+ print("No stable features found.")
189
+ else:
190
+ save_results(results, protein_metadata, config)
191
+ create_visualizations(results, protein_metadata, config)
192
+
193
+ sel_probs = results["feature_stability"]["selection_probabilities"]
194
+ stable = results["feature_stability"]["stable_features"]
195
+ top = sorted(stable, key=lambda f: sel_probs[f], reverse=True)[:10]
196
+ print("\nTop stable features:")
197
+ for f in top:
198
+ gene = protein_metadata.loc[f, "Genes"]
199
+ print(f" {gene:>10s} π̂ = {sel_probs[f]:.3f}")
200
+ ```
201
+
202
+ If the install is healthy you should see most of the first 10 injected proteins (`GENE0`–`GENE9`) show up in the stable list. The toy dataset is small enough to finish in a few seconds on a laptop.
203
+
204
+ ---
205
+
206
+ ## Outputs
207
+
208
+ `output_dir` will contain four timestamped artefacts per run:
209
+
210
+ | File | Contents |
211
+ | --- | --- |
212
+ | `stable_features_<ts>.csv` | One row per *measured* protein with `selection_probability`, `cohens_d`, `direction`, `n_case`, `n_control`, `imputation_rate`. Sorted by π̂. The "stable" subset is everything with π̂ ≥ threshold (recorded in the manifest). |
213
+ | `stable_features_<ts>.schema.json` | Per-column descriptions for the CSV. |
214
+ | `full_results_<ts>.pkl` | Pickled `results` dict (feature_stability, stability_selection, preprocessing, sample_counts). |
215
+ | `run_manifest_<ts>.json` | Reproducibility manifest: package + dependency versions, input SHA-256 hashes, upstream DIA-NN log provenance, resolved config, derived parameters (`C_ref`, threshold, M&B threshold, θ, B, PFER), preprocessing summary, convergence stats. |
216
+
217
+ `create_visualizations` additionally writes selection-probability and threshold-comparison plots to the same directory.
218
+
219
+ ---
220
+
221
+ ## Programmatic API
222
+
223
+ The CLI is just a thin wrapper. The same pipeline from Python:
224
+
225
+ ```python
226
+ from stably import (
227
+ Config, load_data, save_results, create_visualizations,
228
+ full_dataset_stability_elasticnet,
229
+ )
230
+
231
+ config = Config.from_yaml("config_stably.yaml")
232
+ X_raw, y, sample_ids, protein_metadata = load_data(config)
233
+ results = full_dataset_stability_elasticnet(X_raw, y, config)
234
+ save_results(results, protein_metadata, config)
235
+ create_visualizations(results, protein_metadata, config)
236
+ ```
237
+
238
+ Lower-level entry points exposed at the package root:
239
+
240
+ - `Preprocessor(config).fit_transform(X, y)` — leakage-safe missing-filter + log + impute + z-score; reusable on held-out data via `.transform`.
241
+ - `stability_selection_elasticnet(X, y, config)` — returns `(stable_features, threshold, selection_probs, C_ref, threshold_info)`.
242
+ - `compute_rconcave_threshold(q, p, B, pfer)` and `compute_D(...)` — the threshold maths in isolation.
243
+ - `calculate_cohens_d(X, y, features)` — signed effect size on the preprocessed matrix.
244
+
245
+ ---
246
+
247
+ ## Tests
248
+
249
+ ```bash
250
+ pytest stably/tests # fast suite
251
+ pytest stably/tests --runslow # includes the empirical PFER calibration test
252
+ ```
253
+
254
+ The suite covers the r-concave bound (`test_rconcave.py`), an empirical PFER check (`test_pfer_empirical.py`), Cohen's d edge cases, peptide-input rejection, preprocessing leakage guarantees, and the end-to-end CSV/manifest schema.
255
+
256
+ ---
257
+
258
+ ## Theory — one paragraph
259
+
260
+ For each subsample the procedure fits an ElasticNet logistic regression at a fixed regularisation `C_ref` (calibrated once on the full data so each subsample yields ≤ q non-zero coefficients), then records the top-q features by |β|. With B complementary pairs (2B subsamples) the selection probability π̂ for each feature is the empirical proportion of subsamples in which it appeared. A feature is called stable when π̂ ≥ π, where π is the smallest threshold for which the **r-concave bound** (Shah & Samworth 2013, eq. 8)
261
+
262
+ > E[V] ≤ min{ D(θ², 2τ-1, B, -½), D(θ, τ, 2B, -¼) } × p
263
+
264
+ is below the user-specified PFER budget. θ = q/p is data-dependent and so π must be computed *after* preprocessing has fixed p. The bound reduces to M&B's 1/(2τ-1) bound when r-concavity is dropped, hence the manifest reports both for diagnostic purposes.
265
+
266
+ ---
267
+
268
+ ## Citation
269
+
270
+ Shah, R. D. & Samworth, R. J. (2013). *Variable selection with error control: another look at stability selection.* JRSS B, 75(1):55–80.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "stably"
7
+ version = "0.3.0"
8
+ description = "ElasticNet stability selection with the Shah & Samworth (2013) r-concave PFER bound, for protein-level biomarker discovery from DIA-NN output."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Daniel Byrne" }]
13
+ dependencies = [
14
+ "numpy>=1.24",
15
+ "pandas>=2.0",
16
+ "scipy>=1.10",
17
+ "scikit-learn>=1.3",
18
+ "joblib>=1.3",
19
+ "matplotlib>=3.7",
20
+ "seaborn>=0.12",
21
+ "pyyaml>=6.0",
22
+ "openpyxl>=3.1",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ test = ["pytest>=7.4"]
27
+
28
+ [tool.setuptools.packages.find]
29
+ where = ["."]
30
+ include = ["stably*"]
31
+
32
+ [tool.pytest.ini_options]
33
+ testpaths = ["stably/tests"]
34
+ python_files = ["test_*.py"]
35
+ markers = [
36
+ "slow: marks tests as slow (run with --runslow)",
37
+ ]
stably-0.3.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,37 @@
1
+ """
2
+ True Shah & Samworth (2013) ElasticNet stability selection package.
3
+
4
+ Uses the r-concave PFER bound (equation 8) for threshold derivation,
5
+ which is the main theoretical contribution of the paper over Meinshausen
6
+ & Buhlmann (2010). Complementary pairs subsampling with data-adaptive
7
+ threshold computed from the D function (Appendix A.4).
8
+
9
+ Operates on protein-level DIA-NN matrices (pg_matrix). Peptide-level input
10
+ is rejected explicitly — see io.load_data and the package README for the
11
+ rationale.
12
+ """
13
+
14
+ __version__ = "0.3.0"
15
+
16
+ from .config import Config
17
+ from .preprocessing import Preprocessor
18
+ from .io import load_data, save_results
19
+ from .visualization import create_visualizations
20
+
21
+ from .rconcave import compute_D, compute_rconcave_threshold, print_threshold_comparison
22
+ from .feature_selection import stability_selection_elasticnet, calculate_cohens_d
23
+ from .workflows import full_dataset_stability_elasticnet
24
+
25
+ __all__ = [
26
+ 'Config',
27
+ 'Preprocessor',
28
+ 'load_data',
29
+ 'save_results',
30
+ 'create_visualizations',
31
+ 'compute_D',
32
+ 'compute_rconcave_threshold',
33
+ 'print_threshold_comparison',
34
+ 'stability_selection_elasticnet',
35
+ 'calculate_cohens_d',
36
+ 'full_dataset_stability_elasticnet',
37
+ ]