Nobody trains an image model from random weights any more, unless they have millions of labelled examples. They take a network trained on something large and reuse its early layers, because the features those layers learned, edges, corners, textures, are not specific to the task it was trained on.
Transfer learning: Take a model trained on a large dataset, keep its learned feature extractor, and train only a new output layer on your own smaller dataset. Optionally unfreeze the top of the extractor afterwards and continue at a much lower learning rate.
Why this page pretrains its own base
The usual demonstration downloads ImageNet weights for MobileNetV2 or ResNet50, and in your own work that is exactly what you should do, one line, tf.keras.applications.MobileNetV2(weights='imagenet', include_top=False). It needs a network connection, so instead this page pretrains a small base on digits 0 to 4 and transfers it to digits 5 to 9. The mechanics are identical, the numbers are real, and they reproduce offline.
Step 1: train the base on the source task
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import numpy as np
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
d = load_digits()
X = (d.images / 16.0).astype('float32')[..., np.newaxis]
y = d.target.astype('int32')
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
src = y_tr < 5
tf.random.set_seed(42)
base = tf.keras.Sequential([
tf.keras.layers.Input(shape=(8, 8, 1)),
tf.keras.layers.Conv2D(16, 3, padding='same', activation='relu'),
tf.keras.layers.Conv2D(32, 3, padding='same', activation='relu'),
tf.keras.layers.MaxPooling2D(2),
], name='features')
source = tf.keras.Sequential([base,
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(5, activation='softmax')])
source.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
source.fit(X_tr[src], y_tr[src], epochs=40, batch_size=64, verbose=0)
mask = y_te < 5
print('source task (digits 0-4) test accuracy %.4f'
% source.evaluate(X_te[mask], y_te[mask], verbose=0)[1])
base.save_weights('digit_base.weights.h5')
print('feature extractor saved, %d parameters' % base.count_params())
source task (digits 0-4) test accuracy 0.9956
feature extractor saved, 4800 parameters
Step 2: transfer it, with very few labels
The target task is digits 5 to 9, relabelled 0 to 4, with fifteen training examples of each. Two models: one starting from random weights, one reusing the frozen base. Three seeds each, because a seventy-five-row training set produces enough run-to-run variation to show whatever you were hoping to see.
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import numpy as np
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
d = load_digits()
X = (d.images / 16.0).astype('float32')[..., np.newaxis]
y = d.target.astype('int32')
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
def make_base():
return tf.keras.Sequential([
tf.keras.layers.Input(shape=(8, 8, 1)),
tf.keras.layers.Conv2D(16, 3, padding='same', activation='relu'),
tf.keras.layers.Conv2D(32, 3, padding='same', activation='relu'),
tf.keras.layers.MaxPooling2D(2),
], name='features')
def head(base, trainable):
base.trainable = trainable
m = tf.keras.Sequential([base, tf.keras.layers.Flatten(),
tf.keras.layers.Dense(5, activation='softmax')])
m.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
return m
src = y_tr < 5
tgt = np.where(y_tr >= 5)[0]
mask = y_te >= 5
Xv, yv = X_te[mask], y_te[mask] - 5
scratch_runs, frozen_runs = [], []
for seed in range(3):
rng = np.random.default_rng(seed)
few = np.concatenate([rng.permutation(tgt[y_tr[tgt] == c])[:15]
for c in range(5, 10)])
Xf, yf = X_tr[few], y_tr[few] - 5
tf.random.set_seed(100 + seed)
pre = make_base()
head(pre, True).fit(X_tr[src], y_tr[src], epochs=40, batch_size=64,
verbose=0)
tf.random.set_seed(seed)
a = head(make_base(), True)
a.fit(Xf, yf, epochs=60, batch_size=16, verbose=0)
scratch_runs.append(a.evaluate(Xv, yv, verbose=0)[1])
tf.random.set_seed(seed)
b = head(pre, False)
b.fit(Xf, yf, epochs=60, batch_size=16, verbose=0)
frozen_runs.append(b.evaluate(Xv, yv, verbose=0)[1])
print('target training examples: 75')
print('%-28s %9s %18s' % ('', 'mean', 'runs'))
for name, runs in [('from scratch', scratch_runs),
('frozen pretrained features', frozen_runs)]:
print('%-28s %9.4f %18s'
% (name, float(np.mean(runs)),
' '.join('%.3f' % r for r in runs)))
print('\ntrainable parameters: scratch %d, frozen %d'
% (sum(int(np.prod(w.shape)) for w in a.trainable_weights),
sum(int(np.prod(w.shape)) for w in b.trainable_weights)))
target training examples: 75
mean runs
from scratch 0.9509 0.955 0.960 0.938
frozen pretrained features 0.9435 0.960 0.946 0.924
trainable parameters: scratch 7365, frozen 2565
Transfer learning lost, and the honest thing is to say so
Every textbook example of this has the pretrained model winning comfortably. Here it is behind on average, and it wins one run of three, which is to say the pretrained features bought nothing detectable. That is not a bug in the code above: the mechanics are exactly what you would write against ImageNet weights. It is a fact about the source task, and it is worth more than a rigged win.
Why it lost
Transfer only helps when the features the source task learned are better than the features the target's own data can learn. The source here is 675 images of five digit classes. That is not a lot of world knowledge to bring. Growing it should narrow the gap, and it does.
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import numpy as np
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
d = load_digits()
X = (d.images / 16.0).astype('float32')[..., np.newaxis]
y = d.target.astype('int32')
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
def make_base():
return tf.keras.Sequential([
tf.keras.layers.Input(shape=(8, 8, 1)),
tf.keras.layers.Conv2D(16, 3, padding='same', activation='relu'),
tf.keras.layers.Conv2D(32, 3, padding='same', activation='relu'),
tf.keras.layers.MaxPooling2D(2),
], name='features')
def head(base, trainable):
base.trainable = trainable
m = tf.keras.Sequential([base, tf.keras.layers.Flatten(),
tf.keras.layers.Dense(5, activation='softmax')])
m.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
return m
src = np.where(y_tr < 5)[0]
tgt = np.where(y_tr >= 5)[0]
mask = y_te >= 5
Xv, yv = X_te[mask], y_te[mask] - 5
print('%15s %12s' % ('source images', 'frozen acc'))
for n_src in [50, 150, 400, len(src)]:
runs = []
for seed in range(3):
rng = np.random.default_rng(seed)
few = np.concatenate([rng.permutation(tgt[y_tr[tgt] == c])[:15]
for c in range(5, 10)])
tf.random.set_seed(100 + seed)
pre = make_base()
head(pre, True).fit(X_tr[rng.permutation(src)[:n_src]],
y_tr[rng.permutation(src)[:n_src]],
epochs=40, batch_size=64, verbose=0)
tf.random.set_seed(seed)
m = head(pre, False)
m.fit(X_tr[few], y_tr[few] - 5, epochs=60, batch_size=16, verbose=0)
runs.append(m.evaluate(Xv, yv, verbose=0)[1])
print('%15d %12.4f' % (n_src, float(np.mean(runs))))
source images frozen acc
50 0.9196
150 0.9271
400 0.9360
675 0.9390
This is the precondition, stated as a number
The frozen features get steadily better as the source task grows, which is the mechanism working exactly as advertised. It simply has not got far enough by 675 images to beat a network trained directly on the target. Now extrapolate: ImageNet is 1.2 million images across a thousand classes. That is why transfer learning is the default in real work, and why this page could not demonstrate it honestly without a network connection.
The practical rule: transfer when your source is far larger and richer than your target. When it is not, you are just borrowing somebody else's constraints.
Step 3: fine-tuning
Once the new head has stopped being random, you can unfreeze the base and continue at a learning rate one to two orders of magnitude smaller. The order matters: unfreeze while the head is still random and the large gradients coming back from it will destroy the pretrained weights before they are ever used.
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import numpy as np
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
d = load_digits()
X = (d.images / 16.0).astype('float32')[..., np.newaxis]
y = d.target.astype('int32')
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
def make_base():
return tf.keras.Sequential([
tf.keras.layers.Input(shape=(8, 8, 1)),
tf.keras.layers.Conv2D(16, 3, padding='same', activation='relu'),
tf.keras.layers.Conv2D(32, 3, padding='same', activation='relu'),
tf.keras.layers.MaxPooling2D(2),
], name='features')
tf.random.set_seed(42)
base = make_base()
src = y_tr < 5
warm = tf.keras.Sequential([base, tf.keras.layers.Flatten(),
tf.keras.layers.Dense(5, activation='softmax')])
warm.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
warm.fit(X_tr[src], y_tr[src], epochs=40, batch_size=64, verbose=0)
rng = np.random.default_rng(0)
tgt = np.where(y_tr >= 5)[0]
few = np.concatenate([rng.permutation(tgt[y_tr[tgt] == c])[:15]
for c in range(5, 10)])
Xf, yf = X_tr[few], y_tr[few] - 5
mask = y_te >= 5
Xv, yv = X_te[mask], y_te[mask] - 5
tf.random.set_seed(7)
base.trainable = False
model = tf.keras.Sequential([base, tf.keras.layers.Flatten(),
tf.keras.layers.Dense(5, activation='softmax')])
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
model.fit(Xf, yf, epochs=60, batch_size=16, verbose=0)
print('after training the head only %.4f'
% model.evaluate(Xv, yv, verbose=0)[1])
before = model.predict(Xv, verbose=0).argmax(1)
kernel_before = base.get_weights()[0].copy()
base.trainable = True
model.compile(optimizer=tf.keras.optimizers.Adam(1e-4),
loss='sparse_categorical_crossentropy', metrics=['accuracy'])
model.fit(Xf, yf, epochs=40, batch_size=16, verbose=0)
print('after fine-tuning at 1e-4 %.4f'
% model.evaluate(Xv, yv, verbose=0)[1])
after = model.predict(Xv, verbose=0).argmax(1)
print('\npredictions that changed: %d of %d' % ((before != after).sum(),
len(yv)))
print('largest weight change in the base: %.6f'
% np.abs(base.get_weights()[0] - kernel_before).max())
after training the head only 0.9509
after fine-tuning at 1e-4 0.9554
predictions that changed: 1 of 224
largest weight change in the base: 0.016407
Fine-tuning at 1e-4 for forty epochs on seventy-five images is a nudge, not a retraining. The largest weight in the base moved by about a hundredth, and exactly one test prediction changed. That is the intended behaviour: if unfreezing moves your accuracy dramatically, the rate is too high and you are erasing the thing you came for.
Recompile after changing trainable
Keras decides which weights the optimiser will touch when you compile. Flip base.trainable and skip the recompile and nothing changes, the model trains exactly as it did before, and you spend an afternoon wondering why fine-tuning does nothing.
What the first layer actually learned
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
import numpy as np
import tensorflow as tf
tf.random.set_seed(42)
tf.get_logger().setLevel('ERROR')
import numpy as np
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
d = load_digits()
X = (d.images / 16.0).astype('float32')[..., np.newaxis]
y = d.target.astype('int32')
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.25,
stratify=y, random_state=42)
tf.random.set_seed(42)
m = tf.keras.Sequential([
tf.keras.layers.Input(shape=(8, 8, 1)),
tf.keras.layers.Conv2D(8, 3, padding='same', activation='relu',
name='first'),
tf.keras.layers.MaxPooling2D(2),
tf.keras.layers.Flatten(),
tf.keras.layers.Dense(10, activation='softmax'),
])
m.compile(optimizer='adam', loss='sparse_categorical_crossentropy',
metrics=['accuracy'])
m.fit(X_tr, y_tr, epochs=40, batch_size=64, verbose=0)
k = m.get_layer('first').get_weights()[0]
print('four of the eight learned 3x3 kernels:')
for f in range(4):
w = k[:, :, 0, f]
print('\nfilter %d (sum %+.2f)' % (f, w.sum()))
for row in w:
print(' ' + ' '.join('%+.2f' % v for v in row))
four of the eight learned 3x3 kernels:
filter 0 (sum +0.11)
+0.62 +0.57 +0.47
+0.28 -0.01 +0.18
-0.53 -0.82 -0.65
filter 1 (sum -0.09)
+0.43 -0.57 +0.52
-0.60 -0.69 +0.23
-0.12 +0.60 +0.13
filter 2 (sum +0.72)
+0.62 -0.48 -0.84
+0.42 +0.14 -0.24
+0.09 +0.76 +0.25
filter 3 (sum +1.59)
+0.26 +0.55 +0.35
+0.53 +0.47 +0.32
+0.22 -0.44 -0.67
Read the signs rather than the magnitudes. A filter with negatives on one side and positives on the other is an edge detector oriented along that axis; one that is positive in the middle and negative around it is a blob detector. Nobody told the network to learn these. They are what minimising the loss produced, and they are the reason the features transfer to a task the base never saw.
Day 5 takeaway
Reuse a pretrained feature extractor whenever you have fewer than tens of thousands of labelled images and a genuinely large source model to borrow from. That second condition is the one this page could not meet, and the one most tutorials never mention. Train the new head with the base frozen, then optionally unfreeze at a much lower rate, recompiling after every change to trainable.