summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorVineet Kumar <git@vineetk.net>2026-04-18 21:34:11 -0400
committerVineet Kumar <git@vineetk.net>2026-04-18 21:34:11 -0400
commit8f571ee51854540d186aafc01d25908bba5ea09e (patch)
tree951e0d2e9d3eb297c654fd7a0ceb5f999482af89
initial commit
-rw-r--r--.gitignore7
-rw-r--r--channels.scm17
-rw-r--r--dataset.py123
-rw-r--r--evaluate.py146
-rw-r--r--main.py161
-rw-r--r--manifest.scm13
-rw-r--r--models.py35
-rw-r--r--requirements.txt7
-rw-r--r--train.py136
-rw-r--r--utils.py37
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__/
4guix-root
5guix-root-1-link
6results/
7venv/ \ 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 @@
1import os
2from PIL import Image
3import numpy as np
4import cv2
5
6import torch
7from torch.utils.data import Dataset, DataLoader
8from torchvision import transforms
9from sklearn.model_selection import train_test_split
10
11IMAGENET_MEAN = [0.485, 0.456, 0.406]
12IMAGENET_STD = [0.229, 0.224, 0.225]
13
14
15class 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
34class 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
53def 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
80def 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
98def 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 @@
1import os
2import csv
3
4import torch
5import torch.nn as nn
6from torch.utils.data import DataLoader
7import numpy as np
8import matplotlib.pyplot as plt
9import seaborn as sns
10from sklearn.metrics import (
11 accuracy_score,
12 f1_score,
13 classification_report,
14 confusion_matrix,
15 cohen_kappa_score,
16)
17
18from utils import CLASS_NAMES
19
20
21def 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
49def 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
65def 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
90def 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}")
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..6d8b2d9
--- /dev/null
+++ b/main.py
@@ -0,0 +1,161 @@
1"""
2EEL4759 Final Project — Diabetic Retinopathy Classification
3Compares ResNet-50, EfficientNet-B0, and ViT-B/16 with and without CLAHE preprocessing.
4
5Usage:
6 python main.py # run all 6 experiments (default)
7 python main.py --models resnet50 --clahe 0 --epochs 2 # quick sanity check
8"""
9
10import argparse
11import os
12
13import torch
14import torch.nn as nn
15from torch.optim.lr_scheduler import CosineAnnealingLR
16import kagglehub
17
18from utils import set_seed, compute_class_weights, get_device, CLASS_NAMES
19from dataset import split_dataset, build_dataloaders
20from models import create_model
21from train import train_model
22from evaluate import evaluate_model, plot_confusion_matrix, plot_training_curves, generate_summary
23
24RESULTS_DIR = 'results'
25
26
27def 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
50def 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
160if __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 @@
1import torch.nn as nn
2from torchvision.models import (
3 resnet50, ResNet50_Weights,
4 efficientnet_b0, EfficientNet_B0_Weights,
5 vit_b_16, ViT_B_16_Weights,
6)
7
8
9def 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 @@
1kagglehub
2torch
3torchvision
4scikit-learn
5opencv-python-headless
6matplotlib
7seaborn
diff --git a/train.py b/train.py
new file mode 100644
index 0000000..545b299
--- /dev/null
+++ b/train.py
@@ -0,0 +1,136 @@
1import copy
2import time
3
4import torch
5import torch.nn as nn
6from torch.utils.data import DataLoader
7from sklearn.metrics import f1_score
8
9
10def 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 @@
1import random
2import numpy as np
3import torch
4
5CLASS_NAMES = [
6 'Healthy (0)',
7 'Mild NPDR (1)',
8 'Moderate NPDR (2)',
9 'Severe NPDR (3)',
10 'Proliferative DR (4)',
11]
12
13NUM_CLASSES = 5
14
15
16def 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
25def 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
34def get_device() -> torch.device:
35 if torch.cuda.is_available():
36 return torch.device('cuda')
37 return torch.device('cpu')