Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies

Michael BrenndoerferNovember 27, 202557 min read

Covers learning rate strategies for fine-tuning transformers. Topics include discriminative fine-tuning, layer-wise decay, warmup schedules, and decay methods.

Language AI HandbookAll chapters
Part 34 of 67Fine-tuning Fundamentals
Chapter 252Fine-tuning Learning Rates
Reading the full chapter · 12 sections · 57 min12 sections · 57 min

All 12 sections · 57 min read

Fine-tuning Learning RatesFull chapter · Sections in this chapter1 / 12 ↓
Track your reading progress

Sign in to mark sections as read and track your learning journey

Sign in →
Reading tools Definitions, links & notes

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

Notes & comments

Fine-tuning Learning Rates

When you fine-tune a pre-trained language model, choosing the right learning rate is more important than when training from scratch. A learning rate that works well for pre-training can destroy months of computational effort in minutes of fine-tuning. Conversely, a learning rate that is too small might never adapt the model to your task. The challenge lies in finding the narrow band of learning rates that let real adaptation without erasing the knowledge the model has already acquired.

Think of the pre-trained model as a craftsperson who has spent years building an advanced set of skills. When you hire them for a new project, you want them to apply their expertise to the new context, not forget everything they know and start from scratch. The learning rate is the intensity of their retraining. Too intense, and they forget their foundational skills. Too gentle, and they never adapt to the new role. You need a calibrated approach that respects their existing expertise while allowing specialization.

This analogy captures something real about the mathematics. Pre-trained weights stand for a particular region of parameter space that took enormous computational resources to reach. This region is not just any local minimum; it is a high-quality one that encodes linguistic structure, semantic relationships, and world knowledge accumulated over billions of training tokens. When we fine-tune, we are not searching for a good solution from scratch. We are making careful adjustments around an already-excellent solution. The learning rate controls exactly how careful or aggressive those adjustments are.

The challenge is compounded by the heterogeneous structure of transformer models. A 12-layer BERT model is not a single monolithic entity but a stack of specialized subsystems: word embeddings that encode vocabulary semantics, early transformer layers that capture syntax and local patterns, middle layers that build richer contextual representations, and upper layers that prepare outputs for the specific pre-training task. Each of these subsystems has a different relationship to the fine-tuning objective. Some need minimal adjustment, others need substantial reconfiguration, and the task head needs to be trained almost from scratch. A single learning rate cannot optimally serve all of these needs simultaneously.

The strategies in this chapter address this heterogeneity systematically. Discriminative fine-tuning and layer-wise learning rate decay assign different rates to different layers, respecting the hierarchical structure of learned representations. Warmup schedules protect the model during the dangerous early phase of adaptation. Decay schedules guide convergence toward a stable final solution. Together, these techniques provide a way to manage the fine-tuning objective with care.

Historical Context

The idea of using different learning rates for different parts of a network predates transformer fine-tuning by several years. It appears in the optimization literature under various names including "per-parameter learning rates" and "parameter group optimization." The specific formulation for NLP was popularized by the ULMFiT paper (Howard and Ruder, 2018), which introduced discriminative fine-tuning as a key ingredient in their Universal Language Model Fine-Tuning approach for text classification. ULMFiT demonstrated that language model pre-training followed by careful fine-tuning could match or exceed task-specific architectures, a result that was surprising at the time. When BERT arrived later in 2018, the Google team incorporated warmup schedules and small learning rates as standard recommendations in their fine-tuning recipe, and these quickly became the de facto defaults for the field. The cosine decay schedule, while used in image recognition before NLP, gained wide adoption for language model fine-tuning through influential works like "Revisiting Few-sample BERT Fine-tuning" (Zhang et al., 2021), which systematically studied how these hyperparameters interact.

The Fine-tuning Learning Rate Challenge

As we discussed in the previous chapter on catastrophic forgetting, fine-tuning involves a basic tension. You want the model to learn task-specific patterns, but you do not want to overwrite the rich linguistic knowledge acquired during pre-training. Learning rates sit at the heart of this tension because they directly control how much each gradient update changes the model's parameters.

Understanding why fine-tuning is qualitatively different from pre-training requires thinking carefully about the optimization objective. During pre-training, the model starts from random weights and explores the loss surface from a completely uninformed starting point. The optimizer has to make large moves to escape the random initialization and find real structure in the data. There is nothing to lose, because the starting weights encode no knowledge worth preserving. Large learning rates are not just safe at this stage; they are necessary for making rapid progress across billions of training tokens.

Fine-tuning presents a fundamentally different scenario. You are starting from a carefully optimized point that already captures real language structure. The pre-trained weights stand for a local minimum that took enormous computational resources to reach, and this minimum encodes useful knowledge about syntax and semantics as well as world knowledge. Large updates can push parameters far from this optimum, destroying useful representations that took the original training process millions of gradient steps to construct. The loss surface near a high-quality pre-trained minimum is typically much more sensitive than it is near random initialization: small perturbations in the wrong direction can cascade through the network and destabilize representations that downstream layers depend on.

During pre-training, models typically use learning rates between 10−410^{-4} and 10−310^{-3} with the Adam optimizer. These relatively large rates help the model explore the parameter space efficiently when learning from scratch. But fine-tuning requires a reduction of roughly one to two orders of magnitude, with typical values in the range of 10−510^{-5} to 5×10−55 \times 10^{-5} for BERT-style models. This reduction shows the different nature of the optimization problem: rather than exploring a large parameter space, we are making careful adjustments around an already-good solution.

Even this simple observation raises deeper questions. Not all parts of the model are equally sensitive to update magnitude. Not all parts need to change by the same amount. And the optimal strategy depends on characteristics of both the pre-trained model and the fine-tuning task. The strategies we explore in this chapter emerged from attempts to answer these questions systematically. Rather than treating fine-tuning as simply "training with a smaller learning rate," we have developed fine-grained approaches that consider which parts of the model need to change and by how much.

  • Should all layers receive the same learning rate?
  • Should the rate start small and grow, or start larger and decay?
  • How do these choices interact with different model architectures?

The answers depend on the hierarchical structure of what transformers learn, which motivates our first major technique.

Discriminative Fine-tuning

Discriminative fine-tuning assigns different learning rates to different parts of the model based on the intuition that not all parameters should adapt equally. This approach recognizes that a pre-trained language model is not a monolithic entity but rather a hierarchy of representations, each capturing different levels of linguistic abstraction. Think of it as applying a sliding scale of carefulness: the parts of the model that encode universal, broadly applicable knowledge are treated with more caution, while the parts that are task-specific receive more aggressive updates.

Discriminative Fine-tuning

A fine-tuning strategy where different parameter groups receive different learning rates, typically with lower layers learning more slowly than higher layers to preserve general features while letting task-specific adaptation.

The key insight is that neural network layers do not all play the same role in the learned representation hierarchy. Deep networks develop a natural architecture of specialization that mirrors how information is progressively abstracted. In transformer models, this hierarchy is particularly well-studied because probing experiments have systematically mapped what each layer learns. Lower layers capture general linguistic patterns such as tokenization boundaries, basic syntax, and basic semantic relationships. These patterns apply broadly across virtually any language task you might encounter. Higher layers capture more task-specific features that build on these foundations, developing representations increasingly tailored to the pre-training objective. When fine-tuning for a specific task, you want to:

  1. Preserve lower-layer representations: These capture transferable knowledge that applies across tasks. The basic understanding of language structure, word relationships, and syntactic patterns learned in these layers will be useful regardless of whether you are doing sentiment analysis, question answering, or named entity recognition.
  2. Adapt higher-layer representations: These need to specialize for your specific task. The upper layers, which were optimized for masked language modeling or next-token prediction, need to reconfigure their representations to support your new objective.
  3. Train the new task head aggressively: This is entirely task-specific and needs full adaptation. Unlike the pre-trained backbone, the task head starts from random initialization and has no prior knowledge to preserve.

This suggests a natural structure: assign progressively larger learning rates as you move up through the network. Lower layers receive small learning rates that allow only gentle adjustments, nudging the representations slightly while preserving their overall structure. Higher layers receive larger rates that let substantial adaptation to the new task. The task-specific head receives the largest rate of all, letting it to rapidly learn the mapping from pre-trained representations to task outputs. The resulting gradient of adaptation rates mirrors the gradient of specialization that the network itself embodies.

Mathematical Formulation

To implement discriminative fine-tuning, we need a principled way to assign learning rates across layers. The most common approach uses geometric decay, where each step down the network reduces the learning rate by a constant factor. This creates a smooth gradient of adaptation rates that respects the hierarchical nature of the model's representations.

We want to compute a learning rate for each layer that decreases as we move toward the bottom of the network. Let's denote the base learning rate as η\eta and define a decay factor ξ\xi (typically between 0.9 and 0.95). For a model with LL layers, layer ll receives learning rate:

ηl=η⋅ξL−l\eta_l = \eta \cdot \xi^{L-l}

where:

  • ηl\eta_l: the learning rate assigned to layer ll (the value we compute for each layer)
  • η\eta: the base learning rate assigned to the top layer (typically the highest rate in the backbone)
  • ξ\xi: the multiplicative decay factor (usually 0.9 to 0.95), which scales down the rate for each step down the network
  • LL: the total number of layers in the model
  • ll: the index of the current layer, where l=Ll=L is the top layer, l=1l=1 is the bottom transformer layer, and embeddings are treated as l=0l=0

Why does this formula make sense? Notice that the exponent L−lL-l is exactly the layer's distance from the top of the network. The top layer (l=Ll = L) has a distance of 0, so ξ0=1\xi^0=1, receiving the full base rate η\eta. Each subsequent layer down increases the distance by 1, compounding the decay factor ξ\xi. This compounding effect means that the learning rate does not just decrease linearly as we move down the network; it decreases geometrically, with each layer receiving a fixed fraction of the rate assigned to the layer above it.

Geometric decay ensures that the relative difference between adjacent layers remains constant throughout the network. If layer 10 receives 95% of the rate of layer 11, then layer 5 also receives 95% of the rate of layer 6. This proportional relationship ensures that the adaptation dynamics remain consistent regardless of which part of the network we examine, which is an elegant property that a linear decay scheme would not give.

For a 12-layer BERT model with η=2×10−5\eta = 2 \times 10^{-5} and ξ=0.95\xi = 0.95:

Learning rates for each layer of a 12-layer BERT model using Layer-wise Learning Rate Decay (LLRD) with a decay factor of 0.95. The rates decrease geometrically from the top layer to the embeddings. This keeps lower layers remain more stable while upper layers adapt more to the specific task.
LayerLearning Rate
Layer 12 (top)2.0×10−52.0 \times 10^{-5}
Layer 111.9×10−51.9 \times 10^{-5}
Layer 101.81×10−51.81 \times 10^{-5}
Layer 61.47×10−51.47 \times 10^{-5}
Layer 1 (bottom)1.14×10−51.14 \times 10^{-5}
Embeddings1.08×10−51.08 \times 10^{-5}

This creates a smooth gradient of adaptation rates throughout the network. Notice that even the bottom layer still receives a real learning rate, roughly half of the top layer's rate. This shows the philosophy that all layers should be allowed to adapt, just to different degrees. The embeddings receive the lowest rate because they encode basic vocabulary semantics that should remain maximally stable across different downstream tasks. If the embedding representations shift sharply, all subsequent layers must relearn how to interpret them, potentially cascading disruption throughout the entire network.

VisualizationOut [3]
CollapseExpand
Using Python 3.11.14 environment at: /private/tmp/mb-language-ai-modern-plots/books/_quarto_language-ai-handbook/.venv
Checked 2 packages in 8ms
Horizontal bar chart showing learning rates decreasing geometrically from top transformer layer to embeddings.
Geometric decay of learning rates across transformer layers. The exponential relationship means lower layers receive progressively smaller updates, preserving general linguistic knowledge while letting upper layers to adapt to task-specific patterns.

Layer-wise Learning Rate Decay

Layer-wise learning rate decay (LLRD) is the most common implementation of discriminative fine-tuning for transformer models. It was popularized by the ULMFiT paper and has become standard practice for fine-tuning BERT and similar models. While the underlying mathematics are identical to discriminative fine-tuning, LLRD specifically stresses the layer-by-layer structure of modern transformer architectures and has been refined through extensive empirical study across dozens of NLP benchmarks.

Think of LLRD as a zoning ordinance for your network. Historical districts near the foundation of the city (the lower layers) face strict rules about what can be changed: minor renovations only, no demolition. Commercial zones at the top of the network have much more permissive rules, letting rapid development and transformation. The zoning shows not arbitrary preference but the actual character and function of each zone in the overall system.

What makes LLRD particularly effective for transformers is that the layer-by-layer structure maps directly onto the parameter group structure required by modern optimizers. You define one parameter group per layer and assign each group its own learning rate. The AdamW optimizer, which is the standard choice for transformer fine-tuning, handles per-group learning rates natively, so the implementation adds very little complexity over a single-learning-rate setup. The engineering cost is low, but the empirical benefits, particularly on smaller datasets, can be substantial.

Why Layer Position Matters

LLRD works because transformer representations are hierarchical. Varying learning rates by layer is based on research into what each layer learns during pre-training. Research on probing tasks, where simple classifiers are trained on frozen layer representations, has systematically mapped out this hierarchy with remarkable precision:

  • Embedding layers capture lexical semantics and basic word relationships. At this level, the model encodes what words mean in isolation and how they relate to other words with similar meanings. These representations are highly general and transfer well to virtually any downstream task. Damaging them would undermine the entire network.
  • Lower transformer layers (1-4 in BERT) capture syntactic structure and local dependencies. These layers develop sensitivity to part-of-speech patterns, phrase boundaries, and local word order. They learn to distinguish nouns from verbs, identify subject-verb relationships, and recognize basic grammatical patterns. These structural properties are shared across all syntactically regular language tasks.
  • Middle layers (5-8) develop richer semantic representations that integrate local syntactic information with broader contextual meaning. These layers begin to capture coreference, semantic roles, and the flow of meaning across sentences. They are more specialized than lower layers but still broadly useful.
  • Upper layers (9-12) capture task-relevant features that are more specific to the pre-training objective. For a model trained with masked language modeling, these layers specialize in predicting missing words given context. This specialization is useful but also means these representations are most tightly coupled to the pre-training task and farthest from what any given fine-tuning task might need.

When fine-tuning for a downstream task, the upper layers typically need the most adaptation because they were optimized for the pre-training task rather than your specific objective. Lower layers, which capture more universal linguistic properties that any NLP task would benefit from, need less adjustment. Aggressive updates to lower layers risk disrupting the carefully learned syntactic and semantic foundations that the entire model relies upon.

Implementing LLRD for Transformers

For transformer models, we typically define parameter groups corresponding to:

  1. Embedding layer: Token embeddings and position embeddings
  2. Encoder/decoder layers: Each transformer block as a separate group
  3. Task head: The newly added classification or regression layer
PythonIn [4]
CollapseExpand
!uv pip install transformers torch
import torch
import torch.nn as nn
import math
from transformers import AutoModel, BertConfig, BertModel
import matplotlib.pyplot as plt
import numpy as np

def create_llrd_parameter_groups(model, base_lr=2e-5, decay_factor=0.95, weight_decay=0.01):
    """
    Create parameter groups with layer-wise learning rate decay.

    Args:
        model: HuggingFace transformer model
        base_lr: Learning rate for the top layer
        decay_factor: Multiplicative decay per layer
        weight_decay: L2 regularization strength

    Returns:
        List of parameter group dictionaries for optimizer
    """
    parameter_groups = []

    # Get all encoder layers
    encoder_layers = model.encoder.layer if hasattr(model, 'encoder') else model.transformer.h
    num_layers = len(encoder_layers)

    # Embedding parameters - lowest learning rate
    embedding_params = []
    for name, param in model.named_parameters():
        if ('embedding' in name.lower() or 'embed' in name.lower()) and param.requires_grad:
            embedding_params.append(param)

    if embedding_params:
        embedding_lr = base_lr * (decay_factor ** num_layers)
        parameter_groups.append({
            'params': embedding_params,
            'lr': embedding_lr,
            'weight_decay': weight_decay,
            'group_name': 'embeddings'
        })

    # Layer-wise parameters
    for layer_idx, layer in enumerate(encoder_layers):
        layer_lr = base_lr * (decay_factor ** (num_layers - 1 - layer_idx))
        layer_params = [p for p in layer.parameters() if p.requires_grad]

        if layer_params:
            parameter_groups.append({
                'params': layer_params,
                'lr': layer_lr,
                'weight_decay': weight_decay,
                'group_name': f'layer_{layer_idx}'
            })

    # Pooler and any remaining top-level parameters
    remaining_params = []
    assigned_params = set()
    for group in parameter_groups:
        for p in group['params']:
            assigned_params.add(id(p))

    for name, param in model.named_parameters():
        if id(param) not in assigned_params and param.requires_grad:
            remaining_params.append(param)

    if remaining_params:
        parameter_groups.append({
            'params': remaining_params,
            'lr': base_lr,
            'weight_decay': weight_decay,
            'group_name': 'top'
        })

    return parameter_groups

Let's examine the learning rate distribution across layers:

PythonIn [5]
CollapseExpand
# Create a sample configuration to visualize
base_lr = 2e-5
decay_factor = 0.95
num_layers = 12

lr_distribution = {}
lr_distribution["embeddings"] = base_lr * (decay_factor**num_layers)

for layer_idx in range(num_layers):
    lr = base_lr * (decay_factor ** (num_layers - 1 - layer_idx))
    lr_distribution[f"layer_{layer_idx}"] = lr

lr_distribution["task_head"] = base_lr / decay_factor

# Prepare lists for visualization
layer_names = list(lr_distribution.keys())
learning_rates = list(lr_distribution.values())
ConsoleOut [6]
CollapseExpand
Learning rate distribution with LLRD:
Layer           Learning Rate  
------------------------------
embeddings      1.08e-05
layer_0         1.14e-05
layer_1         1.20e-05
layer_2         1.26e-05
layer_3         1.33e-05
layer_4         1.40e-05
layer_5         1.47e-05
layer_6         1.55e-05
layer_7         1.63e-05
layer_8         1.71e-05
layer_9         1.81e-05
layer_10        1.90e-05
layer_11        2.00e-05
task_head       2.11e-05

Learning rates decay geometrically through the layers. The embeddings receive the lowest rate (1.08×10−51.08 \times 10^{-5}). This keeps the model's basic vocabulary representations remain stable. This stability is important because the embedding layer defines the basic semantic space that all other layers build upon. If embeddings shift substantially during fine-tuning, the higher layers must relearn how to interpret these shifted representations, potentially causing cascading disruptions throughout the network.

PythonIn [7]
CollapseExpand
import matplotlib.pyplot as plt
import numpy as np

use_book_style()
plt.rcParams["figure.figsize"] = (6.0, 4.0)

fig, ax = plt.subplots()

x_positions = np.arange(len(layer_names))
colors = plt.cm.viridis(np.linspace(0.2, 0.8, len(layer_names)))

bars = ax.bar(x_positions, [lr * 1e5 for lr in learning_rates], color=colors)
ax.set_xticks(x_positions)
ax.set_xticklabels(layer_names, rotation=45, ha="right")
ax.set_ylabel(r"Learning Rate ($\times 10^{-5}$)")
ax.set_xlabel("Model Component")
ax.set_title(
    f"Layer-wise Learning Rate Decay (LLRD)\nBase LR = {base_lr:.0e}, Decay = {decay_factor}"
)
polish_axes(ax)

# Add value labels on bars
for bar, lr in zip(bars, learning_rates):
    ax.text(
        bar.get_x() + bar.get_width() / 2,
        bar.get_height() + 0.02,
        f"{lr:.1e}",
        ha="center",
        va="bottom",
        fontsize=8,
        rotation=45,
    )

This visualization shows the learning rate hierarchy. The embeddings and lower layers remain relatively stable to preserve basic language understanding, while the learning rate increases exponentially toward the task head to facilitate rapid adaptation to the new problem. The smooth progression ensures that no single layer boundary experiences a large discontinuity in adaptation rates, which could create optimization instabilities where one layer is changing rapidly relative to its neighbors.

Choosing the Decay Factor

The decay factor ξ\xi controls how aggressively learning rates differ between layers, and choosing the right value requires balancing two competing concerns. A decay factor too close to 1.0 gives insufficient differentiation between layers, losing the benefits of discriminative fine-tuning. A decay factor too small may prevent lower layers from adapting at all, which can be problematic if your task requires the model to learn new low-level patterns not well-represented in the pre-training data. The optimal value depends on your task and how much the pre-trained representations need to change:

  • ξ=1.0\xi = 1.0: No decay; all layers receive the same learning rate (standard fine-tuning). This is equivalent to ignoring the hierarchical structure of the network and may be appropriate when you have abundant data and the task differs substantially from pre-training.
  • ξ=0.95\xi = 0.95: Gentle decay; good default for most NLP tasks. This creates roughly a 2:1 ratio between the top and bottom layer learning rates. This gives differentiation while still allowing all layers to adapt.
  • ξ=0.9\xi = 0.9: Moderate decay; useful when lower layers should change minimally. This creates approximately a 3:1 ratio and is appropriate when you have limited data or when the pre-training domain closely matches your target task.
  • ξ=0.8\xi = 0.8: Aggressive decay; for tasks very similar to pre-training. This can create ratios of 5:1 or greater and in effect "freezes" the lower layers by giving them very small learning rates, putting all adaptation in the upper layers.

Empirically, values between 0.9 and 0.95 work well for most transformer fine-tuning scenarios. When in doubt, 0.95 gives a safe starting point that rarely hurts performance and often helps. For domain adaptation tasks where the target text differs substantially from the pre-training corpus, a slightly larger factor (closer to 1.0) may be warranted because even the lower layers need to adjust to the new vocabulary distribution and stylistic patterns.

VisualizationOut [9]
CollapseExpand
Line plot with multiple curves showing relative learning rates from embeddings to top transformer layer for decay factors from 0.8 to 1.0.
Impact of decay factor on the relative learning rate distribution across a 12-layer transformer. At factor 1.0 all layers receive identical rates (no protection), while factor 0.8 concentrates adaptation almost entirely in the upper layers, leaving lower layers with rates close to zero relative to the top.

Learning Rate Warmup for Fine-tuning

Warmup refers to starting training with a very small learning rate and gradually increasing it to the target value over a specified number of steps. This technique is especially important for fine-tuning. While warmup was originally motivated by considerations specific to the Adam optimizer and very deep networks, its benefits during fine-tuning arise from slightly different concerns: the unique vulnerability of pre-trained representations during the earliest phase of adaptation.

Think of warmup as the first few minutes of a workout. Jumping straight into maximum-intensity exercise risks injury precisely because the body is not yet prepared for peak exertion. Similarly, starting fine-tuning at the full target learning rate risks "injuring" the pre-trained representations before the optimizer has had a chance to build reliable gradient estimates. Warmup gives a controlled ramp-up period where the optimizer calibrates itself before making full-size updates.

The warmup phase serves several distinct but complementary purposes that interact to stabilize the beginning of fine-tuning. Understanding each of these purposes helps you reason about how long warmup should last and what can go wrong when it is omitted. In practice, models fine-tuned without warmup often show erratic early loss behavior, sometimes followed by eventual recovery, but frequently exhibiting worse final performance than runs that included warmup from the start.

Why Warmup Matters for Fine-tuning

At the start of fine-tuning, several factors make large learning rates dangerous:

  1. Task head initialization: The newly added classification head is randomly initialized while the pre-trained backbone is well-optimized. This creates an asymmetry in the network: the backbone produces real representations, but the randomly initialized head turns these into in effect random predictions. The gradients flowing back from this random head can be large and poorly directed. Large initial updates from the random head can destabilize the entire network, propagating noise deep into the pre-trained layers. Warmup allows the task head to "catch up" to the backbone before the learning rate becomes large enough to cause damage.

  2. Gradient variance: Early mini-batches may not be representative of the full dataset, leading to noisy gradient estimates. The first few batches might happen to contain unusual examples or reflect sampling artifacts rather than the true data distribution. Small learning rates during warmup prevent over-reacting to this noise, giving the optimizer time to aggregate gradient information from more representative samples.

  3. Adam statistics: As we discussed in the Adam optimizer chapter, Adam maintains running estimates of gradient moments, specifically the first moment (mean) and second moment (variance) of the gradients. These estimates are unreliable early in training because they are initialized to zero and require many updates to become accurate. The bias correction terms in Adam help, but they do not fully compensate for the lack of historical information. Warmup gives these moment estimates time to stabilize before making large parameter updates that rely on their accuracy.

The interaction between these three factors is multiplicative in the worst case. A poorly calibrated task head generating large, noisy gradients, evaluated against a dataset subset that is not representative, feeding into an Adam optimizer with unreliable moment estimates, could easily result in a catastrophic early update that erases key pre-trained representations. Warmup breaks this chain by so that no single factor has time to fully develop before the update magnitude is brought under control.

Linear warmup

The most common warmup schedule increases the learning rate linearly from 0 to the target value over the warmup period. This approach is simple to implement, easy to understand, and effective in practice.

We want to compute a learning rate that starts at zero on the first step and reaches the target value exactly at the end of the warmup period. At step tt during warmup, the learning rate is:

ηt=tTwarmup⋅ηtarget\eta_t = \frac{t}{T_{\text{warmup}}} \cdot \eta_{\text{target}}

where:

  • ηt\eta_t: the learning rate at step tt (what we compute and apply to the optimizer)
  • tt: the current training step, starting at 0
  • TwarmupT_{\text{warmup}}: the total number of warmup steps (defining the duration of the ramp-up phase)
  • ηtarget\eta_{\text{target}}: the target learning rate to reach at the end of warmup (the peak rate)

Why does this formula make sense? The ratio tTwarmup\frac{t}{T_{\text{warmup}}} is the fraction of the warmup phase completed, which grows linearly from 0 to 1 as tt increases from 0 to TwarmupT_{\text{warmup}}. Multiplying this fraction by ηtarget\eta_{\text{target}} scales the learning rate linearly from 0 up to the target value. The linear relationship means that the learning rate increases by a constant amount at each step. This gives a predictable and smooth ramp-up that is easy to reason about and debug.

For fine-tuning, warmup typically spans 5-10% of total training steps. For a fine-tuning run of 10,000 steps, you might use 500-1,000 warmup steps. This duration allows Adam's moment estimates to stabilize and the task head to begin learning patterns without wasting training time in an overly cautious exploration period.

PythonIn [10]
CollapseExpand
def linear_warmup_schedule(step, warmup_steps, target_lr):
    """
    Calculate learning rate during linear warmup.

    Args:
        step: Current training step
        warmup_steps: Number of warmup steps
        target_lr: Target learning rate after warmup

    Returns:
        Learning rate for this step
    """
    if step < warmup_steps:
        return target_lr * (step / warmup_steps)
    return target_lr


def warmup_with_decay_schedule(step, warmup_steps, total_steps, target_lr):
    """
    Linear warmup followed by linear decay to zero.

    This is the most common schedule for fine-tuning BERT-style models.
    """
    if step < warmup_steps:
        # Linear warmup
        return target_lr * (step / warmup_steps)
    else:
        # Linear decay
        decay_steps = total_steps - warmup_steps
        remaining_steps = total_steps - step
        return target_lr * (remaining_steps / decay_steps)

Let's visualize the warmup schedule:

PythonIn [11]
CollapseExpand
import matplotlib.pyplot as plt
import numpy as np

use_book_style()
plt.rcParams["figure.figsize"] = (6.0, 4.0)

total_steps = 10000
warmup_steps = 1000
target_lr = 2e-5

steps = np.arange(total_steps)
lrs = [linear_warmup_schedule(s, warmup_steps, target_lr) for s in steps]

fig, ax = plt.subplots()
ax.plot(steps, [lr * 1e5 for lr in lrs], linewidth=1)
ax.axvline(
    x=warmup_steps,
    color=theme_color("red"),
    linestyle="--",
    label="End of warmup",
)
ax.fill_between(
    steps[: warmup_steps + 1],
    0,
    [lr * 1e5 for lr in lrs[: warmup_steps + 1]],
    alpha=0.3,
    color=theme_color("orange"),
    label="Warmup phase",
)
ax.set_xlabel("Training Step")
ax.set_ylabel(r"Learning Rate ($\times 10^{-5}$)")
ax.set_title("Linear Warmup Schedule")
ax.legend()
ax.set_xlim(0, total_steps)
ax.set_ylim(0, target_lr * 1.1 * 1e5)
polish_axes(ax)

This schedule prevents the optimizer from making dangerously large updates during the initial training phase, when gradient estimates are most volatile. The shaded warmup region is the protective phase where the model's pre-trained knowledge is most vulnerable to disruption.

VisualizationOut [13]
CollapseExpand
Line plot showing three learning rate warmup curves for 5%, 10%, and 15% warmup ratios over the first 3000 training steps.
Comparison of warmup duration effects over the first 3,000 training steps. Shorter warmup (5%, 500 steps) reaches the peak learning rate faster but gives less stabilization time. Longer warmup (15%, 1,500 steps) gives more protection but delays the effective learning phase. The 10% default (1,000 steps) balances both concerns for most fine-tuning tasks.

Gradual Unfreezing as Warmup

A more aggressive warmup strategy combines warmup with gradual unfreezing. Instead of training all layers from the start with small learning rates, you begin by training only the task head, then progressively unfreeze layers from top to bottom as training proceeds:

  1. Epoch 1: Train only the task head
  2. Epoch 2: Unfreeze top transformer layers
  3. Epoch 3: Unfreeze middle layers
  4. Epoch 4+: Train all layers

This approach, introduced in ULMFiT, gives an extreme form of "warmup" where lower layers literally receive zero gradient until later in training. The philosophy is that by the time lower layers are unfrozen, the task head and upper layers have already adapted substantially. This gives more stable and informative gradients. The gradients that flow into the lower layers at the point of unfreezing are no longer the confused, high-variance signals of a randomly initialized network, but the more structured, task-relevant signals of a partially adapted one.

While effective, gradual unfreezing requires more epochs and careful tuning of the unfreezing schedule. It has fallen somewhat out of favor for transformer fine-tuning because LLRD reaches similar goals with less complexity and in fewer training epochs. However, it remains a useful technique when maximum caution is warranted, particularly in low-data regimes where even a brief period of high-variance gradient updates could cause irreversible damage to the pre-trained representations. If you find that standard LLRD with warmup still results in unstable early training, gradual unfreezing is worth adding to your toolkit.

Learning Rate Decay

After warmup completes, learning rate decay schedules reduce the rate throughout training. This helps the model converge to a stable minimum rather than oscillating around it. The intuition is that large learning rates are useful early in training when the model needs to make significant adjustments, but become counterproductive later when the model is close to a good solution and only needs fine adjustments.

Think of decay as the difference between making broad brushstrokes and fine detail work when painting. Early in the process, you want big, confident strokes that establish the overall composition. As the painting develops, you switch to finer tools for precise work. A painter who keeps using the broad brush throughout the entire process will never reach the detail that the final piece requires. Similarly, a constant learning rate throughout fine-tuning tends to produce models that have adapted well on average but oscillate around the optimal parameters rather than converging to them precisely.

The choice of decay schedule shapes the final convergence and the entire trajectory of learning. Different schedules allocate the "learning budget" differently across the training run: some front-load it (maintaining higher rates early), others distribute it more uniformly, and others back-load it (maintaining higher rates in the middle of training). These differences can have real effects on final performance, particularly for tasks where the model needs to make large initial adjustments to the fine-tuning task before settling into precise calibration.

Linear Decay

The simplest decay schedule reduces the learning rate linearly to zero (or some minimum value) by the end of training. This approach gives a steady, predictable reduction in learning rate that is easy to reason about and implement.

We want to compute a learning rate that starts at the target value immediately after warmup ends and decreases uniformly to zero at the final training step. After warmup ends at step TwarmupT_{\text{warmup}}, the learning rate at step tt is:

ηt=ηtarget⋅(1−t−TwarmupTtotal−Twarmup)\eta_t = \eta_{\text{target}} \cdot \left(1 - \frac{t - T_{\text{warmup}}}{T_{\text{total}} - T_{\text{warmup}}}\right)

where:

  • ηt\eta_t: the learning rate at step tt
  • ηtarget\eta_{\text{target}}: the learning rate at the start of decay (equal to the peak warmup value)
  • tt: the current training step
  • TwarmupT_{\text{warmup}}: the step number where decay begins (end of warmup)
  • TtotalT_{\text{total}}: the total number of training steps

Why does this formula make sense? The fraction t−TwarmupTtotal−Twarmup\frac{t - T_{\text{warmup}}}{T_{\text{total}} - T_{\text{warmup}}} is the progress through the decay phase, ranging from 0 at the start of decay to 1 at the final step. Subtracting this from 1 yields the remaining proportion of the learning rate. This creates a steady linear decline from ηtarget\eta_{\text{target}} down to 0. At each step during the decay phase, the learning rate decreases by exactly the same absolute amount. This keeps uniform reduction throughout training.

This is the default schedule for most BERT fine-tuning recipes because it strikes a reasonable balance between simplicity and effectiveness. The linear nature makes it trivial to predict the learning rate at any step, which is helpful for debugging and monitoring training progress.

Cosine Decay

Cosine decay gives a smoother transition that decays slowly at first, accelerates in the middle, and slows again near the end. This schedule has become increasingly popular because it maintains higher learning rates for longer during the middle of training, giving the optimizer more opportunity to explore the loss surface before settling into a final minimum.

We want to compute a smoothly decaying learning rate using the cosine function's natural properties. After warmup, the learning rate at step tt is:

ηt=ηmin+ηtarget−ηmin2⋅(1+cos⁡(π⋅t−TwarmupTtotal−Twarmup))\eta_t = \eta_{\text{min}} + \frac{\eta_{\text{target}} - \eta_{\text{min}}}{2} \cdot \left(1 + \cos\left(\pi \cdot \frac{t - T_{\text{warmup}}}{T_{\text{total}} - T_{\text{warmup}}}\right)\right)

where:

  • ηt\eta_t: the learning rate at step tt
  • ηmin\eta_{\text{min}}: the minimum learning rate to reach at the end of training (often 0)
  • ηtarget\eta_{\text{target}}: the target learning rate at the start of decay (peak warmup value)
  • tt: the current training step
  • TwarmupT_{\text{warmup}}: the step number where decay begins
  • TtotalT_{\text{total}}: the total number of training steps

Why does this formula produce an S-shaped decay? We can trace the mathematical transformations step by step to see the mechanism clearly:

p=t−TwarmupTtotal−Twarmup(progress fraction: 0 at start, 1 at end)θ=π⋅p(angle mapping: 0 to π radians)c=cos⁡(θ)(cosine: 1 at start, -1 at end, slow near endpoints)ηt=ηmin+ηtarget−ηmin2(1+c)(scale to desired range)\begin{aligned} p &= \frac{t - T_{\text{warmup}}}{T_{\text{total}} - T_{\text{warmup}}} && \text{(progress fraction: 0 at start, 1 at end)} \\ \theta &= \pi \cdot p && \text{(angle mapping: 0 to } \pi \text{ radians)} \\ c &= \cos(\theta) && \text{(cosine: 1 at start, -1 at end, slow near endpoints)} \\ \eta_t &= \eta_{\text{min}} + \frac{\eta_{\text{target}} - \eta_{\text{min}}}{2}(1 + c) && \text{(scale to desired range)} \end{aligned}

The cosine transform is what creates the S-curve behavior. The derivative of cos⁡(θ)\cos(\theta) is −sin⁡(θ)-\sin(\theta), which equals zero at θ=0\theta = 0 and θ=π\theta = \pi (the endpoints) but reaches its maximum magnitude at θ=π/2\theta = \pi/2 (the midpoint). This means the cosine schedule changes slowly at the beginning and end, and rapidly in the middle, which is exactly the behavior we want: the model maintains higher learning rates early to explore broadly, transitions through a rapid reduction phase in the middle, and then approaches its final value gently without overshooting.

PythonIn [14]
CollapseExpand
import math


def cosine_decay_schedule(step, warmup_steps, total_steps, target_lr, min_lr=0):
    """
    Linear warmup followed by cosine decay.

    Args:
        step: Current training step
        warmup_steps: Number of warmup steps
        total_steps: Total number of training steps
        target_lr: Target learning rate after warmup
        min_lr: Minimum learning rate at end of training
    """
    if step < warmup_steps:
        return target_lr * (step / warmup_steps)
    else:
        progress = (step - warmup_steps) / (total_steps - warmup_steps)
        return min_lr + (target_lr - min_lr) * 0.5 * (
            1 + math.cos(math.pi * progress)
        )


def polynomial_decay_schedule(
    step, warmup_steps, total_steps, target_lr, min_lr=0, power=1.0
):
    """
    Linear warmup followed by polynomial decay.

    Power=1.0 gives linear decay, power=2.0 gives quadratic decay.
    """
    if step < warmup_steps:
        return target_lr * (step / warmup_steps)
    else:
        progress = (step - warmup_steps) / (total_steps - warmup_steps)
        decay = (1 - progress) ** power
        return min_lr + (target_lr - min_lr) * decay

Let's compare these decay schedules:

PythonIn [15]
CollapseExpand
import matplotlib.pyplot as plt
import numpy as np

use_book_style()
plt.rcParams["figure.figsize"] = (6.0, 4.0)

total_steps = 10000
warmup_steps = 1000
target_lr = 2e-5

steps = np.arange(total_steps)

linear_lrs = [
    warmup_with_decay_schedule(s, warmup_steps, total_steps, target_lr)
    for s in steps
]
cosine_lrs = [
    cosine_decay_schedule(s, warmup_steps, total_steps, target_lr)
    for s in steps
]
poly2_lrs = [
    polynomial_decay_schedule(
        s, warmup_steps, total_steps, target_lr, power=2.0
    )
    for s in steps
]

fig, ax = plt.subplots()
ax.plot(
    steps, [lr * 1e5 for lr in linear_lrs], label="Linear decay", linewidth=1
)
ax.plot(
    steps, [lr * 1e5 for lr in cosine_lrs], label="Cosine decay", linewidth=1
)
ax.plot(
    steps, [lr * 1e5 for lr in poly2_lrs], label="Polynomial (p=2)", linewidth=1
)
ax.axvline(
    x=warmup_steps,
    color=PALETTE["muted"],
    linestyle="--",
    alpha=0.5,
    label="End warmup",
)
ax.set_xlabel("Training Step")
ax.set_ylabel(r"Learning Rate ($\times 10^{-5}$)")
ax.set_title("Comparison of Learning Rate Decay Schedules")
ax.legend()
ax.set_xlim(0, total_steps)
polish_axes(ax)

While all three schedules eventually reach zero, they allocate learning rate differently throughout training. Cosine decay maintains higher learning rates for longer in the middle of training, potentially letting the model to escape local minima more effectively than linear decay. Polynomial decay with power 2 drops quickly at first and then more slowly, front-loading the high learning rate period. The choice between these schedules is a trade-off between exploration and convergence speed. Linear decay is the most predictable and easiest to reason about; cosine decay is often slightly better in practice due to its gentler terminal convergence; polynomial decay gives the most control through its exponent parameter.

Choosing a Decay Schedule

The choice of decay schedule depends on your training dynamics and the specific characteristics of your task:

  • Linear decay: Simple and effective; good default for short fine-tuning runs. The predictable reduction makes it easy to reason about training dynamics and diagnose issues such as learning rate being too high or too low.
  • Cosine decay: Often slightly better performance; allows more learning early and late in training. The gentler decay at both ends can help the model both explore better initially and converge more precisely at the end. It is the recommended default when you have sufficient compute for even a modest hyperparameter search.
  • Polynomial decay: Useful when you want to front-load learning (higher powers) or extend it (lower powers). The exponent parameter gives direct control over the shape of the decay curve, making this a flexible option for tasks with unusual training dynamics.

Empirically, cosine and linear decay perform similarly for most fine-tuning tasks, with cosine sometimes giving a small but consistent improvement. The difference is often smaller than the effect of other hyperparameters like the base learning rate or batch size, so either choice is reasonable for most applications. When in doubt, cosine decay is the better default because its smooth behavior near both endpoints reduces the risk of numerical instability.

Worked Example: Fine-tuning BERT for Sentiment Analysis

To make the abstract mechanics concrete, let's trace through a specific scenario: fine-tuning BERT-base-uncased for binary sentiment classification. We will compute the exact learning rates at representative steps throughout the training run and show how the combined schedule behaves in practice.

Setup: 8 epochs over a dataset of 25,000 training examples with batch size 32. This gives us approximately 25000×832=6,250\frac{25000 \times 8}{32} = 6{,}250 training steps total. We use a 10% warmup (625 steps), base learning rate η=2×10−5\eta = 2 \times 10^{-5}, LLRD decay factor ξ=0.95\xi = 0.95, and cosine decay.

Step 1: Compute the per-layer peak learning rates (end of warmup)

At the end of warmup, each layer reaches its assigned peak rate. Using the LLRD formula ηl=η⋅ξL−l\eta_l = \eta \cdot \xi^{L-l} with L=12L=12, η=2×10−5\eta = 2 \times 10^{-5}, ξ=0.95\xi = 0.95:

ηembeddings=2×10−5⋅0.9512≈1.07×10−5ηlayer 1=2×10−5⋅0.9511≈1.13×10−5ηlayer 6=2×10−5⋅0.956≈1.48×10−5ηlayer 12=2×10−5⋅0.950=2.00×10−5ηtask head=2×10−5×10=2.00×10−4\begin{aligned} \eta_{\text{embeddings}} &= 2 \times 10^{-5} \cdot 0.95^{12} \approx 1.07 \times 10^{-5} \\ \eta_{\text{layer 1}} &= 2 \times 10^{-5} \cdot 0.95^{11} \approx 1.13 \times 10^{-5} \\ \eta_{\text{layer 6}} &= 2 \times 10^{-5} \cdot 0.95^{6} \approx 1.48 \times 10^{-5} \\ \eta_{\text{layer 12}} &= 2 \times 10^{-5} \cdot 0.95^{0} = 2.00 \times 10^{-5} \\ \eta_{\text{task head}} &= 2 \times 10^{-5} \times 10 = 2.00 \times 10^{-4} \end{aligned}

Step 2: Compute learning rates at 25% of training (step 1,563)

At this point we have been past warmup for 1563−625=9381563 - 625 = 938 steps. The cosine decay progress fraction is:

p=9386250−625=9385625≈0.167p = \frac{938}{6250 - 625} = \frac{938}{5625} \approx 0.167

The cosine multiplier is:

m=0.5⋅(1+cos⁡(π⋅0.167))=0.5⋅(1+cos⁡(0.524))≈0.5⋅(1+0.866)=0.933m = 0.5 \cdot (1 + \cos(\pi \cdot 0.167)) = 0.5 \cdot (1 + \cos(0.524)) \approx 0.5 \cdot (1 + 0.866) = 0.933

The task head learning rate at this step is therefore 2.00×10−4×0.933=1.87×10−42.00 \times 10^{-4} \times 0.933 = 1.87 \times 10^{-4}. The embedding rate is 1.07×10−5×0.933=9.98×10−61.07 \times 10^{-5} \times 0.933 = 9.98 \times 10^{-6}.

Step 3: Compute learning rates at 75% of training (step 4,688)

The cosine decay progress fraction is now:

p=4688−6255625=40635625≈0.722p = \frac{4688 - 625}{5625} = \frac{4063}{5625} \approx 0.722

The cosine multiplier drops substantially:

m=0.5⋅(1+cos⁡(π⋅0.722))=0.5⋅(1+cos⁡(2.269))≈0.5⋅(1−0.637)=0.181m = 0.5 \cdot (1 + \cos(\pi \cdot 0.722)) = 0.5 \cdot (1 + \cos(2.269)) \approx 0.5 \cdot (1 - 0.637) = 0.181

The task head is now at 2.00×10−4×0.181=3.62×10−52.00 \times 10^{-4} \times 0.181 = 3.62 \times 10^{-5}. The embedding rate has dropped to 1.07×10−5×0.181=1.94×10−61.07 \times 10^{-5} \times 0.181 = 1.94 \times 10^{-6}, a tiny fraction of its peak value.

Key observation: By 75% of training, the cosine schedule has reduced all learning rates to about 18% of their peak values. The relative hierarchy between layers is preserved (the task head is always 10x the backbone top rate, which is always higher than the embedding rate), but all rates are scaling down together. This is the combined effect we want: LLRD sets the relative rates at each layer, while the decay schedule controls the global scale over time.

Complete Implementation

Let's combine these techniques into a complete fine-tuning setup. We will create a training configuration with layer-wise learning rates and a warmup-plus-cosine schedule. This implementation shows how the individual components work together in practice rather than as a collection of independent tricks.

PythonIn [17]
CollapseExpand
import math

from torch.optim import AdamW
from torch.optim.lr_scheduler import LambdaLR


class FineTuningConfig:
    """Configuration for fine-tuning with advanced learning rate strategies."""

    def __init__(
        self,
        base_lr=2e-5,
        llrd_decay=0.95,
        weight_decay=0.01,
        warmup_ratio=0.1,
        num_training_steps=10000,
        schedule="cosine",
    ):
        self.base_lr = base_lr
        self.llrd_decay = llrd_decay
        self.weight_decay = weight_decay
        self.warmup_ratio = warmup_ratio
        self.num_training_steps = num_training_steps
        self.warmup_steps = int(warmup_ratio * num_training_steps)
        self.schedule = schedule


def create_optimizer_and_scheduler(model, task_head, config):
    """
    Create optimizer with LLRD and scheduler with warmup + decay.

    Args:
        model: Pre-trained transformer model
        task_head: Task-specific classification head
        config: FineTuningConfig instance

    Returns:
        Tuple of (optimizer, scheduler)
    """
    # Get encoder layers for LLRD
    if hasattr(model, "encoder"):
        encoder_layers = list(model.encoder.layer)
    elif hasattr(model, "transformer"):
        encoder_layers = list(model.transformer.h)
    else:
        encoder_layers = []

    num_layers = len(encoder_layers)
    parameter_groups = []

    # Embeddings - lowest learning rate
    embedding_params = []
    for name, param in model.named_parameters():
        if "embed" in name.lower() and param.requires_grad:
            embedding_params.append(param)

    if embedding_params:
        embedding_lr = config.base_lr * (config.llrd_decay**num_layers)
        parameter_groups.append(
            {
                "params": embedding_params,
                "lr": embedding_lr,
                "weight_decay": config.weight_decay,
            }
        )

    # Encoder layers with LLRD
    processed_params = set(id(p) for p in embedding_params)

    for layer_idx, layer in enumerate(encoder_layers):
        layer_lr = config.base_lr * (
            config.llrd_decay ** (num_layers - 1 - layer_idx)
        )
        layer_params = [
            p
            for p in layer.parameters()
            if p.requires_grad and id(p) not in processed_params
        ]

        if layer_params:
            parameter_groups.append(
                {
                    "params": layer_params,
                    "lr": layer_lr,
                    "weight_decay": config.weight_decay,
                }
            )
            processed_params.update(id(p) for p in layer_params)

    # Remaining model parameters (pooler, etc.)
    remaining_params = [
        p
        for name, p in model.named_parameters()
        if p.requires_grad and id(p) not in processed_params
    ]

    if remaining_params:
        parameter_groups.append(
            {
                "params": remaining_params,
                "lr": config.base_lr,
                "weight_decay": config.weight_decay,
            }
        )

    # Task head - highest learning rate (often 10x base)
    task_head_lr = config.base_lr * 10
    parameter_groups.append(
        {
            "params": list(task_head.parameters()),
            "lr": task_head_lr,
            "weight_decay": config.weight_decay,
        }
    )

    # Create optimizer
    optimizer = AdamW(parameter_groups, betas=(0.9, 0.999), eps=1e-8)

    # Create scheduler
    def lr_lambda(current_step):
        if current_step < config.warmup_steps:
            return float(current_step) / float(max(1, config.warmup_steps))

        if config.schedule == "linear":
            progress = float(current_step - config.warmup_steps) / float(
                max(1, config.num_training_steps - config.warmup_steps)
            )
            return max(0.0, 1.0 - progress)

        elif config.schedule == "cosine":
            progress = float(current_step - config.warmup_steps) / float(
                max(1, config.num_training_steps - config.warmup_steps)
            )
            return max(0.0, 0.5 * (1.0 + math.cos(math.pi * progress)))

        return 1.0

    scheduler = LambdaLR(optimizer, lr_lambda)

    return optimizer, scheduler

Let's see this in action with a simple classification task:

PythonIn [18]
CollapseExpand
import torch
from transformers import BertConfig, BertModel

# Create a small model for demonstration (avoids downloading weights)
bert_config = BertConfig(
    hidden_size=128,
    num_hidden_layers=6,
    num_attention_heads=2,
    intermediate_size=512,
)
model = BertModel(bert_config)


# Create a simple task head
class ClassificationHead(torch.nn.Module):
    def __init__(self, hidden_size, num_classes):
        super().__init__()
        self.dropout = torch.nn.Dropout(0.1)
        self.classifier = torch.nn.Linear(hidden_size, num_classes)

    def forward(self, pooled_output):
        x = self.dropout(pooled_output)
        return self.classifier(x)


task_head = ClassificationHead(model.config.hidden_size, num_classes=2)

# Create fine-tuning configuration
config = FineTuningConfig(
    base_lr=2e-5,
    llrd_decay=0.95,
    warmup_ratio=0.1,
    num_training_steps=1000,
    schedule="cosine",
)

# Create optimizer and scheduler
optimizer, scheduler = create_optimizer_and_scheduler(model, task_head, config)

# Calculate parameter stats for display
param_group_stats = []
for i, group in enumerate(optimizer.param_groups):
    num_params = sum(p.numel() for p in group["params"])
    param_group_stats.append(
        {"group_idx": i, "lr": group["lr"], "num_params": num_params}
    )
ConsoleOut [19]
CollapseExpand
Fine-tuning Configuration
==================================================
Base learning rate: 2.00e-05
LLRD decay factor: 0.95
Warmup steps: 100
Total training steps: 1,000
Schedule: cosine

Learning Rates per Parameter Group:
--------------------------------------------------
Group 0: LR = 0.00e+00, Params = 3,972,864
Group 1: LR = 0.00e+00, Params = 198,272
Group 2: LR = 0.00e+00, Params = 198,272
Group 3: LR = 0.00e+00, Params = 198,272
Group 4: LR = 0.00e+00, Params = 198,272
Group 5: LR = 0.00e+00, Params = 198,272
Group 6: LR = 0.00e+00, Params = 198,272
Group 7: LR = 0.00e+00, Params = 16,512
Group 8: LR = 0.00e+00, Params = 258

In this configuration, the task head has a higher learning rate (2.00×10−42.00 \times 10^{-4}) than the embeddings. This hierarchy ensures that the randomly initialized task head can adapt quickly while the pre-trained backbone layers are updated more cautiously. Notice that the absolute difference between groups is largest at the top of the network and compresses toward the bottom. This shows the geometric nature of LLRD.

Now let's simulate a training run and visualize how learning rates evolve:

PythonIn [20]
CollapseExpand
import matplotlib.pyplot as plt
import numpy as np

use_book_style()
plt.rcParams["figure.figsize"] = (6.0, 4.0)

# Reset optimizer state and track learning rates over training
optimizer, scheduler = create_optimizer_and_scheduler(model, task_head, config)

# Track LRs for selected groups
lr_history = {f"Group {i}": [] for i in range(len(optimizer.param_groups))}

for step in range(config.num_training_steps):
    # Record current learning rates
    for i, group in enumerate(optimizer.param_groups):
        lr_history[f"Group {i}"].append(group["lr"])

    # Step the scheduler (in real training, this happens after optimizer.step())
    scheduler.step()

# Plot selected groups for clarity
fig, ax = plt.subplots()
steps = np.arange(config.num_training_steps)

# Plot embedding layer (lowest LR)
ax.plot(
    steps,
    [lr * 1e5 for lr in lr_history["Group 0"]],
    label="Embeddings",
    linewidth=1,
    alpha=0.8,
)

# Plot a middle transformer layer
mid_group = len(optimizer.param_groups) // 2
ax.plot(
    steps,
    [lr * 1e5 for lr in lr_history[f"Group {mid_group}"]],
    label="Middle layers",
    linewidth=1,
    alpha=0.8,
)

# Plot top transformer layer
ax.plot(
    steps,
    [lr * 1e5 for lr in lr_history[f"Group {len(optimizer.param_groups) - 2}"]],
    label="Top layers",
    linewidth=1,
    alpha=0.8,
)

# Plot task head (highest LR)
ax.plot(
    steps,
    [lr * 1e5 for lr in lr_history[f"Group {len(optimizer.param_groups) - 1}"]],
    label="Task head",
    linewidth=1,
    alpha=0.8,
)

ax.axvline(
    x=config.warmup_steps, color=PALETTE["muted"], linestyle="--", alpha=0.5
)
ax.annotate(
    "End warmup",
    xy=(config.warmup_steps, 0),
    xytext=(config.warmup_steps + 50, 3.0),
    fontsize=8,
    color=PALETTE["ink"],
    bbox=dict(
        boxstyle="round,pad=0.3",
        facecolor=PALETTE["paper"],
        edgecolor="none",
        alpha=0.92,
    ),
    zorder=4,
)

ax.set_xlabel("Training Step")
ax.set_ylabel(r"Learning Rate ($\times 10^{-5}$)")
ax.set_title(
    "Learning Rate Evolution During Fine-tuning\n(LLRD + Warmup + Cosine Decay)"
)
ax.legend(loc="upper right")
ax.set_xlim(0, config.num_training_steps)
polish_axes(ax)

The separate trajectories show the combined effect of our strategies: all layers warm up together, but they peak at different values due to LLRD, and then decay in sync according to the cosine schedule. The task head's learning rate is an order of magnitude larger than the embeddings' rate throughout training. This shows our confidence that the task head needs rapid adaptation while the embeddings should remain relatively stable.

VisualizationOut [22]
CollapseExpand
Color heatmap with parameter groups on y-axis and training steps on x-axis, showing warm colors for higher learning rates in the task head.
Heatmap of learning rate magnitude across all parameter groups and training steps. Warm colors indicate higher learning rates concentrated in the task head row, while cool colors in the embedding row confirm that lower layers receive much more conservative updates throughout training. The warmup-then-decay pattern is visible as a ridge across the horizontal dimension.

Complete Training Loop Example

Here is how these components integrate into a complete fine-tuning loop. Notice how the optimizer step, gradient clipping, and scheduler step must occur in the correct order: compute gradients, clip them, apply the optimizer update, then advance the scheduler. Reversing any of these steps would break the intended behavior.

PythonIn [38]
CollapseExpand
def fine_tune_model(
    model, task_head, train_dataloader, val_dataloader, config, device="cuda"
):
    """
    Complete fine-tuning loop with LLRD, warmup, and decay.
    """
    model.to(device)
    task_head.to(device)

    optimizer, scheduler = create_optimizer_and_scheduler(
        model, task_head, config
    )
    criterion = torch.nn.CrossEntropyLoss()

    model.train()
    task_head.train()

    global_step = 0
    best_val_accuracy = 0

    for epoch in range(num_epochs):
        epoch_loss = 0

        for batch in train_dataloader:
            input_ids = batch["input_ids"].to(device)
            attention_mask = batch["attention_mask"].to(device)
            labels = batch["labels"].to(device)

            # Forward pass
            outputs = model(input_ids, attention_mask=attention_mask)
            pooled_output = outputs.last_hidden_state[:, 0]  # CLS token
            logits = task_head(pooled_output)

            loss = criterion(logits, labels)

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

            # Gradient clipping (important for stability)
            torch.nn.utils.clip_grad_norm_(
                list(model.parameters()) + list(task_head.parameters()),
                max_norm=1.0,
            )

            optimizer.step()
            scheduler.step()

            global_step += 1
            epoch_loss += loss.item()

            # Log learning rates periodically
            if global_step % 100 == 0:
                current_lr = scheduler.get_last_lr()[0]
                print(
                    f"Step {global_step}: Loss = {loss.item():.4f}, LR = {current_lr:.2e}"
                )

        # Validation
        val_accuracy = evaluate(model, task_head, val_dataloader, device)
        print(f"Epoch {epoch + 1}: Val Accuracy = {val_accuracy:.4f}")

        if val_accuracy > best_val_accuracy:
            best_val_accuracy = val_accuracy
            # Save best model checkpoint
            torch.save(
                {
                    "model_state_dict": model.state_dict(),
                    "task_head_state_dict": task_head.state_dict(),
                    "optimizer_state_dict": optimizer.state_dict(),
                },
                "best_model.pt",
            )

    return best_val_accuracy

Practical Guidelines

These learning rate strategies have been refined through extensive empirical study across many NLP benchmarks, and certain practical patterns have emerged as reliable defaults. Understanding these defaults and why they differ across model sizes helps you adapt them intelligently to novel scenarios.

For BERT-base and similar models (110M parameters), the relatively modest size means that even aggressive learning rates rarely cause catastrophic failure; the model has enough capacity to recover from moderate perturbations. Recommended settings:

  • Base learning rate: 2×10−52 \times 10^{-5} to 5×10−55 \times 10^{-5}
  • LLRD decay: 0.9 to 0.95
  • Warmup: 6-10% of training steps
  • Task head learning rate: 10x base rate

For larger models (GPT-2, RoBERTa-large, 340M+ parameters), the increased depth means that gradient signals must travel through more layers, amplifying any instabilities in the optimization dynamics. More conservative settings are needed:

  • Base learning rate: 10−510^{-5} to 2×10−52 \times 10^{-5} (lower due to more layers)
  • LLRD decay: 0.85 to 0.95
  • Warmup: 10% of training steps
  • Consider lower weight decay (0.001 instead of 0.01)

For very large models (1B+ parameters), the sensitivity to learning rate is considerably higher, and even small overestimation of the learning rate can trigger training instability. Parameter-efficient methods like LoRA are often preferable for this regime, but when full fine-tuning is necessary:

  • Base learning rate: 5×10−65 \times 10^{-6} to 10−510^{-5}
  • More aggressive LLRD decay: 0.8 to 0.9
  • Longer warmup: 10-15% of training steps
  • Often better to use parameter-efficient methods like LoRA, which we'll cover in upcoming chapters

General diagnostic principles that apply across all model sizes:

  • Start with the lower end of learning rate ranges and increase if learning is too slow
  • Monitor both training loss and validation metrics; overfitting indicates the rate may be too high
  • If loss spikes early, increase warmup steps or decrease initial learning rate
  • If loss plateaus too early, try a higher learning rate or different decay schedule
  • Loss that decreases smoothly in training but stagnates on validation often indicates the decay schedule is letting the model to overfit; try reducing the warmup ratio to allow more steps at full rate, or switch to cosine decay if you are using linear
VisualizationsOut [23]
CollapseExpand
Bar chart showing recommended base learning rates decreasing from BERT-base to large LLMs.
Recommended base learning rates for different model sizes. Larger models require materially lower rates to prevent optimization instability, with the midpoint falling from around 3.5e-5 for BERT-base to 0.75e-5 for billion-parameter LLMs.
Bar chart showing recommended LLRD decay factors decreasing from BERT-base at 0.93 to large LLMs at 0.85.
Recommended layer-wise learning rate decay (LLRD) factors by model size. Larger models benefit from more aggressive decay (lower factors) to more strongly protect pre-trained lower-layer representations from being disrupted by fine-tuning.

Limitations and Impact

These learning rate strategies have materially improved fine-tuning results across many NLP tasks, but they come with important caveats that are easy to overlook when applying them as recipes without understanding their assumptions.

The effectiveness of layer-wise learning rate decay depends fundamentally on the assumption that lower layers capture more general, transferable features while upper layers capture more task-specific ones. This assumption holds strongly for models pre-trained with masked language modeling or causal language modeling on diverse text corpora. But it may not apply universally. For tasks that differ substantially from the pre-training distribution, such as fine-tuning a general English model for a specialized scientific domain with unusual notation and vocabulary, the lower layers may need as much adaptation as the upper ones. In domain adaptation scenarios, practitioners have sometimes found that uniform learning rates perform equally well or better than LLRD, suggesting that the hierarchy of "what needs to change" can invert for out-of-distribution tasks. The key insight is that LLRD is not a universal law but a heuristic grounded in a specific empirical pattern about what transformers learn, and that heuristic can fail when the conditions change.

The interaction between these techniques and other hyperparameters creates a complex, high-dimensional optimization problem. A learning rate that works perfectly with dropout rate 0.1 might be suboptimal with dropout rate 0.3. A warmup ratio of 10% that works well with batch size 32 might be too short with batch size 8, where each step covers fewer examples and gradient estimates are noisier. The LLRD decay factor that reaches the best results with 3 fine-tuning epochs might not be optimal for 10 epochs, because more epochs mean more opportunities for lower-layer drift that the decay factor might need to compensate for. This interdependence means that a complete hyperparameter search, while expensive, often yields better results than following any single recipe. A grid search over the base learning rate and decay factor, with a warmup ratio fixed at 10%, is a common approach when compute allows.

The computational overhead of the combined schedule is minimal: it adds negligible time to the forward-backward pass. But the engineering complexity is not trivial for practitioners new to the field. Maintaining separate learning rates for dozens of parameter groups, coordinating warmup schedules across distributed training setups, and debugging issues where the wrong parameter group received the wrong rate all add development time and potential for subtle bugs. For rapid prototyping or initial experiments, uniform learning rates with basic warmup often give 90% of the benefit with much less engineering overhead. The Hugging Face Trainer class and similar high-level abstractions have helped by implementing these strategies as defaults, reducing the barrier to using them correctly.

These techniques also do not address all fine-tuning challenges. Catastrophic forgetting can still occur even with optimal learning rates, particularly on small datasets or with many training epochs. Methods like learning rate manipulation cannot prevent the model from overfitting if the fine-tuning dataset is too small relative to the number of trainable parameters. As we explore in upcoming chapters on parameter-efficient fine-tuning, methods like LoRA offer complementary approaches that limit the number of parameters updated rather than just controlling how fast each parameter changes. In many practical scenarios, combining LLRD and warmup with LoRA or similar techniques gives the best of both worlds: carefully controlled adaptation dynamics applied to a small, targeted set of parameters.

Finally, it is worth acknowledging that these techniques were largely developed and validated on BERT-style encoder models fine-tuned for classification tasks. Their applicability to decoder-only language models fine-tuned with instruction tuning or RLHF, to multi-modal models, or to architectures with materially different layer structures may require re-investigation. The field is actively studying these questions, and the recommendations in this chapter should be treated as strong empirical starting points rather than definitive theoretical conclusions.

Summary

Fine-tuning learning rates require careful consideration that goes beyond simply selecting a single value. The strategies covered in this chapter address different aspects of the fine-tuning challenge, and together they form a principled framework for protecting pre-trained knowledge while letting real task-specific adaptation.

Layer-wise learning rate decay assigns different learning rates to different layers based on the principle that lower layers capture general features (requiring less adaptation) while upper layers capture task-specific features (requiring more adaptation). Using a decay factor between 0.9 and 0.95 across layers typically gives a good balance between differentiation and flexibility, with the geometric decay formula ηl=η⋅ξL−l\eta_l = \eta \cdot \xi^{L-l} so that the ratio between adjacent layers remains constant throughout the network.

Learning rate warmup gradually increases the learning rate from zero during the first 5-10% of training. This stabilizes optimization by letting Adam's moment estimates to accumulate, preventing the randomly initialized task head from sending large, noisy gradients into the pre-trained backbone, and giving early mini-batch gradient estimates time to become more representative of the full data distribution.

Learning rate decay reduces the rate throughout training after warmup, helping the model converge to a stable minimum rather than oscillating around it. Cosine decay is often the preferred choice because its smooth behavior near both the start and end of the decay phase reduces the risk of instability during transitions. Linear decay gives a simpler alternative that works equally well in most cases.

Combining these techniques creates a reliable fine-tuning setup: warmup followed by decay sets the global learning rate trajectory, while LLRD distributes that trajectory differentially across the network hierarchy. The task head typically receives 10x the base learning rate since it starts from random initialization and needs aggressive training from the outset. Understanding why each component works, not just how to configure it, allows you to adapt these strategies intelligently when facing unusual tasks, architectures, or data conditions that fall outside the standard BERT fine-tuning regime.

These strategies stand for one dimension of efficient fine-tuning. In the next chapter, we will examine data efficiency: how to get the most out of limited labeled data when adapting pre-trained models to new tasks.

Quiz

Ready to test your understanding? Take this quick quiz to reinforce what you've learned about fine-tuning learning rate strategies.

Fine-tuning Learning Rates

Question 1 of 80 of 8 completed
In layer-wise learning rate decay (LLRD), which layers receive the highest learning rates?
Track your reading progress

Sign in to mark sections as read and track your learning journey

Sign in →
Join the discussionAsk a question or share what you learned.

Comments

Add to the conversation

A question, a correction, or an example from your own work.

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

A different angle
All writing
Language AI Handbook · Vol. 11

Fine-tuning Data Efficiency: Few-Shot Learning, Augmentation

Covers few-shot fine-tuning techniques for language models. Topics include PET, SetFit, and data augmentation to achieve strong results.

46 min read
Language AI Handbook · Vol. 11

Catastrophic Forgetting in Fine-Tuning: Causes & Mitigation

Why neural networks lose earlier capabilities during fine-tuning, with mitigation methods including EWC, L2-SP regularization, replay, and adapters.

66 min read
Language AI Handbook · Vol. 11

Full Fine-tuning: Hyperparameters & Learning Rate Schedules

Covers full fine-tuning of pre-trained models. Topics include optimal learning rates, batch sizes, warmup schedules, and gradient accumulation techniques.

66 min read

About the author

Reference

Citation details

Cite or share this article.

BIBTEXAcademic
@misc{brenndoerfer2025finetuning, author = {Michael Brenndoerfer}, title = {Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies}, year = {2025}, url = {https://mbrenndoerfer.com/writing/fine-tuning-learning-rates-llrd-warmup-decay-transformers}, organization = {mbrenndoerfer.com}, note = {Accessed: 2026-10-27} }
APAAcademic
Michael Brenndoerfer (2025). Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies. Retrieved from https://mbrenndoerfer.com/writing/fine-tuning-learning-rates-llrd-warmup-decay-transformers
MLAAcademic
Michael Brenndoerfer. "Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies." 2026. Web. October 27, 2026. <https://mbrenndoerfer.com/writing/fine-tuning-learning-rates-llrd-warmup-decay-transformers>.
CHICAGOAcademic
Michael Brenndoerfer. "Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies." Accessed October 27, 2026. https://mbrenndoerfer.com/writing/fine-tuning-learning-rates-llrd-warmup-decay-transformers.
HARVARDAcademic
Michael Brenndoerfer (2025) 'Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies'. Available at: https://mbrenndoerfer.com/writing/fine-tuning-learning-rates-llrd-warmup-decay-transformers (Accessed: October 27, 2026).
SimpleBasic
Michael Brenndoerfer (2025). Fine-tuning Learning Rates: LLRD, Warmup & Decay Strategies. https://mbrenndoerfer.com/writing/fine-tuning-learning-rates-llrd-warmup-decay-transformers
Newsletter

Stay up to date

Get new articles, book updates, and notes on AI and technology.

Unsubscribe at any time. Privacy policy