BERT Architecture: Embeddings, Attention and Encoder Layers

Michael BrenndoerferUpdated July 16, 202552 min read

Part of Language AI Handbook

Examines the BERT architecture in detail covering model sizes (Base vs Large), three-layer embedding system, bidirectional attention patterns.

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

BERT Architecture

In October 2018, Google released a paper titled "BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding." Within months, BERT had shattered performance records on eleven NLP benchmarks and fundamentally changed how the field approached language understanding tasks. The architecture that made this possible wasn't revolutionary in its components: it stacked transformer encoder blocks with multi-head self-attention and feed-forward networks. The difference lay in BERT's training regime and in the way it deployed these familiar pieces.

To understand why BERT mattered, consider the problem it solved. Before BERT, language models learned representations in a fundamentally asymmetric way. Autoregressive models like GPT read text from left to right, predicting each word based only on the words that came before. This means the model's representation of "bank" in "She went to the river bank" is built only from "She went to the river" with no knowledge of what comes after. For language generation, this makes sense: the next token is unknown. But for language understanding, it is a serious limitation. When a human reads that sentence, they use everything around "bank" to determine its meaning. They look left and right. BERT's central insight was to train a model that could do exactly that.

The technical mechanism BERT used to enable this bidirectional training is called Masked Language Modeling, or MLM. Rather than predicting the next token from all previous tokens, BERT randomly masks some fraction of the input tokens and asks the model to predict the masked values from the surrounding context in both directions. Think of it like a fill-in-the-blank exercise where you can see everything in the sentence except the blanked word. This change alters the attention architecture: because the model must predict masked tokens using both left and right context, it cannot rely on a causal attention mask. Every token attends to every other token. The entire attention mechanism becomes bidirectional.

The second pretraining task BERT introduced is Next Sentence Prediction, or NSP. Given two sentences A and B, the model must predict whether B follows A in the original text or was randomly selected. This task teaches the model to understand relationships between sentences, not just relationships within them. A model trained only on single-sentence tasks would struggle with question answering (where the question and context are separate sentences) or natural language inference (where premise and hypothesis must be compared). NSP gave BERT a mechanism to learn inter-sentence coherence.

These two pretraining objectives worked together to produce a model that, after fine-tuning on task-specific data, could achieve state-of-the-art performance across an unusually broad range of benchmarks simultaneously. The key insight is that language understanding tasks, despite their surface diversity, all require understanding relationships between words and sentences. BERT's bidirectional pretraining gave it exactly the right inductive bias to generalize across all of them.

This chapter examines the BERT architecture in detail. We'll explore the two model sizes (Base and Large) and their layer configurations, understand how BERT's three embedding types combine to represent input, examine the bidirectional attention patterns that distinguish BERT from autoregressive models, and analyze the output representations that downstream tasks use. By the end, you'll understand what BERT's architecture looks like and why each design choice matters.

Model Sizes: Base vs Large

BERT comes in two standard configurations: BERT-Base and BERT-Large. These sizes were chosen deliberately to balance capability against practical deployment constraints. BERT-Base matches the hidden dimension of OpenAI's GPT (768), enabling direct comparisons, while BERT-Large tests whether larger models capture finer language patterns.

The choice to release two sizes reflects the practical reality that research and production have different requirements. BERT-Base was designed to be large enough to achieve strong performance while still being trainable and deployable on hardware available to most researchers and companies in 2018. BERT-Large was designed as a proof of concept: does more scale help? The answer was unambiguously yes. BERT-Large consistently outperformed BERT-Base by several points on most benchmarks, establishing a pattern that would define the field for years: more parameters and more layers generally produce better representations.

Notice that both models use a head dimension of exactly 64. This is not a coincidence. The head dimension determines the size of the QKV vectors within each attention head. At 64 dimensions, these vectors are large enough to express complex patterns but small enough that the dot products between query and key vectors remain numerically stable without aggressive scaling. The Vaswani et al. transformer paper argued that head dimensions should be in this range for stable training, and BERT adopted this guidance. When BERT-Large increases from 12 to 16 heads, it achieves greater representational capacity by running more parallel attention computations rather than by making each computation larger.

BERT Model Variants

BERT-Base contains 12 transformer encoder layers with 12 attention heads and a hidden dimension of 768, totaling approximately 110 million parameters. BERT-Large doubles the layers to 24, increases heads to 16, and expands the hidden dimension to 1024, reaching approximately 340 million parameters.

The architectural specifications for each variant are:

BERT architectural specifications. The head dimension remains constant at 64 across both variants, with BERT-Large achieving greater capacity through more heads and layers.
ParameterBERT-BaseBERT-Large
Layers (LL)1224
Hidden size (HH)7681024
Attention heads (AA)1216
Head dimension (H/AH/A)6464
Feed-forward size30724096
Vocabulary size30,52230,522
Max sequence length512512
Parameters~110M~340M

Notice that the head dimension remains constant at 64 across both variants. This means BERT-Large achieves more capacity by having more heads (16 vs 12) and more layers (24 vs 12), not by making each head larger. The feed-forward dimension follows the standard 4x multiplier relative to hidden size (768 × 4 = 3072 for Base, 1024 × 4 = 4096 for Large).

Let's compute the exact parameter counts to understand where capacity resides:

In[4]:
Code
def count_bert_parameters(
    vocab_size: int = 30522,
    hidden_size: int = 768,
    num_layers: int = 12,
    num_heads: int = 12,
    intermediate_size: int = 3072,
    max_position: int = 512,
    type_vocab_size: int = 2,
) -> dict:
    """Count parameters in each component of BERT."""
    params = {}

    # Embedding layers
    params["token_embeddings"] = vocab_size * hidden_size
    params["position_embeddings"] = max_position * hidden_size
    params["segment_embeddings"] = type_vocab_size * hidden_size
    params["embedding_layernorm"] = 2 * hidden_size  # gamma and beta

    # Per-layer parameters
    # Self-attention: Q, K, V projections + output projection
    attention_params = 4 * (hidden_size * hidden_size + hidden_size)
    # Feed-forward: two linear layers
    ff_params = 2 * (hidden_size * intermediate_size + intermediate_size)
    ff_params += hidden_size  # output bias
    # Layer norms (2 per layer)
    layernorm_params = 4 * hidden_size

    params["per_layer"] = attention_params + ff_params + layernorm_params
    params["all_layers"] = num_layers * params["per_layer"]

    # Pooler (for [CLS] representation)
    params["pooler"] = hidden_size * hidden_size + hidden_size

    # Total
    params["embeddings_total"] = (
        params["token_embeddings"]
        + params["position_embeddings"]
        + params["segment_embeddings"]
        + params["embedding_layernorm"]
    )
    params["total"] = (
        params["embeddings_total"] + params["all_layers"] + params["pooler"]
    )

    return params
Out[5]:
Console
BERT-Base Parameter Distribution:
  Embeddings: 23,837,184 (21.8%)
  Transformer Layers: 85,091,328 (77.7%)
  Pooler: 590,592 (0.5%)
  Total: 109,519,104

BERT-Large Parameter Distribution:
  Embeddings: 31,782,912 (9.5%)
  Transformer Layers: 302,407,680 (90.2%)
  Pooler: 1,049,600 (0.3%)
  Total: 335,240,192
Out[6]:
Visualization
Stacked bar chart comparing parameter distribution between BERT-Base and BERT-Large.
Parameter distribution across BERT components. Transformer layers dominate in both variants, but their share grows from 78% to 90% as model size increases, while embeddings become proportionally smaller.

The vast majority of parameters reside in the transformer layers, particularly in the feed-forward networks. Token embeddings represent a significant portion due to the large vocabulary, but their contribution decreases proportionally as model depth increases. This distribution matters for understanding where BERT stores knowledge: factual information tends to concentrate in feed-forward weights, while attention patterns encode syntactic and semantic relationships.

Historical Context: Why These Numbers?

The BERT-Base configuration was not chosen arbitrarily. The 12-layer, 12-head, 768-hidden-size specification matches OpenAI GPT's architecture almost exactly. The Google BERT team made this choice deliberately to enable an apples-to-apples comparison: with identical architecture but different training objectives (bidirectional MLM plus NSP versus left-to-right LM), any performance difference can be attributed to the training approach rather than model capacity. BERT-Base's outperformance of GPT on most benchmarks showed that bidirectional pretraining was more useful for these understanding tasks, even with identical model size.

In practice, the choice between BERT-Base and BERT-Large often comes down to deployment constraints rather than performance requirements. BERT-Large requires roughly 3x more memory and compute than BERT-Base. For applications where latency and cost are important, BERT-Base frequently offers 90-95% of BERT-Large's performance at one-third the cost. This tradeoff has motivated research into distilled versions like DistilBERT, which achieves about 97% of BERT-Base's performance at 40% of its size. The next chapter covers these variants in detail.

The Input Representation

BERT's input representation is one of its most distinctive features. Unlike simpler models that use only token embeddings, BERT combines three embedding types to capture different aspects of the input. This design enables BERT to process sentence pairs for tasks like question answering and natural language inference.

Think of the three embedding types as answering three different questions about each token. The token embedding answers "what word is this?", storing the semantic and syntactic properties of the vocabulary item itself. The position embedding answers "where does this word appear?", encoding the sequential position within the input. The segment embedding answers "which sentence does this word belong to?", distinguishing sentence A from sentence B in paired inputs. All three answers are equally important, so BERT adds them together into a single dense vector rather than processing them separately.

The 30,522-token vocabulary is produced by WordPiece tokenization, the same subword tokenization scheme used in earlier Google systems. WordPiece splits rare or unknown words into smaller pieces. This ensures that even novel words can be represented as combinations of known subwords. A word like "embedding" might remain whole if it appears frequently in training, but "embeddings" might be split into "embedding" and "##s", where the "##" prefix indicates a continuation. This keeps the vocabulary manageable while still representing nearly any text.

Three Embedding Layers

Every input token receives three embeddings that are summed together:

  1. Token embeddings: Standard learned embeddings that map each vocabulary token to a dense vector
  2. Position embeddings: Learned embeddings for each position (0 to 511) encoding sequential order
  3. Segment embeddings: Two learned embeddings (A and B) indicating which sentence a token belongs to
Out[7]:
Visualization
Diagram showing three stacked embedding matrices being summed to produce the final input representation.
BERT combines three embedding types by element-wise addition. Token embeddings capture word meaning, position embeddings encode sequential order, and segment embeddings distinguish sentence pairs.

The segment embeddings deserve special attention. BERT was designed for tasks involving sentence pairs: given two sentences, determine if the second follows the first (next sentence prediction), if the first entails the second (natural language inference), or find the answer span (question answering). The segment embeddings allow the model to distinguish which tokens belong to which sentence even after they're concatenated.

The concatenation of two sentences into a single sequence with special tokens might seem like a strange design choice. Why not process each sentence independently and combine their representations afterward? The answer is that cross-sentence attention patterns are precisely what many tasks require. In question answering, the model needs to attend from words in the question to matching words in the context passage. In natural language inference, it needs to identify contradictions between specific words in the premise and hypothesis. By putting both sentences in the same context window and letting attention operate freely between them, BERT can learn these cross-sentence relationships directly. The segment embeddings ensure that the model can always tell which sentence each token came from, even while attending freely across both.

Special Tokens

BERT uses several special tokens to structure its input:

  • [CLS]: Prepended to every input. Its final representation is used for classification tasks
  • [SEP]: Inserted between sentences and at the end of the input to mark boundaries
  • [MASK]: Used during pretraining to indicate positions the model should predict
  • [PAD]: Fills sequences shorter than the batch maximum length
  • [UNK]: Represents tokens not in the vocabulary

Let's implement the embedding layer to see how these components combine:

In[8]:
Code
class BertEmbeddings(nn.Module):
    """BERT embedding layer combining token, position, and segment embeddings."""

    def __init__(
        self,
        vocab_size: int = 30522,
        hidden_size: int = 768,
        max_position: int = 512,
        type_vocab_size: int = 2,
        dropout: float = 0.1,
    ):
        super().__init__()

        # Three embedding tables
        self.token_embeddings = nn.Embedding(vocab_size, hidden_size)
        self.position_embeddings = nn.Embedding(max_position, hidden_size)
        self.segment_embeddings = nn.Embedding(type_vocab_size, hidden_size)

        # Layer normalization and dropout
        self.layer_norm = nn.LayerNorm(hidden_size, eps=1e-12)
        self.dropout = nn.Dropout(dropout)

        # Register position ids as buffer (not a parameter)
        self.register_buffer(
            "position_ids", torch.arange(max_position).unsqueeze(0)
        )

    def forward(
        self,
        input_ids: torch.Tensor,
        segment_ids: torch.Tensor | None = None,
    ) -> torch.Tensor:
        """
        Combine embeddings for input tokens.

        Args:
            input_ids: Token indices, shape (batch_size, seq_len)
            segment_ids: Segment indices (0 or 1), shape (batch_size, seq_len)

        Returns:
            Combined embeddings, shape (batch_size, seq_len, hidden_size)
        """
        seq_len = input_ids.size(1)

        # Get position ids for this sequence length
        position_ids = self.position_ids[:, :seq_len]

        # Default segment ids to all zeros (single sentence)
        if segment_ids is None:
            segment_ids = torch.zeros_like(input_ids)

        # Look up embeddings
        token_emb = self.token_embeddings(input_ids)
        position_emb = self.position_embeddings(position_ids)
        segment_emb = self.segment_embeddings(segment_ids)

        # Sum all three
        embeddings = token_emb + position_emb + segment_emb

        # Normalize and apply dropout
        embeddings = self.layer_norm(embeddings)
        embeddings = self.dropout(embeddings)

        return embeddings
Out[9]:
Console
Input shape: torch.Size([1, 8])
Output shape: torch.Size([1, 8, 768])
Embedding dimension: 768

The embedding layer maps our 8-token input into a tensor of shape (1, 8, 768), where each token now has a 768-dimensional representation that combines token identity with positional and segment information. This dense representation is what flows through the subsequent transformer layers.

The element-wise addition of three embedding types may seem unusual. Why not concatenate them? The answer lies in parameter efficiency and training dynamics. Addition keeps the hidden dimension fixed at 768, while concatenation would triple it. More subtly, addition forces the model to learn representations where token meaning, positional information, and segment identity can coexist in the same vector space. The layer normalization after addition rescales these combined representations to have consistent statistics.

In practice, this summation works because modern neural networks are remarkably good at learning linear decompositions within a shared vector space. During training, the model adjusts all three embedding tables simultaneously via gradient descent. The token embeddings learn to represent word meaning in a subspace that leaves room for positional and segment information to be added without collision. The layer normalization that follows stabilizes the combined signal by ensuring the activations entering the first transformer layer have zero mean and unit variance, which helps the subsequent attention computations behave consistently regardless of which specific tokens were summed.

Learned vs Sinusoidal Positions

BERT uses learned position embeddings rather than the sinusoidal encodings from the original transformer. Each of the 512 possible positions gets its own learned vector. This choice trades generalization for expressiveness: learned embeddings cannot extrapolate to positions beyond 512, but they can capture position-specific patterns that sinusoidal encodings cannot.

Recall from the transformer chapter that sinusoidal encodings use a fixed mathematical formula: position pp and dimension ii produce the value sin⁡(p/100002i/d)\sin(p / 10000^{2i/d}) for even dimensions and cos⁡(p/100002i/d)\cos(p / 10000^{2i/d}) for odd dimensions. This formula was designed so that the distance between any two position encodings depends only on their relative offset, not their absolute positions, which enables the model to generalize to sequence lengths it never saw during training. The key insight behind sinusoidal encodings is that any position can be expressed as a linear combination of nearby positions, which means a model trained on sequences up to length 512 can theoretically handle length 600.

Learned position embeddings abandon this mathematical guarantee. Each of BERT's 512 positions gets a 768-dimensional embedding vector that is randomly initialized and trained by gradient descent, exactly like word embeddings. This means position 37 and position 38 start with no particular relationship to each other; they only become related if the training data contains patterns that differ between those positions. In practice, this turns out to work well because NLP tasks rarely require generalizing to sequence lengths longer than those seen in training, and most downstream tasks use sequences well under 512 tokens. The tradeoff is that you cannot simply feed a 600-token sequence to BERT without modification, since positions 513-600 have no learned embeddings.

In[10]:
Code
# Compare learned vs sinusoidal position embeddings
def sinusoidal_position_encoding(max_len: int, d_model: int) -> np.ndarray:
    """Generate sinusoidal position encodings."""
    position = np.arange(max_len)[:, np.newaxis]
    div_term = np.exp(np.arange(0, d_model, 2) * (-np.log(10000.0) / d_model))

    pe = np.zeros((max_len, d_model))
    pe[:, 0::2] = np.sin(position * div_term)
    pe[:, 1::2] = np.cos(position * div_term)
    return pe


# Simulate trained learned position embeddings:
# Real trained embeddings show irregular but structured patterns —
# a mix of low-frequency components (position clusters) and
# high-frequency variation (dimension-specific signals).
positions = np.arange(512)
dims = np.arange(768)
# Base: low-rank structure from 8 dominant components
n_components = 8
freqs = np.linspace(0.5, 6.0, n_components)
amplitudes = np.random.uniform(0.04, 0.12, n_components)
phases = np.random.uniform(0, 2 * np.pi, n_components)
learned_pe = np.zeros((512, 768))
for freq, amp, phase in zip(freqs, amplitudes, phases):
    dim_pattern = np.sin(dims * freq / 768 * np.pi + phase)
    pos_pattern = np.sin(positions * freq / 512 * np.pi)
    learned_pe += amp * np.outer(pos_pattern, dim_pattern)
# Add moderate structured noise (dimension-specific offsets)
learned_pe += np.random.normal(0, 0.015, learned_pe.shape)
# Center and scale the simulation to the displayed color range so the
# irregular structure remains visible instead of saturating the heatmap.
learned_pe -= learned_pe.mean()
learned_pe *= 0.1 / np.max(np.abs(learned_pe))

sinusoidal_pe = sinusoidal_position_encoding(512, 768)
Out[11]:
Visualization
Heatmap of learned position embeddings showing irregular but structured patterns.
Learned position embeddings show irregular patterns that capture position-specific information discovered during training.
Heatmap of sinusoidal position embeddings showing regular wave patterns.
Sinusoidal position embeddings follow a structured pattern that enables extrapolation to unseen sequence lengths.

The learned embeddings appear less regular because they capture whatever positional patterns help with the pretraining objectives. The sinusoidal pattern's mathematical regularity means positions can be expressed as linear combinations of other positions, enabling some length generalization. BERT prioritized expressiveness over extrapolation since most downstream tasks don't require sequences longer than 512 tokens.

Transformer Encoder Layers

The core of BERT consists of stacked transformer encoder blocks. Each block applies multi-head self-attention followed by a position-wise feed-forward network, with residual connections and layer normalization around each sub-layer. This section covers both components in depth, including the subtle design choices that affect training stability and final performance.

Understanding each layer requires appreciating what information flows through it. The input to each layer is a matrix of shape (sequence length, 768), where each row is a 768-dimensional vector representing one token's current representation. The layer transforms this matrix to produce a new matrix of the same shape: same number of tokens, same dimensionality, but with updated representations that incorporate information from across the sequence via attention. The key design constraint is that this shape-preserving property must hold at every layer so that layers can be stacked arbitrarily.

Layer Structure

Each transformer layer follows a consistent pattern:

  1. Multi-head self-attention with residual connection
  2. Layer normalization
  3. Feed-forward network with residual connection
  4. Layer normalization

BERT uses "post-norm" architecture, where layer normalization follows each sub-layer rather than preceding it. This differs from the "pre-norm" variant used in some later models like GPT-2.

The post-norm versus pre-norm distinction matters for training dynamics. In post-norm (used by BERT), the residual path and the transformed path are summed first, then normalized. This means the scale of the residual and the transformed signal can drift relative to each other during training, which sometimes leads to training instability in very deep networks. Pre-norm places the layer normalization before the transformation. This ensures each sub-layer sees normalized inputs, which tends to stabilize gradient flow in deep networks. The reason BERT used post-norm was simply that it followed the original Vaswani et al. transformer design without modification. Later work showed that pre-norm allows training of much deeper models without careful learning rate warmup, but for 12 or 24 layers, post-norm works well with appropriate hyperparameter tuning.

In[12]:
Code
class BertSelfAttention(nn.Module):
    """Multi-head self-attention for BERT."""

    def __init__(
        self, hidden_size: int = 768, num_heads: int = 12, dropout: float = 0.1
    ):
        super().__init__()
        self.num_heads = num_heads
        self.head_dim = hidden_size // num_heads

        # Q, K, V projections
        self.query = nn.Linear(hidden_size, hidden_size)
        self.key = nn.Linear(hidden_size, hidden_size)
        self.value = nn.Linear(hidden_size, hidden_size)

        # Output projection
        self.output = nn.Linear(hidden_size, hidden_size)
        self.dropout = nn.Dropout(dropout)

    def forward(
        self,
        hidden_states: torch.Tensor,
        attention_mask: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        batch_size, seq_len, _ = hidden_states.shape

        # Project to Q, K, V
        q = self.query(hidden_states)
        k = self.key(hidden_states)
        v = self.value(hidden_states)

        # Reshape for multi-head attention: (batch, seq, heads, head_dim)
        q = q.view(
            batch_size, seq_len, self.num_heads, self.head_dim
        ).transpose(1, 2)
        k = k.view(
            batch_size, seq_len, self.num_heads, self.head_dim
        ).transpose(1, 2)
        v = v.view(
            batch_size, seq_len, self.num_heads, self.head_dim
        ).transpose(1, 2)

        # Compute attention scores
        scores = torch.matmul(q, k.transpose(-2, -1)) / (self.head_dim**0.5)

        # Apply attention mask if provided
        if attention_mask is not None:
            scores = scores + attention_mask

        # Softmax and dropout
        attention_probs = F.softmax(scores, dim=-1)
        attention_probs = self.dropout(attention_probs)

        # Apply attention to values
        context = torch.matmul(attention_probs, v)

        # Reshape back: (batch, seq, hidden)
        context = (
            context.transpose(1, 2).contiguous().view(batch_size, seq_len, -1)
        )

        # Output projection
        output = self.output(context)

        return output, attention_probs

The attention computation follows the standard scaled dot-product formula. Given the QKV matrices, attention computes a weighted combination of values where the weights depend on query-key similarity. Before looking at the formula, consider the intuition: for each token position, we want to produce an updated representation that incorporates information from other relevant positions. Relevance is determined by how similar the current token's "query" vector is to each other token's "key" vector. The "value" vector at each position contains the actual content to be aggregated. Think of the attention mechanism as a soft dictionary lookup: the query looks up relevant entries in the dictionary (keys), and the returned values are a weighted combination of the associated values.

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V

where:

  • QQ: the query matrix with shape (sequence length, head dimension), representing what each position is "looking for"
  • KK: the key matrix with shape (sequence length, head dimension), representing what each position "offers" for matching
  • VV: the value matrix with shape (sequence length, head dimension), containing the information to aggregate
  • KTK^T: the transpose of KK, enabling the matrix multiplication QKTQK^T that produces similarity scores
  • dkd_k: the head dimension (64 in BERT), used for scaling
  • dk\sqrt{d_k}: the scaling factor that prevents dot products from growing too large
  • softmax(⋅)\text{softmax}(\cdot): normalizes scores to a probability distribution over positions

The scaling by dk\sqrt{d_k} keeps training stable. Without it, the dot products QKTQK^T grow in magnitude with the dimension, pushing the softmax into regions where gradients vanish. With 64-dimensional heads, dot products could easily reach values of 8-10, making the softmax output nearly one-hot and preventing gradient flow.

Out[13]:
Visualization
Histogram of unscaled attention scores showing wide spread from -15 to 15.
Without scaling, attention scores have high variance, causing softmax to produce near-one-hot distributions.
Histogram of scaled attention scores showing concentrated distribution from -2 to 2.
With scaling by the square root of dimension, scores have controlled variance, producing softer attention distributions.

The histograms above demonstrate this effect. Raw dot products of 64-dimensional vectors have variance around 64, producing scores that span a wide range. After dividing by 64=8\sqrt{64} = 8, the variance drops to approximately 1, keeping scores in a range where softmax produces meaningful probability distributions rather than near-deterministic outputs.

Feed-Forward Network

Each layer's feed-forward network expands the representation to 4x the hidden dimension, applies a non-linearity, then projects back. The feed-forward network operates independently on each token position. Unlike the attention mechanism, which mixes information across positions, the feed-forward network applies the same transformation to each position separately. This makes it position-wise in the precise sense: the token at position 5 gets exactly the same function applied to it as the token at position 23, with no interaction between them.

The reason for the 4x expansion is not fully understood theoretically, but empirically it works well and the pattern has been reproduced across nearly every transformer architecture since. The expansion from 768 to 3,072 dimensions (or 1,024 to 4,096 in BERT-Large) creates a larger intermediate space where the model can express complex non-linear transformations before projecting back down. Some interpretability research suggests that feed-forward networks function as key-value memories: the first linear layer's weights act as keys that activate for specific input patterns, and the second linear layer's weights act as values that add specific information to the representation when those patterns appear. Under this view, the feed-forward network is where BERT stores factual knowledge it acquired during pretraining.

In[14]:
Code
class BertFeedForward(nn.Module):
    """Position-wise feed-forward network for BERT."""

    def __init__(
        self,
        hidden_size: int = 768,
        intermediate_size: int = 3072,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.dense1 = nn.Linear(hidden_size, intermediate_size)
        self.dense2 = nn.Linear(intermediate_size, hidden_size)
        self.dropout = nn.Dropout(dropout)

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        hidden = self.dense1(hidden_states)
        hidden = F.gelu(hidden)  # BERT uses GELU activation
        hidden = self.dense2(hidden)
        hidden = self.dropout(hidden)
        return hidden

BERT uses GELU (Gaussian Error Linear Unit) activation rather than ReLU. GELU provides a smooth approximation to the gating mechanism, allowing small negative values to pass through while still providing non-linearity. The function can be understood as multiplying each input by the probability that a standard normal random variable would be less than that input:

GELU(x)=x⋅Φ(x)=x⋅12[1+erf(x2)]\text{GELU}(x) = x \cdot \Phi(x) = x \cdot \frac{1}{2}\left[1 + \text{erf}\left(\frac{x}{\sqrt{2}}\right)\right]

where:

  • xx: the input value to the activation function
  • Φ(x)\Phi(x): the cumulative distribution function (CDF) of the standard normal distribution, giving the probability that a standard normal random variable is less than xx
  • erf(⋅)\text{erf}(\cdot): the error function, a mathematical function related to the normal distribution's CDF
  • 2\sqrt{2}: a scaling constant that converts from the standard error function to the normal CDF

Unlike ReLU, which abruptly zeroes out all negative inputs, GELU provides a smooth transition. For large positive xx, Φ(x)≈1\Phi(x) \approx 1, so GELU(x)≈x(x) \approx x. For large negative xx, Φ(x)≈0\Phi(x) \approx 0, so GELU(x)≈0(x) \approx 0. The smooth transition around zero means small negative values can still contribute, which empirically improves training dynamics in transformer models.

Notice that GELU is not monotonically increasing for all inputs. Around x=−0.17x = -0.17, GELU reaches a small local minimum before rising. This means the activation function provides a slight regularizing effect: values near zero are "dampened" more than ReLU would dampen them, while large negative values are suppressed similarly. The practical consequence is that GELU-based models tend to converge to better solutions on NLP tasks than ReLU-based models, though the theoretical explanation for this remains an active research area. GPT-2, GPT-3, and many other major language models have adopted GELU or its close relatives following BERT's example.

Out[15]:
Visualization
Line plot comparing GELU and ReLU activation functions from -4 to 4.
GELU provides a smooth transition compared to ReLU's hard cutoff at zero. The shaded region shows where GELU allows small negative values to pass through, potentially preserving useful gradient information.

Complete Encoder Layer

Combining attention and feed-forward with residual connections and layer normalization:

In[16]:
Code
class BertLayer(nn.Module):
    """Single BERT encoder layer."""

    def __init__(
        self,
        hidden_size: int = 768,
        num_heads: int = 12,
        intermediate_size: int = 3072,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.attention = BertSelfAttention(hidden_size, num_heads, dropout)
        self.feed_forward = BertFeedForward(
            hidden_size, intermediate_size, dropout
        )
        self.attention_norm = nn.LayerNorm(hidden_size, eps=1e-12)
        self.output_norm = nn.LayerNorm(hidden_size, eps=1e-12)
        self.dropout = nn.Dropout(dropout)

    def forward(
        self,
        hidden_states: torch.Tensor,
        attention_mask: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        # Self-attention with residual
        attention_output, attention_probs = self.attention(
            hidden_states, attention_mask
        )
        attention_output = self.dropout(attention_output)
        hidden_states = self.attention_norm(hidden_states + attention_output)

        # Feed-forward with residual
        ff_output = self.feed_forward(hidden_states)
        hidden_states = self.output_norm(hidden_states + ff_output)

        return hidden_states, attention_probs
Out[17]:
Console
Input shape: torch.Size([2, 10, 768])
Output shape: torch.Size([2, 10, 768])
Attention probabilities shape: torch.Size([2, 12, 10, 10])

The layer maintains the same tensor shape between input and output, which is essential for stacking layers and enabling residual connections. The attention probabilities tensor indexes the batch, attention head, query position, and key position, confirming that each of the 12 heads computes its own attention pattern over the sequence.

Residual connections enable training of deep networks. They allow gradients to flow directly backward through the network, mitigating the vanishing gradient problem. Without residuals, training a 12 or 24-layer network would be extremely difficult. To understand why, consider what happens to a gradient as it backpropagates through a long chain of transformations. Each transformation multiplies the gradient by a Jacobian matrix. If these Jacobian matrices consistently have values less than 1, the gradient shrinks exponentially with depth, becoming too small to update early layers effectively. Residual connections create a "shortcut" in the gradient computation: the gradient of the loss with respect to early layers includes a direct path through the identity function (the residual) that does not pass through the potentially problematic Jacobian matrices of the transformer sub-layers. This is why you can train a 24-layer BERT-Large successfully while a 24-layer network without residuals would require extreme care with initialization and learning rates.

Worked Example: Residual Connection Gradient Flow

Consider a single residual block: h′=LayerNorm(h+f(h))\mathbf{h}' = \text{LayerNorm}(\mathbf{h} + f(\mathbf{h})), where ff is the attention or feed-forward sub-layer. The gradient of the loss L\mathcal{L} with respect to h\mathbf{h} involves:

∂L∂h=∂L∂h′⋅∂LayerNorm∂(h+f(h))⋅(I+∂f(h)∂h)\frac{\partial \mathcal{L}}{\partial \mathbf{h}} = \frac{\partial \mathcal{L}}{\partial \mathbf{h}'} \cdot \frac{\partial \text{LayerNorm}}{\partial (\mathbf{h} + f(\mathbf{h}))} \cdot \left(I + \frac{\partial f(\mathbf{h})}{\partial \mathbf{h}}\right)

The term I+∂f(h)∂hI + \frac{\partial f(\mathbf{h})}{\partial \mathbf{h}} contains the identity matrix II because of the residual connection. Even if the Jacobian ∂f(h)∂h\frac{\partial f(\mathbf{h})}{\partial \mathbf{h}} is small or poorly conditioned, the II term ensures the gradient is at least as large as if ff were the identity function. Across 12 or 24 such blocks, this prevents gradient vanishing.

Bidirectional Attention Patterns

BERT's attention is bidirectional: every token can attend to every other token in the sequence. This contrasts sharply with the causal (left-to-right) attention used in GPT and other autoregressive models. The difference changes which patterns BERT can learn.

To appreciate the difference concretely, consider the sentence "The animal didn't cross the street because it was too tired." A language model reading left to right must decide what "it" refers to based only on "The animal didn't cross the street because." The causal model might correctly infer that "it" refers to "animal" based on the prior context, but it is working against itself: the word "tired" that resolves any ambiguity appears after "it" and is invisible during the processing of "it". BERT, by contrast, has access to the entire sentence simultaneously when computing the representation of "it", including the word "tired" that strongly suggests "animal" is the antecedent. This access to both preceding and following context is what makes BERT dramatically better at coreference resolution, semantic role labeling, and other tasks that require integrating distant context.

The bidirectionality also enables more powerful contextual disambiguation. The word "bank" means different things in "river bank" and "bank account". A left-to-right model processing "bank account" knows the word before "bank" is "the" and perhaps several earlier words about finance. A bidirectional model processing "bank" in either phrase sees the disambiguating word on both sides, making disambiguation significantly easier. Research on probing BERT's representations has confirmed that BERT develops rich, context-sensitive word senses that outperform previous static word embedding approaches by a wide margin.

Visualizing Attention

Let's examine what attention patterns look like in practice. We'll create sample attention weights and visualize how different heads might specialize:

Out[18]:
Visualization
Heatmap showing attention weights distributed broadly across all positions.
A 'broad attention' head that attends relatively uniformly across the sequence, useful for gathering global context.
Heatmap showing attention weights concentrated near the diagonal.
A 'local attention' head that focuses on nearby tokens, capturing local syntactic relationships.

Research on BERT's attention patterns reveals consistent specialization:

  • Early layers tend to attend broadly, with some heads focusing on the [CLS] token
  • Middle layers develop syntactic patterns, with heads tracking subject-verb relationships, dependency arcs, and constituent boundaries
  • Later layers show more semantic attention, with heads focusing on semantically related tokens

This layered specialization is not explicitly programmed. It emerges from pretraining on the MLM and NSP objectives. The fact that syntactic patterns appear in middle layers and semantic patterns in later layers mirrors how linguists think about the relationship between syntax and semantics: syntax provides the structural scaffolding on which semantic interpretation is built. BERT appears to recapitulate this hierarchy through gradient descent alone, which is one of the most striking findings from early BERT interpretability research.

The specialization of individual heads within layers is also notable. Clark et al. (2019) analyzed BERT-Base's 144 attention heads and found that different heads specialize in different syntactic relationships. One head tracks direct objects, another tracks possessives, another tracks coreference links. Some heads have a "previous token" pattern, attending primarily to the immediately preceding token. Others have a "first token" pattern, routing most attention to [CLS]. This suggests that multi-head attention decomposes contextual processing into simpler, specialized operations.

Attention Mask for Padding

When processing batches of variable-length sequences, BERT uses attention masks to prevent attending to padding tokens. The mask is applied as a large negative value before softmax, effectively zeroing out attention to padded positions:

In[19]:
Code
def create_attention_mask(
    input_ids: torch.Tensor, pad_token_id: int = 0
) -> torch.Tensor:
    """
    Create attention mask for padded sequences.

    Args:
        input_ids: Token indices, shape (batch, seq_len)
        pad_token_id: ID of the padding token

    Returns:
        Attention mask, shape (batch, 1, 1, seq_len) for broadcasting
    """
    # 1 for real tokens, 0 for padding
    mask = (input_ids != pad_token_id).float()

    # Reshape for broadcasting with attention scores (batch, heads, seq, seq)
    mask = mask.unsqueeze(1).unsqueeze(2)

    # Convert to additive mask: 0 for attend, -inf for ignore
    mask = (1.0 - mask) * -10000.0

    return mask
Out[20]:
Console
Input tokens (0 = padding):
tensor([[ 101, 7592, 2088,  102,    0,    0,    0,    0]])

Attention mask shape: torch.Size([1, 1, 1, 8])
Mask values (0 = attend, -10000 = ignore):
tensor([    -0.,     -0.,     -0.,     -0., -10000., -10000., -10000., -10000.])

The large negative value (-10000) becomes approximately zero after softmax. This ensures padded positions receive no attention weight.

Output Representations

BERT produces contextualized representations at every position. Different downstream tasks use these representations in different ways. Understanding the structure of these outputs is essential for knowing how to apply BERT effectively to new tasks.

After all 12 (or 24) encoder layers have processed the input, you have a matrix of shape (sequence length, 768). Each row of this matrix is a contextualized representation of the corresponding input token. The representation at position 5 has been updated by every attention head in every layer, incorporating information from every other token in the sequence. This is fundamentally different from a static word embedding: the representation of the word "bank" depends on which other words appeared in the input. The same word gets a different 768-dimensional vector in every different context.

This contextuality is the source of BERT's power, but it also creates a practical question: how do you extract task-relevant information from a sequence of 512 potentially useful vectors? The answer depends on what your task requires.

The [CLS] Token

The [CLS] token's final hidden state is designed to aggregate sequence-level information. During pretraining with the Next Sentence Prediction task, the [CLS] representation must contain enough information to determine whether two sentences are consecutive. This encourages [CLS] to capture global semantic content.

The [CLS] token is special in that it is prepended to every input regardless of the task. It has no inherent meaning as a word; its entire representation is built up through attention during the forward pass. Because [CLS] can attend to every other token in every attention head, it has the opportunity to aggregate information from the entire sequence. By the final layer, the [CLS] representation is a compressed summary of the input that has been shaped by the pretraining objective to capture sequence-level semantics.

In practice, the quality of [CLS] representations for downstream tasks depends heavily on whether the pretraining objective required sequence-level understanding. BERT's NSP pretraining task did require this, but later work by Lan et al. (2019) on ALBERT and by Liu et al. (2019) on RoBERTa questioned whether NSP helped. RoBERTa, which removed NSP from pretraining, sometimes matched or exceeded BERT's performance on sentence-pair tasks, suggesting that [CLS] can learn useful sequence-level representations even without an explicit sequence-level pretraining signal, as long as the MLM training corpus is large and diverse enough.

For classification tasks, you typically pass the [CLS] representation through a task-specific linear layer:

In[21]:
Code
class BertClassificationHead(nn.Module):
    """Classification head using [CLS] token representation."""

    def __init__(
        self, hidden_size: int = 768, num_classes: int = 2, dropout: float = 0.1
    ):
        super().__init__()
        self.dense = nn.Linear(hidden_size, hidden_size)
        self.dropout = nn.Dropout(dropout)
        self.classifier = nn.Linear(hidden_size, num_classes)

    def forward(self, sequence_output: torch.Tensor) -> torch.Tensor:
        """
        Args:
            sequence_output: BERT output, shape (batch, seq_len, hidden)

        Returns:
            Classification logits, shape (batch, num_classes)
        """
        # Take [CLS] token (first position)
        cls_output = sequence_output[:, 0, :]

        # Project through dense layer
        pooled = self.dense(cls_output)
        pooled = torch.tanh(pooled)
        pooled = self.dropout(pooled)

        # Classify
        logits = self.classifier(pooled)
        return logits

Token-Level Representations

For sequence labeling tasks like named entity recognition or part-of-speech tagging, you use the representation at each token position:

In[22]:
Code
class BertTokenClassificationHead(nn.Module):
    """Token classification head for sequence labeling."""

    def __init__(
        self, hidden_size: int = 768, num_labels: int = 9, dropout: float = 0.1
    ):
        super().__init__()
        self.dropout = nn.Dropout(dropout)
        self.classifier = nn.Linear(hidden_size, num_labels)

    def forward(self, sequence_output: torch.Tensor) -> torch.Tensor:
        """
        Args:
            sequence_output: BERT output, shape (batch, seq_len, hidden)

        Returns:
            Token logits, shape (batch, seq_len, num_labels)
        """
        output = self.dropout(sequence_output)
        logits = self.classifier(output)
        return logits

Span Representations

For extractive question answering, BERT predicts start and end positions of the answer span. Two linear layers project each token's representation to start and end logits:

In[23]:
Code
class BertQuestionAnsweringHead(nn.Module):
    """QA head for extractive question answering."""

    def __init__(self, hidden_size: int = 768):
        super().__init__()
        self.start_classifier = nn.Linear(hidden_size, 1)
        self.end_classifier = nn.Linear(hidden_size, 1)

    def forward(
        self, sequence_output: torch.Tensor
    ) -> tuple[torch.Tensor, torch.Tensor]:
        """
        Args:
            sequence_output: BERT output, shape (batch, seq_len, hidden)

        Returns:
            start_logits: shape (batch, seq_len)
            end_logits: shape (batch, seq_len)
        """
        start_logits = self.start_classifier(sequence_output).squeeze(-1)
        end_logits = self.end_classifier(sequence_output).squeeze(-1)
        return start_logits, end_logits
Out[24]:
Console
Classification output shape: torch.Size([2, 3])
Token classification output shape: torch.Size([2, 20, 9])
QA start/end logits shape: torch.Size([2, 20]) torch.Size([2, 20])

Each head produces output with the appropriate shape for its task. The classification head reduces the sequence to a single prediction per sample. The token classification head produces a label prediction for each position. The QA head generates start and end scores for every token, allowing span extraction by finding the highest-scoring start-end pair.

The flexibility of BERT's output representations is key to its success. The same pretrained model can power classification, sequence labeling, question answering, and many other tasks by simply changing the task-specific head.

Worked Example: Adapting BERT to Sentiment Analysis

Suppose you want to classify product reviews as positive or negative using BERT. The process is:

  1. Tokenize the review text using BERT's WordPiece tokenizer
  2. Prepend [CLS] and append [SEP]: [CLS] This product is amazing [SEP]
  3. Pass through BERT to get the sequence output of shape (seq_len, 768)
  4. Extract the [CLS] representation at position 0: shape (768,)
  5. Pass through a linear layer: 768 inputs, 2 outputs (positive/negative)
  6. Apply softmax to get class probabilities

During fine-tuning, you train the linear head and update BERT's weights with a small learning rate (2e-5 to 5e-5 is typical). The small learning rate is important: BERT already contains powerful language representations, and you want to gently adjust them for the task rather than overwrite them. Too large a learning rate destroys the pretrained representations through "catastrophic forgetting".

A common question is whether to fine-tune BERT's weights at all, or to freeze them and only train the task-specific head. The answer is almost always to fine-tune. Frozen BERT features, while powerful, do not adapt to the vocabulary or style of your specific domain. Fine-tuning allows BERT's internal representations to shift slightly toward representations that are most useful for your task. For small datasets (under a few thousand examples), fine-tuning can overfit, in which case freezing lower layers while fine-tuning upper layers provides a useful middle ground.

The Complete BERT Model

Let's assemble all components into a complete BERT implementation:

In[25]:
Code
class BertEncoder(nn.Module):
    """Stack of BERT encoder layers."""

    def __init__(
        self,
        num_layers: int = 12,
        hidden_size: int = 768,
        num_heads: int = 12,
        intermediate_size: int = 3072,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.layers = nn.ModuleList(
            [
                BertLayer(hidden_size, num_heads, intermediate_size, dropout)
                for _ in range(num_layers)
            ]
        )

    def forward(
        self,
        hidden_states: torch.Tensor,
        attention_mask: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, list[torch.Tensor]]:
        all_attention_probs = []

        for layer in self.layers:
            hidden_states, attention_probs = layer(
                hidden_states, attention_mask
            )
            all_attention_probs.append(attention_probs)

        return hidden_states, all_attention_probs


class BertModel(nn.Module):
    """Complete BERT model."""

    def __init__(
        self,
        vocab_size: int = 30522,
        hidden_size: int = 768,
        num_layers: int = 12,
        num_heads: int = 12,
        intermediate_size: int = 3072,
        max_position: int = 512,
        type_vocab_size: int = 2,
        dropout: float = 0.1,
    ):
        super().__init__()
        self.embeddings = BertEmbeddings(
            vocab_size, hidden_size, max_position, type_vocab_size, dropout
        )
        self.encoder = BertEncoder(
            num_layers, hidden_size, num_heads, intermediate_size, dropout
        )

        # Pooler for [CLS] representation
        self.pooler = nn.Linear(hidden_size, hidden_size)

    def forward(
        self,
        input_ids: torch.Tensor,
        segment_ids: torch.Tensor | None = None,
        attention_mask: torch.Tensor | None = None,
    ) -> dict[str, torch.Tensor]:
        # Create attention mask if not provided
        if attention_mask is None:
            attention_mask = create_attention_mask(input_ids)

        # Embed inputs
        hidden_states = self.embeddings(input_ids, segment_ids)

        # Pass through encoder layers
        sequence_output, all_attention_probs = self.encoder(
            hidden_states, attention_mask
        )

        # Pool [CLS] token
        pooled_output = torch.tanh(self.pooler(sequence_output[:, 0, :]))

        return {
            "last_hidden_state": sequence_output,
            "pooler_output": pooled_output,
            "attention_probs": all_attention_probs,
        }
Out[26]:
Console
Total parameters: 109,482,240

Input shape: torch.Size([2, 32])
Last hidden state shape: torch.Size([2, 32, 768])
Pooler output shape: torch.Size([2, 768])
Number of attention layers: 12

Our implementation produces approximately 85 million parameters, which is lower than the full 110M of BERT-Base because we omit the masked language modeling head and some auxiliary components. The model processes a batch of 2 sequences with 32 tokens each, producing contextualized representations at every position plus a pooled representation for sequence-level tasks.

The architecture we have built mirrors the structure described in the original BERT paper almost exactly. The real BERT model available through Hugging Face adds a few details: the masked LM head is a linear layer mapping each token representation to a vocabulary-sized logit vector (30,522 outputs), and the NSP head is a linear layer mapping the pooled [CLS] representation to a 2-class logit (is-next / not-next). These heads are only used during pretraining and are discarded when you download a pretrained BERT model for fine-tuning. When you call BertModel.from_pretrained('bert-base-uncased'), you get exactly the encoder stack and embeddings we implemented here, with the pretraining heads stripped away and the weights initialized from the pretrained checkpoint.

Practical Considerations

When deploying BERT, two factors dominate resource requirements: memory consumption and computational cost. Both scale with sequence length, making long documents particularly expensive to process. Understanding these scaling properties is essential for making informed decisions about batch sizes, sequence length truncation, and hardware requirements before you begin a project.

Memory Requirements

BERT's memory usage during inference scales with sequence length squared due to the attention mechanism. For a batch of sequences, the dominant terms are:

  • Embeddings: O(batch×seq×d)O(\text{batch} \times \text{seq} \times d)
  • Attention scores: O(batch×heads×seq2)O(\text{batch} \times \text{heads} \times \text{seq}^2)
  • Intermediate activations: O(batch×seq×4d)O(\text{batch} \times \text{seq} \times 4d)

For BERT-Base with a sequence length of 512 and batch size of 1, the attention scores alone require approximately 12 × 512 × 512 × 4 bytes (float32) = 12.6 MB per layer, or about 150 MB for all 12 layers.

Out[27]:
Visualization
Line plot showing memory increasing quadratically as sequence length grows from 128 to 512.
BERT memory usage scales quadratically with sequence length due to the attention mechanism, making long sequences significantly more expensive to process.

Computational Complexity

The computational complexity of BERT is dominated by three operations:

  1. Attention: O(n2⋅d)O(n^2 \cdot d) for sequence length nn and dimension dd
  2. Feed-forward: O(n⋅d2)O(n \cdot d^2) with the 4x expansion
  3. Embeddings: O(n⋅d)O(n \cdot d)

For typical sequence lengths (128-512), attention and feed-forward costs are comparable. The quadratic attention cost becomes prohibitive only for very long sequences, motivating efficient attention variants like Longformer and BigBird.

The crossover point between attention and feed-forward dominance is worth calculating explicitly. Attention cost scales as O(n2d)O(n^2 d) and feed-forward scales as O(nd2)O(n d^2), so attention dominates when n>dn > d. For BERT-Base with d=768d = 768, attention dominates for sequences longer than 768 tokens, which is already beyond BERT's 512-token limit. This means that for all practical BERT inputs, attention and feed-forward computation are similar in magnitude, and reducing sequence length reduces both approximately linearly.

In practice, most fine-tuning workloads for classification tasks truncate sequences to 128 tokens rather than using the full 512. At 128 tokens, BERT runs roughly 16 times faster than at 512 tokens (due to the quadratic attention term, a 4x reduction in length gives a 16x reduction in attention compute), with only modest performance degradation for tasks whose inputs fit in 128 tokens. This truncation is the single most impactful optimization you can make when deploying BERT in a latency-constrained environment.

Limitations and Impact

BERT's architecture introduced several constraints that its successors have worked to address. The fixed sequence length of 512 tokens limits document-level understanding; longer documents must be chunked and processed separately, losing cross-chunk context. The quadratic attention complexity makes extending this limit computationally expensive. Later models like Longformer use sparse attention patterns to handle 4,096+ tokens efficiently.

The pretrain-then-finetune paradigm, while successful, requires task-specific training data and separate models for each task. Each time you want to add a new task, you must fine-tune a full copy of BERT on that task's training data and store the resulting model separately. For an organization with dozens of NLP tasks, this means maintaining dozens of BERT-sized models. This limitation motivated research into prompt-based methods where a single model handles multiple tasks through careful input formatting, culminating in the GPT-3 few-shot learning paradigm where no task-specific fine-tuning is required at all.

BERT also cannot generate text autoregressively, restricting it to discriminative tasks. This is a direct consequence of the bidirectional attention design: to predict the next token autoregressively, you must not allow the current token to attend to future tokens during training, because those future tokens would not be available at generation time. BERT's architecture has no such restriction, so it cannot generate coherent text. This creates a fundamental division between language-model architectures: encoder-only models like BERT excel at understanding tasks, while decoder-only models like GPT excel at generation tasks. Sequence-to-sequence models like T5, which use both an encoder and decoder, attempt to bridge this gap.

The [CLS] token aggregation approach has known limitations for long or complex texts. A single 768-dimensional vector must summarize an entire 512-token document. For short texts (a sentence or paragraph), this works well. For longer documents, task-relevant information may be lost in the compression. Alternative pooling strategies, such as averaging all token representations or using the maximum activation across positions, sometimes outperform [CLS] pooling on certain tasks, which suggests that the single-vector bottleneck is a real limitation.

Despite these limitations, BERT's architectural choices proved remarkably effective. The bidirectional attention mechanism captures context that autoregressive models miss. The three-embedding input representation elegantly handles sentence pairs. The standardized output format enables easy adaptation to diverse tasks. These design decisions established patterns that influenced nearly every subsequent language model.

Historical Context: The Impact of BERT's Release

BERT's release in October 2018 triggered what researchers called the "BERT fever." The paper immediately became one of the most cited in NLP history. Within weeks of the open-source release, researchers fine-tuned BERT on virtually every available benchmark. The results were remarkable: BERT outperformed the previous state of the art on 11 tasks simultaneously, with some improvements exceeding 10 percentage points. The NLP community had seen models improve benchmarks one at a time, often through task-specific architectures. Seeing a single architecture, with only a lightweight classification head added, dominate across the board surprised the NLP community.

The broader significance was that BERT demonstrated language models could be pretrained on raw text and then adapted to almost any downstream task with a small amount of labeled data. This was a paradigm shift. Before BERT, NLP systems typically required large amounts of task-specific labeled data and careful feature engineering. After BERT, you could achieve state-of-the-art performance on many tasks with just a few hundred labeled examples and a few minutes of fine-tuning. This democratized NLP research and enabled companies without large annotation budgets to build powerful language systems.

BERT's release in late 2018 catalyzed a transformation in NLP. Within months, BERT or BERT-derived models topped leaderboards for question answering (SQuAD), natural language inference (MNLI), sentiment analysis (SST-2), and many other benchmarks. The pretrain-finetune paradigm became standard practice, and "BERT" became shorthand for transformer-based language understanding. The architecture we've examined in this chapter, while not the final word in language model design, remains a foundational reference point for understanding modern NLP. In the next chapter, we examine how later variants such as RoBERTa and ALBERT, as well as DistilBERT, changed BERT's training procedure and its efficiency at different scales while preserving its core architectural insights.

Key Parameters

The BERT architecture is defined by a small set of core hyperparameters that determine model capacity, memory usage, and computational cost:

  • hidden_size (768 for Base, 1024 for Large): The dimensionality of token representations throughout the model. Larger values increase capacity but quadratically increase attention computation costs.

  • num_layers (12 for Base, 24 for Large): The number of stacked transformer encoder blocks. More layers enable more complex feature hierarchies but increase memory and compute linearly.

  • num_heads (12 for Base, 16 for Large): The number of parallel attention heads. More heads allow the model to attend to different aspects of the input simultaneously. The head dimension is typically hidden_size / num_heads.

  • intermediate_size (3072 for Base, 4096 for Large): The hidden dimension of the feed-forward network, typically 4× hidden_size. This expansion allows the FFN to learn complex transformations.

  • max_position (512): The maximum sequence length the model can process. Longer sequences require more memory due to quadratic attention complexity.

  • vocab_size (30,522 for BERT): The number of unique tokens in the vocabulary. Larger vocabularies reduce out-of-vocabulary issues but increase embedding parameter count.

  • dropout (0.1): Applied to attention weights, feed-forward outputs, and embeddings during training to prevent overfitting. Set to 0 during inference.

When adapting BERT for specific applications, the most impactful parameters are max_position (for document length requirements) and dropout (for controlling overfitting on small datasets).

Summary

BERT's architecture combines familiar transformer components in a configuration optimized for language understanding. The key architectural elements include:

  • Two model sizes: BERT-Base (110M parameters, 12 layers) and BERT-Large (340M parameters, 24 layers) balancing capability against deployment constraints
  • Three embedding types: The input combines token embeddings with positional and segment embeddings, enabling rich representations of sentence pairs
  • Bidirectional attention: Unlike autoregressive models, every token attends to every other token, capturing full context for understanding tasks
  • Flexible outputs: The [CLS] token representation supports classification, token representations enable sequence labeling, and span predictions handle extractive QA

The architecture's success established the pretrain-finetune paradigm that dominated NLP through the early 2020s. While subsequent models have extended and improved upon BERT's design, understanding this architecture provides essential foundation for comprehending the evolution of language models.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about BERT architecture.

BERT Architecture Quiz

Question 1 of 80 of 8 completed
How many transformer encoder layers does BERT-Base have?

Comments

No comments yet. Be the first to share your thoughts!

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025bertarchitecture, author = {Michael Brenndoerfer}, title = {BERT Architecture: Embeddings, Attention and Encoder Layers}, year = {2025}, url = {https://mbrenndoerfer.com/writing/bert-architecture-model-structure-components}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-09-30} }
APAAcademic
Michael Brenndoerfer (2025). BERT Architecture: Embeddings, Attention and Encoder Layers. Retrieved from https://mbrenndoerfer.com/writing/bert-architecture-model-structure-components
MLAAcademic
Michael Brenndoerfer. "BERT Architecture: Embeddings, Attention and Encoder Layers." 2026. Web. September 30, 2026. <https://mbrenndoerfer.com/writing/bert-architecture-model-structure-components>.
CHICAGOAcademic
Michael Brenndoerfer. "BERT Architecture: Embeddings, Attention and Encoder Layers." Accessed September 30, 2026. https://mbrenndoerfer.com/writing/bert-architecture-model-structure-components.
HARVARDAcademic
Michael Brenndoerfer (2025) 'BERT Architecture: Embeddings, Attention and Encoder Layers'. Available at: https://mbrenndoerfer.com/writing/bert-architecture-model-structure-components (Accessed: September 30, 2026).
SimpleBasic
Michael Brenndoerfer (2025). BERT Architecture: Embeddings, Attention and Encoder Layers. https://mbrenndoerfer.com/writing/bert-architecture-model-structure-components

About the author

Continue with the full handbook

This chapter is part of Language AI Handbook. Use the handbook page to browse the complete table of contents and continue reading in sequence.

Explore Language AI Handbook
Newsletter

Stay up to date

Get articles, book updates, and news delivered to your inbox.

No spam, unsubscribe anytime.

or

Join the community

Sign in to remove popups, track your reading progress, and join the discussion.