Pulmo / modeling.py
ariyul's picture
Add two-stage pipeline: 3D nodule-centre detector (HeatmapUNet3D, CPM 0.629) alongside the existing 2.5D concept-bottleneck characteriser (Student2p5D) with end-to-end inference wrapper.
745873a verified
Raw
History Blame Contribute Delete
14.3 kB
"""
Pulmo — Two-stage explainable lung-nodule analysis pipeline.
This module defines BOTH models of the pipeline and the glue code that chains
them into a single `volume -> findings` call:
Stage 1 (detector) HeatmapUNet3D -> find nodule centres in a full CT volume
Stage 2 (characteriser) Student2p5D -> per-candidate diagnosis + explanation
Module keys here MUST match the released checkpoints exactly:
Stage 1 (`stage1_detector_v2.pth`)
e1..e4, bott, u1..u4, d1..d4, out -> 3D U-Net heatmap detector
Stage 2 (`student_2p5d_best.pth`)
cnn.* -> 2D U-Net backbone (shared trunk)
detection_head.* -> binary nodule / non-nodule
concept_head.* -> 8 LIDC radiological concepts (regression)
malignancy_head.*-> Linear(8 -> 2) (the concept bottleneck)
cnn.final.* -> segmentation logits of the middle slice
Only `torch`, `numpy`, and `scipy` are required.
"""
import numpy as np
import torch
import torch.nn as nn
from scipy.ndimage import maximum_filter
CONCEPT_NAMES = [
"subtlety", "internalStructure", "calcification", "sphericity",
"margin", "lobulation", "spiculation", "texture",
]
# ---- shared preprocessing constants (identical for both stages) ----
HU_CLIP = (-1000, 1000)
ROI = 64 # Stage-2 patch edge (Z = Y = X = 64)
N_SLICES = 7 # Stage-2 input: 7 central axial slices
STAGE1_PATCH = (64, 128, 128) # Stage-1 sliding-window patch (Z, Y, X)
def normalize_hu(x):
"""Clip to HU_CLIP and scale to [0, 1] (same for both stages)."""
x = np.clip(x.astype(np.float32), HU_CLIP[0], HU_CLIP[1])
return (x - HU_CLIP[0]) / (HU_CLIP[1] - HU_CLIP[0])
# =====================================================================
# Stage 2 — Student2p5D (concept-bottleneck multi-task characteriser)
# =====================================================================
class ResBlock2D(nn.Module):
def __init__(self, i, o):
super().__init__()
self.conv1 = nn.Conv2d(i, o, 3, padding=1, bias=False)
self.norm1 = nn.InstanceNorm2d(o)
self.conv2 = nn.Conv2d(o, o, 3, padding=1, bias=False)
self.norm2 = nn.InstanceNorm2d(o)
self.act = nn.LeakyReLU(0.1, inplace=True)
self.skip = nn.Conv2d(i, o, 1, bias=False) if i != o else nn.Identity()
def forward(self, x):
idt = self.skip(x)
out = self.act(self.norm1(self.conv1(x)))
out = self.norm2(self.conv2(out))
return self.act(out + idt)
class UNet2D(nn.Module):
def __init__(self, in_channels, base=24):
super().__init__()
self.stem = ResBlock2D(in_channels, base)
self.down1 = nn.Sequential(nn.MaxPool2d(2), ResBlock2D(base, base * 2))
self.down2 = nn.Sequential(nn.MaxPool2d(2), ResBlock2D(base * 2, base * 4))
self.down3 = nn.Sequential(nn.MaxPool2d(2), ResBlock2D(base * 4, base * 8))
self.bottom = nn.Sequential(nn.MaxPool2d(2), ResBlock2D(base * 8, base * 16))
self.global_pool = nn.AdaptiveAvgPool2d(1)
self.up4 = nn.ConvTranspose2d(base * 16, base * 8, 2, 2)
self.dec4 = ResBlock2D(base * 16, base * 8)
self.up3 = nn.ConvTranspose2d(base * 8, base * 4, 2, 2)
self.dec3 = ResBlock2D(base * 8, base * 4)
self.up2 = nn.ConvTranspose2d(base * 4, base * 2, 2, 2)
self.dec2 = ResBlock2D(base * 4, base * 2)
self.up1 = nn.ConvTranspose2d(base * 2, base, 2, 2)
self.dec1 = ResBlock2D(base * 2, base)
self.final = nn.Conv2d(base, 1, 1)
self.out_dim = base * 16
def forward(self, x):
s0 = self.stem(x)
s1 = self.down1(s0)
s2 = self.down2(s1)
s3 = self.down3(s2)
b = self.bottom(s3)
gf = self.global_pool(b).flatten(1)
u4 = self.up4(b); d4 = self.dec4(torch.cat([u4, s3], 1))
u3 = self.up3(d4); d3 = self.dec3(torch.cat([u3, s2], 1))
u2 = self.up2(d3); d2 = self.dec2(torch.cat([u2, s1], 1))
u1 = self.up1(d2); d1 = self.dec1(torch.cat([u1, s0], 1))
return gf, self.final(d1)
class Student2p5D(nn.Module):
"""Stage 2: 2.5D concept-bottleneck multi-task characteriser."""
def __init__(self, n_slices=7, n_concepts=8, base=24, head_dropout=0.1):
super().__init__()
self.n_slices = n_slices
self.n_concepts = n_concepts
self.cnn = UNet2D(n_slices, base=base)
cd = self.cnn.out_dim
self.detection_head = nn.Sequential(
nn.LayerNorm(cd), nn.Linear(cd, 256), nn.GELU(),
nn.Dropout(head_dropout), nn.Linear(256, 2),
)
self.concept_head = nn.Sequential(
nn.LayerNorm(cd), nn.Linear(cd, 256), nn.GELU(),
nn.Dropout(0.3), nn.Linear(256, n_concepts),
)
# Concept bottleneck: malignancy is predicted ONLY from the 8 concepts.
self.malignancy_head = nn.Linear(n_concepts, 2)
def forward(self, x):
gf, seg = self.cnn(x)
concepts = self.concept_head(gf)
return {
"detection": self.detection_head(gf), # (B, 2)
"concepts": concepts, # (B, 8)
"malignancy": self.malignancy_head(concepts), # (B, 2)
"segmentation": seg, # (B, 1, 64, 64)
}
# =====================================================================
# Stage 1 — HeatmapUNet3D (nodule-centre detector)
# =====================================================================
class CB3(nn.Module):
def __init__(self, i, o):
super().__init__()
self.c1 = nn.Conv3d(i, o, 3, padding=1, bias=False)
self.n1 = nn.InstanceNorm3d(o)
self.c2 = nn.Conv3d(o, o, 3, padding=1, bias=False)
self.n2 = nn.InstanceNorm3d(o)
self.a = nn.LeakyReLU(0.1, inplace=True)
def forward(self, x):
return self.a(self.n2(self.c2(self.a(self.n1(self.c1(x))))))
class HeatmapUNet3D(nn.Module):
"""Stage 1: 3D U-Net that outputs a nodule-centre probability heatmap."""
def __init__(self, base=16):
super().__init__()
self.e1 = CB3(1, base); self.e2 = CB3(base, base * 2)
self.e3 = CB3(base * 2, base * 4); self.e4 = CB3(base * 4, base * 8)
self.pool = nn.MaxPool3d(2); self.bott = CB3(base * 8, base * 16)
self.u4 = nn.ConvTranspose3d(base * 16, base * 8, 2, 2); self.d4 = CB3(base * 16, base * 8)
self.u3 = nn.ConvTranspose3d(base * 8, base * 4, 2, 2); self.d3 = CB3(base * 8, base * 4)
self.u2 = nn.ConvTranspose3d(base * 4, base * 2, 2, 2); self.d2 = CB3(base * 4, base * 2)
self.u1 = nn.ConvTranspose3d(base * 2, base, 2, 2); self.d1 = CB3(base * 2, base)
self.out = nn.Conv3d(base, 1, 1)
def forward(self, x):
e1 = self.e1(x); e2 = self.e2(self.pool(e1))
e3 = self.e3(self.pool(e2)); e4 = self.e4(self.pool(e3))
b = self.bott(self.pool(e4))
d = self.d4(torch.cat([self.u4(b), e4], 1)); d = self.d3(torch.cat([self.u3(d), e3], 1))
d = self.d2(torch.cat([self.u2(d), e2], 1)); d = self.d1(torch.cat([self.u1(d), e1], 1))
return self.out(d)
# =====================================================================
# Loaders
# =====================================================================
def load_stage1(ckpt_path, device="cpu"):
"""Load the Stage-1 detector (HeatmapUNet3D)."""
ck = torch.load(ckpt_path, map_location=device, weights_only=False)
base = ck.get("base", 16) if isinstance(ck, dict) else 16
model = HeatmapUNet3D(base=base).to(device)
state = ck["model_state_dict"] if isinstance(ck, dict) and "model_state_dict" in ck else ck
model.load_state_dict(state, strict=True)
model.eval()
return model
def load_stage2(ckpt_path, device="cpu", n_slices=7, n_concepts=8, base=24):
"""Load the Stage-2 characteriser (Student2p5D)."""
model = Student2p5D(n_slices=n_slices, n_concepts=n_concepts, base=base).to(device)
ck = torch.load(ckpt_path, map_location=device, weights_only=False)
state = ck["model_state_dict"] if isinstance(ck, dict) and "model_state_dict" in ck else ck
model.load_state_dict(state, strict=True)
model.eval()
return model
# =====================================================================
# Stage 1 inference — sliding-window heatmap + candidate extraction
# =====================================================================
@torch.no_grad()
def _heatmap_volume(model, volume, device, stride=(32, 64, 64), batch=4):
"""Run the 3D detector over a full volume with an overlapping sliding window.
Returns a per-voxel nodule-centre probability heatmap, same shape as `volume`.
"""
PZ, PY, PX = STAGE1_PATCH
Z0, Y0, X0 = volume.shape
Zp, Yp, Xp = max(Z0, PZ), max(Y0, PY), max(X0, PX)
if (Zp, Yp, Xp) != (Z0, Y0, X0): # pad small scans (Z < PZ) with air
v2 = np.full((Zp, Yp, Xp), HU_CLIP[0], dtype=volume.dtype)
v2[:Z0, :Y0, :X0] = volume
volume = v2
Z, Y, X = volume.shape
acc = np.zeros((Z, Y, X), np.float32); cnt = np.zeros((Z, Y, X), np.float32)
sz, sy, sx = stride
zs = list(range(0, Z - PZ + 1, sz)) + ([Z - PZ] if (Z - PZ) % sz else [])
ys = list(range(0, Y - PY + 1, sy)) + ([Y - PY] if (Y - PY) % sy else [])
xs = list(range(0, X - PX + 1, sx)) + ([X - PX] if (X - PX) % sx else [])
coords = [(z, y, x) for z in zs for y in ys for x in xs]
buf, pos = [], []
use_amp = (str(device) != "cpu")
def flush():
if not buf:
return
xb = torch.from_numpy(np.stack(buf)[:, None]).to(device)
if use_amp:
with torch.autocast("cuda", dtype=torch.bfloat16):
hm = torch.sigmoid(model(xb).float()).cpu().numpy()
else:
hm = torch.sigmoid(model(xb)).cpu().numpy()
for k, (z, y, x) in enumerate(pos):
acc[z:z + PZ, y:y + PY, x:x + PX] += hm[k, 0]
cnt[z:z + PZ, y:y + PY, x:x + PX] += 1
buf.clear(); pos.clear()
for (z, y, x) in coords:
p = np.clip(volume[z:z + PZ, y:y + PY, x:x + PX].astype(np.float32), HU_CLIP[0], HU_CLIP[1])
if p.mean() < HU_CLIP[0] + 20: # skip near-pure-air windows (speed)
continue
buf.append((p - HU_CLIP[0]) / (HU_CLIP[1] - HU_CLIP[0]))
pos.append((z, y, x))
if len(buf) >= batch:
flush()
flush()
cnt[cnt == 0] = 1
return (acc / cnt)[:Z0, :Y0, :X0]
def find_candidates(model, volume, spacing, device="cpu",
peak_thresh=0.1, cluster_mm=6.0, return_scores=False):
"""Stage 1: locate nodule centres in a full CT volume.
Args:
model : a loaded HeatmapUNet3D (Stage 1).
volume : (Z, Y, X) numpy array of raw HU values.
spacing : (sz, sy, sx) voxel spacing in mm, [z, y, x] order.
peak_thresh : heatmap threshold. Lower -> higher recall, more false
positives (Stage 2 then filters them). 0.1 is a balanced
default; ~0.05 maximises recall.
cluster_mm : merge peaks closer than this (mm).
Returns:
list of (z, y, x) voxel coordinates, highest-scoring first.
If return_scores, returns (coords, scores).
"""
hm = _heatmap_volume(model, volume, device)
mx = maximum_filter(hm, size=(5, 9, 9))
peaks = np.argwhere((hm == mx) & (hm >= peak_thresh))
if len(peaks) == 0:
return ([], []) if return_scores else []
sc = hm[peaks[:, 0], peaks[:, 1], peaks[:, 2]]
order = np.argsort(-sc); peaks = peaks[order]; sc = sc[order]
sz, sy, sx = spacing
mm = peaks * np.array([sz, sy, sx])
taken = np.zeros(len(peaks), bool); out = []; outs = []
for i in range(len(peaks)):
if taken[i]:
continue
out.append(tuple(int(v) for v in peaks[i])); outs.append(float(sc[i]))
taken |= (np.linalg.norm(mm - mm[i], axis=1) < cluster_mm)
return (out, outs) if return_scores else out
# =====================================================================
# Stage 2 helpers — crop a candidate and read the explanation
# =====================================================================
def crop_stage2_input(volume, center_zyx):
"""Crop a 64^3 patch centred on a candidate and return the Stage-2 input
tensor `(1, 7, 64, 64)` (7 central axial slices, normalized to [0, 1]).
Out-of-bounds regions are padded with air (HU_CLIP[0]).
"""
z, y, x = (int(round(c)) for c in center_zyx)
Z, Y, X = volume.shape
h = ROI // 2
patch = np.full((ROI, ROI, ROI), HU_CLIP[0], dtype=np.float32)
z0, y0, x0 = z - h, y - h, x - h
zz0, zz1 = max(0, z0), min(Z, z0 + ROI)
yy0, yy1 = max(0, y0), min(Y, y0 + ROI)
xx0, xx1 = max(0, x0), min(X, x0 + ROI)
patch[zz0 - z0:zz1 - z0, yy0 - y0:yy1 - y0, xx0 - x0:xx1 - x0] = \
volume[zz0:zz1, yy0:yy1, xx0:xx1]
patch = normalize_hu(patch)
c = ROI // 2; hs = N_SLICES // 2
slices = patch[c - hs:c + hs + 1] # (7, 64, 64)
return torch.from_numpy(slices[None]).float() # (1, 7, 64, 64)
def explain_malignancy(stage2_model, out):
"""Concept-bottleneck attribution for one Stage-2 output.
malignancy logit = sum_i w_net[i] * concept[i], w_net = W[malign] - W[benign].
Returns a list of (concept_name, concept_value, contribution) sorted by
contribution (most malignancy-driving first).
"""
concepts = out["concepts"][0].detach().cpu().numpy()
W = stage2_model.malignancy_head.weight.detach().cpu().numpy()
w_net = W[1] - W[0]
contrib = w_net * concepts
order = np.argsort(contrib)[::-1]
return [(CONCEPT_NAMES[i], float(concepts[i]), float(contrib[i])) for i in order]
if __name__ == "__main__":
s1 = HeatmapUNet3D(base=16)
s2 = Student2p5D()
print("Stage 1 HeatmapUNet3D : %.2fM params" % (sum(p.numel() for p in s1.parameters()) / 1e6))
print("Stage 2 Student2p5D : %.2fM params" % (sum(p.numel() for p in s2.parameters()) / 1e6))
out = s2(torch.randn(2, 7, 64, 64))
for k, v in out.items():
print(f" stage2 {k:13s}: {tuple(v.shape)}")
hm = s1(torch.randn(1, 1, 64, 128, 128))
print(f" stage1 heatmap : {tuple(hm.shape)}")