medai / model /model.py
Relixsx
Deploy MedAI backend to Hugging Face Space
3a8534b
Raw
History Blame Contribute Delete
5.29 kB
"""
model/model.py
──────────────
DenseNet-121 backbone fine-tuned for binary classification of
breast cancer histopathology images (benign vs. malignant).
Architecture
────────────
DenseNet-121 (pretrained on ImageNet)
└─ Adaptive Average Pool β†’ flatten
└─ Classifier head
β”œβ”€ BatchNorm1d(1024)
β”œβ”€ Dropout(p=0.4)
β”œβ”€ Linear(1024 β†’ 256) + ReLU
β”œβ”€ BatchNorm1d(256)
β”œβ”€ Dropout(p=0.3)
└─ Linear(256 β†’ 2) ← raw logits [benign, malignant]
Outputs
────────
logits : Tensor[1, 2] β€” raw scores (pre-softmax)
probs : Tensor[1, 2] β€” calibrated probabilities via softmax
"""
import torch
import torch.nn as nn
from torchvision import models
class BreastCancerClassifier(nn.Module):
"""
DenseNet-121 backbone with a custom two-class head.
Parameters
----------
pretrained : bool
Load ImageNet weights into the DenseNet-121 backbone (default True).
freeze_backbone : bool
Freeze all DenseNet layers except the classifier head (default False).
Set True for pure feature-extraction / fast fine-tuning scenarios.
dropout_rate : float
Dropout probability applied in the classifier head (default 0.4).
"""
def __init__(
self,
pretrained: bool = True,
freeze_backbone: bool = False,
dropout_rate: float = 0.4,
) -> None:
super().__init__()
# ── Backbone ────────────────────────────────────────────────────────
weights = models.DenseNet121_Weights.IMAGENET1K_V1 if pretrained else None
densenet = models.densenet121(weights=weights)
# Keep every layer except the original FC classifier
self.features = densenet.features # Conv + DenseBlocks + Transitions
self.pool = nn.AdaptiveAvgPool2d((1, 1))
in_features = densenet.classifier.in_features # 1024 for DenseNet-121
# ── Classifier head ─────────────────────────────────────────────────
self.classifier = nn.Sequential(
nn.BatchNorm1d(in_features),
nn.Dropout(p=dropout_rate),
nn.Linear(in_features, 256),
nn.ReLU(inplace=True),
nn.BatchNorm1d(256),
nn.Dropout(p=dropout_rate * 0.75),
nn.Linear(256, 2), # 2 logits: [benign, malignant]
)
# ── Optional backbone freeze ─────────────────────────────────────────
if freeze_backbone:
for param in self.features.parameters():
param.requires_grad = False
self._init_classifier_weights()
# ────────────────────────────────────────────────────────────────────────
def _init_classifier_weights(self) -> None:
"""Kaiming / Xavier initialisation for the custom head."""
for module in self.classifier.modules():
if isinstance(module, nn.Linear):
nn.init.kaiming_normal_(module.weight, nonlinearity="relu")
if module.bias is not None:
nn.init.zeros_(module.bias)
elif isinstance(module, nn.BatchNorm1d):
nn.init.ones_(module.weight)
nn.init.zeros_(module.bias)
# ────────────────────────────────────────────────────────────────────────
def forward(self, x: torch.Tensor) -> dict:
"""
Forward pass.
Parameters
----------
x : torch.Tensor
Normalised image tensor of shape (B, 3, 224, 224).
Returns
-------
dict with keys
"logits" : Tensor[B, 2] β€” raw model outputs
"probs" : Tensor[B, 2] β€” softmax probabilities
"""
features = self.features(x) # (B, 1024, 7, 7)
pooled = self.pool(features) # (B, 1024, 1, 1)
flat = torch.flatten(pooled, 1) # (B, 1024)
logits = self.classifier(flat) # (B, 2)
probs = torch.softmax(logits, dim=1) # (B, 2)
return {"logits": logits, "probs": probs}
# ────────────────────────────────────────────────────────────────────────
def get_feature_maps(self, x: torch.Tensor) -> torch.Tensor:
"""
Return the final DenseNet feature maps before pooling.
Used by Grad-CAM and other spatial explainability modules.
Returns
-------
Tensor[B, 1024, 7, 7]
"""
return self.features(x)