Keras Alongside PyTorch

Week 15 of 18 · Production · 7 days

Full curriculum
Week 15 · Production

Keras Alongside PyTorch

Week 15 · Day 1 of 7

The Other Framework

Keras 3, its backends, and the axis order that catches everybody

By 436 words

Fourteen weeks of this course have been written in PyTorch, and that was a choice rather than a necessity. Keras is the other framework you will meet, it is what a great deal of existing code is written in, and since version 3 it is no longer tied to TensorFlow at all. This week builds the same things twice and compares them on the same data.

The goal is not to decide which is better. It is to make you able to read either one, because the job that hands you a five year old Keras model to maintain does not care which you prefer.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
print('keras %s on the %s backend' % (keras.__version__,
keras.backend.backend()))
print('train %s test %s' % (xtr.shape, xte.shape))
keras 3.15.1 on the tensorflow backend
train (12000, 28, 28, 1) test (2000, 28, 28, 1)

The backend is chosen before the import, not after

KERAS_BACKEND is read when keras is first imported. Setting it afterwards does nothing at all, silently. If a notebook has already imported Keras, you have to restart it, which is a genuinely common way to lose twenty minutes.

One difference that will bite you immediately

Keras puts the channel dimension last and PyTorch puts it first. An MNIST batch is (batch, 28, 28, 1) in Keras and (batch, 1, 28, 28) in PyTorch. Nothing warns you, the shapes are both valid, and a convolution will happily treat 28 channels of a 28 by 1 image as though that were sensible.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
import torch
print('keras batch %s' % (xtr[:8].shape,))
print('torch batch %s' % (torch.tensor(xtr[:8]).permute(0, 3, 1, 2).shape,))
print()
print('permute moves the axes without copying the data')
keras batch (8, 28, 28, 1)
torch batch torch.Size([8, 1, 28, 28])

permute moves the axes without copying the data

What Keras 3 actually is now

A model-building and training API that runs on TensorFlow, PyTorch or JAX. That means the choice between the two frameworks is less about capability than it used to be, and more about which layer of abstraction you want to work at.
Week 15 · Day 2 of 7

Declaring a Model Instead of Running One

Sequential, the shape table, and the functional API

By 537 words

The most common way to write a Keras model is a list of layers, which is a near-exact match for nn.Sequential. The difference is what comes with it.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
def build_keras():
return keras.Sequential([
layers.Input((28, 28, 1)),
layers.Conv2D(32, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Conv2D(64, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(10)])
model = build_keras()
model.summary()
Model: "sequential"
┌─────────────────────────────────┬────────────────────────┬───────────────┐
│ Layer (type) │ Output Shape │ Param # │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d (Conv2D) │ (None, 28, 28, 32) │ 320 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d (MaxPooling2D) │ (None, 14, 14, 32) │ 0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ conv2d_1 (Conv2D) │ (None, 14, 14, 64) │ 18,496 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ max_pooling2d_1 (MaxPooling2D) │ (None, 7, 7, 64) │ 0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ flatten (Flatten) │ (None, 3136) │ 0 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense (Dense) │ (None, 64) │ 200,768 │
├─────────────────────────────────┼────────────────────────┼───────────────┤
│ dense_1 (Dense) │ (None, 10) │ 650 │
└─────────────────────────────────┴────────────────────────┴───────────────┘
Total params: 220,234 (860.29 KB)
Trainable params: 220,234 (860.29 KB)
Non-trainable params: 0 (0.00 B)

PyTorch has nothing equivalent to that table out of the box. Printing an nn.Sequential gives you the layers you wrote, not the shapes they produce or the parameters they hold, because PyTorch does not know either until a tensor has been through. Keras knows because you declared the input shape.

Where the shapes come from

This is the deeper difference and it explains most of the others. Keras builds a description of the model first and runs it second, so it can compute every intermediate shape and tell you that layer four will receive 3136 features before you have any data. PyTorch defines the model by running it, which is why nn.Linear(64 * 7 * 7, 64) requires you to have done that arithmetic yourself.

Neither is strictly better. Working the shapes out by hand is annoying and it is also the reason a PyTorch model can change its structure per batch, which week 7's variable length sequences quietly relied on.

The functional API

A list of layers cannot express a model that branches or has more than one input. The functional API calls each layer on a tensor and lets you keep references to the intermediate results, which is the same thing a PyTorch forward does with local variables.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
inp = keras.Input((28, 28, 1))
x = layers.Conv2D(16, 3, padding='same', activation='relu')(inp)
x = layers.MaxPooling2D()(x)
x = layers.Flatten()(x)
digit = layers.Dense(10, name='digit')(x)
big = layers.Dense(1, name='is_big')(x)
model = keras.Model(inp, [digit, big])
print('inputs %s' % [t.shape for t in model.inputs])
print('outputs %s' % [t.shape for t in model.outputs])
inputs [(None, 28, 28, 1)]
outputs [(None, 10), (None, 1)]

The shortcut, the residual connection and the two-headed model from week 6 are all written this way. If you find yourself wanting something a Sequential cannot say, this is the next step up rather than a rewrite.

Week 15 · Day 3 of 7

The Training Loop You Do Not Write

compile and fit, and what compile is really doing

By 997 words

Week 1 spent a day writing a training loop by hand and week 2 spent a day making it respectable. Keras replaces the whole of that with two method calls, and it is worth being precise about what you gain and what you give up.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
def build_keras():
return keras.Sequential([
layers.Input((28, 28, 1)),
layers.Conv2D(32, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Conv2D(64, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(10)])
import time
keras.utils.set_random_seed(0)
model = build_keras()
model.compile(optimizer=keras.optimizers.Adam(1e-3),
loss=keras.losses.SparseCategoricalCrossentropy(
from_logits=True),
metrics=['accuracy'])
start = time.perf_counter()
hist = model.fit(xtr, ytr, epochs=5, batch_size=128, verbose=2,
validation_data=(xte, yte))
print()
print('keras took %.1f seconds' % (time.perf_counter() - start))
print('final validation accuracy %.4f'
% hist.history['val_accuracy'][-1])
Epoch 1/5
94/94 - 5s - 48ms/step - accuracy: 0.8140 - loss: 0.6536 - val_accuracy: 0.9250 - val_loss: 0.2761
Epoch 2/5
94/94 - 4s - 39ms/step - accuracy: 0.9540 - loss: 0.1589 - val_accuracy: 0.9555 - val_loss: 0.1538
Epoch 3/5
94/94 - 4s - 40ms/step - accuracy: 0.9732 - loss: 0.0934 - val_accuracy: 0.9630 - val_loss: 0.1117
Epoch 4/5
94/94 - 4s - 40ms/step - accuracy: 0.9812 - loss: 0.0661 - val_accuracy: 0.9680 - val_loss: 0.0929
Epoch 5/5
94/94 - 4s - 40ms/step - accuracy: 0.9866 - loss: 0.0525 - val_accuracy: 0.9705 - val_loss: 0.0823

keras took 19.6 seconds
final validation accuracy 0.9705

Everything week 2 insisted on is in there. The validation split is evaluated every epoch, the metrics are tracked separately for training and validation, the model is switched between training and evaluation mode around the right calls, and the history is kept. None of it was written.

from_logits=True is not optional

The model above ends in a plain Dense(10) with no activation, so its outputs are logits. Week 2 made the case for that, and the loss has to be told. Leave the flag off and Keras takes the logits for probabilities, applies a log to numbers that may be negative, and trains something that is not what you meant. It does not raise anything.

What compile is actually doing

  • Attaching the optimiser, so fit knows what to step.
  • Attaching the loss, and building the metric objects that accumulate across a whole epoch rather than reporting the last batch.
  • On the TensorFlow backend, tracing the training step into a graph, which is why the first epoch above is slower than the rest.

That last point explains a timing result people misread constantly. The first epoch pays for compilation, so a benchmark of one epoch makes Keras look worse than it is, and a benchmark that ignores it makes it look better.

Writing your own layer

A custom layer is the same shape of object as an nn.Module: declare the weights, say what the forward pass does. Keras splits it into build, which runs once when the input shape is known, and call, which is the forward pass. That split is what lets you write a layer without knowing its input width, which PyTorch makes you supply.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
class Scale(keras.layers.Layer):
"""One learned multiplier per feature."""
def build(self, input_shape):
# called the first time the layer sees data, so the width
# is known here and did not have to be passed in
self.w = self.add_weight(shape=(input_shape[-1],),
initializer='ones', trainable=True)

def call(self, x):
return x * self.w

layer = Scale()
out = layer(np.ones((2, 5), dtype='float32'))
print('output %s' % (out.shape,))
print('weights created on first call: %s'
% [tuple(w.shape) for w in layer.weights])
print('trainable: %d' % len(layer.trainable_weights))
output (2, 5)
weights created on first call: [(5,)]
trainable: 1

Where the abstraction stops paying

Everything above holds as long as your training step is the ordinary one. When it is not, you override train_step, and the code inside it is written against a particular backend rather than against Keras.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
import tensorflow as tf

class Noisy(keras.Model):
"""An ordinary model that adds noise to its inputs while training."""
def __init__(self, inner):
super().__init__()
self.inner = inner

def call(self, x, training=False):
return self.inner(x)

def train_step(self, data):
x, y = data
x = x + tf.random.normal(tf.shape(x), stddev=0.1)
with tf.GradientTape() as tape:
loss = self.compute_loss(y=y, y_pred=self(x, training=True))
grads = tape.gradient(loss, self.trainable_variables)
self.optimizer.apply_gradients(zip(grads,
self.trainable_variables))
return {'loss': loss}

keras.utils.set_random_seed(0)
model = Noisy(keras.Sequential([keras.layers.Input((28, 28, 1)),
keras.layers.Flatten(),
keras.layers.Dense(10)]))
model.compile(optimizer='adam',
loss=keras.losses.SparseCategoricalCrossentropy(
from_logits=True))
hist = model.fit(xtr[:2000], ytr[:2000], epochs=2, batch_size=128,
verbose=2)
print()
print('the loop ran, and every line inside train_step was TensorFlow')
Epoch 1/2
16/16 - 0s - 17ms/step - loss: 0.0000e+00
Epoch 2/2
16/16 - 0s - 4ms/step - loss: 0.0000e+00

the loop ran, and every line inside train_step was TensorFlow

A custom train_step is not portable across backends

tf.GradientTape is TensorFlow. On the PyTorch backend the same method is written with loss.backward() and an optimiser step, and on JAX it is a pure function returning gradients. So the moment you need a training step Keras does not already provide, you have picked a backend and the portability that was the reason for Keras 3 is gone.

This is the honest boundary of the abstraction, and it is worth knowing before you build on it rather than after.

Week 15 · Day 4 of 7

Callbacks

The real argument for fit, and the default that undoes it

By 622 words

The real argument for fit is not that it saves you twenty lines. It is callbacks: objects that hook into the loop at defined points, so behaviour you would otherwise hand-roll becomes an argument.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
def build_keras():
return keras.Sequential([
layers.Input((28, 28, 1)),
layers.Conv2D(32, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Conv2D(64, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(10)])
keras.utils.set_random_seed(0)
model = build_keras()
model.compile(optimizer='adam',
loss=keras.losses.SparseCategoricalCrossentropy(
from_logits=True),
metrics=['accuracy'])
cbs = [keras.callbacks.EarlyStopping(monitor='val_loss', patience=2,
restore_best_weights=True),
keras.callbacks.ReduceLROnPlateau(monitor='val_loss', patience=1,
factor=0.5)]
hist = model.fit(xtr, ytr, epochs=20, batch_size=128, verbose=2,
validation_data=(xte, yte), callbacks=cbs)
print()
print('stopped after %d epochs of a possible 20' % len(hist.history['loss']))
print('best validation accuracy %.4f' % max(hist.history['val_accuracy']))
Epoch 1/20
94/94 - 4s - 45ms/step - accuracy: 0.8140 - loss: 0.6536 - val_accuracy: 0.9250 - val_loss: 0.2761 - learning_rate: 0.0010
Epoch 2/20
94/94 - 4s - 42ms/step - accuracy: 0.9540 - loss: 0.1589 - val_accuracy: 0.9555 - val_loss: 0.1538 - learning_rate: 0.0010
Epoch 3/20
94/94 - 4s - 41ms/step - accuracy: 0.9732 - loss: 0.0934 - val_accuracy: 0.9630 - val_loss: 0.1117 - learning_rate: 0.0010
Epoch 4/20
94/94 - 4s - 42ms/step - accuracy: 0.9812 - loss: 0.0661 - val_accuracy: 0.9680 - val_loss: 0.0929 - learning_rate: 0.0010
Epoch 5/20
94/94 - 4s - 42ms/step - accuracy: 0.9866 - loss: 0.0525 - val_accuracy: 0.9705 - val_loss: 0.0823 - learning_rate: 0.0010
Epoch 6/20
94/94 - 4s - 44ms/step - accuracy: 0.9902 - loss: 0.0421 - val_accuracy: 0.9725 - val_loss: 0.0738 - learning_rate: 0.0010
Epoch 7/20
94/94 - 4s - 46ms/step - accuracy: 0.9916 - loss: 0.0329 - val_accuracy: 0.9770 - val_loss: 0.0659 - learning_rate: 0.0010
Epoch 8/20
94/94 - 4s - 45ms/step - accuracy: 0.9932 - loss: 0.0263 - val_accuracy: 0.9770 - val_loss: 0.0631 - learning_rate: 0.0010
Epoch 9/20
94/94 - 4s - 46ms/step - accuracy: 0.9948 - loss: 0.0213 - val_accuracy: 0.9810 - val_loss: 0.0601 - learning_rate: 0.0010
Epoch 10/20
94/94 - 4s - 48ms/step - accuracy: 0.9958 - loss: 0.0159 - val_accuracy: 0.9800 - val_loss: 0.0639 - learning_rate: 0.0010
Epoch 11/20
94/94 - 4s - 47ms/step - accuracy: 0.9964 - loss: 0.0119 - val_accuracy: 0.9775 - val_loss: 0.0653 - learning_rate: 5.0000e-04

stopped after 11 epochs of a possible 20
best validation accuracy 0.9810

Twenty epochs were requested and eleven were run. Validation loss bottomed out at epoch nine, the learning rate was halved at eleven when it had not improved for one epoch, and training stopped when it had not improved for two. The weights returned are the ones from epoch nine, not from epoch eleven.

restore_best_weights defaults to False

Week 2 made this exact point in PyTorch: stopping is the easy half, and keeping the best weights is the half that matters. Keras will happily stop and hand you the weights from the worst of the last three epochs unless you ask otherwise. It is one keyword and it is the whole value of the callback.

The ones worth knowing

CallbackWhat it doesThe PyTorch equivalent
EarlyStoppingStops when a monitored metric stops improvingA patience counter you write
ModelCheckpointSaves every epoch, or only the besttorch.save inside your loop
ReduceLROnPlateauCuts the learning rate on a plateauReduceLROnPlateau, which exists in torch too
CSVLoggerWrites the history to a fileWhatever logging you set up in week 2
TerminateOnNaNStops when the loss goes to NaNThe check week 3 told you to add

There is nothing in that table PyTorch cannot do. The difference is that in Keras they are one argument each and already correct, and in PyTorch they are code you own, which means they are also code you can get subtly wrong. Week 3's warning about a scheduler stepping per batch instead of per epoch is exactly that class of mistake.

Week 15 · Day 5 of 7

The Same Model, Both Ways

Measured side by side, and Keras running on PyTorch

By 563 words

Same architecture, same data, same optimiser and learning rate, same number of epochs, on the same machine. The only thing that differs is the framework.

import torch
from torch import nn
from torch.utils.data import DataLoader, TensorDataset
from torchvision import datasets, transforms

tf_ = transforms.ToTensor()
train_all = datasets.MNIST('data', train=True, download=True, transform=tf_)
test_all = datasets.MNIST('data', train=False, download=True, transform=tf_)

def stack(ds, n):
xs = torch.stack([ds[i][0] for i in range(n)])
ys = torch.tensor([ds[i][1] for i in range(n)])
return xs, ys

xtr, ytr = stack(train_all, 12000)
xte, yte = stack(test_all, 2000)
def build_torch():
return nn.Sequential(
nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
nn.Flatten(),
nn.Linear(64 * 7 * 7, 64), nn.ReLU(),
nn.Linear(64, 10))

def train_torch(epochs=5, batch=128, lr=1e-3, seed=0):
torch.manual_seed(seed)
model = build_torch()
opt = torch.optim.Adam(model.parameters(), lr=lr)
lf = nn.CrossEntropyLoss()
loader = DataLoader(TensorDataset(xtr, ytr), batch_size=batch,
shuffle=True)
for _ in range(epochs):
model.train()
for xb, yb in loader:
opt.zero_grad()
lf(model(xb), yb).backward()
opt.step()
model.eval()
with torch.no_grad():
return model, (model(xte).argmax(1) == yte).float().mean().item()
import time
start = time.perf_counter()
model, acc = train_torch(epochs=5)
print('torch took %.1f seconds' % (time.perf_counter() - start))
print('final validation accuracy %.4f' % acc)
print('%d parameters' % sum(p.numel() for p in model.parameters()))
torch took 16.7 seconds
final validation accuracy 0.9625
220234 parameters

220,234 parameters in both, which is the check that the two models are genuinely the same and not merely similar. PyTorch finished in 16.7 seconds at 0.9625 and Keras in 19.6 at 0.9705.

Do not read either of those numbers as a verdict

The accuracy gap is one run each with different random initialisation, on 2000 test images. Week 3 was blunt about this: an improvement you cannot distinguish from run to run variation is not an improvement, and neither of these was repeated enough times to know. The timing includes Keras compiling its graph on the first epoch. What the comparison genuinely establishes is that the two are in the same place, which is the useful finding.

The same model, written twice

KerasPyTorch
Declare the modelkeras.Sequential([...])nn.Sequential(...)
ShapesInferred from InputYou compute them
Channel orderLastFirst
Attach loss and optimisermodel.compile(...)Objects you hold and call
The loopmodel.fit(...)Roughly fifteen lines you write
Train and eval modeHandledmodel.train() and model.eval(), and week 4 showed what forgetting costs
Custom behaviour mid-loopA callbackAny Python you like, in the loop

Running Keras on PyTorch

Since Keras 3 the two are not exclusive. The same Keras code runs on the PyTorch backend, and what comes back is a torch tensor:

import os
os.environ['KERAS_BACKEND'] = 'torch'
import keras
from keras import layers
import numpy as np
print('keras is now running on %s' % keras.backend.backend())
keras.utils.set_random_seed(0)
model = keras.Sequential([layers.Input((4,)), layers.Dense(3)])
out = model(np.zeros((2, 4), dtype='float32'))
print('a Keras layer returned a %s' % type(out).__name__)
keras is now running on torch
a Keras layer returned a Tensor

This matters more than it looks. A Keras model on the torch backend is an nn.Module, so it can go inside a PyTorch model, be trained by a PyTorch loop, and use PyTorch tooling. The choice stopped being all or nothing.

Week 15 · Day 6 of 7

Saving, Loading and Handing It Over

One file against a state dictionary, and the formats deployment wants

By 447 words

Saving is where the two differ most, and where the difference actually affects how you deploy.

import os
os.environ['KERAS_BACKEND'] = 'tensorflow'
import numpy as np
import keras
from keras import layers

(xtr, ytr), (xte, yte) = keras.datasets.mnist.load_data()
xtr = (xtr[:12000].astype('float32') / 255.0)[..., None]
ytr = ytr[:12000]
xte = (xte[:2000].astype('float32') / 255.0)[..., None]
yte = yte[:2000]
keras.utils.set_random_seed(0)
def build_keras():
return keras.Sequential([
layers.Input((28, 28, 1)),
layers.Conv2D(32, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Conv2D(64, 3, padding='same', activation='relu'),
layers.MaxPooling2D(),
layers.Flatten(),
layers.Dense(64, activation='relu'),
layers.Dense(10)])
import os
keras.utils.set_random_seed(0)
model = build_keras()
model.compile(optimizer='adam',
loss=keras.losses.SparseCategoricalCrossentropy(
from_logits=True))
model.fit(xtr[:1000], ytr[:1000], epochs=1, batch_size=128, verbose=0)
model.save('m.keras')
again = keras.models.load_model('m.keras')
a = model.predict(xte[:64], verbose=0)
b = again.predict(xte[:64], verbose=0)
print('one file, %d KB' % (os.path.getsize('m.keras') // 1024))
print('predictions identical after reload: %s'
% bool(np.allclose(a, b)))
print('no model class was needed to load it')
one file, 2617 KB
predictions identical after reload: True
no model class was needed to load it

That last line is the whole point. Week 2 was careful to say that a PyTorch state_dict is a mapping from names to tensors and nothing else, so loading it requires the class definition to be importable. A .keras file carries the architecture as well, so it can be loaded by code that has never seen your source.

Which is the right trade

It depends who loads it. Inside a codebase you control, the state_dict approach is arguably safer: the architecture lives in version control where you can review changes to it, rather than inside a binary. Handing a model to somebody else, the self describing file is obviously better, and it is the reason so much deployment tooling speaks Keras or ONNX rather than raw PyTorch.

Never load a model file you did not produce

This applies to both frameworks. Loading a serialised model can execute code from the file. torch.load now defaults to weights_only=True for exactly this reason, and Keras will run custom objects a file asks for. Treat a checkpoint from an untrusted source the way you would treat a script from one.

The formats you will meet

  • .keras, architecture and weights in one file, loadable without your source.
  • state_dict, weights only, needs the class.
  • SavedModel, the TensorFlow serving format, which is what TensorFlow Serving and much of the cloud tooling expects.
  • ONNX, a framework-neutral graph that both can export to, and the usual answer when the thing that runs the model is not written in Python. Week 16 uses it.
Week 15 · Day 7 of 7

Choosing, and Reading Both

Where each one wins, and a translation table

By 388 words

Both frameworks train the same model to the same place. The choice is about which problems you would rather have.

Where Keras is the better answer

  • A standard architecture on standard data, where fit and three callbacks are the entire training script.
  • A team where not everybody writes training loops, because the loop is the part that goes subtly wrong.
  • Deployment into TensorFlow tooling, or handing a model to somebody without your codebase.
  • Teaching, where the shape table and the declared input shape remove a class of confusion entirely.

Where PyTorch is the better answer

  • Anything where the training step is the research: the two optimisers of week 13, the two-view loss of week 14, the gradient accumulation of week 3.
  • Control flow that depends on the data, which the eager model makes ordinary Python.
  • Reading and reusing recent published work, which is overwhelmingly PyTorch.
  • Debugging, because a stack trace goes through your own code and there is no traced graph in between.

The honest summary

Keras is a good default for models that look like other models, and it gets less comfortable the further you get from that. PyTorch asks for more code at the start and stops asking for anything unusual later. Almost every difficult week of this course, from the adversarial loop to the contrastive loss, would have been written as a custom training step in Keras, at which point the thing that made Keras attractive is no longer being used.

Reading Keras code when you write PyTorch

KerasPyTorch
layers.Dense(n)nn.Linear(in, n)
layers.Conv2D(c, k, padding='same')nn.Conv2d(in, c, k, padding=k//2)
layers.BatchNormalization()nn.BatchNorm2d(c)
layers.Dropout(r)nn.Dropout(r)
SparseCategoricalCrossentropy(from_logits=True)nn.CrossEntropyLoss()
BinaryCrossentropy(from_logits=True)nn.BCEWithLogitsLoss()
model.fit(...)The loop from week 2
model.predict(x)model.eval() and torch.no_grad()

The row worth memorising is the loss one. Both frameworks have a version that takes logits and a version that takes probabilities, both default in different directions, and getting it wrong produces a model that trains to something plausible and wrong rather than an error.

What to take from the week

You can now read both, and you know which parts of a Keras script are doing work that a PyTorch script makes you write. The framework was never the interesting part of any of the previous fourteen weeks, and being able to move between them is what makes that true rather than merely a nice thought.