diff options
| -rw-r--r-- | dataset.py | 70 | ||||
| -rw-r--r-- | evaluate.py | 34 | ||||
| -rw-r--r-- | main.py | 60 | ||||
| -rw-r--r-- | train.py | 29 | ||||
| -rw-r--r-- | utils.py | 30 |
5 files changed, 160 insertions, 63 deletions
| @@ -4,7 +4,7 @@ import numpy as np | |||
| 4 | import cv2 | 4 | import cv2 |
| 5 | 5 | ||
| 6 | import torch | 6 | import torch |
| 7 | from torch.utils.data import Dataset, DataLoader | 7 | from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler |
| 8 | from torchvision import transforms | 8 | from torchvision import transforms |
| 9 | from sklearn.model_selection import train_test_split | 9 | from sklearn.model_selection import train_test_split |
| 10 | 10 | ||
| @@ -19,14 +19,12 @@ class CLAHETransform: | |||
| 19 | """ | 19 | """ |
| 20 | 20 | ||
| 21 | def __init__(self, clip_limit: float = 2.0, tile_grid_size: tuple = (8, 8)): | 21 | def __init__(self, clip_limit: float = 2.0, tile_grid_size: tuple = (8, 8)): |
| 22 | self.clip_limit = clip_limit | 22 | self.clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size) |
| 23 | self.tile_grid_size = tile_grid_size | ||
| 24 | 23 | ||
| 25 | def __call__(self, img: Image.Image) -> Image.Image: | 24 | def __call__(self, img: Image.Image) -> Image.Image: |
| 26 | img_np = np.array(img) # RGB uint8 | 25 | img_np = np.array(img) # RGB uint8 |
| 27 | lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB) | 26 | lab = cv2.cvtColor(img_np, cv2.COLOR_RGB2LAB) |
| 28 | clahe = cv2.createCLAHE(clipLimit=self.clip_limit, tileGridSize=self.tile_grid_size) | 27 | lab[:, :, 0] = self.clahe.apply(lab[:, :, 0]) |
| 29 | lab[:, :, 0] = clahe.apply(lab[:, :, 0]) | ||
| 30 | result = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) | 28 | result = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) |
| 31 | return Image.fromarray(result) | 29 | return Image.fromarray(result) |
| 32 | 30 | ||
| @@ -77,17 +75,32 @@ def split_dataset(data_root: str, seed: int = 42): | |||
| 77 | return train_paths, train_labels, val_paths, val_labels, test_paths, test_labels | 75 | return train_paths, train_labels, val_paths, val_labels, test_paths, test_labels |
| 78 | 76 | ||
| 79 | 77 | ||
| 80 | def build_transforms(use_clahe: bool, training: bool): | 78 | def build_transforms(use_clahe: bool, training: bool, img_size: int = 224): |
| 81 | """Build a transform pipeline for training or eval.""" | 79 | """Build a transform pipeline for training or eval. |
| 82 | ops = [transforms.Resize((224, 224))] | 80 | |
| 83 | if use_clahe: | 81 | Training path uses RandomResizedCrop for scale/position augmentation. |
| 84 | ops.append(CLAHETransform(clip_limit=2.0, tile_grid_size=(8, 8))) | 82 | Eval path uses a deterministic Resize to preserve comparability. |
| 83 | CLAHE is applied before spatial transforms at a slightly larger size | ||
| 84 | so the crop has room to operate. | ||
| 85 | """ | ||
| 86 | ops = [] | ||
| 85 | if training: | 87 | if training: |
| 88 | if use_clahe: | ||
| 89 | # Resize larger so RandomResizedCrop still sees enough context after CLAHE | ||
| 90 | ops.append(transforms.Resize((img_size + 32, img_size + 32))) | ||
| 91 | ops.append(CLAHETransform(clip_limit=2.0, tile_grid_size=(8, 8))) | ||
| 86 | ops += [ | 92 | ops += [ |
| 93 | transforms.RandomResizedCrop(img_size, scale=(0.8, 1.0), ratio=(0.9, 1.1)), | ||
| 87 | transforms.RandomHorizontalFlip(p=0.5), | 94 | transforms.RandomHorizontalFlip(p=0.5), |
| 88 | transforms.RandomVerticalFlip(p=0.5), | 95 | transforms.RandomVerticalFlip(p=0.5), |
| 89 | transforms.RandomRotation(degrees=15), | 96 | transforms.RandomRotation(degrees=30), |
| 97 | transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2, hue=0.02), | ||
| 98 | transforms.GaussianBlur(kernel_size=3, sigma=(0.1, 1.0)), | ||
| 90 | ] | 99 | ] |
| 100 | else: | ||
| 101 | ops.append(transforms.Resize((img_size, img_size))) | ||
| 102 | if use_clahe: | ||
| 103 | ops.append(CLAHETransform(clip_limit=2.0, tile_grid_size=(8, 8))) | ||
| 91 | ops += [ | 104 | ops += [ |
| 92 | transforms.ToTensor(), | 105 | transforms.ToTensor(), |
| 93 | transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), | 106 | transforms.Normalize(mean=IMAGENET_MEAN, std=IMAGENET_STD), |
| @@ -102,22 +115,41 @@ def build_dataloaders( | |||
| 102 | use_clahe: bool, | 115 | use_clahe: bool, |
| 103 | batch_size: int = 32, | 116 | batch_size: int = 32, |
| 104 | num_workers: int = 4, | 117 | num_workers: int = 4, |
| 118 | img_size: int = 224, | ||
| 105 | ): | 119 | ): |
| 106 | """Create DataLoaders for all three splits.""" | 120 | """Create DataLoaders for all three splits. |
| 107 | train_ds = DRDataset(train_paths, train_labels, transform=build_transforms(use_clahe, training=True)) | 121 | |
| 108 | val_ds = DRDataset(val_paths, val_labels, transform=build_transforms(use_clahe, training=False)) | 122 | The training DataLoader uses WeightedRandomSampler so that each class |
| 109 | test_ds = DRDataset(test_paths, test_labels, transform=build_transforms(use_clahe, training=False)) | 123 | is sampled at approximately equal frequency, counteracting class imbalance. |
| 124 | """ | ||
| 125 | train_ds = DRDataset(train_paths, train_labels, transform=build_transforms(use_clahe, training=True, img_size=img_size)) | ||
| 126 | val_ds = DRDataset(val_paths, val_labels, transform=build_transforms(use_clahe, training=False, img_size=img_size)) | ||
| 127 | test_ds = DRDataset(test_paths, test_labels, transform=build_transforms(use_clahe, training=False, img_size=img_size)) | ||
| 128 | |||
| 129 | # Per-sample weights: inverse of class frequency so all classes are seen equally | ||
| 130 | labels_arr = np.array(train_labels) | ||
| 131 | class_counts = np.bincount(labels_arr, minlength=5) | ||
| 132 | class_sample_weights = 1.0 / class_counts.astype(float) | ||
| 133 | sample_weights = class_sample_weights[labels_arr] | ||
| 134 | sampler = WeightedRandomSampler( | ||
| 135 | weights=torch.from_numpy(sample_weights).float(), | ||
| 136 | num_samples=len(train_labels), | ||
| 137 | replacement=True, | ||
| 138 | ) | ||
| 110 | 139 | ||
| 111 | train_loader = DataLoader( | 140 | train_loader = DataLoader( |
| 112 | train_ds, batch_size=batch_size, shuffle=True, | 141 | train_ds, batch_size=batch_size, sampler=sampler, |
| 113 | num_workers=num_workers, pin_memory=True | 142 | num_workers=num_workers, pin_memory=True, |
| 143 | persistent_workers=True, prefetch_factor=4, | ||
| 114 | ) | 144 | ) |
| 115 | val_loader = DataLoader( | 145 | val_loader = DataLoader( |
| 116 | val_ds, batch_size=batch_size * 2, shuffle=False, | 146 | val_ds, batch_size=batch_size * 2, shuffle=False, |
| 117 | num_workers=num_workers, pin_memory=True | 147 | num_workers=num_workers, pin_memory=True, |
| 148 | persistent_workers=True, prefetch_factor=4, | ||
| 118 | ) | 149 | ) |
| 119 | test_loader = DataLoader( | 150 | test_loader = DataLoader( |
| 120 | test_ds, batch_size=batch_size * 2, shuffle=False, | 151 | test_ds, batch_size=batch_size * 2, shuffle=False, |
| 121 | num_workers=num_workers, pin_memory=True | 152 | num_workers=num_workers, pin_memory=True, |
| 153 | persistent_workers=True, prefetch_factor=4, | ||
| 122 | ) | 154 | ) |
| 123 | return train_loader, val_loader, test_loader | 155 | return train_loader, val_loader, test_loader |
diff --git a/evaluate.py b/evaluate.py index 7a39db4..7f996dc 100644 --- a/evaluate.py +++ b/evaluate.py | |||
| @@ -76,10 +76,10 @@ def plot_training_curves( | |||
| 76 | ax1.set_title('Training and Validation Loss') | 76 | ax1.set_title('Training and Validation Loss') |
| 77 | ax1.legend() | 77 | ax1.legend() |
| 78 | 78 | ||
| 79 | ax2.plot(epochs, val_f1s, label='Val weighted F1', color='green') | 79 | ax2.plot(epochs, val_f1s, label='Val macro F1', color='green') |
| 80 | ax2.set_xlabel('Epoch') | 80 | ax2.set_xlabel('Epoch') |
| 81 | ax2.set_ylabel('Weighted F1') | 81 | ax2.set_ylabel('Macro F1') |
| 82 | ax2.set_title('Validation Weighted F1') | 82 | ax2.set_title('Validation Macro F1') |
| 83 | ax2.legend() | 83 | ax2.legend() |
| 84 | 84 | ||
| 85 | plt.tight_layout() | 85 | plt.tight_layout() |
| @@ -127,18 +127,28 @@ def generate_summary(all_results: dict, save_dir: str) -> None: | |||
| 127 | writer.writerows(rows) | 127 | writer.writerows(rows) |
| 128 | print(f"\nSummary saved to {csv_path}") | 128 | print(f"\nSummary saved to {csv_path}") |
| 129 | 129 | ||
| 130 | # Comparison bar chart | 130 | # Comparison bar chart — show both macro and weighted F1 |
| 131 | labels = [f"{r['model']}\nCLAHE={r['clahe']}" for r in rows] | 131 | x_labels = [f"{r['model']}\nCLAHE={r['clahe']}" for r in rows] |
| 132 | w_f1_vals = [r['weighted_f1'] for r in rows] | 132 | w_f1_vals = [r['weighted_f1'] for r in rows] |
| 133 | 133 | m_f1_vals = [r['macro_f1'] for r in rows] | |
| 134 | fig, ax = plt.subplots(figsize=(10, 5)) | 134 | |
| 135 | bars = ax.bar(labels, w_f1_vals, color=['#4C72B0', '#DD8452'] * 3) | 135 | x = np.arange(len(x_labels)) |
| 136 | ax.set_ylabel('Weighted F1-Score') | 136 | bar_width = 0.35 |
| 137 | ax.set_title('Weighted F1 Comparison Across Experiments') | 137 | fig, ax = plt.subplots(figsize=(12, 5)) |
| 138 | bars_w = ax.bar(x - bar_width / 2, w_f1_vals, bar_width, label='Weighted F1', color='#4C72B0') | ||
| 139 | bars_m = ax.bar(x + bar_width / 2, m_f1_vals, bar_width, label='Macro F1', color='#DD8452') | ||
| 140 | ax.set_ylabel('F1-Score') | ||
| 141 | ax.set_title('F1 Comparison Across Experiments') | ||
| 142 | ax.set_xticks(x) | ||
| 143 | ax.set_xticklabels(x_labels) | ||
| 138 | ax.set_ylim(0, 1.0) | 144 | ax.set_ylim(0, 1.0) |
| 139 | for bar, val in zip(bars, w_f1_vals): | 145 | ax.legend() |
| 146 | for bar, val in zip(bars_w, w_f1_vals): | ||
| 147 | ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, | ||
| 148 | f'{val:.3f}', ha='center', va='bottom', fontsize=8) | ||
| 149 | for bar, val in zip(bars_m, m_f1_vals): | ||
| 140 | ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, | 150 | ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01, |
| 141 | f'{val:.4f}', ha='center', va='bottom', fontsize=9) | 151 | f'{val:.3f}', ha='center', va='bottom', fontsize=8) |
| 142 | plt.tight_layout() | 152 | plt.tight_layout() |
| 143 | chart_path = os.path.join(save_dir, 'comparison_chart.png') | 153 | chart_path = os.path.join(save_dir, 'comparison_chart.png') |
| 144 | fig.savefig(chart_path, dpi=150) | 154 | fig.savefig(chart_path, dpi=150) |
| @@ -12,10 +12,10 @@ import os | |||
| 12 | 12 | ||
| 13 | import torch | 13 | import torch |
| 14 | import torch.nn as nn | 14 | import torch.nn as nn |
| 15 | from torch.optim.lr_scheduler import CosineAnnealingLR | 15 | from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR |
| 16 | import kagglehub | 16 | import kagglehub |
| 17 | 17 | ||
| 18 | from utils import set_seed, compute_class_weights, get_device, CLASS_NAMES | 18 | from utils import set_seed, get_device, FocalLoss |
| 19 | from dataset import split_dataset, build_dataloaders | 19 | from dataset import split_dataset, build_dataloaders |
| 20 | from models import create_model | 20 | from models import create_model |
| 21 | from train import train_model | 21 | from train import train_model |
| @@ -35,15 +35,17 @@ def parse_args(): | |||
| 35 | '--clahe', nargs='+', type=int, default=[0, 1], | 35 | '--clahe', nargs='+', type=int, default=[0, 1], |
| 36 | help='CLAHE settings to run (0=off, 1=on)' | 36 | help='CLAHE settings to run (0=off, 1=on)' |
| 37 | ) | 37 | ) |
| 38 | parser.add_argument('--epochs', type=int, default=30, help='Max training epochs') | 38 | parser.add_argument('--epochs', type=int, default=50, help='Max training epochs') |
| 39 | parser.add_argument('--patience', type=int, default=5, help='Early stopping patience') | 39 | parser.add_argument('--patience', type=int, default=10, help='Early stopping patience') |
| 40 | parser.add_argument('--batch-size', type=int, default=32, help='Training batch size') | 40 | parser.add_argument('--batch-size', type=int, default=64, help='Training batch size') |
| 41 | parser.add_argument('--lr', type=float, default=1e-4, help='Learning rate') | 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') | 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') | 43 | parser.add_argument('--seed', type=int, default=42, help='Random seed') |
| 44 | parser.add_argument('--data-root', type=str, default=None, | 44 | parser.add_argument('--data-root', type=str, default=None, |
| 45 | help='Path to dataset root (auto-downloaded if not set)') | 45 | help='Path to dataset root (auto-downloaded if not set)') |
| 46 | parser.add_argument('--num-workers', type=int, default=4, help='DataLoader workers') | 46 | parser.add_argument('--num-workers', type=int, default=8, help='DataLoader workers') |
| 47 | parser.add_argument('--focal-gamma', type=float, default=2.0, help='Focal loss gamma (0=standard CE)') | ||
| 48 | parser.add_argument('--warmup-epochs', type=int, default=3, help='LR warmup epochs') | ||
| 47 | return parser.parse_args() | 49 | return parser.parse_args() |
| 48 | 50 | ||
| 49 | 51 | ||
| @@ -64,10 +66,6 @@ def main(): | |||
| 64 | train_paths, train_labels, val_paths, val_labels, test_paths, test_labels = \ | 66 | train_paths, train_labels, val_paths, val_labels, test_paths, test_labels = \ |
| 65 | split_dataset(data_root, seed=args.seed) | 67 | split_dataset(data_root, seed=args.seed) |
| 66 | 68 | ||
| 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 ────────────────────────────────────────────────────────── | 69 | # ── Experiments ────────────────────────────────────────────────────────── |
| 72 | experiments = [ | 70 | experiments = [ |
| 73 | (model_name, bool(use_clahe)) | 71 | (model_name, bool(use_clahe)) |
| @@ -97,11 +95,35 @@ def main(): | |||
| 97 | ) | 95 | ) |
| 98 | 96 | ||
| 99 | model = create_model(model_name, num_classes=5, pretrained=True).to(device) | 97 | model = create_model(model_name, num_classes=5, pretrained=True).to(device) |
| 100 | criterion = nn.CrossEntropyLoss(weight=class_weights) | 98 | |
| 99 | # Discriminative LR: backbone gets 10x lower LR than the new head | ||
| 100 | if model_name == 'resnet50': | ||
| 101 | head_params = list(model.fc.parameters()) | ||
| 102 | elif model_name == 'efficientnet_b0': | ||
| 103 | head_params = list(model.classifier.parameters()) | ||
| 104 | elif model_name == 'vit_b_16': | ||
| 105 | head_params = list(model.heads.parameters()) | ||
| 106 | else: | ||
| 107 | head_params = [] | ||
| 108 | head_ids = {id(p) for p in head_params} | ||
| 109 | backbone_params = [p for p in model.parameters() if id(p) not in head_ids] | ||
| 110 | |||
| 111 | if torch.cuda.device_count() > 1: | ||
| 112 | print(f" Using {torch.cuda.device_count()} GPUs via DataParallel") | ||
| 113 | model = torch.nn.DataParallel(model) | ||
| 114 | |||
| 115 | criterion = FocalLoss(alpha=None, gamma=args.focal_gamma) | ||
| 101 | optimizer = torch.optim.AdamW( | 116 | optimizer = torch.optim.AdamW( |
| 102 | model.parameters(), lr=args.lr, weight_decay=args.weight_decay | 117 | [ |
| 118 | {'params': backbone_params, 'lr': args.lr * 0.1}, | ||
| 119 | {'params': head_params, 'lr': args.lr}, | ||
| 120 | ], | ||
| 121 | weight_decay=args.weight_decay, | ||
| 103 | ) | 122 | ) |
| 104 | scheduler = CosineAnnealingLR(optimizer, T_max=args.epochs) | 123 | warmup_epochs = min(args.warmup_epochs, args.epochs - 1) |
| 124 | warmup_scheduler = LinearLR(optimizer, start_factor=0.1, total_iters=warmup_epochs) | ||
| 125 | cosine_scheduler = CosineAnnealingLR(optimizer, T_max=max(args.epochs - warmup_epochs, 1)) | ||
| 126 | scheduler = SequentialLR(optimizer, schedulers=[warmup_scheduler, cosine_scheduler], milestones=[warmup_epochs]) | ||
| 105 | 127 | ||
| 106 | # Train | 128 | # Train |
| 107 | train_results = train_model( | 129 | train_results = train_model( |
| @@ -113,16 +135,20 @@ def main(): | |||
| 113 | model_name=exp_key, | 135 | model_name=exp_key, |
| 114 | ) | 136 | ) |
| 115 | 137 | ||
| 116 | # Save best checkpoint | 138 | # Save best checkpoint (strip DataParallel 'module.' prefix for portability) |
| 117 | ckpt_path = os.path.join(exp_dir, 'best_model.pth') | 139 | ckpt_path = os.path.join(exp_dir, 'best_model.pth') |
| 118 | torch.save(train_results['best_model_state'], ckpt_path) | 140 | best_state = train_results['best_model_state'] |
| 141 | if isinstance(model, torch.nn.DataParallel): | ||
| 142 | best_state = {k[7:]: v for k, v in best_state.items()} | ||
| 143 | torch.save(best_state, ckpt_path) | ||
| 119 | print(f" Checkpoint saved: {ckpt_path}") | 144 | print(f" Checkpoint saved: {ckpt_path}") |
| 120 | 145 | ||
| 121 | # Load best weights for evaluation | 146 | # Load best weights for evaluation |
| 122 | model.load_state_dict(train_results['best_model_state']) | 147 | core_model = model.module if isinstance(model, torch.nn.DataParallel) else model |
| 148 | core_model.load_state_dict(best_state) | ||
| 123 | 149 | ||
| 124 | # Evaluate on test set | 150 | # Evaluate on test set |
| 125 | test_metrics = evaluate_model(model, test_loader, device) | 151 | test_metrics = evaluate_model(core_model, test_loader, device) |
| 126 | 152 | ||
| 127 | print(f"\n Test results — Weighted F1: {test_metrics['weighted_f1']:.4f} | " | 153 | print(f"\n Test results — Weighted F1: {test_metrics['weighted_f1']:.4f} | " |
| 128 | f"Macro F1: {test_metrics['macro_f1']:.4f} | " | 154 | f"Macro F1: {test_metrics['macro_f1']:.4f} | " |
| @@ -19,11 +19,11 @@ def train_model( | |||
| 19 | patience: int = 5, | 19 | patience: int = 5, |
| 20 | model_name: str = "", | 20 | model_name: str = "", |
| 21 | ) -> dict: | 21 | ) -> dict: |
| 22 | """Train model with mixed precision, early stopping on val weighted-F1. | 22 | """Train model with mixed precision, early stopping on val macro F1. |
| 23 | 23 | ||
| 24 | Returns a dict with: | 24 | Returns a dict with: |
| 25 | best_model_state, train_losses, val_losses, val_f1s, | 25 | best_model_state, train_losses, val_losses, val_f1s (macro), val_weighted_f1s, |
| 26 | train_time (seconds), epochs_trained | 26 | train_time (seconds), epochs_trained, best_val_f1 (macro) |
| 27 | """ | 27 | """ |
| 28 | scaler = torch.amp.GradScaler('cuda') if device.type == 'cuda' else None | 28 | scaler = torch.amp.GradScaler('cuda') if device.type == 'cuda' else None |
| 29 | 29 | ||
| @@ -31,7 +31,7 @@ def train_model( | |||
| 31 | best_state = None | 31 | best_state = None |
| 32 | patience_counter = 0 | 32 | patience_counter = 0 |
| 33 | 33 | ||
| 34 | train_losses, val_losses, val_f1s = [], [], [] | 34 | train_losses, val_losses, val_f1s, val_weighted_f1s = [], [], [], [] |
| 35 | start_time = time.time() | 35 | start_time = time.time() |
| 36 | 36 | ||
| 37 | for epoch in range(1, num_epochs + 1): | 37 | for epoch in range(1, num_epochs + 1): |
| @@ -97,21 +97,23 @@ def train_model( | |||
| 97 | val_loss = val_running_loss / n_val | 97 | val_loss = val_running_loss / n_val |
| 98 | val_losses.append(val_loss) | 98 | val_losses.append(val_loss) |
| 99 | 99 | ||
| 100 | val_f1 = f1_score(all_labels, all_preds, average='weighted', zero_division=0) | 100 | val_f1_macro = f1_score(all_labels, all_preds, average='macro', zero_division=0) |
| 101 | val_f1s.append(val_f1) | 101 | val_f1_weighted = f1_score(all_labels, all_preds, average='weighted', zero_division=0) |
| 102 | val_f1s.append(val_f1_macro) | ||
| 103 | val_weighted_f1s.append(val_f1_weighted) | ||
| 102 | 104 | ||
| 103 | current_lr = scheduler.get_last_lr()[0] if hasattr(scheduler, 'get_last_lr') else optimizer.param_groups[0]['lr'] | 105 | current_lr = scheduler.get_last_lr()[0] if hasattr(scheduler, 'get_last_lr') else optimizer.param_groups[0]['lr'] |
| 104 | print( | 106 | print( |
| 105 | f"[{model_name}] Epoch {epoch:3d}/{num_epochs} | " | 107 | f"[{model_name}] Epoch {epoch:3d}/{num_epochs} | " |
| 106 | f"train loss: {train_loss:.4f} | val loss: {val_loss:.4f} | " | 108 | f"train loss: {train_loss:.4f} | val loss: {val_loss:.4f} | " |
| 107 | f"val F1: {val_f1:.4f} | lr: {current_lr:.2e}" | 109 | f"macro F1: {val_f1_macro:.4f} | weighted F1: {val_f1_weighted:.4f} | lr: {current_lr:.2e}" |
| 108 | ) | 110 | ) |
| 109 | 111 | ||
| 110 | scheduler.step() | 112 | scheduler.step() |
| 111 | 113 | ||
| 112 | # ── Early stopping ──────────────────────────────────────────────────── | 114 | # ── Early stopping (monitored on macro F1) ─────────────────────────── |
| 113 | if val_f1 > best_val_f1: | 115 | if val_f1_macro > best_val_f1: |
| 114 | best_val_f1 = val_f1 | 116 | best_val_f1 = val_f1_macro |
| 115 | best_state = copy.deepcopy(model.state_dict()) | 117 | best_state = copy.deepcopy(model.state_dict()) |
| 116 | patience_counter = 0 | 118 | patience_counter = 0 |
| 117 | else: | 119 | else: |
| @@ -123,14 +125,15 @@ def train_model( | |||
| 123 | train_time = time.time() - start_time | 125 | train_time = time.time() - start_time |
| 124 | epochs_trained = len(train_losses) | 126 | epochs_trained = len(train_losses) |
| 125 | 127 | ||
| 126 | print(f" Training complete: {epochs_trained} epochs, {train_time:.1f}s, best val F1: {best_val_f1:.4f}") | 128 | print(f" Training complete: {epochs_trained} epochs, {train_time:.1f}s, best val macro F1: {best_val_f1:.4f}") |
| 127 | 129 | ||
| 128 | return { | 130 | return { |
| 129 | 'best_model_state': best_state, | 131 | 'best_model_state': best_state, |
| 130 | 'train_losses': train_losses, | 132 | 'train_losses': train_losses, |
| 131 | 'val_losses': val_losses, | 133 | 'val_losses': val_losses, |
| 132 | 'val_f1s': val_f1s, | 134 | 'val_f1s': val_f1s, # macro F1 per epoch (used for early stopping) |
| 135 | 'val_weighted_f1s': val_weighted_f1s, | ||
| 133 | 'train_time': train_time, | 136 | 'train_time': train_time, |
| 134 | 'epochs_trained': epochs_trained, | 137 | 'epochs_trained': epochs_trained, |
| 135 | 'best_val_f1': best_val_f1, | 138 | 'best_val_f1': best_val_f1, # best macro F1 |
| 136 | } | 139 | } |
| @@ -1,6 +1,8 @@ | |||
| 1 | import random | 1 | import random |
| 2 | import numpy as np | 2 | import numpy as np |
| 3 | import torch | 3 | import torch |
| 4 | import torch.nn as nn | ||
| 5 | import torch.nn.functional as F | ||
| 4 | 6 | ||
| 5 | CLASS_NAMES = [ | 7 | CLASS_NAMES = [ |
| 6 | 'Healthy (0)', | 8 | 'Healthy (0)', |
| @@ -18,8 +20,7 @@ def set_seed(seed: int = 42) -> None: | |||
| 18 | np.random.seed(seed) | 20 | np.random.seed(seed) |
| 19 | torch.manual_seed(seed) | 21 | torch.manual_seed(seed) |
| 20 | torch.cuda.manual_seed_all(seed) | 22 | torch.cuda.manual_seed_all(seed) |
| 21 | torch.backends.cudnn.deterministic = True | 23 | torch.backends.cudnn.benchmark = True |
| 22 | torch.backends.cudnn.benchmark = False | ||
| 23 | 24 | ||
| 24 | 25 | ||
| 25 | def compute_class_weights(labels) -> torch.FloatTensor: | 26 | def compute_class_weights(labels) -> torch.FloatTensor: |
| @@ -35,3 +36,28 @@ def get_device() -> torch.device: | |||
| 35 | if torch.cuda.is_available(): | 36 | if torch.cuda.is_available(): |
| 36 | return torch.device('cuda') | 37 | return torch.device('cuda') |
| 37 | return torch.device('cpu') | 38 | return torch.device('cpu') |
| 39 | |||
| 40 | |||
| 41 | class FocalLoss(nn.Module): | ||
| 42 | """Focal loss with optional per-class alpha weighting. | ||
| 43 | |||
| 44 | Focuses learning on hard, misclassified examples by down-weighting | ||
| 45 | easy examples (high pt). Good for class imbalance in medical imaging. | ||
| 46 | |||
| 47 | Args: | ||
| 48 | alpha: Per-class weight tensor (same shape as class weights for CE). | ||
| 49 | If None, no per-class weighting is applied. | ||
| 50 | gamma: Focusing parameter. gamma=0 reduces to standard CE. | ||
| 51 | gamma=2 is the standard value from the RetinaNet paper. | ||
| 52 | """ | ||
| 53 | |||
| 54 | def __init__(self, alpha=None, gamma: float = 2.0): | ||
| 55 | super().__init__() | ||
| 56 | self.alpha = alpha | ||
| 57 | self.gamma = gamma | ||
| 58 | |||
| 59 | def forward(self, inputs: torch.Tensor, targets: torch.Tensor) -> torch.Tensor: | ||
| 60 | ce_loss = F.cross_entropy(inputs, targets, weight=self.alpha, reduction='none') | ||
| 61 | pt = torch.exp(-ce_loss) | ||
| 62 | focal_loss = ((1.0 - pt) ** self.gamma) * ce_loss | ||
| 63 | return focal_loss.mean() | ||
