Patient-wise Dataset Splitting
This notebook shows how to use group_by_patient() and split(by_patient=True) on a DICOM dataset.
The Problem: Data Leakage
Standard random splitting shuffles individual resources. If a patient has multiple scans (e.g. a baseline CT and a follow-up CT), both scans can end up in different splits (one in train, one in test). Because the images share patient anatomy, the model effectively sees the test patient during training, inflating metrics.
Patient-wise splitting fixes this by shuffling patients instead of resources: every scan belonging to a patient lands in the same split.
Resource-wise (risky) Patient-wise (safe)
────────────────────── ──────────────────────
train: scan_A1, scan_B1 train: patient_A (scan_A1, scan_A2)
test: scan_A2, scan_B2 test: patient_B (scan_B1, scan_B2)
↑ same patient! ↑ clean boundary
What You’ll Learn
How
patient_idis exposed on resourcesgroup_by_patient()— organise a dataset by patient for inspection or custom splitssplit(by_patient=True)— an option for the splitHow to persist patient-wise splits to the server and reload them reproducibly
How to handle resources without a
patient_id
1. Setup
We use a small set of DICOM files bundled with pydicom as example data. Each file already carries a PatientID tag in its header.
The upload runs only once: if the project already exists we skip straight to loading the dataset.
[7]:
from pathlib import Path
import pydicom
import pydicom.data
from datamint import Api
from datamint.dataset import ImageDataset
PROJECT_NAME = "split_save_test"
# 8 patients selected from pydicom's
SELECTED_PATIENTS = {
'4MR1', '8NM1', '13US1', 'CQ500-CT-310',
'1CT1', '021234567', '11-05-25-142825', '204',
}
def _collect_dicom_files() -> list[str]:
data_root = Path(pydicom.data.DATA_ROOT) / 'test_files'
selected = []
for f in sorted(data_root.glob('*.dcm')):
try:
ds = pydicom.dcmread(str(f), stop_before_pixels=True, force=True)
if getattr(ds, 'PatientID', None) in SELECTED_PATIENTS:
selected.append(str(f))
except Exception:
pass
return selected
api = Api()
project = api.projects.get_by_name(PROJECT_NAME)
if project is None:
project = api.projects.create(PROJECT_NAME, description="Patient Split Demo")
dicom_files = _collect_dicom_files()
print(f"Uploading {len(dicom_files)} DICOM files for {len(SELECTED_PATIENTS)} patients...")
api.resources.upload_resources(
dicom_files,
publish_to=project,
assemble_dicoms=False,
progress_bar=True,
)
print("Upload complete.")
else:
print(f"Project '{PROJECT_NAME}' already exists, skipping upload.")
dataset = ImageDataset(project=project, include_unannotated=True)
print(f"\nLoaded {len(dataset)} resources")
Project 'split_save_test' already exists, skipping upload.
Loaded 20 resources
2. Inspecting patient_id
For DICOM files the platform extracts PatientID from the DICOM header automatically at upload time and stores it as a top-level field on the resource. No manual assignment needed.
[8]:
for r in dataset.resources[:5]:
print(f"{r.filename:40s} patient_id={r.patient_id!r}")
MR_truncated.dcm patient_id='4MR1'
MR_small_RLE.dcm patient_id='4MR1'
JPEG2000-embedded-sequence-delimiter.dcm patient_id='8NM1'
JPEG2000.dcm patient_id='8NM1'
MR_small.dcm patient_id='4MR1'
3. group_by_patient()
group_by_patient() returns a dict mapping each patient ID to a sub-dataset containing only that patient’s resources.
Handling patient_id=None
Well-formed DICOM files always carry a PatientID tag, so all resources here should have one. In edge cases, anonymised DICOMs where the tag was stripped, patient_id will be None. The none_patient_id_strategy parameter controls what to do:
Strategy |
Behaviour |
|---|---|
|
Each |
|
All grouped together under key |
|
Excluded from the output entirely |
|
Raises |
[9]:
groups = dataset.group_by_patient()
sizes = [len(g) for g in groups.values()]
print(f"Patient groups : {len(groups)}")
print(f"Total resources: {sum(sizes)}")
print(f"Per-group size : min={min(sizes)}, max={max(sizes)}")
print()
for pid, g in groups.items():
print(f" {pid:20s} {len(g):2d} resource(s)")
Patient groups : 8
Total resources: 20
Per-group size : min=1, max=9
4MR1 9 resource(s)
8NM1 4 resource(s)
1CT1 1 resource(s)
13US1 2 resource(s)
CQ500-CT-310 1 resource(s)
11-05-25-142825 1 resource(s)
021234567 1 resource(s)
204 1 resource(s)
4. split(by_patient=True) — Compute Patient-wise Splits Locally
When passing by_patient=True, the split shuffles patients instead of individual resources. Internally it shuffles patients, assigns patient buckets to splits by ratio, then collects all resources from each bucket.
[10]:
parts = dataset.split(
train=0.7,
val=0.15,
test=0.15,
by_patient=True,
seed=42,
)
for name, ds in parts.items():
print(f"{name:6s}: {len(ds):4d} resources")
train : 7 resources
val : 9 resources
test : 4 resources
5. Persist Splits to the Server
The parts object returned by split() is a SplitResult — it behaves exactly like a dict, but it also knows how to save itself to the server via .save().
Once persisted, anyone can reload exactly the same split (without re-running the patient split logic) by calling dataset.split() with no arguments.
If the project already has split assignments, .save() raises an error. Pass force=True to overwrite.
[11]:
parts.save()
# If the project already has splits, this raises:
# ValueError: Project '...' already has split assignments. Use save(force=True) to overwrite.
# To overwrite:
# parts.save(force=True)
6. Reload Splits from the Server
After persisting, the splits are loaded back just like any project-scoped split, no by_patient, no ratios. The split_as_of_timestamp returned on each sub-dataset can be stored and replayed later to recover the exact same snapshot.
[12]:
# Reload the dataset fresh (simulates a new session)
dataset_fresh = ImageDataset(project=project, include_unannotated=True)
# No by_patient, no ratios, reads from project API
reloaded = dataset_fresh.split()
for name, ds in reloaded.items():
print(f"{name:6s}: {len(ds):4d} resources")
val : 9 resources
test : 4 resources
train : 7 resources
[13]:
# Replay the exact same snapshot at any later point
snapshot_ts = reloaded['train'].split_as_of_timestamp
replayed = dataset_fresh.split(as_of_timestamp=snapshot_ts)
print(f"Replaying split snapshot from {snapshot_ts}")
print({name: len(ds) for name, ds in replayed.items()})
Replaying split snapshot from 2026-06-19T16:37:40.622014Z
{'val': 9, 'test': 4, 'train': 7}