HardBERT

Fine-tuning Architecture

BERT: Pre-training of Deep Bidirectional Transformers

Hard

Problem

Implement one gradient-descent step for a BERT sequence-classification head. Treat the supplied hidden states as the output of a pre-trained encoder and update only the classifier parameters. Use the hidden state at sequence position zero for every sample.

Z = H_{:,0,:}W + b

P_{i,c} = \frac{\exp(Z_{i,c})}{\sum_{k=1}^{C}\exp(Z_{i,k})}

L = -\frac{1}{B}\sum_{i=1}^{B}\log P_{i,y_i}

For the softmax-cross-entropy gradient, subtract one from the probability of each true class and divide by B. Here, B is batch size, C is class count, and y_i is the integer label for sample i. Return a Python dictionary with exactly new_classifier_W, new_classifier_b, and loss. The updated parameters must be float64 NumPy arrays, and loss must be a Python float measured before the update.

Theory

Fine-tuning is the second stage of BERT's two-stage paradigm (Devlin et al., 2019): first pre-train a deep bidirectional Transformer on unlabeled corpora, then fine-tune the entire model on a smaller labeled dataset for a specific downstream task. As the paper states: "Fine-tuning is straightforward since the self-attention mechanism in the Transformer allows BERT to model many downstream tasks. For each task, we simply plug in the task-specific inputs and outputs and fine-tune all the parameters end-to-end."


What It Is / What It Does

Fine-tuning takes the pre-trained BERT encoder and adds a small, randomly initialized task-specific layer (the "head") on top. The entire system is trained end-to-end on labeled data. This contrasts with feature-based approaches (like ELMo) where pre-trained representations are frozen.

The task head is typically a single linear layer. For sequence classification it operates on the [CLS] hidden state; for token classification it operates on every token's hidden state independently. The head is the only component initialized from scratch.


Key Equations

Let H \in \mathbb{R}^{L \times d} be the final encoder output, where L is sequence length and d is hidden dimension (768 for BERT-Base, 1024 for BERT-Large). $ \in \mathbb{R}^d$ is the [CLS] hidden state, and h_i \in \mathbb{R}^d is the hidden state at position i.

Sequence classification uses only the [CLS] token's representation:

\text{logits} = h_{\text{CLS}} \cdot W + b

where W \in \mathbb{R}^{d \times K} is the classifier weight matrix, b \in \mathbb{R}^K is the bias vector, and K is the number of classes.

Token classification applies the same linear transformation to every token independently:

\text{logits}_i = h_i \cdot W + b \quad \text{for } i = 1, 2, \ldots, L

where W \in \mathbb{R}^{d \times K} and b \in \mathbb{R}^K are shared across all positions.

Probability distribution via softmax:

P(y = k \mid h) = \frac{\exp(\text{logits}_k)}{\sum_{j=1}^{K} \exp(\text{logits}_j)}

Training loss is standard cross-entropy. For sequence classification with ground-truth class c:

\mathcal{L} = -\log P(y = c \mid h_{\text{CLS}})

For token classification, the loss sums over all token positions (ignoring padding and special tokens):

\mathcal{L} = -\sum_{i=1}^{L} \log P(y_i = c_i \mid h_i)

Gradients from this loss flow back through the entire encoder, adjusting all pre-trained parameters. This end-to-end gradient flow is what distinguishes fine-tuning from feature extraction.


Sequence vs Token Classification

The same BERT encoder serves both tasks. The difference lies entirely in which hidden states the classifier head reads.

Sequence classification produces one prediction per input:

Token classification produces one prediction per token:

For sentence-pair tasks, BERT packs both sentences into one input: [CLS] sentence_A [SEP] sentence_B [SEP]. Segment embeddings distinguish which sentence each token belongs to, and [CLS] captures the cross-sentence relationship.


Layer Freezing

Layer freezing disables gradient updates for selected parameters by setting requires_grad = False, so they retain pre-trained values throughout training.

Why freeze layers:

Which layers to freeze:

Guidelines by dataset size:

Gradual unfreezing is a related technique: start with only the top layer unfrozen, train briefly, then unfreeze the next layer down and repeat.


Paper Context

Devlin et al. (2019) introduced a fine-tuning recipe that became the standard. The recommended hyperparameters were deliberately conservative.

Recommended hyperparameters:

Key results from the paper:

Fine-tuning outperformed feature-based approaches on most tasks, validating the end-to-end strategy.


Catastrophic Forgetting

Catastrophic forgetting occurs when fine-tuning overwrites the general knowledge learned during pre-training. The model becomes specialized for the new task at the cost of losing broad linguistic understanding.

Why small learning rates matter:

Why few epochs matter:

Connection to layer freezing:

Discriminative learning rates offer a middle ground: assign smaller rates to lower layers and larger rates to upper layers. For example, layer 0 uses 1e-6, layer 6 uses 1e-5, layer 11 uses 5e-5.


Numerical Example

Consider 3-class sentiment classification (negative, neutral, positive) with hidden dimension d = 4.

Step 1: Encoder output. After passing "[CLS] This movie is great [SEP]" through BERT, the [CLS] hidden state is:

h_{\text{CLS}} = [0.5, -0.3, 0.8, 0.1]

Step 2: Classifier weights. W \in \mathbb{R}^{4 \times 3} and b \in \mathbb{R}^3:

W = \begin{bmatrix} 0.2 & -0.1 & 0.4 \\ 0.3 & 0.5 & -0.2 \\ -0.1 & 0.2 & 0.6 \\ 0.4 & -0.3 & 0.1 \end{bmatrix}, \quad b = [0.1, -0.1, 0.2]

Step 3: Compute logits. \text{logits} = h_{\text{CLS}} \cdot W + b:

\text{logits}_0 = (0.5)(0.2) + (-0.3)(0.3) + (0.8)(-0.1) + (0.1)(0.4) + 0.1 = 0.07

\text{logits}_1 = (0.5)(-0.1) + (-0.3)(0.5) + (0.8)(0.2) + (0.1)(-0.3) - 0.1 = -0.17

\text{logits}_2 = (0.5)(0.4) + (-0.3)(-0.2) + (0.8)(0.6) + (0.1)(0.1) + 0.2 = 0.95

\text{logits} = [0.07, -0.17, 0.95]

Step 4: Softmax.

\exp(\text{logits}) = [e^{0.07}, e^{-0.17}, e^{0.95}] = [1.073, 0.844, 2.586]

\text{sum} = 1.073 + 0.844 + 2.586 = 4.503

P = [0.238, 0.187, 0.574]

The model predicts class 2 (positive) with 57.4% probability, matching "This movie is great."

Step 5: Loss. With ground-truth label class 2:

\mathcal{L} = -\log(0.574) = 0.555

This loss backpropagates through the classifier head and the entire BERT encoder.

Token Classification Example

NER on "John works at Google" with labels {O, PER, ORG} (K = 3). The same W and b are applied to every token's hidden state independently.

After encoding:

h_{\text{John}} = [0.9, -0.2, 0.4, 0.3], \quad h_{\text{works}} = [0.1, 0.5, -0.1, 0.2], \quad h_{\text{Google}} = [0.7, 0.1, 0.6, -0.4]

"John": \text{logits} = h_{\text{John}} \cdot W + b = [0.30, -0.30, 0.87]. Softmax: [0.243, 0.133, 0.624]. Prediction: ORG. With random weights this is incorrect -- a trained model would predict PER.

"works": \text{logits} = [0.36, 0.06, 0.10]. Softmax: [0.402, 0.298, 0.310]. Prediction: O. Correct -- "works" is not an entity.

"Google": \text{logits} = [0.05, 0.12, 0.78]. Softmax: [0.225, 0.242, 0.533]. Prediction: ORG. Correct -- "Google" is an organization.

The total loss sums cross-entropy across all token positions.


Modern Context

As models grew to hundreds of billions of parameters, full fine-tuning became impractical, driving parameter-efficient alternatives.

Despite these advances, full fine-tuning remains the gold standard when maximum performance is needed. BERT's core insight -- that pre-trained representations can be adapted with minimal architecture changes -- underpins all of these methods.


Pitfalls


Examples

Example 1

Input
hidden_states = [[[1,0],[0.2,0.3]],[[0,1],[-0.1,0.4]]], labels = [0,1], classifier_W = [[0.2,-0.1],[-0.3,0.4]], classifier_b = [0.05,-0.05], learning_rate = 0.1
Output
{"new_classifier_W":[[0.220066,-0.120066],[-0.317717,0.417717]],"new_classifier_b":[0.052348,-0.052348],"loss":0.475252}
Explanation
Only the classification-token states contribute to the softmax loss and the classifier update.

Example 2

Input
hidden_states = [[[0.5,-0.5,1],[9,9,9]],[[-1,0.25,0.5],[8,8,8]],[[0.2,0.1,-0.3],[7,7,7]]], labels = [2,0,1], classifier_W = [[0.1,-0.2,0.3],[0.4,0.1,-0.1],[-0.3,0.2,0.5]], classifier_b = [0,0.1,-0.1], learning_rate = 0.05
Output
{"new_classifier_W":[[0.085013,-0.192573,0.30756],[0.403876,0.101804,-0.10568],[-0.294879,0.187395,0.507484]],"new_classifier_b":[0.002816,0.097798,-0.100613],"loss":1.031599}

Example 3

Input
hidden_states = [[[1,-1],[4,4]]], labels = [1], classifier_W = [[0,0],[0,0]], classifier_b = [0,0], learning_rate = 0.2
Output
{"new_classifier_W":[[-0.1,0.1],[0.1,-0.1]],"new_classifier_b":[-0.1,0.1],"loss":0.693147}

Hints

  1. Subtract each row maximum before exponentiating logits.
  2. Set grad_logits[np.arange(batch_size), labels] -= 1.
  3. Use cls_states.T @ grad_logits for the weight gradient.

Requirements

Constraints

Starter Code

import numpy as np

def bert_fine_tuning_step(hidden_states: np.ndarray, labels: np.ndarray,
                          classifier_W: np.ndarray, classifier_b: np.ndarray,
                          learning_rate: float) -> dict:
    """
    Returns updated classifier parameters and the pre-update loss.
    """
    pass

Test Cases

CaseMatches
Binary classifier steppublic
Three classespublic
Single samplepublic