Skip to content

Keras in practice

Chapter 32 16 code blocks 4433 words

The code in this chapter uses these filesrun it on your own machine

The scene: the 40 lines from Chapter 30

In Chapter 30 we wrote a network from scratch: 40 lines of code. Forward, backward, gradient, update.

In Keras the same thing takes five lines.

This chapter teaches you how to use the library. But careful: don't skip Chapters 30 and 31. Keras is easy to use, but understanding why it isn't working is only possible if you know what's inside.

Column names

The rental data has Uzbek column names. xonalar rooms, maydon_m2 area in m², qavat floor, qavatlar_soni floors in the building, metro_km distance to the metro, mebel furnished (0/1), qurilgan_yil year built, narx_usd monthly rent in USD. The target ijaraga_berildi_30k means rented within 30 days.

The first model: five lines

python13 lines
import os
os.environ["CUDA_VISIBLE_DEVICES"] = "-1"       # CPU, for reproducibility
os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3"
import numpy as np
import pandas as pd
import tensorflow as tf
from tensorflow import keras

tf.random.set_seed(42)
np.random.seed(42)
tf.config.threading.set_intra_op_parallelism_threads(2)
print(f"TensorFlow {tf.__version__}  |  Keras {keras.__version__}")
print(f"Device: {'GPU' if tf.config.list_physical_devices('GPU') else 'CPU'}")
Output
TensorFlow 2.20.0  |  Keras 3.12.0
Device: CPU
python23 lines
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer

df = pd.read_csv(DATA / "uy_ijara.csv")
num = ["xonalar", "maydon_m2", "qavat", "qavatlar_soni", "metro_km", "mebel", "qurilgan_yil"]
X = SimpleImputer(strategy="median").fit_transform(df[num])
y = df["ijaraga_berildi_30k"].to_numpy(dtype="float32")

X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.3, random_state=42, stratify=y)
sc = StandardScaler().fit(X_tr)
X_tr, X_te = sc.transform(X_tr).astype("float32"), sc.transform(X_te).astype("float32")

model = keras.Sequential([
    keras.layers.Input(shape=(X_tr.shape[1],)),
    keras.layers.Dense(16, activation="relu"),
    keras.layers.Dense(1, activation="sigmoid"),
])
model.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
history = model.fit(X_tr, y_tr, epochs=40, batch_size=32, verbose=0,
                    validation_split=0.2)
loss, acc = model.evaluate(X_te, y_te, verbose=0)
print(f"Test loss: {loss:.4f}   Test accuracy: {acc:.4f}")
Output
Test loss: 0.5050   Test accuracy: 0.7722

Five lines. The same job as the 40 lines in Chapter 30.

The structure of a Sequential

python1 lines
model.summary(print_fn=lambda s: print("  " + s))
Output
  Model: "sequential"
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Layer (type)                    ┃ Output Shape           ┃       Param # ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ dense (Dense)                   │ (None, 16)             │           128 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense)                 │ (None, 1)              │            17 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
 Total params: 437 (1.71 KB)
 Trainable params: 145 (580.00 B)
 Non-trainable params: 0 (0.00 B)
 Optimizer params: 292 (1.14 KB)

Let's check the Param # column against the matrix shapes from Chapter 12:

python11 lines
n_in = X_tr.shape[1]
print(f"Layer 1: Dense(16)")
print(f"  W shape: ({n_in}, 16) -> {n_in * 16} parameters")
print(f"  b shape: (16,)            -> 16 parameters")
print(f"  total: {n_in * 16 + 16}")
print()
print(f"Layer 2: Dense(1)")
print(f"  W: (16, 1) -> 16  |  b: (1,) -> 1  |  total: 17")
print()
print(f"Overall: {n_in * 16 + 16 + 17}")
print(f"model.count_params(): {model.count_params()}")
Output
Layer 1: Dense(16)
  W shape: (7, 16) -> 112 parameters
  b shape: (16,)            -> 16 parameters
  total: 128

Layer 2: Dense(1)
  W: (16, 1) -> 16  |  b: (1,) -> 1  |  total: 17

Overall: 145
model.count_params(): 145

Three steps: build, compile, fit

python8 lines
show(pd.DataFrame([
    {"Step": "1. Build", "What you do": "The layers and their shapes",
     "In code": "keras.Sequential([...])"},
    {"Step": "2. Compile", "What you do": "The optimizer, the loss, the metrics",
     "In code": "model.compile(...)"},
    {"Step": "3. Fit", "What you do": "The data, the epochs, the batch",
     "In code": "model.fit(...)"},
]))
StepWhat you doIn code
1. BuildThe layers and their shapeskeras.Sequential([...])
2. CompileThe optimizer, the loss, the metricsmodel.compile(...)
3. FitThe data, the epochs, the batchmodel.fit(...)

The place where people go wrong most is the pairing of the output layer and the loss function:

python10 lines
show(pd.DataFrame([
    {"Task": "Binary classification", "Output layer": "Dense(1, activation='sigmoid')",
     "loss": "binary_crossentropy", "metrics": "accuracy, AUC"},
    {"Task": "Multiclass (3+ classes)", "Output layer": "Dense(n_classes, activation='softmax')",
     "loss": "sparse_categorical_crossentropy", "metrics": "accuracy"},
    {"Task": "Multilabel (several labels)", "Output layer": "Dense(n, activation='sigmoid')",
     "loss": "binary_crossentropy", "metrics": "accuracy"},
    {"Task": "Regression", "Output layer": "Dense(1): NO activation",
     "loss": "mse or mae", "metrics": "mae, RootMeanSquaredError"},
]))
TaskOutput layerlossmetrics
Binary classificationDense(1, activation='sigmoid')binary_crossentropyaccuracy, AUC
Multiclass (3+ classes)Dense(n_classes, activation='softmax')sparse_categorical_crossentropyaccuracy
Multilabel (several labels)Dense(n, activation='sigmoid')binary_crossentropyaccuracy
RegressionDense(1): NO activationmse or maemae, RootMeanSquaredError

softmax + binary_crossentropy: a silent mistake

This pairing doesn't raise an error; the code runs, but the model doesn't learn, or learns very badly.

The rule: sigmoid -> binary_crossentropy. softmax -> categorical_crossentropy. Regression -> no activation + mse.

One more: sparse_categorical_crossentropy is for labels that are integers (0, 1, 2), categorical_crossentropy for one-hot labels. Mix them up and you get a shape error.

python22 lines
print("A regression problem, three variants:")
yn = df["narx_usd"].to_numpy(dtype="float32")
Xn_tr, Xn_te, yn_tr, yn_te = train_test_split(X, yn, test_size=0.3, random_state=42)
scn = StandardScaler().fit(Xn_tr)
Xn_tr, Xn_te = scn.transform(Xn_tr).astype("float32"), scn.transform(Xn_te).astype("float32")

print(f"  (the average rent is {yn.mean():.0f} USD: the MAE MUST be far below that)")
print()
print(f"{'variant':<34} {'epochs':>6} {'test MAE':>10}")
print("-" * 54)
for name, last, ep in [
    ("Dense(1): right, few epochs", keras.layers.Dense(1), 30),
    ("Dense(1): right, enough epochs", keras.layers.Dense(1), 400),
    ("Dense(1, 'sigmoid'): WRONG", keras.layers.Dense(1, activation="sigmoid"), 400),
]:
    tf.random.set_seed(42)
    m = keras.Sequential([keras.layers.Input(shape=(Xn_tr.shape[1],)),
                          keras.layers.Dense(32, activation="relu"), last])
    m.compile(optimizer="adam", loss="mse", metrics=["mae"])
    m.fit(Xn_tr, yn_tr, epochs=ep, batch_size=32, verbose=0)
    _, mae = m.evaluate(Xn_te, yn_te, verbose=0)
    print(f"{name:<34} {ep:>6} {mae:>10.1f}")
Output
A regression problem, three variants:
  (the average rent is 776 USD: the MAE MUST be far below that)

variant                            epochs   test MAE
------------------------------------------------------
Dense(1): right, few epochs            30      726.3
Dense(1): right, enough epochs        400       70.2
Dense(1, 'sigmoid'): WRONG            400      785.7

The table gives two lessons, and I missed the first one at first.

Lesson 1: too few epochs also looks like a mistake. The right architecture at 30 epochs gives an MAE close to the average rent, which means the model hasn't learned anything yet. At 400 epochs the MAE drops more than tenfold and approaches the Ridge result from Chapter 15.

In other words, while trying to show "sigmoid is bad", I had actually undertrained both models, and the gap looked small.

Lesson 2: sigmoid never recovers. Even at 400 epochs its MAE stays at the level of the average rent. The reason is clear: sigmoid squeezes the output between 0 and 1, while rents run from 225 to 1515. However long the model trains, it can't reach that range.

A bad result has two different causes, and you have to tell them apart

The three rows above show it:

Cause Sign Fix
Undertrained the train loss is still falling More epochs, a larger lr
Wrong architecture the train loss has stopped, but it's high Fix the output layer/loss

How to tell: look at the train loss curve. If it's still going down, it needs time. If it has flattened and the result is still bad, the structure is wrong.

I drew a conclusion without checking that difference. The rule from Chapter 11 applies here too: first the diagnosis, then the action.

Keras numbers come out SLIGHTLY different every time

Run the table above twice and the MAE values won't be identical: they usually differ by 1-2%.

I checked this by measuring: even with tf.random.set_seed(42), np.random.seed(42) and tf.config.experimental.enable_op_determinism(), TensorFlow didn't give the same result.

PyTorch (Chapter 31), on the other hand, reproduces exactly with torch.manual_seed(42): the same to six decimal places.

This has a practical consequence, and it applies to this book too: I didn't copy the exact numbers from this chapter into the text. Look at the table: it's recomputed on every build. The text only states the direction ("drops tenfold"), not an exact number.

Do the same: when you report a Keras result, don't give one number; give the mean and spread of several runs (the rule from Chapter 10).

Callbacks: steering the training

python22 lines
tf.random.set_seed(42)
model2 = keras.Sequential([
    keras.layers.Input(shape=(X_tr.shape[1],)),
    keras.layers.Dense(64, activation="relu"),
    keras.layers.Dropout(0.3),
    keras.layers.Dense(32, activation="relu"),
    keras.layers.Dense(1, activation="sigmoid"),
])
model2.compile(optimizer=keras.optimizers.Adam(1e-3),
               loss="binary_crossentropy", metrics=["accuracy"])

callbacks = [
    keras.callbacks.EarlyStopping(monitor="val_loss", patience=15, restore_best_weights=True),
    keras.callbacks.ReduceLROnPlateau(monitor="val_loss", factor=0.3, patience=7, min_lr=1e-5),
]
t2 = model2.fit(X_tr, y_tr, epochs=300, batch_size=32, verbose=0,
                validation_split=0.2, callbacks=callbacks)
print(f"Epochs allowed           : 300")
print(f"Early stopping stopped at: {len(t2.history['loss'])} epochs")
print(f"Best val_loss            : {min(t2.history['val_loss']):.4f}")
loss2, acc2 = model2.evaluate(X_te, y_te, verbose=0)
print(f"Test accuracy            : {acc2:.4f}")
Output
Epochs allowed           : 300
Early stopping stopped at: 18 epochs
Best val_loss            : 0.6136
Test accuracy            : 0.7722
python10 lines
show(pd.DataFrame([
    {"Callback": "EarlyStopping", "What it does": "Stops when val_loss stops improving",
     "Key parameter": "patience, restore_best_weights=True"},
    {"Callback": "ReduceLROnPlateau", "What it does": "Lowers the learning rate on a plateau",
     "Key parameter": "factor, patience"},
    {"Callback": "ModelCheckpoint", "What it does": "Saves the best model to disk",
     "Key parameter": "save_best_only=True"},
    {"Callback": "TensorBoard", "What it does": "Writes charts to a log",
     "Key parameter": "log_dir"},
]))
CallbackWhat it doesKey parameter
EarlyStoppingStops when val_loss stops improvingpatience, restore_best_weights=True
ReduceLROnPlateauLowers the learning rate on a plateaufactor, patience
ModelCheckpointSaves the best model to disksave_best_only=True
TensorBoardWrites charts to a loglog_dir

Don't forget restore_best_weights=True

By default EarlyStopping keeps the last weights: the model that got worse over the patience epochs.

restore_best_weights=True goes back to the best epoch. You almost always want this, but it's off by default.

Training curves: the most important diagnostic

python17 lines
import matplotlib.pyplot as plt

figure, axes = plt.subplots(1, 2, figsize=(13.5, 4.6))
axes[0].plot(t2.history["loss"], lw=2, label="train", color="#1a73e8")
axes[0].plot(t2.history["val_loss"], lw=2, label="validation", color="#c5221f")
axes[0].set_xlabel("Epoch"); axes[0].set_ylabel("Loss")
axes[0].set_title("Loss", fontweight="bold"); axes[0].legend(); axes[0].grid(alpha=.3)

axes[1].plot(t2.history["accuracy"], lw=2, label="train", color="#1a73e8")
axes[1].plot(t2.history["val_accuracy"], lw=2, label="validation", color="#c5221f")
axes[1].set_xlabel("Epoch"); axes[1].set_ylabel("Accuracy")
axes[1].set_title("Accuracy", fontweight="bold"); axes[1].legend(); axes[1].grid(alpha=.3)
fig("keras_tarix.png", caption="If the two lines pull apart, it's overfitting. If they run side by side, it's good.")

last_gap = t2.history["val_loss"][-1] - t2.history["loss"][-1]
print(f"At the last epoch val_loss - loss = {last_gap:+.4f}")
print("Diagnosis:", "a sign of overfitting" if last_gap > 0.1 else "the lines are close: good")
Output
At the last epoch val_loss - loss = +0.0725
Diagnosis: the lines are close: good

If the two lines pull apart, it's overfitting. If they run side by side, it's good.

If the two lines pull apart, it's overfitting. If they run side by side, it's good.

This chart is the same idea as the learning curve in Chapter 11: look at train and validation side by side.

The Functional API: when the network isn't a straight line

Sequential only knows a linear sequence. If you need two inputs or two outputs, use the Functional API.

python26 lines
tf.random.set_seed(42)
inp = keras.layers.Input(shape=(X_tr.shape[1],), name="features")
h = keras.layers.Dense(32, activation="relu")(inp)
h = keras.layers.Dense(16, activation="relu")(h)
out_class = keras.layers.Dense(1, activation="sigmoid", name="goes_fast")(h)
out_price = keras.layers.Dense(1, name="price")(h)

two_outputs = keras.Model(inp, [out_class, out_price])
two_outputs.compile(
    optimizer="adam",
    loss={"goes_fast": "binary_crossentropy", "price": "mse"},
    loss_weights={"goes_fast": 1.0, "price": 0.0001},
    metrics={"goes_fast": "accuracy", "price": "mae"},
)
yn_tr2 = df["narx_usd"].to_numpy(dtype="float32")[
    train_test_split(np.arange(len(df)), test_size=0.3, random_state=42, stratify=y)[0]]
t3 = two_outputs.fit(X_tr, {"goes_fast": y_tr, "price": yn_tr2},
                     epochs=40, batch_size=32, verbose=0, validation_split=0.2)
result = two_outputs.evaluate(X_te, {"goes_fast": y_te,
                                     "price": df["narx_usd"].to_numpy(dtype="float32")[
                                         train_test_split(np.arange(len(df)), test_size=0.3,
                                                          random_state=42, stratify=y)[1]]},
                              verbose=0, return_dict=True)
print("One network, TWO tasks:")
for k, v in result.items():
    print(f"  {k:28s} {v:.4f}")
Output
One network, TWO tasks:
  goes_fast_accuracy           0.7500
  goes_fast_loss               0.5325
  loss                         3.6039
  price_loss                   30369.9316
  price_mae                    146.5417

One network learned two things at once: whether a flat goes fast and what it costs. The hidden layers serve both.

When you need the Functional API

  • Several inputs (for example image + text)
  • Several outputs (the example above)
  • Splitting or merging layers (the skip connection in ResNet)
  • Transfer learning: connecting into the middle of an existing model

Sequential stays for the simple cases.

tf.data: feeding data efficiently

python26 lines
import time
batch = 64
ds_tr = (tf.data.Dataset.from_tensor_slices((X_tr, y_tr))
         .shuffle(1000, seed=42).batch(batch).prefetch(tf.data.AUTOTUNE))
ds_te = tf.data.Dataset.from_tensor_slices((X_te, y_te)).batch(batch)

tf.random.set_seed(42)
m4 = keras.Sequential([keras.layers.Input(shape=(X_tr.shape[1],)),
                       keras.layers.Dense(32, activation="relu"),
                       keras.layers.Dense(1, activation="sigmoid")])
m4.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
t0 = time.perf_counter(); m4.fit(ds_tr, epochs=30, verbose=0); dt_ds = time.perf_counter()-t0
print(f"With tf.data      : {dt_ds:.1f}s, test accuracy {m4.evaluate(ds_te, verbose=0)[1]:.4f}")

tf.random.set_seed(42)
m5 = keras.Sequential([keras.layers.Input(shape=(X_tr.shape[1],)),
                       keras.layers.Dense(32, activation="relu"),
                       keras.layers.Dense(1, activation="sigmoid")])
m5.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
t0 = time.perf_counter(); m5.fit(X_tr, y_tr, batch_size=batch, epochs=30, verbose=0); dt_np = time.perf_counter()-t0
print(f"With a numpy array: {dt_np:.1f}s, test accuracy {m5.evaluate(X_te, y_te, verbose=0)[1]:.4f}")
print()
print("On small data there's no difference. You need tf.data on big data:")
print("  - when it doesn't all fit in memory (streaming from disk)")
print("  - when you need image augmentation")
print("  - so the GPU isn't left waiting, thanks to prefetch")
Output
With tf.data      : 0.5s, test accuracy 0.7750
With a numpy array: 1.0s, test accuracy 0.7806

On small data there's no difference. You need tf.data on big data:
  - when it doesn't all fit in memory (streaming from disk)
  - when you need image augmentation
  - so the GPU isn't left waiting, thanks to prefetch

Saving and loading a model

python10 lines
path = ISH / "model.keras"
model2.save(path)
print(f"Saved: {path.name}  ({path.stat().st_size / 1024:.0f} KB)")

loaded = keras.models.load_model(path)
a1 = model2.predict(X_te[:5], verbose=0).ravel()
a2 = loaded.predict(X_te[:5], verbose=0).ravel()
print(f"Original model's predictions: {a1.round(5)}")
print(f"The loaded model            : {a2.round(5)}")
print(f"Exactly the same            : {bool(np.allclose(a1, a2))}")
Output
Saved: model.keras  (58 KB)
Original model's predictions: [0.32769 0.64991 0.66323 0.19593 0.7781 ]
The loaded model            : [0.32769 0.64991 0.66323 0.19593 0.7781 ]
Exactly the same            : True

Saving the model doesn't save the StandardScaler

model.save() saves only the network. The scaler, imputer and encoder: none of them are inside it.

If the model on the server gets data at the wrong scale, its predictions become meaningless, and it raises no error.

The fix: save the preprocessing steps separately (joblib.dump(scaler, ...)), or put the normalisation inside the network with keras.layers.Normalization.

We come back to this in Chapter 40.

python21 lines
import joblib
sc_path = ISH / "scaler.joblib"
joblib.dump(sc, sc_path)
print(f"Scaler saved separately: {sc_path.name} ({sc_path.stat().st_size} bytes)")
print()
# or we put the normalisation INSIDE the network
tf.random.set_seed(42)
norm = keras.layers.Normalization()
X_raw_tr = SimpleImputer(strategy="median").fit_transform(df[num])[
    train_test_split(np.arange(len(df)), test_size=0.3, random_state=42, stratify=y)[0]]
norm.adapt(X_raw_tr.astype("float32"))
inner = keras.Sequential([keras.layers.Input(shape=(len(num),)), norm,
                          keras.layers.Dense(32, activation="relu"),
                          keras.layers.Dense(1, activation="sigmoid")])
inner.compile(optimizer="adam", loss="binary_crossentropy", metrics=["accuracy"])
inner.fit(X_raw_tr.astype("float32"), y_tr, epochs=40, batch_size=32, verbose=0)
X_raw_te = SimpleImputer(strategy="median").fit_transform(df[num])[
    train_test_split(np.arange(len(df)), test_size=0.3, random_state=42, stratify=y)[1]]
print("Normalisation INSIDE the network: it's given RAW data:")
print(f"  test accuracy: {inner.evaluate(X_raw_te.astype('float32'), y_te, verbose=0)[1]:.4f}")
print("  Save this model and the scaling is saved along with it.")
Output
Scaler saved separately: scaler.joblib (767 bytes)

Normalisation INSIDE the network: it's given RAW data:
  test accuracy: 0.7750
  Save this model and the scaling is saved along with it.

Keras vs sklearn: a comparison

python11 lines
from sklearn.ensemble import RandomForestClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

print(f"{'model':<34} {'test accuracy':>14}")
print("-" * 50)
print(f"{'Keras MLP (64-32, dropout)':<34} {acc2:>14.4f}")
for name, m in [("LogisticRegression", LogisticRegression(max_iter=3000)),
                ("RandomForest", RandomForestClassifier(n_estimators=200, random_state=42))]:
    m.fit(X_tr, y_tr)
    print(f"{name:<34} {m.score(X_te, y_te):>14.4f}")
Output
model                               test accuracy
--------------------------------------------------
Keras MLP (64-32, dropout)                 0.7722
LogisticRegression                         0.7694
RandomForest                               0.7250

The conclusion from Chapter 30 still holds: on tabular data Keras gives no advantage.

What we learned today

python9 lines
show(pd.DataFrame([
    {"Concept": "Three steps", "In one sentence": "Sequential -> compile -> fit"},
    {"Concept": "Output and loss pairing", "In one sentence": "sigmoid+BCE, softmax+CCE, regression: no activation+MSE"},
    {"Concept": "EarlyStopping", "In one sentence": "DON'T FORGET restore_best_weights=True"},
    {"Concept": "The training curve", "In one sentence": "Train and val side by side: the main diagnostic"},
    {"Concept": "Functional API", "In one sentence": "Several inputs/outputs or a branching network"},
    {"Concept": "tf.data", "In one sentence": "For big data, augmentation, prefetch"},
    {"Concept": "The saving trap", "In one sentence": "model.save() doesn't save the scaler"},
]))
ConceptIn one sentence
Three stepsSequential -> compile -> fit
Output and loss pairingsigmoid+BCE, softmax+CCE, regression: no activation+MSE
EarlyStoppingDON'T FORGET restore_best_weights=True
The training curveTrain and val side by side: the main diagnostic
Functional APISeveral inputs/outputs or a branching network
tf.dataFor big data, augmentation, prefetch
The saving trapmodel.save() doesn't save the scaler

The next chapter does the same job in PyTorch, and we see how the two libraries differ.

Questions about this chapter?

The course, homework review and the other five books are in Uzbek. Questions in English are welcome on Telegram.

Write on Telegram Contents