summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--dataset.py70
-rw-r--r--evaluate.py34
-rw-r--r--main.py60
-rw-r--r--train.py29
-rw-r--r--utils.py30
5 files changed, 160 insertions, 63 deletions
diff --git a/dataset.py b/dataset.py
index 696a312..0068a97 100644
--- a/dataset.py
+++ b/dataset.py
@@ -4,7 +4,7 @@ import numpy as np
4import cv2 4import cv2
5 5
6import torch 6import torch
7from torch.utils.data import Dataset, DataLoader 7from torch.utils.data import Dataset, DataLoader, WeightedRandomSampler
8from torchvision import transforms 8from torchvision import transforms
9from sklearn.model_selection import train_test_split 9from 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
80def build_transforms(use_clahe: bool, training: bool): 78def 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)
diff --git a/main.py b/main.py
index 6d8b2d9..fb87c33 100644
--- a/main.py
+++ b/main.py
@@ -12,10 +12,10 @@ import os
12 12
13import torch 13import torch
14import torch.nn as nn 14import torch.nn as nn
15from torch.optim.lr_scheduler import CosineAnnealingLR 15from torch.optim.lr_scheduler import CosineAnnealingLR, LinearLR, SequentialLR
16import kagglehub 16import kagglehub
17 17
18from utils import set_seed, compute_class_weights, get_device, CLASS_NAMES 18from utils import set_seed, get_device, FocalLoss
19from dataset import split_dataset, build_dataloaders 19from dataset import split_dataset, build_dataloaders
20from models import create_model 20from models import create_model
21from train import train_model 21from 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} | "
diff --git a/train.py b/train.py
index 545b299..4a04a30 100644
--- a/train.py
+++ b/train.py
@@ -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 }
diff --git a/utils.py b/utils.py
index a836ca2..90c79d5 100644
--- a/utils.py
+++ b/utils.py
@@ -1,6 +1,8 @@
1import random 1import random
2import numpy as np 2import numpy as np
3import torch 3import torch
4import torch.nn as nn
5import torch.nn.functional as F
4 6
5CLASS_NAMES = [ 7CLASS_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
25def compute_class_weights(labels) -> torch.FloatTensor: 26def 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
41class 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()