duyle2408 commited on
Commit
f68983e
·
verified ·
1 Parent(s): 41018f5

Upload 26 files

Browse files
Files changed (26) hide show
  1. milk10k_new_collapse_research/README.md +120 -0
  2. milk10k_new_collapse_research/__init__.py +2 -0
  3. milk10k_new_collapse_research/__pycache__/__init__.cpython-310.pyc +0 -0
  4. milk10k_new_collapse_research/__pycache__/compat.cpython-310.pyc +0 -0
  5. milk10k_new_collapse_research/__pycache__/config.cpython-310.pyc +0 -0
  6. milk10k_new_collapse_research/__pycache__/features.cpython-310.pyc +0 -0
  7. milk10k_new_collapse_research/__pycache__/hierarchy.cpython-310.pyc +0 -0
  8. milk10k_new_collapse_research/__pycache__/metrics_ext.cpython-310.pyc +0 -0
  9. milk10k_new_collapse_research/compat.py +15 -0
  10. milk10k_new_collapse_research/config.py +36 -0
  11. milk10k_new_collapse_research/features.py +129 -0
  12. milk10k_new_collapse_research/hierarchy.py +78 -0
  13. milk10k_new_collapse_research/metrics_ext.py +126 -0
  14. milk10k_new_collapse_research/run_new_ideas.sh +179 -0
  15. milk10k_new_collapse_research/scripts/__init__.py +2 -0
  16. milk10k_new_collapse_research/scripts/__pycache__/__init__.cpython-310.pyc +0 -0
  17. milk10k_new_collapse_research/scripts/__pycache__/analyze_decision_policy.cpython-310.pyc +0 -0
  18. milk10k_new_collapse_research/scripts/__pycache__/extract_foundation_features.cpython-310.pyc +0 -0
  19. milk10k_new_collapse_research/scripts/__pycache__/linear_probe.cpython-310.pyc +0 -0
  20. milk10k_new_collapse_research/scripts/__pycache__/train_hierarchical.cpython-310.pyc +0 -0
  21. milk10k_new_collapse_research/scripts/__pycache__/train_multimodal_ssl.cpython-310.pyc +0 -0
  22. milk10k_new_collapse_research/scripts/analyze_decision_policy.py +106 -0
  23. milk10k_new_collapse_research/scripts/extract_foundation_features.py +77 -0
  24. milk10k_new_collapse_research/scripts/linear_probe.py +110 -0
  25. milk10k_new_collapse_research/scripts/train_hierarchical.py +114 -0
  26. milk10k_new_collapse_research/scripts/train_multimodal_ssl.py +222 -0
milk10k_new_collapse_research/README.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MILK10k Collapse Research
2
+
3
+ ## Why this folder exists
4
+
5
+ The main training pipeline (`milk10k_effb2_metadata`) does full end-to-end dual-encoder training —
6
+ hours per run, heavy GPU, lots of hyperparameters. That's fine for final experiments, but terrible
7
+ for iterating on new ideas.
8
+
9
+ This folder is a **lightweight research sandbox**. The idea is:
10
+
11
+ 1. Extract frozen features from a foundation model once (DINOv2, ConvNeXt, etc.)
12
+ 2. Train cheap sklearn probes (minutes, CPU) on those features
13
+ 3. Test collapse-mitigation ideas fast without re-training the encoder
14
+
15
+ Results go to `results/new_collapse_research/` and never interfere with the controlled
16
+ dual-encoder runs.
17
+
18
+ ## The problem: class collapse
19
+
20
+ MILK10k has a severe long-tail distribution:
21
+
22
+ - Head: NV, MEL, BCC, BKL
23
+ - Tail: **BEN_OTH**, **INF**, **MAL_OTH** (called "tail gate" classes)
24
+
25
+ The tail classes get crushed — the model rarely predicts them. This manifests as low
26
+ recall on BEN_OTH/INF/MAL_OTH even when overall metrics look fine.
27
+
28
+ The `BASELINE_F1_MACRO = 0.5828` is the reference point to beat.
29
+
30
+ ## The pipeline
31
+
32
+ ```
33
+ extract_foundation_features.py # Step 1: freeze a backbone, dump .npz features
34
+ │
35
+ ├── linear_probe.py # Quick baseline: logistic/MLP on frozen features
36
+ ├── train_hierarchical.py # Two-stage: group → within-group expert
37
+ ├── train_multimodal_ssl.py # Contrastive pretrain on pairs, then linear eval
38
+ │
39
+ analyze_decision_policy.py # Post-hoc: optimize tail-class logit biases
40
+ ```
41
+
42
+ ### Feature extraction (`extract_foundation_features.py`)
43
+
44
+ Runs a frozen model (DINOv2, or any timm model) on all clinical/dermoscopic pairs and
45
+ saves features as `.npz`. Features are L2-normalized by default.
46
+
47
+ Output for each split: `{train,val}_features.npz` containing `clinical`, `dermoscopic`,
48
+ `lesion_id`, and `label` arrays.
49
+
50
+ ### Linear probe (`linear_probe.py`)
51
+
52
+ Trains a `LogisticRegression` or `MLPClassifier` on frozen features. Supports three
53
+ feature modes:
54
+
55
+ - `pair` — concatenates `[clinical, dermoscopic, |clinical-dermoscopic|, clinical×dermoscopic]`
56
+ - `clinical` — clinical image features only
57
+ - `dermoscopic` — dermoscopic image features only
58
+
59
+ Useful for: establishing a feature-quality baseline, comparing backbones, testing
60
+ whether the frozen DINOv2 features already contain useful signal.
61
+
62
+ ### Hierarchical probe (`train_hierarchical.py`)
63
+
64
+ Two-stage classification:
65
+
66
+ 1. **Group classifier** — predicts one of three groups:
67
+ - `melanocytic` (MEL, NV)
68
+ - `keratinocyte_like` (AKIEC, BCC, BKL, SCCKA)
69
+ - `rare_other` (BEN_OTH, INF, MAL_OTH, DF, VASC)
70
+ 2. **Per-group expert** — predicts the fine class within the predicted group
71
+
72
+ The final probability = P(group) × P(class | group), renormalized.
73
+
74
+ Useful for: testing whether partitioning the problem reduces tail-class confusion.
75
+ A rare-class like BEN_OTH only competes within `rare_other` instead of all 11 classes.
76
+
77
+ ### Multimodal SSL (`train_multimodal_ssl.py`)
78
+
79
+ Contrastively pretrains an encoder on clinical-dermoscopic **pairs** (positive pairs
80
+ are clinical and dermoscopic images of the same lesion) using a symmetric CLIP loss.
81
+ After pretraining, evaluates via linear probe on frozen encoder features.
82
+
83
+ Useful for: learning representations that are invariant to the clinical/dermoscopic
84
+ modality shift, potentially improving downstream classification.
85
+
86
+ ### Decision policy (`analyze_decision_policy.py`)
87
+
88
+ Post-hoc optimization: searches for logit biases on tail classes
89
+ (BEN_OTH, INF, MAL_OTH) that maximize macro F1 on the validation set.
90
+
91
+ Useful for: squeezing the last bit of performance from an already-trained model
92
+ without re-training. The `--tail-only` flag restricts optimization to just the three
93
+ tail gate classes.
94
+
95
+ ## How to run
96
+
97
+ ```bash
98
+ # Activate your environment first
99
+ conda activate ml2
100
+
101
+ # Full pipeline (features → probes → SSL → policy)
102
+ DATA_DIR=/path/to/milk10k bash milk10k_new_collapse_research/run_new_ideas.sh
103
+
104
+ # Only feature extraction with a different backbone
105
+ MODEL_NAME=dinov2_vits14 bash milk10k_new_collapse_research/run_new_ideas.sh
106
+
107
+ # Only decision policy on an existing prediction CSV
108
+ PREDICTIONS_CSV=/path/to/val_predictions.csv bash milk10k_new_collapse_research/run_new_ideas.sh
109
+ ```
110
+
111
+ Each step skips if its outputs already exist, so you can rerun selectively.
112
+
113
+ ## Key metrics file
114
+
115
+ Every script writes to `results/new_collapse_research/{experiment}/`:
116
+
117
+ - `metrics.json` — overall metrics (f1_macro, balanced_accuracy, etc.)
118
+ - `per_class_metrics.csv` — per-class recall, precision, f1
119
+ - `confusion_matrix.csv` — full 11×11 confusion matrix
120
+ - `collapse_report.md` — human-readable report focused on tail-gate classes
milk10k_new_collapse_research/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """Experimental MILK10k collapse research tools."""
2
+
milk10k_new_collapse_research/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (208 Bytes). View file
 
milk10k_new_collapse_research/__pycache__/compat.cpython-310.pyc ADDED
Binary file (695 Bytes). View file
 
milk10k_new_collapse_research/__pycache__/config.cpython-310.pyc ADDED
Binary file (1.08 kB). View file
 
milk10k_new_collapse_research/__pycache__/features.cpython-310.pyc ADDED
Binary file (5.14 kB). View file
 
milk10k_new_collapse_research/__pycache__/hierarchy.cpython-310.pyc ADDED
Binary file (4.3 kB). View file
 
milk10k_new_collapse_research/__pycache__/metrics_ext.cpython-310.pyc ADDED
Binary file (5.98 kB). View file
 
milk10k_new_collapse_research/compat.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compatibility helpers for reusing the existing local MILK10k package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+
9
+ def ensure_legacy_package_path() -> None:
10
+ repo_root = Path(__file__).resolve().parents[1]
11
+ legacy_root = repo_root / "milk10k_effb2_metadata"
12
+ legacy_path = str(legacy_root)
13
+ if legacy_root.is_dir() and legacy_path not in sys.path:
14
+ sys.path.insert(0, legacy_path)
15
+
milk10k_new_collapse_research/config.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared defaults for experimental MILK10k collapse research."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+
8
+ CLASS_NAMES = [
9
+ "AKIEC",
10
+ "BCC",
11
+ "BEN_OTH",
12
+ "BKL",
13
+ "DF",
14
+ "INF",
15
+ "MAL_OTH",
16
+ "MEL",
17
+ "NV",
18
+ "SCCKA",
19
+ "VASC",
20
+ ]
21
+
22
+ DEFAULT_HIERARCHY = {
23
+ "melanocytic": ["MEL", "NV"],
24
+ "keratinocyte_like": ["AKIEC", "BCC", "BKL", "SCCKA"],
25
+ "rare_other": ["BEN_OTH", "INF", "MAL_OTH", "DF", "VASC"],
26
+ }
27
+
28
+ TAIL_GATE_CLASSES = ["BEN_OTH", "INF", "MAL_OTH"]
29
+ RESULTS_ROOT = Path("results/new_collapse_research")
30
+ BASELINE_F1_MACRO = 0.5828
31
+
32
+
33
+ def class_to_idx(class_names: list[str] | None = None) -> dict[str, int]:
34
+ names = class_names or CLASS_NAMES
35
+ return {name: idx for idx, name in enumerate(names)}
36
+
milk10k_new_collapse_research/features.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frozen feature extraction utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import torch
10
+ import torch.nn.functional as F
11
+ from PIL import Image
12
+ from timm.data import create_transform, resolve_data_config
13
+ from torch.utils.data import DataLoader, Dataset
14
+
15
+
16
+ DINOV2_MODELS = {"dinov2_vits14", "dinov2_vitb14", "dinov2_vitl14", "dinov2_vitg14"}
17
+
18
+
19
+ @dataclass
20
+ class FeatureBatch:
21
+ lesion_ids: list[str]
22
+ labels: list[str]
23
+ clinical: np.ndarray
24
+ dermoscopic: np.ndarray
25
+
26
+
27
+ class PairImageDataset(Dataset):
28
+ def __init__(self, df, transform) -> None:
29
+ self.df = df.reset_index(drop=True)
30
+ self.transform = transform
31
+
32
+ def __len__(self) -> int:
33
+ return len(self.df)
34
+
35
+ def _load(self, path: str) -> torch.Tensor:
36
+ with Image.open(path) as image:
37
+ image = image.convert("RGB")
38
+ return self.transform(image)
39
+
40
+ def __getitem__(self, idx: int) -> dict[str, object]:
41
+ row = self.df.iloc[idx]
42
+ return {
43
+ "lesion_id": str(row["lesion_id"]),
44
+ "label": str(row["label"]),
45
+ "clinical": self._load(row["clinical_path"]),
46
+ "dermoscopic": self._load(row["dermoscopic_path"]),
47
+ }
48
+
49
+
50
+ def build_feature_model(model_name: str, device: torch.device, pretrained: bool = True):
51
+ if model_name in DINOV2_MODELS:
52
+ if not pretrained:
53
+ raise ValueError("DINOv2 torch.hub models require pretrained weights; use a timm model for --no-pretrained smoke tests.")
54
+ model = torch.hub.load("facebookresearch/dinov2", model_name)
55
+ image_size = 224
56
+ transform = dinov2_transform(image_size)
57
+ else:
58
+ import timm
59
+
60
+ model = timm.create_model(model_name, pretrained=pretrained, num_classes=0, global_pool="avg")
61
+ cfg = resolve_data_config({}, model=model)
62
+ image_size = int(cfg.get("input_size", (3, 224, 224))[-1])
63
+ transform = create_transform(**cfg, is_training=False)
64
+ model.to(device).eval()
65
+ return model, transform, image_size
66
+
67
+
68
+ def dinov2_transform(image_size: int):
69
+ from torchvision import transforms
70
+
71
+ return transforms.Compose(
72
+ [
73
+ transforms.Resize(int(image_size * 1.15), interpolation=transforms.InterpolationMode.BICUBIC),
74
+ transforms.CenterCrop(image_size),
75
+ transforms.ToTensor(),
76
+ transforms.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),
77
+ ]
78
+ )
79
+
80
+
81
+ @torch.no_grad()
82
+ def extract_pair_features(model, loader: DataLoader, device: torch.device, l2_normalize: bool = True) -> FeatureBatch:
83
+ lesion_ids: list[str] = []
84
+ labels: list[str] = []
85
+ clinical_features: list[np.ndarray] = []
86
+ dermoscopic_features: list[np.ndarray] = []
87
+ for batch in loader:
88
+ clinical = batch["clinical"].to(device, non_blocking=True)
89
+ dermoscopic = batch["dermoscopic"].to(device, non_blocking=True)
90
+ clinical_z = model(clinical)
91
+ dermoscopic_z = model(dermoscopic)
92
+ if l2_normalize:
93
+ clinical_z = F.normalize(clinical_z, dim=1)
94
+ dermoscopic_z = F.normalize(dermoscopic_z, dim=1)
95
+ clinical_features.append(clinical_z.cpu().numpy())
96
+ dermoscopic_features.append(dermoscopic_z.cpu().numpy())
97
+ lesion_ids.extend(str(item) for item in batch["lesion_id"])
98
+ labels.extend(str(item) for item in batch["label"])
99
+ return FeatureBatch(
100
+ lesion_ids=lesion_ids,
101
+ labels=labels,
102
+ clinical=np.concatenate(clinical_features, axis=0),
103
+ dermoscopic=np.concatenate(dermoscopic_features, axis=0),
104
+ )
105
+
106
+
107
+ def save_feature_npz(path: Path, batch: FeatureBatch, model_name: str, split: str) -> None:
108
+ path.parent.mkdir(parents=True, exist_ok=True)
109
+ np.savez_compressed(
110
+ path,
111
+ lesion_id=np.asarray(batch.lesion_ids, dtype=object),
112
+ label=np.asarray(batch.labels, dtype=object),
113
+ clinical=batch.clinical,
114
+ dermoscopic=batch.dermoscopic,
115
+ model_name=np.asarray(model_name),
116
+ split=np.asarray(split),
117
+ )
118
+
119
+
120
+ def make_feature_matrix(data: dict[str, np.ndarray], mode: str = "pair") -> np.ndarray:
121
+ clinical = data["clinical"]
122
+ dermoscopic = data["dermoscopic"]
123
+ if mode == "clinical":
124
+ return clinical
125
+ if mode == "dermoscopic":
126
+ return dermoscopic
127
+ if mode == "pair":
128
+ return np.concatenate([clinical, dermoscopic, np.abs(clinical - dermoscopic), clinical * dermoscopic], axis=1)
129
+ raise ValueError(f"Unsupported feature mode: {mode}")
milk10k_new_collapse_research/hierarchy.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hierarchical class mapping helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import numpy as np
8
+
9
+ from milk10k_new_collapse_research.config import CLASS_NAMES, DEFAULT_HIERARCHY
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HierarchySpec:
14
+ groups: dict[str, list[str]]
15
+ class_names: list[str]
16
+
17
+ @property
18
+ def group_names(self) -> list[str]:
19
+ return list(self.groups)
20
+
21
+ @property
22
+ def class_to_group(self) -> dict[str, str]:
23
+ mapping: dict[str, str] = {}
24
+ for group, labels in self.groups.items():
25
+ for label in labels:
26
+ if label in mapping:
27
+ raise ValueError(f"Class {label!r} appears in multiple hierarchy groups.")
28
+ mapping[label] = group
29
+ missing = sorted(set(self.class_names) - set(mapping))
30
+ extra = sorted(set(mapping) - set(self.class_names))
31
+ if missing:
32
+ raise ValueError(f"Hierarchy missing classes: {missing}")
33
+ if extra:
34
+ raise ValueError(f"Hierarchy references unknown classes: {extra}")
35
+ return mapping
36
+
37
+ def group_index(self) -> dict[str, int]:
38
+ return {name: idx for idx, name in enumerate(self.group_names)}
39
+
40
+ def group_labels_for_class_labels(self, labels: list[str]) -> np.ndarray:
41
+ c2g = self.class_to_group
42
+ g2i = self.group_index()
43
+ return np.asarray([g2i[c2g[label]] for label in labels], dtype=np.int64)
44
+
45
+ def class_group_indices(self) -> list[int]:
46
+ c2g = self.class_to_group
47
+ g2i = self.group_index()
48
+ return [g2i[c2g[label]] for label in self.class_names]
49
+
50
+
51
+ def default_hierarchy(class_names: list[str] | None = None) -> HierarchySpec:
52
+ names = class_names or CLASS_NAMES
53
+ name_set = set(names)
54
+ groups = {
55
+ group: [label for label in labels if label in name_set]
56
+ for group, labels in DEFAULT_HIERARCHY.items()
57
+ }
58
+ groups = {group: labels for group, labels in groups.items() if labels}
59
+ return HierarchySpec(groups=groups, class_names=names)
60
+
61
+
62
+ def combine_group_and_expert_probabilities(
63
+ group_prob: np.ndarray,
64
+ expert_prob_by_group: dict[str, np.ndarray],
65
+ spec: HierarchySpec,
66
+ ) -> np.ndarray:
67
+ """Return flat class probabilities from group and in-group probabilities."""
68
+ flat = np.zeros((group_prob.shape[0], len(spec.class_names)), dtype=np.float64)
69
+ group_to_idx = spec.group_index()
70
+ class_to_idx = {label: idx for idx, label in enumerate(spec.class_names)}
71
+ for group_name, labels in spec.groups.items():
72
+ expert_prob = expert_prob_by_group[group_name]
73
+ if expert_prob.shape[1] != len(labels):
74
+ raise ValueError(f"Expert {group_name!r} has {expert_prob.shape[1]} columns, expected {len(labels)}.")
75
+ for local_idx, label in enumerate(labels):
76
+ flat[:, class_to_idx[label]] = group_prob[:, group_to_idx[group_name]] * expert_prob[:, local_idx]
77
+ row_sum = flat.sum(axis=1, keepdims=True)
78
+ return flat / np.clip(row_sum, 1e-12, None)
milk10k_new_collapse_research/metrics_ext.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Metrics and reporting helpers for collapse-focused experiments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from milk10k_new_collapse_research.compat import ensure_legacy_package_path
13
+
14
+ ensure_legacy_package_path()
15
+ from milk10k_effb2_metadata.metrics import compute_metrics, save_predictions
16
+ from milk10k_new_collapse_research.config import BASELINE_F1_MACRO, TAIL_GATE_CLASSES
17
+
18
+
19
+ def write_standard_outputs(
20
+ output_dir: Path,
21
+ val_df: pd.DataFrame,
22
+ y_true: np.ndarray,
23
+ y_prob: np.ndarray,
24
+ class_names: list[str],
25
+ extra: dict[str, Any] | None = None,
26
+ ) -> dict[str, Any]:
27
+ output_dir.mkdir(parents=True, exist_ok=True)
28
+ metrics, per_class_df, cm = compute_metrics(y_true, y_prob, class_names)
29
+ if extra:
30
+ metrics.update(extra)
31
+ (output_dir / "metrics.json").write_text(json.dumps(json_safe(metrics), indent=2), encoding="utf-8")
32
+ per_class_df.to_csv(output_dir / "per_class_metrics.csv", index=False)
33
+ pd.DataFrame(cm, index=class_names, columns=class_names).to_csv(output_dir / "confusion_matrix.csv")
34
+ save_predictions(val_df, y_true, y_prob, class_names, output_dir)
35
+ (output_dir / "collapse_report.md").write_text(
36
+ build_collapse_report(metrics, cm, class_names),
37
+ encoding="utf-8",
38
+ )
39
+ return metrics
40
+
41
+
42
+ def build_collapse_report(metrics: dict[str, Any], cm: np.ndarray, class_names: list[str]) -> str:
43
+ class_to_idx = {name: idx for idx, name in enumerate(class_names)}
44
+ lines = [
45
+ "# Collapse Report",
46
+ "",
47
+ f"- f1_macro: `{metrics.get('f1_macro', 0.0):.4f}`",
48
+ f"- baseline_f1_macro: `{BASELINE_F1_MACRO:.4f}`",
49
+ f"- balanced_accuracy: `{metrics.get('balanced_accuracy', 0.0):.4f}`",
50
+ f"- accuracy: `{metrics.get('accuracy', 0.0):.4f}`",
51
+ "",
52
+ "## Tail Gates",
53
+ "",
54
+ "| Class | Support | Correct | Recall | F1 | Gate |",
55
+ "|---|---:|---:|---:|---:|---|",
56
+ ]
57
+ per_class = {row["class"]: row for row in metrics.get("per_class", [])}
58
+ for label in TAIL_GATE_CLASSES:
59
+ if label not in class_to_idx:
60
+ continue
61
+ idx = class_to_idx[label]
62
+ row = per_class.get(label, {})
63
+ support = int(cm[idx].sum())
64
+ correct = int(cm[idx, idx])
65
+ recall = float(row.get("recall_sensitivity", 0.0))
66
+ f1 = float(row.get("f1", 0.0))
67
+ gate = tail_gate_status(label, correct, f1)
68
+ lines.append(f"| `{label}` | {support} | {correct} | {recall:.4f} | {f1:.4f} | {gate} |")
69
+
70
+ lines.extend(["", "## Key Confusions", ""])
71
+ for src, dst in [("MEL", "NV"), ("NV", "MEL"), ("INF", "BCC")]:
72
+ if src in class_to_idx and dst in class_to_idx:
73
+ lines.append(f"- `{src} -> {dst}`: `{int(cm[class_to_idx[src], class_to_idx[dst]])}`")
74
+ for src in ["AKIEC", "BCC", "BKL", "SCCKA"]:
75
+ if src not in class_to_idx:
76
+ continue
77
+ idx = class_to_idx[src]
78
+ cluster = [label for label in ["AKIEC", "BCC", "BKL", "SCCKA"] if label in class_to_idx and label != src]
79
+ total = sum(int(cm[idx, class_to_idx[dst]]) for dst in cluster)
80
+ lines.append(f"- `{src}` errors into keratinocyte-like cluster: `{total}`")
81
+
82
+ return "\n".join(lines) + "\n"
83
+
84
+
85
+ def tail_gate_status(label: str, correct: int, f1: float) -> str:
86
+ if label == "BEN_OTH":
87
+ return "PASS" if correct > 2 or f1 > 0.3077 else "FAIL"
88
+ if label == "INF":
89
+ return "PASS" if correct >= 5 else "FAIL"
90
+ if label == "MAL_OTH":
91
+ return "PASS" if correct >= 1 else "UNRESOLVED"
92
+ return "CHECK"
93
+
94
+
95
+ def load_prediction_csv(path: Path) -> tuple[pd.DataFrame, np.ndarray, np.ndarray, list[str]]:
96
+ df = pd.read_csv(path)
97
+ prob_cols = [col for col in df.columns if col.startswith("prob_")]
98
+ class_names = [col.removeprefix("prob_") for col in prob_cols]
99
+ true_col = "y_true" if "y_true" in df.columns else "true_label"
100
+ if true_col == "true_label" and not np.issubdtype(df[true_col].dtype, np.number):
101
+ label_true_col = "label_true" if "label_true" in df.columns else "true_label"
102
+ label_to_idx = {label: idx for idx, label in enumerate(class_names)}
103
+ y_true = df[label_true_col].map(label_to_idx).to_numpy(dtype=np.int64)
104
+ else:
105
+ y_true = df[true_col].to_numpy(dtype=np.int64)
106
+ return df, y_true, df[prob_cols].to_numpy(dtype=np.float64), class_names
107
+
108
+ return df, y_true, df[prob_cols].to_numpy(dtype=np.float64), class_names
109
+
110
+
111
+ def json_safe(value: Any) -> Any:
112
+ if isinstance(value, dict):
113
+ return {str(k): json_safe(v) for k, v in value.items()}
114
+ if isinstance(value, list):
115
+ return [json_safe(v) for v in value]
116
+ if isinstance(value, tuple):
117
+ return [json_safe(v) for v in value]
118
+ if isinstance(value, np.ndarray):
119
+ return value.tolist()
120
+ if isinstance(value, (np.integer,)):
121
+ return int(value)
122
+ if isinstance(value, (np.floating,)):
123
+ return float(value)
124
+ if isinstance(value, (np.bool_,)):
125
+ return bool(value)
126
+ return value
milk10k_new_collapse_research/run_new_ideas.sh ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+
3
+ set -Eeuo pipefail
4
+
5
+ DATA_DIR="${DATA_DIR:-/marimo/milk10k}"
6
+ OUT_ROOT="${OUT_ROOT:-results/new_collapse_research}"
7
+ MODEL_NAME="${MODEL_NAME:-dinov2_vitb14}"
8
+ PREDICTIONS_CSV="${PREDICTIONS_CSV:-}"
9
+
10
+ run_py() {
11
+ python -m "$@"
12
+ }
13
+
14
+ ###### Feature extraction ######
15
+
16
+ FEATURES_DIR="$OUT_ROOT/features_$MODEL_NAME"
17
+ TRAIN_FEATURES="$FEATURES_DIR/train_features.npz"
18
+ VAL_FEATURES="$FEATURES_DIR/val_features.npz"
19
+
20
+ COMMON_FEATURE_ARGS=(
21
+ --data-dir "$DATA_DIR"
22
+ --model-name "$MODEL_NAME"
23
+ --output-dir "$FEATURES_DIR"
24
+ )
25
+
26
+ run_feature_config() {
27
+ local run_name="$1"
28
+ shift
29
+ local log="$OUT_ROOT/${run_name}.log"
30
+
31
+ mkdir -p "$FEATURES_DIR"
32
+ echo "START $run_name"
33
+
34
+ if [[ -s "$TRAIN_FEATURES" && -s "$VAL_FEATURES" ]]; then
35
+ echo "SKIP feature extraction; both train_features.npz and val_features.npz already exist"
36
+ else
37
+ run_py milk10k_new_collapse_research.scripts.extract_foundation_features \
38
+ "${COMMON_FEATURE_ARGS[@]}" "$@" >"$log" 2>&1
39
+ fi
40
+
41
+ for artifact in "$TRAIN_FEATURES" "$VAL_FEATURES"; do
42
+ test -s "$artifact" || { echo "FATAL: missing $artifact" >&2; exit 3; }
43
+ done
44
+ echo "DONE $run_name"
45
+ }
46
+
47
+ run_feature_config extract_features
48
+
49
+ ###### Linear probe ######
50
+
51
+ PROBE_DIR="$OUT_ROOT/linear_probe_$MODEL_NAME"
52
+ PROBE_METRICS="$PROBE_DIR/metrics.json"
53
+
54
+ COMMON_PROBE_ARGS=(
55
+ --train-features "$TRAIN_FEATURES"
56
+ --val-features "$VAL_FEATURES"
57
+ --output-dir "$PROBE_DIR"
58
+ )
59
+
60
+ run_probe_config() {
61
+ local run_name="$1"
62
+ shift
63
+ local log="$OUT_ROOT/${run_name}.log"
64
+
65
+ mkdir -p "$PROBE_DIR"
66
+ echo "START $run_name"
67
+
68
+ if [[ -s "$PROBE_METRICS" ]]; then
69
+ echo "SKIP probe; metrics.json already exists at $PROBE_DIR"
70
+ else
71
+ run_py milk10k_new_collapse_research.scripts.linear_probe \
72
+ "${COMMON_PROBE_ARGS[@]}" "$@" >"$log" 2>&1
73
+ fi
74
+
75
+ test -s "$PROBE_METRICS" || { echo "FATAL: missing $PROBE_METRICS" >&2; exit 3; }
76
+ echo "DONE $run_name"
77
+ }
78
+
79
+ run_probe_config linear_probe_baseline --feature-mode pair --probe logistic
80
+ run_probe_config linear_probe_clinical --feature-mode clinical --probe logistic
81
+ run_probe_config linear_probe_dermoscopic --feature-mode dermoscopic --probe logistic
82
+ run_probe_config linear_probe_mlp --feature-mode pair --probe mlp
83
+
84
+ ###### Hierarchical probe ######
85
+
86
+ HIER_DIR="$OUT_ROOT/hierarchical_$MODEL_NAME"
87
+ HIER_METRICS="$HIER_DIR/metrics.json"
88
+
89
+ COMMON_HIER_ARGS=(
90
+ --train-features "$TRAIN_FEATURES"
91
+ --val-features "$VAL_FEATURES"
92
+ --output-dir "$HIER_DIR"
93
+ )
94
+
95
+ run_hier_config() {
96
+ local run_name="$1"
97
+ shift
98
+ local log="$OUT_ROOT/${run_name}.log"
99
+
100
+ mkdir -p "$HIER_DIR"
101
+ echo "START $run_name"
102
+
103
+ if [[ -s "$HIER_METRICS" ]]; then
104
+ echo "SKIP hierarchical; metrics.json already exists at $HIER_DIR"
105
+ else
106
+ run_py milk10k_new_collapse_research.scripts.train_hierarchical \
107
+ "${COMMON_HIER_ARGS[@]}" "$@" >"$log" 2>&1
108
+ fi
109
+
110
+ test -s "$HIER_METRICS" || { echo "FATAL: missing $HIER_METRICS" >&2; exit 3; }
111
+ echo "DONE $run_name"
112
+ }
113
+
114
+ run_hier_config hierarchical_probe --feature-mode pair
115
+ run_hier_config hierarchical_probe_clinical --feature-mode clinical
116
+ run_hier_config hierarchical_probe_dermoscopic --feature-mode dermoscopic
117
+
118
+ ###### Multimodal SSL ######
119
+
120
+ SSL_DIR="$OUT_ROOT/multimodal_ssl"
121
+ SSL_METRICS="$SSL_DIR/metrics.json"
122
+
123
+ run_ssl_config() {
124
+ local run_name="$1"
125
+ shift
126
+ local log="$OUT_ROOT/${run_name}.log"
127
+
128
+ mkdir -p "$SSL_DIR"
129
+ echo "START $run_name"
130
+
131
+ if [[ -s "$SSL_METRICS" ]]; then
132
+ echo "SKIP SSL; metrics.json already exists at $SSL_DIR"
133
+ else
134
+ run_py milk10k_new_collapse_research.scripts.train_multimodal_ssl \
135
+ --data-dir "$DATA_DIR" \
136
+ --output-dir "$SSL_DIR" \
137
+ "$@" >"$log" 2>&1
138
+ fi
139
+
140
+ test -s "$SSL_METRICS" || { echo "FATAL: missing $SSL_METRICS" >&2; exit 3; }
141
+ echo "DONE $run_name"
142
+ }
143
+
144
+ run_ssl_config multimodal_ssl_convnext --backbone convnext_base --epochs 20
145
+
146
+ ###### Decision policy (optional, requires a predictions CSV) ######
147
+
148
+ if [[ -n "$PREDICTIONS_CSV" && -s "$PREDICTIONS_CSV" ]]; then
149
+ POLICY_DIR="$OUT_ROOT/decision_policy"
150
+ POLICY_METRICS="$POLICY_DIR/metrics.json"
151
+
152
+ run_policy_config() {
153
+ local run_name="$1"
154
+ shift
155
+ local log="$OUT_ROOT/${run_name}.log"
156
+
157
+ mkdir -p "$POLICY_DIR"
158
+ echo "START $run_name"
159
+
160
+ if [[ -s "$POLICY_METRICS" ]]; then
161
+ echo "SKIP policy; metrics.json already exists at $POLICY_DIR"
162
+ else
163
+ run_py milk10k_new_collapse_research.scripts.analyze_decision_policy \
164
+ --predictions "$PREDICTIONS_CSV" \
165
+ --output-dir "$POLICY_DIR" \
166
+ "$@" >"$log" 2>&1
167
+ fi
168
+
169
+ test -s "$POLICY_METRICS" || { echo "FATAL: missing $POLICY_METRICS" >&2; exit 3; }
170
+ echo "DONE $run_name"
171
+ }
172
+
173
+ run_policy_config decision_policy_tail --tail-only
174
+ run_policy_config decision_policy_all
175
+ else
176
+ echo "SKIP decision policy; set PREDICTIONS_CSV to a valid prediction CSV to enable"
177
+ fi
178
+
179
+ echo "ALL RUNS COMPLETED"
milk10k_new_collapse_research/scripts/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ """CLI entrypoints for experimental MILK10k collapse research."""
2
+
milk10k_new_collapse_research/scripts/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (230 Bytes). View file
 
milk10k_new_collapse_research/scripts/__pycache__/analyze_decision_policy.cpython-310.pyc ADDED
Binary file (4.24 kB). View file
 
milk10k_new_collapse_research/scripts/__pycache__/extract_foundation_features.cpython-310.pyc ADDED
Binary file (2.85 kB). View file
 
milk10k_new_collapse_research/scripts/__pycache__/linear_probe.cpython-310.pyc ADDED
Binary file (4.73 kB). View file
 
milk10k_new_collapse_research/scripts/__pycache__/train_hierarchical.cpython-310.pyc ADDED
Binary file (5.46 kB). View file
 
milk10k_new_collapse_research/scripts/__pycache__/train_multimodal_ssl.cpython-310.pyc ADDED
Binary file (9.47 kB). View file
 
milk10k_new_collapse_research/scripts/analyze_decision_policy.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Optimize rare-class logit bias on an existing validation prediction CSV."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+
12
+ from milk10k_new_collapse_research.compat import ensure_legacy_package_path
13
+
14
+ ensure_legacy_package_path()
15
+ from milk10k_effb2_metadata.metrics import apply_class_bias, compute_metrics, optimize_class_bias
16
+ from milk10k_new_collapse_research.config import RESULTS_ROOT, TAIL_GATE_CLASSES
17
+ from milk10k_new_collapse_research.metrics_ext import build_collapse_report, json_safe, load_prediction_csv
18
+
19
+
20
+ def parse_args() -> argparse.Namespace:
21
+ parser = argparse.ArgumentParser(description=__doc__)
22
+ parser.add_argument("--predictions", type=Path, required=True)
23
+ parser.add_argument("--output-dir", type=Path, default=RESULTS_ROOT / "decision_policy")
24
+ parser.add_argument("--metric", choices=["f1_macro", "dice_macro"], default="f1_macro")
25
+ parser.add_argument("--max-bias", type=float, default=2.0)
26
+ parser.add_argument("--step", type=float, default=0.25)
27
+ parser.add_argument("--passes", type=int, default=3)
28
+ parser.add_argument("--tail-only", action="store_true", help="Optimize only BEN_OTH/INF/MAL_OTH biases.")
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ args.output_dir.mkdir(parents=True, exist_ok=True)
35
+ df, y_true, y_prob, class_names = load_prediction_csv(args.predictions)
36
+ base_metrics, _, base_cm = compute_metrics(y_true, y_prob, class_names)
37
+ if args.tail_only:
38
+ bias, best_score = optimize_tail_bias(y_true, y_prob, class_names, args)
39
+ else:
40
+ bias, best_score = optimize_class_bias(
41
+ y_true,
42
+ y_prob,
43
+ class_names,
44
+ metric_name=args.metric,
45
+ max_bias=args.max_bias,
46
+ step=args.step,
47
+ passes=args.passes,
48
+ )
49
+ adjusted_prob = apply_class_bias(y_prob, bias)
50
+ adjusted_metrics, per_class_df, adjusted_cm = compute_metrics(y_true, adjusted_prob, class_names)
51
+ out_df = df.copy()
52
+ pred = adjusted_prob.argmax(axis=1)
53
+ out_df["policy_y_pred"] = pred
54
+ out_df["policy_label_pred"] = [class_names[idx] for idx in pred]
55
+ for idx, label in enumerate(class_names):
56
+ out_df[f"policy_prob_{label}"] = adjusted_prob[:, idx]
57
+ out_df.to_csv(args.output_dir / "val_predictions.csv", index=False)
58
+ per_class_df.to_csv(args.output_dir / "per_class_metrics.csv", index=False)
59
+ pd.DataFrame(adjusted_cm, index=class_names, columns=class_names).to_csv(args.output_dir / "confusion_matrix.csv")
60
+ payload = {
61
+ "experiment": "decision_policy",
62
+ "source_predictions": str(args.predictions),
63
+ "metric": args.metric,
64
+ "tail_only": args.tail_only,
65
+ "base_metric": base_metrics[args.metric],
66
+ "best_search_metric": best_score,
67
+ "bias": {label: float(bias[idx]) for idx, label in enumerate(class_names)},
68
+ "adjusted": adjusted_metrics,
69
+ "base": base_metrics,
70
+ }
71
+ (args.output_dir / "metrics.json").write_text(json.dumps(json_safe(payload), indent=2), encoding="utf-8")
72
+ (args.output_dir / "collapse_report.md").write_text(
73
+ "# Base Collapse Report\n\n"
74
+ + build_collapse_report(base_metrics, base_cm, class_names)
75
+ + "\n# Adjusted Collapse Report\n\n"
76
+ + build_collapse_report(adjusted_metrics, adjusted_cm, class_names),
77
+ encoding="utf-8",
78
+ )
79
+
80
+
81
+ def optimize_tail_bias(y_true: np.ndarray, y_prob: np.ndarray, class_names: list[str], args: argparse.Namespace):
82
+ bias = np.zeros(len(class_names), dtype=np.float32)
83
+ tail_indices = [class_names.index(label) for label in TAIL_GATE_CLASSES if label in class_names]
84
+ best_score = float(compute_metrics(y_true, y_prob, class_names)[0][args.metric])
85
+ deltas = np.arange(-args.max_bias, args.max_bias + args.step * 0.5, args.step, dtype=np.float32)
86
+ for _ in range(max(1, args.passes)):
87
+ improved = False
88
+ for idx in tail_indices:
89
+ current = float(bias[idx])
90
+ best_local = current
91
+ for delta in deltas:
92
+ trial = bias.copy()
93
+ trial[idx] = current + float(delta)
94
+ score = float(compute_metrics(y_true, apply_class_bias(y_prob, trial), class_names)[0][args.metric])
95
+ if score > best_score + 1e-12:
96
+ best_score = score
97
+ best_local = float(trial[idx])
98
+ improved = True
99
+ bias[idx] = best_local
100
+ if not improved:
101
+ break
102
+ return bias, best_score
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
milk10k_new_collapse_research/scripts/extract_foundation_features.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extract frozen paired clinical/dermoscopic features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import torch
10
+ from torch.utils.data import DataLoader
11
+
12
+ from milk10k_new_collapse_research.compat import ensure_legacy_package_path
13
+ from milk10k_new_collapse_research.config import RESULTS_ROOT
14
+ from milk10k_new_collapse_research.features import (
15
+ PairImageDataset,
16
+ build_feature_model,
17
+ extract_pair_features,
18
+ save_feature_npz,
19
+ )
20
+
21
+ ensure_legacy_package_path()
22
+ from milk10k_effb2_metadata.data import lesion_split, load_paired_dataframe
23
+
24
+
25
+ def parse_args() -> argparse.Namespace:
26
+ parser = argparse.ArgumentParser(description=__doc__)
27
+ parser.add_argument("--data-dir", type=Path, required=True)
28
+ parser.add_argument("--output-dir", type=Path, default=RESULTS_ROOT / "features")
29
+ parser.add_argument("--model-name", default="dinov2_vitb14")
30
+ parser.add_argument("--val-size", type=float, default=0.20)
31
+ parser.add_argument("--seed", type=int, default=42)
32
+ parser.add_argument("--batch-size", type=int, default=16)
33
+ parser.add_argument("--num-workers", type=int, default=4)
34
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
35
+ parser.add_argument("--limit", type=int, default=0, help="Optional per-split row cap for smoke tests.")
36
+ parser.add_argument("--no-l2-normalize", action="store_true")
37
+ parser.add_argument("--no-pretrained", action="store_true", help="Only for local smoke tests with timm models.")
38
+ return parser.parse_args()
39
+
40
+
41
+ def main() -> None:
42
+ args = parse_args()
43
+ device = torch.device(args.device)
44
+ df = load_paired_dataframe(args.data_dir)
45
+ train_df, val_df = lesion_split(df, args.val_size, args.seed)
46
+ if args.limit > 0:
47
+ train_df = train_df.head(args.limit).copy()
48
+ val_df = val_df.head(args.limit).copy()
49
+
50
+ model, transform, image_size = build_feature_model(args.model_name, device, pretrained=not args.no_pretrained)
51
+ args.output_dir.mkdir(parents=True, exist_ok=True)
52
+ for split, split_df in [("train", train_df), ("val", val_df)]:
53
+ dataset = PairImageDataset(split_df, transform)
54
+ loader = DataLoader(
55
+ dataset,
56
+ batch_size=args.batch_size,
57
+ shuffle=False,
58
+ num_workers=args.num_workers,
59
+ pin_memory=torch.cuda.is_available(),
60
+ )
61
+ batch = extract_pair_features(model, loader, device, l2_normalize=not args.no_l2_normalize)
62
+ save_feature_npz(args.output_dir / f"{split}_features.npz", batch, args.model_name, split)
63
+
64
+ manifest = {
65
+ "model_name": args.model_name,
66
+ "image_size": image_size,
67
+ "data_dir": str(args.data_dir),
68
+ "train_rows": int(len(train_df)),
69
+ "val_rows": int(len(val_df)),
70
+ "val_size": args.val_size,
71
+ "seed": args.seed,
72
+ }
73
+ (args.output_dir / "feature_manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
milk10k_new_collapse_research/scripts/linear_probe.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train a linear or small MLP probe on frozen paired features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.neural_network import MLPClassifier
13
+ from sklearn.pipeline import make_pipeline
14
+ from sklearn.preprocessing import StandardScaler
15
+
16
+ from milk10k_new_collapse_research.config import CLASS_NAMES, RESULTS_ROOT
17
+ from milk10k_new_collapse_research.features import make_feature_matrix
18
+ from milk10k_new_collapse_research.metrics_ext import write_standard_outputs
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(description=__doc__)
23
+ parser.add_argument("--train-features", type=Path, required=True)
24
+ parser.add_argument("--val-features", type=Path, required=True)
25
+ parser.add_argument("--output-dir", type=Path, default=RESULTS_ROOT / "linear_probe")
26
+ parser.add_argument("--feature-mode", choices=["pair", "clinical", "dermoscopic"], default="pair")
27
+ parser.add_argument("--probe", choices=["logistic", "mlp"], default="logistic")
28
+ parser.add_argument("--max-iter", type=int, default=1000)
29
+ parser.add_argument("--seed", type=int, default=42)
30
+ return parser.parse_args()
31
+
32
+
33
+ def main() -> None:
34
+ args = parse_args()
35
+ train = dict(np.load(args.train_features, allow_pickle=True))
36
+ val = dict(np.load(args.val_features, allow_pickle=True))
37
+ class_names = infer_class_names(train, val)
38
+ label_to_idx = {label: idx for idx, label in enumerate(class_names)}
39
+ x_train = make_feature_matrix(train, args.feature_mode)
40
+ x_val = make_feature_matrix(val, args.feature_mode)
41
+ y_train = np.asarray([label_to_idx[str(label)] for label in train["label"]], dtype=np.int64)
42
+ y_val = np.asarray([label_to_idx[str(label)] for label in val["label"]], dtype=np.int64)
43
+
44
+ if args.probe == "logistic":
45
+ clf = LogisticRegression(
46
+ max_iter=args.max_iter,
47
+ class_weight="balanced",
48
+ solver="lbfgs",
49
+ multi_class="auto",
50
+ random_state=args.seed,
51
+ )
52
+ else:
53
+ clf = MLPClassifier(
54
+ hidden_layer_sizes=(512,),
55
+ alpha=1e-4,
56
+ batch_size=64,
57
+ max_iter=args.max_iter,
58
+ early_stopping=True,
59
+ random_state=args.seed,
60
+ )
61
+ model = make_pipeline(StandardScaler(), clf)
62
+ model.fit(x_train, y_train)
63
+ y_prob = model.predict_proba(x_val)
64
+ y_prob = align_probabilities(y_prob, model.classes_, len(class_names))
65
+ val_df = prediction_frame(val)
66
+ metrics = write_standard_outputs(
67
+ args.output_dir,
68
+ val_df,
69
+ y_val,
70
+ y_prob,
71
+ class_names,
72
+ extra={
73
+ "experiment": "linear_probe",
74
+ "feature_mode": args.feature_mode,
75
+ "probe": args.probe,
76
+ "train_features": str(args.train_features),
77
+ "val_features": str(args.val_features),
78
+ },
79
+ )
80
+ (args.output_dir / "probe_config.json").write_text(json.dumps({"metrics_f1_macro": metrics["f1_macro"]}, indent=2))
81
+
82
+
83
+ def infer_class_names(*arrays: dict[str, np.ndarray]) -> list[str]:
84
+ observed = sorted({str(label) for data in arrays for label in data["label"].tolist()})
85
+ preferred = [label for label in CLASS_NAMES if label in observed]
86
+ extras = [label for label in observed if label not in preferred]
87
+ return preferred + extras
88
+
89
+
90
+ def align_probabilities(y_prob: np.ndarray, classes: np.ndarray, n_classes: int) -> np.ndarray:
91
+ aligned = np.zeros((y_prob.shape[0], n_classes), dtype=np.float64)
92
+ for src_idx, class_idx in enumerate(classes):
93
+ aligned[:, int(class_idx)] = y_prob[:, src_idx]
94
+ row_sum = aligned.sum(axis=1, keepdims=True)
95
+ return aligned / np.clip(row_sum, 1e-12, None)
96
+
97
+
98
+ def prediction_frame(data: dict[str, np.ndarray]) -> pd.DataFrame:
99
+ return pd.DataFrame(
100
+ {
101
+ "lesion_id": [str(item) for item in data["lesion_id"]],
102
+ "clinical_path": [""] * len(data["label"]),
103
+ "dermoscopic_path": [""] * len(data["label"]),
104
+ }
105
+ )
106
+
107
+
108
+ if __name__ == "__main__":
109
+ main()
110
+
milk10k_new_collapse_research/scripts/train_hierarchical.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train a two-stage hierarchy on frozen paired features."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.pipeline import make_pipeline
13
+ from sklearn.preprocessing import StandardScaler
14
+
15
+ from milk10k_new_collapse_research.config import CLASS_NAMES, RESULTS_ROOT
16
+ from milk10k_new_collapse_research.features import make_feature_matrix
17
+ from milk10k_new_collapse_research.hierarchy import combine_group_and_expert_probabilities, default_hierarchy
18
+ from milk10k_new_collapse_research.metrics_ext import write_standard_outputs
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(description=__doc__)
23
+ parser.add_argument("--train-features", type=Path, required=True)
24
+ parser.add_argument("--val-features", type=Path, required=True)
25
+ parser.add_argument("--output-dir", type=Path, default=RESULTS_ROOT / "hierarchical")
26
+ parser.add_argument("--feature-mode", choices=["pair", "clinical", "dermoscopic"], default="pair")
27
+ parser.add_argument("--max-iter", type=int, default=1000)
28
+ parser.add_argument("--seed", type=int, default=42)
29
+ return parser.parse_args()
30
+
31
+
32
+ def main() -> None:
33
+ args = parse_args()
34
+ train = dict(np.load(args.train_features, allow_pickle=True))
35
+ val = dict(np.load(args.val_features, allow_pickle=True))
36
+ class_names = infer_class_names(train, val)
37
+ spec = default_hierarchy(class_names)
38
+ label_to_idx = {label: idx for idx, label in enumerate(class_names)}
39
+ x_train = make_feature_matrix(train, args.feature_mode)
40
+ x_val = make_feature_matrix(val, args.feature_mode)
41
+ train_labels = [str(label) for label in train["label"]]
42
+ val_labels = [str(label) for label in val["label"]]
43
+ y_train = np.asarray([label_to_idx[label] for label in train_labels], dtype=np.int64)
44
+ y_val = np.asarray([label_to_idx[label] for label in val_labels], dtype=np.int64)
45
+ y_train_group = spec.group_labels_for_class_labels(train_labels)
46
+
47
+ group_model = classifier(args.max_iter, args.seed)
48
+ group_model.fit(x_train, y_train_group)
49
+ group_prob = align_probabilities(group_model.predict_proba(x_val), group_model.classes_, len(spec.group_names))
50
+
51
+ expert_prob_by_group: dict[str, np.ndarray] = {}
52
+ for group_name, labels in spec.groups.items():
53
+ local_label_to_idx = {label: idx for idx, label in enumerate(labels)}
54
+ mask = np.asarray([label in local_label_to_idx for label in train_labels], dtype=bool)
55
+ if len(labels) == 1:
56
+ expert_prob_by_group[group_name] = np.ones((x_val.shape[0], 1), dtype=np.float64)
57
+ continue
58
+ expert = classifier(args.max_iter, args.seed)
59
+ expert.fit(x_train[mask], np.asarray([local_label_to_idx[label] for label in np.asarray(train_labels)[mask]]))
60
+ expert_prob_by_group[group_name] = align_probabilities(expert.predict_proba(x_val), expert.classes_, len(labels))
61
+
62
+ y_prob = combine_group_and_expert_probabilities(group_prob, expert_prob_by_group, spec)
63
+ val_df = pd.DataFrame(
64
+ {
65
+ "lesion_id": [str(item) for item in val["lesion_id"]],
66
+ "clinical_path": [""] * len(val_labels),
67
+ "dermoscopic_path": [""] * len(val_labels),
68
+ }
69
+ )
70
+ metrics = write_standard_outputs(
71
+ args.output_dir,
72
+ val_df,
73
+ y_val,
74
+ y_prob,
75
+ class_names,
76
+ extra={"experiment": "hierarchical_probe", "feature_mode": args.feature_mode, "groups": spec.groups},
77
+ )
78
+ (args.output_dir / "hierarchy_config.json").write_text(
79
+ json.dumps({"groups": spec.groups, "metrics_f1_macro": metrics["f1_macro"]}, indent=2),
80
+ encoding="utf-8",
81
+ )
82
+
83
+
84
+ def classifier(max_iter: int, seed: int):
85
+ return make_pipeline(
86
+ StandardScaler(),
87
+ LogisticRegression(
88
+ max_iter=max_iter,
89
+ class_weight="balanced",
90
+ solver="lbfgs",
91
+ multi_class="auto",
92
+ random_state=seed,
93
+ ),
94
+ )
95
+
96
+
97
+ def infer_class_names(*arrays: dict[str, np.ndarray]) -> list[str]:
98
+ observed = sorted({str(label) for data in arrays for label in data["label"].tolist()})
99
+ preferred = [label for label in CLASS_NAMES if label in observed]
100
+ extras = [label for label in observed if label not in preferred]
101
+ return preferred + extras
102
+
103
+
104
+ def align_probabilities(y_prob: np.ndarray, classes: np.ndarray, n_classes: int) -> np.ndarray:
105
+ aligned = np.zeros((y_prob.shape[0], n_classes), dtype=np.float64)
106
+ for src_idx, class_idx in enumerate(classes):
107
+ aligned[:, int(class_idx)] = y_prob[:, src_idx]
108
+ row_sum = aligned.sum(axis=1, keepdims=True)
109
+ return aligned / np.clip(row_sum, 1e-12, None)
110
+
111
+
112
+ if __name__ == "__main__":
113
+ main()
114
+
milk10k_new_collapse_research/scripts/train_multimodal_ssl.py ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Contrastively pretrain a dual-use encoder on clinical/dermoscopic MILK10k pairs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import torch
12
+ import torch.nn.functional as F
13
+ from PIL import Image
14
+ from sklearn.linear_model import LogisticRegression
15
+ from sklearn.pipeline import make_pipeline
16
+ from sklearn.preprocessing import StandardScaler
17
+ from timm.data import create_transform, resolve_data_config
18
+ from torch import nn
19
+ from torch.utils.data import DataLoader, Dataset
20
+
21
+ from milk10k_new_collapse_research.compat import ensure_legacy_package_path
22
+ from milk10k_new_collapse_research.config import CLASS_NAMES, RESULTS_ROOT
23
+ from milk10k_new_collapse_research.metrics_ext import write_standard_outputs
24
+
25
+ ensure_legacy_package_path()
26
+ from milk10k_effb2_metadata.data import lesion_split, load_paired_dataframe
27
+
28
+
29
+ class SslPairDataset(Dataset):
30
+ def __init__(self, df: pd.DataFrame, transform) -> None:
31
+ self.df = df.reset_index(drop=True)
32
+ self.transform = transform
33
+
34
+ def __len__(self) -> int:
35
+ return len(self.df)
36
+
37
+ def _load(self, path: str) -> torch.Tensor:
38
+ with Image.open(path) as image:
39
+ image = image.convert("RGB")
40
+ return self.transform(image)
41
+
42
+ def __getitem__(self, idx: int) -> dict[str, object]:
43
+ row = self.df.iloc[idx]
44
+ return {
45
+ "clinical": self._load(row["clinical_path"]),
46
+ "dermoscopic": self._load(row["dermoscopic_path"]),
47
+ "label": str(row["label"]),
48
+ "lesion_id": str(row["lesion_id"]),
49
+ }
50
+
51
+
52
+ class ContrastiveEncoder(nn.Module):
53
+ def __init__(self, backbone_name: str, projection_dim: int, pretrained: bool) -> None:
54
+ super().__init__()
55
+ import timm
56
+
57
+ self.encoder = timm.create_model(backbone_name, pretrained=pretrained, num_classes=0, global_pool="avg")
58
+ feature_dim = int(self.encoder.num_features)
59
+ self.projector = nn.Sequential(
60
+ nn.LayerNorm(feature_dim),
61
+ nn.Linear(feature_dim, feature_dim),
62
+ nn.GELU(),
63
+ nn.Linear(feature_dim, projection_dim),
64
+ )
65
+
66
+ def forward_features(self, images: torch.Tensor) -> torch.Tensor:
67
+ return self.encoder(images)
68
+
69
+ def forward(self, images: torch.Tensor) -> torch.Tensor:
70
+ return F.normalize(self.projector(self.forward_features(images)), dim=1)
71
+
72
+
73
+ def parse_args() -> argparse.Namespace:
74
+ parser = argparse.ArgumentParser(description=__doc__)
75
+ parser.add_argument("--data-dir", type=Path, required=True)
76
+ parser.add_argument("--output-dir", type=Path, default=RESULTS_ROOT / "multimodal_ssl")
77
+ parser.add_argument("--backbone", default="convnext_base")
78
+ parser.add_argument("--imagenet-pretrained", action="store_true")
79
+ parser.add_argument("--image-size", type=int, default=224)
80
+ parser.add_argument("--projection-dim", type=int, default=256)
81
+ parser.add_argument("--epochs", type=int, default=20)
82
+ parser.add_argument("--batch-size", type=int, default=32)
83
+ parser.add_argument("--num-workers", type=int, default=4)
84
+ parser.add_argument("--lr", type=float, default=1e-4)
85
+ parser.add_argument("--weight-decay", type=float, default=1e-4)
86
+ parser.add_argument("--temperature", type=float, default=0.07)
87
+ parser.add_argument("--val-size", type=float, default=0.20)
88
+ parser.add_argument("--seed", type=int, default=42)
89
+ parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
90
+ parser.add_argument("--limit", type=int, default=0, help="Optional per-split row cap for smoke tests.")
91
+ return parser.parse_args()
92
+
93
+
94
+ def main() -> None:
95
+ args = parse_args()
96
+ torch.manual_seed(args.seed)
97
+ np.random.seed(args.seed)
98
+ device = torch.device(args.device)
99
+ args.output_dir.mkdir(parents=True, exist_ok=True)
100
+
101
+ df = load_paired_dataframe(args.data_dir)
102
+ train_df, val_df = lesion_split(df, args.val_size, args.seed)
103
+ if args.limit > 0:
104
+ train_df = train_df.head(args.limit).copy()
105
+ val_df = val_df.head(args.limit).copy()
106
+
107
+ model = ContrastiveEncoder(args.backbone, args.projection_dim, args.imagenet_pretrained).to(device)
108
+ train_transform, eval_transform = make_transforms(model.encoder, args.image_size)
109
+ train_loader = DataLoader(
110
+ SslPairDataset(train_df, train_transform),
111
+ batch_size=args.batch_size,
112
+ shuffle=True,
113
+ num_workers=args.num_workers,
114
+ pin_memory=torch.cuda.is_available(),
115
+ drop_last=True,
116
+ )
117
+ optimizer = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=args.weight_decay)
118
+ history = []
119
+ for epoch in range(1, args.epochs + 1):
120
+ model.train()
121
+ losses = []
122
+ for batch in train_loader:
123
+ clinical = batch["clinical"].to(device, non_blocking=True)
124
+ dermoscopic = batch["dermoscopic"].to(device, non_blocking=True)
125
+ optimizer.zero_grad(set_to_none=True)
126
+ z1 = model(clinical)
127
+ z2 = model(dermoscopic)
128
+ loss = symmetric_clip_loss(z1, z2, args.temperature)
129
+ loss.backward()
130
+ optimizer.step()
131
+ losses.append(float(loss.detach().cpu().item()))
132
+ history.append({"epoch": epoch, "ssl_loss": float(np.mean(losses)) if losses else None})
133
+ print(f"epoch={epoch} ssl_loss={history[-1]['ssl_loss']}")
134
+
135
+ torch.save(
136
+ {
137
+ "backbone": args.backbone,
138
+ "model_state": model.state_dict(),
139
+ "projection_dim": args.projection_dim,
140
+ "image_size": args.image_size,
141
+ "history": history,
142
+ },
143
+ args.output_dir / "ssl_encoder.pt",
144
+ )
145
+
146
+ x_train, y_train, class_names = extract_features_for_probe(model, train_df, eval_transform, args, device)
147
+ x_val, y_val, _ = extract_features_for_probe(model, val_df, eval_transform, args, device, class_names=class_names)
148
+ probe = make_pipeline(
149
+ StandardScaler(),
150
+ LogisticRegression(max_iter=1000, class_weight="balanced", solver="lbfgs", multi_class="auto", random_state=args.seed),
151
+ )
152
+ probe.fit(x_train, y_train)
153
+ y_prob = align_probabilities(probe.predict_proba(x_val), probe.classes_, len(class_names))
154
+ metrics = write_standard_outputs(
155
+ args.output_dir,
156
+ val_df,
157
+ y_val,
158
+ y_prob,
159
+ class_names,
160
+ extra={"experiment": "multimodal_ssl_linear_eval", "ssl_history": history},
161
+ )
162
+ (args.output_dir / "ssl_config.json").write_text(
163
+ json.dumps({"backbone": args.backbone, "metrics_f1_macro": metrics["f1_macro"], "history": history}, indent=2),
164
+ encoding="utf-8",
165
+ )
166
+
167
+
168
+ def make_transforms(model: nn.Module, image_size: int):
169
+ cfg = resolve_data_config({}, model=model)
170
+ cfg["input_size"] = (3, image_size, image_size)
171
+ train_transform = create_transform(**cfg, is_training=True)
172
+ eval_transform = create_transform(**cfg, is_training=False)
173
+ return train_transform, eval_transform
174
+
175
+
176
+ def symmetric_clip_loss(z1: torch.Tensor, z2: torch.Tensor, temperature: float) -> torch.Tensor:
177
+ logits = z1 @ z2.T / temperature
178
+ labels = torch.arange(z1.size(0), device=z1.device)
179
+ return 0.5 * (F.cross_entropy(logits, labels) + F.cross_entropy(logits.T, labels))
180
+
181
+
182
+ @torch.no_grad()
183
+ def extract_features_for_probe(
184
+ model: ContrastiveEncoder,
185
+ df: pd.DataFrame,
186
+ transform,
187
+ args: argparse.Namespace,
188
+ device: torch.device,
189
+ class_names: list[str] | None = None,
190
+ ):
191
+ dataset = SslPairDataset(df, transform)
192
+ loader = DataLoader(dataset, batch_size=args.batch_size, shuffle=False, num_workers=args.num_workers)
193
+ model.eval()
194
+ features = []
195
+ labels = []
196
+ for batch in loader:
197
+ clinical = batch["clinical"].to(device, non_blocking=True)
198
+ dermoscopic = batch["dermoscopic"].to(device, non_blocking=True)
199
+ c = F.normalize(model.forward_features(clinical), dim=1)
200
+ d = F.normalize(model.forward_features(dermoscopic), dim=1)
201
+ pair = torch.cat([c, d, torch.abs(c - d), c * d], dim=1)
202
+ features.append(pair.cpu().numpy())
203
+ labels.extend(str(label) for label in batch["label"])
204
+ if class_names is None:
205
+ observed = sorted(set(labels))
206
+ class_names = [label for label in CLASS_NAMES if label in observed] + [label for label in observed if label not in CLASS_NAMES]
207
+ label_to_idx = {label: idx for idx, label in enumerate(class_names)}
208
+ y = np.asarray([label_to_idx[label] for label in labels], dtype=np.int64)
209
+ return np.concatenate(features, axis=0), y, class_names
210
+
211
+
212
+ def align_probabilities(y_prob: np.ndarray, classes: np.ndarray, n_classes: int) -> np.ndarray:
213
+ aligned = np.zeros((y_prob.shape[0], n_classes), dtype=np.float64)
214
+ for src_idx, class_idx in enumerate(classes):
215
+ aligned[:, int(class_idx)] = y_prob[:, src_idx]
216
+ row_sum = aligned.sum(axis=1, keepdims=True)
217
+ return aligned / np.clip(row_sum, 1e-12, None)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
222
+