zaidshabbir's picture
Upload 9 files
81d1ded verified
Raw
History Blame Contribute Delete
6 kB
"""
model.py – Model definitions for Chest X-Ray classification.
Two architectures:
- SimpleCNN : lightweight 3-block CNN trained from scratch (baseline)
- MobileNetV2 : fine-tuned ImageNet pretrained network (strong model)
Both expose the same forward(x) β†’ logits interface.
"""
import logging
from typing import Literal
import torch
import torch.nn as nn
import torchvision.models as models
logger = logging.getLogger(__name__)
ModelName = Literal["simple_cnn", "mobilenet_v2"]
# ─── Baseline: SimpleCNN ──────────────────────────────────────────────────────
class SimpleCNN(nn.Module):
"""
3-block convolutional network trained from scratch.
Architecture:
Conv(3β†’32) + BN + ReLU + MaxPool β†’ 112Γ—112
Conv(32β†’64) + BN + ReLU + MaxPool β†’ 56Γ—56
Conv(64β†’128) + BN + ReLU + MaxPool β†’ 28Γ—28
AdaptiveAvgPool β†’ 4Γ—4
FC(2048β†’256) β†’ Dropout β†’ FC(256β†’num_classes)
Chosen as a baseline because it:
- Has no pretrained dependency
- Trains quickly on CPU
- Establishes a meaningful lower bound for comparison
"""
def __init__(self, num_classes: int = 2, dropout: float = 0.4):
super().__init__()
self.features = nn.Sequential(
# Block 1
nn.Conv2d(3, 32, kernel_size=3, padding=1),
nn.BatchNorm2d(32),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # β†’ 112Γ—112
# Block 2
nn.Conv2d(32, 64, kernel_size=3, padding=1),
nn.BatchNorm2d(64),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # β†’ 56Γ—56
# Block 3
nn.Conv2d(64, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.MaxPool2d(2, 2), # β†’ 28Γ—28
)
self.classifier = nn.Sequential(
nn.AdaptiveAvgPool2d((4, 4)), # fixed spatial size regardless of input
nn.Flatten(),
nn.Linear(128 * 4 * 4, 256),
nn.ReLU(inplace=True),
nn.Dropout(dropout),
nn.Linear(256, num_classes),
)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.classifier(self.features(x))
# ─── Strong model: MobileNetV2 ────────────────────────────────────────────────
class MobileNetV2Classifier(nn.Module):
"""
Fine-tuned MobileNetV2 with a custom classification head.
Transfer learning strategy:
1. Load ImageNet pretrained weights.
2. Freeze the backbone – only the new classifier head is trained.
3. After `unfreeze_after_epoch` epochs, unfreeze the backbone and
fine-tune end-to-end with a reduced LR (10Γ— lower).
Why MobileNetV2:
- Proven accuracy on medical imaging tasks
- Designed for constrained compute (depthwise separable convolutions)
- ~3.4M params vs ResNet-50's ~25M – suitable for CPU / laptop GPU
"""
def __init__(
self,
num_classes: int = 2,
pretrained: bool = True,
freeze_backbone: bool = True,
):
super().__init__()
weights = models.MobileNet_V2_Weights.IMAGENET1K_V1 if pretrained else None
backbone = models.mobilenet_v2(weights=weights)
# Swap out the classifier head
in_features = backbone.classifier[1].in_features
backbone.classifier = nn.Sequential(
nn.Dropout(0.3),
nn.Linear(in_features, 256),
nn.ReLU(inplace=True),
nn.Dropout(0.2),
nn.Linear(256, num_classes),
)
self.backbone = backbone
if freeze_backbone:
self._freeze_backbone()
def _freeze_backbone(self) -> None:
"""Freeze all feature extraction layers; only the head trains."""
for param in self.backbone.features.parameters():
param.requires_grad = False
logger.info("Backbone frozen – training classifier head only.")
def unfreeze_backbone(self) -> None:
"""Unfreeze all layers for full end-to-end fine-tuning."""
for param in self.backbone.features.parameters():
param.requires_grad = True
logger.info("Backbone unfrozen – full fine-tuning enabled.")
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.backbone(x)
# ─── Factory ──────────────────────────────────────────────────────────────────
def build_model(cfg: dict) -> nn.Module:
"""
Instantiate the model described in cfg['model'].
Args:
cfg: Full config dict (loaded from configs/config.yaml).
Returns:
Initialised nn.Module (not yet on device).
"""
model_cfg = cfg["model"]
name: str = model_cfg["name"]
num_classes: int = model_cfg.get("num_classes", 2)
if name == "simple_cnn":
model = SimpleCNN(num_classes=num_classes)
logger.info("Built SimpleCNN baseline.")
elif name == "mobilenet_v2":
model = MobileNetV2Classifier(
num_classes=num_classes,
pretrained=model_cfg.get("pretrained", True),
freeze_backbone=model_cfg.get("freeze_backbone", True),
)
logger.info("Built MobileNetV2 classifier.")
else:
raise ValueError(
f"Unknown model '{name}'. Valid choices: 'simple_cnn', 'mobilenet_v2'."
)
total = sum(p.numel() for p in model.parameters())
trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
logger.info(f"Parameters – total: {total:,} | trainable: {trainable:,}")
return model