from __future__ import annotations import argparse import csv import json import os from pathlib import Path os.environ.setdefault("MPLCONFIGDIR", "/tmp/matplotlib") os.makedirs(os.environ["MPLCONFIGDIR"], exist_ok=True) import matplotlib.pyplot as plt import numpy as np import torch import torch.nn as nn import torch.optim as optim from sklearn.metrics import accuracy_score, classification_report, confusion_matrix from torch.utils.data import DataLoader, Dataset from torchvision.models import efficientnet_b0, resnet18 from tqdm import tqdm class SpectrogramDataset(Dataset): def __init__(self, processed_dir: Path, split: str, preload: bool = True): self.processed_dir = processed_dir self.sample_dir = processed_dir / "samples" with (processed_dir / "manifest.csv").open() as f: rows = list(csv.DictReader(f)) self.rows = [row for row in rows if row["split"] == split] if not self.rows: raise ValueError(f"No rows found for split={split}") self.x_cache: torch.Tensor | None = None self.y_cache: torch.Tensor | None = None if preload: xs = [] ys = [] for row in tqdm(self.rows, desc=f"preload {split}"): data = np.load(self.sample_dir / row["path"]) x = data["x"].astype(np.float32) x = (x - x.mean()) / (x.std() + 1e-6) xs.append(x) ys.append(int(row["label_id"])) self.x_cache = torch.from_numpy(np.stack(xs, axis=0)).unsqueeze(1) self.y_cache = torch.tensor(ys, dtype=torch.long) def __len__(self) -> int: return len(self.rows) def __getitem__(self, idx: int): if self.x_cache is not None and self.y_cache is not None: return self.x_cache[idx], self.y_cache[idx] row = self.rows[idx] data = np.load(self.sample_dir / row["path"]) x = data["x"].astype(np.float32) x = (x - x.mean()) / (x.std() + 1e-6) x = torch.from_numpy(x).unsqueeze(0) y = torch.tensor(int(row["label_id"]), dtype=torch.long) return x, y class SmallSpectrogramCNN(nn.Module): def __init__(self, num_classes: int): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1), nn.BatchNorm2d(16), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(16, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool2d((1, 1)), ) self.classifier = nn.Linear(128, num_classes) def forward(self, x): x = self.features(x) x = torch.flatten(x, 1) return self.classifier(x) def build_model(name: str, num_classes: int) -> nn.Module: if name == "small_cnn": return SmallSpectrogramCNN(num_classes) if name == "resnet18": model = resnet18(weights=None) model.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False) model.fc = nn.Linear(model.fc.in_features, num_classes) return model if name == "efficientnet_b0": model = efficientnet_b0(weights=None) model.features[0][0] = nn.Conv2d(1, 32, kernel_size=3, stride=2, padding=1, bias=False) model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) return model raise ValueError(f"Unknown model: {name}") def read_labels(processed_dir: Path) -> list[str]: labels = [] with (processed_dir / "labels.txt").open() as f: for line in f: _, label = line.rstrip("\n").split("\t", 1) labels.append(label) return labels def evaluate(model: nn.Module, loader: DataLoader, device: torch.device): model.eval() y_true = [] y_pred = [] loss_sum = 0.0 criterion = nn.CrossEntropyLoss() use_cuda = device.type == "cuda" with torch.no_grad(): for x, y in loader: x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) if use_cuda: x = x.contiguous(memory_format=torch.channels_last) with torch.amp.autocast("cuda", enabled=use_cuda): logits = model(x) loss = criterion(logits, y) loss_sum += loss.item() * x.size(0) y_true.extend(y.cpu().numpy().tolist()) y_pred.extend(logits.argmax(dim=1).cpu().numpy().tolist()) return { "loss": loss_sum / len(loader.dataset), "accuracy": accuracy_score(y_true, y_pred), "y_true": y_true, "y_pred": y_pred, } def plot_confusion(cm: np.ndarray, labels: list[str], out_path: Path) -> None: fig, ax = plt.subplots(figsize=(8, 7)) im = ax.imshow(cm, interpolation="nearest", cmap="Blues") fig.colorbar(im, ax=ax) ax.set_xticks(np.arange(len(labels)), labels=labels, rotation=45, ha="right") ax.set_yticks(np.arange(len(labels)), labels=labels) ax.set_ylabel("True label") ax.set_xlabel("Predicted label") for i in range(cm.shape[0]): for j in range(cm.shape[1]): ax.text(j, i, str(cm[i, j]), ha="center", va="center", color="black") fig.tight_layout() fig.savefig(out_path, dpi=160) plt.close(fig) def main() -> None: parser = argparse.ArgumentParser(description="Train a spectrogram CNN for RFUAV classification.") parser.add_argument("--processed-dir", default="/data/RFUAV_processed") parser.add_argument("--out-dir", default="/data/checkpoints") parser.add_argument("--results-dir", default="/data/results") parser.add_argument("--model", choices=["small_cnn", "resnet18", "efficientnet_b0"], default="small_cnn") parser.add_argument("--epochs", type=int, default=10) parser.add_argument("--batch-size", type=int, default=256) parser.add_argument("--lr", type=float, default=1e-3) parser.add_argument("--num-workers", type=int, default=0) parser.add_argument("--no-amp", action="store_true", help="Disable CUDA automatic mixed precision.") parser.add_argument("--no-preload", action="store_true", help="Read .npz files lazily instead of preloading tensors into RAM.") args = parser.parse_args() processed_dir = Path(args.processed_dir) out_dir = Path(args.out_dir) results_dir = Path(args.results_dir) out_dir.mkdir(parents=True, exist_ok=True) results_dir.mkdir(parents=True, exist_ok=True) labels = read_labels(processed_dir) preload = not args.no_preload train_ds = SpectrogramDataset(processed_dir, "train", preload=preload) val_ds = SpectrogramDataset(processed_dir, "val", preload=preload) test_ds = SpectrogramDataset(processed_dir, "test", preload=preload) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") use_cuda = device.type == "cuda" if use_cuda: torch.backends.cudnn.benchmark = True loader_kwargs = { "batch_size": args.batch_size, "num_workers": args.num_workers, "pin_memory": use_cuda, } if args.num_workers > 0: loader_kwargs["persistent_workers"] = True loader_kwargs["prefetch_factor"] = 4 train_loader = DataLoader(train_ds, shuffle=True, **loader_kwargs) val_loader = DataLoader(val_ds, shuffle=False, **loader_kwargs) test_loader = DataLoader(test_ds, shuffle=False, **loader_kwargs) model = build_model(args.model, len(labels)).to(device) if use_cuda: model = model.to(memory_format=torch.channels_last) optimizer = optim.AdamW(model.parameters(), lr=args.lr, weight_decay=1e-4) criterion = nn.CrossEntropyLoss() use_amp = use_cuda and not args.no_amp scaler = torch.amp.GradScaler("cuda", enabled=use_amp) best_val_acc = -1.0 history = [] best_path = out_dir / "best_model.pt" for epoch in range(1, args.epochs + 1): model.train() train_loss = 0.0 for x, y in tqdm(train_loader, desc=f"epoch {epoch}/{args.epochs}"): x = x.to(device, non_blocking=True) y = y.to(device, non_blocking=True) if use_cuda: x = x.contiguous(memory_format=torch.channels_last) optimizer.zero_grad(set_to_none=True) with torch.amp.autocast("cuda", enabled=use_amp): logits = model(x) loss = criterion(logits, y) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() train_loss += loss.item() * x.size(0) train_loss /= len(train_loader.dataset) val = evaluate(model, val_loader, device) row = {"epoch": epoch, "train_loss": train_loss, "val_loss": val["loss"], "val_accuracy": val["accuracy"]} history.append(row) print(row) if val["accuracy"] > best_val_acc: best_val_acc = val["accuracy"] torch.save({"model": model.state_dict(), "labels": labels, "model_name": args.model}, best_path) checkpoint = torch.load(best_path, map_location=device) model.load_state_dict(checkpoint["model"]) test = evaluate(model, test_loader, device) cm = confusion_matrix(test["y_true"], test["y_pred"], labels=list(range(len(labels)))) report = classification_report(test["y_true"], test["y_pred"], target_names=labels, output_dict=True, zero_division=0) metrics = { "model": args.model, "best_val_accuracy": best_val_acc, "test_accuracy": test["accuracy"], "history": history, "classification_report": report, } (results_dir / "metrics.json").write_text(json.dumps(metrics, indent=2)) plot_confusion(cm, labels, results_dir / "confusion_matrix.png") print(json.dumps({"best_val_accuracy": best_val_acc, "test_accuracy": test["accuracy"]}, indent=2)) print(f"Saved checkpoint: {best_path}") print(f"Saved metrics: {results_dir / 'metrics.json'}") if __name__ == "__main__": main()