PyTorch CNN

OVERVIEW

What you'll learn

In Part 1 we built the pipeline to prepare the datasets - A custom Dataset, transforms, and a DataLoader that hands out augmented, shuffled batches of Oxford 102 Flowers with 8K images images across 102 classes. We also split the dataset into training, test and validation and show how the transforms were applied differently for training and validation datasets. If you haven't read it, you may want to read that first.

Data Management in PyTorch: Taming the Oxford 102 Flowers Dataset

Custom Dataset, transforms, DataLoader, and the bug-proofing that turns a folder of unlabeled images into training-ready batches.


In this part, we will build a convolutional neural network (CNN) from scratch in PyTorch and train it on the Oxford 102 Flowers dataset. We will also inspect and debug the model and learn how to improve CNNs.

WHY CNNS?

Why a stack of linear layers can't see a flower?

Linear layers treat every pixel as independent, CNNs do not.

To train an image with 224 x 224 x 3 channels, a stack of nn.Linear layers would require 224 * 224 * 3 = 150,528 input nodes. This is computationally expensive and leads to overfitting due to the large number of parameters.

And any hidden layer added increases the complexity and parameters really quickly. If you were to add even one hidden layer with 2048 nodes, the number of parameters would explode. This is already 300M+ parameters

Additonally, Linear layers treat every pixel as independent. The model sees thousands of separate numbers with no understanding that neighbouring pixels form petals, edges, and veins.

To contrast, how do you look at image? You don't identify a flower pixel by pixel either. You notice shape, contrast, texture. That's what CNNs are made for. Understand pattern from the images

The idea CNNs are inspired by biology. Working in the 1960s, Hubel and Wiesel found cells in the visual cortex that fire only for edges at a particular orientation. CNNs borrow the idea: a small detector, applied everywhere.

Let's understand how CNNs do it, via convolution.

CONVOLUTION

The C in CNNs

Convolution is a filter

At each position, multiply the filter values with the pixel values underneath and add them together. Slide it across the image and every pixel gets a new value based on its neighbours. That process is a convolution.

Different weights highlight different patterns. Take a vertical edge filter. Similar pixels left and right cancel out. A sharp contrast — dark left, bright right — produces a strong output. Rotate the same idea and you get a horizontal edge detector.

image multiply + add feature map downsampled one filter in, one map out

The output isn't a picture. It's an array of numbers showing how strongly that filter reacted to each part of the image — a feature map.

Why the network learns its own filters

You could design filters by hand. But which filters separate a pink primrose from a wild pansy? You don't know, and you shouldn't have to. The model learns the filters and tunes them to the patterns that distinguish your classes.

The five arguments to nn.Conv2d

conv_layer.py
nn.Conv2d(in_channels=3, out_channels=32, kernel_size=3, stride=1, padding=1)
  • in_channels — colour channels coming in. Three for RGB.
  • out_channels — how many filters this layer learns. Each detects a different feature.
  • kernel_size — the size of each filter. 3×3 sees every pixel with its immediate neighbours.
  • stride — how far the filter moves each step. 1 checks every pixel; 2 halves the output.
  • padding — adds zeros outside the border so the filter can centre on an edge pixel.
Padding decides whether your image shrinks Kernel 3 with padding 1 keeps the size. Kernel 3 with no padding takes 2 pixels off each side, every layer. Both are fine — but you have to know which one you chose when you compute the flatten size later.

What a filter actually does to a picture

That is the mechanic. Here is what it looks like on a real photo. The same image runs through two filters that differ only in how their nine numbers are arranged — and each one keeps a different set of edges.

Original grayscale photo of Marina Bay Sands at night
original
horizontal edges
Horizontal Sobel edge map — rooftop deck, ledges and waterline light up
vertical edges
Vertical Sobel edge map — the towers' vertical sides light up

Both filters are the same nine numbers, transposed. The horizontal kernel lights up the rooftop deck, the ledges, and the waterline; the vertical one lights up the towers' sides. The kernel decides which edges survive.

Making the Image smaller

MaxPooling

pooling.py
# halves H and W, no weights
nn.MaxPool2d(kernel_size=2)  

Max pooling (MaxPool2d) takes a 2×2 area, keeps the largest value, discards the rest. The output is a quarter of the size. Repeat across every 2×2 group and you end up with a smaller output. This is safe because your filters have already extracted the important features from the original image, so pooling just compresses each filtered image, keeping the most significant information.

4×4 feature mapeach 2×2 windowmax 0 64 128 128 48 192 144 144 142 226 168 0 255 0 0 64 0 64 48 192 128 128 144 144 142 226 255 0 168 0 0 64 192 144 255 168

The highlighted window — 168, 0, 0, 64 — keeps 168 and drops the other three. Repeat for all four windows and the 4×4 map becomes 2×2.

Rule of thumb Each max pooling layer halves the size of its input.

CHAINING CONV2D, RELU and MAXPOOL2D

The First Architecture

Four blocks of convolution, ReLU, pool. Then three linear layers. Textbook.

V1 — model.py
model = nn.Sequential(
    nn.Conv2d(in_channels=3, out_channels=16, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2),

    nn.Conv2d(in_channels=16, out_channels=32, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2),

    nn.Conv2d(in_channels=32, out_channels=64, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2),

    nn.Conv2d(in_channels=64, out_channels=128, kernel_size=3, padding=1),
    nn.ReLU(),
    nn.MaxPool2d(kernel_size=2),

    nn.Flatten(),
    nn.Linear(128*196, 2048),
    nn.Dropout(0.5),
    nn.Linear(2048, 512),
    nn.Dropout(0.5),
    nn.Linear(512, 102),
)
INPUT3@224x224 C1: f. maps16@112x112 C2: f. maps32@56x56 C3: f. maps64@28x28 C4: f. maps128@14x14 F5: layer2048 F6: layer512 OUTPUT102 conv + pool conv + pool conv + pool conv + pool flatten 25,088 Full connection Full connection

Everything the convolutions learn is squeezed through one 25,088-wide vector. Four convolution layers hold 0.2% of the model.

Channels grow 3 → 16 → 32 → 64 → 128. Padding 1 with kernel 3 keeps the size, so only the pooling layers shrink anything. Before it can train, two things need setting up: the defences that stop it memorising, and the loss and optimizer that drive it.

STOPPING THE MODEL FROM MEMORISING

Regularization

Dropout, and the husky that got called a wolf

Ribeiro et al. (2016): a husky classified as a wolf, not because it looked like one, but because the model had learned that snow means wolf. Some neurons become snow detectors and the rest lean on them. That's co-adaptation — the model leaning on a shortcut instead of learning body shape.

A grid of nine wolf photographs, most standing in snow

Look at almost any set of wolf photos and the background gives the game away: snow, again and again. A model can score well by learning "white background" instead of "wolf" — right for the wrong reason, until the first wolf on grass.

We avoid this co-adaption with Dropout Layers. During training, a dropout layer randomly deactivates a fraction of the neurons — around half. It sounds destructive. But the advantage is that, Dropout makes shortcuts risky. If the snow detector might vanish on any given batch, the network has to find other cues — the ones that actually matter. The technique is from Srivastava et al. (2014).

standard network after dropout

Each training step, dropout removes a random subset of units and every connection into or out of them. The network can't build a fixed dependency between two specific neurons if either might be gone next batch, so it spreads the work across many. At test time all units are back.

In practice dropout rates run 0.2 to 0.5, placed after the activation and before the final classification layer

When to not confuse with Codaptation

If every wolf image has snow and no dog image does, that's a dataset problem, not overfitting. The model is learning the only pattern it can see. Dropout helps when patterns are mixed, not when they're absent.

REGULARIZATION Dropout removes neurons at random; weight decay shrinks the weights that remain. Both make it harder for the model to lean on any single pattern, and both are set up in the next step.

LOSS AND OPTIMIZER

Loss and Optimizer

The loss is CrossEntropyLoss — standard for multi-class classification. The optimizer is Adam.

V2 — loss and optimizer
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)

loss_function = nn.CrossEntropyLoss()
optimizer = torch.optim.Adam(model.parameters(), lr=0.0005, weight_decay=0.0005)

CrossEntropyLoss expects raw scores, not probabilities — which is why the model ends at a bare linear layer with no softmax. Adding one here would be a bug that trains quietly and badly

Weight decay

weight_decay=0.0005 discourages the network from using very large weights. Large weights are often a sign the model is memorising specific training examples rather than learning features that generalise, so a small penalty for large weights nudges it toward simpler, more robust solutions.

RUNNING THE LOOP

Training

model.train() and model.eval() switch dropout and batch norm between modes — leave dropout on during validation and the accuracy is wrong. And optimizer.zero_grad() comes first, because gradients accumulate by default.

train and eval
def train_model():
    model.train()
    train_loss = correct = total = 0
    for batch, (imgs, lbls) in enumerate(train_loader):
        imgs, lbls = imgs.to(device), lbls.to(device)
        optimizer.zero_grad()
        preds = model(imgs)
        loss = loss_function(preds, lbls)
        loss.backward()
        optimizer.step()

        train_loss += loss.item()
        _, predicted = preds.max(1)
        total += lbls.size(0)
        correct += predicted.eq(lbls).sum().item()

        print(f'BATCH {batch+1} | TRAIN LOSS: {train_loss/(batch+1):.3f} | ACCURACY: {correct*100/total:.2f} %')

    torch.save({"epoch": epoch,
                "model_state_dict": model.state_dict(),
                "optimizer_state_dict": optimizer.state_dict()},
               ckpt_dir / f"epoch_{epoch:03d}.pt")


def eval_model():
    model.eval()
    val_loss = correct = total = 0
    with torch.no_grad():
        for batch, (imgs, lbls) in enumerate(val_loader):
            imgs, lbls = imgs.to(device), lbls.to(device)
            preds = model(imgs)
            loss = loss_function(preds, lbls)
            val_loss += loss.item()
            _, predicted = preds.max(1)
            total += lbls.size(0)
            correct += predicted.eq(lbls).sum().item()

    return val_loss/BATCH_SIZE, correct/total
training loop
for epoch in range(30):
    train_model()
    vl, acc = eval_model()
    print(f'EPOCH: {epoch+1} | VAL LOSS:{vl:.3f} | VAL ACCURACY: {acc*100:.2f}%')
    report = run_diagnostics(model, val_loader, train_dataset, val_dataset, device)
    train_vs_val_gap(model, train_dataset, val_loader, device)

The diagnostics the loop calls

Those last two lines are where every per-class number in this post comes from. Both run on the validation set after each epoch. Trimmed to the parts this post quotes:

diagnose.py (trimmed)
@torch.no_grad()
def collect_predictions(model, loader, device):
    model.eval()
    ys, ps, top5s, confs = [], [], [], []
    for imgs, lbls in loader:
        logits = model(imgs.to(device))
        ys.append(lbls)
        ps.append(logits.argmax(1).cpu())
        top5s.append(logits.topk(5, dim=1).indices.cpu())
        confs.append(logits.softmax(1).max(1).values.cpu())
    return [torch.cat(x).numpy() for x in (ys, ps, top5s, confs)]


def run_diagnostics(model, val_loader, train_dataset, device, n_classes=102, show=15):
    y, pred, top5, conf = collect_predictions(model, val_loader, device)
    correct = (pred == y)
    top5_correct = (top5 == y[:, None]).any(1)
    train_counts = np.bincount(labels_of(train_dataset), minlength=n_classes)

    print(f'  top-1 accuracy  : {correct.mean()*100:.2f}%')
    print(f'  top-5 accuracy  : {top5_correct.mean()*100:.2f}%')
    print(f'  mean confidence : {conf.mean():.3f} '
          f'(correct {conf[correct].mean():.3f} / wrong {conf[~correct].mean():.3f})')

    # collapse check — is it answering with only a handful of classes?
    pred_counts = np.bincount(pred, minlength=n_classes)
    used = (pred_counts > 0).sum()
    print(f'  distinct classes predicted : {used}/{n_classes}')
    if used < n_classes * 0.7:
        for c in np.argsort(-pred_counts)[:5]:
            print(f'    {NAMES[c]:<28} {pred_counts[c]*100/len(y):5.1f}% of all predictions')

    # per-class accuracy, sorted worst to best
    rows = [(c, int((y == c).sum()), correct[y == c].mean(),
             top5_correct[y == c].mean(), int(train_counts[c]))
            for c in range(n_classes) if (y == c).any()]
    rows.sort(key=lambda r: (r[2], r[1]))
    for title, block in [('WORST', rows[:show]), ('BEST', rows[-show:][::-1])]:
        print(f'\n{title} {show} CLASSES')
        for c, n, a1, a5, tn in block:
            print(f'  {NAMES[c]:<28} {n:>5} {a1*100:>6.1f}% {a5*100:>6.1f}% {tn:>7}')

    # is accuracy just tracking how much data each class had?
    tn = np.array([r[4] for r in rows], dtype=float)
    a1 = np.array([r[2] for r in rows], dtype=float)
    print(f'\n  corr(train images, val accuracy) = {np.corrcoef(tn, a1)[0,1]:+.3f}')
train_vs_val_gap
def train_vs_val_gap(model, train_dataset, val_loader, device, max_batches=10):
    model.eval()
    tr = accuracy(DataLoader(train_dataset, batch_size=128), limit=max_batches)
    va = accuracy(val_loader, limit=10**9)
    print(f'  train accuracy (in eval mode): {tr*100:.2f}%')
    print(f'  gap                          : {(tr-va)*100:+.2f} pts')
    if tr - va > 0.15:
        print('  -> OVERFITTING. Add augmentation, weight decay, early stopping.')
    elif tr < 0.75:
        print('  -> UNDERFITTING. The model is the ceiling, not the data.')
    return tr, va

The train side samples ten batches, not the whole training set, so the gap is an estimate. That is deliberate — a full pass over 5,737 images every epoch would cost more than the training step it is diagnosing.

The first architecture, over thirty epochs

Run that loop on the first architecture and here is what comes back.

V1 over 30 epochs — hover for the numbers. Training accuracy climbs past 81%. Validation accuracy crosses 55% at epoch 13 and then goes sideways, while validation loss bottoms out at epoch 18 and starts climbing back.

Over the last ten epochs validation accuracy moves between 58.14% and 61.81%. Best is 61.81% at epoch 28. Training loss keeps falling the whole time, 0.887 down to 0.623. Loss improving while validation accuracy does not is the signature of a model spending its capacity on the training set.

The problem is the single number 128*196.

Reading shapes, and the 51-million-parameter layer hiding in one line

Debugging

Where does 196 come from, and what does it cost?

Step 1: Debug Matrix Shapes

Images arrive at 224×224 from CenterCrop(224). Four pooling layers, each halving: 224 → 112 → 56 → 28 → 14. The last convolution outputs 128 channels. So the flattened vector is 128 × 14 × 14, and 14 × 14 is 196.

Stage Channels Spatial Values
input 3 224 × 224 150,528
after pool 1 16 112 × 112 200,704
after pool 2 32 56 × 56 100,352
after pool 3 64 28 × 28 50,176
after pool 4 128 14 × 14 25,088

If the shapes don't match you, will likely run into errors like this:

traceback
RuntimeError: mat1 and mat2 shapes cannot be multiplied (128x25088 and 2048x2048)

The first pair is what arrived — batch of 128, flattened to 25,088. The second is what the layer expected. Two numbers, and the fix is whichever one you can defend.

trace_shapes.py
img, label = next(iter(train_dataset))
x = img.unsqueeze(0)

for layer in model:
    x = layer(x)
    print(f'{layer.__class__.__name__:16} {tuple(x.shape)}')

This works because PyTorch builds the graph as the code runs — more on that in a moment.

Step 2: Counting Model Parameters

nn.Linear(25088, 2048) is 51,380,224 weights. The whole model is 52,581,126. One line holds 97.7% of the network.

V1 — parameter count
Trainable parameters: 52,581,126

  all four conv layers        97,440      0.2%
  Linear(25088, 2048)     51,382,272     97.7%
  Linear(2048, 512)        1,049,088      2.0%
  Linear(512, 102)            52,326      0.1%
Fix the architecture When the flatten size is enormous, the answer is more downsampling before the classifier. Smaller grid, fewer parameters, better features.

Version 2: nine times smaller, thirteen points better

The Rebuild

Same data, same optimizer, same 30 epochs. Different shape of network.

V2 — model.py
model = nn.Sequential(
    nn.Conv2d(3, 32, kernel_size=3), nn.ReLU(), nn.BatchNorm2d(32),
    nn.Conv2d(32, 32, kernel_size=3), nn.ReLU(), nn.BatchNorm2d(32),
    nn.Conv2d(32, 32, kernel_size=5, stride=2, padding=2), nn.ReLU(), nn.BatchNorm2d(32),
    nn.Dropout(0.4),

    nn.Conv2d(32, 64, kernel_size=3), nn.ReLU(), nn.BatchNorm2d(64),
    nn.Conv2d(64, 64, kernel_size=3), nn.ReLU(), nn.BatchNorm2d(64),
    nn.Conv2d(64, 64, kernel_size=5, stride=2, padding=2), nn.ReLU(), nn.BatchNorm2d(64),
    nn.Dropout(0.4),

    nn.Conv2d(64, 128, kernel_size=3), nn.ReLU(), nn.BatchNorm2d(128),
    nn.Conv2d(128, 128, kernel_size=3), nn.ReLU(), nn.BatchNorm2d(128),
    nn.Conv2d(128, 128, kernel_size=5, stride=2, padding=2), nn.ReLU(), nn.BatchNorm2d(128),
    nn.Dropout(0.4),

    nn.Conv2d(128, 256, kernel_size=5, stride=2, padding=2), nn.ReLU(), nn.BatchNorm2d(256),
    nn.Conv2d(256, 512, kernel_size=5, stride=2, padding=2), nn.ReLU(), nn.BatchNorm2d(512),

    nn.AdaptiveAvgPool2d(4),
    nn.Flatten(),
    nn.Dropout(0.4),
    nn.Linear(8192, 102),
)

trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
print(f"Trainable parameters: {trainable_params:,}")
output
Trainable parameters: 5,760,166
INPUT3@192x192 S1: f. maps32@94x94 S2: f. maps64@45x45 S3: f. maps128@21x21 C4: f. maps256@11x11 C5: f. maps512@6x6 P: f. maps512@4x4 OUTPUT102 conv x3 + BN conv x3 + BN conv x3 + BN stride-2 conv stride-2 conv adaptive avg pool flatten 8,192

Same picture, redrawn. No single layer dominates: stages 1–3 hold 14.3% of the parameters, the 512-channel convolution 56.9%, the classifier 14.5%. The largest block is now a convolution that actually learns features.

Five changes, and what each one is for

  • AdaptiveAvgPool2d(4) — averages whatever grid arrives down to 4×4. The flatten is always 512 × 4 × 4 = 8,192, whatever the input size. One head, 835K parameters instead of 51M, and you never compute that number by hand again.
  • BatchNorm2d after every convolution — rescales each channel to zero mean and unit variance across the batch, which keeps activations from drifting as depth grows. Standard practice for a stack this deep; I did not run V2 without it.
  • stride=2 convolutions replace MaxPool2d — the network learns how to downsample instead of always keeping the maximum.
  • two 3×3 convolutions before each downsample — more depth per stage, so features compose before the picture shrinks.
  • Dropout(0.4) between stages, not only in the classifier — regularisation applied where the features are, not just at the end.

All five changes shipped together, so this comparison does not tell you which one earned the thirteen points. Treat the table as the outcome of a rewrite, not as evidence for any single line in it.

Note the 3×3 convolutions have no padding, so each one shaves two pixels off every side. Follow 192 through the stack: 192 → 190 → 188 → 94, then 92 → 90 → 45, then 43 → 41 → 21, then 11, then 6. AdaptiveAvgPool2d takes that 6×6 to 4×4 and the arithmetic stops mattering.

V1 V2
parameters 52,581,126 5,760,166
largest single layer 51.4M (97.7%) 0.84M (14.5%)
conv layers 4 11
best val accuracy 61.81% 74.80%

THE REBUILT MODEL, MEASURED

After the Rebuild

Two accuracy numbers appear from here on. Top-1 is the ordinary one: the highest-scoring class is the right one. Top-5 asks whether the right class is anywhere in the model's five best guesses — a useful second reading, because a model that is close but not first is a different problem from one that is lost.

The loss curve

Training loss falls from 3.741 to 0.188 and keeps falling. Validation loss flattens around epoch 20 near 1.1. When one keeps dropping and the other doesn't, you're past the useful part of training.

The accuracy curve

Validation top-1 climbs from 9.87% to a best of 74.80% at epoch 28 and top-5 reaches 91.52%. The shaded band is the gap between train and validation — the thing to watch.

What the epochs actually say

Epoch Train acc Val top-1 Val top-5 Gap Classes predicted
1 11.64% 9.87% 28.22% +1.8 57 / 102
3 35.78% 31.16% 62.23% +4.6 98 / 102
7 60.00% 50.65% 78.30% +9.4 102 / 102
15 83.91% 63.95% 87.19% +20.0 102 / 102
20 91.48% 70.15% 90.70% +21.3 102 / 102
28 96.72% 74.80% 91.52% +21.9 102 / 102
30 96.88% 71.62% 91.27% +25.3 102 / 102

Epoch 1 — collapse. The model predicted only 57 of 102 classes, and 28% of every prediction it made was "rose". The train/validation gap was +1.8 points, which reads as underfitting: the model was the ceiling, not the data.

Epochs 2 to 7 — the collapse resolves. Distinct classes predicted goes 57 → 86 → 98 → 102. Once the network stops hedging on a few common flowers, accuracy triples.

Epochs 9 to 15 — the crossover. The gap passes 10 points at epoch 9 and 20 points by epoch 15. Training accuracy is now pulling away. The model has stopped underfitting and started memorising.

Epochs 20 to 30 — diminishing returns. Measured to epoch 30, training gains 5.4 points and validation gains 1.5, and the gap widens to 25.3. The best validation accuracy — 74.80% — lands at epoch 28, and it is 3.2 points above where the run finishes. Your best model is not your last one, which is why the loop checkpoints every epoch.

V1 against V2, epoch for epoch

V2 passes V1's final 60.02% at epoch 15 and keeps climbing for another thirteen epochs. Nine times fewer parameters, thirteen points better.

What thirty epochs costs on a T4

Both runs are Kaggle notebooks on a single T4. The V2 log gives per-epoch wall-clock times, and they're remarkably flat

V2 on one T4 Time
setup — download, dataset, model 44 s
per epoch, mean 125.8 s (min 124.5, max 127.8)
  — train + validate 117.3 s
  — per-epoch diagnostics 8.5 s
30 epochs 62.9 min
notebook wall clock 63.9 min

Which flowers it actually learned

Press play to run the leaderboard through all thirty epochs. By epoch 30 ten classes are perfect; hover any row for its top-5 score and how many images it had to learn from — wallflower gets all 29 of its validation images right, off 138 training images.

Sixty-one different classes pass through the top fifteen over thirty epochs, with an average of eight swapping in or out each epoch. Model training is fairly stable until about epoch 20.

Class size explains some of it. Split the 102 classes into quartiles by training-set size: the smallest quartile, 28 to 35 images, averages 60.6%; the largest, 65 to 180 images, averages 77.8%. A seventeen-point spread is real. But the correlation across all classes is only +0.218 — weak. If imbalance were the bottleneck you would expect something north of +0.4. So more data per class would help, and it would not be the fix.

Similarity explains the rest. The confusion pairs at epoch 30 are not random: primula read as wallflower six times, bougainvillea as petunia four, sunflower as barbeton daisy three, colt's foot as marigold three. Those are yellow-centred composites and open trumpets — flowers that genuinely resemble each other at 192 pixels. The dataset's own authors note it contains several very similar categories. That is the part a bigger model, not a bigger dataset, has to solve.

What to check before you start a thirty-epoch run

Inspection

print(model) shows layer names, types, and settings — good for spotting structural mistakes. It won't tell you how big the model is or where the weight sits.

inspect.py
# does one image survive the whole stack?
img, label = next(iter(train_dataset))
print(model(img.unsqueeze(0)).shape)          # torch.Size([1, 102])

# how big is it, and where does the weight live?
total = sum(p.numel() for p in model.parameters())
print(f'total parameters: {total:,}')

for name, p in model.named_parameters():
    print(f'{name:24} {tuple(p.shape)}  {p.numel():,}')

Three things worth knowing about that output. model.parameters() returns a generator, so nothing loads into memory until you iterate. numel() gives the element count per tensor. And a linear layer's shape reads outputs first — (102, 8192), one row per output neuron.

For nested models, children() shows only the top level while modules() walks the whole tree. Folders versus everything inside them.

Why you can print a tensor in the middle of a model at all

Dynamic Graphs

When you chain layers together, you are really writing one big equation. Conv2d is thousands of multiplications and additions. ReLU zeroes out the negative values. Written out one step at a time — multiply, add, zero, multiply, add the bias — that step-by-step breakdown is called a computation graph.

The framework records every operation for a reason. To train the model, it has to walk backwards through the graph and use the chain rule to work out how to adjust each parameter.

Traditional Libraries

Older frameworks asked you to define the entire graph up front, before any data moved through it:

  • You wrote out every operation and every connection first.
  • Once defined, the structure was locked.
  • The upside: a framework that knows the full graph ahead of time can optimise it for speed and memory.
  • The downside: no flexibility. You are describing a fixed equation, so there are no loops, no conditionals, and no way to print a value from the middle.

The PyTorch way: build the graph as the code runs

PyTorch builds the graph while your code runs. You can put branching logic straight inside the forward method, and the if statement does not just choose the logic — it shapes the graph itself.

branching_forward.py
def forward(self, x):
    x = self.features(x)

    # the branch taken decides the graph that gets built
    if x.mean() > self.threshold:
        x = self.detail_head(x)      # tricky sample, heavier path
    else:
        x = self.simple_head(x)      # easy sample, cheaper path

    return x

Here is what happens each time forward runs:

  • PyTorch records exactly what happened — every multiplication, addition, layer, and branch.
  • That graph is used for backpropagation, and the parameters get updated.
  • Then the graph is thrown away.
  • The next batch builds a brand new graph, even if it takes a different path.

What this makes possible

  • Variable input shapes. A three-word sentence and a fifty-word sentence can go through the same model.
  • Easy debugging. No special debug mode — it is just Python, so you add a print.
  • Input-adaptive models. A cheaper path for easy inputs and a heavier path for hard ones.

There is a small performance cost to building the graph each time, but for most work the flexibility is worth it. Static frameworks make you think like a compiler; PyTorch lets you think like a Python programmer.

Where nn.Sequential fits

nn.Sequential gives that flexibility back up. It is a fixed, straight-line path: no conditionals, no loops, and no returning two outputs. Both models in this post are Sequential, and that is the right choice while the network is a simple pipeline.

When you need branching, you move to nn.Module: __init__ holds the layers, and forward holds the flow.

This post is inspired by the PyTorch Fundamentals course on DeepLearning.AI, taught by Laurence Moroney. The explanations follow the course material; the two architectures, both training runs, and every number and chart are from my own notebooks — two Kaggle sessions on one T4.

Nilsback & Zisserman (2008), 102 Category Flower Dataset · Hubel & Wiesel (1962), receptive fields in the cat visual cortex · Srivastava et al. (2014), Dropout · Ribeiro et al. (2016), "Why Should I Trust You?"