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 +21 -0
- stably-0.3.0/PKG-INFO +313 -0
- stably-0.3.0/README.md +270 -0
- stably-0.3.0/pyproject.toml +37 -0
- stably-0.3.0/setup.cfg +4 -0
- stably-0.3.0/stably/__init__.py +37 -0
- stably-0.3.0/stably/__main__.py +131 -0
- stably-0.3.0/stably/config.py +87 -0
- stably-0.3.0/stably/diann_log.py +141 -0
- stably-0.3.0/stably/feature_selection.py +217 -0
- stably-0.3.0/stably/io.py +374 -0
- stably-0.3.0/stably/preprocessing.py +129 -0
- stably-0.3.0/stably/rconcave.py +292 -0
- stably-0.3.0/stably/tests/__init__.py +0 -0
- stably-0.3.0/stably/tests/conftest.py +148 -0
- stably-0.3.0/stably/tests/test_cohens_d.py +80 -0
- stably-0.3.0/stably/tests/test_e2e_schema.py +145 -0
- stably-0.3.0/stably/tests/test_peptide_input_rejected.py +33 -0
- stably-0.3.0/stably/tests/test_pfer_empirical.py +95 -0
- stably-0.3.0/stably/tests/test_preprocessing_leakage.py +97 -0
- stably-0.3.0/stably/tests/test_preprocessing_no_variance_filter.py +48 -0
- stably-0.3.0/stably/tests/test_rconcave.py +97 -0
- stably-0.3.0/stably/visualization.py +108 -0
- stably-0.3.0/stably/workflows.py +91 -0
- stably-0.3.0/stably.egg-info/PKG-INFO +313 -0
- stably-0.3.0/stably.egg-info/SOURCES.txt +27 -0
- stably-0.3.0/stably.egg-info/dependency_links.txt +1 -0
- stably-0.3.0/stably.egg-info/requires.txt +12 -0
- stably-0.3.0/stably.egg-info/top_level.txt +1 -0
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,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
|
+
]
|