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
../../../../_images/e47f37965fd21f3025cd503bf892790a37de6ac52e7e24820081e8a97add5b39.png ../../../../_images/5f334abe9d32c4f0c6e7b7338ac06c2e3afb24b8bf00d997f6e111030c81775a.png
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.