Download scripts/train.py from habib-analyst/vesselcnn: direct link, hf CLI and curl.
- Browser
- Download file 12.4 kB
-
https://huggingface.co/habib-analyst/vesselcnn/resolve/main/scripts/train.py
- Command line
-
hf download hf://habib-analyst/vesselcnn/scripts/train.py
-
curl -L -o train.py https://huggingface.co/habib-analyst/vesselcnn/resolve/main/scripts/train.py
12.4 kB
| """Train VesselCNN from scratch on VesselMNIST3D — the exact script that | |
| produced the released weights. | |
| Protocol: | |
| - VesselMNIST3D official splits (train 1,335 / val 191 / test 382), | |
| downloaded from Zenodo (record 10519652, md5 b41fd4f7e7e2feedddb201585ecafa1b). | |
| - Inputs: /255, normalized with the train-split mean/std (the rounded | |
| contract values in vesselcnn.infer.TRAIN_MEAN/TRAIN_STD, re-derived | |
| here with chunked float64 accumulation and asserted to agree). | |
| - Augmentation (train only): a random element of the octahedral group | |
| per volume per epoch — a random axis permutation composed with | |
| random axis flips (48 transforms). Vessel segments are cut from | |
| vessel trees at arbitrary orientations and "aneurysm" is a local | |
| shape property, so every element of the group is label-preserving. | |
| - Loss: class-weighted cross-entropy, weights N/(2 * n_class) = | |
| [0.5633, 4.4500] for the train split's 1,185 vessel / 150 aneurysm | |
| volumes. Plain CE on an 88.8%-majority split would pay the model | |
| to ignore the aneurysm class; the weighting is stated here and in | |
| the metrics record rather than hidden. | |
| - AdamW (lr 2e-3, weight decay 1e-4, OneCycle cosine), batch 32, | |
| 30 epochs, seed 42 throughout. | |
| - Checkpoint selected by best validation balanced accuracy (ties | |
| broken by validation AUC); the test split is evaluated exactly | |
| once, after selection, and recorded in the metrics JSON. | |
| examples/verify_metrics.py later reproduces those test numbers | |
| from the shipped weights. | |
| - Resumable: after every epoch the full training state is saved to | |
| <out>/last.pt; rerun with --resume to continue after an | |
| interruption with no work lost. | |
| Usage: | |
| python scripts/train.py --data /path/to/vesselmnist3d.npz --out /path/to/workdir [--resume] | |
| """ | |
| import argparse | |
| import json | |
| import os | |
| import random | |
| import sys | |
| import time | |
| import numpy as np | |
| sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) | |
| import torch # noqa: E402 | |
| import torch.nn as nn # noqa: E402 | |
| from sklearn.metrics import (accuracy_score, balanced_accuracy_score, # noqa: E402 | |
| confusion_matrix, precision_score, | |
| recall_score, roc_auc_score) | |
| from vesselcnn.infer import LABELS, TRAIN_MEAN, TRAIN_STD # noqa: E402 | |
| from vesselcnn.model import build_model # noqa: E402 | |
| NUM_CLASSES = 2 | |
| def compute_split_stats(images_u8): | |
| """Scalar mean/std with chunked float64 accumulation. | |
| A single float32 numpy reduction over a whole split can silently | |
| return wrong statistics at this size (the family's documented | |
| trap, measured on PathMNIST). Chunked float64 is the safe route. | |
| """ | |
| s = 0.0 | |
| ss = 0.0 | |
| n = 0 | |
| for i in range(0, len(images_u8), 200): | |
| xb = images_u8[i:i + 200].astype(np.float64) / 255.0 | |
| s += float(xb.sum()) | |
| ss += float((xb ** 2).sum()) | |
| n += xb.size | |
| mean = s / n | |
| std = float(np.sqrt(ss / n - mean ** 2)) | |
| return float(mean), std | |
| def normalize(x_u8_ncdhw): | |
| """uint8 NCDHW tensor -> normalized float tensor (contract stats).""" | |
| x = x_u8_ncdhw.float() / 255.0 | |
| return (x - TRAIN_MEAN) / TRAIN_STD | |
| def augment(x_ncdhw, gen): | |
| """Random octahedral transform per volume (axis permutation + flips). | |
| The permutation is drawn per volume from the 6 spatial-axis orders; | |
| each spatial axis is then flipped with probability 1/2. Voxel sums | |
| are preserved exactly (it is a relabelling of voxel positions), and | |
| the vessel/aneurysm label is a property of the shape, not of its | |
| orientation in the cube. | |
| """ | |
| n = x_ncdhw.shape[0] | |
| out = torch.empty_like(x_ncdhw) | |
| perms = [(0, 1, 2), (0, 2, 1), (1, 0, 2), (1, 2, 0), (2, 0, 1), (2, 1, 0)] | |
| for i in range(n): | |
| v = x_ncdhw[i] | |
| p = perms[int(torch.randint(0, 6, (1,), generator=gen))] | |
| v = v.permute(0, p[0] + 1, p[1] + 1, p[2] + 1) | |
| flips = [d + 1 for d in range(3) if int(torch.randint(0, 2, (1,), generator=gen))] | |
| if flips: | |
| v = torch.flip(v, dims=flips) | |
| out[i] = v | |
| return out.contiguous() | |
| def evaluate(model, images_u8, batch=64): | |
| """Return (probs np array, preds np array) for a split, contract preprocessing.""" | |
| model.eval() | |
| xt = torch.from_numpy(images_u8)[:, None] | |
| probs = [] | |
| for i in range(0, len(xt), batch): | |
| xb = normalize(xt[i:i + batch]) | |
| probs.append(torch.softmax(model(xb), dim=1)) | |
| probs = torch.cat(probs).numpy() | |
| return probs, probs.argmax(1) | |
| def full_metrics(y_true, probs, preds): | |
| cm = confusion_matrix(y_true, preds, labels=list(range(NUM_CLASSES))) | |
| rec = recall_score(y_true, preds, labels=list(range(NUM_CLASSES)), | |
| average=None, zero_division=0) | |
| prec = precision_score(y_true, preds, labels=list(range(NUM_CLASSES)), | |
| average=None, zero_division=0) | |
| return { | |
| "accuracy": round(float(accuracy_score(y_true, preds)), 4), | |
| "auc": round(float(roc_auc_score(y_true, probs[:, 1])), 4), | |
| "balanced_accuracy": round(float(balanced_accuracy_score(y_true, preds)), 4), | |
| "confusion_matrix": cm.tolist(), | |
| "per_class_recall": {LABELS[i]: round(float(rec[i]), 4) for i in range(NUM_CLASSES)}, | |
| "per_class_precision": {LABELS[i]: round(float(prec[i]), 4) for i in range(NUM_CLASSES)}, | |
| "class_counts": np.bincount(y_true, minlength=NUM_CLASSES).tolist(), | |
| "majority_baseline": round(float(np.bincount(y_true).max() / len(y_true)), 4), | |
| "n": int(len(y_true)), | |
| } | |
| def main(): | |
| ap = argparse.ArgumentParser() | |
| ap.add_argument("--data", required=True, help="path to vesselmnist3d.npz") | |
| ap.add_argument("--out", required=True, help="work dir for checkpoints + metrics") | |
| ap.add_argument("--epochs", type=int, default=30) | |
| ap.add_argument("--batch", type=int, default=32) | |
| ap.add_argument("--lr", type=float, default=2e-3) | |
| ap.add_argument("--seed", type=int, default=42) | |
| ap.add_argument("--resume", action="store_true", | |
| help="resume from <out>/last.pt if it exists") | |
| args = ap.parse_args() | |
| os.makedirs(args.out, exist_ok=True) | |
| random.seed(args.seed) | |
| np.random.seed(args.seed) | |
| torch.manual_seed(args.seed) | |
| torch.set_num_threads(2) | |
| gen = torch.Generator().manual_seed(args.seed) | |
| d = np.load(args.data) | |
| x_train_u8 = d["train_images"] | |
| y_train = d["train_labels"].ravel().astype(np.int64) | |
| x_val_u8 = d["val_images"] | |
| y_val = d["val_labels"].ravel().astype(np.int64) | |
| x_test_u8 = d["test_images"] | |
| y_test = d["test_labels"].ravel().astype(np.int64) | |
| exact_mean, exact_std = compute_split_stats(x_train_u8) | |
| print(f"train stats (chunked float64): mean={exact_mean:.5f} std={exact_std:.5f}", | |
| flush=True) | |
| print(f"contract stats used: mean={TRAIN_MEAN} std={TRAIN_STD}", flush=True) | |
| assert abs(exact_mean - TRAIN_MEAN) < 1e-4 | |
| assert abs(exact_std - TRAIN_STD) < 1e-4 | |
| counts = np.bincount(y_train, minlength=NUM_CLASSES) | |
| class_weights = len(y_train) / (NUM_CLASSES * counts) | |
| print(f"train class counts: {counts.tolist()}; " | |
| f"class weights: {class_weights.round(4).tolist()}", flush=True) | |
| xt = torch.from_numpy(x_train_u8)[:, None] # uint8 NCDHW in RAM | |
| yt = torch.from_numpy(y_train) | |
| model = build_model() | |
| n_params = sum(p.numel() for p in model.parameters()) | |
| print(f"model parameters: {n_params}", flush=True) | |
| opt = torch.optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4) | |
| steps_per_epoch = (len(xt) + args.batch - 1) // args.batch | |
| sched = torch.optim.lr_scheduler.OneCycleLR( | |
| opt, max_lr=args.lr, total_steps=steps_per_epoch * args.epochs, | |
| pct_start=0.1, anneal_strategy="cos") | |
| lossf = nn.CrossEntropyLoss( | |
| weight=torch.tensor(class_weights, dtype=torch.float32)) | |
| best_path = os.path.join(args.out, "vesselcnn.pt") | |
| last_path = os.path.join(args.out, "last.pt") | |
| log = [] | |
| best_key = (-1.0, -1.0) # (val balanced accuracy, val AUC) | |
| start_epoch = 0 | |
| if args.resume and os.path.exists(last_path): | |
| ck = torch.load(last_path, map_location="cpu", weights_only=False) | |
| model.load_state_dict(ck["state_dict"]) | |
| opt.load_state_dict(ck["optimizer"]) | |
| sched.load_state_dict(ck["scheduler"]) | |
| start_epoch = ck["epoch"] | |
| log = ck["log"] | |
| best_key = tuple(ck["best_key"]) | |
| gen.set_state(ck["gen_state"]) | |
| print(f"resumed at epoch {start_epoch}; best so far " | |
| f"(bal_acc, auc) = {best_key}", flush=True) | |
| t_start = time.time() | |
| for epoch in range(start_epoch, args.epochs): | |
| model.train() | |
| perm = torch.randperm(len(xt), generator=gen) | |
| ep_loss, ep_correct, ep_n = 0.0, 0, 0 | |
| t0 = time.time() | |
| for i in range(0, len(xt), args.batch): | |
| idx = perm[i:i + args.batch] | |
| xb = augment(normalize(xt[idx]), gen) | |
| yb = yt[idx] | |
| opt.zero_grad() | |
| out = model(xb) | |
| loss = lossf(out, yb) | |
| loss.backward() | |
| opt.step() | |
| sched.step() | |
| ep_loss += loss.item() * len(idx) | |
| ep_correct += (out.argmax(1) == yb).sum().item() | |
| ep_n += len(idx) | |
| val_probs, val_preds = evaluate(model, x_val_u8) | |
| val_acc = float(accuracy_score(y_val, val_preds)) | |
| val_bal = float(balanced_accuracy_score(y_val, val_preds)) | |
| val_auc = float(roc_auc_score(y_val, val_probs[:, 1])) | |
| row = { | |
| "epoch": epoch + 1, | |
| "train_loss": round(ep_loss / ep_n, 4), | |
| "train_acc": round(ep_correct / ep_n, 4), | |
| "val_acc": round(val_acc, 4), | |
| "val_balanced_acc": round(val_bal, 4), | |
| "val_auc": round(val_auc, 4), | |
| "epoch_seconds": round(time.time() - t0, 1), | |
| } | |
| log.append(row) | |
| print(json.dumps(row), flush=True) | |
| key = (round(val_bal, 4), round(val_auc, 4)) | |
| if key > best_key: | |
| best_key = key | |
| torch.save({ | |
| "state_dict": model.state_dict(), | |
| "epoch": epoch + 1, | |
| "val_acc": val_acc, | |
| "val_balanced_acc": val_bal, | |
| "val_auc": val_auc, | |
| "seed": args.seed, | |
| "train_mean": TRAIN_MEAN, | |
| "train_std": TRAIN_STD, | |
| "class_weights": class_weights.tolist(), | |
| }, best_path) | |
| print(f" saved best checkpoint (val bal_acc {val_bal:.4f}, " | |
| f"auc {val_auc:.4f})", flush=True) | |
| torch.save({ | |
| "state_dict": model.state_dict(), | |
| "optimizer": opt.state_dict(), | |
| "scheduler": sched.state_dict(), | |
| "epoch": epoch + 1, | |
| "log": log, | |
| "best_key": list(best_key), | |
| "gen_state": gen.get_state(), | |
| }, last_path) | |
| total_seconds = round(time.time() - t_start, 1) | |
| # Final: load the best-val checkpoint and evaluate the test split once. | |
| ckpt = torch.load(best_path, map_location="cpu", weights_only=False) | |
| model.load_state_dict(ckpt["state_dict"]) | |
| test_probs, test_preds = evaluate(model, x_test_u8) | |
| metrics = { | |
| "model": "VesselCNN", | |
| "version": "0.1.0", | |
| "dataset": "VesselMNIST3D (MedMNIST v2), official splits", | |
| "seed": args.seed, | |
| "epochs_run": args.epochs, | |
| "batch": args.batch, | |
| "lr": args.lr, | |
| "class_weights": class_weights.round(4).tolist(), | |
| "best_epoch": ckpt["epoch"], | |
| "best_val_acc": round(float(ckpt["val_acc"]), 4), | |
| "best_val_balanced_acc": round(float(ckpt["val_balanced_acc"]), 4), | |
| "best_val_auc": round(float(ckpt["val_auc"]), 4), | |
| "train_seconds_total": total_seconds, | |
| "train_mean_exact": round(exact_mean, 5), | |
| "train_std_exact": round(exact_std, 5), | |
| "test": full_metrics(y_test, test_probs, test_preds), | |
| "epoch_log": log, | |
| } | |
| with open(os.path.join(args.out, "vesselcnn_metrics.json"), "w") as f: | |
| json.dump(metrics, f, indent=2) | |
| print(json.dumps(metrics["test"], indent=2), flush=True) | |
| print(f"DONE total_seconds={total_seconds} best_epoch={ckpt['epoch']} " | |
| f"best_key={best_key}", flush=True) | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |