SatMAE-PP / scripts /train.py
zhangrenchao's picture
Upload SatMAE++ model package
bffb03e verified
Raw
History Blame Contribute Delete
10.8 kB
"""Pre-train SatMAE++ with masked and native multiscale targets."""
import argparse
import importlib.util
import json
import math
import os
import random
from contextlib import nullcontext
from functools import partial
from pathlib import Path
import numpy as np
import torch
import yaml
from torch import distributed as dist
from torch.nn.parallel import DistributedDataParallel
from torch.utils.data import DataLoader, Dataset, DistributedSampler
ROOT = Path(__file__).resolve().parents[1]
class NPZDataset(Dataset):
def __init__(self, path, scales, channels, image_size):
archive = np.load(path)
self.images = archive["images"]
expected = (channels, image_size, image_size)
if self.images.ndim != 4 or tuple(self.images.shape[1:]) != expected:
raise ValueError(f"images must have shape [N, {channels}, {image_size}, {image_size}]")
self.targets = {
str(scale): archive[f"images_{scale}x"]
for scale in scales if scale != 1 and f"images_{scale}x" in archive
}
missing = [scale for scale in scales if scale != 1 and str(scale) not in self.targets]
if missing:
raise ValueError(f"dataset is missing native high-resolution targets: {missing}")
for scale, values in self.targets.items():
expected_target = (len(self.images), channels, image_size * int(scale), image_size * int(scale))
if tuple(values.shape) != expected_target:
raise ValueError(f"images_{scale}x must have shape {expected_target}")
def __len__(self):
return len(self.images)
def __getitem__(self, index):
return torch.from_numpy(self.images[index]), {
scale: torch.from_numpy(values[index]) for scale, values in self.targets.items()
}
def load_model_class():
spec = importlib.util.spec_from_file_location("satmaepp", ROOT / "model/satmaepp.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module.SatMAEPP
def model_config(config):
return {key: value for key, value in config["model"].items()
if key not in {"architecture", "runtime_profile"}}
def validate_config(config):
data, model, training = config["data"], config["model"], config["training"]
if data["image_size"] != model["image_size"]:
raise ValueError("data.image_size and model.image_size must match")
if data["channels"] != model["in_channels"] or data["scales"] != model["scales"]:
raise ValueError("data and model channels/scales must match")
if model["mode"] == "rgb" and data["channels"] != 3:
raise ValueError("RGB protocol requires three channels")
if model["mode"] == "multispectral" and data["channels"] != 10:
raise ValueError("Sentinel protocol requires ten channels")
if training["epochs"] <= 0 or training["batch_size"] <= 0 or training["accum_iter"] <= 0:
raise ValueError("epochs, batch_size, and accum_iter must be positive")
def validate_dataset_config(config, archive):
validate_config(config)
data, model = config["data"], config["model"]
if "images" not in archive:
raise ValueError("dataset must contain images")
expected = (data["channels"], data["image_size"], data["image_size"])
if archive["images"].ndim != 4 or tuple(archive["images"].shape[1:]) != expected:
raise ValueError(f"images must have shape [N, {expected[0]}, {expected[1]}, {expected[2]}]")
def parse_args():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=ROOT / "conf/config.yaml")
parser.add_argument("--data", type=Path, default=None)
parser.add_argument("--output", type=Path, default=None)
parser.add_argument("--resume", type=Path, default=None)
parser.add_argument("--epochs", type=int, default=None)
parser.add_argument("--batch-size", type=int, default=None)
parser.add_argument("--device", choices=("auto", "cpu", "cuda"), default=None)
return parser.parse_args()
def learning_rate(progress, config, peak):
warmup = config["warmup_epochs"]
if warmup and progress < warmup:
return peak * progress / warmup
phase = min(max((progress - warmup) / max(config["epochs"] - warmup, 1), 0), 1)
return config["min_learning_rate"] + 0.5 * (peak - config["min_learning_rate"]) * (
1 + math.cos(math.pi * phase)
)
def main():
args = parse_args()
config = yaml.safe_load(args.config.read_text())
validate_config(config)
training = config["training"]
if args.epochs is not None:
training["epochs"] = args.epochs
if args.batch_size is not None:
training["batch_size"] = args.batch_size
world = int(os.environ.get("WORLD_SIZE", "1"))
rank = int(os.environ.get("RANK", "0"))
local_rank = int(os.environ.get("LOCAL_RANK", "0"))
requested = args.device or config["runtime"]["device"]
cuda = torch.cuda.is_available() and requested != "cpu"
if requested == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested but is unavailable")
if world > 1:
dist.init_process_group("nccl" if cuda else "gloo")
device = torch.device(f"cuda:{local_rank}" if cuda else "cpu")
if cuda:
torch.cuda.set_device(local_rank)
seed = config["seed"] + rank
random.seed(seed); np.random.seed(seed); torch.manual_seed(seed)
data_path = args.data or ROOT / config["data"]["root"] / "train.npz"
validate_dataset_config(config, np.load(data_path))
dataset = NPZDataset(data_path, config["model"]["scales"], config["model"]["in_channels"], config["model"]["image_size"])
sampler = DistributedSampler(dataset) if world > 1 else None
loader = DataLoader(dataset, batch_size=training["batch_size"],
shuffle=sampler is None, sampler=sampler,
num_workers=training["num_workers"], pin_memory=cuda)
model = load_model_class()(**model_config(config)).to(device)
bare_model = model
if world > 1:
model = DistributedDataParallel(model, device_ids=[local_rank] if cuda else None)
bare_model = model.module
effective_batch = training["batch_size"] * training["accum_iter"] * world
peak_lr = training["learning_rate"]
if peak_lr is None:
peak_lr = training["base_learning_rate"] * effective_batch / 256
decay, no_decay = [], []
for name, parameter in bare_model.named_parameters():
if parameter.requires_grad:
(no_decay if parameter.ndim == 1 or name.endswith("bias") else decay).append(parameter)
optimizer = torch.optim.AdamW([
{"params": decay, "weight_decay": training["weight_decay"]},
{"params": no_decay, "weight_decay": 0.0},
], lr=peak_lr, betas=(0.9, 0.95))
amp = bool(config["runtime"].get("amp", True) and cuda)
scaler = torch.amp.GradScaler("cuda", enabled=amp)
start_epoch, history = 0, []
resume = args.resume or (ROOT / training["resume"] if training.get("resume") else None)
if resume is not None:
checkpoint = torch.load(resume, map_location="cpu", weights_only=False)
bare_model.load_state_dict(checkpoint["model"])
optimizer.load_state_dict(checkpoint["optimizer"])
if checkpoint.get("scaler"):
scaler.load_state_dict(checkpoint["scaler"])
start_epoch, history = checkpoint["epoch"] + 1, checkpoint.get("history", [])
checkpoint_path = args.output or ROOT / config["paths"]["checkpoint"]
metrics_path = ROOT / config["paths"]["training_metrics"]
optimizer.zero_grad(set_to_none=True)
for epoch in range(start_epoch, training["epochs"]):
if sampler:
sampler.set_epoch(epoch)
model.train(); totals = {"loss": 0.0, "reconstruction": 0.0, "multiscale": 0.0}
for step, (images, targets) in enumerate(loader):
lr = learning_rate(epoch + step / max(len(loader), 1), training, peak_lr)
for group in optimizer.param_groups:
group["lr"] = lr
images = images.to(device, non_blocking=cuda)
targets = {scale: value.to(device, non_blocking=cuda) for scale, value in targets.items()}
autocast = partial(torch.amp.autocast, "cuda") if amp else nullcontext
with autocast():
output = model(images, high_resolution_targets=targets)
loss = output["loss"] / training["accum_iter"]
if not torch.isfinite(loss):
raise ValueError("non-finite training loss")
scaler.scale(loss).backward()
update = (step + 1) % training["accum_iter"] == 0 or step + 1 == len(loader)
if update:
scaler.step(optimizer); scaler.update(); optimizer.zero_grad(set_to_none=True)
totals["loss"] += output["loss"].detach().item()
totals["reconstruction"] += output["reconstruction_loss"].detach().item()
totals["multiscale"] += output["multiscale_loss"].detach().item()
if world > 1:
values = torch.tensor([totals[name] for name in ("loss", "reconstruction", "multiscale")], device=device)
dist.all_reduce(values, op=dist.ReduceOp.SUM)
totals = dict(zip(("loss", "reconstruction", "multiscale"), (value.item() / world for value in values)))
record = {"epoch": epoch + 1, "learning_rate": optimizer.param_groups[0]["lr"]}
record.update({name + "_loss": value / max(len(loader), 1) for name, value in totals.items()})
history.append(record)
if rank == 0:
print(f"epoch={epoch + 1} loss={record['loss_loss']:.6f} "
f"reconstruction={record['reconstruction_loss']:.6f} multiscale={record['multiscale_loss']:.6f}")
if (epoch + 1) % training["save_every"] == 0 or epoch + 1 == training["epochs"]:
checkpoint_path.parent.mkdir(parents=True, exist_ok=True)
torch.save({"model": bare_model.state_dict(), "optimizer": optimizer.state_dict(),
"scaler": scaler.state_dict() if amp else None, "epoch": epoch,
"history": history, "config": config}, checkpoint_path)
if rank == 0:
metrics_path.parent.mkdir(parents=True, exist_ok=True)
metrics_path.write_text(json.dumps({"history": history, "protocol": config["data"]["protocol"],
"data_source": "synthetic", "effective_batch_size": effective_batch,
"peak_learning_rate": peak_lr}, indent=2) + "\n")
print("checkpoint=", checkpoint_path)
if world > 1:
dist.destroy_process_group()
if __name__ == "__main__":
main()