This is the moment the whole module has been building toward. You know what a perceptron is and why layers get stacked (02-01), which activation goes where (02-02), how the network learns with forward and backpropagation (02-03), and which loss and optimizer to choose (02-04). Today we assemble it all into a project from start to finish: building, training and evaluating in Keras a dense network that recognizes handwritten digits using MNIST, the discipline's classic dataset. We follow the TecnoMarket methodology introduced in module 1 — prototype on public datasets → application to fictional TecnoMarket data: before attempting to classify product photos, the team validates the complete workflow on a standard test bench. That workflow (load → explore → preprocess → define → train → evaluate → predict → save) is identical in any real project, and from today on it's yours.
Contents
- The project and the dataset: why MNIST
- Loading and exploring the data
- Preprocessing: normalization and splits
- Defining the model (justifying every choice)
- Training and reading the loss/accuracy curves
- Evaluating on test and making individual predictions
- Saving the model in
tecnomarket-dl/ - The prototype's limits: why real photos will demand another architecture
The project and the dataset: why MNIST
MNIST contains 70,000 grayscale images of 28×28 pixels with handwritten digits (0–9), already split into 60,000 for training and 10,000 for test. It's the "hello world" of deep learning for three practical reasons: it downloads with one line, trains in minutes without a GPU, and is hard enough for the concepts to matter yet easy enough for everything to work on the first try.
The TecnoMarket fit: the vision team's end goal is to classify product photos into categories (laptop, coffee maker, vacuum cleaner…). MNIST is their test bench: same type of problem (multiclass image classification), manageable size, known outcome. If the workflow works here, migrating to the real photos will be a matter of architecture (module 3) and data, not methodology.
Work where you prepared the environment in module 1: a notebook in tecnomarket-dl/notebooks/ (or Colab, if you chose that route).
Loading and exploring the data
Never train on data you haven't looked at. It's the first rule of any project.
import numpy as np
import matplotlib.pyplot as plt
from tensorflow import keras
(X_train, y_train), (X_test, y_test) = keras.datasets.mnist.load_data()
print("Train:", X_train.shape, y_train.shape) # (60000, 28, 28) (60000,)
print("Test: ", X_test.shape, y_test.shape) # (10000, 28, 28) (10000,)
print("Type:", X_train.dtype, "| Range:", X_train.min(), "-", X_train.max())
print("Classes:", np.unique(y_train)) # [0 1 2 ... 9]
print("Examples per class:", np.bincount(y_train))Train: (60000, 28, 28) (60000,)
Test: (10000, 28, 28) (10000,)
Type: uint8 | Range: 0 - 255
Classes: [0 1 2 3 4 5 6 7 8 9]
Examples per class: [5923 6742 5958 6131 5842 5421 5918 6265 5851 5949]Reading the exploration: each image is a 28×28 matrix of integers 0–255 (0 = black, 255 = white), the labels are integers 0–9, and the ten classes are reasonably balanced (between 5,400 and 6,700 examples each) — important, because a class with very few examples would be learned poorly. Let's look at some images with their labels:
fig, axes = plt.subplots(2, 5, figsize=(10, 4))
for i, ax in enumerate(axes.flat):
ax.imshow(X_train[i], cmap="gray")
ax.set_title(f"label: {y_train[i]}")
ax.axis("off")
plt.tight_layout(); plt.show()Spend a minute looking at them: you'll see sevens with and without a crossbar, slanted ones, nines that almost look like fours. That variability is exactly what the network will have to absorb.
Preprocessing: normalization and splits
Three transformations separate raw data from ready data:
1. Flatten. Our dense network expects one vector per example, not a matrix: we convert each 28×28 image into a vector of 784 values. (Yes, this destroys the spatial structure; we'll return to that wound in section 8.)
2. Normalize. We scale the pixels from 0–255 to 0–1. Why? Because of what we learned in 02-03: gradients depend on the magnitude of the inputs, and with inputs up to 255 the weighted sums would be enormous, would saturate activations, and would force minuscule learning rates. Inputs around [0, 1] keep the training in the comfortable zone.
3. Set aside validation. From the 01-04 vocabulary: train to adjust weights, validation to monitor progress on data the network doesn't use for learning, test for the final exam, one single time. MNIST already comes with the test split; we now reserve 10% of the train set as validation.
# 1. Flatten: (60000, 28, 28) -> (60000, 784)
X_train_prep = X_train.reshape(-1, 784).astype("float32")
X_test_prep = X_test.reshape(-1, 784).astype("float32")
# 2. Normalize to [0, 1]
X_train_prep /= 255.0
X_test_prep /= 255.0
# 3. Validation split: last 6000 examples of the train set
X_val, y_val = X_train_prep[54000:], y_train[54000:]
X_train_prep, y_train_prep = X_train_prep[:54000], y_train[:54000]
print(X_train_prep.shape, X_val.shape, X_test_prep.shape)
# (54000, 784) (6000, 784) (10000, 784)We leave the labels as integers 0–9: in 02-04 we saw that sparse_categorical_crossentropy accepts them directly, without one-hot conversion.
Defining the model (justifying every choice)
We build a dense 784 → 128 → 64 → 10 network. Each decision comes out of a lesson in this module:
| Decision | Value | Justification (lesson) |
|---|---|---|
| Layer type | Dense (fully connected) |
It's the MLP from 02-01, Keras edition |
| Hidden activation | ReLU | Default for hidden layers; avoids the sigmoids' vanishing gradient (02-02, 02-03) |
| Output neurons | 10, softmax | Mutually exclusive multiclass with 10 classes (02-02) |
| Loss | sparse_categorical_crossentropy |
Softmax's partner; integer labels (02-04) |
| Optimizer | Adam | Robust starting point (02-04) |
| Hidden sizes (128, 64) | Hyperparameter | A reasonable starting choice; a progressive funnel toward the 10 classes |
model = keras.Sequential([
keras.layers.Input(shape=(784,)), # input: the flattened vector
keras.layers.Dense(128, activation="relu"), # hidden 1
keras.layers.Dense(64, activation="relu"), # hidden 2
keras.layers.Dense(10, activation="softmax"), # output: 10 probabilities summing to 1
])
model.compile(
optimizer="adam",
loss="sparse_categorical_crossentropy",
metrics=["accuracy"],
)
model.summary()Layer (type) Output Shape Param #
dense (Dense) (None, 128) 100480
dense_1 (Dense) (None, 64) 8256
dense_2 (Dense) (None, 10) 650
Total params: 109,386Do the check you already did with the 29-parameter 3-4-2-1 network in 01-04, now at scale: $784 \times 128 + 128 = 100480$; $128 \times 64 + 64 = 8256$; $64 \times 10 + 10 = 650$. A total of 109,386 parameters — weights and biases that backpropagation will adjust. The same counting as always, three orders of magnitude up.
Training and reading the loss/accuracy curves
history = model.fit(
X_train_prep, y_train_prep,
epochs=15, # 15 complete passes over the train set
batch_size=64, # mini-batch (02-04)
validation_data=(X_val, y_val), # monitors data it doesn't use for learning
verbose=2,
)Epoch 1/15 - loss: 0.2949 - accuracy: 0.9152 - val_loss: 0.1493 - val_accuracy: 0.9558
Epoch 5/15 - loss: 0.0522 - accuracy: 0.9836 - val_loss: 0.0857 - val_accuracy: 0.9743
Epoch 10/15 - loss: 0.0219 - accuracy: 0.9929 - val_loss: 0.0870 - val_accuracy: 0.9770
Epoch 15/15 - loss: 0.0117 - accuracy: 0.9962 - val_loss: 0.1010 - val_accuracy: 0.9772Each epoch is $54000 / 64 \approx 844$ mini-batch updates of the 109,386 parameters, each with its forward pass, its backward pass and its Adam step: all of 02-03 and 02-04 spinning inside. The history object stores the per-epoch metrics; plotting them is mandatory:
h = history.history
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
ax1.plot(h["loss"], label="train"); ax1.plot(h["val_loss"], label="validation")
ax1.set_title("Loss"); ax1.set_xlabel("epoch"); ax1.legend(); ax1.grid(alpha=0.3)
ax2.plot(h["accuracy"], label="train"); ax2.plot(h["val_accuracy"], label="validation")
ax2.set_title("Accuracy"); ax2.set_xlabel("epoch"); ax2.legend(); ax2.grid(alpha=0.3)
plt.show()How to read the curves:
- Both losses drop together at first: the network is learning general patterns. Good.
- Around epoch 5–7 they separate: the train loss keeps falling (down to 0.01) but the validation loss stalls and even rises slightly (0.086 → 0.101). The network is starting to memorize quirks of the train set that don't generalize: this is overfitting. At this magnitude it's mild (val_accuracy holds at ≈ 97.7%), but the signal is unmistakable: training for more epochs no longer improves anything useful.
- Practical rule: the curve that matters is the validation one. The techniques to fight overfitting (regularization, dropout, early stopping) have their own lesson in module 5; for now, it's enough to know how to detect it and not to overtrain.
Evaluating on test and making individual predictions
The test set is touched one single time, at the end, to get the honest figure you would report to the TecnoMarket team:
test_loss, test_acc = model.evaluate(X_test_prep, y_test, verbose=0)
print(f"Test accuracy: {test_acc:.4f}") # -> ~0.97797.7% with a simple dense network: about 230 images missed out of 10,000. Now, individual predictions — which is how the model would be used in production:
probs = model.predict(X_test_prep[:5], verbose=0) # shape (5, 10)
print(np.round(probs[0], 3))
# [0. 0. 0. 0. 0. 0. 0. 0.999 0. 0.001] <- softmax: almost everything on class 7
print("Prediction:", probs.argmax(axis=1)) # [7 2 1 0 4]
print("Truth: ", y_test[:5]) # [7 2 1 0 4]predict returns the softmax's 10 probabilities per image; argmax picks the most likely class. Even more instructive is looking at the errors:
probs_all = model.predict(X_test_prep, verbose=0)
pred = probs_all.argmax(axis=1)
errors = np.where(pred != y_test)[0]
print(f"{len(errors)} errors out of {len(y_test)}")
fig, axes = plt.subplots(1, 5, figsize=(12, 3))
for ax, idx in zip(axes, errors[:5]):
ax.imshow(X_test[idx], cmap="gray")
ax.set_title(f"true {y_test[idx]} / pred {pred[idx]}")
ax.axis("off")
plt.show()You'll see that many misses are genuinely ambiguous digits (fours that look like nines, twisted sevens) where even a human would hesitate. Analyzing specific errors — not just the global metric — is a professional habit that will reappear in every project in module 7.
Saving the model in tecnomarket-dl/
A model that lives only in the notebook's memory is lost when the session closes. We save the 109,386 weights, the architecture and the compile configuration in a single file, inside the folder structure we created in 01-05:
model.save("tecnomarket-dl/models/mnist_dense_v1.keras")
# Check: reload and verify it predicts the same
loaded_model = keras.models.load_model("tecnomarket-dl/models/mnist_dense_v1.keras")
print(loaded_model.predict(X_test_prep[:1], verbose=0).argmax()) # -> 7, as beforeThe .keras format is Keras's modern native one, and a single load_model is enough to recover the entire model, ready to predict or keep training. This is all you need for now: serious versioning, alternative formats and the real deployment of models (serving them in production for TecnoMarket's website) have their own lesson in module 6.
The prototype's limits: why real photos will demand another architecture
The prototype is validated; can the team dive straight into classifying TecnoMarket's product photos with this same network? No, and understanding why is the best transition to the next module:
- Flattening destroys the spatial structure. By converting 28×28 into 784 values, the network doesn't know that pixel 30 sits directly below pixel 2. It learns by statistical brute force, which is viable with centered 28×28 digits, but not with 224×224 photos where the coffee maker can appear in any corner.
- No position invariance. If you shift a digit three pixels to the right, to the dense network it's a completely different vector — every pixel lands on a different input with different weights. Real product photos vary in position, scale, angle and lighting.
- Parameter explosion. With 224×224 color photos (150,528 inputs), the first 128-neuron dense layer alone would have 19 million parameters. Unfeasible and inefficient.
The solution is convolutional neural networks (CNNs): layers that look at the image in patches, share weights and respect the spatial structure. They are exactly the subject of module 3.
Common Mistakes and Tips
- Forgetting the normalization. With 0–255 pixels the network can still train, but worse and more slowly (typical accuracy several points lower). If your results are suspiciously bad, check the input range before anything else:
X.min(), X.max(). - Normalizing train and test with different criteria. Every transformation is defined on the train set and applied identically to the test set. Here it's trivial (dividing by 255), but the principle will save you from serious mistakes with tabular data.
- Evaluating on test many times while tuning. If you try ten architectures while watching the test set and keep the best one, the test has stopped being an honest exam: you've fitted to it. Validation is for iterating; the test is the final grade.
- Looking only at the train accuracy. 99.6% on train with 97.7% on validation is overfitting, not success. The figure that counts is always the validation/test one.
- Forgetting to flatten/preprocess at prediction time. The model expects exactly the format it was trained with: a vector of 784 floats in [0, 1], with shape
(n, 784)(even for a single image:(1, 784), not(784,)!). - Extra epochs "just in case". When the validation curve has been flat or rising for several epochs, further training only increases the overfitting. Stop and keep the good zone.
Exercises
- Fashion-MNIST, same workflow. Repeat the complete project with
keras.datasets.fashion_mnist(same shapes: 28×28, 10 classes, but clothing items: t-shirts, sneakers, bags — a test bench even closer to TecnoMarket's products). What test accuracy do you get? Why do you think it's lower than on MNIST? - Size matters (or does it?). On MNIST, train three variants: (a) no hidden layers (784 → 10 softmax, a multiclass logistic regression), (b) one hidden layer of 32, (c) the network from the text (128 + 64). Compare validation accuracy and parameter count of each in a small table. Is each size jump worth it?
- Confidence report. Write a function
predict_with_confidence(model, image)that returns the predicted class, its probability, and the second most likely class with its probability; test it on 3 correctly classified images and 3 misclassified ones from the test set. What do you observe about the confidence on the misses?
Solutions
Exercise 1. The code is identical, changing the loading line to keras.datasets.fashion_mnist.load_data(). The typical test accuracy hovers around 88–90%, versus ~98% on MNIST. The reason: the clothing classes look much more alike (t-shirt vs. shirt vs. coat share a silhouette) than digits do, and fine texture/shape matters more — a difficulty that foreshadows real product photos and motivates module 3's CNNs even further.
Exercise 2. Typical results (yours will vary somewhat due to the randomness of initialization):
| Variant | Parameters | Val accuracy |
|---|---|---|
| (a) 784 → 10 | 7,850 | ~92.5% |
| (b) 784 → 32 → 10 | 25,450 | ~96.5% |
| (c) 784 → 128 → 64 → 10 | 109,386 | ~97.7% |
The first jump (adding a hidden layer) contributes the most: it goes from a linear model to a non-linear one, +4 points. The second jump quadruples the parameters to gain ~1 point: diminishing returns. Lesson: bigger helps, but less and less, and the qualitative leap comes from non-linearity (as you've known since 02-02).
Exercise 3.
def predict_with_confidence(model, image):
probs = model.predict(image.reshape(1, 784), verbose=0)[0]
ranking = probs.argsort()[::-1] # classes from highest to lowest probability
return {"prediction": int(ranking[0]), "confidence": float(probs[ranking[0]]),
"second": int(ranking[1]), "second_conf": float(probs[ranking[1]])}On correctly classified images the confidence usually exceeds 0.99 and the second option stays below 0.01. On misses it's common to see lower confidences (0.5–0.9) with a strong second option that often is the correct class (true 4, pred 9 at 0.55, second 4 at 0.42). The moral for TecnoMarket: the softmax's probabilities enable "human review if confidence is low" policies, just like the fraud detector's adjustable threshold.
Conclusion
You've just completed your first deep learning project from start to finish: you explored MNIST, normalized and partitioned it, defined a dense 784-128-64-10 network justifying every piece with the module's lessons (ReLU in hidden layers, softmax + categorical cross-entropy at the output, Adam as optimizer, mini-batches of 64), trained while watching the curves and detecting the onset of overfitting, obtained an honest ~97.7% on test, examined individual errors, and saved the versioned model in tecnomarket-dl/models/. The workflow you followed is the same one you'll use in every remaining project in the course; only the data and the architectures will change.
And architectures are precisely what the next step is about. We've seen that the dense network treats the image as a flat list of pixels: it barely managed with 28×28 digits and would struggle much more with Fashion-MNIST's clothing — and it would be unfeasible with TecnoMarket's real product photos. In module 3 you'll meet convolutional neural networks (CNNs), the architecture that understands images as what they are: spatial structures where position, edges and shapes matter.
Deep Learning Course
Module 1: Introduction to Deep Learning
- What is Deep Learning?
- History and evolution of Deep Learning
- Applications of Deep Learning
- Basic concepts of neural networks
- Setting up the work environment
Module 2: Neural Network Fundamentals
- Perceptron and Multilayer Perceptron
- Activation functions
- Forward and backward propagation
- Optimization and loss functions
- Your first complete neural network
Module 3: Convolutional Neural Networks (CNN)
- Introduction to CNNs
- Convolutional and pooling layers
- Popular CNN architectures
- CNN applications in image recognition
Module 4: Recurrent Neural Networks (RNN)
- Introduction to RNNs
- LSTM and GRU
- RNN applications in natural language processing
- Sequences and time series
Module 5: Advanced Deep Learning Techniques
- Generative Adversarial Networks (GAN)
- Autoencoders
- Transfer Learning
- Regularization and improvement techniques
- Attention mechanisms and Transformers
Module 6: Tools and Frameworks
- Introduction to TensorFlow
- Introduction to PyTorch
- Framework comparison
- Development environments and additional resources
- Saving, loading and deploying models
Module 7: Hands-On Projects
- Image classification with CNNs
- Text generation with RNNs
- Anomaly detection with Autoencoders
- Building a GAN for image generation
- Fine-tuning a pretrained model
