Skip to content

Commit 04ca18f

Browse files
authored
[Conformal EEG] K-means clustering (#795)
* kmeans cluster implementation * init stuff * script for eval * Add tests * Fix copilot suggestions
1 parent 216fde8 commit 04ca18f

5 files changed

Lines changed: 1002 additions & 1 deletion

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
"""K-means Cluster-Based Conformal Prediction (ClusterLabel) on TUEV EEG Events using ContraWR.
2+
3+
This script:
4+
1) Loads the TUEV dataset and applies the EEGEventsTUEV task.
5+
2) Splits into train/val/cal/test using split conformal protocol.
6+
3) Trains a ContraWR model.
7+
4) Extracts embeddings for training and calibration splits using embed=True.
8+
5) Calibrates a ClusterLabel prediction-set predictor (K-means clustering).
9+
6) Evaluates prediction-set coverage/miscoverage and efficiency on the test split.
10+
11+
Example (from repo root):
12+
python examples/conformal_eeg/tuev_kmeans_conformal.py --root downloads/tuev/v2.0.1/edf --n-clusters 5
13+
14+
Notes:
15+
- ClusterLabel uses K-means clustering on embeddings to compute cluster-specific thresholds.
16+
- Different K values can be tested to find optimal cluster count.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import argparse
22+
import random
23+
from pathlib import Path
24+
25+
import numpy as np
26+
import torch
27+
28+
from pyhealth.calib.predictionset.cluster import ClusterLabel
29+
from pyhealth.calib.utils import extract_embeddings
30+
from pyhealth.datasets import TUEVDataset, get_dataloader, split_by_sample_conformal
31+
from pyhealth.models import ContraWR
32+
from pyhealth.tasks import EEGEventsTUEV
33+
from pyhealth.trainer import Trainer, get_metrics_fn
34+
35+
36+
def parse_args() -> argparse.Namespace:
37+
parser = argparse.ArgumentParser(
38+
description="K-means cluster-based conformal prediction (ClusterLabel) on TUEV EEG events using ContraWR."
39+
)
40+
parser.add_argument(
41+
"--root",
42+
type=str,
43+
default="downloads/tuev/v2.0.1/edf",
44+
help="Path to TUEV edf/ folder.",
45+
)
46+
parser.add_argument("--subset", type=str, default="both", choices=["train", "eval", "both"])
47+
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)
50+
parser.add_argument("--alpha", type=float, default=0.1, help="Miscoverage rate (e.g., 0.1 => 90% target coverage).")
51+
parser.add_argument(
52+
"--ratios",
53+
type=float,
54+
nargs=4,
55+
default=(0.6, 0.1, 0.15, 0.15),
56+
metavar=("TRAIN", "VAL", "CAL", "TEST"),
57+
help="Split ratios for train/val/cal/test. Must sum to 1.0.",
58+
)
59+
parser.add_argument(
60+
"--n-clusters",
61+
type=int,
62+
default=5,
63+
help="Number of K-means clusters for cluster-specific thresholds.",
64+
)
65+
parser.add_argument("--n-fft", type=int, default=128, help="STFT FFT size used by ContraWR.")
66+
parser.add_argument(
67+
"--device",
68+
type=str,
69+
default=None,
70+
help="Device string, e.g. 'cuda:0' or 'cpu'. Defaults to auto-detect.",
71+
)
72+
return parser.parse_args()
73+
74+
75+
def set_seed(seed: int) -> None:
76+
random.seed(seed)
77+
np.random.seed(seed)
78+
torch.manual_seed(seed)
79+
if torch.cuda.is_available():
80+
torch.cuda.manual_seed_all(seed)
81+
82+
83+
def main() -> None:
84+
args = parse_args()
85+
set_seed(args.seed)
86+
87+
device = args.device or ("cuda:0" if torch.cuda.is_available() else "cpu")
88+
root = Path(args.root)
89+
if not root.exists():
90+
raise FileNotFoundError(
91+
f"TUEV root not found: {root}. "
92+
"Pass --root to point to your downloaded TUEV edf/ directory."
93+
)
94+
95+
print("=" * 80)
96+
print("STEP 1: Load TUEV + build task dataset")
97+
print("=" * 80)
98+
dataset = TUEVDataset(root=str(root), subset=args.subset)
99+
sample_dataset = dataset.set_task(EEGEventsTUEV(), cache_dir="examples/conformal_eeg/cache")
100+
101+
print(f"Task samples: {len(sample_dataset)}")
102+
print(f"Input schema: {sample_dataset.input_schema}")
103+
print(f"Output schema: {sample_dataset.output_schema}")
104+
105+
if len(sample_dataset) == 0:
106+
raise RuntimeError("No samples produced. Verify TUEV root/subset/task.")
107+
108+
print("\n" + "=" * 80)
109+
print("STEP 2: Split train/val/cal/test")
110+
print("=" * 80)
111+
train_ds, val_ds, cal_ds, test_ds = split_by_sample_conformal(
112+
dataset=sample_dataset, ratios=list(args.ratios), seed=args.seed
113+
)
114+
print(f"Train: {len(train_ds)}")
115+
print(f"Val: {len(val_ds)}")
116+
print(f"Cal: {len(cal_ds)}")
117+
print(f"Test: {len(test_ds)}")
118+
119+
train_loader = get_dataloader(train_ds, batch_size=args.batch_size, shuffle=True)
120+
val_loader = get_dataloader(val_ds, batch_size=args.batch_size, shuffle=False) if len(val_ds) else None
121+
test_loader = get_dataloader(test_ds, batch_size=args.batch_size, shuffle=False)
122+
123+
print("\n" + "=" * 80)
124+
print("STEP 3: Train ContraWR")
125+
print("=" * 80)
126+
model = ContraWR(dataset=sample_dataset, n_fft=args.n_fft).to(device)
127+
trainer = Trainer(model=model, device=device, enable_logging=False)
128+
129+
trainer.train(
130+
train_dataloader=train_loader,
131+
val_dataloader=val_loader,
132+
epochs=args.epochs,
133+
monitor="accuracy" if val_loader is not None else None,
134+
)
135+
136+
print("\nBase model performance on test set:")
137+
y_true_base, y_prob_base, _loss_base = trainer.inference(test_loader)
138+
base_metrics = get_metrics_fn("multiclass")(y_true_base, y_prob_base, metrics=["accuracy", "f1_weighted"])
139+
for metric, value in base_metrics.items():
140+
print(f" {metric}: {value:.4f}")
141+
142+
print("\n" + "=" * 80)
143+
print("STEP 4: K-means Cluster-Based Conformal Prediction (ClusterLabel)")
144+
print("=" * 80)
145+
print(f"Target miscoverage alpha: {args.alpha} (target coverage {1 - args.alpha:.0%})")
146+
print(f"Number of clusters: {args.n_clusters}")
147+
148+
print("Extracting embeddings for training split...")
149+
train_embeddings = extract_embeddings(model, train_ds, batch_size=args.batch_size, device=device)
150+
print(f" train_embeddings shape: {train_embeddings.shape}")
151+
152+
print("Extracting embeddings for calibration split...")
153+
cal_embeddings = extract_embeddings(model, cal_ds, batch_size=args.batch_size, device=device)
154+
print(f" cal_embeddings shape: {cal_embeddings.shape}")
155+
156+
cluster_predictor = ClusterLabel(
157+
model=model,
158+
alpha=float(args.alpha),
159+
n_clusters=args.n_clusters,
160+
random_state=args.seed,
161+
)
162+
print("Calibrating ClusterLabel predictor (fits K-means and computes cluster-specific thresholds)...")
163+
cluster_predictor.calibrate(
164+
cal_dataset=cal_ds,
165+
train_embeddings=train_embeddings,
166+
cal_embeddings=cal_embeddings,
167+
)
168+
169+
print("Evaluating ClusterLabel predictor on test set...")
170+
y_true, y_prob, _loss, extra = Trainer(model=cluster_predictor).inference(
171+
test_loader, additional_outputs=["y_predset"]
172+
)
173+
174+
cluster_metrics = get_metrics_fn("multiclass")(
175+
y_true,
176+
y_prob,
177+
metrics=["accuracy", "miscoverage_ps"],
178+
y_predset=extra["y_predset"],
179+
)
180+
181+
predset = extra["y_predset"]
182+
if isinstance(predset, np.ndarray):
183+
predset_t = torch.tensor(predset)
184+
else:
185+
predset_t = predset
186+
avg_set_size = predset_t.float().sum(dim=1).mean().item()
187+
188+
miscoverage = cluster_metrics["miscoverage_ps"]
189+
if isinstance(miscoverage, np.ndarray):
190+
miscoverage = float(miscoverage.item() if miscoverage.size == 1 else miscoverage.mean())
191+
else:
192+
miscoverage = float(miscoverage)
193+
194+
print("\nClusterLabel Results:")
195+
print(f" Accuracy: {cluster_metrics['accuracy']:.4f}")
196+
print(f" Empirical miscoverage: {miscoverage:.4f}")
197+
print(f" Empirical coverage: {1 - miscoverage:.4f}")
198+
print(f" Average set size: {avg_set_size:.2f}")
199+
print(f" Number of clusters: {args.n_clusters}")
200+
201+
202+
if __name__ == "__main__":
203+
main()
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Prediction set construction methods"""
22

33
from pyhealth.calib.predictionset.base_conformal import BaseConformal
4+
from pyhealth.calib.predictionset.cluster import ClusterLabel
45
from pyhealth.calib.predictionset.covariate import CovariateLabel
56
from pyhealth.calib.predictionset.favmac import FavMac
67
from pyhealth.calib.predictionset.label import LABEL
78
from pyhealth.calib.predictionset.scrib import SCRIB
89

9-
__all__ = ["BaseConformal", "LABEL", "SCRIB", "FavMac", "CovariateLabel"]
10+
__all__ = ["BaseConformal", "LABEL", "SCRIB", "FavMac", "CovariateLabel", "ClusterLabel"]
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Cluster-based prediction set methods"""
2+
3+
from pyhealth.calib.predictionset.cluster.cluster_label import ClusterLabel
4+
5+
__all__ = ["ClusterLabel"]

0 commit comments

Comments
 (0)