Skip to content

Commit e01a659

Browse files
authored
[Conformal EEG] Neighborhood Conformal Prediction (#824)
* NCP implementation * test script * All the init files * Check build * random unrealted fix to fix flaky test * Add docs * scripts for baseline testing * update data path * quick test script changes * fix nan error * Fixed ncp implementation * minor fixes * empty set logic * Math fix * Modify tests to reflect fix * Updated tests
1 parent 32e448a commit e01a659

11 files changed

Lines changed: 982 additions & 18 deletions

File tree

docs/api/calib.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ confidence levels:
2222
- :class:`~pyhealth.calib.predictionset.SCRIB`: Class-specific risk control
2323
- :class:`~pyhealth.calib.predictionset.FavMac`: Value-maximizing sets with cost control
2424
- :class:`~pyhealth.calib.predictionset.CovariateLabel`: Covariate shift adaptive conformal
25+
- :class:`~pyhealth.calib.predictionset.ClusterLabel`: K-means cluster-based conformal prediction
26+
- :class:`~pyhealth.calib.predictionset.NeighborhoodLabel`: Neighborhood Conformal Prediction (NCP)
2527

2628
Getting Started
2729
---------------

docs/api/calib/pyhealth.calib.predictionset.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ Available Methods
1616
pyhealth.calib.predictionset.SCRIB
1717
pyhealth.calib.predictionset.FavMac
1818
pyhealth.calib.predictionset.CovariateLabel
19+
pyhealth.calib.predictionset.ClusterLabel
20+
pyhealth.calib.predictionset.NeighborhoodLabel
1921

2022
LABEL (Least Ambiguous Set-valued Classifier)
2123
----------------------------------------------
@@ -49,6 +51,22 @@ CovariateLabel (Covariate Shift Adaptive)
4951
:undoc-members:
5052
:show-inheritance:
5153

54+
ClusterLabel (K-means Cluster-based Conformal)
55+
----------------------------------------------
56+
57+
.. autoclass:: pyhealth.calib.predictionset.ClusterLabel
58+
:members:
59+
:undoc-members:
60+
:show-inheritance:
61+
62+
NeighborhoodLabel (Neighborhood Conformal Prediction)
63+
-----------------------------------------------------
64+
65+
.. autoclass:: pyhealth.calib.predictionset.NeighborhoodLabel
66+
:members:
67+
:undoc-members:
68+
:show-inheritance:
69+
5270
Helper Functions
5371
----------------
5472

examples/conformal_eeg/tuev_kmeans_conformal.py

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
6) Evaluates prediction-set coverage/miscoverage and efficiency on the test split.
1010
1111
Example (from repo root):
12-
python examples/conformal_eeg/tuev_kmeans_conformal.py --root downloads/tuev/v2.0.1/edf --n-clusters 5
12+
python examples/conformal_eeg/tuev_kmeans_conformal.py --root /srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf --n-clusters 5
13+
python examples/conformal_eeg/tuev_kmeans_conformal.py --quick-test --log-file quicktest_kmeans.log
1314
1415
Notes:
1516
- ClusterLabel uses K-means clustering on embeddings to compute cluster-specific thresholds.
@@ -20,11 +21,30 @@
2021

2122
import argparse
2223
import random
24+
import sys
2325
from pathlib import Path
2426

2527
import numpy as np
2628
import torch
2729

30+
31+
class _Tee:
32+
"""Writes to both a stream and a file."""
33+
34+
def __init__(self, stream, file):
35+
self._stream = stream
36+
self._file = file
37+
38+
def write(self, data):
39+
self._stream.write(data)
40+
self._file.write(data)
41+
self._file.flush()
42+
43+
def flush(self):
44+
self._stream.flush()
45+
self._file.flush()
46+
47+
2848
from pyhealth.calib.predictionset.cluster import ClusterLabel
2949
from pyhealth.calib.utils import extract_embeddings
3050
from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_sample_conformal
@@ -40,13 +60,13 @@ def parse_args() -> argparse.Namespace:
4060
parser.add_argument(
4161
"--root",
4262
type=str,
43-
default="downloads/tuev/v2.0.1/edf",
63+
default="/srv/local/data/TUH/tuh_eeg_events/v2.0.0/edf",
4464
help="Path to TUEV edf/ folder.",
4565
)
46-
parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
66+
parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
4767
parser.add_argument("--seed", type=int, default=42)
48-
parser.add_argument("--batch-size", type=int, default=32)
49-
parser.add_argument("--epochs", type=int, default=3)
68+
parser.add_argument("--batch-size", type=int, default=64)
69+
parser.add_argument("--epochs", type=int, default=20)
5070
parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
5171
parser.add_argument(
5272
"--ratios",
@@ -69,6 +89,17 @@ def parse_args() -> argparse.Namespace:
6989
default=None,
7090
help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
7191
)
92+
parser.add_argument(
93+
"--log-file",
94+
type=str,
95+
default=None,
96+
help="Path to log file. Stdout and stderr are teed to this file.",
97+
)
98+
parser.add_argument(
99+
"--quick-test",
100+
action="store_true",
101+
help="Smoke test: dev=True, max 2000 samples, 2 epochs, ~5-10 min.",
102+
)
72103
return parser.parse_args()
73104

74105

@@ -84,6 +115,23 @@ def main() -> None:
84115
args = parse_args()
85116
set_seed(args.seed)
86117

118+
orig_stdout, orig_stderr = sys.stdout, sys.stderr
119+
log_file = None
120+
if args.log_file:
121+
log_file = open(args.log_file, "w", encoding="utf-8")
122+
sys.stdout = _Tee(orig_stdout, log_file)
123+
sys.stderr = _Tee(orig_stderr, log_file)
124+
125+
try:
126+
_run(args)
127+
finally:
128+
if log_file is not None:
129+
sys.stdout = orig_stdout
130+
sys.stderr = orig_stderr
131+
log_file.close()
132+
133+
134+
def _run(args: argparse.Namespace) -> None:
87135
device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
88136
root = Path(args.root)
89137
if not root.exists():
@@ -92,11 +140,19 @@ def main() -> None:
92140
"Pass --root to point to your downloaded TUEV edf/ directory."
93141
)
94142

143+
epochs = 2 if args.quick_test else args.epochs
144+
quick_test_max_samples = 2000 # cap samples so quick-test finishes in ~5-10 min
145+
if args.quick_test:
146+
print("*** QUICK TEST MODE (dev=True, 2 epochs, max 2000 samples) ***")
147+
95148
print("=" * 80)
96149
print("STEP 1: Load TUEV + build task dataset")
97150
print("=" * 80)
98-
dataset = TUEVDataset(root=str(root), subset=args.subset)
151+
dataset = TUEVDataset(root=str(root), subset=args.subset, dev=args.quick_test)
99152
sample_dataset = dataset.set_task(EEGEventsTUEV(), cache_dir="examples/conformal_eeg/cache")
153+
if args.quick_test and len(sample_dataset) > quick_test_max_samples:
154+
sample_dataset = sample_dataset.subset(range(quick_test_max_samples))
155+
print(f"Capped to {quick_test_max_samples} samples for quick-test.")
100156

101157
print(f"Task samples: {len(sample_dataset)}")
102158
print(f"Input schema: {sample_dataset.input_schema}")
@@ -129,7 +185,7 @@ def main() -> None:
129185
trainer.train(
130186
train_dataloader=train_loader,
131187
val_dataloader=val_loader,
132-
epochs=args.epochs,
188+
epochs=epochs,
133189
monitor="accuracy" if val_loader is not None else None,
134190
)
135191

0 commit comments

Comments
 (0)