SAM3-LoRA-Breast-Lesion / scripts /train_sam3_decoder_segmentation.py
pengzeli's picture
Add files using upload-large-folder tool
590304f verified
Raw
History Blame Contribute Delete
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))
@torch.no_grad()
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()