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
- The brief
- What you will build
- Architecture
- Tech stack
- Build it, milestone by milestone
- 1. Generate, split and standardise the delivery data
- 2. Write the forward pass from scratch with tf.Variable
- 3. Train the scratch network with a GradientTape loop
- 4. Rebuild it with Keras layers and train with model.fit
- 5. Write a custom GradientTape loop for the Keras model
- 6. Evaluate all three on the test set
- 7. Save, reload and document the model
- Done means done
- Stretch goals
- Common pitfalls
- Show it off
- 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.GradientTapethat updates the variables yourself. - The same network rebuilt with Keras layers and trained with
model.fit, with early stopping. - A custom
GradientTapetraining loop for the Keras model, with metrics, matchingmodel.fit’s results. - Test-set evaluation of all three versions: accuracy, precision, recall, confusion matrix, at thresholds 0.5 and 0.3.
- A saved
.kerasfile, 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_logitsis TensorFlow’s version of PyTorch’sBCEWithLogitsLoss: 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 writev = v - lr * g, because that would create a new tensor and leave the variable unchanged.@tf.functiontraces the Python function once and compiles it into a graph. The loop runs several times faster. The trade-off: Pythonprintinside 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.pyprints 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
GradientTapeThe 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, andfitThe 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
fitThe 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.Normalizationlayer 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}tofitso 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=Truefor raw scores, the default for sigmoid outputs. - Label shapes.
(n,)against(n, 1)can silently broadcast. Keep(n, 1). - Forgetting
training=Truein custom loops. Dropout does nothing and your loop disagrees withfit. - Expecting
printinside@tf.functionto run every step. It runs once, during tracing. Usetf.printif 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.