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.
- wsidrift-0.1.0/PKG-INFO +52 -0
- wsidrift-0.1.0/README.md +25 -0
- wsidrift-0.1.0/pyproject.toml +43 -0
- wsidrift-0.1.0/setup.cfg +4 -0
- wsidrift-0.1.0/src/wsidrift/__init__.py +8 -0
- wsidrift-0.1.0/src/wsidrift/adapt/__init__.py +5 -0
- wsidrift-0.1.0/src/wsidrift/adapt/dann.py +72 -0
- wsidrift-0.1.0/src/wsidrift/adapt/grl.py +39 -0
- wsidrift-0.1.0/src/wsidrift/cli.py +55 -0
- wsidrift-0.1.0/src/wsidrift/cyclegan/__init__.py +5 -0
- wsidrift-0.1.0/src/wsidrift/cyclegan/generator.py +65 -0
- wsidrift-0.1.0/src/wsidrift/cyclegan/trainer.py +58 -0
- wsidrift-0.1.0/src/wsidrift/data/__init__.py +1 -0
- wsidrift-0.1.0/src/wsidrift/data/camelyon17_dataset.py +77 -0
- wsidrift-0.1.0/src/wsidrift/data/tcga_dataset.py +71 -0
- wsidrift-0.1.0/src/wsidrift/eval/__init__.py +5 -0
- wsidrift-0.1.0/src/wsidrift/eval/delta_gap.py +67 -0
- wsidrift-0.1.0/src/wsidrift/eval/stats.py +38 -0
- wsidrift-0.1.0/src/wsidrift/monitor/__init__.py +1 -0
- wsidrift-0.1.0/src/wsidrift/monitor/divergence.py +168 -0
- wsidrift-0.1.0/src/wsidrift/monitor/embedding.py +139 -0
- wsidrift-0.1.0/src/wsidrift/monitor/pipeline.py +82 -0
- wsidrift-0.1.0/src/wsidrift/monitor/validity_score.py +117 -0
- wsidrift-0.1.0/src/wsidrift/norm/__init__.py +5 -0
- wsidrift-0.1.0/src/wsidrift/norm/macenko.py +61 -0
- wsidrift-0.1.0/src/wsidrift/norm/reinhard.py +77 -0
- wsidrift-0.1.0/src/wsidrift.egg-info/PKG-INFO +52 -0
- wsidrift-0.1.0/src/wsidrift.egg-info/SOURCES.txt +32 -0
- wsidrift-0.1.0/src/wsidrift.egg-info/dependency_links.txt +1 -0
- wsidrift-0.1.0/src/wsidrift.egg-info/entry_points.txt +2 -0
- wsidrift-0.1.0/src/wsidrift.egg-info/requires.txt +12 -0
- wsidrift-0.1.0/src/wsidrift.egg-info/top_level.txt +1 -0
- wsidrift-0.1.0/tests/test_monitor.py +86 -0
- wsidrift-0.1.0/tests/test_pipeline.py +82 -0
wsidrift-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
wsidrift-0.1.0/README.md
ADDED
|
@@ -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"]
|
wsidrift-0.1.0/setup.cfg
ADDED
|
@@ -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,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,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
|
+
)
|