Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |