Upload 26 files
Browse files- milk10k_new_collapse_research/README.md +120 -0
- milk10k_new_collapse_research/__init__.py +2 -0
- milk10k_new_collapse_research/__pycache__/__init__.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/__pycache__/compat.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/__pycache__/config.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/__pycache__/features.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/__pycache__/hierarchy.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/__pycache__/metrics_ext.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/compat.py +15 -0
- milk10k_new_collapse_research/config.py +36 -0
- milk10k_new_collapse_research/features.py +129 -0
- milk10k_new_collapse_research/hierarchy.py +78 -0
- milk10k_new_collapse_research/metrics_ext.py +126 -0
- milk10k_new_collapse_research/run_new_ideas.sh +179 -0
- milk10k_new_collapse_research/scripts/__init__.py +2 -0
- milk10k_new_collapse_research/scripts/__pycache__/__init__.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/scripts/__pycache__/analyze_decision_policy.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/scripts/__pycache__/extract_foundation_features.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/scripts/__pycache__/linear_probe.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/scripts/__pycache__/train_hierarchical.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/scripts/__pycache__/train_multimodal_ssl.cpython-310.pyc +0 -0
- milk10k_new_collapse_research/scripts/analyze_decision_policy.py +106 -0
- milk10k_new_collapse_research/scripts/extract_foundation_features.py +77 -0
- milk10k_new_collapse_research/scripts/linear_probe.py +110 -0
- milk10k_new_collapse_research/scripts/train_hierarchical.py +114 -0
- 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 |
+
|