DeepDream
What you’ll learn
- how DeepDream runs a pretrained convnet in reverse: instead of adjusting weights to fit an image, it adjusts the image to maximize a layer’s activation
- why this is just gradient ascent instead of gradient descent — you’re climbing toward more activation, not descending toward less loss
- what an octave is, and why processing an image at several scales produces richer, more detailed dreams
- why lower layers give geometric patterns and higher layers give recognizable (dog-eye, bird-feather) shapes
Running a convnet backward
Every convnet you’ve trained so far works one direction: pixels go in, a prediction comes out, and training nudges the weights to make that prediction better. DeepDream asks a different question: what if we freeze the weights, and instead nudge the pixels to make some layer’s activation as large as possible?
That’s it — that’s the whole trick. Pick a pretrained convnet (Chollet’s book uses Inception V3), pick one or more of its layers, and define a loss equal to the mean of the squared activations in those layers. Then run gradient ascent — repeatedly compute the gradient of that loss with respect to the input image, and add a small step in that direction. Each step nudges the image to contain a little more of whatever pattern already excites those layers. Because DeepDream starts from a real photograph instead of random noise, the effect looks like the image’s existing edges and textures are “growing” eyes, feathers, and swirls out of themselves — DeepDream’s convnet was trained on ImageNet, where dogs and birds are hugely overrepresented, so those are the patterns that tend to bubble up.
flowchart LR I["Input image"] --> N["Frozen pretrained convnet"] N --> A["Layer activations (e.g. mixed4, mixed5)"] A --> L["Loss = mean(activations^2)"] L -->|"gradient ascent: image += lr * grad"| I
The loss: maximize activation, not minimize error
In a normal classifier, the loss measures error and you push it down. Here,
the “loss” measures how strongly a layer is firing and you push it up — so
every gradient step is image += learning_rate * gradientimage += learning_rate * gradient, addition instead of
subtraction. Chollet’s version sums a weighted combination of several layers so
the dream mixes several kinds of pattern at once:
import tensorflow as tf
from tensorflow import keras
model = keras.applications.inception_v3.InceptionV3(weights="imagenet", include_top=False)
layer_settings = {
"mixed4": 1.0,
"mixed5": 1.5,
"mixed6": 2.0,
"mixed7": 2.5,
}
outputs_dict = dict(
(layer.name, layer.output) for layer in [model.get_layer(name) for name in layer_settings]
)
feature_extractor = keras.Model(inputs=model.inputs, outputs=outputs_dict)
def compute_loss(input_image):
features = feature_extractor(input_image)
loss = tf.zeros(shape=())
for name in features:
coeff = layer_settings[name]
activation = features[name]
# skip border pixels to avoid edge artifacts
loss += coeff * tf.reduce_mean(tf.square(activation[:, 2:-2, 2:-2, :]))
return loss
@tf.function
def gradient_ascent_step(image, learning_rate):
with tf.GradientTape() as tape:
tape.watch(image)
loss = compute_loss(image)
grads = tf.math.l2_normalize(tape.gradient(loss, image))
return loss, image + learning_rate * gradsimport tensorflow as tf
from tensorflow import keras
model = keras.applications.inception_v3.InceptionV3(weights="imagenet", include_top=False)
layer_settings = {
"mixed4": 1.0,
"mixed5": 1.5,
"mixed6": 2.0,
"mixed7": 2.5,
}
outputs_dict = dict(
(layer.name, layer.output) for layer in [model.get_layer(name) for name in layer_settings]
)
feature_extractor = keras.Model(inputs=model.inputs, outputs=outputs_dict)
def compute_loss(input_image):
features = feature_extractor(input_image)
loss = tf.zeros(shape=())
for name in features:
coeff = layer_settings[name]
activation = features[name]
# skip border pixels to avoid edge artifacts
loss += coeff * tf.reduce_mean(tf.square(activation[:, 2:-2, 2:-2, :]))
return loss
@tf.function
def gradient_ascent_step(image, learning_rate):
with tf.GradientTape() as tape:
tape.watch(image)
loss = compute_loss(image)
grads = tf.math.l2_normalize(tape.gradient(loss, image))
return loss, image + learning_rate * gradsTwo details matter: tape.watch(image)tape.watch(image) is required because imageimage isn’t a
trainable weight — it’s just a tensor, so TensorFlow needs to be told explicitly
to track gradients through it. And the gradient is L2-normalized before being
applied, exactly like the filter-visualization trick from earlier convnet
chapters — this keeps each step a similar size regardless of how large the raw
gradient happens to be.
Verifying the mechanism with plain NumPy
You don’t need a real convnet to see gradient ascent working. Swap Inception’s
layers for a tiny linear “filter bank” WW, define the same
sum-of-squared-activations loss, and the gradient is 2 * Wᵀ(W·image)2 * Wᵀ(W·image) — pure
NumPy, no TensorFlow required, and you can watch the loss climb step by step:
import numpy as np
np.random.seed(0)
n = 8
image = np.random.uniform(-1, 1, size=n) # toy 1D "image"
W = np.random.uniform(-0.5, 0.5, size=(4, n)) # toy "feature detectors"
def compute_loss(img):
activations = W @ img
return np.sum(activations ** 2) # mean/sum of squared activations
def compute_grad(img):
activations = W @ img
return 2 * (W.T @ activations) # gradient of the loss w.r.t. the image
lr = 0.05
img = image.copy()
loss_before = compute_loss(img)
for step in range(10):
grad = compute_grad(img)
grad = grad / (np.sqrt(np.mean(grad ** 2)) + 1e-8) # L2-normalize, like the book
img = img + lr * grad # ASCENT: add, don't subtract
loss_after = compute_loss(img)
print("loss before:", round(loss_before, 4))
print("loss after 10 ascent steps:", round(loss_after, 4))
print("loss increased:", loss_after > loss_before)import numpy as np
np.random.seed(0)
n = 8
image = np.random.uniform(-1, 1, size=n) # toy 1D "image"
W = np.random.uniform(-0.5, 0.5, size=(4, n)) # toy "feature detectors"
def compute_loss(img):
activations = W @ img
return np.sum(activations ** 2) # mean/sum of squared activations
def compute_grad(img):
activations = W @ img
return 2 * (W.T @ activations) # gradient of the loss w.r.t. the image
lr = 0.05
img = image.copy()
loss_before = compute_loss(img)
for step in range(10):
grad = compute_grad(img)
grad = grad / (np.sqrt(np.mean(grad ** 2)) + 1e-8) # L2-normalize, like the book
img = img + lr * grad # ASCENT: add, don't subtract
loss_after = compute_loss(img)
print("loss before:", round(loss_before, 4))
print("loss after 10 ascent steps:", round(loss_after, 4))
print("loss increased:", loss_after > loss_before)loss before: 0.2347
loss after 10 ascent steps: 4.5998
loss increased: Trueloss before: 0.2347
loss after 10 ascent steps: 4.5998
loss increased: TrueTen small steps nearly 20x’d the activation — that’s the entire DeepDream mechanism, minus the convnet and the pretty pixels.
Octaves: processing the image at multiple scales
A single pass of gradient ascent at one resolution tends to produce fairly uniform, small-scale texture. DeepDream instead processes the image at several octaves — progressively larger scales — running a handful of ascent steps at each one, then upscaling and re-injecting the detail that upscaling lost. Smaller scales tend to shape broad composition; larger scales add fine texture on top of it:
original_shape = (400, 300) # (height, width) of the real image
num_octave = 3
octave_scale = 1.4
successive_shapes = [original_shape]
for i in range(1, num_octave):
shape = tuple(int(dim / (octave_scale ** i)) for dim in original_shape)
successive_shapes.append(shape)
successive_shapes = successive_shapes[::-1] # smallest first, so we scale UP
print("octave shapes (smallest to largest):", successive_shapes)original_shape = (400, 300) # (height, width) of the real image
num_octave = 3
octave_scale = 1.4
successive_shapes = [original_shape]
for i in range(1, num_octave):
shape = tuple(int(dim / (octave_scale ** i)) for dim in original_shape)
successive_shapes.append(shape)
successive_shapes = successive_shapes[::-1] # smallest first, so we scale UP
print("octave shapes (smallest to largest):", successive_shapes)octave shapes (smallest to largest): [(204, 153), (285, 214), (400, 300)]octave shapes (smallest to largest): [(204, 153), (285, 214), (400, 300)]The outer loop runs gradient ascent at (204, 153)(204, 153) first, upscales to
(285, 214)(285, 214) and runs more ascent steps, then upscales again to the full
(400, 300)(400, 300) — each upscale re-injects the fine detail that a naive resize would
have blurred away, so the final image stays sharp instead of pixelated.
Visualize it
Watch a small noisy pattern get amplified step by step, exactly like repeated gradient-ascent steps on a real DeepDream image — with each “octave” boundary (dashed line) marking an upscale, after which amplification continues at a larger effective scale:
Mini-checkpoint
Why does DeepDream start from a real photograph instead of random noise, unlike the filter-visualization technique it’s built on?
- Starting from a real image means the gradient-ascent process has existing edges, textures, and shapes to latch onto and amplify — the resulting dream distorts and exaggerates elements that were already faintly there, which is what gives DeepDream its “hallucinating on top of reality” look, rather than generating an abstract pattern from scratch.
🧪 Try It Yourself
Exercise 1 – Compute the DeepDream-Style Loss
Exercise 2 – Take a Gradient Ascent Step
Exercise 3 – Build the Octave Shape Sequence
Next
Continue to Neural Style Transfer — instead of amplifying one image’s own features, blend the content of one image with the style of another by optimizing a combined loss.
If this helped you, consider buying me a coffee ☕
Buy me a coffeeWas this page helpful?
Let us know how we did
