wsidrift 0.1.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.
Files changed (34) hide show
  1. wsidrift-0.1.0/PKG-INFO +52 -0
  2. wsidrift-0.1.0/README.md +25 -0
  3. wsidrift-0.1.0/pyproject.toml +43 -0
  4. wsidrift-0.1.0/setup.cfg +4 -0
  5. wsidrift-0.1.0/src/wsidrift/__init__.py +8 -0
  6. wsidrift-0.1.0/src/wsidrift/adapt/__init__.py +5 -0
  7. wsidrift-0.1.0/src/wsidrift/adapt/dann.py +72 -0
  8. wsidrift-0.1.0/src/wsidrift/adapt/grl.py +39 -0
  9. wsidrift-0.1.0/src/wsidrift/cli.py +55 -0
  10. wsidrift-0.1.0/src/wsidrift/cyclegan/__init__.py +5 -0
  11. wsidrift-0.1.0/src/wsidrift/cyclegan/generator.py +65 -0
  12. wsidrift-0.1.0/src/wsidrift/cyclegan/trainer.py +58 -0
  13. wsidrift-0.1.0/src/wsidrift/data/__init__.py +1 -0
  14. wsidrift-0.1.0/src/wsidrift/data/camelyon17_dataset.py +77 -0
  15. wsidrift-0.1.0/src/wsidrift/data/tcga_dataset.py +71 -0
  16. wsidrift-0.1.0/src/wsidrift/eval/__init__.py +5 -0
  17. wsidrift-0.1.0/src/wsidrift/eval/delta_gap.py +67 -0
  18. wsidrift-0.1.0/src/wsidrift/eval/stats.py +38 -0
  19. wsidrift-0.1.0/src/wsidrift/monitor/__init__.py +1 -0
  20. wsidrift-0.1.0/src/wsidrift/monitor/divergence.py +168 -0
  21. wsidrift-0.1.0/src/wsidrift/monitor/embedding.py +139 -0
  22. wsidrift-0.1.0/src/wsidrift/monitor/pipeline.py +82 -0
  23. wsidrift-0.1.0/src/wsidrift/monitor/validity_score.py +117 -0
  24. wsidrift-0.1.0/src/wsidrift/norm/__init__.py +5 -0
  25. wsidrift-0.1.0/src/wsidrift/norm/macenko.py +61 -0
  26. wsidrift-0.1.0/src/wsidrift/norm/reinhard.py +77 -0
  27. wsidrift-0.1.0/src/wsidrift.egg-info/PKG-INFO +52 -0
  28. wsidrift-0.1.0/src/wsidrift.egg-info/SOURCES.txt +32 -0
  29. wsidrift-0.1.0/src/wsidrift.egg-info/dependency_links.txt +1 -0
  30. wsidrift-0.1.0/src/wsidrift.egg-info/entry_points.txt +2 -0
  31. wsidrift-0.1.0/src/wsidrift.egg-info/requires.txt +12 -0
  32. wsidrift-0.1.0/src/wsidrift.egg-info/top_level.txt +1 -0
  33. wsidrift-0.1.0/tests/test_monitor.py +86 -0
  34. wsidrift-0.1.0/tests/test_pipeline.py +82 -0
@@ -0,0 +1,52 @@
1
+ Metadata-Version: 2.4
2
+ Name: wsidrift
3
+ Version: 0.1.0
4
+ Summary: Label-free validity monitor for computational pathology pipelines
5
+ Author-email: Abhishek Thakur <a.thakur5690@gmail.com>
6
+ License: MIT
7
+ Keywords: digital-pathology,drift-detection,model-monitoring,whole-slide-image,computational-pathology,domain-shift
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Intended Audience :: Healthcare Industry
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ Requires-Dist: torch>=2.0.0
16
+ Requires-Dist: torchvision>=0.15.0
17
+ Requires-Dist: pytorch-lightning>=2.0.0
18
+ Requires-Dist: numpy>=1.24.0
19
+ Requires-Dist: scipy>=1.10.0
20
+ Requires-Dist: scikit-learn>=1.3.0
21
+ Requires-Dist: pandas>=2.0.0
22
+ Requires-Dist: openslide-python>=1.3.0
23
+ Requires-Dist: opencv-python>=4.8.0
24
+ Requires-Dist: Pillow>=10.0.0
25
+ Requires-Dist: omegaconf>=2.3.0
26
+ Requires-Dist: tqdm>=4.65.0
27
+
28
+ # wsidrift
29
+
30
+ A label-free, model-agnostic validity monitor for deployed computational pathology pipelines.
31
+
32
+ Detects when a WSI processing pipeline has drifted from its validated reference — without requiring labels or retraining.
33
+
34
+ ## Architecture
35
+
36
+ ```
37
+ Input WSIs ──► Feature Extractor ──► Tier 1: Input Divergence (MMD/Wasserstein)
38
+ └──► Tier 2: Embedding Monitor (Mahalanobis/Energy)
39
+ │
40
+ Conjunction Rule ──► Validity Flag
41
+ ```
42
+
43
+ - **Tier 1** — covariate shift in stain/feature distributions (label-free, assumption-light)
44
+ - **Tier 2** — model-response drift in encoder embedding space (closer to correctness)
45
+ - **Flag** — raised only when *both* tiers move in the direction that historically predicted a large generalization gap (ΔG)
46
+
47
+ ## Status
48
+
49
+ Private research repository. Part of a three-paper dissertation.
50
+
51
+ ## License
52
+ MIT
@@ -0,0 +1,25 @@
1
+ # wsidrift
2
+
3
+ A label-free, model-agnostic validity monitor for deployed computational pathology pipelines.
4
+
5
+ Detects when a WSI processing pipeline has drifted from its validated reference — without requiring labels or retraining.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ Input WSIs ──► Feature Extractor ──► Tier 1: Input Divergence (MMD/Wasserstein)
11
+ └──► Tier 2: Embedding Monitor (Mahalanobis/Energy)
12
+ │
13
+ Conjunction Rule ──► Validity Flag
14
+ ```
15
+
16
+ - **Tier 1** — covariate shift in stain/feature distributions (label-free, assumption-light)
17
+ - **Tier 2** — model-response drift in encoder embedding space (closer to correctness)
18
+ - **Flag** — raised only when *both* tiers move in the direction that historically predicted a large generalization gap (ΔG)
19
+
20
+ ## Status
21
+
22
+ Private research repository. Part of a three-paper dissertation.
23
+
24
+ ## License
25
+ MIT
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "wsidrift"
7
+ version = "0.1.0"
8
+ description = "Label-free validity monitor for computational pathology pipelines"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ authors = [{name = "Abhishek Thakur", email = "a.thakur5690@gmail.com"}]
12
+ requires-python = ">=3.10"
13
+ keywords = [
14
+ "digital-pathology", "drift-detection", "model-monitoring",
15
+ "whole-slide-image", "computational-pathology", "domain-shift"
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "Intended Audience :: Healthcare Industry",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Topic :: Scientific/Engineering :: Medical Science Apps.",
23
+ ]
24
+ dependencies = [
25
+ "torch>=2.0.0",
26
+ "torchvision>=0.15.0",
27
+ "pytorch-lightning>=2.0.0",
28
+ "numpy>=1.24.0",
29
+ "scipy>=1.10.0",
30
+ "scikit-learn>=1.3.0",
31
+ "pandas>=2.0.0",
32
+ "openslide-python>=1.3.0",
33
+ "opencv-python>=4.8.0",
34
+ "Pillow>=10.0.0",
35
+ "omegaconf>=2.3.0",
36
+ "tqdm>=4.65.0",
37
+ ]
38
+
39
+ [project.scripts]
40
+ wsidrift = "wsidrift.cli:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """wsidrift: label-free validity monitor for computational pathology pipelines."""
2
+
3
+ from .monitor.divergence import Tier1Monitor
4
+ from .monitor.embedding import Tier2Monitor
5
+ from .monitor.validity_score import ValidityMonitor
6
+
7
+ __version__ = "0.1.0"
8
+ __all__ = ["Tier1Monitor", "Tier2Monitor", "ValidityMonitor"]
@@ -0,0 +1,5 @@
1
+ """Domain adaptation modules for wsidrift."""
2
+ from .grl import GradientReversalLayer, lambda_schedule
3
+ from .dann import DANNModel
4
+
5
+ __all__ = ["GradientReversalLayer", "lambda_schedule", "DANNModel"]
@@ -0,0 +1,72 @@
1
+ """DANN: Domain-Adversarial Neural Network for stain-shift adaptation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytorch_lightning as pl
6
+ import torch
7
+ import torch.nn as nn
8
+ import torch.nn.functional as F
9
+ from torchvision.models import resnet50
10
+
11
+ from .grl import GradientReversalLayer, lambda_schedule
12
+
13
+
14
+ class DANNModel(pl.LightningModule):
15
+ """ResNet-50 backbone with task and domain classification heads.
16
+
17
+ Args:
18
+ n_classes: Number of task classes.
19
+ n_domains: Number of source domains (institutions / centers).
20
+ lr: Learning rate.
21
+ gamma: GRL lambda schedule steepness.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ n_classes: int = 2,
27
+ n_domains: int = 5,
28
+ lr: float = 1e-4,
29
+ gamma: float = 10.0,
30
+ ) -> None:
31
+ super().__init__()
32
+ self.save_hyperparameters()
33
+ backbone = resnet50(weights=None)
34
+ feat_dim = backbone.fc.in_features
35
+ backbone.fc = nn.Identity()
36
+ self.backbone = backbone
37
+ self.task_head = nn.Sequential(
38
+ nn.Linear(feat_dim, 256), nn.ReLU(), nn.Linear(256, n_classes)
39
+ )
40
+ self.domain_head = nn.Sequential(
41
+ nn.Linear(feat_dim, 256), nn.ReLU(), nn.Linear(256, n_domains)
42
+ )
43
+ self.grl = GradientReversalLayer()
44
+
45
+ def forward(self, x: torch.Tensor):
46
+ return self.task_head(self.backbone(x))
47
+
48
+ def get_embeddings(self, x: torch.Tensor) -> torch.Tensor:
49
+ return self.backbone(x)
50
+
51
+ def _step(self, batch, stage: str):
52
+ imgs, labels, meta = batch
53
+ p = self.global_step / max(self.trainer.max_steps, 1)
54
+ self.grl.lam = lambda_schedule(p, self.hparams.gamma)
55
+ feats = self.backbone(imgs)
56
+ task_loss = F.cross_entropy(self.task_head(feats), labels)
57
+ domain_labels = meta.get("domain_idx", torch.zeros_like(labels))
58
+ domain_loss = F.cross_entropy(self.domain_head(self.grl(feats)), domain_labels)
59
+ loss = task_loss + domain_loss
60
+ self.log(f"{stage}/task_loss", task_loss, prog_bar=True)
61
+ self.log(f"{stage}/domain_loss", domain_loss)
62
+ self.log(f"{stage}/loss", loss)
63
+ return loss
64
+
65
+ def training_step(self, batch, _):
66
+ return self._step(batch, "train")
67
+
68
+ def validation_step(self, batch, _):
69
+ self._step(batch, "val")
70
+
71
+ def configure_optimizers(self):
72
+ return torch.optim.Adam(self.parameters(), lr=self.hparams.lr)
@@ -0,0 +1,39 @@
1
+ """Gradient Reversal Layer for domain-adversarial training."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+ from torch.autograd import Function
8
+
9
+
10
+ class _GRLFunction(Function):
11
+ @staticmethod
12
+ def forward(ctx, x, lam):
13
+ ctx.save_for_backward(torch.tensor(lam))
14
+ return x.clone()
15
+
16
+ @staticmethod
17
+ def backward(ctx, grad_output):
18
+ (lam,) = ctx.saved_tensors
19
+ return -lam * grad_output, None
20
+
21
+
22
+ class GradientReversalLayer(nn.Module):
23
+ """Apply gradient reversal with a scalar lambda."""
24
+
25
+ def __init__(self, lam: float = 1.0) -> None:
26
+ super().__init__()
27
+ self.lam = lam
28
+
29
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
30
+ return _GRLFunction.apply(x, self.lam)
31
+
32
+
33
+ def lambda_schedule(p: float, gamma: float = 10.0) -> float:
34
+ """Anneal lambda from 0 → 1 as training fraction p goes 0 → 1.
35
+
36
+ Schedule from Ganin et al. (JMLR 2016): 2/(1+exp(−γp))−1.
37
+ """
38
+ import math
39
+ return 2.0 / (1.0 + math.exp(-gamma * p)) - 1.0
@@ -0,0 +1,55 @@
1
+ """CLI entrypoint for wsidrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+
9
+
10
+ def main(argv: list[str] | None = None) -> None:
11
+ parser = argparse.ArgumentParser(
12
+ prog="wsidrift",
13
+ description="Label-free validity monitor for computational pathology pipelines.",
14
+ )
15
+ sub = parser.add_subparsers(dest="command")
16
+
17
+ # wsidrift score
18
+ score_p = sub.add_parser("score", help="Score a deployment batch against a reference")
19
+ score_p.add_argument("--reference", required=True, help="Reference features .npy or .csv")
20
+ score_p.add_argument("--deployment", required=True, help="Deployment features .npy or .csv")
21
+ score_p.add_argument("--output", default=None, help="Save result JSON to this path")
22
+ score_p.add_argument("--mmd-threshold", type=float, default=0.05)
23
+ score_p.add_argument("--wass-threshold", type=float, default=0.10)
24
+
25
+ args = parser.parse_args(argv)
26
+
27
+ if args.command == "score":
28
+ import numpy as np
29
+ from wsidrift.monitor.divergence import Tier1Monitor
30
+ from wsidrift.monitor.validity_score import ValidityMonitor
31
+
32
+ X_ref = np.load(args.reference) if args.reference.endswith(".npy") else \
33
+ __import__("pandas").read_csv(args.reference).values
34
+ X_dep = np.load(args.deployment) if args.deployment.endswith(".npy") else \
35
+ __import__("pandas").read_csv(args.deployment).values
36
+
37
+ tier1 = Tier1Monitor(
38
+ mmd_threshold=args.mmd_threshold,
39
+ wass_threshold=args.wass_threshold,
40
+ ).fit(X_ref)
41
+ monitor = ValidityMonitor(tier1)
42
+ result = monitor.score(X_dep)
43
+ print(result.summary_line())
44
+
45
+ if args.output:
46
+ with open(args.output, "w") as f:
47
+ json.dump(result.to_dict(), f, indent=2)
48
+ print(f"Result saved to {args.output}")
49
+ else:
50
+ parser.print_help()
51
+ sys.exit(1)
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,5 @@
1
+ """CycleGAN stain transfer stub for wsidrift E4/E7/E9 conditions."""
2
+ from .generator import ResNetGenerator
3
+ from .trainer import CycleGANTrainer
4
+
5
+ __all__ = ["ResNetGenerator", "CycleGANTrainer"]
@@ -0,0 +1,65 @@
1
+ """ResNet-based generator for CycleGAN stain transfer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn as nn
7
+
8
+
9
+ class ResidualBlock(nn.Module):
10
+ def __init__(self, dim: int) -> None:
11
+ super().__init__()
12
+ self.block = nn.Sequential(
13
+ nn.ReflectionPad2d(1),
14
+ nn.Conv2d(dim, dim, 3),
15
+ nn.InstanceNorm2d(dim),
16
+ nn.ReLU(inplace=True),
17
+ nn.ReflectionPad2d(1),
18
+ nn.Conv2d(dim, dim, 3),
19
+ nn.InstanceNorm2d(dim),
20
+ )
21
+
22
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
23
+ return x + self.block(x)
24
+
25
+
26
+ class ResNetGenerator(nn.Module):
27
+ """9-block ResNet generator for 256×256 image translation.
28
+
29
+ Args:
30
+ n_residual: Number of residual blocks (6 for 128px, 9 for 256px).
31
+ ngf: Base number of generator filters.
32
+ """
33
+
34
+ def __init__(self, n_residual: int = 9, ngf: int = 64) -> None:
35
+ super().__init__()
36
+ layers: list[nn.Module] = [
37
+ nn.ReflectionPad2d(3),
38
+ nn.Conv2d(3, ngf, 7),
39
+ nn.InstanceNorm2d(ngf),
40
+ nn.ReLU(inplace=True),
41
+ ]
42
+ # downsampling
43
+ for mult in [1, 2]:
44
+ layers += [
45
+ nn.Conv2d(ngf * mult, ngf * mult * 2, 3, stride=2, padding=1),
46
+ nn.InstanceNorm2d(ngf * mult * 2),
47
+ nn.ReLU(inplace=True),
48
+ ]
49
+ # residual blocks
50
+ for _ in range(n_residual):
51
+ layers.append(ResidualBlock(ngf * 4))
52
+ # upsampling
53
+ for mult in [2, 1]:
54
+ layers += [
55
+ nn.ConvTranspose2d(
56
+ ngf * mult * 2, ngf * mult, 3, stride=2, padding=1, output_padding=1
57
+ ),
58
+ nn.InstanceNorm2d(ngf * mult),
59
+ nn.ReLU(inplace=True),
60
+ ]
61
+ layers += [nn.ReflectionPad2d(3), nn.Conv2d(ngf, 3, 7), nn.Tanh()]
62
+ self.model = nn.Sequential(*layers)
63
+
64
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
65
+ return self.model(x)
@@ -0,0 +1,58 @@
1
+ """CycleGAN training harness (stub — full training loop in scripts/train_cyclegan.py)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ import torch
9
+ import torch.nn as nn
10
+
11
+ from .generator import ResNetGenerator
12
+
13
+
14
+ @dataclass
15
+ class CycleGANConfig:
16
+ source_dir: str = ""
17
+ target_dir: str = ""
18
+ checkpoint_dir: str = "checkpoints/cyclegan"
19
+ n_epochs: int = 200
20
+ batch_size: int = 4
21
+ lr: float = 2e-4
22
+ lambda_cycle: float = 10.0
23
+ lambda_identity: float = 5.0
24
+ n_residual: int = 9
25
+
26
+
27
+ class CycleGANTrainer:
28
+ """Minimal wrapper to load/save CycleGAN generators and apply inference.
29
+
30
+ Full adversarial training is handled by scripts/train_cyclegan.py.
31
+ This class handles checkpoint management and inference for E4/E7/E9.
32
+ """
33
+
34
+ def __init__(self, config: CycleGANConfig | None = None) -> None:
35
+ self.config = config or CycleGANConfig()
36
+ self.G_AB = ResNetGenerator(n_residual=self.config.n_residual)
37
+ self.G_BA = ResNetGenerator(n_residual=self.config.n_residual)
38
+
39
+ def save(self, epoch: int) -> None:
40
+ outdir = Path(self.config.checkpoint_dir)
41
+ outdir.mkdir(parents=True, exist_ok=True)
42
+ torch.save(self.G_AB.state_dict(), outdir / f"G_AB_epoch{epoch:03d}.pt")
43
+ torch.save(self.G_BA.state_dict(), outdir / f"G_BA_epoch{epoch:03d}.pt")
44
+
45
+ def load(self, checkpoint_dir: str, epoch: int) -> None:
46
+ d = Path(checkpoint_dir)
47
+ self.G_AB.load_state_dict(torch.load(d / f"G_AB_epoch{epoch:03d}.pt", map_location="cpu"))
48
+ self.G_BA.load_state_dict(torch.load(d / f"G_BA_epoch{epoch:03d}.pt", map_location="cpu"))
49
+
50
+ @torch.no_grad()
51
+ def translate_AtoB(self, x: torch.Tensor) -> torch.Tensor:
52
+ self.G_AB.eval()
53
+ return self.G_AB(x)
54
+
55
+ @torch.no_grad()
56
+ def translate_BtoA(self, x: torch.Tensor) -> torch.Tensor:
57
+ self.G_BA.eval()
58
+ return self.G_BA(x)
@@ -0,0 +1 @@
1
+ """Dataset classes for TCGA and CAMELYON17."""
@@ -0,0 +1,77 @@
1
+ """CAMELYON17 patch dataset for wsidrift experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import torch
10
+ from torch.utils.data import Dataset
11
+ from torchvision import transforms as T
12
+ from PIL import Image
13
+
14
+
15
+ _DEFAULT_TRANSFORM = T.Compose([
16
+ T.Resize(256),
17
+ T.CenterCrop(224),
18
+ T.ToTensor(),
19
+ T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
20
+ ])
21
+
22
+ # Map stages.csv slide-level labels to binary: negative=0, any metastasis=1
23
+ _BINARY_MAP = {"negative": 0, "itc": 1, "micro": 1, "macro": 1}
24
+
25
+
26
+ class CAMELYON17PatchDataset(Dataset):
27
+ """Patch-level dataset for CAMELYON17 loaded from a manifest CSV.
28
+
29
+ Expected manifest columns: path, slide_label, center, patient_id, split.
30
+ slide_label is the raw stages.csv value (negative/itc/micro/macro).
31
+
32
+ Args:
33
+ manifest_path: Path to manifest CSV.
34
+ split: 'train', 'val', 'test', or None.
35
+ center: Filter to a specific center (0–4).
36
+ transform: torchvision transform.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ manifest_path: str | Path,
42
+ split: str | None = None,
43
+ center: int | None = None,
44
+ transform=None,
45
+ ) -> None:
46
+ df = pd.read_csv(manifest_path)
47
+ if split is not None:
48
+ df = df[df["split"] == split]
49
+ if center is not None:
50
+ df = df[df["center"] == center]
51
+ df = df.reset_index(drop=True)
52
+ df["label"] = df["slide_label"].map(_BINARY_MAP).fillna(0).astype(int)
53
+ self.df = df
54
+ self.transform = transform or _DEFAULT_TRANSFORM
55
+
56
+ def __len__(self) -> int:
57
+ return len(self.df)
58
+
59
+ def __getitem__(self, idx: int):
60
+ row = self.df.iloc[idx]
61
+ img = Image.open(row["path"]).convert("RGB")
62
+ img = self.transform(img)
63
+ label = int(row["label"])
64
+ meta = {
65
+ "center": int(row["center"]),
66
+ "patient_id": str(row.get("patient_id", "")),
67
+ "slide_label": str(row["slide_label"]),
68
+ }
69
+ return img, label, meta
70
+
71
+ @property
72
+ def labels(self) -> np.ndarray:
73
+ return self.df["label"].to_numpy()
74
+
75
+ @property
76
+ def centers(self) -> list[int]:
77
+ return self.df["center"].tolist()
@@ -0,0 +1,71 @@
1
+ """TCGA patch dataset for wsidrift experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ import torch
10
+ from torch.utils.data import Dataset
11
+ from torchvision import transforms as T
12
+ from PIL import Image
13
+
14
+
15
+ _DEFAULT_TRANSFORM = T.Compose([
16
+ T.Resize(256),
17
+ T.CenterCrop(224),
18
+ T.ToTensor(),
19
+ T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
20
+ ])
21
+
22
+
23
+ class TCGAPatchDataset(Dataset):
24
+ """Patch-level dataset loaded from a manifest CSV.
25
+
26
+ Expected manifest columns: path, label, slide_id, tss, cohort, split.
27
+
28
+ Args:
29
+ manifest_path: Path to manifest CSV.
30
+ split: One of 'train', 'val', 'test', or None (all).
31
+ cohort: Filter to a specific cohort (e.g. 'BRCA').
32
+ transform: torchvision transform; defaults to ImageNet normalization.
33
+ """
34
+
35
+ def __init__(
36
+ self,
37
+ manifest_path: str | Path,
38
+ split: str | None = None,
39
+ cohort: str | None = None,
40
+ transform=None,
41
+ ) -> None:
42
+ df = pd.read_csv(manifest_path)
43
+ if split is not None:
44
+ df = df[df["split"] == split]
45
+ if cohort is not None:
46
+ df = df[df["cohort"] == cohort]
47
+ self.df = df.reset_index(drop=True)
48
+ self.transform = transform or _DEFAULT_TRANSFORM
49
+
50
+ def __len__(self) -> int:
51
+ return len(self.df)
52
+
53
+ def __getitem__(self, idx: int):
54
+ row = self.df.iloc[idx]
55
+ img = Image.open(row["path"]).convert("RGB")
56
+ img = self.transform(img)
57
+ label = int(row["label"])
58
+ meta = {
59
+ "slide_id": row["slide_id"],
60
+ "tss": str(row.get("tss", "")),
61
+ "cohort": str(row.get("cohort", "")),
62
+ }
63
+ return img, label, meta
64
+
65
+ @property
66
+ def labels(self) -> np.ndarray:
67
+ return self.df["label"].to_numpy()
68
+
69
+ @property
70
+ def tss_codes(self) -> list[str]:
71
+ return self.df["tss"].tolist()
@@ -0,0 +1,5 @@
1
+ """Evaluation utilities: ΔG, AUC, and statistical tests."""
2
+ from .delta_gap import DeltaGap, DeltaGapResult
3
+ from .stats import paired_ttest, wilcoxon_test, bonferroni_correct
4
+
5
+ __all__ = ["DeltaGap", "DeltaGapResult", "paired_ttest", "wilcoxon_test", "bonferroni_correct"]
@@ -0,0 +1,67 @@
1
+ """ΔG computation across E1-E9 conditions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+ from sklearn.metrics import roc_auc_score
9
+
10
+
11
+ @dataclass
12
+ class DeltaGapResult:
13
+ condition: str
14
+ cohort: str
15
+ auc_in_mean: float
16
+ auc_in_std: float
17
+ auc_cross_mean: float
18
+ auc_cross_std: float
19
+ delta_gap_mean: float
20
+ delta_gap_std: float
21
+
22
+ def __str__(self) -> str:
23
+ return (
24
+ f"{self.condition} [{self.cohort}]: "
25
+ f"ΔG = {self.delta_gap_mean:+.4f} ± {self.delta_gap_std:.4f} "
26
+ f"(in={self.auc_in_mean:.4f}, cross={self.auc_cross_mean:.4f})"
27
+ )
28
+
29
+
30
+ class DeltaGap:
31
+ """Compute ΔG = AUC_in − AUC_cross across multiple seeds.
32
+
33
+ Args:
34
+ condition: Condition name (e.g. 'E1').
35
+ cohort: Cohort name (e.g. 'BRCA').
36
+ """
37
+
38
+ def __init__(self, condition: str, cohort: str) -> None:
39
+ self.condition = condition
40
+ self.cohort = cohort
41
+ self._in_auc: list[float] = []
42
+ self._cross_auc: list[float] = []
43
+
44
+ def add_seed(
45
+ self,
46
+ y_true_in: np.ndarray,
47
+ y_score_in: np.ndarray,
48
+ y_true_cross: np.ndarray,
49
+ y_score_cross: np.ndarray,
50
+ ) -> None:
51
+ self._in_auc.append(float(roc_auc_score(y_true_in, y_score_in)))
52
+ self._cross_auc.append(float(roc_auc_score(y_true_cross, y_score_cross)))
53
+
54
+ def compute(self) -> DeltaGapResult:
55
+ if not self._in_auc:
56
+ raise ValueError("No seeds added — call add_seed() first.")
57
+ dg = [i - c for i, c in zip(self._in_auc, self._cross_auc)]
58
+ return DeltaGapResult(
59
+ condition=self.condition,
60
+ cohort=self.cohort,
61
+ auc_in_mean=float(np.mean(self._in_auc)),
62
+ auc_in_std=float(np.std(self._in_auc)),
63
+ auc_cross_mean=float(np.mean(self._cross_auc)),
64
+ auc_cross_std=float(np.std(self._cross_auc)),
65
+ delta_gap_mean=float(np.mean(dg)),
66
+ delta_gap_std=float(np.std(dg)),
67
+ )