HardGAN

GAN Training Loop

Generative Adversarial Networks (GAN)

Hard

Problem

Implement one gradient-descent update of a linear GAN discriminator while holding the real and fake samples fixed. The discriminator probabilities are

p^{\mathrm{real}} = \sigma(X^{\mathrm{real}}W_D)

p^{\mathrm{fake}} = \sigma(X^{\mathrm{fake}}W_D)

The discriminator loss and its weight gradient are

L_D = -\frac{1}{N}\sum_{i=1}^{N}\left[\log p_i^{\mathrm{real}} + \log\left(1-p_i^{\mathrm{fake}}\right)\right]

\nabla_{W_D}L_D = \frac{(X^{\mathrm{real}})^{\mathsf T}(p^{\mathrm{real}}-1) + (X^{\mathrm{fake}})^{\mathsf T}p^{\mathrm{fake}}}{N}

Update the weights with W_D' = W_D - \eta\nabla_{W_D}L_D. Here, N is the shared batch size and \eta is learning_rate. Clip probabilities only when evaluating logarithms. Return a dictionary containing new_discriminator_weights as a float64 NumPy array and discriminator_loss as a Python float computed before the update.

Theory

The GAN training loop is the alternating optimization procedure at the heart of Generative Adversarial Networks (Goodfellow et al., 2014). Each iteration pits two networks -- a discriminator D and a generator G -- against each other: D is updated to better separate real from fake data, then G is updated to better fool D. Repeated over many iterations, this drives both networks toward equilibrium where G produces samples indistinguishable from real data.


What It Is

One iteration of the GAN training loop performs two sequential updates. First, D is trained to maximize its ability to classify real vs. generated data. Then, G is trained to maximize the probability that D misclassifies generated outputs as real. This alternating structure is the defining feature of adversarial training.

D and G have opposing objectives. D wants to assign high scores to real data and low scores to fake data. G wants D to assign high scores to its outputs. Neither network's loss can be minimized in isolation -- the optimal strategy for each depends on the current state of the other. The training loop addresses this by holding one network fixed while updating the other.

In this problem, the discriminator uses a sigmoid linear model: a linear transformation followed by sigmoid activation producing a probability score. The training loop logic generalizes to arbitrarily complex architectures.


Key Equations

Sigmoid Scoring

The discriminator scores input x via a linear transformation and sigmoid:

D(x) = \sigma(W_D \cdot x) = \frac{1}{1 + e^{-W_D \cdot x}}

where W_D is the discriminator weight vector. The output D(x) \in (0, 1) represents the probability that x came from the real data distribution.

Discriminator Loss

\mathcal{L}_D = -\text{mean}\left[\log(D(x_{\text{real}})) + \log(1 - D(G(z)))\right]

Generator Loss

\mathcal{L}_G = -\text{mean}\left[\log(D(G(z)))\right]

This is the "non-saturating" variant from Goodfellow et al. Rather than minimizing \log(1 - D(G(z))) (which saturates when D is confident), G maximizes \log(D(G(z))), providing stronger gradients early in training.

Epsilon Clipping

Both losses involve logarithms of values in (0, 1). When D(x) \approx 0 or D(x) \approx 1, the log approaches -\infty. Epsilon clipping prevents this:

\log(\text{clip}(p, \epsilon, 1 - \epsilon)) \quad \text{where } \epsilon = 10^{-8}

The Minimax Formulation

The full game from the paper:

\min_G \max_D \; \mathbb{E}_{x \sim p_{\text{data}}}[\log D(x)] + \mathbb{E}_{z \sim p_z}[\log(1 - D(G(z)))]

The training loop solves this approximately by alternating: one gradient step on D (ascending), then one on G (descending, using the non-saturating loss).


The Alternating Optimization

Why not train D and G jointly? The minimax structure means D wants to maximize V(D, G) while G wants to minimize it. Joint optimization creates conflicting gradients that cancel out or oscillate, preventing either network from learning.

Alternating optimization decomposes the problem into two cooperative subproblems. Fix G, update D: a standard classification problem. Fix D, update G: a standard generation problem. Each subproblem is well-posed and solvable with gradient descent. This is coordinate descent in function space -- optimizing one "coordinate" (network) while holding the other fixed.

k Steps of D per 1 Step of G

Algorithm 1 in Goodfellow et al. specifies k discriminator steps per single generator step. D must be a reasonably accurate critic before G's gradients become meaningful. However, the paper states: "In practice, we use k = 1, the least expensive option." Training D too many steps risks it becoming too strong, causing G's gradients to vanish. The k = 1 choice means strict alternation -- one update each per iteration.


Step-by-Step Training Procedure

Step 1: Sample Real Batch

Draw m samples \{x^{(1)}, \ldots, x^{(m)}\} from the real data distribution p_{\text{data}}.

Step 2: Sample Noise and Generate Fake Batch

Draw m noise vectors from p_z(z) (typically Gaussian). Pass through G to produce fake samples \{G(z^{(1)}), \ldots, G(z^{(m)})\}.

Step 3: Compute Discriminator Scores

Run both batches through D:

Step 4: Compute \mathcal{L}_D and Update D

\mathcal{L}_D = -\frac{1}{m}\sum_{i=1}^{m}\left[\log(p_{\text{real}}^{(i)}) + \log(1 - p_{\text{fake}}^{(i)})\right]

Compute gradients w.r.t. D's parameters only and apply gradient descent. G's parameters are frozen.

Step 5: Compute \mathcal{L}_G and Update G

\mathcal{L}_G = -\frac{1}{m}\sum_{i=1}^{m}\log(p_{\text{fake}}^{(i)})

Compute gradients w.r.t. G's parameters only and apply gradient descent. D's parameters are frozen.

Step 6: Return Losses

Record \mathcal{L}_D and \mathcal{L}_G for monitoring, rounded to 4 decimal places.


Paper Context

The training loop is Algorithm 1 in "Generative Adversarial Nets" (Goodfellow et al., 2014). The algorithm structure:

The paper recommends SGD with momentum (0.5 initially, increasing to 0.7). It proves that if D reaches its optimum at each step, the procedure minimizes the Jensen-Shannon divergence between real and generated distributions. In practice with k = 1, D does not reach optimality, so training dynamics are approximate.

The characteristic training pattern: early on, D easily separates real from fake. As G improves, D's task becomes harder, and scores converge toward 0.5 for both -- indicating D cannot distinguish them. This D(x) = 0.5 for all x is the theoretical equilibrium.


Why Alternating Works

The GAN objective is a two-player zero-sum game. The solution is a Nash equilibrium: a pair (D^*, G^*) where neither player can improve unilaterally. Finding this requires navigating a saddle point -- maximizing over D while minimizing over G.

Standard gradient descent cannot find saddle points; it follows gradients downhill for all parameters, pushing D and G in conflicting directions. Simultaneous gradient descent/ascent is known to exhibit rotational dynamics, cycling around the equilibrium rather than converging.

Alternating optimization resolves this by fully computing one player's best response before the other moves. When D is updated with G fixed, D moves toward its best response. When G is then updated, it responds to the improved D. This best-response dynamic has better convergence properties than simultaneous updates.

The key requirement is balance: neither player should get too far ahead. If D becomes too strong, G's gradients vanish. If G becomes too strong relative to D, the training signal becomes noisy and unreliable.


Training Stability

D/G Balance

The most critical factor is maintaining balance between D and G. D should be strong enough to provide meaningful gradients to G, but not so strong that it perfectly classifies all fake samples and kills G's gradients.

When D Dominates

D assigns D(G(z)) \approx 0 to all generated samples. The sigmoid saturates near zero, so \nabla_G \log(D(G(z))) vanishes. G receives no useful signal and stops improving -- the vanishing gradient problem.

When G Dominates

G produces outputs that fool D, so D(G(z)) \approx 1. D cannot distinguish real from fake, providing no directional gradient. Worse, G may have found a single mode that fools D -- mode collapse -- producing limited variety regardless of input noise.

Learning Rate Choices

A common practice is using a lower learning rate for G than D, ensuring D stays slightly ahead. The Adam optimizer with \beta_1 = 0.5 (not default 0.9) is widely used because lower momentum reduces oscillatory dynamics in the adversarial setting.

Label Smoothing

Instead of hard labels (1 real, 0 fake), one-sided label smoothing uses soft labels (e.g., 0.9 for real, 0 for fake). This prevents D from becoming overconfident, maintaining gradient flow. Only the real label is smoothed -- smoothing the fake label encourages D to assign non-zero probability to obvious fakes.


Numerical Example

Consider 3 real and 3 fake samples in 2D, with discriminator weights W_D = [1.0, -0.5].

Data

D Scores on Real Data

D Scores on Fake Data

Computing \mathcal{L}_D

\mathcal{L}_D = -\frac{1}{3}\left[\log(0.7941) + \log(0.8858) + \log(0.8250) + \log(0.5744) + \log(0.6682) + \log(0.5250)\right]

= -\frac{1}{3}\left[-0.2306 - 0.1214 - 0.1924 - 0.5546 - 0.4035 - 0.6444\right]

= -\frac{1}{3}(-2.1469) = 0.7156

D's loss is positive, indicating room to improve at separating real from fake.

Computing \mathcal{L}_G

\mathcal{L}_G = -\frac{1}{3}\left[\log(0.4256) + \log(0.3318) + \log(0.4750)\right]

= -\frac{1}{3}\left[-0.8544 - 1.1035 - 0.7445\right] = -\frac{1}{3}(-2.7024) = 0.9008

G's loss is higher than D's, typical early in training when D outperforms G. G needs the fake scores to move toward 1.0.


Pitfalls

Training G and D on the Same Forward Pass

Computing both losses from a single forward pass and backpropagating simultaneously violates alternating optimization. D's optimizer step must complete before computing G's loss. The computation graphs must be separate.

Forgetting to Detach Fake Samples for D Update

When computing \mathcal{L}_D, fake samples G(z) must be treated as fixed data -- D should not backpropagate through G. In PyTorch, call .detach() on the generator output before passing to D, or wrap the generator forward pass in torch.no_grad(). Failing to detach causes D's gradient update to corrupt G's computation graph.

Wrong Alternation Order

Algorithm 1 specifies: update D first, then G. Reversing this means G optimizes against a stale discriminator. D should always be updated first so G receives gradients from the most current critic.

Unbalanced Training Causing Mode Collapse

If G is updated too many times relative to D, it exploits weaknesses in D's decision boundary, collapsing to a narrow set of outputs that reliably fool D. This is mode collapse -- G ignores the diversity of the real distribution. The fix is ensuring D is updated at least as frequently as G.

Vanishing Gradients from the Original Loss

The original formulation has G minimizing \log(1 - D(G(z))). When D is strong, D(G(z)) \approx 0, so \log(1 - D(G(z))) \approx 0 and gradients vanish. The non-saturating alternative -\log(D(G(z))) has gradient proportional to 1/D(G(z)), which grows as D(G(z)) \to 0, providing strong signal precisely when G needs it most.

Forgetting Epsilon Clipping

Computing \log(0) produces -\infty or NaN, corrupting all subsequent gradients. Clipping discriminator outputs to [\epsilon, 1 - \epsilon] before taking logarithms is essential. With \epsilon = 10^{-8}, the effect on valid probabilities is negligible but prevents catastrophic numerical failures at the boundaries.


Examples

Example 1

Input
real_data = [[1,0.5,-0.3],[0.8,-0.2,0.6]], fake_data = [[-0.5,0.3,0.1],[0.2,-0.8,0.4]], D_W = [[0.5],[-0.3],[0.2]], learning_rate = 0.1
Output
{"new_discriminator_weights":[[0.54024],[-0.27506],[0.190175]],"discriminator_loss":1.237087}
Explanation
The real examples contribute probability minus one to the gradient, while fake examples contribute their predicted probability.

Example 2

Input
real_data = [[2,1],[1.5,0.8]], fake_data = [[-2,-1],[-1.5,-0.8]], D_W = [[1],[0.5]], learning_rate = 0.05
Output
{"new_discriminator_weights":[[1.017344],[0.508997]],"discriminator_loss":0.218276}

Example 3

Input
real_data = [[0.3,-0.7]], fake_data = [[0.5,0.2]], D_W = [[0.4],[-0.6]], learning_rate = 0.2
Output
{"new_discriminator_weights":[[0.370092],[-0.672346]],"discriminator_loss":1.19311}

Hints

  1. Compute the real and fake residual columns before multiplying by the transposed data matrices.
  2. Divide the combined gradient by the shared batch size before the update.

Requirements

Constraints

Starter Code

import numpy as np

def train_discriminator_step(
    real_data: np.ndarray,
    fake_data: np.ndarray,
    D_W: np.ndarray,
    learning_rate: float,
) -> dict:
    """
    Returns updated discriminator weights and the pre-update loss.
    """
    pass

Test Cases

CaseMatches
Two-sample updatepublic
Separated samplespublic
Single-sample updatepublic