MNIST DCGAN with Keras 3 — PyTorch Backend#
This complete workflow modernizes the archived DCGAN. A fixed 100-dimensional noise panel
tracks the same generated samples across training. Full execution uses 50
epochs and batch size 128. Reduce EPOCHS to 1 or 2 for a quicker run.
Losses and selected images are diagnostics, not proof that the generator learned the complete data distribution.
Runtime dependency check#
import importlib.util
import subprocess
import sys
from pathlib import Path
REQUIRED_RUNTIME = {'keras': 'keras', 'torch': 'torch'}
COLAB_EXTRAS = {}
missing_required = [
package for module, package in REQUIRED_RUNTIME.items()
if importlib.util.find_spec(module) is None
]
missing_extras = [
package for module, package in COLAB_EXTRAS.items()
if importlib.util.find_spec(module) is None
]
if missing_extras and "google.colab" in sys.modules:
subprocess.check_call(
[sys.executable, "-m", "pip", "install", "-q", *missing_extras]
)
missing_extras = []
if missing_required or missing_extras:
missing = ", ".join(missing_required + missing_extras)
raise RuntimeError(
f"Missing notebook dependencies: {missing}. Locally run "
"`uv sync --group notebooks`; in Colab restart the runtime if an "
"installation cell just changed the environment."
)
print("runtime dependency check passed")
runtime dependency check passed
%matplotlib inline
Keras-to-PyTorch crosswalk#
This optional implementation uses the same Torch runtime as the canonical
native PyTorch path. Keras compile() selects the optimizer and loss,
fit() owns the explicit batch and epoch loop, and callbacks provide
high-level training control. Data, splits, budgets, evidence, and scientific
conclusions remain aligned with the canonical PyTorch workflow.
Imports, data, and fixed seeds#
import hashlib
import json
import os
os.environ["KERAS_BACKEND"] = "torch"
import keras
import matplotlib.pyplot as plt
import numpy as np
import torch
from keras import layers
from sklearn.metrics import (
ConfusionMatrixDisplay,
accuracy_score,
classification_report,
confusion_matrix,
)
from sklearn.model_selection import train_test_split
assert keras.backend.backend() == "torch"
print(
f"Keras {keras.__version__}; backend={keras.backend.backend()}; "
f"PyTorch {torch.__version__}"
)
SEED = 42
EPOCHS = 50 # Reduce to 1 or 2 for a quicker run.
BATCH_SIZE = 128
LATENT_DIM = 100
LABEL_SMOOTHING = 0.0
keras.utils.set_random_seed(SEED)
(x_train, _), _ = keras.datasets.mnist.load_data()
x_train = (x_train.astype("float32") - 127.5) / 127.5
x_train = x_train[..., np.newaxis]
dataset = torch.utils.data.DataLoader(
torch.utils.data.TensorDataset(torch.from_numpy(x_train)),
batch_size=BATCH_SIZE,
shuffle=True,
drop_last=True,
generator=torch.Generator().manual_seed(SEED),
)
fixed_noise = keras.random.normal((16, LATENT_DIM), seed=SEED)
Keras 3.15.1; backend=torch; PyTorch 2.13.0
Define generator and discriminator#
generator = keras.Sequential(
[
keras.Input(shape=(LATENT_DIM,)),
layers.Dense(7 * 7 * 256, use_bias=False),
layers.BatchNormalization(),
layers.LeakyReLU(negative_slope=0.2),
layers.Reshape((7, 7, 256)),
layers.Conv2DTranspose(128, 5, padding="same", use_bias=False),
layers.BatchNormalization(),
layers.LeakyReLU(negative_slope=0.2),
layers.Conv2DTranspose(64, 5, strides=2, padding="same", use_bias=False),
layers.BatchNormalization(),
layers.LeakyReLU(negative_slope=0.2),
layers.Conv2DTranspose(1, 5, strides=2, padding="same", activation="tanh"),
],
name="generator",
)
discriminator = keras.Sequential(
[
keras.Input(shape=(28, 28, 1)),
layers.Conv2D(64, 5, strides=2, padding="same"),
layers.LeakyReLU(negative_slope=0.2),
layers.Dropout(0.3),
layers.Conv2D(128, 5, strides=2, padding="same"),
layers.LeakyReLU(negative_slope=0.2),
layers.Dropout(0.3),
layers.Flatten(),
layers.Dense(1),
],
name="discriminator",
)
class AdversarialModel(keras.Model):
def __init__(self, generator, discriminator, latent_dim, label_smoothing):
super().__init__()
self.generator = generator
self.discriminator = discriminator
self.latent_dim = latent_dim
self.label_smoothing = label_smoothing
self.seed_generator = keras.random.SeedGenerator(SEED)
self.generator_loss_tracker = keras.metrics.Mean(name="generator_loss")
self.discriminator_loss_tracker = keras.metrics.Mean(name="discriminator_loss")
self.built = True
@property
def metrics(self):
return [self.generator_loss_tracker, self.discriminator_loss_tracker]
def compile(self, generator_optimizer, discriminator_optimizer, loss_function):
super().compile()
self.generator_optimizer = generator_optimizer
self.discriminator_optimizer = discriminator_optimizer
self.loss_function = loss_function
def train_step(self, real_images):
if isinstance(real_images, (tuple, list)):
real_images = real_images[0]
batch_size = real_images.shape[0]
noise = keras.random.normal(
(batch_size, self.latent_dim), seed=self.seed_generator
)
generated_images = self.generator(noise, training=True)
self.zero_grad()
real_logits = self.discriminator(real_images, training=True)
generated_logits = self.discriminator(generated_images.detach(), training=True)
real_targets = torch.ones_like(real_logits) * (1.0 - self.label_smoothing)
discriminator_loss = self.loss_function(real_targets, real_logits) + self.loss_function(
torch.zeros_like(generated_logits), generated_logits
)
discriminator_loss.backward()
discriminator_weights = list(self.discriminator.trainable_weights)
discriminator_gradients = [weight.value.grad for weight in discriminator_weights]
with torch.no_grad():
self.discriminator_optimizer.apply(
discriminator_gradients, discriminator_weights
)
noise = keras.random.normal(
(batch_size, self.latent_dim), seed=self.seed_generator
)
self.zero_grad()
generated_logits = self.discriminator(
self.generator(noise, training=True), training=True
)
generator_loss = self.loss_function(
torch.ones_like(generated_logits), generated_logits
)
generator_loss.backward()
generator_weights = list(self.generator.trainable_weights)
generator_gradients = [weight.value.grad for weight in generator_weights]
with torch.no_grad():
self.generator_optimizer.apply(generator_gradients, generator_weights)
self.generator_loss_tracker.update_state(generator_loss)
self.discriminator_loss_tracker.update_state(discriminator_loss)
return {
"generator_loss": self.generator_loss_tracker.result(),
"discriminator_loss": self.discriminator_loss_tracker.result(),
}
gan = AdversarialModel(generator, discriminator, LATENT_DIM, LABEL_SMOOTHING)
gan.compile(
generator_optimizer=keras.optimizers.Adam(1e-4),
discriminator_optimizer=keras.optimizers.Adam(1e-4),
loss_function=keras.losses.BinaryCrossentropy(from_logits=True),
)
Train adversarially through the high-level fit workflow#
history = gan.fit(dataset, epochs=EPOCHS, verbose=2, shuffle=False)
generator_losses = [float(value) for value in history.history["generator_loss"]]
discriminator_losses = [
float(value) for value in history.history["discriminator_loss"]
]
Epoch 1/50
468/468 - 45s - 96ms/step - discriminator_loss: 0.9501 - generator_loss: 1.1166
Epoch 2/50
468/468 - 45s - 97ms/step - discriminator_loss: 1.1429 - generator_loss: 1.1004
Epoch 3/50
468/468 - 47s - 100ms/step - discriminator_loss: 1.2701 - generator_loss: 0.8998
Epoch 4/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2378 - generator_loss: 0.9660
Epoch 5/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2050 - generator_loss: 0.9548
Epoch 6/50
468/468 - 45s - 96ms/step - discriminator_loss: 1.1235 - generator_loss: 1.0585
Epoch 7/50
468/468 - 45s - 96ms/step - discriminator_loss: 1.0826 - generator_loss: 1.1477
Epoch 8/50
468/468 - 44s - 95ms/step - discriminator_loss: 1.0668 - generator_loss: 1.1641
Epoch 9/50
468/468 - 45s - 96ms/step - discriminator_loss: 1.0830 - generator_loss: 1.1802
Epoch 10/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.1113 - generator_loss: 1.1390
Epoch 11/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.1729 - generator_loss: 1.0382
Epoch 12/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2009 - generator_loss: 1.0575
Epoch 13/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.1905 - generator_loss: 0.9940
Epoch 14/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2168 - generator_loss: 0.9711
Epoch 15/50
468/468 - 45s - 95ms/step - discriminator_loss: 1.2158 - generator_loss: 0.9938
Epoch 16/50
468/468 - 45s - 96ms/step - discriminator_loss: 1.2122 - generator_loss: 0.9559
Epoch 17/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2222 - generator_loss: 0.9594
Epoch 18/50
468/468 - 45s - 96ms/step - discriminator_loss: 1.2281 - generator_loss: 0.9420
Epoch 19/50
468/468 - 45s - 97ms/step - discriminator_loss: 1.2329 - generator_loss: 0.9398
Epoch 20/50
468/468 - 47s - 99ms/step - discriminator_loss: 1.2328 - generator_loss: 0.9307
Epoch 21/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2263 - generator_loss: 0.9443
Epoch 22/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2356 - generator_loss: 0.9306
Epoch 23/50
468/468 - 47s - 100ms/step - discriminator_loss: 1.2343 - generator_loss: 0.9165
Epoch 24/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2293 - generator_loss: 0.9540
Epoch 25/50
468/468 - 47s - 100ms/step - discriminator_loss: 1.2308 - generator_loss: 0.9266
Epoch 26/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2399 - generator_loss: 0.9538
Epoch 27/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2286 - generator_loss: 0.9429
Epoch 28/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2335 - generator_loss: 0.9464
Epoch 29/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2244 - generator_loss: 0.9469
Epoch 30/50
468/468 - 45s - 95ms/step - discriminator_loss: 1.2358 - generator_loss: 0.9210
Epoch 31/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2262 - generator_loss: 0.9002
Epoch 32/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2387 - generator_loss: 0.9045
Epoch 33/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2335 - generator_loss: 0.9394
Epoch 34/50
468/468 - 45s - 97ms/step - discriminator_loss: 1.2282 - generator_loss: 0.9861
Epoch 35/50
468/468 - 46s - 98ms/step - discriminator_loss: 1.2346 - generator_loss: 0.9477
Epoch 36/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2329 - generator_loss: 0.9341
Epoch 37/50
468/468 - 45s - 97ms/step - discriminator_loss: 1.2338 - generator_loss: 0.9383
Epoch 38/50
468/468 - 45s - 96ms/step - discriminator_loss: 1.2388 - generator_loss: 0.9175
Epoch 39/50
468/468 - 46s - 99ms/step - discriminator_loss: 1.2406 - generator_loss: 0.9118
Epoch 40/50
468/468 - 44s - 95ms/step - discriminator_loss: 1.2353 - generator_loss: 0.9326
Epoch 41/50
468/468 - 50s - 106ms/step - discriminator_loss: 1.2468 - generator_loss: 0.9037
Epoch 42/50
468/468 - 51s - 109ms/step - discriminator_loss: 1.2487 - generator_loss: 0.9067
Epoch 43/50
468/468 - 51s - 108ms/step - discriminator_loss: 1.2423 - generator_loss: 0.9620
Epoch 44/50
468/468 - 50s - 108ms/step - discriminator_loss: 1.2402 - generator_loss: 0.9436
Epoch 45/50
468/468 - 50s - 108ms/step - discriminator_loss: 1.2401 - generator_loss: 0.9409
Epoch 46/50
468/468 - 51s - 108ms/step - discriminator_loss: 1.2474 - generator_loss: 0.9201
Epoch 47/50
468/468 - 51s - 108ms/step - discriminator_loss: 1.2504 - generator_loss: 0.9023
Epoch 48/50
468/468 - 50s - 106ms/step - discriminator_loss: 1.2489 - generator_loss: 0.8945
Epoch 49/50
468/468 - 49s - 105ms/step - discriminator_loss: 1.2560 - generator_loss: 0.9010
Epoch 50/50
468/468 - 49s - 105ms/step - discriminator_loss: 1.2482 - generator_loss: 0.9313
Fixed-noise evidence#
generated = generator(fixed_noise, training=False).detach().cpu().numpy()
fig, axes = plt.subplots(4, 4, figsize=(6, 6))
for axis, image in zip(axes.flat, generated):
axis.imshow(image.squeeze(), cmap="gray", vmin=-1, vmax=1)
axis.axis("off")
plt.suptitle("Fixed-noise generated samples")
plt.tight_layout()
plt.show()
fig, ax = plt.subplots(figsize=(7, 3.5))
ax.plot(generator_losses, label="generator")
ax.plot(discriminator_losses, label="discriminator")
ax.set(title="Adversarial training losses", xlabel="Epoch", ylabel="Loss")
ax.legend()
plt.show()
pixel_diversity = float(generated.std(axis=0).mean())
print(f"mean per-pixel sample standard deviation: {pixel_diversity:.4f}")
print(
"HELIO_RESULT "
+ json.dumps(
{
"epochs": EPOCHS,
"generated_shape": list(generated.shape),
"generator_loss": generator_losses[-1],
"discriminator_loss": discriminator_losses[-1],
"pixel_diversity": pixel_diversity,
},
sort_keys=True,
)
)
assert generated.shape == (16, 28, 28, 1)
assert np.isfinite(generated).all()
assert pixel_diversity > 0
mean per-pixel sample standard deviation: 0.3466
HELIO_RESULT {"discriminator_loss": 1.2481979131698608, "epochs": 50, "generated_shape": [16, 28, 28, 1], "generator_loss": 0.931276798248291, "pixel_diversity": 0.34664812684059143}
Try it yourself in Keras#
Change one choice at a time and keep the data split and evaluation unchanged:
try discriminator label smoothing of
0.1.vary the latent dimension while keeping the fixed noise samples.
save the fixed-noise grid at several points during training.