CNN in Practice: Flattening, Fully-Connected Layers, Softmax & Implementation
How a stack of feature maps becomes a class prediction — and how everything since Lecture 4 (dense layers, cross-entropy, backprop, Adam) reassembles inside a real, runnable Keras CNN.
Lecture 13 leaves us with a stack of feature maps — a 3-D block of numbers. That's not a classification yet. This lecture closes the loop: turning those feature maps into an actual predicted class, using nothing new mathematically (it's Lecture 4's dense layers and Lecture 6's cross-entropy again) — the point of this lecture is seeing that a "CNN" is really just convolution feeding into everything we already know.
- Explain why a nonlinearity is applied after every convolution, and that it doesn't change the feature map's shape.
- Describe flattening as a pure reshape with no learned parameters.
- Derive the softmax + categorical cross-entropy gradient and connect it explicitly to Lecture 8's sigmoid+BCE shortcut.
- Write the batch normalization equations and explain what γ and β are for.
- Explain how residual connections F(X)+X keep gradients flowing in very deep networks.
- Build, compile and train a complete Keras CNN for MNIST digit classification.
1. Nonlinearity After Convolution
Convolution itself — a sum of elementwise products — is a strictly linear operator. Stack linear operations on top of linear operations and the whole network collapses mathematically into a single linear map, no matter how many layers deep (exactly the argument from Lecture 4/5 for dense networks). So, exactly as with a dense layer, every convolution is followed by an elementwise nonlinearity — in practice almost always ReLU.
Input \(32\times32\times3\), filter \(K=3,S=1,P=1\) (Lecture 13's "same" padding rule):
$$\underbrace{w^\top x_{ij}+b}_{\text{linear response, }32\times32} \;\xrightarrow{\ \text{ReLU}\ }\; \underbrace{\max\big(0,\ w^\top x_{ij}+b\big)}_{\text{still }32\times32}$$ReLU is applied independently to every one of the 32×32 spatial positions (and every channel) — the feature map's shape never changes; only individual values that were negative get clipped to zero.
Lecture 5's dying-ReLU failure mode carries over unchanged: if a filter's pre-activation is negative across every training image at a given spatial position, that unit's gradient is permanently zero and it stops learning. The fix is the same — Leaky ReLU, careful initialization, or a smaller learning rate — just applied per-filter instead of per-neuron.
2. Flattening
After the last convolution+pooling block, the network holds a 3D feature map of shape \(H''\times W''\times D_K\) — height, width, and channel depth. To feed this into the dense classifier layers built in Lecture 4, it is flattened: reshaped into a single 1D vector of length \(H''\times W''\times D_K\).
Flattening is literally a reshape — no weights, no biases, nothing is learned or computed beyond rearranging existing numbers into a new shape. Every parameter in a CNN lives either in a convolutional filter or in a fully-connected weight matrix; flatten is pure bookkeeping between them.
3. Fully Connected Layers, Softmax & Cross-Entropy
The flattened feature vector passes through one or more dense layers — exactly the feedforward math of Lecture 4 and the backprop machinery of Lecture 8, unchanged. For multi-class classification, the final layer is a softmax, which turns a vector of raw scores (logits) \(z\) into a valid probability distribution over \(C\) classes:
$$\text{softmax}(z)_i=\frac{e^{z_i}}{\sum_{j=1}^{C} e^{z_j}}$$paired with categorical cross-entropy loss against a one-hot target \(y\) (the direct multi-class generalization of Lecture 6's binary cross-entropy, derived there from maximum likelihood):
$$L = -\sum_{i=1}^{C} y_i\log(\hat y_i), \qquad \hat y=\text{softmax}(z)$$Lecture 8 showed that a sigmoid output paired with binary cross-entropy collapses to the remarkably clean gradient \(\partial L/\partial Z_2 = Q-y\). The same thing happens here — but that's a claim, not yet a fact, until it's actually derived rather than asserted by analogy. It's worth doing in full, because it is one of the most-used shortcuts in all of deep learning.
Start from the two pieces already on this page: the loss and the softmax function itself.
$$L=-\sum_i y_i\log(\hat y_i) \qquad \hat y_i=\text{softmax}(z)_i=\frac{e^{z_i}}{\sum_k e^{z_k}}$$
Differentiating the softmax formula for \(\hat y_i\) with respect to an arbitrary logit \(z_j\) (quotient rule, splitting into the \(i=j\) and \(i\ne j\) cases) collapses to one compact identity:
$$\frac{\partial \hat y_i}{\partial z_j}=\hat y_i(\delta_{ij}-\hat y_j), \qquad \delta_{ij}=\begin{cases}1 & i=j\\0 & i\ne j\end{cases}$$
In words: nudging logit \(z_j\) pushes \(\hat y_j\) itself up (the \(\delta_{ij}\) term, only active when \(i=j\)) while simultaneously pulling every other \(\hat y_i\) down in proportion to \(\hat y_i\hat y_j\) — because all \(C\) probabilities must keep summing to exactly 1.
Substitute Step 2's identity into \(\partial L/\partial z_j=-\sum_i y_i\frac1{\hat y_i}\cdot\frac{\partial \hat y_i}{\partial z_j}\):
$$\frac{\partial L}{\partial z_j}=-\sum_i y_i\frac{1}{\hat y_i}\cdot\hat y_i(\delta_{ij}-\hat y_j)=-\sum_i y_i(\delta_{ij}-\hat y_j)$$
The \(1/\hat y_i\) and \(\hat y_i\) cancel inside the sum — the same kind of cancellation that produced Lecture 8's \(Q-y\). Distributing the remaining sum over its two terms, and noting the first collapses to just \(-y_j\) because \(\delta_{ij}\) is 1 only when \(i=j\) (every other term vanishes):
$$-\sum_i y_i(\delta_{ij}-\hat y_j) = -\sum_i y_i\delta_{ij} + \hat y_j\sum_i y_i = -y_j+\hat y_j\sum_i y_i$$
\(y\) is a one-hot vector — exactly one entry is 1 (the true class), the rest are 0 — so \(\sum_i y_i=1\) always, regardless of which class is true. Substituting:
$$\frac{\partial L}{\partial z_j}=-y_j+\hat y_j(1)=\mathbf{\hat y_j-y_j} \qquad\Longrightarrow\qquad \frac{\partial L}{\partial z}=\hat y-y$$
Exactly the multi-class analogue of Lecture 8's \(Q-y\): the gradient flowing out of the softmax+cross-entropy pair is, once again, simply "prediction minus target" — now one component per class instead of a single scalar.
You can replay the same four steps interactively below — useful for testing yourself before moving on:
Lecture 8 (binary, sigmoid+BCE): \(\dfrac{\partial L}{\partial Z_2}=Q-y\) (a scalar). Lecture 14 (multi-class, softmax+CCE): \(\dfrac{\partial L}{\partial z}=\hat y - y\) (a length-\(C\) vector). They are literally the same shortcut — "predicted minus target" — generalized from one output unit to \(C\) of them. This is precisely why softmax and cross-entropy are always paired in classifiers: any other loss/activation pairing does not simplify this cleanly.
A small worked example makes this concrete. Suppose a 3-class classifier produces logits \(z=[2.0,\ 1.0,\ 0.1]\) and the true class is class 0 (\(y=[1,0,0]\)):
$$e^{2.0}=7.389,\ \ e^{1.0}=2.718,\ \ e^{0.1}=1.105 \qquad \sum = 11.212$$ $$\hat y = [0.6590,\ 0.2424,\ 0.0986]$$ $$L=-\log(0.6590)\approx0.417$$ $$\frac{\partial L}{\partial z}=\hat y-y=[0.6590-1,\ 0.2424-0,\ 0.0986-0]=[-0.3410,\ 0.2424,\ 0.0986]$$
The negative first component says "increase logit 0" (push the correct class's score up); the positive remaining components say "decrease logits 1 and 2" (push the wrong classes' scores down) — exactly the direction that would reduce the loss on the next gradient-descent step (Lecture 7).
4. Batch Normalization
As networks get deep (recall ResNet's 152 layers, Lecture 12), the distribution of each layer's inputs keeps shifting as earlier layers' weights update during training — a problem often called internal covariate shift. Batch normalization re-centers and re-scales each layer's inputs using statistics computed over the current mini-batch:
$$\mu_B=\frac1{N_B}\sum_i x_i \qquad \sigma_B^2=\frac1{N_B}\sum_i(x_i-\mu_B)^2$$ $$\hat x_i=\frac{x_i-\mu_B}{\sqrt{\sigma_B^2+\epsilon}} \qquad y_i=\gamma\hat x_i+\beta$$Here \(N_B\) is the batch size and \(\epsilon\) a tiny constant preventing division by zero. \(\gamma\) and \(\beta\) are trainable scale and shift parameters, learned by backprop just like any weight — they exist so the network can, if it needs to, learn to undo the normalization (e.g. recovering the original scale) rather than being forced to keep every activation zero-mean, unit-variance.
A mini-batch of 4 pre-activation values: \(x=[2,\ 4,\ 4,\ 6]\).
$$\mu_B=\frac{2+4+4+6}{4}=4 \qquad \sigma_B^2=\frac{(-2)^2+0^2+0^2+2^2}{4}=\frac{8}{4}=2$$ $$\hat x = \left[\frac{-2}{\sqrt{2}},\ 0,\ 0,\ \frac{2}{\sqrt{2}}\right] \approx [-1.414,\ 0,\ 0,\ 1.414]$$With \(\gamma=1,\beta=0\) (their typical initialization), \(y=\hat x\) unchanged — training then adjusts \(\gamma,\beta\) as needed.
5. Residual (Skip) Connections
Lecture 12 named ResNet's 152-layer network the "beginning of the ultra-deep network era" and credited residual connections for making it trainable. The mechanism is simple to state: instead of a block of layers computing a new representation \(F(X)\) from scratch, its input \(X\) is added back to the block's output before the next activation, giving \(F(X)+X\). The input "bypasses" the block via a shortcut path.
During backpropagation (Lecture 8's chain rule), the gradient flowing backward through a residual block has two paths: through \(F(X)\) (which can shrink, as ordinary chained derivatives do) and straight through the identity shortcut (whose local gradient is exactly 1, unimpeded). Even if the \(F(X)\) path's gradient nearly vanishes, the shortcut path still delivers a usable gradient signal all the way back — which is precisely why 152-layer networks became trainable when plain (non-residual) stacks of that depth were not.
6. Building the Network in Keras: A Walkthrough
Putting Sections 1–3 together for a concrete task — MNIST digit classification, 10 classes, 28×28 grayscale input — gives the instructor's own layer choices: two convolution+pool blocks, then flatten, then two dense layers ending in softmax. Applying Lecture 13's output-size formula layer by layer (no padding specified, so Keras defaults to padding='valid', i.e. \(P=0\)) traces exactly how the shape shrinks and the channel depth grows:
| Layer | Config | Output shape |
|---|---|---|
| Input | — | 28×28×1 |
| Conv2D | 32 filters, 5×5, stride 1, ReLU | 24×24×32 |
| MaxPooling2D | 2×2, stride 2 | 12×12×32 |
| Conv2D | 64 filters, 5×5, stride 1, ReLU | 8×8×64 |
| MaxPooling2D | 2×2, stride 2 | 4×4×64 |
| Flatten | — | 1024 |
| Dense | 1000 units, ReLU | 1000 |
| Dense | 10 units, softmax | 10 |
Check the first row with Lecture 13's formula: \((28-5)/1+1=24\), giving 24×24, times 32 filters (Section 4 of Lecture 13's "number of filters controls output depth" rule) → 24×24×32. Every other row follows the same formula.
The optimizer is Adam (Lecture 9), and because there are more than two classes the loss is (sparse) categorical cross-entropy (Section 3 above) rather than Lecture 8's binary cross-entropy.
The code in Section 8 is complete and correct — a student can run it as-is — but a full training run over many epochs on the full 60,000-image MNIST training set is slow on CPU. Use a GPU (local, or a hosted notebook such as Google Colab) for a realistic training time; see the external resource link for a full, GPU-ready tutorial notebook.
7. Common Pitfalls
- Forgetting
Flatten()entirely — passing a 3D feature map directly into aDenselayer raises a shape error; Keras dense layers expect a 1D (per-example) input. - Mismatching the loss function to the label encoding. Use
sparse_categorical_crossentropyfor integer class labels (0–9), orcategorical_crossentropyif labels are already one-hot — mixing them up silently produces wrong gradients, not an error. - Applying batch normalization's running statistics incorrectly at test time. During training, \(\mu_B,\sigma_B^2\) come from the current batch; at inference, frameworks automatically switch to a running average collected during training — forgetting this distinction when writing custom training loops silently degrades test-time accuracy.
- Assuming residual connections require matching shapes for free. \(F(X)+X\) only works directly if \(F(X)\) and \(X\) have the same shape; when a block changes channel depth or spatial size, the shortcut path itself needs a small (usually 1×1 conv) projection to match shapes before the addition.
8. Summary
- Convolution is linear, so ReLU (or another nonlinearity) always follows it, elementwise, leaving the feature map's shape unchanged.
- Flattening reshapes the final 3D feature map into a 1D vector with zero learned parameters, purely to feed the dense classifier head.
- Softmax + categorical cross-entropy generalizes Lecture 6's binary cross-entropy, and its gradient \(\hat y-y\) is the direct multi-class analogue of Lecture 8's \(Q-y\) shortcut.
- Batch normalization (\(\hat x=(x-\mu_B)/\sqrt{\sigma_B^2+\epsilon}\), \(y=\gamma\hat x+\beta\)) stabilizes training in deep networks, with \(\gamma,\beta\) learned so the network can undo the normalization if needed.
- Residual connections (\(F(X)+X\)) give backprop an unimpeded shortcut path, which is why 152-layer ResNets are trainable at all.
- A complete CNN — Conv→ReLU→Pool ×2, Flatten, Dense→ReLU, Dense→Softmax, Adam optimizer, cross-entropy loss — is a direct assembly of Lectures 4–9, 12 and 13.
9. Code: A Complete Keras CNN for MNIST
This is the instructor's own layer stack from Section 6, written as a complete, correct, runnable Keras script — including data loading, model definition, compilation with Adam (Lecture 9) and sparse categorical cross-entropy (Section 3), and a model.fit call. A full training run is heavy for a CPU; the comments note expected CPU vs. GPU training time so a student can decide how many epochs to actually run.
"""
Lecture 14 -- CNN in Practice: Flattening, FC Layers, Softmax & Implementation
A complete, runnable Keras CNN for MNIST digit classification (10 classes,
28x28x1 input), using the instructor's own layer choices:
Conv2D(32,5x5) -> MaxPool(2x2) -> Conv2D(64,5x5) -> MaxPool(2x2)
-> Flatten -> Dense(1000, relu) -> Dense(num_classes, softmax)
CPU training note: ~1-2 minutes/epoch on a typical laptop CPU for MNIST.
GPU (local or Colab) note: a few seconds/epoch -- prefer GPU for anything
beyond a quick correctness check. See resources.html for a link to the
official TensorFlow CNN tutorial (Colab-enabled) for a full training run.
"""
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense
from tensorflow.keras.datasets import mnist
from tensorflow.keras.utils import to_categorical # not used with sparse CE, kept for reference
# ---- data: MNIST handwritten digits ----
(x_train, y_train), (x_test, y_test) = mnist.load_data()
input_shape = (28, 28, 1)
num_classes = 10
x_train = x_train.reshape(-1, 28, 28, 1).astype("float32") / 255.0
x_test = x_test.reshape(-1, 28, 28, 1).astype("float32") / 255.0
# labels stay as plain integers 0-9 -- matches sparse_categorical_crossentropy below
# ---- model: instructor's layer stack ----
model = Sequential()
model.add(Conv2D(32, kernel_size=(5, 5), strides=(1, 1), activation='relu', input_shape=input_shape))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))
model.add(Conv2D(64, (5, 5), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
model.add(Dense(1000, activation='relu'))
model.add(Dense(num_classes, activation='softmax'))
model.summary()
# ---- compile: Adam optimizer (Lecture 9) + categorical cross-entropy (Section 3) ----
model.compile(optimizer='adam',
loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
# ---- train ----
# NOTE: run a small number of epochs / a data subset for a quick CPU check;
# see the box in Lecture 14 Section 6 for why a full run belongs on GPU.
model.fit(x_train, y_train,
batch_size=128,
epochs=5,
validation_split=0.1)
test_loss, test_acc = model.evaluate(x_test, y_test, verbose=0)
print(f"Test accuracy: {test_acc:.4f}")
⬇ Download lecture-14-cnn-keras.py More resources for this lecture →