Download scripts/train_sam3_decoder_segmentation.py from Tao-AI-Informatics/SAM3-LoRA-Breast-Lesion: direct link, hf CLI and curl.
- Browser
- Download file 15.2 kB
-
https://huggingface.co/Tao-AI-Informatics/SAM3-LoRA-Breast-Lesion/resolve/main/scripts/train_sam3_decoder_segmentation.py
- Command line
-
hf download hf://Tao-AI-Informatics/SAM3-LoRA-Breast-Lesion/scripts/train_sam3_decoder_segmentation.py
-
curl -L -o train_sam3_decoder_segmentation.py https://huggingface.co/Tao-AI-Informatics/SAM3-LoRA-Breast-Lesion/resolve/main/scripts/train_sam3_decoder_segmentation.py
15.2 kB
| #!/usr/bin/env python3 | |
| import argparse | |
| import json | |
| import os | |
| import time | |
| from pathlib import Path | |
| import pandas as pd | |
| import torch | |
| from torch.utils.data import DataLoader | |
| from tqdm import tqdm | |
| from sam3_decoder_experiment_lib import ( | |
| PublicSegmentationDataset, | |
| SAM3FeatureModel, | |
| binary_metrics, | |
| bootstrap_ci, | |
| choose_visual_examples, | |
| dice_bce_loss, | |
| ensure_dir, | |
| save_overlay, | |
| save_prediction, | |
| seed_everything, | |
| split_dataset, | |
| summarize_metrics, | |
| write_csv, | |
| write_json, | |
| ) | |
| def parse_args(): | |
| p = argparse.ArgumentParser() | |
| p.add_argument("--dataset_csv", required=True) | |
| p.add_argument("--protocol", choices=["all_public_indomain", "leave_one_dataset_out", "source_to_target"], required=True) | |
| p.add_argument("--heldout_dataset", default=None) | |
| p.add_argument("--train_datasets", default=None, help="Comma-separated source datasets for source_to_target.") | |
| p.add_argument("--test_dataset", default=None, help="Target dataset for source_to_target.") | |
| p.add_argument("--output_dir", required=True) | |
| bundle_root = Path(__file__).resolve().parent.parent | |
| p.add_argument( | |
| "--sam3_checkpoint", | |
| default=os.environ.get("SAM3_CHECKPOINT", str(bundle_root / "model" / "sam3_base.pt")), | |
| ) | |
| p.add_argument("--encoder", default="sam3") | |
| p.add_argument("--encoder_trainable", choices=["frozen", "last_block", "lora"], default="frozen") | |
| p.add_argument("--decoder", choices=["sam3_native", "cnn", "unet", "segformer"], required=True) | |
| p.add_argument("--prompt_type", choices=["none", "semantic_text", "gt_bbox", "text"], default="none") | |
| p.add_argument("--prompt_text", default="breast tumor") | |
| p.add_argument("--prompt_column", default=None, help="Optional dataset column containing one semantic prompt per row.") | |
| p.add_argument("--lora_rank", type=int, default=8) | |
| p.add_argument("--lora_alpha", type=float, default=16) | |
| p.add_argument("--no_train_native_head", action="store_true", help="For encoder_trainable=lora, train only LoRA params and freeze native heads.") | |
| p.add_argument("--image_size", type=int, default=512) | |
| p.add_argument("--epochs", type=int, default=100) | |
| p.add_argument("--patience", type=int, default=15) | |
| p.add_argument("--batch_size", type=int, default=2) | |
| p.add_argument("--lr", type=float, default=1e-4) | |
| p.add_argument("--encoder_lr", type=float, default=1e-5) | |
| p.add_argument("--weight_decay", type=float, default=1e-4) | |
| p.add_argument("--num_workers", type=int, default=4) | |
| p.add_argument("--use_amp", action="store_true") | |
| p.add_argument("--seed", type=int, default=42) | |
| p.add_argument("--resume", action="store_true") | |
| p.add_argument("--debug", action="store_true") | |
| p.add_argument("--max_train_samples", type=int, default=None) | |
| p.add_argument("--max_val_samples", type=int, default=None) | |
| p.add_argument("--max_test_samples", type=int, default=None) | |
| p.add_argument("--bootstrap_resamples", type=int, default=2000) | |
| return p.parse_args() | |
| def make_loader(df, image_size, batch_size, num_workers, train): | |
| ds = PublicSegmentationDataset(df, image_size=image_size, augment=train) | |
| return DataLoader( | |
| ds, | |
| batch_size=batch_size, | |
| shuffle=train, | |
| num_workers=num_workers, | |
| pin_memory=torch.cuda.is_available(), | |
| drop_last=False, | |
| ) | |
| def make_optimizer(model, args): | |
| encoder_params = [] | |
| decoder_params = [] | |
| for name, p in model.named_parameters(): | |
| if not p.requires_grad: | |
| continue | |
| if "backbone" in name: | |
| encoder_params.append(p) | |
| else: | |
| decoder_params.append(p) | |
| groups = [] | |
| if decoder_params: | |
| groups.append({"params": decoder_params, "lr": args.lr}) | |
| if encoder_params: | |
| groups.append({"params": encoder_params, "lr": args.encoder_lr}) | |
| return torch.optim.AdamW(groups, weight_decay=args.weight_decay) | |
| def batch_prompt_texts(batch, args): | |
| if args.prompt_column: | |
| prompts = batch.get(args.prompt_column) | |
| if prompts is None: | |
| raise KeyError(f"Prompt column {args.prompt_column!r} is missing from dataset batch") | |
| return list(prompts) | |
| return None | |
| def train_one_epoch(model, loader, optimizer, scaler, device, use_amp, args): | |
| model.train() | |
| losses = [] | |
| for batch in tqdm(loader, desc="train", leave=False): | |
| images = batch["image"].to(device, non_blocking=True) | |
| masks = batch["mask"].to(device, non_blocking=True) | |
| optimizer.zero_grad(set_to_none=True) | |
| with torch.cuda.amp.autocast(enabled=use_amp and device.type == "cuda"): | |
| logits = model(images, masks, prompt_texts=batch_prompt_texts(batch, args)) | |
| loss = dice_bce_loss(logits, masks) | |
| if scaler is not None: | |
| scaler.scale(loss).backward() | |
| scaler.step(optimizer) | |
| scaler.update() | |
| else: | |
| loss.backward() | |
| optimizer.step() | |
| losses.append(float(loss.detach().cpu())) | |
| return sum(losses) / max(1, len(losses)) | |
| def run_eval(model, loader, device, threshold=0.5, save_dir=None, save_outputs=False, seed=42, args=None): | |
| model.eval() | |
| rows = [] | |
| pred_dir = Path(save_dir) / "masks" if save_dir and save_outputs else None | |
| vis_dir = Path(save_dir) / "overlays" if save_dir and save_outputs else None | |
| if pred_dir: | |
| ensure_dir(pred_dir) | |
| ensure_dir(vis_dir) | |
| cache_for_vis = [] | |
| for batch in tqdm(loader, desc="eval", leave=False): | |
| images = batch["image"].to(device, non_blocking=True) | |
| masks = batch["mask"].to(device, non_blocking=True) | |
| logits = model(images, masks, prompt_texts=batch_prompt_texts(batch, args) if args else None) | |
| probs = torch.sigmoid(logits).detach().cpu().numpy() | |
| gt = masks.detach().cpu().numpy() | |
| for i in range(probs.shape[0]): | |
| prob_i = probs[i, 0] | |
| gt_i = gt[i, 0] | |
| metrics = binary_metrics(prob_i, gt_i, threshold=threshold) | |
| row = { | |
| "dataset": batch["dataset"][i], | |
| "label": batch["label"][i], | |
| "label_id": int(batch["label_id"][i]), | |
| "case_id": batch["case_id"][i], | |
| "image_name": batch["image_name"][i], | |
| "mask_name": batch["mask_name"][i], | |
| "image_path": batch["image_path"][i], | |
| "threshold": float(threshold), | |
| **metrics, | |
| } | |
| rows.append(row) | |
| if save_outputs: | |
| safe_name = Path(row["image_name"]).stem | |
| pred_path = pred_dir / f"{safe_name}_pred.png" | |
| save_prediction(prob_i, pred_path) | |
| cache_for_vis.append((row, gt_i, prob_i >= threshold)) | |
| if save_outputs and cache_for_vis: | |
| by_name = {row["image_name"]: (row, gt_i, pred_i) for row, gt_i, pred_i in cache_for_vis} | |
| for group, examples in choose_visual_examples(rows, seed=seed).items(): | |
| group_dir = vis_dir / group | |
| ensure_dir(group_dir) | |
| for row in examples: | |
| cached = by_name.get(row["image_name"]) | |
| if cached is None: | |
| continue | |
| _, gt_i, pred_i = cached | |
| save_overlay(row["image_path"], gt_i, pred_i, row, group_dir / f"{Path(row['image_name']).stem}.png") | |
| return rows | |
| def select_threshold(model, loader, device, args=None): | |
| candidates = [0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7] | |
| best_t = 0.5 | |
| best_dice = -1.0 | |
| for t in candidates: | |
| rows = run_eval(model, loader, device, threshold=t, save_outputs=False, args=args) | |
| dice = pd.DataFrame(rows)["dice"].mean() | |
| if dice > best_dice: | |
| best_dice = float(dice) | |
| best_t = float(t) | |
| return best_t, best_dice | |
| def save_eval_outputs(rows, output_dir, prefix, bootstrap_resamples, seed): | |
| output_dir = Path(output_dir) | |
| write_csv(output_dir / f"{prefix}_per_image_metrics.csv", rows) | |
| overall = summarize_metrics(rows) | |
| overall.update(bootstrap_ci(rows, "dice", n_resamples=bootstrap_resamples, seed=seed)) | |
| overall.update(bootstrap_ci(rows, "iou", n_resamples=bootstrap_resamples, seed=seed + 1)) | |
| write_json(output_dir / f"{prefix}_overall_metrics.json", overall) | |
| by_dataset = summarize_metrics(rows, ["dataset"]) | |
| by_label = summarize_metrics(rows, ["label"]) | |
| by_dataset.to_csv(output_dir / f"{prefix}_metrics_by_dataset.csv", index=False) | |
| by_label.to_csv(output_dir / f"{prefix}_metrics_by_label.csv", index=False) | |
| write_json(output_dir / f"{prefix}_metrics_by_dataset.json", by_dataset.to_dict(orient="records")) | |
| write_json(output_dir / f"{prefix}_metrics_by_label.json", by_label.to_dict(orient="records")) | |
| return overall | |
| def main(): | |
| args = parse_args() | |
| if args.prompt_type == "text": | |
| args.prompt_type = "semantic_text" | |
| if args.prompt_type == "none": | |
| args.prompt_text = "" | |
| seed_everything(args.seed) | |
| output_dir = Path(args.output_dir) | |
| ensure_dir(output_dir) | |
| if (output_dir / "best_model.pt").exists() and (output_dir / "test_overall_metrics.json").exists() and not args.resume: | |
| print(f"Skip existing finished run: {output_dir}") | |
| return | |
| df = pd.read_csv(args.dataset_csv) | |
| train_df, val_df, test_df = split_dataset( | |
| df, | |
| args.protocol, | |
| heldout_dataset=args.heldout_dataset, | |
| train_datasets=args.train_datasets, | |
| test_dataset=args.test_dataset, | |
| seed=args.seed, | |
| max_train_samples=args.max_train_samples, | |
| max_val_samples=args.max_val_samples, | |
| max_test_samples=args.max_test_samples, | |
| ) | |
| config = vars(args).copy() | |
| config.update( | |
| { | |
| "train_datasets": sorted(train_df["dataset"].unique().tolist()), | |
| "val_datasets": sorted(val_df["dataset"].unique().tolist()), | |
| "test_datasets": sorted(test_df["dataset"].unique().tolist()), | |
| "n_train": int(len(train_df)), | |
| "n_val": int(len(val_df)), | |
| "n_test": int(len(test_df)), | |
| "loss": "BCEWithLogitsLoss + DiceLoss", | |
| "augmentation": "hflip, small rotation, brightness/contrast jitter", | |
| "checkpoint_path": args.sam3_checkpoint, | |
| "prompt_note": "Main decoder comparison should use prompt_type=none for all runs. Prompt-based sam3_native runs are reference baselines only.", | |
| "prompt_purpose": "semantic lesion localization, not OCR/text segmentation" if args.prompt_type != "none" else "no prompt", | |
| "lora_rank": args.lora_rank if args.encoder_trainable == "lora" else None, | |
| "lora_alpha": args.lora_alpha if args.encoder_trainable == "lora" else None, | |
| "train_native_head": (not args.no_train_native_head) if args.encoder_trainable == "lora" else None, | |
| } | |
| ) | |
| write_json(output_dir / "config.json", config) | |
| train_df.to_csv(output_dir / "train_split.csv", index=False) | |
| val_df.to_csv(output_dir / "val_split.csv", index=False) | |
| test_df.to_csv(output_dir / "test_split.csv", index=False) | |
| train_loader = make_loader(train_df, args.image_size, args.batch_size, args.num_workers, train=True) | |
| val_loader = make_loader(val_df, args.image_size, args.batch_size, args.num_workers, train=False) | |
| test_loader = make_loader(test_df, args.image_size, args.batch_size, args.num_workers, train=False) | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| model = SAM3FeatureModel( | |
| args.sam3_checkpoint, | |
| image_size=args.image_size, | |
| encoder_trainable=args.encoder_trainable, | |
| decoder_name=args.decoder, | |
| prompt_type=args.prompt_type, | |
| prompt_text=args.prompt_text, | |
| lora_rank=args.lora_rank, | |
| lora_alpha=args.lora_alpha, | |
| train_native_head=not args.no_train_native_head, | |
| ).to(device) | |
| optimizer = make_optimizer(model, args) | |
| scaler = torch.cuda.amp.GradScaler(enabled=args.use_amp and device.type == "cuda") | |
| best_dice = -1.0 | |
| best_epoch = -1 | |
| wait = 0 | |
| log_rows = [] | |
| start_epoch = 1 | |
| if args.resume and (output_dir / "last_model.pt").exists(): | |
| ckpt = torch.load(output_dir / "last_model.pt", map_location=device) | |
| model.load_state_dict(ckpt["model"], strict=False) | |
| optimizer.load_state_dict(ckpt["optimizer"]) | |
| start_epoch = int(ckpt.get("epoch", 0)) + 1 | |
| best_dice = float(ckpt.get("best_dice", -1.0)) | |
| best_epoch = int(ckpt.get("best_epoch", -1)) | |
| for epoch in range(start_epoch, args.epochs + 1): | |
| tic = time.time() | |
| train_loss = train_one_epoch(model, train_loader, optimizer, scaler, device, args.use_amp, args) | |
| val_rows = run_eval(model, val_loader, device, threshold=0.5, args=args) | |
| val_dice = float(pd.DataFrame(val_rows)["dice"].mean()) | |
| row = {"epoch": epoch, "train_loss": train_loss, "val_mean_dice": val_dice, "seconds": time.time() - tic} | |
| log_rows.append(row) | |
| write_csv(output_dir / "train_log.csv", log_rows) | |
| torch.save( | |
| {"model": model.state_dict(), "optimizer": optimizer.state_dict(), "epoch": epoch, "best_dice": best_dice, "best_epoch": best_epoch}, | |
| output_dir / "last_model.pt", | |
| ) | |
| print(json.dumps(row)) | |
| if val_dice > best_dice: | |
| best_dice = val_dice | |
| best_epoch = epoch | |
| wait = 0 | |
| torch.save({"model": model.state_dict(), "epoch": epoch, "best_dice": best_dice, "config": config}, output_dir / "best_model.pt") | |
| write_csv(output_dir / "val_metrics.csv", val_rows) | |
| save_eval_outputs(val_rows, output_dir, "val", args.bootstrap_resamples if not args.debug else 200, args.seed) | |
| else: | |
| wait += 1 | |
| if wait >= args.patience: | |
| break | |
| best = torch.load(output_dir / "best_model.pt", map_location=device) | |
| model.load_state_dict(best["model"], strict=False) | |
| best_t, best_t_dice = select_threshold(model, val_loader, device, args=args) | |
| threshold_info = {"threshold_0p5": 0.5, "val_selected_threshold": best_t, "val_selected_threshold_dice": best_t_dice} | |
| write_json(output_dir / "thresholds.json", threshold_info) | |
| test_dir_05 = output_dir / "test_predictions_threshold_0p5" | |
| test_rows = run_eval(model, test_loader, device, threshold=0.5, save_dir=test_dir_05, save_outputs=True, seed=args.seed, args=args) | |
| overall = save_eval_outputs(test_rows, output_dir, "test", args.bootstrap_resamples if not args.debug else 200, args.seed) | |
| test_dir_val = output_dir / "test_predictions_val_threshold" | |
| test_rows_val = run_eval(model, test_loader, device, threshold=best_t, save_dir=test_dir_val, save_outputs=True, seed=args.seed, args=args) | |
| save_eval_outputs(test_rows_val, output_dir, "test_val_threshold", args.bootstrap_resamples if not args.debug else 200, args.seed) | |
| print(json.dumps({"best_epoch": best_epoch, "best_val_dice": best_dice, "test_mean_dice": overall.get("mean_dice"), "test_mean_iou": overall.get("mean_iou")}, indent=2)) | |
| if __name__ == "__main__": | |
| main() | |