The problem in brief
You must segment PASCAL VOC 2012 images at the pixel level into six classes: background plus person, car, dog, bicycle and chair (all other VOC objects count as background). Only images containing at least one of the five classes are kept: 726 training images (from VOC train) and 719 evaluation images (from VOC val), resized to . The notebook walks through a progression: data augmentation (Q1.1), a U-Net (Q1.2), a class-weighted loss (Q1.3), a U-Net whose encoder is a pretrained ResNet-34 (Q1.4, plus a bonus for higher mIoU), a residual U-Net and the focal loss (Q2), then an analysis of the class distribution, hyperparameter tuning, other pretrained backbones and other architectures (Q3).
How you are scored
100 points, mostly for correct code and answers, with the bonus of Q1.4 "based on the final mIoU". The metric is the mean Intersection over Union over the five object classes (background excluded, pixels labelled 255 ignored):
The notebook states that the ResNet-34 U-Net trained for 5 epochs should reach "around 35 % mIoU": that is our reference.
A subtlety: the notebook's validate_model computes the IoU per batch of 8 images and averages over batches, skipping classes absent from a batch. We report the standard dataset-level mIoU (one confusion matrix accumulated over all validation pixels), and give the notebook's number for the final model too. They differ: a few false-positive pixels of a class absent from a batch give that batch an IoU of 0 for the class, which drags the batch average down.
First look at the data
Q3 Task 1 asks to look for a long tail, and it is there.

- Background is 81 % of the pixels; a model predicting "background" everywhere has 81 % pixel accuracy and 0 mIoU. That is why accuracy is useless here and why the loss needs care.
- Person dominates the objects (442 of 726 images); bicycle is the rarest (0.6 % of pixels, 65 images) and also the hardest shape: thin frames and spokes, mostly made of background pixels.
- Chairs are often small, partly hidden and seen from any angle.
A first baseline
The notebook's warm-up model (two convolutions, one pooling, upsampling) reaches 0.068 mIoU after 5 epochs, and the U-Net from scratch with weighted cross-entropy 0.062. Neither has learnt to recognise objects: with 726 images, learning "what a dog is" from random weights in 5 epochs is hopeless. The U-Net (31 M parameters) is not worse because of its architecture; it simply has far more to learn from too little data.
The key idea
Segmentation needs two things: knowing what is in the image and knowing where exactly. A U-Net gets the "what" from its deep, low-resolution encoder and the "where" from skip connections; with little data, the "what" must be borrowed from a pretrained encoder.
The code questions.
- Q1.1, augmentation. A horizontal flip with probability 0.5 and a random rotation in , with the same random draw applied to the image and to the mask (otherwise labels no longer match pixels). The mask must be rotated with nearest-neighbour interpolation (no in-between class values), and the corners that the rotation brings in are filled with the "ignore" label 255 rather than with background. The notebook re-seeds
torchbefore each of its twoComposepipelines to synchronise them; drawing the parameters once and callingTF.hflip/TF.rotateon both is cleaner. - Q1.2, U-Net.
self.inc = DoubleConv(3, 64); eachdownKisnn.Sequential(nn.MaxPool2d(2), DoubleConv(c, 2c))up to 1024 channels; the skip connection istorch.cat([x, x4], dim=1)(channel-wise concatenation, which is whyconv1takes 1024 channels); the head is a convolutionnn.Conv2d(64, n_classes, 1)that turns each pixel's 64 features into 6 class scores. - Q1.3, weighted loss.
nn.CrossEntropyLoss(weight=class_weights, ignore_index=255), with weights proportional to the inverse pixel frequency (normalised to sum to 6): background gets 0.026, bicycle 3.5. A bicycle pixel then counts 135 times more than a background pixel. - Q1.4, pretrained encoder.
self.encoder1 = nn.Sequential(resnet.conv1, resnet.bn1, resnet.relu): the first ResNet stage (a convolution with stride 2, then BatchNorm and ReLU), at half resolution, whose output is the shallowest skip connection. The pretrained encoder gets learning rate and the new decoder : we want to adjust ImageNet features gently, not destroy them, while the randomly initialised decoder must learn fast. - Q2.1, residual block.
out = out + self.skip(residual), whereskipis the identity or a convolution when the channel count changes. The block then learns a correction to its input, and the gradient flows through the "+" unchanged, which is what makes deep networks trainable. - Q2.2, focal loss. With the predicted probability of the true class,
(focusing) multiplies the cross-entropy by : a pixel already classified with has its loss divided by 100 for , so training concentrates on hard, misclassified pixels (object boundaries, rare classes) instead of the millions of easy background pixels. (balancing) rescales the loss, globally or per class, to compensate for class frequency, the same role as the class weights of Q1.3. Our implementation computes from the unweighted cross-entropy and applies the class weights and on top.
What actually moves the mIoU (Q1.4 bonus and Q3). Three changes, each with a reason:
- Train longer, with a cosine learning-rate schedule. Five epochs on 726 images are only 455 gradient steps; the curve below is still climbing steeply at epoch 5. Twenty epochs with a cosine decay take the ResNet-34 U-Net from 0.243 to 0.388.
- Replace the inverse-frequency weights by cross-entropy + soft Dice. The weights are extreme (background ), so the model is pushed to paint uncertain regions with rare classes, which creates false positives and ruins IoU, a metric that punishes false positives as much as misses. The soft Dice loss optimises an overlap measure directly, per class, and is insensitive to how many pixels a class has:
where is the softmax probability of class at pixel . Plain (unweighted) cross-entropy keeps the per-pixel calibration. Result: 0.484, and background IoU jumps from 0.74 to 0.92. 3. A stronger pretrained encoder (Q3 Task 3). ResNet-50 (feature maps of 256 to 2048 channels instead of 64 to 512) with the same decoder recipe: 0.609. Better features are the single largest gain: dog 0.71 to 0.79, bicycle 0.0 to 0.35.
The full solution
solution.py contains the data pipeline, all the models (simple CNN, U-Net, ResNet-34 and ResNet-50 U-Nets), the weighted CE, focal and Dice losses, and the evaluation; python solution.py <model> <epochs> trains one configuration. The data come from the Hugging Face mirror nateraw/pascal-voc-2012 (the official 1 464 / 1 449 segmentation split, masks stored as colour PNGs, which we map back to class indices with the standard VOC colour map). Each image is resized to once at load time; all models use batches of 8 and Adam; one epoch takes about 20 seconds on a laptop GPU (Apple MPS).
loss = F.cross_entropy(logits, target, ignore_index=255) + dice_loss(logits, target)
opt = Adam([{"params": encoder, "lr": 1e-4}, {"params": decoder, "lr": 1e-3}])
sched = CosineAnnealingLR(opt, T_max=20)
Results
Dataset-level mIoU over the 5 object classes on the 719 validation images (VOC val, whose labels are public; VOC has no public test labels). All numbers are those of the last epoch, not the best one, so that the validation set is not used to pick a checkpoint.
| model | loss | epochs | mIoU | person | car | dog | bicycle | chair |
|---|---|---|---|---|---|---|---|---|
| simple CNN (warm-up) | weighted CE | 5 | 0.068 | 0.16 | 0.06 | 0.08 | 0.02 | 0.01 |
| U-Net from scratch | weighted CE | 5 | 0.062 | 0.14 | 0.08 | 0.06 | 0.02 | 0.01 |
| ResNet-34 U-Net (notebook setting) | weighted CE | 5 | 0.243 | 0.38 | 0.31 | 0.42 | 0.03 | 0.07 |
| ResNet-34 U-Net | weighted CE, cosine | 20 | 0.388 | 0.58 | 0.53 | 0.47 | 0.21 | 0.15 |
| ResNet-34 U-Net | CE + Dice, cosine | 20 | 0.484 | 0.76 | 0.75 | 0.71 | 0.00 | 0.20 |
| ResNet-50 U-Net | CE + Dice, cosine | 20 | 0.609 | 0.81 | 0.81 | 0.79 | 0.35 | 0.28 |
For the final model, the notebook's batch-averaged metric gives 0.490 (against 0.609 dataset-level): whichever way it is measured, it is well above the expected 35 %. Our 5-epoch ResNet-34 run (0.243) is below the notebook's "around 35 %"; differences in metric, augmentation and data loading probably explain part of the gap, and the curve shows that 5 epochs is simply too early to judge.


What remains hard: chairs (0.28) and bicycles (0.35). Both are thin or occluded, with few training examples, and VOC annotates bicycles as filled regions while the model learns to follow the frame. The ResUNet of Q2.1 and the focal loss of Q2.2 are implemented in the code, and Q3 Task 4 suggests comparing with Transformer-based segmenters (SegFormer, Mask2Former); we did not train those three variants within our compute budget. Given the table, a pretrained Transformer backbone is the most promising next step; a residual U-Net trained from scratch would face the same lack of data as the plain U-Net.
What to remember
- Look at the class distribution first: 81 % background means pixel accuracy is meaningless, and rare thin classes (bicycle) will dominate the mIoU's weak spots.
- Augment image and mask with the same random transform, nearest-neighbour for masks, ignore label for padded corners.
- Pretrained encoders are what make segmentation possible on a few hundred images: from scratch, 0.06; with ImageNet ResNet-34, 0.39; with ResNet-50, 0.61. Use a smaller learning rate for the pretrained part.
- Pick a loss that matches the metric: extreme inverse-frequency weights create false positives that IoU punishes; CE + soft Dice optimises overlap directly.
- Check how the metric is aggregated (per batch or over the whole dataset): the same model scores 0.49 or 0.61.