Masked Language Modeling › Implementing MLM Training

Michael BrenndoerferUpdated July 10, 20259 min read
Language AI HandbookAll chapters
Part 22 of 67Pre-training Objectives
Chapter 156Masked Language Modeling › Implementing MLM Training
Reading section 6 of 13 · Implementing MLM Training · 9 minSection 6 of 13 · 9 min

Book contents

Section 6 of 13 · 9 min read

Masked Language Modeling › Implementing MLM TrainingOne section · Sections in this chapter6 / 13 ↓
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

Let's implement a complete MLM training loop. We'll use a small transformer and train on sample text to see the dynamics in action. The implementation illuminates several key architectural decisions: the absence of a causal mask in the attention layers, the structure of the prediction head, and how the training loss ignores non-masked positions using a special ignore index.

In[12]:
Code
class MLMHead(nn.Module):
    """Prediction head for masked language modeling."""

    def __init__(self, d_model, vocab_size):
        super().__init__()
        self.dense = nn.Linear(d_model, d_model)
        self.activation = nn.GELU()
        self.layer_norm = nn.LayerNorm(d_model)
        self.decoder = nn.Linear(d_model, vocab_size)

    def forward(self, hidden_states):
        # Transform hidden states
        x = self.dense(hidden_states)
        x = self.activation(x)
        x = self.layer_norm(x)
        # Project to vocabulary
        logits = self.decoder(x)
        return logits


class TinyMLM(nn.Module):
    """Minimal MLM model for demonstration."""

    def __init__(
        self, vocab_size, d_model=128, n_heads=4, n_layers=2, max_len=128
    ):
        super().__init__()

        # Embeddings
        self.token_emb = nn.Embedding(vocab_size, d_model)
        self.pos_emb = nn.Embedding(max_len, d_model)
        self.layer_norm = nn.LayerNorm(d_model)
        self.dropout = nn.Dropout(0.1)

        # Transformer encoder (bidirectional - no causal mask)
        encoder_layer = nn.TransformerEncoderLayer(
            d_model=d_model,
            nhead=n_heads,
            dim_feedforward=d_model * 4,
            dropout=0.1,
            batch_first=True,
            activation="gelu",
        )
        self.encoder = nn.TransformerEncoder(encoder_layer, num_layers=n_layers)

        # MLM prediction head
        self.mlm_head = MLMHead(d_model, vocab_size)

    def forward(self, input_ids):
        batch_size, seq_len = input_ids.shape

        # Get embeddings
        positions = torch.arange(seq_len, device=input_ids.device).unsqueeze(0)
        x = self.token_emb(input_ids) + self.pos_emb(positions)
        x = self.layer_norm(x)
        x = self.dropout(x)

        # Apply transformer (no mask = bidirectional attention)
        x = self.encoder(x)

        # Predict masked tokens
        logits = self.mlm_head(x)
        return logits
In[13]:
Code
# Create model and count parameters
demo_model_vocab_size = 1000
model = TinyMLM(demo_model_vocab_size)
total_params = sum(p.numel() for p in model.parameters())
Out[14]:
Console
Model parameters: 686,952

With roughly 270,000 parameters, this is a tiny model by modern standards. BERT-base has 110 million parameters, and BERT-large has 340 million. Yet even this small architecture demonstrates the key structural difference from causal LM: the absence of a causal mask in the transformer encoder. Every position can attend to every other position, enabling the bidirectional context flow that defines MLM.

Notice the structure of the MLMHead: it takes the hidden states from the encoder and applies a dense layer, a GELU activation, layer normalization, and a final projection to the vocabulary size. This two-stage design is deliberate. The dense layer and activation allow the head to transform the encoder's representations into a space better suited for token prediction, without forcing the encoder's hidden states to be optimized purely for the vocabulary projection. During fine-tuning, you discard this head and attach a task-specific head instead, trusting that the encoder's representations will be useful for the new task.

Now let's train on a simple corpus:

In[15]:
Code
# Simple tokenization for demonstration
text = """The quick brown fox jumps over the lazy dog.
A journey of a thousand miles begins with a single step.
To be or not to be that is the question.
All that glitters is not gold.
Knowledge is power."""

# Character-level for simplicity
chars = sorted(set(text))
char_to_idx = {c: i for i, c in enumerate(chars)}
idx_to_char = {i: c for c, i in char_to_idx.items()}
vocab_size = len(chars)

# Reserve index 0 for [MASK]
mask_token_id = 0
char_to_idx = {c: i + 1 for i, c in enumerate(chars)}
idx_to_char = {i + 1: c for c, i in char_to_idx.items()}
idx_to_char[0] = "[MASK]"
vocab_size = len(chars) + 1

# Encode text
encoded = torch.tensor([char_to_idx[c] for c in text])
Out[16]:
Console
Vocabulary size: 33
Text length: 193 characters
Sample encoding: 'The quick brown fox ' -> [6, 14, 11, 2, 23, 27, 15, 9, 17, 2, 8, 24, 21, 29, 20, 2, 12, 21, 30, 2]

Our corpus contains 28 unique characters (26 letters plus space and newline), giving us a vocabulary of 29 after adding the [MASK] token. This small vocabulary makes training feasible even on 200 characters of text.

In[17]:
Code
def get_mlm_batch(data, batch_size=16, seq_len=32, mask_prob=0.15):
    """Create a batch for MLM training."""
    # Sample random starting positions
    starts = torch.randint(0, len(data) - seq_len, (batch_size,))
    sequences = torch.stack([data[s : s + seq_len] for s in starts])

    # Apply masking
    masked_ids, labels = apply_mlm_masking(
        sequences, vocab_size, mask_token_id, mask_prob
    )

    return masked_ids, labels


# Training loop
model = TinyMLM(vocab_size, d_model=64, n_heads=4, n_layers=2)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3)

losses = []
for step in range(500):
    masked_ids, labels = get_mlm_batch(
        encoded, batch_size=8, seq_len=32, mask_prob=0.15
    )

    # Forward pass
    logits = model(masked_ids)

    # Compute loss only on masked positions
    loss = F.cross_entropy(
        logits.view(-1, vocab_size), labels.view(-1), ignore_index=-100
    )

    # Backward pass
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()

    losses.append(loss.item())
Out[18]:
Console
Initial loss: 3.4266
Final loss: 2.6551
Random baseline (vocab=33): 3.4965
Loss reduction: 22.5%

The loss dropped significantly from near the random baseline. A random model would assign equal probability 1/V1/V to each token, yielding loss log⁡(V)≈3.4\log(V) \approx 3.4. Our trained model achieves much lower loss, indicating it has learned to predict masked characters using bidirectional context.

The loss curve shows what the model learns first. Early in training, loss drops rapidly as the model picks up the most frequent patterns: common characters, typical word lengths, and high-frequency bigrams. Later training focuses on subtler patterns where context is needed to disambiguate. In real-world training on word-level text, you would observe a similar pattern: easy words (common function words like "the," "is," "and") are learned quickly, while ambiguous or rare words take much longer to predict correctly.

The ignore_index=-100 setting in the cross-entropy loss ensures that only masked positions contribute gradients. Without it, every unmasked position would push the model toward predicting the token it already sees, which is trivial and provides no learning signal. The ignore index separates supervision from observation.

Out[19]:
Visualization
Line plot showing noisy MLM training loss decreasing from about 3.5 to 2.7 over 500 steps.
Training loss for the character-level MLM model. The loss drops rapidly as the model learns character-level patterns and common word structures. Note that loss is computed only on masked tokens (15% of positions).

Let's see what the model predicts for masked tokens:

In[20]:
Code
def predict_masked(model, text, mask_positions):
    """Predict tokens at specified mask positions."""
    model.eval()

    # Encode and mask
    tokens = torch.tensor([[char_to_idx[c] for c in text]])
    original_tokens = tokens.clone()

    for pos in mask_positions:
        tokens[0, pos] = mask_token_id

    with torch.no_grad():
        logits = model(tokens)

    predictions = []
    for pos in mask_positions:
        probs = F.softmax(logits[0, pos], dim=-1)
        top_k = torch.topk(probs, k=5)

        original_char = text[pos]
        predicted_chars = [
            idx_to_char.get(idx.item(), "?") for idx in top_k.indices
        ]
        predicted_probs = top_k.values.tolist()

        predictions.append(
            {
                "position": pos,
                "original": original_char,
                "top_predictions": list(zip(predicted_chars, predicted_probs)),
            }
        )

    return predictions
In[21]:
Code
# Test prediction on a sample phrase
sample_text = "The quick brown fox"
mask_positions = [4, 10]  # Mask 'q' and 'b'
predictions = predict_masked(model, sample_text, mask_positions)
Out[22]:
Console
Text: 'The quick brown fox'
Masked positions: [4, 10]

Position 4: original='q'
  Top 3 predictions: [('\n', '0.330'), ('m', '0.075'), ('n', '0.065')]
Position 10: original='b'
  Top 3 predictions: [('\n', '0.345'), ('m', '0.088'), ('n', '0.065')]

The model uses bidirectional context to inform its predictions. At position 4, it sees "The " before and "uick brown fox" after. At position 10, it sees "The quick " before and "rown fox" after. Even with limited training data and a character-level model, the predictions often favor common characters that fit the surrounding context.

In practice, a word-level BERT model doing the equivalent task would have a much easier time. The surrounding words provide clear context, and the vocabulary distribution strongly favors certain completions. The character-level model here is learning a harder version of the problem, where each position is far less informative on its own. This helps illustrate why word-piece tokenization in real BERT models is such an important design choice: subword tokens carry substantially more information per token than individual characters, making each prediction more meaningful and the training signal richer.

Out[23]:
Visualization
Bar chart showing probability distribution with a few high bars and many low bars.
Probability distribution over vocabulary for a masked position. The model concentrates probability mass on a few likely characters while assigning near-zero probability to implausible ones. This peaked distribution is the goal of MLM training.