Skip to content

Commit 3402c79

Browse files
committed
Add coreg/atlas layout-2 support and rare-pathology rebalanced sampling
Dataloader now picks the image subfolder from --space when scanning the raw HuggingFace download layout: native_space -> img/, coreg_space -> coreg_img/, atlas_space -> atlas_img/. Layout 1 (<uid>/<space>/img/) is unchanged. Adds optional inverse-prevalence weighted sampling for contrastive pretraining: pass --pathology_labels_csv together with --rebalance_strategy (inverse_freq | sqrt_inverse_freq | max_inverse_freq) to switch the DataLoader to a WeightedRandomSampler that upsamples subjects with rare positive pathologies. Unlabeled / all-negative subjects keep --rebalance_base_weight (default 1.0). 8 new unit tests cover the weight formulas and sampler integration; 5 new tests cover the coreg/atlas layout discovery.
1 parent 839c428 commit 3402c79

6 files changed

Lines changed: 530 additions & 13 deletions

File tree

contrastive-pretraining/README.md

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -146,21 +146,32 @@ data_folder/
146146
```
147147

148148
**Layout 2 — Batch-based** (HuggingFace dataset format):
149+
150+
The image subdirectory name is chosen automatically from `--space`:
151+
152+
| Space (`--space`) | Image subfolder | HuggingFace repo |
153+
|-------------------|-----------------|------------------|
154+
| `native_space` *(default)* | `img/` | [Forithmus/MR-RATE](https://huggingface.co/datasets/Forithmus/MR-RATE) |
155+
| `coreg_space` | `coreg_img/` | [Forithmus/MR-RATE-coreg](https://huggingface.co/datasets/Forithmus/MR-RATE-coreg) |
156+
| `atlas_space` | `atlas_img/` | [Forithmus/MR-RATE-atlas](https://huggingface.co/datasets/Forithmus/MR-RATE-atlas) |
157+
149158
```
150-
data_folder/
159+
data_folder/ # e.g. ./data/MR-RATE-coreg/mri
151160
├── batch00/
152161
│ ├── SUBJECT_001/
153-
│ │ └── img/
162+
│ │ └── coreg_img/ # img/ for native, atlas_img/ for atlas
154163
│ │ ├── SUBJECT_001_t1w-raw-axi.nii.gz
155164
│ │ ├── SUBJECT_001_flair-raw-axi.nii.gz
156165
│ │ └── ...
157166
│ ├── SUBJECT_002/
158-
│ │ └── img/
167+
│ │ └── coreg_img/
159168
│ │ └── ...
160169
├── batch01/
161170
│ └── ...
162171
```
163172

173+
After `merge_downloaded_repos.py` consolidates native + coreg + atlas under a single tree, all three subfolders (`img/`, `coreg_img/`, `atlas_img/`) live side-by-side under each study, and `--space` selects which one to load.
174+
164175
Reports are stored in a JSONL file with one entry per subject:
165176

166177
```json
@@ -230,8 +241,11 @@ FUSION_MODE=late_attn POOLING_STRATEGY=cross_attn NUM_TRAIN_STEPS=50000 \
230241
| `--pooling_strategy` | `simple_attn`, `cross_attn`, `gated` | `simple_attn` | Volume pooling (used with `late_attn`) |
231242
| `--data_folder` || required | Path to MR data folder |
232243
| `--jsonl_file` || required | Path to reports JSONL file |
233-
| `--space` | | `native_space` | Image space subfolder (only for space-based layout) |
244+
| `--space` | `native_space`, `coreg_space`, `atlas_space` | `native_space` | Selects the image subfolder under each study: `<space>/img/` (layout 1) or `img/` / `coreg_img/` / `atlas_img/` (layout 2) |
234245
| `--normalizer` | `zscore`, `percentile`, `minmax` | `zscore` | Volume normalization method |
246+
| `--pathology_labels_csv` ||| Path to pathology labels CSV (e.g. `mrrate_labels.csv`). Required for rebalancing. |
247+
| `--rebalance_strategy` | `inverse_freq`, `sqrt_inverse_freq`, `max_inverse_freq` | — (uniform) | Per-subject sampling weight strategy for upsampling rare pathologies. |
248+
| `--rebalance_base_weight` || `1.0` | Base sampling weight for all-negative / unlabeled subjects. |
235249
| `--num_train_steps` || `100001` | Total training steps |
236250
| `--lr` || `1e-5` | Learning rate |
237251
| `--results_folder` || `./mr_rate_results` | Checkpoint output directory |
@@ -249,6 +263,61 @@ FUSION_MODE=late_attn POOLING_STRATEGY=cross_attn NUM_TRAIN_STEPS=50000 \
249263
- **W&B integration**: `--wandb` logs loss, learning rate, volume count per step; run ID is persisted for seamless resume across SLURM jobs
250264
- **Gradient checkpointing**: Nested checkpointing at both volume level (in MRRATE) and chunk level (in sliding encoders) for maximum memory efficiency
251265
- **Dual checkpoints**: Saves both model-only `.pt` (for inference) and full `.pt` (model + optimizer + scheduler + step, for resume)
266+
- **Rare-pathology rebalancing** (see below): inverse-prevalence weighted sampling so contrastive batches see uncommon pathologies more often
267+
268+
### Rare-Pathology Rebalancing
269+
270+
Pathology prevalence in MR-RATE is heavily skewed (~43% of studies are all-negative; the rarest pathology, *Hemangioma of vertebral column*, occurs in ~0.23% of studies; *Gliosis* in ~37%). Under uniform sampling, contrastive batches are dominated by common findings and the model rarely sees rare ones. Passing `--pathology_labels_csv` together with `--rebalance_strategy` switches the training DataLoader to a `WeightedRandomSampler` whose per-subject weights are derived from inverse prevalence.
271+
272+
#### How the weighting works
273+
274+
Each subject's report is **multi-label** — a single scan can have zero, one, or many positive pathologies. Every subject is reduced to a single sampling weight `w_i`. Bigger weight = drawn more often. The three strategies differ in how a subject's binary label vector is collapsed into that one number.
275+
276+
Concrete walk-through with two pathologies and three subjects (using real prevalences from `mrrate_labels.csv`: *Hemangioma of vertebral column* ≈ 0.23% → inv-freq ≈ 433; *Gliosis* ≈ 37% → inv-freq ≈ 2.7):
277+
278+
| Subject | Hemangioma (rare) | Gliosis (common) | `inverse_freq` weight |
279+
|---------|-------------------|------------------|-----------------------|
280+
| A | 1 | 1 | `1 + 433 + 2.7 ≈ 436.7` |
281+
| B | 0 | 1 | `1 + 0 + 2.7 ≈ 3.7` |
282+
| C | 0 | 0 | `1` (base) |
283+
284+
Subject A is drawn ~436× more often than C, and ~118× more often than B.
285+
286+
#### Strategy choice
287+
288+
For each pathology `p`, `prevalence[p]` is the positive rate across labeled subjects in the active split, and `inv_freq[p] = 1 / max(prevalence[p], eps)`. The three strategies are different ways to aggregate across a subject's positive labels:
289+
290+
| Strategy | Formula | Subject A's weight | Behavior |
291+
|----------|---------|--------------------|----------|
292+
| `inverse_freq` | `base + Σ_p y_p · inv_freq[p]` | `436.7` | **Sum** — rewards multiple co-occurring rare findings. On `mrrate_labels.csv`, rarest pathology mass: 0.23% → ~4.0% (~17× boost). |
293+
| `sqrt_inverse_freq` | `base + Σ_p y_p · √inv_freq[p]` | `22.4` | Sum with diminishing returns. Rarest pathology mass: 0.23% → ~1.5%. Use when `inverse_freq` over-amplifies. |
294+
| `max_inverse_freq` | `max(base, max_p y_p · inv_freq[p])` | `433` | **Max** — a subject's weight is set by its single rarest positive pathology. Doesn't matter if they have one rare finding or five — caps combinatorial blow-up. |
295+
296+
Recommendation: start with `inverse_freq`. Switch to `sqrt_inverse_freq` if training loss gets noisy from over-sampling the same handful of rare subjects.
297+
298+
Subjects missing from the labels CSV — and all-negative subjects — receive `--rebalance_base_weight` (default `1.0`), so they remain in the pool but do not dominate it.
299+
300+
#### Enabling it
301+
302+
Add two flags to your existing training command:
303+
304+
```bash
305+
accelerate launch --multi_gpu --num_processes 4 scripts/run_train.py \
306+
--encoder vjepa2 --fusion_mode late \
307+
--data_folder /path/to/data \
308+
--jsonl_file /path/to/reports.jsonl \
309+
--pathology_labels_csv /path/to/mrrate_labels.csv \
310+
--rebalance_strategy inverse_freq
311+
```
312+
313+
Without those flags, nothing changes — uniform `shuffle=True` as before. With them, the dataset prints a one-line summary at startup so you can verify the math:
314+
315+
```
316+
[MRReportDataset] Rebalancing enabled (strategy=inverse_freq): 97896/97896 subjects matched labels CSV, weight range=[1.0, 1418.9], mean=33.0
317+
[Trainer] Using WeightedRandomSampler (strategy=inverse_freq)
318+
```
319+
320+
Total compute is unchanged (still `--num_train_steps` steps × batch_size × num_processes draws). Rebalancing only **redistributes** that draw budget toward rare-pathology subjects via `WeightedRandomSampler(..., replacement=True)`.
252321

253322
### Training Configuration
254323

contrastive-pretraining/scripts/data.py

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import torch
77
import torch.nn.functional as F
88
import nibabel as nib
9-
from torch.utils.data import Dataset
9+
from torch.utils.data import Dataset, WeightedRandomSampler
1010
from tqdm import tqdm
1111

1212

13+
REBALANCE_STRATEGIES = ('inverse_freq', 'sqrt_inverse_freq', 'max_inverse_freq')
14+
15+
1316
def cycle(dl):
1417
"""Helper to infinitely loop through a DataLoader."""
1518
while True:
@@ -91,6 +94,16 @@ def normalize(self, data):
9194
}
9295

9396

97+
# Mapping from logical space name to the image subdirectory used in the
98+
# raw HuggingFace download layout (layout 2). The native repo stores volumes
99+
# in `img/`, while derivative repos (coreg, atlas) use prefixed names.
100+
SPACE_TO_IMG_SUBDIR = {
101+
'native_space': 'img',
102+
'coreg_space': 'coreg_img',
103+
'atlas_space': 'atlas_img',
104+
}
105+
106+
94107
class MRReportDataset(Dataset):
95108
"""
96109
Dataset for brain MRI with variable numbers of volumes per subject.
@@ -120,6 +133,10 @@ def __init__(
120133
normalizer_kwargs=None,
121134
splits_csv=None,
122135
split="train",
136+
pathology_labels_csv=None,
137+
rebalance_strategy=None,
138+
rebalance_base_weight=1.0,
139+
rebalance_eps=1e-6,
123140
):
124141
self.data_folder = data_folder
125142
self.space = space
@@ -144,6 +161,17 @@ def __init__(
144161
# Discover subjects
145162
self.samples = self._prepare_samples(data_folder)
146163

164+
# Optional inverse-prevalence rebalancing weights for rare pathologies
165+
self.rebalance_strategy = rebalance_strategy
166+
self.label_columns = []
167+
self.label_prevalence = None
168+
self.sample_weights = self._compute_sample_weights(
169+
pathology_labels_csv,
170+
rebalance_strategy,
171+
rebalance_base_weight,
172+
rebalance_eps,
173+
)
174+
147175
print(f"[MRReportDataset] Found {len(self.samples)} subjects")
148176
for s in self.samples[:5]:
149177
print(f" - {s['subject_id']}: {len(s['image_paths'])} volumes, {len(s['sentences'])} sentences")
@@ -182,7 +210,13 @@ def _prepare_samples(self, data_folder):
182210
183211
Supports two directory layouts:
184212
1) data_folder/<study_uid>/<space>/img/*.nii.gz
185-
2) data_folder/batchXX/<study_uid>/img/*.nii.gz (legacy HuggingFace layout)
213+
2) data_folder/batchXX/<study_uid>/<img_subdir>/*.nii.gz
214+
(raw HuggingFace download layout; <img_subdir> depends on space)
215+
216+
For layout 2 the image subdirectory depends on the chosen space:
217+
- native_space -> img/ (Forithmus/MR-RATE)
218+
- coreg_space -> coreg_img/ (Forithmus/MR-RATE-coreg)
219+
- atlas_space -> atlas_img/ (Forithmus/MR-RATE-atlas)
186220
187221
Layout is auto-detected: if the first subdirectory contains a <space>
188222
subfolder, layout 1 is used; otherwise layout 2.
@@ -207,11 +241,12 @@ def _prepare_samples(self, data_folder):
207241
img_dir = os.path.join(data_folder, study_uid, self.space, 'img')
208242
self._add_subject(samples, study_uid, img_dir)
209243
else:
210-
# Layout 2: data_folder/batchXX/<study_uid>/img/
244+
# Layout 2: data_folder/batchXX/<study_uid>/<img_subdir>/
245+
img_subdir = SPACE_TO_IMG_SUBDIR.get(self.space, 'img')
211246
for batch_dir in first_level_dirs:
212247
batch_path = os.path.join(data_folder, batch_dir)
213248
for study_uid in sorted(os.listdir(batch_path)):
214-
img_dir = os.path.join(batch_path, study_uid, 'img')
249+
img_dir = os.path.join(batch_path, study_uid, img_subdir)
215250
self._add_subject(samples, study_uid, img_dir)
216251

217252
return samples
@@ -238,6 +273,119 @@ def _add_subject(self, samples, study_uid, img_dir):
238273
'sentences': self.subject_to_sentences[study_uid],
239274
})
240275

276+
def _compute_sample_weights(self, csv_path, strategy, base_weight, eps):
277+
"""Compute per-subject sampling weights from a pathology-labels CSV.
278+
279+
Inverse-prevalence weighting upsamples subjects with rare positive
280+
pathologies so contrastive batches see them more often. Subjects not
281+
listed in the CSV (or all-negative subjects) receive `base_weight`.
282+
283+
Strategies:
284+
- 'inverse_freq': base + sum_p y_p * (1 / prevalence_p)
285+
- 'sqrt_inverse_freq': base + sum_p y_p * sqrt(1 / prevalence_p)
286+
- 'max_inverse_freq': max(base, max_p y_p * (1 / prevalence_p))
287+
288+
Returns:
289+
A torch.FloatTensor of length len(self.samples) if rebalancing is
290+
enabled, else None. Weights are unnormalized (WeightedRandomSampler
291+
normalizes internally).
292+
"""
293+
if csv_path is None or strategy is None:
294+
return None
295+
if strategy not in REBALANCE_STRATEGIES:
296+
raise ValueError(
297+
f"Unknown rebalance_strategy '{strategy}'. "
298+
f"Choose from: {list(REBALANCE_STRATEGIES)}"
299+
)
300+
301+
labels_by_uid, label_columns = self._load_pathology_labels(csv_path)
302+
self.label_columns = label_columns
303+
304+
# Compute prevalence over the subset of dataset subjects that have labels
305+
label_rows = [labels_by_uid[s['subject_id']] for s in self.samples
306+
if s['subject_id'] in labels_by_uid]
307+
if not label_rows:
308+
print(
309+
f"[MRReportDataset] WARNING: no dataset subjects matched the "
310+
f"pathology labels CSV; rebalancing disabled."
311+
)
312+
return None
313+
label_matrix = np.stack(label_rows, axis=0)
314+
prevalence = label_matrix.mean(axis=0)
315+
self.label_prevalence = prevalence
316+
inv_freq = 1.0 / np.clip(prevalence, eps, None)
317+
318+
if strategy == 'sqrt_inverse_freq':
319+
per_class = np.sqrt(inv_freq)
320+
else:
321+
per_class = inv_freq
322+
323+
weights = np.full(len(self.samples), base_weight, dtype=np.float32)
324+
for i, s in enumerate(self.samples):
325+
y = labels_by_uid.get(s['subject_id'])
326+
if y is None:
327+
continue
328+
if strategy == 'max_inverse_freq':
329+
pos = y * inv_freq
330+
weights[i] = max(base_weight, float(pos.max()))
331+
else:
332+
weights[i] = base_weight + float((y * per_class).sum())
333+
334+
n_labeled = sum(1 for s in self.samples if s['subject_id'] in labels_by_uid)
335+
print(
336+
f"[MRReportDataset] Rebalancing enabled (strategy={strategy}): "
337+
f"{n_labeled}/{len(self.samples)} subjects matched labels CSV, "
338+
f"weight range=[{weights.min():.3g}, {weights.max():.3g}], "
339+
f"mean={weights.mean():.3g}"
340+
)
341+
return torch.from_numpy(weights)
342+
343+
@staticmethod
344+
def _load_pathology_labels(csv_path):
345+
"""Load a pathology labels CSV.
346+
347+
Expects a header row with 'study_uid' (or 'subject_id') followed by
348+
one binary column per pathology. Returns (dict uid -> np.ndarray,
349+
list of label column names).
350+
"""
351+
labels = {}
352+
with open(csv_path, 'r') as f:
353+
reader = csv.DictReader(f)
354+
fields = reader.fieldnames or []
355+
if 'study_uid' in fields:
356+
id_col = 'study_uid'
357+
elif 'subject_id' in fields:
358+
id_col = 'subject_id'
359+
else:
360+
raise ValueError(
361+
f"Pathology labels CSV {csv_path} must have a 'study_uid' "
362+
f"or 'subject_id' column. Got: {fields}"
363+
)
364+
label_columns = [c for c in fields if c != id_col]
365+
for row in reader:
366+
labels[row[id_col]] = np.array(
367+
[float(row[c]) for c in label_columns], dtype=np.float32
368+
)
369+
return labels, label_columns
370+
371+
def get_weighted_sampler(self, num_samples=None, generator=None):
372+
"""Build a WeightedRandomSampler from the precomputed sample weights.
373+
374+
Replacement sampling is required so high-weight (rare-pathology)
375+
subjects can be drawn multiple times per epoch.
376+
"""
377+
if self.sample_weights is None:
378+
raise RuntimeError(
379+
"sample_weights not computed; pass pathology_labels_csv and "
380+
"rebalance_strategy to the dataset constructor."
381+
)
382+
return WeightedRandomSampler(
383+
weights=self.sample_weights,
384+
num_samples=num_samples or len(self.samples),
385+
replacement=True,
386+
generator=generator,
387+
)
388+
241389
def __len__(self):
242390
return len(self.samples)
243391

contrastive-pretraining/scripts/data_inference.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
import nibabel as nib
3030
from torch.utils.data import Dataset
3131

32-
from data import NORMALIZERS, resize_array
32+
from data import NORMALIZERS, SPACE_TO_IMG_SUBDIR, resize_array
3333

3434

3535
class MRReportDatasetInfer(Dataset):
@@ -128,7 +128,8 @@ def _prepare_samples(self, data_folder):
128128
129129
Supports two directory layouts (same auto-detection as training):
130130
1) data_folder/<study_uid>/<space>/img/*.nii.gz
131-
2) data_folder/batchXX/<study_uid>/img/*.nii.gz
131+
2) data_folder/batchXX/<study_uid>/<img_subdir>/*.nii.gz
132+
(img_subdir is space-dependent: img/, coreg_img/, atlas_img/)
132133
"""
133134
samples = []
134135

@@ -147,10 +148,11 @@ def _prepare_samples(self, data_folder):
147148
img_dir = os.path.join(data_folder, study_uid, self.space, 'img')
148149
self._add_subject(samples, study_uid, img_dir)
149150
else:
151+
img_subdir = SPACE_TO_IMG_SUBDIR.get(self.space, 'img')
150152
for batch_dir in first_level_dirs:
151153
batch_path = os.path.join(data_folder, batch_dir)
152154
for study_uid in sorted(os.listdir(batch_path)):
153-
img_dir = os.path.join(batch_path, study_uid, 'img')
155+
img_dir = os.path.join(batch_path, study_uid, img_subdir)
154156
self._add_subject(samples, study_uid, img_dir)
155157

156158
return samples

0 commit comments

Comments
 (0)