stably 0.3.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.
- stably/__init__.py +37 -0
- stably/__main__.py +131 -0
- stably/config.py +87 -0
- stably/diann_log.py +141 -0
- stably/feature_selection.py +217 -0
- stably/io.py +374 -0
- stably/preprocessing.py +129 -0
- stably/rconcave.py +292 -0
- stably/tests/__init__.py +0 -0
- stably/tests/conftest.py +148 -0
- stably/tests/test_cohens_d.py +80 -0
- stably/tests/test_e2e_schema.py +145 -0
- stably/tests/test_peptide_input_rejected.py +33 -0
- stably/tests/test_pfer_empirical.py +95 -0
- stably/tests/test_preprocessing_leakage.py +97 -0
- stably/tests/test_preprocessing_no_variance_filter.py +48 -0
- stably/tests/test_rconcave.py +97 -0
- stably/visualization.py +108 -0
- stably/workflows.py +91 -0
- stably-0.3.0.dist-info/METADATA +313 -0
- stably-0.3.0.dist-info/RECORD +24 -0
- stably-0.3.0.dist-info/WHEEL +5 -0
- stably-0.3.0.dist-info/licenses/LICENSE +21 -0
- stably-0.3.0.dist-info/top_level.txt +1 -0
stably/__init__.py
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
|
+
]
|
stably/__main__.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line entry point for stably.
|
|
3
|
+
|
|
4
|
+
Uses the r-concave bound (equation 8, Shah & Samworth 2013) for threshold
|
|
5
|
+
derivation — the main theoretical contribution of S&S over Meinshausen &
|
|
6
|
+
Buhlmann (2010).
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python -m stably --config config_stably.yaml
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
# Force UTF-8 output so unicode glyphs (θ, π, π̂) print correctly on Windows
|
|
19
|
+
# consoles whose default codepage is cp1252.
|
|
20
|
+
if hasattr(sys.stdout, 'reconfigure'):
|
|
21
|
+
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
22
|
+
if hasattr(sys.stderr, 'reconfigure'):
|
|
23
|
+
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
|
|
24
|
+
|
|
25
|
+
from stably import (
|
|
26
|
+
Config,
|
|
27
|
+
load_data,
|
|
28
|
+
save_results,
|
|
29
|
+
create_visualizations,
|
|
30
|
+
full_dataset_stability_elasticnet,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Silence noisy non-actionable warnings only. ConvergenceWarning is captured
|
|
34
|
+
# per-iteration inside the stability selection loop and surfaced in the run
|
|
35
|
+
# manifest, so we deliberately do not blanket-filter warnings here.
|
|
36
|
+
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
|
37
|
+
warnings.filterwarnings('ignore', category=FutureWarning)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
"""Run the complete biomarker discovery pipeline."""
|
|
42
|
+
parser = argparse.ArgumentParser(
|
|
43
|
+
description='Biomarker Discovery: true S&S (2013) r-concave ElasticNet stability selection'
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
'--config',
|
|
47
|
+
type=str,
|
|
48
|
+
required=True,
|
|
49
|
+
help='Path to YAML configuration file'
|
|
50
|
+
)
|
|
51
|
+
args = parser.parse_args()
|
|
52
|
+
|
|
53
|
+
print(f"Loading configuration from: {args.config}")
|
|
54
|
+
config = Config.from_yaml(args.config)
|
|
55
|
+
|
|
56
|
+
np.random.seed(config.RANDOM_STATE)
|
|
57
|
+
|
|
58
|
+
print("=" * 60)
|
|
59
|
+
print("TRUE S&S (2013) r-CONCAVE ELASTICNET STABILITY SELECTION")
|
|
60
|
+
print("=" * 60)
|
|
61
|
+
|
|
62
|
+
# Load data
|
|
63
|
+
print("\nLoading data...")
|
|
64
|
+
X_raw, y, sample_ids, protein_metadata = load_data(config)
|
|
65
|
+
print(f"Loaded {X_raw.shape[0]} samples and {X_raw.shape[1]} features.")
|
|
66
|
+
print(f"Class distribution: {np.sum(y==0)} {config.CONTROL_NAME}, {np.sum(y==1)} {config.CASE_NAME}.")
|
|
67
|
+
|
|
68
|
+
k = config.MAX_CANDIDATES
|
|
69
|
+
B = config.STABILITY_ITERATIONS // 2
|
|
70
|
+
pfer = config.STABILITY_PFER
|
|
71
|
+
print(f"\nMAX_CANDIDATES = {k}, L1_RATIO = {config.L1_RATIO}")
|
|
72
|
+
print(f"B = {B} complementary pairs ({config.STABILITY_ITERATIONS} subsamples)")
|
|
73
|
+
print(f"PFER budget: {pfer:.1f} expected false positives")
|
|
74
|
+
print(f"NOTE: θ = q/p will be computed after preprocessing (p is data-dependent)")
|
|
75
|
+
|
|
76
|
+
if B > 50:
|
|
77
|
+
print(f"\n WARNING: S&S Section 3.4.1 recommends B ≤ 50 for the r-concavity")
|
|
78
|
+
print(f" assumption to hold. Current B = {B}. Consider reducing")
|
|
79
|
+
print(f" stability_iterations to 100 (= 2 × 50).")
|
|
80
|
+
|
|
81
|
+
results = full_dataset_stability_elasticnet(X_raw, y, config)
|
|
82
|
+
|
|
83
|
+
if results is None:
|
|
84
|
+
print("\nAnalysis failed — no stable features found.")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
print(f"\n{'=' * 60}")
|
|
88
|
+
print("RESULTS")
|
|
89
|
+
print(f"{'=' * 60}")
|
|
90
|
+
|
|
91
|
+
feat_stab = results['feature_stability']
|
|
92
|
+
ss_info = results.get('stability_selection', {})
|
|
93
|
+
threshold_info = ss_info.get('threshold_info', {})
|
|
94
|
+
|
|
95
|
+
def get_gene_name(feature_idx):
|
|
96
|
+
if feature_idx in protein_metadata.index:
|
|
97
|
+
gene = protein_metadata.loc[feature_idx, 'Genes']
|
|
98
|
+
return f"{gene} (index {feature_idx})"
|
|
99
|
+
return str(feature_idx)
|
|
100
|
+
|
|
101
|
+
threshold = ss_info.get('threshold', float('nan'))
|
|
102
|
+
c_ref = ss_info.get('C_ref', float('nan'))
|
|
103
|
+
sel_probs = feat_stab.get('selection_probabilities', {})
|
|
104
|
+
stable_list = feat_stab.get('stable_features', [])
|
|
105
|
+
sorted_stable = sorted(stable_list, key=lambda f: sel_probs.get(f, 0), reverse=True)
|
|
106
|
+
|
|
107
|
+
print(f"\nStable features (sorted by selection probability):")
|
|
108
|
+
print(f" r-concave threshold: π = {threshold:.4f}")
|
|
109
|
+
if threshold_info:
|
|
110
|
+
print(f" M&B threshold: π = {threshold_info.get('mb_threshold', 'N/A'):.4f}")
|
|
111
|
+
print(f" Threshold reduction: Δπ = {threshold_info.get('threshold_reduction', 0):.4f}")
|
|
112
|
+
print(f" C_ref = {c_ref:.2e}")
|
|
113
|
+
print(f" Total stable: {len(sorted_stable)}")
|
|
114
|
+
|
|
115
|
+
for i, feat in enumerate(sorted_stable[:20], 1):
|
|
116
|
+
print(f" {i}. {get_gene_name(feat)} π̂={sel_probs.get(feat, 0):.3f}")
|
|
117
|
+
|
|
118
|
+
print(f"\n{'=' * 60}")
|
|
119
|
+
print("ANALYSIS COMPLETE")
|
|
120
|
+
print(f"{'=' * 60}")
|
|
121
|
+
|
|
122
|
+
results['permutation_test'] = None
|
|
123
|
+
|
|
124
|
+
save_results(results, protein_metadata, config)
|
|
125
|
+
create_visualizations(results, protein_metadata, config)
|
|
126
|
+
|
|
127
|
+
return results
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
main()
|
stably/config.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Configuration management for the true S&S ElasticNet stability selection pipeline."""
|
|
2
|
+
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Config:
|
|
9
|
+
"""
|
|
10
|
+
Configuration manager that loads settings from a YAML file.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
config = Config.from_yaml('config.yaml')
|
|
14
|
+
print(config.DATA_FILE)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config_dict: dict):
|
|
18
|
+
"""Initialize Config from a dictionary."""
|
|
19
|
+
# Data files
|
|
20
|
+
self.DATA_FILE = config_dict['data_file']
|
|
21
|
+
self.LABEL_FILE = config_dict['label_file']
|
|
22
|
+
|
|
23
|
+
# Sample information
|
|
24
|
+
self.CASE_NAME = config_dict['case_name']
|
|
25
|
+
self.CONTROL_NAME = config_dict['control_name']
|
|
26
|
+
self.GROUP_COLUMN = config_dict['group_column']
|
|
27
|
+
self.SAMPLE_ID_COLUMN = config_dict['sample_id_column']
|
|
28
|
+
|
|
29
|
+
# Random seed
|
|
30
|
+
self.RANDOM_STATE = config_dict['random_state']
|
|
31
|
+
|
|
32
|
+
# Preprocessing parameters
|
|
33
|
+
self.MAX_MISSING = config_dict['max_missing']
|
|
34
|
+
self.LOG_TRANSFORM = config_dict['log_transform']
|
|
35
|
+
self.CORRELATION_THRESHOLD = config_dict['correlation_threshold']
|
|
36
|
+
self.IMPUTATION_STRATEGY = config_dict['imputation_strategy']
|
|
37
|
+
self.KNN_NEIGHBORS = config_dict.get('knn_neighbors', 5)
|
|
38
|
+
self.MIN_PROTEOTYPIC_PEPTIDES = config_dict.get('min_proteotypic_peptides', 2)
|
|
39
|
+
|
|
40
|
+
# Upstream DIA-NN log (for database version capture, HC-INTER-03).
|
|
41
|
+
# Optional: when None, load_data will try to auto-discover a *.log.txt
|
|
42
|
+
# next to the data file or in a sibling PDC_outputs_*/ directory.
|
|
43
|
+
self.DIANN_LOG_FILE = config_dict.get('diann_log_file', None)
|
|
44
|
+
|
|
45
|
+
# Machine learning
|
|
46
|
+
self.MAX_ITERATIONS = config_dict['max_iterations']
|
|
47
|
+
|
|
48
|
+
# ElasticNet mixing parameter
|
|
49
|
+
self.L1_RATIO = config_dict.get('l1_ratio', 0.5)
|
|
50
|
+
|
|
51
|
+
# Stability selection
|
|
52
|
+
self.MAX_CANDIDATES = config_dict['max_candidates']
|
|
53
|
+
self.STABILITY_ITERATIONS = config_dict['stability_iterations']
|
|
54
|
+
self.STABILITY_PFER = float(config_dict['stability_pfer'])
|
|
55
|
+
self.N_JOBS = config_dict['n_jobs']
|
|
56
|
+
|
|
57
|
+
# Output
|
|
58
|
+
self.OUTPUT_DIR = config_dict.get('output_dir', 'biomarker_results')
|
|
59
|
+
|
|
60
|
+
@classmethod
|
|
61
|
+
def from_yaml(cls, yaml_path: str) -> 'Config':
|
|
62
|
+
"""
|
|
63
|
+
Load configuration from a YAML file.
|
|
64
|
+
|
|
65
|
+
Parameters
|
|
66
|
+
----------
|
|
67
|
+
yaml_path : str
|
|
68
|
+
Path to the YAML configuration file
|
|
69
|
+
|
|
70
|
+
Returns
|
|
71
|
+
-------
|
|
72
|
+
Config
|
|
73
|
+
Configuration object
|
|
74
|
+
"""
|
|
75
|
+
yaml_path = Path(yaml_path)
|
|
76
|
+
if not yaml_path.exists():
|
|
77
|
+
raise FileNotFoundError(f"Configuration file not found: {yaml_path}")
|
|
78
|
+
|
|
79
|
+
with open(yaml_path, 'r') as f:
|
|
80
|
+
config_dict = yaml.safe_load(f)
|
|
81
|
+
|
|
82
|
+
return cls(config_dict)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_dict(cls, config_dict: dict) -> 'Config':
|
|
86
|
+
"""Create Config from a dictionary (useful for testing)."""
|
|
87
|
+
return cls(config_dict)
|
stably/diann_log.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Parse a DIA-NN log file to capture the upstream provenance needed for
|
|
2
|
+
HC-INTER-03 (peptide/protein identifiers referenced to a specific database
|
|
3
|
+
version) and HC-FDR-06 (MBR-transferred vs MS2-confirmed IDs).
|
|
4
|
+
|
|
5
|
+
The parser is intentionally permissive: if fields cannot be located it returns
|
|
6
|
+
None for those fields rather than raising, so a partial manifest is still
|
|
7
|
+
better than no manifest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def find_diann_log(data_file: str | Path) -> Optional[Path]:
|
|
18
|
+
"""
|
|
19
|
+
Heuristic search for a DIA-NN log near `data_file`.
|
|
20
|
+
|
|
21
|
+
Looks in:
|
|
22
|
+
1. The directory of `data_file` itself.
|
|
23
|
+
2. Immediate subdirectories (one level deep).
|
|
24
|
+
3. Sibling directories (one level up, one level down).
|
|
25
|
+
|
|
26
|
+
Returns the first match or None.
|
|
27
|
+
"""
|
|
28
|
+
data_path = Path(data_file).resolve()
|
|
29
|
+
candidates = []
|
|
30
|
+
|
|
31
|
+
if data_path.parent.exists():
|
|
32
|
+
candidates.extend(sorted(data_path.parent.glob("*.log.txt")))
|
|
33
|
+
for child in sorted(data_path.parent.iterdir()):
|
|
34
|
+
if child.is_dir():
|
|
35
|
+
candidates.extend(sorted(child.glob("*.log.txt")))
|
|
36
|
+
|
|
37
|
+
parent = data_path.parent.parent
|
|
38
|
+
if parent.exists():
|
|
39
|
+
candidates.extend(sorted(parent.glob("*.log.txt")))
|
|
40
|
+
|
|
41
|
+
# De-duplicate while preserving order
|
|
42
|
+
seen = set()
|
|
43
|
+
for path in candidates:
|
|
44
|
+
resolved = path.resolve()
|
|
45
|
+
if resolved in seen:
|
|
46
|
+
continue
|
|
47
|
+
seen.add(resolved)
|
|
48
|
+
if resolved.is_file() and _looks_like_diann_log(resolved):
|
|
49
|
+
return resolved
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _looks_like_diann_log(path: Path) -> bool:
|
|
54
|
+
try:
|
|
55
|
+
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
|
56
|
+
head = f.read(512)
|
|
57
|
+
except OSError:
|
|
58
|
+
return False
|
|
59
|
+
return head.lstrip().startswith("DIA-NN")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_FASTA_RE = re.compile(r"--fasta\s+(\S+)")
|
|
63
|
+
_LIB_RE = re.compile(r"--lib\s+(\S+)")
|
|
64
|
+
_QVALUE_RE = re.compile(r"--qvalue\s+(\S+)")
|
|
65
|
+
_THREADS_RE = re.compile(r"--threads\s+(\d+)")
|
|
66
|
+
_REANALYSE_RE = re.compile(r"--reanalyse(?!\w)")
|
|
67
|
+
_MATRICES_RE = re.compile(r"--matrices(?!\w)")
|
|
68
|
+
_MBR_LINE_RE = re.compile(r"MBR\s+enabled", re.IGNORECASE)
|
|
69
|
+
_VERSION_RE = re.compile(r"^DIA-NN\s+(\S+)(?:\s+(\S+))?", re.MULTILINE)
|
|
70
|
+
_COMPILED_RE = re.compile(r"^Compiled on\s+(.+)$", re.MULTILINE)
|
|
71
|
+
_FASTA_DATE_TAIL_RE = re.compile(r"_([A-Z][a-z]{2}\d{2})\.", re.IGNORECASE)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_diann_log(path: str | Path, head_chars: int = 8192) -> dict:
|
|
75
|
+
"""
|
|
76
|
+
Parse the header region of a DIA-NN log.
|
|
77
|
+
|
|
78
|
+
Only the first `head_chars` are read, which is enough for every field of
|
|
79
|
+
interest on a standard DIA-NN log. Returns a dict with keys:
|
|
80
|
+
diann_version, diann_edition, compile_date, command_line,
|
|
81
|
+
fasta_path, fasta_name, fasta_release_tag,
|
|
82
|
+
library_path, qvalue, mbr_enabled, reanalyse,
|
|
83
|
+
matrices, threads, log_file.
|
|
84
|
+
Missing fields are None.
|
|
85
|
+
"""
|
|
86
|
+
path = Path(path)
|
|
87
|
+
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
|
88
|
+
head = f.read(head_chars)
|
|
89
|
+
|
|
90
|
+
version_match = _VERSION_RE.search(head)
|
|
91
|
+
diann_version = version_match.group(1) if version_match else None
|
|
92
|
+
diann_edition = version_match.group(2) if version_match and version_match.group(2) else None
|
|
93
|
+
|
|
94
|
+
compiled_match = _COMPILED_RE.search(head)
|
|
95
|
+
compile_date = compiled_match.group(1).strip() if compiled_match else None
|
|
96
|
+
|
|
97
|
+
command_line = _extract_command_line(head)
|
|
98
|
+
|
|
99
|
+
fasta_match = _FASTA_RE.search(head)
|
|
100
|
+
fasta_path = fasta_match.group(1) if fasta_match else None
|
|
101
|
+
fasta_name = Path(fasta_path).name if fasta_path else None
|
|
102
|
+
fasta_tag_match = _FASTA_DATE_TAIL_RE.search(fasta_name or "")
|
|
103
|
+
fasta_release_tag = fasta_tag_match.group(1) if fasta_tag_match else None
|
|
104
|
+
|
|
105
|
+
lib_match = _LIB_RE.search(head)
|
|
106
|
+
library_path = lib_match.group(1) if lib_match else None
|
|
107
|
+
|
|
108
|
+
qvalue_match = _QVALUE_RE.search(head)
|
|
109
|
+
qvalue = float(qvalue_match.group(1)) if qvalue_match else None
|
|
110
|
+
|
|
111
|
+
threads_match = _THREADS_RE.search(head)
|
|
112
|
+
threads = int(threads_match.group(1)) if threads_match else None
|
|
113
|
+
|
|
114
|
+
reanalyse = bool(_REANALYSE_RE.search(head))
|
|
115
|
+
matrices = bool(_MATRICES_RE.search(head))
|
|
116
|
+
mbr_enabled = bool(_MBR_LINE_RE.search(head))
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
'diann_version': diann_version,
|
|
120
|
+
'diann_edition': diann_edition,
|
|
121
|
+
'compile_date': compile_date,
|
|
122
|
+
'command_line': command_line,
|
|
123
|
+
'fasta_path': fasta_path,
|
|
124
|
+
'fasta_name': fasta_name,
|
|
125
|
+
'fasta_release_tag': fasta_release_tag,
|
|
126
|
+
'library_path': library_path,
|
|
127
|
+
'qvalue': qvalue,
|
|
128
|
+
'mbr_enabled': mbr_enabled,
|
|
129
|
+
'reanalyse': reanalyse,
|
|
130
|
+
'matrices': matrices,
|
|
131
|
+
'threads': threads,
|
|
132
|
+
'log_file': str(path),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _extract_command_line(head: str) -> Optional[str]:
|
|
137
|
+
"""The DIA-NN command line is the first line containing --fasta or --lib."""
|
|
138
|
+
for line in head.splitlines():
|
|
139
|
+
if '--fasta' in line or '--lib' in line:
|
|
140
|
+
return line.strip()
|
|
141
|
+
return None
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Feature selection using the true Shah & Samworth (2013) r-concave bound.
|
|
2
|
+
|
|
3
|
+
Uses the r-concave PFER bound (equation 8) instead of the Meinshausen &
|
|
4
|
+
Buhlmann worst-case bound for threshold computation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from sklearn.linear_model import LogisticRegression
|
|
12
|
+
from sklearn.model_selection import StratifiedShuffleSplit
|
|
13
|
+
from sklearn.exceptions import ConvergenceWarning
|
|
14
|
+
from joblib import Parallel, delayed
|
|
15
|
+
from typing import List, Dict, Tuple
|
|
16
|
+
|
|
17
|
+
from .rconcave import compute_rconcave_threshold, print_threshold_comparison
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Design note: C_ref is calibrated once on the FULL training set and reused
|
|
21
|
+
# across every complementary-pairs subsample. This is the Shah & Samworth
|
|
22
|
+
# (2013) design — regularisation is treated as a fixed hyperparameter of the
|
|
23
|
+
# stability procedure, not something re-tuned per subsample. It is not a
|
|
24
|
+
# cross-validation leakage bug: stability selection does not evaluate model
|
|
25
|
+
# performance on held-out data; it counts how often each feature is selected
|
|
26
|
+
# across subsamples at a fixed regularisation strength.
|
|
27
|
+
def _find_elasticnet_C_for_q(X, y, q, random_state, l1_ratio, max_iter=1000):
|
|
28
|
+
"""Largest ElasticNet C (in a log-spaced sweep) that keeps <= q non-zero coefs."""
|
|
29
|
+
Cs = np.logspace(-4, 2, 50)
|
|
30
|
+
C_ref = Cs[0]
|
|
31
|
+
for C in Cs:
|
|
32
|
+
model = LogisticRegression(
|
|
33
|
+
penalty='elasticnet',
|
|
34
|
+
C=C,
|
|
35
|
+
l1_ratio=l1_ratio,
|
|
36
|
+
solver='saga',
|
|
37
|
+
random_state=random_state,
|
|
38
|
+
max_iter=max_iter,
|
|
39
|
+
)
|
|
40
|
+
model.fit(X, y)
|
|
41
|
+
n_nonzero = int(np.sum(model.coef_[0] != 0))
|
|
42
|
+
if n_nonzero <= q:
|
|
43
|
+
C_ref = C
|
|
44
|
+
return C_ref
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _stability_iteration_elasticnet_path(
|
|
48
|
+
X, y, q, iteration_idx, random_seed, C_ref, l1_ratio, max_iter
|
|
49
|
+
):
|
|
50
|
+
"""Single complementary-pairs iteration. Returns (selected_features, converged)."""
|
|
51
|
+
# Complementary pairs: (2i, 2i+1) share the split seed
|
|
52
|
+
splitter = StratifiedShuffleSplit(
|
|
53
|
+
n_splits=1,
|
|
54
|
+
train_size=0.5,
|
|
55
|
+
random_state=random_seed + (iteration_idx // 2),
|
|
56
|
+
)
|
|
57
|
+
train_indices, test_indices = next(splitter.split(X, y))
|
|
58
|
+
subsample_indices = train_indices if iteration_idx % 2 == 0 else test_indices
|
|
59
|
+
|
|
60
|
+
X_sub = X.iloc[subsample_indices]
|
|
61
|
+
y_sub = y[subsample_indices]
|
|
62
|
+
|
|
63
|
+
model = LogisticRegression(
|
|
64
|
+
penalty='elasticnet',
|
|
65
|
+
C=C_ref,
|
|
66
|
+
l1_ratio=l1_ratio,
|
|
67
|
+
solver='saga',
|
|
68
|
+
random_state=random_seed + iteration_idx,
|
|
69
|
+
max_iter=max_iter,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
73
|
+
warnings.simplefilter("always", ConvergenceWarning)
|
|
74
|
+
model.fit(X_sub, y_sub)
|
|
75
|
+
converged = not any(issubclass(w.category, ConvergenceWarning) for w in caught)
|
|
76
|
+
|
|
77
|
+
coefs = np.abs(model.coef_[0])
|
|
78
|
+
nonzero_idx = np.where(coefs > 1e-6)[0]
|
|
79
|
+
if len(nonzero_idx) == 0:
|
|
80
|
+
return [], converged
|
|
81
|
+
top_q_idx = nonzero_idx[np.argsort(coefs[nonzero_idx])[::-1][:q]]
|
|
82
|
+
return X.columns[top_q_idx].tolist(), converged
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def stability_selection_elasticnet(
|
|
86
|
+
X, y, config, q=None, n_iterations=None, n_jobs=None
|
|
87
|
+
) -> Tuple[List[str], float, Dict[str, float], float, dict]:
|
|
88
|
+
"""
|
|
89
|
+
Shah & Samworth (2013) stability selection with the true r-concave bound.
|
|
90
|
+
|
|
91
|
+
Returns
|
|
92
|
+
-------
|
|
93
|
+
stable_features : list of str
|
|
94
|
+
threshold : float (r-concave PFER-derived selection probability)
|
|
95
|
+
selection_probs : dict[str, float]
|
|
96
|
+
C_ref : float
|
|
97
|
+
threshold_info : dict with rconcave/MB comparison and convergence stats
|
|
98
|
+
"""
|
|
99
|
+
q = q or config.MAX_CANDIDATES
|
|
100
|
+
n_iterations = n_iterations or config.STABILITY_ITERATIONS
|
|
101
|
+
n_jobs = n_jobs or config.N_JOBS
|
|
102
|
+
l1_ratio = config.L1_RATIO
|
|
103
|
+
|
|
104
|
+
p = X.shape[1]
|
|
105
|
+
B = n_iterations // 2
|
|
106
|
+
pfer = config.STABILITY_PFER
|
|
107
|
+
|
|
108
|
+
# Compute the r-concave threshold (true S&S bound)
|
|
109
|
+
print(f" Computing r-concave threshold (q={q}, p={p}, B={B}, PFER={pfer:.1f})...")
|
|
110
|
+
threshold, bound_at_tau, mb_threshold = compute_rconcave_threshold(q, p, B, pfer)
|
|
111
|
+
threshold = min(threshold, 1.0)
|
|
112
|
+
|
|
113
|
+
print_threshold_comparison(q, p, B, pfer)
|
|
114
|
+
|
|
115
|
+
threshold_info = {
|
|
116
|
+
'rconcave_threshold': threshold,
|
|
117
|
+
'mb_threshold': mb_threshold,
|
|
118
|
+
'threshold_reduction': mb_threshold - threshold,
|
|
119
|
+
'pfer_bound': bound_at_tau,
|
|
120
|
+
'pfer_target': pfer,
|
|
121
|
+
'theta': q / p,
|
|
122
|
+
'B': B,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Calibrate C from the regularisation path (once, on the full data; see
|
|
126
|
+
# design note at the top of this module).
|
|
127
|
+
print(f" Calibrating ElasticNet regularisation from path (l1_ratio={l1_ratio})...")
|
|
128
|
+
C_ref = _find_elasticnet_C_for_q(X, y, q, config.RANDOM_STATE, l1_ratio, config.MAX_ITERATIONS)
|
|
129
|
+
print(f" C_ref={C_ref:.2e} | r-concave threshold: π={threshold:.4f} | PFER≤{pfer:.1f}")
|
|
130
|
+
print(f" Running {n_iterations} complementary-pairs iterations (n_jobs={n_jobs})...")
|
|
131
|
+
|
|
132
|
+
iteration_results = Parallel(n_jobs=n_jobs, verbose=0)(
|
|
133
|
+
delayed(_stability_iteration_elasticnet_path)(
|
|
134
|
+
X, y, q, i, config.RANDOM_STATE, C_ref, l1_ratio, config.MAX_ITERATIONS
|
|
135
|
+
)
|
|
136
|
+
for i in range(n_iterations)
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
all_selected = [features for features, _ in iteration_results]
|
|
140
|
+
n_non_converged = sum(1 for _, converged in iteration_results if not converged)
|
|
141
|
+
threshold_info['n_iterations'] = n_iterations
|
|
142
|
+
threshold_info['n_non_converged'] = n_non_converged
|
|
143
|
+
threshold_info['non_convergence_rate'] = n_non_converged / n_iterations
|
|
144
|
+
|
|
145
|
+
if n_non_converged / n_iterations > 0.05:
|
|
146
|
+
print(
|
|
147
|
+
f" WARNING: {n_non_converged}/{n_iterations} "
|
|
148
|
+
f"({100 * n_non_converged / n_iterations:.1f}%) iterations did not converge. "
|
|
149
|
+
f"Consider increasing config.MAX_ITERATIONS."
|
|
150
|
+
)
|
|
151
|
+
elif n_non_converged > 0:
|
|
152
|
+
print(f" Convergence: {n_iterations - n_non_converged}/{n_iterations} iterations")
|
|
153
|
+
|
|
154
|
+
# Empirical selection probabilities
|
|
155
|
+
feature_counts: Dict[str, int] = {}
|
|
156
|
+
for selected in all_selected:
|
|
157
|
+
for feature in selected:
|
|
158
|
+
feature_counts[feature] = feature_counts.get(feature, 0) + 1
|
|
159
|
+
|
|
160
|
+
selection_probs = {f: c / n_iterations for f, c in feature_counts.items()}
|
|
161
|
+
|
|
162
|
+
stable_features = [f for f, prob in selection_probs.items() if prob >= threshold]
|
|
163
|
+
|
|
164
|
+
print(f" Completed: {len(stable_features)} stable features (π̂ ≥ {threshold:.4f})")
|
|
165
|
+
return stable_features, threshold, selection_probs, C_ref, threshold_info
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def calculate_cohens_d(X, y, features):
|
|
169
|
+
"""
|
|
170
|
+
Cohen's d and per-group sample sizes for each feature between case and control.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
dict[str, dict]
|
|
175
|
+
Per-feature entries with keys:
|
|
176
|
+
cohens_d — signed (group1 - group0) / pooled_std
|
|
177
|
+
n_case — number of non-NaN samples with y == 1
|
|
178
|
+
n_control — number of non-NaN samples with y == 0
|
|
179
|
+
direction — 'up_case', 'up_control', or 'zero'
|
|
180
|
+
"""
|
|
181
|
+
results = {}
|
|
182
|
+
for feature in features:
|
|
183
|
+
group0 = X.loc[y == 0, feature].dropna()
|
|
184
|
+
group1 = X.loc[y == 1, feature].dropna()
|
|
185
|
+
n0, n1 = len(group0), len(group1)
|
|
186
|
+
|
|
187
|
+
if n0 < 2 or n1 < 2:
|
|
188
|
+
results[feature] = {
|
|
189
|
+
'cohens_d': float('nan'),
|
|
190
|
+
'n_case': n1,
|
|
191
|
+
'n_control': n0,
|
|
192
|
+
'direction': 'undetermined',
|
|
193
|
+
}
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
pooled_std = np.sqrt(
|
|
197
|
+
((n0 - 1) * group0.std() ** 2 + (n1 - 1) * group1.std() ** 2) / (n0 + n1 - 2)
|
|
198
|
+
)
|
|
199
|
+
if pooled_std > 0:
|
|
200
|
+
d = (group1.mean() - group0.mean()) / pooled_std
|
|
201
|
+
else:
|
|
202
|
+
d = 0.0
|
|
203
|
+
|
|
204
|
+
if d > 0:
|
|
205
|
+
direction = 'up_case'
|
|
206
|
+
elif d < 0:
|
|
207
|
+
direction = 'up_control'
|
|
208
|
+
else:
|
|
209
|
+
direction = 'zero'
|
|
210
|
+
|
|
211
|
+
results[feature] = {
|
|
212
|
+
'cohens_d': float(d),
|
|
213
|
+
'n_case': n1,
|
|
214
|
+
'n_control': n0,
|
|
215
|
+
'direction': direction,
|
|
216
|
+
}
|
|
217
|
+
return results
|