diff options
| author | Vineet Kumar <git@vineetk.net> | 2026-04-18 21:34:11 -0400 |
|---|---|---|
| committer | Vineet Kumar <git@vineetk.net> | 2026-04-18 21:34:11 -0400 |
| commit | 8f571ee51854540d186aafc01d25908bba5ea09e (patch) | |
| tree | 951e0d2e9d3eb297c654fd7a0ceb5f999482af89 | |
initial commit
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | channels.scm | 17 | ||||
| -rw-r--r-- | dataset.py | 123 | ||||
| -rw-r--r-- | evaluate.py | 146 | ||||
| -rw-r--r-- | main.py | 161 | ||||
| -rw-r--r-- | manifest.scm | 13 | ||||
| -rw-r--r-- | models.py | 35 | ||||
| -rw-r--r-- | requirements.txt | 7 | ||||
| -rw-r--r-- | train.py | 136 | ||||
| -rw-r--r-- | utils.py | 37 |
10 files changed, 682 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..69d6571 --- /dev/null +++ b/.gitignore | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | *~ | ||
| 2 | .claude/ | ||
| 3 | __pycache__/ | ||
| 4 | guix-root | ||
| 5 | guix-root-1-link | ||
| 6 | results/ | ||
| 7 | venv/ \ No newline at end of file | ||
diff --git a/channels.scm b/channels.scm new file mode 100644 index 0000000..79bf8c0 --- /dev/null +++ b/channels.scm | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | (list | ||
| 2 | (channel | ||
| 3 | (name 'guix) | ||
| 4 | (url "https://codeberg.org/dtelsing/Guix") | ||
| 5 | (branch "pytorch")) | ||
| 6 | (channel | ||
| 7 | (name 'guix-rocm) | ||
| 8 | (url "https://codeberg.org/dtelsing/Guix-ROCm") | ||
| 9 | (branch "master") | ||
| 10 | (commit | ||
| 11 | "84964834d67d3e6edeba5bdef7dc42b4413f91c0")) | ||
| 12 | (channel | ||
| 13 | (name 'guix-rocm-ml) | ||
| 14 | (url "https://codeberg.org/dtelsing/Guix-ROCm-ML") | ||
| 15 | (branch "master") | ||
| 16 | (commit | ||
| 17 | "b6c90eccbbb6e280e7222f12102a69aa963d1de4"))) | ||
diff --git a/dataset.py b/dataset.py new file mode 100644 index 0000000..696a312 --- /dev/null +++ b/dataset.py | |||
| @@ -0,0 +1,123 @@ | |||
| 1 | import os | ||
| 2 | from PIL import Image | ||
| 3 | import numpy as np | ||
| 4 | import cv2 | ||
| 5 | |||
| 6 | import torch | ||
| 7 | from torch.utils.data import Dataset, DataLoader | ||
| 8 | from torchvision import transforms | ||
| 9 | from sklearn.model_selection import train_test_split | ||
| 10 | |||
| 11 | IMAGENET_MEAN = [0.485, 0.456, 0.406] | ||
| 12 | IMAGENET_STD = [0.229, 0.224, 0.225] | ||
| 13 | |||
| 14 | |||
| 15 | class CLAHETransform: | ||
| 16 | """Apply CLAHE to the L channel of LAB color space. | ||
| 17 | |||
| 18 | Operates on PIL Images; returns a PIL Image. | ||
| 19 | """ | ||
| 20 | |||
| 21 | def __init__(self, clip_limit: float = 2.0, tile_grid_size: tuple = (8, 8)): | ||
| 22 | self.clip_limit = clip_limit | ||
| 23 | self.tile_grid_size = tile_grid_size | ||
| 24 | |||
| 25 | def __call__(self, img: Image.Image) -> Image.Image: | ||
| 26 | img_np = np.array(img) # RGB uint8 | ||
| 27 | lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB) | ||
| 28 | clahe = cv2.createCLAHE(clipLimit=self.clip_limit, tileGridSize=self.tile_grid_size) | ||
| 29 | lab[:, :, 0] = clahe.apply(lab[:, :, 0]) | ||
| 30 | result = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) | ||
| 31 | return Image.fromarray(result) | ||
| 32 | |||
| 33 | |||
| 34 | class DRDataset(Dataset): | ||
| 35 | """Diabetic Retinopathy dataset from flat class-folder layout.""" | ||
| 36 | |||
| 37 | def __init__(self, image_paths: list, labels: list, transform=None): | ||
| 38 | self.image_paths = image_paths | ||
| 39 | self.labels = labels | ||
| 40 | self.transform = transform | ||
| 41 | |||
| 42 | def __len__(self) -> int: | ||
| 43 | return len(self.image_paths) | ||
| 44 | |||
| 45 | def __getitem__(self, idx: int): | ||
| 46 | img = Image.open(self.image_paths[idx]).convert('RGB') | ||
| 47 | label = self.labels[idx] | ||
| 48 | if self.transform is not None: | ||
| 49 | img = self.transform(img) | ||
| 50 | return img, label | ||
| 51 | |||
| 52 | |||
| 53 | def split_dataset(data_root: str, seed: int = 42): | ||
| 54 | """Scan class folders 0–4 and return stratified 70/15/15 splits. | ||
| 55 | |||
| 56 | Returns: | ||
| 57 | (train_paths, train_labels, val_paths, val_labels, test_paths, test_labels) | ||
| 58 | """ | ||
| 59 | all_paths, all_labels = [], [] | ||
| 60 | for class_idx in range(5): | ||
| 61 | class_dir = os.path.join(data_root, str(class_idx)) | ||
| 62 | for fname in sorted(os.listdir(class_dir)): | ||
| 63 | if fname.lower().endswith(('.jpg', '.jpeg', '.png')): | ||
| 64 | all_paths.append(os.path.join(class_dir, fname)) | ||
| 65 | all_labels.append(class_idx) | ||
| 66 | |||
| 67 | # 70% train, 30% temp | ||
| 68 | train_paths, temp_paths, train_labels, temp_labels = train_test_split( | ||
| 69 | all_paths, all_labels, test_size=0.30, stratify=all_labels, random_state=seed | ||
| 70 | ) | ||
| 71 | # 50/50 of temp -> 15% val, 15% test | ||
| 72 | val_paths, test_paths, val_labels, test_labels = train_test_split( | ||
| 73 | temp_paths, temp_labels, test_size=0.50, stratify=temp_labels, random_state=seed | ||
| 74 | ) | ||
| 75 | |||
| 76 | print(f"Dataset split — train: {len(train_paths)}, val: {len(val_paths)}, test: {len(test_paths)}") | ||
| 77 | return train_paths, train_labels, val_paths, val_labels, test_paths, test_labels | ||
| 78 | |||
| 79 | |||
| 80 | def build_transforms(use_clahe: bool, training: bool): | ||
| 81 | """Build a transform pipeline for training or eval.""" | ||
| 82 | ops = [transforms.Resize((224, 224))] | ||
| 83 | if use_clahe: | ||
| 84 | ops.append(CLAHETransform(clip_limit=2.0, tile_grid_size=(8, 8))) | ||
| 85 | if training: | ||
| 86 | ops += [ | ||
| 87 | transforms.RandomHorizontalFlip(p=0.5), | ||
| 88 | transforms.RandomVerticalFlip(p=0.5), | ||
| 89 | transforms.RandomRotation(degrees=15), | ||
| 90 | ] | ||
| 91 | ops += [ | ||
| 92 | transforms.ToTensor(), | ||
| 93 | transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), | ||
| 94 | ] | ||
| 95 | return transforms.Compose(ops) | ||
| 96 | |||
| 97 | |||
| 98 | def build_dataloaders( | ||
| 99 | train_paths, train_labels, | ||
| 100 | val_paths, val_labels, | ||
| 101 | test_paths, test_labels, | ||
| 102 | use_clahe: bool, | ||
| 103 | batch_size: int = 32, | ||
| 104 | num_workers: int = 4, | ||
| 105 | ): | ||
| 106 | """Create DataLoaders for all three splits.""" | ||
| 107 | train_ds = DRDataset(train_paths, train_labels, transform=build_transforms(use_clahe, training=True)) | ||
| 108 | val_ds = DRDataset(val_paths, val_labels, transform=build_transforms(use_clahe, training=False)) | ||
| 109 | test_ds = DRDataset(test_paths, test_labels, transform=build_transforms(use_clahe, training=False)) | ||
| 110 | |||
| 111 | train_loader = DataLoader( | ||
| 112 | train_ds, batch_size=batch_size, shuffle=True, | ||
| 113 | num_workers=num_workers, pin_memory=True | ||
| 114 | ) | ||
| 115 | val_loader = DataLoader( | ||
| 116 | val_ds, batch_size=batch_size * 2, shuffle=False, | ||
| 117 | num_workers=num_workers, pin_memory=True | ||
| 118 | ) | ||
| 119 | test_loader = DataLoader( | ||
| 120 | test_ds, batch_size=batch_size * 2, shuffle=False, | ||
| 121 | num_workers=num_workers, pin_memory=True | ||
| 122 | ) | ||
| 123 | return train_loader, val_loader, test_loader | ||
diff --git a/evaluate.py b/evaluate.py new file mode 100644 index 0000000..7a39db4 --- /dev/null +++ b/evaluate.py | |||
| @@ -0,0 +1,146 @@ | |||
| 1 | import os | ||
| 2 | import csv | ||
| 3 | |||
| 4 | import torch | ||
| 5 | import torch.nn as nn | ||
| 6 | from torch.utils.data import DataLoader | ||
| 7 | import numpy as np | ||
| 8 | import matplotlib.pyplot as plt | ||
| 9 | import seaborn as sns | ||
| 10 | from sklearn.metrics import ( | ||
| 11 | accuracy_score, | ||
| 12 | f1_score, | ||
| 13 | classification_report, | ||
| 14 | confusion_matrix, | ||
| 15 | cohen_kappa_score, | ||
| 16 | ) | ||
| 17 | |||
| 18 | from utils import CLASS_NAMES | ||
| 19 | |||
| 20 | |||
| 21 | def evaluate_model(model: nn.Module, test_loader: DataLoader, device: torch.device) -> dict: | ||
| 22 | """Run inference on test_loader and return a dict of all metrics.""" | ||
| 23 | model.eval() | ||
| 24 | all_preds, all_labels = [], [] | ||
| 25 | |||
| 26 | with torch.no_grad(): | ||
| 27 | for images, labels in test_loader: | ||
| 28 | images = images.to(device, non_blocking=True) | ||
| 29 | outputs = model(images) | ||
| 30 | preds = outputs.argmax(dim=1) | ||
| 31 | all_preds.extend(preds.cpu().numpy()) | ||
| 32 | all_labels.extend(labels.numpy()) | ||
| 33 | |||
| 34 | all_preds = np.array(all_preds) | ||
| 35 | all_labels = np.array(all_labels) | ||
| 36 | |||
| 37 | return { | ||
| 38 | 'accuracy': accuracy_score(all_labels, all_preds), | ||
| 39 | 'weighted_f1': f1_score(all_labels, all_preds, average='weighted', zero_division=0), | ||
| 40 | 'macro_f1': f1_score(all_labels, all_preds, average='macro', zero_division=0), | ||
| 41 | 'classification_report': classification_report( | ||
| 42 | all_labels, all_preds, target_names=CLASS_NAMES, zero_division=0 | ||
| 43 | ), | ||
| 44 | 'confusion_matrix': confusion_matrix(all_labels, all_preds), | ||
| 45 | 'cohen_kappa': cohen_kappa_score(all_labels, all_preds, weights='quadratic'), | ||
| 46 | } | ||
| 47 | |||
| 48 | |||
| 49 | def plot_confusion_matrix(cm: np.ndarray, save_path: str) -> None: | ||
| 50 | """Save a seaborn confusion matrix heatmap to save_path.""" | ||
| 51 | fig, ax = plt.subplots(figsize=(8, 6)) | ||
| 52 | sns.heatmap( | ||
| 53 | cm, annot=True, fmt='d', cmap='Blues', | ||
| 54 | xticklabels=CLASS_NAMES, yticklabels=CLASS_NAMES, | ||
| 55 | ax=ax | ||
| 56 | ) | ||
| 57 | ax.set_xlabel('Predicted') | ||
| 58 | ax.set_ylabel('True') | ||
| 59 | ax.set_title('Confusion Matrix') | ||
| 60 | plt.tight_layout() | ||
| 61 | fig.savefig(save_path, dpi=150) | ||
| 62 | plt.close(fig) | ||
| 63 | |||
| 64 | |||
| 65 | def plot_training_curves( | ||
| 66 | train_losses: list, val_losses: list, val_f1s: list, save_path: str | ||
| 67 | ) -> None: | ||
| 68 | """Save loss and F1 training curves to save_path.""" | ||
| 69 | epochs = range(1, len(train_losses) + 1) | ||
| 70 | fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4)) | ||
| 71 | |||
| 72 | ax1.plot(epochs, train_losses, label='Train loss') | ||
| 73 | ax1.plot(epochs, val_losses, label='Val loss') | ||
| 74 | ax1.set_xlabel('Epoch') | ||
| 75 | ax1.set_ylabel('Loss') | ||
| 76 | ax1.set_title('Training and Validation Loss') | ||
| 77 | ax1.legend() | ||
| 78 | |||
| 79 | ax2.plot(epochs, val_f1s, label='Val weighted F1', color='green') | ||
| 80 | ax2.set_xlabel('Epoch') | ||
| 81 | ax2.set_ylabel('Weighted F1') | ||
| 82 | ax2.set_title('Validation Weighted F1') | ||
| 83 | ax2.legend() | ||
| 84 | |||
| 85 | plt.tight_layout() | ||
| 86 | fig.savefig(save_path, dpi=150) | ||
| 87 | plt.close(fig) | ||
| 88 | |||
| 89 | |||
| 90 | def generate_summary(all_results: dict, save_dir: str) -> None: | ||
| 91 | """Print comparison table, save as CSV, and generate bar chart.""" | ||
| 92 | os.makedirs(save_dir, exist_ok=True) | ||
| 93 | |||
| 94 | rows = [] | ||
| 95 | for key, r in all_results.items(): | ||
| 96 | model_name, clahe_str = key.rsplit('_clahe=', 1) | ||
| 97 | rows.append({ | ||
| 98 | 'model': model_name, | ||
| 99 | 'clahe': clahe_str, | ||
| 100 | 'weighted_f1': r['weighted_f1'], | ||
| 101 | 'macro_f1': r['macro_f1'], | ||
| 102 | 'accuracy': r['accuracy'], | ||
| 103 | 'cohen_kappa': r['cohen_kappa'], | ||
| 104 | 'train_time_s': round(r['train_time'], 1), | ||
| 105 | 'epochs_trained': r['epochs_trained'], | ||
| 106 | }) | ||
| 107 | |||
| 108 | # Print table | ||
| 109 | header = f"{'Model':<18} {'CLAHE':<6} {'W-F1':>6} {'M-F1':>6} {'Acc':>6} {'Kappa':>6} {'Time(s)':>8} {'Epochs':>7}" | ||
| 110 | print('\n' + '=' * len(header)) | ||
| 111 | print(header) | ||
| 112 | print('-' * len(header)) | ||
| 113 | for row in rows: | ||
| 114 | print( | ||
| 115 | f"{row['model']:<18} {row['clahe']:<6} " | ||
| 116 | f"{row['weighted_f1']:>6.4f} {row['macro_f1']:>6.4f} " | ||
| 117 | f"{row['accuracy']:>6.4f} {row['cohen_kappa']:>6.4f} " | ||
| 118 | f"{row['train_time_s']:>8} {row['epochs_trained']:>7}" | ||
| 119 | ) | ||
| 120 | print('=' * len(header)) | ||
| 121 | |||
| 122 | # Save CSV | ||
| 123 | csv_path = os.path.join(save_dir, 'summary.csv') | ||
| 124 | with open(csv_path, 'w', newline='') as f: | ||
| 125 | writer = csv.DictWriter(f, fieldnames=list(rows[0].keys())) | ||
| 126 | writer.writeheader() | ||
| 127 | writer.writerows(rows) | ||
| 128 | print(f"\nSummary saved to {csv_path}") | ||
| 129 | |||
| 130 | # Comparison bar chart | ||
| 131 | labels = [f"{r['model']}\nCLAHE={r['clahe']}" for r in rows] | ||
| 132 | w_f1_vals = [r['weighted_f1'] for r in rows] | ||
| 133 | |||
| 134 | fig, ax = plt.subplots(figsize=(10, 5)) | ||
| 135 | bars = ax.bar(labels, w_f1_vals, color=['#4C72B0', '#DD8452'] * 3) | ||
| 136 | ax.set_ylabel('Weighted F1-Score') | ||
| 137 | ax.set_title('Weighted F1 Comparison Across Experiments') | ||
| 138 | ax.set_ylim(0, 1.0) | ||
| 139 | for bar, val in zip(bars, w_f1_vals): | ||
| 140 | ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, | ||
| 141 | f'{val:.4f}', ha='center', va='bottom', fontsize=9) | ||
| 142 | plt.tight_layout() | ||
| 143 | chart_path = os.path.join(save_dir, 'comparison_chart.png') | ||
| 144 | fig.savefig(chart_path, dpi=150) | ||
| 145 | plt.close(fig) | ||
| 146 | print(f"Comparison chart saved to {chart_path}") | ||
| @@ -0,0 +1,161 @@ | |||
| 1 | """ | ||
| 2 | EEL4759 Final Project — Diabetic Retinopathy Classification | ||
| 3 | Compares ResNet-50, EfficientNet-B0, and ViT-B/16 with and without CLAHE preprocessing. | ||
| 4 | |||
| 5 | Usage: | ||
| 6 | python main.py # run all 6 experiments (default) | ||
| 7 | python main.py --models resnet50 --clahe 0 --epochs 2 # quick sanity check | ||
| 8 | """ | ||
| 9 | |||
| 10 | import argparse | ||
| 11 | import os | ||
| 12 | |||
| 13 | import torch | ||
| 14 | import torch.nn as nn | ||
| 15 | from torch.optim.lr_scheduler import CosineAnnealingLR | ||
| 16 | import kagglehub | ||
| 17 | |||
| 18 | from utils import set_seed, compute_class_weights, get_device, CLASS_NAMES | ||
| 19 | from dataset import split_dataset, build_dataloaders | ||
| 20 | from models import create_model | ||
| 21 | from train import train_model | ||
| 22 | from evaluate import evaluate_model, plot_confusion_matrix, plot_training_curves, generate_summary | ||
| 23 | |||
| 24 | RESULTS_DIR = 'results' | ||
| 25 | |||
| 26 | |||
| 27 | def parse_args(): | ||
| 28 | parser = argparse.ArgumentParser(description='Diabetic Retinopathy Classification') | ||
| 29 | parser.add_argument( | ||
| 30 | '--models', nargs='+', | ||
| 31 | default=['resnet50', 'efficientnet_b0', 'vit_b_16'], | ||
| 32 | help='Models to run' | ||
| 33 | ) | ||
| 34 | parser.add_argument( | ||
| 35 | '--clahe', nargs='+', type=int, default=[0, 1], | ||
| 36 | help='CLAHE settings to run (0=off, 1=on)' | ||
| 37 | ) | ||
| 38 | parser.add_argument('--epochs', type=int, default=30, help='Max training epochs') | ||
| 39 | parser.add_argument('--patience', type=int, default=5, help='Early stopping patience') | ||
| 40 | parser.add_argument('--batch-size', type=int, default=32, help='Training batch size') | ||
| 41 | parser.add_argument('--lr', type=float, default=1e-4, help='Learning rate') | ||
| 42 | parser.add_argument('--weight-decay', type=float, default=1e-4, help='AdamW weight decay') | ||
| 43 | parser.add_argument('--seed', type=int, default=42, help='Random seed') | ||
| 44 | parser.add_argument('--data-root', type=str, default=None, | ||
| 45 | help='Path to dataset root (auto-downloaded if not set)') | ||
| 46 | parser.add_argument('--num-workers', type=int, default=4, help='DataLoader workers') | ||
| 47 | return parser.parse_args() | ||
| 48 | |||
| 49 | |||
| 50 | def main(): | ||
| 51 | args = parse_args() | ||
| 52 | set_seed(args.seed) | ||
| 53 | device = get_device() | ||
| 54 | print(f"Using device: {device}") | ||
| 55 | |||
| 56 | # ── Dataset ────────────────────────────────────────────────────────────── | ||
| 57 | if args.data_root is None: | ||
| 58 | print("Downloading/locating dataset via kagglehub...") | ||
| 59 | data_root = kagglehub.dataset_download("amanneo/diabetic-retinopathy-resized-arranged") | ||
| 60 | else: | ||
| 61 | data_root = args.data_root | ||
| 62 | print(f"Dataset root: {data_root}") | ||
| 63 | |||
| 64 | train_paths, train_labels, val_paths, val_labels, test_paths, test_labels = \ | ||
| 65 | split_dataset(data_root, seed=args.seed) | ||
| 66 | |||
| 67 | # Class weights computed once from training labels | ||
| 68 | class_weights = compute_class_weights(train_labels).to(device) | ||
| 69 | print("Class weights:", [f"{w:.3f}" for w in class_weights.cpu().numpy()]) | ||
| 70 | |||
| 71 | # ── Experiments ────────────────────────────────────────────────────────── | ||
| 72 | experiments = [ | ||
| 73 | (model_name, bool(use_clahe)) | ||
| 74 | for model_name in args.models | ||
| 75 | for use_clahe in args.clahe | ||
| 76 | ] | ||
| 77 | |||
| 78 | all_results = {} | ||
| 79 | os.makedirs(RESULTS_DIR, exist_ok=True) | ||
| 80 | |||
| 81 | for model_name, use_clahe in experiments: | ||
| 82 | exp_key = f"{model_name}_clahe={use_clahe}" | ||
| 83 | exp_dir = os.path.join(RESULTS_DIR, exp_key) | ||
| 84 | os.makedirs(exp_dir, exist_ok=True) | ||
| 85 | |||
| 86 | print(f"\n{'='*70}") | ||
| 87 | print(f"Experiment: {model_name} | CLAHE: {use_clahe}") | ||
| 88 | print(f"{'='*70}") | ||
| 89 | |||
| 90 | train_loader, val_loader, test_loader = build_dataloaders( | ||
| 91 | train_paths, train_labels, | ||
| 92 | val_paths, val_labels, | ||
| 93 | test_paths, test_labels, | ||
| 94 | use_clahe=use_clahe, | ||
| 95 | batch_size=args.batch_size, | ||
| 96 | num_workers=args.num_workers, | ||
| 97 | ) | ||
| 98 | |||
| 99 | model = create_model(model_name, num_classes=5, pretrained=True).to(device) | ||
| 100 | criterion = nn.CrossEntropyLoss(weight=class_weights) | ||
| 101 | optimizer = torch.optim.AdamW( | ||
| 102 | model.parameters(), lr=args.lr, weight_decay=args.weight_decay | ||
| 103 | ) | ||
| 104 | scheduler = CosineAnnealingLR(optimizer, T_max=args.epochs) | ||
| 105 | |||
| 106 | # Train | ||
| 107 | train_results = train_model( | ||
| 108 | model, train_loader, val_loader, | ||
| 109 | criterion, optimizer, scheduler, | ||
| 110 | device, | ||
| 111 | num_epochs=args.epochs, | ||
| 112 | patience=args.patience, | ||
| 113 | model_name=exp_key, | ||
| 114 | ) | ||
| 115 | |||
| 116 | # Save best checkpoint | ||
| 117 | ckpt_path = os.path.join(exp_dir, 'best_model.pth') | ||
| 118 | torch.save(train_results['best_model_state'], ckpt_path) | ||
| 119 | print(f" Checkpoint saved: {ckpt_path}") | ||
| 120 | |||
| 121 | # Load best weights for evaluation | ||
| 122 | model.load_state_dict(train_results['best_model_state']) | ||
| 123 | |||
| 124 | # Evaluate on test set | ||
| 125 | test_metrics = evaluate_model(model, test_loader, device) | ||
| 126 | |||
| 127 | print(f"\n Test results — Weighted F1: {test_metrics['weighted_f1']:.4f} | " | ||
| 128 | f"Macro F1: {test_metrics['macro_f1']:.4f} | " | ||
| 129 | f"Accuracy: {test_metrics['accuracy']:.4f} | " | ||
| 130 | f"Kappa: {test_metrics['cohen_kappa']:.4f}") | ||
| 131 | print(test_metrics['classification_report']) | ||
| 132 | |||
| 133 | # Save per-class report | ||
| 134 | report_path = os.path.join(exp_dir, 'classification_report.txt') | ||
| 135 | with open(report_path, 'w') as f: | ||
| 136 | f.write(f"Experiment: {exp_key}\n\n") | ||
| 137 | f.write(test_metrics['classification_report']) | ||
| 138 | |||
| 139 | # Plots | ||
| 140 | plot_confusion_matrix( | ||
| 141 | test_metrics['confusion_matrix'], | ||
| 142 | save_path=os.path.join(exp_dir, 'confusion_matrix.png'), | ||
| 143 | ) | ||
| 144 | plot_training_curves( | ||
| 145 | train_results['train_losses'], | ||
| 146 | train_results['val_losses'], | ||
| 147 | train_results['val_f1s'], | ||
| 148 | save_path=os.path.join(exp_dir, 'training_curves.png'), | ||
| 149 | ) | ||
| 150 | |||
| 151 | all_results[exp_key] = {**train_results, **test_metrics} | ||
| 152 | # Remove bulky state dict from summary dict (already saved to disk) | ||
| 153 | all_results[exp_key].pop('best_model_state', None) | ||
| 154 | |||
| 155 | # ── Summary ─────────────────────────────────────────────────────────────── | ||
| 156 | if len(all_results) > 1: | ||
| 157 | generate_summary(all_results, save_dir=RESULTS_DIR) | ||
| 158 | |||
| 159 | |||
| 160 | if __name__ == '__main__': | ||
| 161 | main() | ||
diff --git a/manifest.scm b/manifest.scm new file mode 100644 index 0000000..38c3cdf --- /dev/null +++ b/manifest.scm | |||
| @@ -0,0 +1,13 @@ | |||
| 1 | (module-define! | ||
| 2 | (resolve-module '(guix-rocm packages rocm-base)) | ||
| 3 | '%amdgpu-targets | ||
| 4 | (lambda () | ||
| 5 | "gfx1100")) | ||
| 6 | |||
| 7 | (specifications->manifest | ||
| 8 | (list "python" | ||
| 9 | "python-flask" | ||
| 10 | "python-pytorch-rocm" | ||
| 11 | "python-torchvision-rocm" | ||
| 12 | "rocminfo" | ||
| 13 | "rocm-smi")) | ||
diff --git a/models.py b/models.py new file mode 100644 index 0000000..ab21055 --- /dev/null +++ b/models.py | |||
| @@ -0,0 +1,35 @@ | |||
| 1 | import torch.nn as nn | ||
| 2 | from torchvision.models import ( | ||
| 3 | resnet50, ResNet50_Weights, | ||
| 4 | efficientnet_b0, EfficientNet_B0_Weights, | ||
| 5 | vit_b_16, ViT_B_16_Weights, | ||
| 6 | ) | ||
| 7 | |||
| 8 | |||
| 9 | def create_model(model_name: str, num_classes: int = 5, pretrained: bool = True) -> nn.Module: | ||
| 10 | """Create a pretrained model with the classification head replaced for num_classes. | ||
| 11 | |||
| 12 | Supported model_name values: 'resnet50', 'efficientnet_b0', 'vit_b_16' | ||
| 13 | All backbone layers are trainable (full fine-tuning). | ||
| 14 | """ | ||
| 15 | if model_name == 'resnet50': | ||
| 16 | weights = ResNet50_Weights.DEFAULT if pretrained else None | ||
| 17 | model = resnet50(weights=weights) | ||
| 18 | model.fc = nn.Linear(model.fc.in_features, num_classes) # 2048 -> num_classes | ||
| 19 | |||
| 20 | elif model_name == 'efficientnet_b0': | ||
| 21 | weights = EfficientNet_B0_Weights.DEFAULT if pretrained else None | ||
| 22 | model = efficientnet_b0(weights=weights) | ||
| 23 | # classifier is Sequential(Dropout(0.2), Linear(1280, 1000)) | ||
| 24 | model.classifier[1] = nn.Linear(model.classifier[1].in_features, num_classes) | ||
| 25 | |||
| 26 | elif model_name == 'vit_b_16': | ||
| 27 | weights = ViT_B_16_Weights.DEFAULT if pretrained else None | ||
| 28 | model = vit_b_16(weights=weights) | ||
| 29 | # heads is Sequential(head=Linear(768, 1000)) | ||
| 30 | model.heads.head = nn.Linear(model.heads.head.in_features, num_classes) | ||
| 31 | |||
| 32 | else: | ||
| 33 | raise ValueError(f"Unknown model: {model_name!r}. Choose from: resnet50, efficientnet_b0, vit_b_16") | ||
| 34 | |||
| 35 | return model | ||
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e77f7b5 --- /dev/null +++ b/requirements.txt | |||
| @@ -0,0 +1,7 @@ | |||
| 1 | kagglehub | ||
| 2 | torch | ||
| 3 | torchvision | ||
| 4 | scikit-learn | ||
| 5 | opencv-python-headless | ||
| 6 | matplotlib | ||
| 7 | seaborn | ||
diff --git a/train.py b/train.py new file mode 100644 index 0000000..545b299 --- /dev/null +++ b/train.py | |||
| @@ -0,0 +1,136 @@ | |||
| 1 | import copy | ||
| 2 | import time | ||
| 3 | |||
| 4 | import torch | ||
| 5 | import torch.nn as nn | ||
| 6 | from torch.utils.data import DataLoader | ||
| 7 | from sklearn.metrics import f1_score | ||
| 8 | |||
| 9 | |||
| 10 | def train_model( | ||
| 11 | model: nn.Module, | ||
| 12 | train_loader: DataLoader, | ||
| 13 | val_loader: DataLoader, | ||
| 14 | criterion: nn.Module, | ||
| 15 | optimizer: torch.optim.Optimizer, | ||
| 16 | scheduler, | ||
| 17 | device: torch.device, | ||
| 18 | num_epochs: int = 30, | ||
| 19 | patience: int = 5, | ||
| 20 | model_name: str = "", | ||
| 21 | ) -> dict: | ||
| 22 | """Train model with mixed precision, early stopping on val weighted-F1. | ||
| 23 | |||
| 24 | Returns a dict with: | ||
| 25 | best_model_state, train_losses, val_losses, val_f1s, | ||
| 26 | train_time (seconds), epochs_trained | ||
| 27 | """ | ||
| 28 | scaler = torch.amp.GradScaler('cuda') if device.type == 'cuda' else None | ||
| 29 | |||
| 30 | best_val_f1 = -1.0 | ||
| 31 | best_state = None | ||
| 32 | patience_counter = 0 | ||
| 33 | |||
| 34 | train_losses, val_losses, val_f1s = [], [], [] | ||
| 35 | start_time = time.time() | ||
| 36 | |||
| 37 | for epoch in range(1, num_epochs + 1): | ||
| 38 | # ── Training phase ────────────────────────────────────────────────── | ||
| 39 | model.train() | ||
| 40 | running_loss = 0.0 | ||
| 41 | n_train = 0 | ||
| 42 | |||
| 43 | for images, labels in train_loader: | ||
| 44 | images = images.to(device, non_blocking=True) | ||
| 45 | labels = labels.to(device, non_blocking=True) | ||
| 46 | |||
| 47 | optimizer.zero_grad() | ||
| 48 | |||
| 49 | if scaler is not None: | ||
| 50 | with torch.amp.autocast('cuda'): | ||
| 51 | outputs = model(images) | ||
| 52 | loss = criterion(outputs, labels) | ||
| 53 | scaler.scale(loss).backward() | ||
| 54 | scaler.unscale_(optimizer) | ||
| 55 | torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) | ||
| 56 | scaler.step(optimizer) | ||
| 57 | scaler.update() | ||
| 58 | else: | ||
| 59 | outputs = model(images) | ||
| 60 | loss = criterion(outputs, labels) | ||
| 61 | loss.backward() | ||
| 62 | torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) | ||
| 63 | optimizer.step() | ||
| 64 | |||
| 65 | running_loss += loss.item() * images.size(0) | ||
| 66 | n_train += images.size(0) | ||
| 67 | |||
| 68 | train_loss = running_loss / n_train | ||
| 69 | train_losses.append(train_loss) | ||
| 70 | |||
| 71 | # ── Validation phase ───────────────────────────────────────────────── | ||
| 72 | model.eval() | ||
| 73 | val_running_loss = 0.0 | ||
| 74 | n_val = 0 | ||
| 75 | all_preds, all_labels = [], [] | ||
| 76 | |||
| 77 | with torch.no_grad(): | ||
| 78 | for images, labels in val_loader: | ||
| 79 | images = images.to(device, non_blocking=True) | ||
| 80 | labels = labels.to(device, non_blocking=True) | ||
| 81 | |||
| 82 | if scaler is not None: | ||
| 83 | with torch.amp.autocast('cuda'): | ||
| 84 | outputs = model(images) | ||
| 85 | loss = criterion(outputs, labels) | ||
| 86 | else: | ||
| 87 | outputs = model(images) | ||
| 88 | loss = criterion(outputs, labels) | ||
| 89 | |||
| 90 | val_running_loss += loss.item() * images.size(0) | ||
| 91 | n_val += images.size(0) | ||
| 92 | |||
| 93 | preds = outputs.argmax(dim=1) | ||
| 94 | all_preds.extend(preds.cpu().numpy()) | ||
| 95 | all_labels.extend(labels.cpu().numpy()) | ||
| 96 | |||
| 97 | val_loss = val_running_loss / n_val | ||
| 98 | val_losses.append(val_loss) | ||
| 99 | |||
| 100 | val_f1 = f1_score(all_labels, all_preds, average='weighted', zero_division=0) | ||
| 101 | val_f1s.append(val_f1) | ||
| 102 | |||
| 103 | current_lr = scheduler.get_last_lr()[0] if hasattr(scheduler, 'get_last_lr') else optimizer.param_groups[0]['lr'] | ||
| 104 | print( | ||
| 105 | f"[{model_name}] Epoch {epoch:3d}/{num_epochs} | " | ||
| 106 | f"train loss: {train_loss:.4f} | val loss: {val_loss:.4f} | " | ||
| 107 | f"val F1: {val_f1:.4f} | lr: {current_lr:.2e}" | ||
| 108 | ) | ||
| 109 | |||
| 110 | scheduler.step() | ||
| 111 | |||
| 112 | # ── Early stopping ──────────────────────────────────────────────────── | ||
| 113 | if val_f1 > best_val_f1: | ||
| 114 | best_val_f1 = val_f1 | ||
| 115 | best_state = copy.deepcopy(model.state_dict()) | ||
| 116 | patience_counter = 0 | ||
| 117 | else: | ||
| 118 | patience_counter += 1 | ||
| 119 | if patience_counter >= patience: | ||
| 120 | print(f" Early stopping at epoch {epoch} (no improvement for {patience} epochs).") | ||
| 121 | break | ||
| 122 | |||
| 123 | train_time = time.time() - start_time | ||
| 124 | epochs_trained = len(train_losses) | ||
| 125 | |||
| 126 | print(f" Training complete: {epochs_trained} epochs, {train_time:.1f}s, best val F1: {best_val_f1:.4f}") | ||
| 127 | |||
| 128 | return { | ||
| 129 | 'best_model_state': best_state, | ||
| 130 | 'train_losses': train_losses, | ||
| 131 | 'val_losses': val_losses, | ||
| 132 | 'val_f1s': val_f1s, | ||
| 133 | 'train_time': train_time, | ||
| 134 | 'epochs_trained': epochs_trained, | ||
| 135 | 'best_val_f1': best_val_f1, | ||
| 136 | } | ||
diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..a836ca2 --- /dev/null +++ b/utils.py | |||
| @@ -0,0 +1,37 @@ | |||
| 1 | import random | ||
| 2 | import numpy as np | ||
| 3 | import torch | ||
| 4 | |||
| 5 | CLASS_NAMES = [ | ||
| 6 | 'Healthy (0)', | ||
| 7 | 'Mild NPDR (1)', | ||
| 8 | 'Moderate NPDR (2)', | ||
| 9 | 'Severe NPDR (3)', | ||
| 10 | 'Proliferative DR (4)', | ||
| 11 | ] | ||
| 12 | |||
| 13 | NUM_CLASSES = 5 | ||
| 14 | |||
| 15 | |||
| 16 | def set_seed(seed: int = 42) -> None: | ||
| 17 | random.seed(seed) | ||
| 18 | np.random.seed(seed) | ||
| 19 | torch.manual_seed(seed) | ||
| 20 | torch.cuda.manual_seed_all(seed) | ||
| 21 | torch.backends.cudnn.deterministic = True | ||
| 22 | torch.backends.cudnn.benchmark = False | ||
| 23 | |||
| 24 | |||
| 25 | def compute_class_weights(labels) -> torch.FloatTensor: | ||
| 26 | """Inverse-frequency weighting computed from training labels.""" | ||
| 27 | labels = np.array(labels) | ||
| 28 | class_counts = np.bincount(labels, minlength=NUM_CLASSES) | ||
| 29 | total = len(labels) | ||
| 30 | weights = total / (NUM_CLASSES * class_counts.astype(float)) | ||
| 31 | return torch.FloatTensor(weights) | ||
| 32 | |||
| 33 | |||
| 34 | def get_device() -> torch.device: | ||
| 35 | if torch.cuda.is_available(): | ||
| 36 | return torch.device('cuda') | ||
| 37 | return torch.device('cpu') | ||
