What it actually does
Downsampling is how a network learns WHAT is in an image, and it is exactly what destroys WHERE it is. A U-Net refuses to choose. A contracting path (convolution, then downsample, repeated) trades resolution for context until the bottleneck sees the whole scene. An expanding path (upsample, then convolution) climbs back to input resolution. And at every scale the encoder feature map is CONCATENATED onto the decoder feature map, handing the decoder the fine spatial detail the pooling threw away, so it only has to decide what each pixel is rather than reconstruct where the boundary was. The output is a class distribution at every pixel, which is why it is called dense prediction. Concatenation is the point: ResNet adds its skip, which forces the two sources to share channels and be summed blindly, whereas a U-Net stacks them and lets the next convolution learn how much of each to trust.
- Neighbouring pixels are related and a feature worth detecting in one place is worth detecting everywhere (translation equivariance)
- The target is a dense map aligned with the input grid, with labels at the same resolution
- Fine spatial detail from early layers is useful to the output, so passing it across the bottleneck helps rather than leaks
- Labels are consistent enough that the boundary the annotator drew is a stable target; boundary noise caps achievable Dice
- Deployment images come from the same acquisition pipeline, spacing and contrast as training images
Loss function
Two terms, and the second exists because the first is blind to the thing that matters. Pixel-wise cross-entropy averages over every pixel, so on a scan where a tumour is 2% of the image the average is governed by the 98% background. Predicting "background everywhere" scores 98% pixel accuracy, a cross-entropy near 0.1 nats, and a Dice and IoU of exactly zero. The Dice term fixes that by being a SET-OVERLAP similarity: treat the predicted mask A and the true mask B as sets of pixels and measure 2|A∩B| / (|A|+|B|). It is normalised by the size of the foreground, so a small lesion carries the same weight as a large one and the all-background solution is no longer a plateau. Dice is the same family as Jaccard (IoU = |A∩B| / |A∪B|), related exactly by J = D / (2 − D); they rank a single prediction identically and differ only in how they weight errors and aggregate across images. On the soft probabilities used in training, the intersection becomes a sum of products, which is a relaxation rather than the real set quantity. For binary masks Dice also equals 2⟨a,b⟩ / (‖a‖² + ‖b‖²), which is bounded above by cosine similarity, so it is stricter than cosine about size mismatch. Swap it for plain pixel accuracy or mean squared error and the model collapses to background; swap it for IoU as an evaluation metric and nothing changes in the ranking, only in the numbers. Cross-entropy stays in the mix because Dice gives noisy gradients early in training, when predictions are near uniform, while cross-entropy supplies a smooth per-pixel signal.
- a pixel (or voxel) index; N is the number of pixels in the batch
- ground-truth one-hot label and predicted softmax probability of class c at pixel p
- soft Dice coefficient for class c: twice the overlap, divided by the total mass of prediction and truth
- weight on the Dice term relative to cross-entropy; 1 is the common starting point
- smoothing constant so a class absent from both prediction and truth scores 1 instead of 0/0
- Intersection-over-Union (Jaccard) for class c, recoverable from Dice as D/(2 - D); the evaluation metric
How it is minimized
Backpropagation with SGD-momentum or AdamW on the combined cross-entropy and Dice loss, with heavy elastic-deformation augmentation
- momentum buffer and its decay; the original U-Net used a high momentum of 0.99 with very small batches
- learning rate
- the encoder feature map at level \ell, which feeds two consumers: the next downsampling step and the concatenation into the decoder
- the two gradient routes into the same encoder activation; the skip route is short, so early layers get a strong direct signal
The optimizer is ordinary; what is distinctive is the gradient topology. Every encoder activation has two consumers, so its gradient is the sum of a short route (straight from the decoder level at the same scale) and a long route (down through the bottleneck and back up). That short route is why a U-Net trains stably without residual blocks and why early layers learn fast: they are one concatenation away from the loss. Momentum SGD with a small batch was the original recipe because a single large tile already fills memory; modern practice is AdamW or SGD with a polynomial learning-rate decay. Augmentation is not polish here, it is the method: the original paper trained on about thirty annotated images by applying random elastic deformations, which teach the network the shape variation of soft tissue, so the data-efficiency claim rests on the augmentation as much as on the architecture. The loss also carries a design choice: Dice is computed over the batch (pooled) rather than per image, because a per-image Dice on an image with no foreground is 0/0 and its gradient is unbounded as the foreground mass goes to zero. Differentiating Dice gives ∂D/∂ŷᵢ = 2yᵢ/S − 2I/S² with S the total mass, which makes the instability visible: it scales like 1/S.
depth (number of downsamplings)
Sets the receptive field at the bottleneck and the divisibility the input size must satisfy (2 to the depth) (3 to 5)
base width
Channels at full resolution; doubles at each downsampling, so it sets parameter count and memory (16 to 64)
Dice weight λ
Balances overlap against per-pixel cross-entropy (0.5 to 1.0 (often equal weights))
normalisation layer
BatchNorm is fine at batch 8 or more; at batch 1 to 2 (common for 3-D volumes) use GroupNorm or InstanceNorm (BatchNorm, GroupNorm(8-32 groups), InstanceNorm)
patch / tile size and overlap
Training crop and inference window; bounds memory and the context available at a tile edge (256x256 to 512x512 in 2-D, 128^3 in 3-D)
augmentation
Elastic deformation, flips, rotations, intensity and contrast jitter; the main defence against overfitting a small labelled set (elastic, flip, rotate, intensity)
learning rate + schedule
Polynomial or cosine decay (1e-3 AdamW, 1e-2 SGD-momentum)
Non-convex but reliably trainable. The characteristic failure is the trivial plateau: with severe class imbalance and cross-entropy alone, the network settles into predicting background everywhere, which is a real local minimum rather than a bug, and adding a Dice term (or class weights, or foreground-biased crop sampling) is what escapes it. The second failure is Dice instability on tiny or empty targets, where the gradient scales like the reciprocal of the foreground mass and a few pixels can swing a step. The third is overfitting a small labelled set, which augmentation and a pretrained encoder address and a bigger decoder does not. A subtler one is label noise at the boundary: annotators disagree by a pixel or two along an edge, so Dice has a ceiling well below 1 on thin structures, and chasing it overfits the annotator.
O(H · W · C0² · k²) per level, and, perhaps surprisingly, roughly equal at every level: resolution falls by 4x while channels double, so the squared channel count grows by 4x and the two cancel. Total compute is therefore about depth times the full-resolution cost. Memory is the binding constraint: the encoder activations must be held until the decoder consumes them through the skip, and the full-resolution levels dominate, which is why tile size, not parameter count, is what exhausts the GPU. The original 64-to-1024 channel network is on the order of 30 million parameters.
How, where, and why it is used
Time-Series Forecasting
AdaptedOnly for gridded spatio-temporal fields, never for a scalar series. Stack the last several frames (radar reflectivity, for example) as input channels, and train the U-Net to emit the next frame or a few future frames as output channels. Time is flattened into the channel axis, so the network has no recurrence and no notion of the order of its inputs beyond what the channel position encodes.
- Radar-based precipitation nowcasting, where U-Net-style networks predict the next hour of rainfall maps
- Sea-ice, cloud and other satellite-derived field forecasting on regular grids
It is adapted rather than viable because the problem is reshaped to fit the tool. The strengths carry over: the spatial prior suits fields where weather moves and deforms locally, and the multi-scale structure captures both small cells and large fronts. The limits are just as real. A U-Net sees a fixed window of frames, so it cannot extrapolate beyond what that window encodes, it has no mechanism for chaotic growth of uncertainty, and trained with a pixel-wise loss it predicts the conditional mean, which blurs: a sharp but misplaced storm is penalised more than a smooth smear, so forecasts get smoother with lead time. For a univariate or low-dimensional series, an N-BEATS, TCN or gradient-boosted model is simply the right tool and this is the wrong one.
- Stack past frames along the channel axis in strict time order and keep the stacking order identical at train and serve time
- Normalise the physical field per variable (rain rate is heavy-tailed; log or Box-Cox transform it before scaling)
- Crop or tile large domains with enough overlap that the receptive field at a tile edge is not starved of context
Rolling-origin evaluation by event date, never random frame splits (adjacent frames are nearly identical, so random splits leak). Score with a threshold-based skill measure such as critical success index at several rain-rate thresholds and a fractions skill score that tolerates small displacement, plus a persistence baseline (the last frame, advected) that the model must beat at every lead time.
- Random frame-level splits leak near-duplicate neighbouring frames and produce spectacular, meaningless validation scores
- A pixel-wise loss yields blurry means at long lead times, which looks like skill under MSE and is useless for locating a storm
- Reaching for it on a one-dimensional series, where it adds an image architecture with no spatial structure to exploit
Anomaly Detection
ViableTwo routes with very different failure modes. The robust one is supervised: train the U-Net on pixel-level defect masks so its output IS an anomaly map, thresholded and cleaned into connected components. The tempting one is reconstruction: train on normal images only and read the pixel-wise residual as the anomaly map. A plain U-Net is a bad choice for the second route, because its skip connections let the decoder copy the input straight through and reconstruct defects perfectly, so the residual is near zero exactly where it should be large. The usable variants cut or constrain the skips, or train against SYNTHETIC defects pasted into normal images so the network is supervised on what an anomaly looks like without needing real ones.
- Surface-defect segmentation on manufactured parts, with pixel masks marking scratches and dents
- Lesion and tumour segmentation in medical imaging, where the "anomaly" is the target class
- Satellite and aerial change or damage maps against a known baseline
The decisive advantage is localisation: the output is a mask, so a reviewer sees WHERE the defect is, and the multi-scale structure catches both a hairline crack and a broad stain. That is more useful than the single image-level score most detectors emit. The cost is that the supervised route needs pixel-level annotation, which is expensive and subjective at the edges, and the unsupervised route needs the skip-connection workaround above. If you only need "is this part defective", a classifier or an autoencoder score is cheaper; reach for a U-Net when the location or extent drives the downstream decision.
- For the reconstruction route, remove or heavily dropout the highest-resolution skips, or train a denoising variant, so the bottleneck cannot be bypassed
- Synthesise training anomalies by pasting textured patches onto normal images and use the paste mask as the target
- Oversample crops that contain defect pixels; a uniformly sampled crop is almost always empty
Pixel-level AUROC is inflated by the sea of true negatives, so prefer per-region overlap (PRO / AUPRO) or Dice at a threshold chosen on a clean validation split, plus image-level detection recall at a fixed false-alarm rate. Slice by defect type and size, since a small scratch and a large stain are different problems.
- A plain U-Net autoencoder reconstructs the defect through its skip connections and detects nothing
- Thresholding a residual map with a global constant, which breaks the moment lighting or texture shifts
- Pixel accuracy or pixel AUROC reported alone, both of which are dominated by the defect-free background
Optimization
Not applicableA U-Net is a perception component: it maps pixels to a per-pixel reading and has no decision variable, constraint or feasible region. It often sits upstream of an optimizer (a segmented field boundary feeding a path planner, a tumour mask feeding a radiotherapy dose plan), but the network itself optimizes nothing about the world, only its own weights.
Computer Vision
Primary fitSemantic segmentation as the core use: feed an image, receive an H-by-W-by-C probability map, take the argmax. Instance segmentation needs an extra step (connected components, a watershed on a boundary class, or an instance head such as Mask R-CNN), because a per-pixel class cannot by itself separate two touching objects of the same class. The U-Net body also serves as the denoiser inside diffusion models: it receives the noisy image and a timestep embedding (injected into the residual blocks as a scale and shift) and returns a same-shaped noise estimate, an ideal fit because the output must be the same shape as the input at the same resolution. Attention blocks at the low-resolution levels and cross-attention for text conditioning are added there, and large-scale diffusion work is increasingly moving to transformer backbones.
- Medical image segmentation (organs, tumours, vessels in CT and MRI), where a self-configuring U-Net remains the strong default baseline
- Cell and nucleus segmentation in microscopy, the original application
- Satellite and aerial imagery: building footprints, roads, flood extent, crop fields
- The denoising backbone of diffusion image generators, conditioned on timestep and text
It wins when labelled data is scarce and the output must be pixel-accurate. The skip connections make boundaries sharp without a large decoder, the translation-equivariant convolutions mean a feature learned in one place applies everywhere, and the whole thing is trainable on tens to hundreds of images because augmentation does the heavy lifting. It is the wrong choice when objects (not pixels) are the unit, in which case a detector is cheaper, and when the scene needs global reasoning across the entire image, where a transformer-based segmenter overtakes it given enough data. Self-configuring variants have repeatedly matched or beaten bespoke architectures on medical benchmarks, which is a reminder that preprocessing and training recipe matter more than the diagram.
- Resample to a consistent pixel or voxel spacing and normalise intensity per image (CT in Hounsfield units, MRI per scan), because spacing and contrast are not comparable across scanners
- Pad the input to a multiple of 2 to the depth, then crop the prediction back, rather than cropping the input
- Sample training crops with a bias toward the foreground class so a rare structure appears in most batches
- Combine cross-entropy with Dice, and report both Dice and IoU per class rather than the average over classes
Per-class Dice and IoU on a held-out set split by PATIENT or scene, never by slice or tile (neighbouring slices are near duplicates). Add a boundary-sensitive measure such as Hausdorff distance, since Dice barely moves when a thin spur is missed. Report mean over images and the worst-decile images, because the mean hides the failure that matters.
- Splitting by slice instead of by patient, which leaks anatomy between train and test and inflates Dice
- Pixel accuracy as the headline number on an imbalanced mask, which rewards predicting nothing
- Odd input sizes with floor-division pooling, which silently misalign the upsampled map against its skip
- Tile seams at inference when windows are stitched without overlap and blending
What it costs to run
Hours on a single GPU for a 2-D biomedical dataset of hundreds of images; days for large 3-D volumes. Memory rather than compute is the limit, so patch size, not model size, sets the budget. Pretrained encoders cut the time and the data needed substantially.
Tens of milliseconds per 512x512 tile on GPU, seconds per large 3-D volume with overlapping sliding windows. Full-resolution activations and the held skip tensors dominate memory, so inference is windowed with overlap and Gaussian blending rather than run on the whole image.
Whenever the acquisition changes: a new scanner, staining protocol, camera or sensor is a larger trigger than elapsed time. Re-evaluate on a freshly annotated sample from each new site before trusting a model that was validated elsewhere.
- Track the predicted foreground fraction and the distribution of connected-component sizes; a shift is the cheapest early signal that the input distribution moved
- Monitor per-site and per-scanner Dice on a small recurring annotated audit set, since aggregate Dice hides a single failing source
- Watch the mean per-pixel entropy of the output, which rises on out-of-distribution inputs before accuracy visibly drops
- Resample to the training voxel or pixel spacing before inference; a model trained at one resolution silently degrades at another
- Window with overlap and blend with a Gaussian weight, otherwise predictions at tile edges (where context is missing) show as visible seams
- Input height and width must be a multiple of 2 to the depth, or padding and cropping must be handled explicitly, otherwise concatenation fails or misaligns by a pixel
- BatchNorm at a batch of 1 to 2 gives unstable statistics; use GroupNorm or InstanceNorm, and keep the model in eval mode when serving
- Dice on an image with an empty ground-truth mask is undefined; decide the convention (1 if the prediction is also empty) before comparing numbers across teams
Pros and cons, in context
Pixel-accurate boundaries from very little labelled data
Concatenated skips return the spatial detail that downsampling discarded, and augmentation (elastic deformation above all) stretches a few dozen annotated images a long way. Decisive in biomedical settings where annotation is expensive; matters less once data is abundant.
One architecture serves segmentation, restoration and denoising
Anywhere the output has the same shape as the input and needs both global context and local detail, the same encoder-decoder with skips fits, from medical masks to the denoiser in a diffusion model. The caveat is that very large diffusion systems are moving to transformer backbones.
Trains stably without residual blocks
Each encoder activation has a short gradient route through its skip, so early layers are one concatenation from the loss. Depth is rarely the problem; data and class imbalance are.
Overlap losses make severe class imbalance tractable
A Dice term normalises by foreground size, so a 2% lesion matters as much as a large organ and the all-background solution stops being a plateau. It needs cross-entropy alongside it for early-training stability.
Fully convolutional, so it accepts any input size that is a valid multiple
There is no fixed dense layer, so the same weights run on tiles of different size. This is what makes overlapping sliding-window inference over arbitrarily large images possible.
Memory-hungry because encoder activations must be held for the skips
Full-resolution feature maps from every level are alive until the decoder consumes them, and 3-D variants multiply this by the depth axis. Tile size, not parameter count, limits the GPU; gradient checkpointing trades compute for memory when it bites.
Dice loss is unstable on tiny or empty targets
Its gradient scales like the reciprocal of the foreground mass, so a handful of pixels can swing a step and an empty mask gives 0/0. Smoothing, batch-level pooling and cross-entropy mitigate it; they do not remove it.
Fixed local receptive field with no global reasoning
Context grows only through downsampling depth. Tasks needing long-range agreement across the whole image (consistent labelling of a large structure from distant cues) favour attention-based segmenters once data supports them.
A plain U-Net is a poor reconstruction-based anomaly detector
The skips let the decoder copy the input, so defects are reconstructed along with everything else and the residual map is blank. Useful anomaly detection needs supervised masks, synthetic defects or constrained skips.
Per-pixel classes cannot separate touching instances
Two adjacent objects of one class merge into a single blob. Instance separation needs a boundary class and a watershed, a weighted border loss, or a detection-based head.
Intuitive, idiomatic, optimized
The same algorithm in three languages, each progressing through Kent Beck's three stages. Every optimization names the lever it pulls and the trade-off it accepts — cost profiles are illustrative, not measured benchmarks.
The math, transcribed literally. No dependencies.
"""A one-level U-Net forward pass and the Dice score - the structure, written out.
Feature maps are lists of channels, and a channel is a 2-D list [h][w]. Nothing
is vectorized, so every function maps onto one box of the encoder / decoder /
skip diagram. Concatenation across channels is literally list concatenation.
"""
import math
def conv3x3_same(maps, kernels, bias):
"""kernels[out][in] is a 3x3 grid; zero padding keeps height and width."""
h, w = len(maps[0]), len(maps[0][0])
output = []
for out_ch in range(len(kernels)):
channel = [[bias[out_ch]] * w for _ in range(h)]
for in_ch in range(len(maps)):
kernel = kernels[out_ch][in_ch]
for y in range(h):
for x in range(w):
for u in range(3):
for v in range(3):
yy, xx = y + u - 1, x + v - 1
if 0 <= yy < h and 0 <= xx < w: # zero padding
channel[y][x] += maps[in_ch][yy][xx] * kernel[u][v]
output.append(channel)
return output
def relu(maps):
return [[[max(0.0, v) for v in row] for row in channel] for channel in maps]
def max_pool2(maps):
"""Contracting path: halve resolution, keep the strongest response."""
h, w = len(maps[0]), len(maps[0][0])
return [
[
[
max(channel[2 * i + u][2 * j + v] for u in range(2) for v in range(2))
for j in range(w // 2)
]
for i in range(h // 2)
]
for channel in maps
]
def up_conv2x2(maps, kernels, bias):
"""Expanding path: transposed conv, stride 2. Each input pixel paints a 2x2 block."""
h, w = len(maps[0]), len(maps[0][0])
output = [[[bias[o]] * (2 * w) for _ in range(2 * h)] for o in range(len(kernels))]
for out_ch in range(len(kernels)):
for in_ch in range(len(maps)):
for y in range(h):
for x in range(w):
for u in range(2):
for v in range(2):
output[out_ch][2 * y + u][2 * x + v] += (
maps[in_ch][y][x] * kernels[out_ch][in_ch][u][v]
)
return output
def concat(skip_maps, decoder_maps):
"""The U-Net skip: stack channels. ResNet would ADD them element-wise instead."""
return skip_maps + decoder_maps
def conv1x1(maps, weights, bias):
h, w = len(maps[0]), len(maps[0][0])
return [
[[bias[o] + sum(weights[o][c] * maps[c][y][x] for c in range(len(maps))) for x in range(w)] for y in range(h)]
for o in range(len(weights))
]
def softmax_per_pixel(logits):
h, w = len(logits[0]), len(logits[0][0])
probs = [[[0.0] * w for _ in range(h)] for _ in logits]
for y in range(h):
for x in range(w):
peak = max(channel[y][x] for channel in logits)
exps = [math.exp(channel[y][x] - peak) for channel in logits]
total = sum(exps)
for c, value in enumerate(exps):
probs[c][y][x] = value / total
return probs
def unet_forward(image, params):
"""encoder -> downsample -> bottleneck -> upsample -> CONCAT skip -> decoder -> head."""
enc = relu(conv3x3_same(image, *params["enc"])) # full resolution
down = max_pool2(enc) # context up, detail lost
mid = relu(conv3x3_same(down, *params["mid"]))
up = up_conv2x2(mid, *params["up"]) # back to full resolution, but blurry
merged = concat(enc, up) # the skip returns the lost detail
dec = relu(conv3x3_same(merged, *params["dec"]))
return softmax_per_pixel(conv1x1(dec, *params["head"]))
def cross_entropy(probs, labels):
"""Mean over every pixel of -log p(true class)."""
h, w = len(labels), len(labels[0])
total = 0.0
for y in range(h):
for x in range(w):
total -= math.log(max(probs[labels[y][x]][y][x], 1e-12))
return total / (h * w)
def dice_set(predicted, truth):
"""2|A n B| / (|A| + |B|), with masks as sets of (row, col) pixels."""
if not predicted and not truth:
return 1.0 # both empty: a perfect match by convention
return 2 * len(predicted & truth) / (len(predicted) + len(truth))
def jaccard_set(predicted, truth):
"""|A n B| / |A u B| - the IoU used as the evaluation metric."""
union = predicted | truth
return len(predicted & truth) / len(union) if union else 1.0
def soft_dice_loss(probs, labels, eps=1e-6):
"""1 - mean over classes of 2*sum(p*y) / (sum(p) + sum(y)), on soft probabilities."""
h, w = len(labels), len(labels[0])
scores = []
for c in range(len(probs)):
intersection = prob_sum = truth_sum = 0.0
for y in range(h):
for x in range(w):
is_class = 1.0 if labels[y][x] == c else 0.0
intersection += probs[c][y][x] * is_class
prob_sum += probs[c][y][x]
truth_sum += is_class
scores.append((2 * intersection + eps) / (prob_sum + truth_sum + eps))
return 1.0 - sum(scores) / len(scores)
def imbalance_demo():
"""A 2%-foreground mask: predicting nothing scores 98% accuracy and 0 overlap."""
truth = {(r, c) for r in range(10) for c in range(20)} # 200 of 10,000 pixels
predicted = set() # "background everywhere"
accuracy = 1 - len(predicted ^ truth) / 10_000
return accuracy, dice_set(predicted, truth), jaccard_set(predicted, truth)O(H·W·C_in·C_out·k²) per conv in pure Python, thousands of times slower than a real implementation; one level only, to keep the skip visible.
How this language actually wants it written.
"""U-Net - typed modules, concat skips with explicit shape contracts, Dice + CE."""
from dataclasses import dataclass
import torch
import torch.nn.functional as F
from torch import Tensor, nn
@dataclass(frozen=True)
class UNetConfig:
in_channels: int = 1
num_classes: int = 2
base_width: int = 32
depth: int = 4
@property
def size_multiple(self) -> int:
"""Each downsampling halves H and W, so inputs must be a multiple of 2**depth."""
return 2**self.depth
class DoubleConv(nn.Module):
"""(Conv3x3 -> BatchNorm -> ReLU) twice. Bias is off: BatchNorm re-centres anyway."""
def __init__(self, in_ch: int, out_ch: int) -> None:
super().__init__()
self.block = nn.Sequential(
nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1, bias=False),
nn.BatchNorm2d(out_ch),
nn.ReLU(inplace=True),
)
def forward(self, x: Tensor) -> Tensor:
return self.block(x)
class Down(nn.Module):
def __init__(self, in_ch: int, out_ch: int) -> None:
super().__init__()
self.pool = nn.MaxPool2d(2)
self.conv = DoubleConv(in_ch, out_ch)
def forward(self, x: Tensor) -> Tensor:
return self.conv(self.pool(x))
class Up(nn.Module):
"""Upsample, CONCATENATE the encoder skip, then convolve.
The concat is why the decoder conv takes (in_ch // 2) + skip_ch channels:
the two sources are stacked and the conv learns how to weigh them. A
ResNet-style add would need equal channel counts and mix them blindly.
"""
def __init__(self, in_ch: int, skip_ch: int, out_ch: int) -> None:
super().__init__()
self.up = nn.ConvTranspose2d(in_ch, in_ch // 2, kernel_size=2, stride=2)
self.conv = DoubleConv(in_ch // 2 + skip_ch, out_ch)
def forward(self, x: Tensor, skip: Tensor) -> Tensor:
x = self.up(x)
if x.shape[-2:] != skip.shape[-2:]:
raise ValueError(
f"skip {tuple(skip.shape[-2:])} and upsampled {tuple(x.shape[-2:])} "
"feature maps disagree; pad the input to a multiple of 2**depth"
)
return self.conv(torch.cat([skip, x], dim=1))
class UNet(nn.Module):
def __init__(self, config: UNetConfig) -> None:
super().__init__()
if config.depth < 1:
raise ValueError(f"depth must be >= 1, got {config.depth}")
if config.base_width < 1:
raise ValueError(f"base_width must be >= 1, got {config.base_width}")
self.config = config
widths = [config.base_width * 2**level for level in range(config.depth + 1)]
self.stem = DoubleConv(config.in_channels, widths[0])
self.downs = nn.ModuleList(
Down(widths[level], widths[level + 1]) for level in range(config.depth)
)
self.ups = nn.ModuleList(
Up(widths[level + 1], widths[level], widths[level])
for level in reversed(range(config.depth))
)
self.head = nn.Conv2d(widths[0], config.num_classes, kernel_size=1)
def forward(self, x: Tensor) -> Tensor:
if x.dim() != 4:
raise ValueError(f"expected (batch, channels, height, width), got {tuple(x.shape)}")
multiple = self.config.size_multiple
height, width = x.shape[-2:]
if height % multiple or width % multiple:
raise ValueError(
f"height and width must be multiples of {multiple}, got {height}x{width}; "
"use pad_to_multiple() and crop the output"
)
skips: list[Tensor] = []
x = self.stem(x)
for down in self.downs:
skips.append(x) # held until the matching decoder level needs it
x = down(x)
for up in self.ups:
x = up(x, skips.pop())
return self.head(x) # logits: (batch, num_classes, H, W)
def pad_to_multiple(x: Tensor, multiple: int) -> tuple[Tensor, tuple[int, int]]:
"""Reflect-pad bottom/right so H and W divide evenly; return the padding to undo it.
Padding then cropping the OUTPUT is preferred to cropping the input, which
would throw away real pixels at the border.
"""
height, width = x.shape[-2:]
pad_h = (-height) % multiple
pad_w = (-width) % multiple
if pad_h >= height or pad_w >= width:
raise ValueError("input is too small to reflect-pad; use mode='constant' instead")
return F.pad(x, (0, pad_w, 0, pad_h), mode="reflect"), (pad_h, pad_w)
def crop_padding(x: Tensor, padding: tuple[int, int]) -> Tensor:
pad_h, pad_w = padding
return x[..., : x.shape[-2] - pad_h, : x.shape[-1] - pad_w]
class DiceCrossEntropy(nn.Module):
"""Cross-entropy + soft Dice. CE is smooth early; Dice fixes class imbalance."""
def __init__(self, dice_weight: float = 1.0, eps: float = 1e-6) -> None:
super().__init__()
if dice_weight < 0.0:
raise ValueError(f"dice_weight must be >= 0, got {dice_weight}")
self.dice_weight = dice_weight
self.eps = eps
def forward(self, logits: Tensor, target: Tensor) -> Tensor:
"""logits (N, C, H, W); target (N, H, W) of integer class ids."""
if target.dim() != 3:
raise ValueError(f"target must be (N, H, W), got {tuple(target.shape)}")
cross_entropy = F.cross_entropy(logits, target)
probs = logits.float().softmax(dim=1)
one_hot = F.one_hot(target, logits.shape[1]).permute(0, 3, 1, 2).to(probs.dtype)
# Pooled over batch AND space, per class. Per-image Dice is 0/0 on an
# image with no foreground and its gradient blows up as mass -> 0.
dims = (0, 2, 3)
intersection = (probs * one_hot).sum(dims)
cardinality = probs.sum(dims) + one_hot.sum(dims)
dice = (2.0 * intersection + self.eps) / (cardinality + self.eps)
return cross_entropy + self.dice_weight * (1.0 - dice.mean())
@dataclass(frozen=True)
class SegmentationScores:
dice: float
iou: float
@torch.no_grad()
def class_scores(pred: Tensor, target: Tensor, class_id: int) -> SegmentationScores | None:
"""Hard-mask Dice and IoU for one class; None when the class is absent from both."""
predicted = pred == class_id
truth = target == class_id
intersection = int((predicted & truth).sum())
total = int(predicted.sum()) + int(truth.sum())
if total == 0:
return None # undefined, not perfect: skip rather than inflate the mean
union = total - intersection
return SegmentationScores(dice=2 * intersection / total, iou=intersection / union)What changed: The loops become framework modules, and the skip becomes an explicit shape contract. The Up block asserts that the upsampled map and the encoder skip agree spatially, and the model refuses inputs that are not a multiple of 2 to the depth, with pad_to_multiple and crop_padding handling the odd-size case by padding the input and cropping the output rather than discarding border pixels. The loss is built from the same two terms as the objective, but with the details that matter: softmax in float32, Dice pooled over the batch so an empty-foreground image cannot produce 0/0, and an epsilon so absent classes score 1. Scoring skips classes absent from both prediction and truth rather than counting them as perfect, which would inflate the mean.
O(H·W·C0²·k²) per level, roughly equal across levels, on fused cuDNN kernels; skip tensors held until the decoder.
Faster, and the justification for each change.
"""U-Net - channels-last + bf16 + compile for training; batched, blended tiling for inference."""
import torch
from torch import Tensor, nn
def prepare_for_training(model: nn.Module, device: str) -> nn.Module:
"""channels_last layout and torch.compile.
Tensor cores want NHWC; PyTorch defaults to NCHW, so cuDNN inserts a
transpose around every convolution. Converting once removes it everywhere.
"""
model = model.to(device, memory_format=torch.channels_last)
return torch.compile(model) # fuses BatchNorm + ReLU into the surrounding kernels
def train_step(model: nn.Module, optimizer, loss_fn, images: Tensor, masks: Tensor, device: str) -> float:
images = images.to(device, non_blocking=True, memory_format=torch.channels_last)
masks = masks.to(device, non_blocking=True)
optimizer.zero_grad(set_to_none=True)
with torch.autocast(device_type=device, dtype=torch.bfloat16):
logits = model(images)
# Dice sums many small probabilities: do that reduction in float32.
loss = loss_fn(logits.float(), masks)
loss.backward()
optimizer.step()
return float(loss.item())
def gaussian_window(tile: int, device: str, sigma_scale: float = 0.125) -> Tensor:
"""Weight tile centres more than edges, where the receptive field lacks context."""
coords = torch.arange(tile, dtype=torch.float32, device=device) - (tile - 1) / 2
profile = torch.exp(-(coords**2) / (2 * (tile * sigma_scale) ** 2))
window = profile[:, None] * profile[None, :]
return window / window.max()
def tile_origins(length: int, tile: int, stride: int) -> list[int]:
"""Window starts that cover the axis, with a final tile flush against the edge."""
if length < tile:
raise ValueError(f"image side {length} is smaller than the tile {tile}; pad first")
last = length - tile
origins = list(range(0, last + 1, stride))
if origins[-1] != last:
origins.append(last)
return origins
@torch.no_grad()
def sliding_window_predict(
model: nn.Module,
image: Tensor, # (C, H, W), already padded to be at least tile x tile
num_classes: int,
tile: int = 256,
overlap: float = 0.5,
batch_size: int = 8,
) -> Tensor:
model.eval()
device = image.device.type
_, height, width = image.shape
stride = max(1, int(tile * (1.0 - overlap)))
window = gaussian_window(tile, device)
# Pre-allocated accumulators; every tile is added in place, no per-tile result list.
prob_sum = torch.zeros(num_classes, height, width, device=image.device)
weight_sum = torch.zeros(1, height, width, device=image.device)
origins = [
(top, left)
for top in tile_origins(height, tile, stride)
for left in tile_origins(width, tile, stride)
]
for start in range(0, len(origins), batch_size):
batch_origins = origins[start : start + batch_size]
# One forward over a stack of tiles instead of one call per tile.
batch = torch.stack(
[image[:, top : top + tile, left : left + tile] for top, left in batch_origins]
).contiguous(memory_format=torch.channels_last)
with torch.autocast(device_type=device, dtype=torch.bfloat16):
logits = model(batch)
probs = logits.float().softmax(dim=1)
for index, (top, left) in enumerate(batch_origins):
prob_sum[:, top : top + tile, left : left + tile].add_(probs[index] * window)
weight_sum[:, top : top + tile, left : left + tile].add_(window)
return prob_sum / weight_sumWhat changed: Three kinds of change, all about the hardware rather than the model. For training, tensors move to channels-last, the model is compiled so BatchNorm and ReLU fuse into neighbouring kernels, and the forward pass runs in bfloat16 while the Dice reductions are cast back to float32. For inference, the dominant cost is that a full image does not fit, so it is cut into overlapping tiles that are run as a stacked batch (one forward call for many tiles) and blended with a Gaussian window into pre-allocated accumulators updated in place, which removes the visible seams that unblended tiling leaves at tile edges.
Ensure contiguous memory layout and a single dtype
channels_last is the layout tensor cores actually want, and a single bfloat16 compute dtype halves activation memory, which for a U-Net is the binding constraint because the skip tensors must be held.
Trade-off: bfloat16 has about three significant digits, so reductions like Dice sums are cast to float32 by hand, and any custom op assuming NCHW silently transposes back and can make the model slower.
Fuse adjacent operations so intermediates are never materialized
torch.compile folds the BatchNorm and ReLU that follow each convolution into fewer kernels, so those elementwise passes stop reading and writing the whole activation tensor.
Trade-off: Compilation takes seconds to minutes and recompiles for every new input shape, so tiles must be a fixed size; the torch.cat for the skip still materializes the concatenated tensor.
Batch work to amortize interpreter overhead
Running stacked tiles through one forward call amortizes kernel-launch and Python overhead and keeps the GPU saturated, where a call per tile leaves it idle between small launches.
Trade-off: Peak memory scales with the batch because every tile holds its own skip tensors, so the batch size is capped by memory and a too-large value fails with out-of-memory rather than running slower.
Pre-allocate output arrays and use in-place operations
The probability and weight accumulators are allocated once and updated with add_, so there is no list of per-tile results to hold and then stitch together.
Trade-off: The accumulators are full-image float32 (classes x H x W), which is large for a big 3-D volume, and in-place updates are only legal under no_grad, so this path cannot be reused for training.
Roughly 1.5-2x faster steps from layout, precision and fusion; tiling converts an out-of-memory failure into a bounded-memory run. Illustrative, not a measured benchmark.