ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Project 6 of 7 180 min

Mini project: a neural network in TensorFlow

Build the late-delivery classifier three ways in TensorFlow (raw variables with GradientTape, Keras model.fit, and a custom Keras loop), evaluate them honestly and ship a .keras file.

Words in this lessonKeras · GradientTape · Training loop · Early stopping · Feature scaling · Precision and recall
Keras
A high-level deep learning library, built into TensorFlow, for defining and training models with little code. Like a well-equipped kitchen with preset programmes: fast for standard recipes, with manual controls when you need them.
GradientTape
TensorFlow’s tool for recording calculations so it can compute gradients. Like pressing record on a camera only for the part of the recipe you want to replay backwards.
Training loop
The repeated cycle of predict, measure the loss, compute gradients and update parameters. Like a baker practising: bake a tray, taste, compare with the recipe, adjust, bake again.
Early stopping
Stop training when the validation score stops improving, and keep the best version. Like a coach who stops the drills the moment mock-exam scores stop rising.
Feature scaling
Rescaling input features so they are on comparable scales, usually mean 0 and standard deviation 1. Like converting every judge’s score to the same scale before adding them up.
Precision and recall
Precision: of the items flagged, how many were right. Recall: of the items that mattered, how many were flagged. Like a smoke alarm: precision is how often an alarm means real smoke, recall is how many real fires it catches.
On this page
  1. The brief
  2. What you will build
  3. Architecture
  4. Tech stack
  5. Build it, milestone by milestone
  6. 1. Generate, split and standardise the delivery data
  7. 2. Write the forward pass from scratch with tf.Variable
  8. 3. Train the scratch network with a GradientTape loop
  9. 4. Rebuild it with Keras layers and train with model.fit
  10. 5. Write a custom GradientTape loop for the Keras model
  11. 6. Evaluate all three on the test set
  12. 7. Save, reload and document the model
  13. Done means done
  14. Stretch goals
  15. Common pitfalls
  16. Show it off
  17. Go deeper

The call came from Crumb’s biggest delivery partner. Their dispatch platform already decides which rider takes which order, and their lead engineer, Sana, had heard about Arjun’s late-order model from a store manager. “If you can give us a model that flags risky orders, we’ll use it to assign them to our most reliable riders before they leave. One catch: our whole stack runs TensorFlow. We can host a Keras .keras file. We can’t host PyTorch.”

Arjun’s heart sank a little. He had just got comfortable with PyTorch.

Priya was delighted. “Perfect timing. Rebuild it in TensorFlow, from scratch. You’ll find out which ideas belong to deep learning and which ones were just PyTorch’s way of spelling them. The ideas transfer. The spelling doesn’t matter.”

Meera only had one question. “Will it be as good as the PyTorch one?”

“If it isn’t,” said Priya, “that tells us one of them has a bug.”

Important

What you will ship: a late-delivery classifier built three ways in TensorFlow (raw variables with a hand-written GradientTape loop, a Keras model trained with model.fit, and the same Keras model trained by a custom loop), evaluated honestly on held-out orders and saved as a .keras file the delivery partner can load.

The brief

Crumb’s delivery partner wants a model that takes five facts about an order (distance in km, number of items, hour of day, whether it is raining, and the assigned rider’s rating) and returns the probability that the order arrives late. They will load it in TensorFlow and call it on every new order.

You have already built this model in PyTorch in Module 27. This project is deliberately the same problem, so every difference you notice is a framework difference, not a problem difference. You will build it three times, each a little higher-level than the last, so you see exactly what Keras does for you and what it hides.

The data is the same kind of fake order data as before (the real logs contain customer addresses). About 19% of orders are late, so the rubber-stamp baseline, “always on time”, scores about 81% accuracy. Your model must beat it, and you must report recall, not just accuracy.

What you will build

Must have:

  • A data pipeline that generates, splits (train, validation, test) and standardises the orders using training statistics only.
  • A from-scratch network using only tf.Variable, matrix multiplication and ReLU, with a parameter count you can explain (241).
  • A hand-written training loop using tf.GradientTape that updates the variables yourself.
  • The same network rebuilt with Keras layers and trained with model.fit, with early stopping.
  • A custom GradientTape training loop for the Keras model, with metrics, matching model.fit’s results.
  • Test-set evaluation of all three versions: accuracy, precision, recall, confusion matrix, at thresholds 0.5 and 0.3.
  • A saved .keras file, reloaded in a fresh script to prove it works, and a README.

Nice to have:

  • A side-by-side comparison with your Module 27 PyTorch model on the same orders.
  • Training curves plotted from history.history.
  • A tiny prediction function the partner can copy, with the standardisation statistics baked in.

Architecture

flowchart LR
    A["Order generator (NumPy)"] --> B["Split and standardise"]
    B --> C["tf.data pipeline"]
    C --> D["Version 1: tf.Variable network + GradientTape"]
    C --> E["Version 2: Keras Sequential + model.fit"]
    C --> F["Version 3: Keras model + custom loop"]
    D --> G["Evaluation on test orders"]
    E --> G
    F --> G
    E --> H["late_delivery.keras + stats"]

Order generator. A NumPy port of Module 27’s make_deliveries, producing realistic orders from a hidden rule (distance, items, rain, evening rush hour and rider rating all push lateness up or down, plus luck).

Split and standardise. 4,000 orders for training, 1,000 for validation (decisions such as when to stop), 1,000 for the final test. Means and standard deviations come from the training orders only, and are saved, because the partner’s system must apply exactly the same numbers.

tf.data pipeline. TensorFlow’s equivalent of PyTorch’s DataLoader: it shuffles and batches.

Three versions. The same 5 → 16 → 8 → 1 network at three levels of abstraction. Version 1 shows the raw mechanics. Version 2 is what you would write at work on a normal day. Version 3 is what you write when fit is not flexible enough, as it will not be for the multi-head OCR model in Module 29.

Evaluation. One function, applied to all three, on orders none of them trained on.

Saved model. The Keras version, in the .keras format the partner asked for, plus the standardisation statistics.

Tech stack

Piece Tool Why
Language Python 3.12 with uv Same setup as the rest of the course
Framework TensorFlow 2 with Keras 3 What the partner runs; tf.GradientTape for low-level control
Data NumPy Generating and splitting arrays; TensorFlow accepts them directly
Input pipeline tf.data Shuffling and batching for the hand-written loops
Plots (optional) matplotlib Training curves
uv init late-delivery-tf && cd late-delivery-tf
uv add tensorflow numpy matplotlib

TensorFlow is a large download (several hundred MB). It runs on Linux, Windows and Apple Silicon Macs on the CPU, which is plenty for this project. Library APIs change quickly. If a name has moved, check the TensorFlow tutorials and the Keras guides linked from them.

A quick translation table to keep beside you. Every row is the same idea with different spelling:

Idea PyTorch (Module 27) TensorFlow
Array of numbers torch.Tensor tf.Tensor (NumPy arrays work too)
Learnable number tensor with requires_grad=True tf.Variable
Record operations for gradients always on for those tensors inside with tf.GradientTape() as tape:
Compute gradients loss.backward() then .grad tape.gradient(loss, variables)
Update parameters optimizer.step() optimizer.apply_gradients(...)
Model base class nn.Module tf.Module or keras.Model
Dense layer nn.Linear(in, out) layers.Dense(out) (input size inferred)
Training vs eval mode model.train() / model.eval() model(x, training=True) / training=False
Batching DataLoader tf.data.Dataset

Build it, milestone by milestone

1. Generate, split and standardise the delivery data

Why. Every later step depends on honest splits and on standardisation computed from training data only. Get this wrong and all three models look better than they are.

Save this as deliveries_np.py. It is the same rule as Module 27, written with NumPy’s random generator, so the individual orders differ from the PyTorch version but follow the same pattern.

# deliveries_np.py
import numpy as np

FEATURES = ["distance_km", "items", "hour", "raining", "partner_rating"]

def make_deliveries(n: int = 6000, seed: int = 42):
    """Fake Crumb delivery data (NumPy version). Returns X (n, 5) and y (n, 1), float32."""
    rng = np.random.default_rng(seed)
    distance = rng.uniform(1, 15, n)
    items = rng.integers(1, 13, n)
    hour = rng.integers(8, 23, n)
    raining = (rng.random(n) < 0.3).astype(float)
    rating = rng.uniform(3, 5, n)
    rush = ((hour >= 18) & (hour <= 21)).astype(float)
    logit = 0.3 * distance + 0.1 * items + 1.5 * raining + 1.5 * rush - 1.2 * (rating - 4) - 6
    late = rng.random(n) < 1 / (1 + np.exp(-logit))
    X = np.stack([distance, items, hour, raining, rating], axis=1).astype("float32")
    return X, late.astype("float32").reshape(-1, 1)

Then split, standardise and build the pipeline in data.py:

# data.py
import numpy as np
import tensorflow as tf
from deliveries_np import make_deliveries

X, y = make_deliveries(6000)
X_train, y_train = X[:4000], y[:4000]
X_val, y_val = X[4000:5000], y[4000:5000]
X_test, y_test = X[5000:], y[5000:]

mean, std = X_train.mean(axis=0), X_train.std(axis=0)        # training statistics only
X_train, X_val, X_test = [(a - mean) / std for a in (X_train, X_val, X_test)]

train_ds = tf.data.Dataset.from_tensor_slices((X_train, y_train)).shuffle(4000, seed=0).batch(64)
val_ds = tf.data.Dataset.from_tensor_slices((X_val, y_val)).batch(256)

if __name__ == "__main__":
    print(f"late rate: train {y_train.mean():.3f}  val {y_val.mean():.3f}  test {y_test.mean():.3f}")
    xb, yb = next(iter(train_ds))
    print(xb.shape, yb.shape, xb.dtype)

from_tensor_slices turns arrays into a dataset of individual examples; .shuffle(4000) shuffles within a buffer of 4,000 (the whole training set, so it is a full shuffle); .batch(64) groups them. Iterating gives batches as tf.Tensors.

Working looks like:

late rate: train 0.189  val 0.195  test 0.189
(64, 5) (64, 1) <dtype: 'float32'>

Most likely bug: labels with shape (n,) instead of (n, 1). Keras will sometimes broadcast (64,) against (64, 1) into a (64, 64) grid without complaint, and your loss quietly becomes nonsense. Keep labels as (n, 1) everywhere.

Stuck? Hint

If the late rates of the three splits differ wildly (say 0.05 and 0.35), you have probably sorted the data by label somewhere before splitting. The generator produces orders in random order, so slicing it is safe. With real data, shuffle once (with a fixed seed) before splitting, or split by date.

2. Write the forward pass from scratch with tf.Variable

Why. Before Keras hides everything, build the network out of its raw parts. If you can write this, you understand what layers.Dense is.

A tf.Variable is a tensor that can change, which is how TensorFlow marks learnable numbers. A tf.Module is a container that automatically finds every variable you attach to it, so net.trainable_variables lists them all.

# scratch.py
import numpy as np
import tensorflow as tf
from data import X_train

tf.random.set_seed(0)

def he_init(fan_in: int, fan_out: int) -> tf.Variable:
    """Random weights scaled for ReLU layers (He initialisation)."""
    return tf.Variable(tf.random.normal([fan_in, fan_out], stddev=np.sqrt(2.0 / fan_in)))

class ScratchNet(tf.Module):
    def __init__(self):
        super().__init__()
        self.W1, self.b1 = he_init(5, 16), tf.Variable(tf.zeros([16]))
        self.W2, self.b2 = he_init(16, 8), tf.Variable(tf.zeros([8]))
        self.W3, self.b3 = he_init(8, 1), tf.Variable(tf.zeros([1]))

    def __call__(self, x):                         # x: (batch, 5)
        h1 = tf.nn.relu(x @ self.W1 + self.b1)     # (batch, 16)
        h2 = tf.nn.relu(h1 @ self.W2 + self.b2)    # (batch, 8)
        return h2 @ self.W3 + self.b3              # (batch, 1) raw scores (logits)

net = ScratchNet()
print(sum(int(tf.size(v)) for v in net.trainable_variables))   # 241
logits = net(X_train[:4])
print(logits.numpy().ravel())
print(tf.sigmoid(logits).numpy().ravel())

Two differences from PyTorch to notice. The weight matrices here are (in, out), so the code reads x @ W; PyTorch stores (out, in). Both are fine, they are just conventions. And the scaling sqrt(2 / fan_in) keeps the size of the signal roughly steady through ReLU layers; you will see why this matters in Module 28.

Working looks like: 241 parameters, and four random-ish probabilities, something like [0.15 0.60 0.98 0.39]. The model has not learned anything; these are the guesses of random weights.

Most likely bug: forgetting super().__init__(), or storing the variables in a plain Python list created after __init__, so trainable_variables is empty and nothing ever trains. Always print the parameter count.

Stuck? Hint

Count by hand to check: , , . Total 241. If you get 232, you forgot a bias somewhere.

3. Train the scratch network with a GradientTape loop

Why. This is the TensorFlow version of the five-step loop, with no optimiser object at all, so you see the update rule happen in plain sight.

PyTorch records operations on any tensor that requires gradients. TensorFlow records only what happens inside a GradientTape block. You run the forward pass and the loss inside the block, then ask the tape for gradients.

# scratch.py (continued)
from data import train_ds, X_val, y_val, X_test, y_test

def bce_from_logits(y, logits):
    return tf.reduce_mean(tf.nn.sigmoid_cross_entropy_with_logits(labels=y, logits=logits))

lr = 0.1

@tf.function                                       # compile to a fast graph
def scratch_step(xb, yb):
    with tf.GradientTape() as tape:                # 1-2. forward + loss, recorded
        loss = bce_from_logits(yb, net(xb))
    grads = tape.gradient(loss, net.trainable_variables)   # 3-4. backward
    for v, g in zip(net.trainable_variables, grads):       # 5. plain SGD update
        v.assign_sub(lr * g)                       # v = v - lr * g, in place
    return loss

for epoch in range(1, 21):
    losses = [scratch_step(xb, yb) for xb, yb in train_ds]
    val_loss = bce_from_logits(y_val, net(X_val))
    print(f"epoch {epoch:2d}  train {np.mean(losses):.3f}  val {float(val_loss):.3f}")

np.save("scratch_probs.npy", tf.sigmoid(net(X_test)).numpy())

Line by line, the new parts:

  • tf.nn.sigmoid_cross_entropy_with_logits is TensorFlow’s version of PyTorch’s BCEWithLogitsLoss: sigmoid and binary cross-entropy in one numerically stable step.
  • tape.gradient(loss, variables) returns a list of gradients in the same order as the variables.
  • v.assign_sub(lr * g) subtracts in place. You cannot write v = v - lr * g, because that would create a new tensor and leave the variable unchanged.
  • @tf.function traces the Python function once and compiles it into a graph. The loop runs several times faster. The trade-off: Python print inside it only runs during tracing, which confuses everyone exactly once.

Notice there is no zero_grad(). A tape computes fresh gradients each time and is thrown away, so nothing accumulates. That entire class of bug from Module 27 does not exist here.

Working looks like:

epoch  1  train 0.472  val 0.413
epoch  5  train 0.362  val 0.364
epoch 10  train 0.355  val 0.361
epoch 20  train 0.351  val 0.365

The validation loss bottoms out around 0.36, close to where the PyTorch model ended up. Same problem, same ideas, same answer.

Most likely bug: calling tape.gradient inside the with block for a second loss, or calling it twice. A normal tape can only be used once. If you get None gradients instead, the variable was not used to compute the loss inside the tape, usually because you created a new variable by accident with v = v - ....

Stuck? Hint

Print [g is None for g in grads] once. Every entry should be False. If the loss becomes nan, lower lr to 0.03; if it barely moves, check that assign_sub is actually being called (put the update outside @tf.function temporarily to debug).

4. Rebuild it with Keras layers and train with model.fit

Why. This is how most TensorFlow code is written at work. Keras packages the layers, the loss, the optimiser, the loop, the metrics and the early stopping, and you configure rather than write them.

# keras_fit.py
import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from data import X_train, y_train, X_val, y_val, X_test, y_test

keras.utils.set_random_seed(0)

def build_model() -> keras.Model:
    return keras.Sequential([
        keras.Input(shape=(5,)),
        layers.Dense(16, activation="relu"),
        layers.Dropout(0.2),
        layers.Dense(8, activation="relu"),
        layers.Dense(1, activation="sigmoid"),     # outputs a probability directly
    ], name="late_delivery")

model = build_model()
model.summary()

model.compile(
    optimizer=keras.optimizers.Adam(learning_rate=1e-3),
    loss=keras.losses.BinaryCrossentropy(),
    metrics=[keras.metrics.BinaryAccuracy(name="accuracy"), keras.metrics.Recall(name="recall")],
)

early = keras.callbacks.EarlyStopping(monitor="val_loss", patience=10, restore_best_weights=True)
history = model.fit(X_train, y_train, validation_data=(X_val, y_val),
                    epochs=200, batch_size=64, callbacks=[early], verbose=2)
model.save("late_fit.keras")        # a quick save so milestone 6 can load it

compile chooses the optimiser, loss and metrics. fit runs the loop: batching, shuffling, the five steps, validation after each epoch, and the callbacks. EarlyStopping with restore_best_weights=True does exactly what you wrote by hand in Module 27: watch validation loss, stop after 10 epochs without improvement, roll back to the best weights.

One deliberate difference: the last layer has activation="sigmoid", so the model outputs probabilities, and BinaryCrossentropy() expects probabilities. That is the common Keras style, and it makes the saved model easier for the partner to use, since model.predict returns probabilities directly. (You can instead output logits and use BinaryCrossentropy(from_logits=True); then predict returns raw scores.) Mixing the two styles is the classic Keras mistake, exactly like the double sigmoid in PyTorch.

Working looks like: a summary table showing 96, 0, 136 and 9 parameters (Dropout has none), totalling 241. Then epoch lines like:

Epoch 1/200
63/63 - 1s - 13ms/step - accuracy: 0.7197 - loss: 0.6092 - recall: 0.2464 - val_accuracy: 0.7990 - val_loss: 0.5397 - val_recall: 0.0103
...
Epoch 50/200
63/63 - 0s - 1ms/step - accuracy: 0.8397 - loss: 0.3625 - recall: 0.3550 - val_accuracy: 0.8480 - val_loss: 0.3624 - val_recall: 0.3590

and an early stop somewhere past epoch 100 with a best validation loss of about 0.35. “63/63” is the number of batches per epoch: 4,000 orders in batches of 64.

Look at epoch 1 and 2: recall drops to almost zero while accuracy climbs to 81%. The model briefly discovers the rubber stamp, “everything is on time”, before it learns anything real. Watching recall, not just accuracy, is how you catch that.

Most likely bug: passing validation_split=0.2 and your own validation data, or using validation_split on data sorted by time (it always takes the last 20%, without shuffling). Pass validation_data explicitly.

Stuck? Hint

If accuracy sits at exactly 0.81 for many epochs with recall 0, the model is stuck on the rubber stamp. Check that inputs are standardised (print X_train.mean(axis=0), which should be near zero) and that the last layer’s activation matches the loss.

5. Write a custom GradientTape loop for the Keras model

Why. fit is wonderful until you need something it does not do: two losses with different weights, a model with two inputs, gradient clipping on only some layers, logging an image every 100 steps. In Module 29’s OCR project you will need exactly that kind of control. The pattern is the scratch loop from milestone 3, but with a Keras model, a real optimiser and Keras metrics.

# keras_loop.py
import numpy as np
import tensorflow as tf
from tensorflow import keras
from data import train_ds, val_ds
from keras_fit import build_model

keras.utils.set_random_seed(0)
model = build_model()
optimizer = keras.optimizers.Adam(learning_rate=1e-3)
loss_fn = keras.losses.BinaryCrossentropy()
train_acc = keras.metrics.BinaryAccuracy()
val_acc = keras.metrics.BinaryAccuracy()

@tf.function
def train_step(x, y):
    with tf.GradientTape() as tape:
        probs = model(x, training=True)          # dropout ON
        loss = loss_fn(y, probs)
    grads = tape.gradient(loss, model.trainable_variables)
    optimizer.apply_gradients(zip(grads, model.trainable_variables))
    train_acc.update_state(y, probs)
    return loss

@tf.function
def val_step(x, y):
    probs = model(x, training=False)             # dropout OFF
    val_acc.update_state(y, probs)
    return loss_fn(y, probs)

best, bad, best_weights = float("inf"), 0, None
for epoch in range(1, 201):
    train_acc.reset_state()
    val_acc.reset_state()
    train_loss = np.mean([float(train_step(x, y)) for x, y in train_ds])
    val_loss = np.mean([float(val_step(x, y)) for x, y in val_ds])
    print(f"epoch {epoch:3d}  loss {train_loss:.3f}  acc {float(train_acc.result()):.3f}  "
          f"val_loss {val_loss:.3f}  val_acc {float(val_acc.result()):.3f}")
    if val_loss < best:
        best, bad, best_weights = val_loss, 0, model.get_weights()
    else:
        bad += 1
        if bad >= 10:
            break
model.set_weights(best_weights)

(Importing build_model from keras_fit.py will also run that file’s training. Move build_model into its own model.py to avoid that; this is the “one job per file” lesson from Module 28 arriving early.)

The training=True argument is TensorFlow’s model.train(): it switches dropout on for that call only. Metrics follow the same update_state, result, reset_state pattern as torchmetrics’ update, compute, reset. get_weights and set_weights are the early-stopping snapshot.

Working looks like:

epoch   1  loss 0.611  acc 0.727  val_loss 0.539  val_acc 0.799
epoch   5  loss 0.391  acc 0.833  val_loss 0.382  val_acc 0.840
epoch  20  loss 0.364  acc 0.844  val_loss 0.367  val_acc 0.843

and an early stop with a best validation loss around 0.36, matching fit within normal run-to-run noise. If your custom loop is noticeably worse than fit, something in it is wrong; fit is the reference.

Most likely bug: forgetting training=True, so dropout never activates and the loop quietly differs from fit; or forgetting reset_state(), so the reported accuracy is an average over every epoch so far and looks suspiciously stable.

Stuck? Hint

If you see a warning about tf.function retracing, you are probably passing Python numbers or NumPy arrays of different shapes into the step function. Feed it batches from tf.data and it traces once (or twice, once for the last smaller batch).

6. Evaluate all three on the test set

Why. Three implementations of the same model should perform about the same on the same unseen orders. If they do, you can trust all three. If one is much worse, it has a bug, and you have two references to find it with.

# evaluate.py
import numpy as np
import tensorflow as tf
from tensorflow import keras
from data import X_test, y_test

def report(name: str, probs: np.ndarray, threshold: float) -> None:
    preds = (probs > threshold).astype("int32")
    cm = tf.math.confusion_matrix(y_test.ravel().astype("int32"), preds.ravel(), num_classes=2).numpy()
    tn, fp, fn, tp = cm.ravel()
    precision = tp / max(tp + fp, 1)
    recall = tp / max(tp + fn, 1)
    accuracy = (tp + tn) / cm.sum()
    print(f"{name:8s} t={threshold}  acc {accuracy:.3f}  precision {precision:.3f}  "
          f"recall {recall:.3f}  confusion {cm.tolist()}")

print(f"baseline (always on time) accuracy: {1 - y_test.mean():.3f}")
models = {
    "scratch": np.load("scratch_probs.npy"),
    "fit": keras.models.load_model("late_fit.keras").predict(X_test, verbose=0),
}
for name, probs in models.items():
    for t in (0.5, 0.3):
        report(name, probs, t)

(Add the custom-loop model the same way: save it with model.save("late_loop.keras") at the end of milestone 5. Milestone 7 covers saving properly; for now, model.save is enough.)

The confusion matrix from tf.math.confusion_matrix has actual labels as rows and predictions as columns: [[TN, FP], [FN, TP]], the same layout as Module 27.

Working looks like (your numbers will differ slightly):

Model Threshold Accuracy Precision Recall Confusion [[TN, FP], [FN, TP]]
Baseline n/a 0.811 n/a 0.000 [[811, 0], [189, 0]]
Scratch 0.5 0.841 0.663 0.323 [[780, 31], [128, 61]]
fit 0.5 0.842 0.650 0.354 [[775, 36], [122, 67]]
Custom loop 0.5 0.842 0.640 0.376 [[771, 40], [118, 71]]
fit 0.3 0.813 0.505 0.561 [[707, 104], [83, 106]]
Custom loop 0.3 0.813 0.504 0.608 [[698, 113], [74, 115]]

All three land within a hair of each other. All three beat the baseline by about three accuracy points, and at a threshold of 0.3 all three catch well over half the late orders. That is the PyTorch story again, which is the point: the ideas did the work, not the framework.

Most likely bug: comparing with probabilities from one model and logits from another. The scratch model outputs logits, so its saved predictions went through tf.sigmoid first. If one model’s “probabilities” include values below 0 or above 1, they are logits.

Stuck? Hint

If precision or recall come out as nan, a model predicted zero positives at that threshold (a rubber stamp). The max(..., 1) guards in report prevent the crash, but the real fix is upstream: that model did not train.

7. Save, reload and document the model

Why. The partner will load your file in a different process, on a different machine, months from now. The only way to know that works is to do it yourself in a fresh script. And the model is useless to them without the standardisation statistics.

# at the end of keras_fit.py
import json
model.save("late_delivery.keras")
from data import mean, std
with open("late_delivery_stats.json", "w") as f:
    json.dump({"features": ["distance_km", "items", "hour", "raining", "partner_rating"],
               "mean": mean.tolist(), "std": std.tolist(), "threshold": 0.3}, f, indent=2)

Then, in a brand-new file that imports nothing from your project:

# check_saved.py
import json
import numpy as np
from tensorflow import keras

model = keras.models.load_model("late_delivery.keras")
stats = json.load(open("late_delivery_stats.json"))

order = np.array([[12.0, 3, 19, 1, 3.4]], dtype="float32")   # 12 km, 3 items, 7pm, rain, weak rider
x = (order - np.array(stats["mean"])) / np.array(stats["std"])
p = float(model.predict(x, verbose=0)[0, 0])
print(f"late probability {p:.2f} -> {'FLAG' if p > stats['threshold'] else 'ok'}")

Working looks like: a probability around 0.7 and the word FLAG for that rainy evening order. Try a 2 km, 2-item lunchtime order with a 4.9-rated rider in the dry; it should be close to zero (well under 0.01).

Most likely bug: the reloaded model gives different answers from the original because the stats were recomputed instead of loaded, which is exactly the kind of bug that opens Module 28: preprocessing that drifts away from what the model was trained with. The .keras file stores the architecture, weights and compile settings, but not your preprocessing.

Stuck? Hint

To make the model self-contained, you can put the standardisation inside it with a layers.Normalization layer: call norm.adapt(X_train_raw) on raw training data, then make it the first layer. The saved file then includes the statistics, and the partner can feed raw order values. This is a good stretch goal.

Finish with a README that states, in this order: what the model predicts; its inputs and their units; the test-set results table including the baseline; the recommended threshold and why (Meera’s ₹350 refund versus ₹50 false-alarm costs from Module 27); how to load it; and its limits (trained on simulated data, no monsoon-specific behaviour, must be monitored after deployment).

Done means done

  • data.py prints three similar late rates and batch shapes of (64, 5) and (64, 1) `data.py` prints three similar late rates and batch shapes of `(64, 5)` and `(64, 1)`.
  • The scratch network reports 241 parameters and trains with a GradientTape The scratch network reports 241 parameters and trains with a `GradientTape` loop to a validation loss around 0.36.
  • The Keras model’s summary() shows 241 parameters, and fit The Keras model's `summary()` shows 241 parameters, and `fit` early-stops with restored best weights.
  • The custom loop’s best validation loss is within about 0.01 of fit The custom loop's best validation loss is within about 0.01 of `fit`'s.
  • All three beat the 81% baseline on the test set, and you report precision, recall and confusion matrices at 0.5 and 0.3.
  • check_saved.py `check_saved.py` loads the model in a fresh process and flags a risky order.
  • The README includes the results table, the threshold choice and the limitations.

Stretch goals

  • Self-contained preprocessing. Add a keras.layers.Normalization layer so the saved model accepts raw values.
  • Rush hour, explicitly. Replace the raw hour with two features, and , so 11pm and midnight are close together. Does recall improve?
  • Same orders, both frameworks. Export the NumPy splits, train your Module 27 PyTorch model on them, and add it to the results table.
  • Class weights. Pass class_weight={0: 1.0, 1: 4.0} to fit so late orders count four times as much in the loss. Compare with simply lowering the threshold.
  • Learning curves. Plot history.history["loss"] and ["val_loss"] and mark the epoch early stopping chose.

Common pitfalls

  • Logits versus probabilities. Decide per model, and make the loss match: from_logits=True for raw scores, the default for sigmoid outputs.
  • Label shapes. (n,) against (n, 1) can silently broadcast. Keep (n, 1).
  • Forgetting training=True in custom loops. Dropout does nothing and your loop disagrees with fit.
  • Expecting print inside @tf.function to run every step. It runs once, during tracing. Use tf.print if you need it every step, or debug without the decorator.
  • Saving the model but not the preprocessing. The weights mean nothing without the statistics.
  • Believing one framework is “more accurate”. On the same data with the same architecture, PyTorch and TensorFlow should match within noise. A big gap means a bug.

Show it off

Put the three versions in one repo with a clear README and the results table at the top. Record a 30-second terminal GIF of check_saved.py flagging the rainy 7pm order and passing the lunchtime one.

For LinkedIn, write about the comparison, not the model: “I built the same late-delivery classifier three ways in TensorFlow: raw variables and a hand-written GradientTape loop, Keras fit, and a custom training loop. All three matched each other, and my earlier PyTorch version, within 0.2% accuracy. The biggest lesson: the deep learning ideas (loss, gradients, early stopping, honest test sets, choosing a threshold by cost) transfer between frameworks unchanged. Only the spelling differs.” Then link the repo. Hiring managers read that as someone who understands what the libraries do, which is rarer than someone who can call them.

Sana from the delivery partner loaded late_delivery.keras into their staging system on Friday. Her only feedback was a question: “Can you put the normalisation inside the model next time?” Arjun added it to the stretch goals with a grin. Priya had already moved on. “Next project has no labels at all. Let’s see what a network can learn about handwriting when nobody tells it what anything is.”

Go deeper

  • TensorFlow tutorials: the beginner and “customization” sections cover GradientTape, custom training loops and Keras models.
  • PyTorch tutorials for comparing against your Module 27 version.

Your build checklist

0/7 done

Check your understanding

3 quick questions
1. Why does a TensorFlow GradientTape loop not need an equivalent of PyTorch’s zero_grad()?
2. Your Keras model ends with Dense(1, activation="sigmoid"). Which loss setting matches it?
3. All three TensorFlow versions and the earlier PyTorch model reach about 84% accuracy on the same kind of data. What is the main lesson?