Replaced Token Detection › The Complete Training Loop

Michael BrenndoerferUpdated July 12, 20255 min read
Language AI HandbookAll chapters
Part 22 of 67Pre-training Objectives
Chapter 160Replaced Token Detection › The Complete Training Loop
Reading section 8 of 17 · The Complete Training Loop · 5 minSection 8 of 17 · 5 min

Book contents

Section 8 of 17 · 5 min read

Replaced Token Detection › The Complete Training LoopOne section · Sections in this chapter8 / 17 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Training RTD involves three steps per batch: mask inputs, generate replacements, and update both networks. It is worth pausing to appreciate how all the pieces fit together before seeing the code. The full loop has a specific order that matters: you must complete the generator forward pass and the sampling step before starting the discriminator forward pass, because the discriminator's input depends on the generator's output. This sequential dependency means you cannot parallelize the two forward passes. However, the two backward passes (one through the generator loss, one through the discriminator loss) are independent and can both be computed from the single combined loss through standard backpropagation.

One practical implementation note: the sampling step uses torch.no_grad() or explicit detachment to prevent gradients from flowing from the discriminator's loss back through the sampling operation into the generator. As mentioned earlier, this sampling step is non-differentiable. If you attempt to backpropagate through it without blocking gradients, PyTorch will raise an error or silently produce incorrect gradients. The generator learns only from its own MLM cross-entropy loss.

In[21]:
Code
def compute_rtd_loss(
    generator,
    discriminator,
    token_ids,
    mask_token_id,
    mask_prob=0.15,
    disc_weight=50.0,
):
    """Compute combined RTD loss for generator and discriminator."""

    # Step 1: Mask tokens
    masked_ids, gen_labels, mask_positions = create_masked_input(
        token_ids, mask_token_id, mask_prob
    )

    # Step 2: Generator forward pass and loss
    gen_logits = generator(masked_ids)
    gen_loss = F.cross_entropy(
        gen_logits.view(-1, gen_logits.size(-1)),
        gen_labels.view(-1),
        ignore_index=-100,
    )

    # Step 3: Sample replacements (detach to not backprop through sampling)
    with torch.no_grad():
        gen_probs = F.softmax(gen_logits, dim=-1)

    replaced_ids = masked_ids.clone()
    disc_labels = torch.ones_like(token_ids, dtype=torch.float)  # 1 = original

    for batch_idx in range(token_ids.size(0)):
        for pos in range(token_ids.size(1)):
            if mask_positions[batch_idx, pos]:
                # Sample from generator
                sampled = torch.multinomial(
                    gen_probs[batch_idx, pos], num_samples=1
                )
                replaced_ids[batch_idx, pos] = sampled
                # Label: 0 if replaced with different token, 1 if same (or original)
                if sampled != token_ids[batch_idx, pos]:
                    disc_labels[batch_idx, pos] = 0.0

    # Step 4: Discriminator forward pass and loss
    disc_logits = discriminator(replaced_ids)
    disc_loss = F.binary_cross_entropy_with_logits(disc_logits, disc_labels)

    # Combined loss
    total_loss = gen_loss + disc_weight * disc_loss

    return total_loss, gen_loss, disc_loss

Let's train on a small corpus to see the dynamics:

In[22]:
Code
# Create a small training corpus
corpus_tokens = []
sentences = [
    ["the", "chef", "cooked", "the", "meal"],
    ["the", "waiter", "served", "the", "food"],
    ["the", "chef", "ate", "the", "food"],
    ["a", "waiter", "cooked", "a", "meal"],
]

for sent in sentences:
    corpus_tokens.append([word_to_idx[w] for w in sent])

corpus_tensor = torch.tensor(corpus_tokens)

# Initialize models
generator = RTDGenerator(vocab_size, d_model=32, n_heads=2, n_layers=1)
discriminator = RTDDiscriminator(vocab_size, d_model=48, n_heads=2, n_layers=2)

# Optimizers
gen_optimizer = torch.optim.AdamW(generator.parameters(), lr=1e-3)
disc_optimizer = torch.optim.AdamW(discriminator.parameters(), lr=1e-3)

# Training loop
gen_losses = []
disc_losses = []

for step in range(200):
    # Sample a batch
    batch_idx = torch.randint(0, len(corpus_tensor), (2,))
    batch = corpus_tensor[batch_idx]

    # Compute loss
    total_loss, gen_loss, disc_loss = compute_rtd_loss(
        generator, discriminator, batch, mask_token_id, mask_prob=0.3
    )

    # Update both models
    gen_optimizer.zero_grad()
    disc_optimizer.zero_grad()
    total_loss.backward()
    gen_optimizer.step()
    disc_optimizer.step()

    gen_losses.append(gen_loss.item())
    disc_losses.append(disc_loss.item())
Out[23]:
Console
Initial generator loss: 2.0001
Final generator loss: 0.9318
Initial discriminator loss: 0.6686
Final discriminator loss: 0.3077

Both losses decrease over training. The generator loss drops as it learns to predict masked tokens, while the discriminator loss decreases as it learns to distinguish original from replaced tokens. The initial discriminator loss around 0.69 corresponds to random guessing (binary cross-entropy for 50/50 predictions), confirming the model starts with no knowledge of which tokens are replaced.

Out[24]:
Visualization
Line plot showing generator MLM loss decreasing over training steps.
Generator loss decreases as it learns to predict masked tokens, producing more plausible replacements over time.
Line plot showing discriminator binary classification loss over training steps.
Discriminator loss tracks its ability to distinguish original from replaced tokens. The two losses are interdependent.

The training dynamics show both losses decreasing. The generator learns to predict masked tokens better, while the discriminator learns to detect replacements. These objectives are complementary: a better generator produces harder replacements, which in turn trains a better discriminator.

In practice, you might notice that the discriminator loss decreases more quickly at first and then plateaus while the generator continues improving. This makes sense intuitively. Early in training, the generator produces very poor replacements (essentially random tokens from the vocabulary), and the discriminator can detect these easily. As the generator learns basic language statistics, its replacements become more plausible, and the discriminator must work harder to maintain low loss. The final performance depends on this equilibrium: can the discriminator develop representations subtle enough to catch the generator's best attempts at plausible replacements?

Another practical consideration is the masking probability used during training. The original ELECTRA paper uses 15% masking, matching BERT. This fraction determines how many positions are "potentially replaced" in the discriminator's view. With 15% masking, roughly 15% of positions have been modified by the generator (though some of those modifications may have landed on the correct token anyway). The remaining 85% are guaranteed originals. A higher masking rate would give the discriminator more replaced examples to learn from, but it would also degrade the generator's context window, making it harder to produce plausible replacements. The 15% rate represents a well-calibrated trade-off backed by extensive empirical testing.