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 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