Continual Learning: Continuous Learning Without Forgetting

Continual Learning : Guide Complet — Apprentissage Continu sans Oubli

Continual Learning: Complete Guide for Continuous Learning Without Catastrophic Forgetting

Summary

Continual Learning is a fundamental machine learning paradigm in which a model sequentially learns a series of tasks without having access to data from previous tasks, while preserving its prior knowledge. Unlike classical training where all data is available simultaneously, Continual Learning reflects the reality of the world: information arrives gradually, and an intelligent system must adapt without erasing what it has already learned.

The main challenge is catastrophic forgetting: when a neural network is retrained on a new task, its weights change destructively for previously acquired skills. This phenomenon renders classical models unsuitable for dynamic environments where data evolves continuously.

Three major families of solutions exist: regularization (such as Elastic Weight Consolidation), replay (memorizing past samples), and dynamic architecture (model expansion). This guide explores each of these approaches in detail, with a complete Python implementation in PyTorch.


Mathematical Principles of Continual Learning

The Problem of Catastrophic Forgetting

Imagine a neural network trained on task A with a loss function ℒ_A(θ). After convergence, the parameters θ_A are optimal for task A. If we then train the same network on task B with a loss ℒ_B(θ), the weights converge toward θ_B. The problem: θ_B has no mechanism for preserving what made θ_A performant on task A.

Mathematically, the loss on task A after training on B explodes:

ℒ_A(θ_B) ≫ ℒ_A(θ_A)

This happens because the gradients of ℒ_B push the weights in directions that are arbitrary relative to the minimum of ℒ_A. Neurons that encoded essential features for task A are reassigned to task B. This is catastrophic forgetting — the model “forgets” the previous task almost instantly.

Elastic Weight Consolidation (EWC)

Kirkpatrick et al. (2017) proposed an elegant solution inspired by synaptic consolidation in the biological brain. The idea is to penalize changes to weights that are important for the previous task, while allowing less important weights to adapt freely.

The combined loss function is written:

ℒ(θ) = ℒ_B(θ) + λ · Σ_i F_i · (θ_i − θ_A_i)²

Where:
– ℒ_B(θ) is the loss on the new task B.
– λ (lambda) is a hyperparameter that controls the strength of regularization.
– F_i is the importance of parameter θ_i for task A, estimated by the diagonal of the Fisher information matrix.
– θ_A_i is the optimal value of parameter i after training on task A.
– θ_i is the current value of parameter i during training on B.

The Fisher information matrix measures the sensitivity of the model’s output distribution to each parameter. Intuitively, if slightly changing θ_i greatly changes the output on task A, then F_i is large, and the parameter is “important” — it must be protected.

In practice, the diagonal approximation of the Fisher information matrix is used, computed on a sample of data from task A:

F_i ≈ 𝔼_{x~D_A}[(∂log p(y|x, θ)/∂θ_i)²]

This approximation makes EWC tractable: one does not need to store the entire Fisher matrix (which would be of size n² for n parameters), only its diagonal (size n).

Replay Approach

Replay consists of mixing a small subset of data from old tasks with data from the new task during training. Formally, if D_old is the replay buffer and D_new is the current data:

ℒ(θ) = 𝔼_{(x,y)~D_new}[ℒ_new(θ; x, y)] + α · 𝔼_{(x,y)~D_old}[ℒ_old(θ; x, y)]

This approach is simple but effective. The main challenge is selecting which samples to keep in the buffer: the most representative, the most difficult, or a combination of both?

Progressive Distillation

Distillation uses the old model as a teacher to guide the new one. During training on task B, a regularization term is added based on the Kullback-Leibler divergence between the outputs of the old (frozen) model and the current model:

ℒ(θ) = ℒ_new(θ) + β · KL(p_old(x) || p_current(x))

Distillation is particularly useful in Class-Incremental Learning, where new classes appear sequentially, because the old model already contains knowledge of all previous classes.


Intuition: The Musician and Their Instruments

Continual Learning is like a professional musician learning a new instrument.

Imagine a concert pianist who decides to learn the violin. If they completely stop practicing the piano for two years to focus only on the violin, their piano skills will deteriorate — that’s catastrophic forgetting. Their fingers lose muscle music, their piano score reading slows down, and their interpretations become less nuanced.

But in reality, a musician doesn’t work that way. With EWC, it’s as if they protected the fundamental skills that serve both instruments — note reading, sense of rhythm, musical ear — while adapting the specific skills: fingering for piano, bowing for violin, hand position.

The “important” weights in the neural network are the fundamental skills: we consolidate them, we protect them, we don’t let them be overwritten by new learning. The “less important” weights are the specific details: we let them evolve freely to adapt to the new instrument.

With replay, the musician occasionally practices their old instrument to maintain their level. With distillation, they listen to their old recordings and try to reproduce the same quality of interpretation while integrating their new technique.

The essence of Continual Learning is protecting what is essential while remaining open to the new. It’s the balance between stability and plasticity — the same balance that our own brain maintains every day.


Complete Python Implementation with PyTorch

Here is a complete implementation of EWC applied to a sequence of image classification tasks: MNIST → SVHN → MNIST-M. The code uses the diagonal approximation of the Fisher information matrix and compares performance with naive fine-tuning.

import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.data import DataLoader, TensorDataset
import numpy as np
import copy


class SimpleCNN(nn.Module):
    """
    Simple convolutional network for image classification.
    Intentionally lightweight architecture to illustrate continual learning.
    """
    def __init__(self, n_classes=10):
        super(SimpleCNN, self).__init__()
        self.features = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),
            nn.Conv2d(32, 64, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(64 * 7 * 7, 256),
            nn.ReLU(inplace=True),
            nn.Linear(256, n_classes),
        )

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


class EWCRegularizer:
    """
    Elastic Weight Consolidation (EWC) regularizer.

    Computes the importance of each parameter via the diagonal
    of the Fisher information matrix after training on a task,
    then penalizes significant changes on subsequent tasks.
    """
    def __init__(self, model, device="cpu"):
        self.model = model
        self.device = device
        self.fisher = {}
        self.optimal_params = {}

    def compute_fisher(self, dataloader, n_samples=None):
        """
        Estimates the diagonal of the Fisher information matrix.

        For each mini-batch, computes the gradient of the loss
        with respect to each parameter, then takes the mean
        square of these gradients over all samples.
        """
        self.model.eval()
        fisher_accumulator = {}

        for name, param in self.model.named_parameters():
            fisher_accumulator[name] = torch.zeros_like(param)

        total_samples = 0
        for batch_idx, (inputs, targets) in enumerate(dataloader):
            if n_samples is not None and total_samples >= n_samples:
                break

            inputs = inputs.to(self.device)
            targets = targets.to(self.device)

            self.model.zero_grad()
            outputs = self.model(inputs)
            loss = nn.CrossEntropyLoss()(outputs, targets)
            loss.backward()

            for name, param in self.model.named_parameters():
                if param.grad is not None:
                    fisher_accumulator[name] += param.grad.data ** 2

            total_samples += inputs.size(0)

        n_processed = min(total_samples, n_samples if n_samples else total_samples)
        for name in fisher_accumulator:
            self.fisher[name] = fisher_accumulator[name] / n_processed

        self.optimal_params = {
            name: param.clone().detach()
            for name, param in self.model.named_parameters()
        }

    def penalization(self, lam):
        """
        Computes the EWC penalty term:
        lbd * sum_i F_i * (theta_i - theta_A_i)^2

        Args:
            lam (float): Lambda regularization coefficient.

        Returns:
            torch.Tensor: Penalty term to add to the loss.
        """
        penalty = torch.tensor(0.0, device=self.device)

        for name, param in self.model.named_parameters():
            if name in self.fisher and name in self.optimal_params:
                importance = self.fisher[name]
                optimal = self.optimal_params[name].to(self.device)
                penalty += (importance * (param - optimal) ** 2).sum()

        return lam * penalty

    def merge_fisher(self, new_fisher, blending=0.5):
        """
        Merges a new Fisher estimate with previous ones.
        Useful when chaining multiple tasks sequentially.
        """
        for name in self.fisher:
            if name in new_fisher:
                self.fisher[name] = blending * self.fisher[name] + \
                                    (1 - blending) * new_fisher[name]


def train_epoch(model, dataloader, optimizer, ewc=None, lambda_ewc=0.0, device="cpu"):
    """
    Trains the model for one complete epoch.

    If EWC is active, adds the penalty term to the loss.
    """
    model.train()
    total_loss = 0.0
    correct = 0
    total = 0

    criterion = nn.CrossEntropyLoss()

    for inputs, targets in dataloader:
        inputs, targets = inputs.to(device), targets.to(device)

        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, targets)

        if ewc is not None and lambda_ewc > 0:
            loss += ewc.penalization(lambda_ewc)

        loss.backward()
        optimizer.step()

        total_loss += loss.item() * inputs.size(0)
        _, predicted = outputs.max(1)
        correct += predicted.eq(targets).sum().item()
        total += targets.size(0)

    avg_loss = total_loss / total
    accuracy = 100.0 * correct / total
    return avg_loss, accuracy


@torch.no_grad()
def evaluate(model, dataloader, device="cpu"):
    """Evaluates the model on a dataset."""
    model.eval()
    correct = 0
    total = 0

    for inputs, targets in dataloader:
        inputs, targets = inputs.to(device), targets.to(device)
        outputs = model(inputs)
        _, predicted = outputs.max(1)
        correct += predicted.eq(targets).sum().item()
        total += targets.size(0)

    return 100.0 * correct / total


def run_continual_learning(device="cuda" if torch.cuda.is_available() else "cpu"):
    """
    Runs the experimental protocol with and without EWC.
    Compares performance to demonstrate the effectiveness
    of Continual Learning against naive fine-tuning.
    """
    print(f"== Continual Learning: EWC on MNIST -> SVHN -> MNIST-M ==")
    print(f"Device: {device}\n")

    n_classes = 10
    lambda_ewc = 5000.0
    fisher_samples = 2000
    n_epochs = 5

    n_train = 1000
    n_test = 200

    train_loader = DataLoader(
        TensorDataset(torch.randn(n_train, 3, 28, 28),
                      torch.randint(0, n_classes, (n_train,))),
        batch_size=128, shuffle=True
    )
    test_loader = DataLoader(
        TensorDataset(torch.randn(n_test, 3, 28, 28),
                      torch.randint(0, n_classes, (n_test,))),
        batch_size=128
    )

    tasks = ["Task 1 (MNIST-like)", "Task 2 (SVHN-like)", "Task 3 (MNIST-M-like)"]

    print("--- Protocol 1: Naive fine-tuning (baseline) ---")
    model_naive = SimpleCNN(n_classes).to(device)
    results_naive = {}

    for t, task_name in enumerate(tasks):
        print(f"\n  Training on {task_name}...")
        optimizer = optim.Adam(model_naive.parameters(), lr=1e-3)

        for epoch in range(n_epochs):
            loss, acc = train_epoch(
                model_naive, train_loader, optimizer, device=device
            )
            print(f"    Epoch {epoch+1}/{n_epochs} -- Loss: {loss:.4f}, Acc: {acc:.1f}%")

        acc_current = evaluate(model_naive, test_loader, device)
        results_naive[task_name] = acc_current
        print(f"  -> Accuracy on {task_name}: {acc_current:.1f}%")

    print(f"\n  Naive fine-tuning summary: {results_naive}")

    print("\n--- Protocol 2: EWC (Continual Learning) ---")
    model_ewc = SimpleCNN(n_classes).to(device)
    ewc = EWCRegularizer(model_ewc, device=device)
    results_ewc = {}

    for t, task_name in enumerate(tasks):
        print(f"\n  Training on {task_name}...")
        optimizer = optim.Adam(model_ewc.parameters(), lr=1e-3)

        for epoch in range(n_epochs):
            loss, acc = train_epoch(
                model_ewc, train_loader, optimizer,
                ewc=ewc if t > 0 else None,
                lambda_ewc=lambda_ewc if t > 0 else 0.0,
                device=device
            )
            print(f"    Epoch {epoch+1}/{n_epochs} -- Loss: {loss:.4f}, Acc: {acc:.1f}%")

        if t < len(tasks) - 1:
            ewc.compute_fisher(test_loader, n_samples=fisher_samples)

        acc_current = evaluate(model_ewc, test_loader, device)
        results_ewc[task_name] = acc_current
        print(f"  -> Accuracy on {task_name}: {acc_current:.1f}%")

    print(f"\n  EWC summary: {results_ewc}")

    print("\n" + "=" * 60)
    print("PERFORMANCE COMPARISON")
    print("=" * 60)
    for task in tasks:
        naive_acc = results_naive.get(task, 0.0)
        ewc_acc = results_ewc.get(task, 0.0)
        diff = ewc_acc - naive_acc
        marker = "OK" if diff > 0 else "X "
        print(f"{task:<30}  Naive: {naive_acc:>6.1f}%  EWC: {ewc_acc:>6.1f}%  [{marker}]")

    print("\nNote: With real data, EWC typically shows")
    print("a clear advantage over naive fine-tuning,")
    print("especially on older tasks.")


if __name__ == "__main__":
    run_continual_learning()

This code demonstrates the essential components of a Continual Learning pipeline:

  1. Fisher diagonal computation: accumulation of squared gradients over samples from the previous task, normalized by the number of samples.
  2. EWC penalization: for each parameter, the deviation from the previous optimal value is measured, weighted by Fisher importance.
  3. Direct comparison: the naive protocol trains sequentially without protection, while the EWC protocol adds regularization after the first task.

For production use with real data, simply replace the simulated data with torchvision.datasets.MNIST, SVHN, and transformed variants like MNIST-M (MNIST with Berkeley Segmentation Dataset backgrounds).


Key Continual Learning Hyperparameters

Hyperparameter selection is crucial in Continual Learning. Here are the most important ones:

λ (ewc_lambda) — EWC regularization strength

  • Typical: 1,000 to 50,000 depending on the loss scale.
  • Too low: the model forgets previous tasks (catastrophic forgetting not controlled).
  • Too high: the model cannot learn new tasks (excessive rigidity).
  • Practical rule: λ should be on the order of the ratio between the gradient variance of the new task and the Fisher importance. A logarithmic sweep (100, 1,000, 10,000, 50,000) is recommended.

fisher_samples — Samples to estimate Fisher

  • Typical: 500 to 5,000 samples per task.
  • More samples give a more precise estimate of parameter importance.
  • However, beyond a certain threshold, the marginal gain diminishes.
  • In a real scenario, one does not want to spend too much time estimating Fisher at the expense of training.

memory_size — Replay buffer size

  • Typical: 100 to 5,000 samples per old task.
  • The larger the buffer, the better the retention of old tasks.
  • Advanced selection strategies: reservoir sampling, most misclassified samples, or gradient-based selection.
  • The memory/performance trade-off is an active area of research.

β (beta_distillation) — Distillation coefficient

  • Typical: 0.1 to 10.0.
  • Used in combination with EWC or replay to strengthen knowledge preservation.
  • In Class-Incremental Learning, distillation is often more effective than EWC alone, because it preserves inter-class relationships, not just individual weights.

Advantages and Limitations

Advantages

  1. Continuous adaptability: The model evolves with new data without requiring complete retraining from scratch. This is essential for production systems that must adapt in real time.
  2. Storage efficiency: Unlike batch retraining that requires keeping all historical data, Continual Learning only keeps a small replay buffer (a few hundred samples per task) or even no samples at all (pure regularization approaches like EWC).
  3. Privacy compliance: Old data does not need to be stored indefinitely, reducing leakage risks and facilitating GDPR compliance. Learning happens as a continuous stream — data can be deleted after use.
  4. More natural learning: The sequential learning pattern mirrors how humans acquire knowledge — progressively, building on what is already known.

Limitations

  1. Stability-plasticity trade-off: Too much regularization prevents learning new tasks; too little causes catastrophic forgetting. Finding the right balance is difficult and domain-dependent.
  2. Algorithmic complexity: Advanced methods like EWC require computing the Fisher information matrix, which adds significant computational overhead. Replay methods require complex memory buffer management.
  3. Cumulative degradation: Even with EWC, performance on the earliest tasks tends to gradually decrease over sequences, as each new task introduces a small perturbation that accumulates.
  4. Difficult evaluation: Standard benchmarks (Split-MNIST, Permuted-MNIST, CORe50) do not always reflect the reality of practical applications where data distribution changes in more subtle and non-stationary ways.

4 Real-World Use Cases for Continual Learning

1. Autonomous Vehicles — Adapting to Road Conditions

An autonomous vehicle deployed in Paris must adapt to the specific traffic signs of other cities or countries (United States, Japan) without forgetting how to drive in Paris. Continual Learning allows the perception model to progressively learn new regulations, extreme weather conditions, and local driving styles, while maintaining its core competencies.

Safety implications make catastrophic forgetting unacceptable: a vehicle that “forgot” how to detect pedestrians after learning to recognize new signs would be catastrophic. EWC and replay are natural candidates for this scenario.

2. Voice Assistants — Learning User Preferences

A voice assistant like Alexa or Siri must learn the preferences, habits, and language patterns of each user individually, without learning on one user degrading performance on others. Continual Learning enables incremental personalization: the base model stays stable (thanks to EWC regularization), while a small portion of the weights adapts to the user’s profile.

This is also a privacy concern: users’ voice data does not need to be centralized on a server for global retraining. Learning happens locally, continuously.

3. Medical Diagnosis — Integrating New Pathologies

A radiological diagnosis assistance system must integrate the detection of new pathologies (such as an emerging epidemic) without losing its ability to diagnose diseases for which it was originally trained. Continual Learning allows classes to be added progressively: first common tumors, then rare ones, then emerging ones.

Progressive distillation is particularly relevant here: the previous model serves as a reference to ensure that predictions on existing classes do not drift during the addition of new classes.

4. Industrial Robots — Learning New Tasks

A robotic arm in a factory successively learns to assemble different products as production lines change. Rather than retraining the control model from scratch for each new product (which would take days), Continual Learning enables fast adjustment: the robot retains its fundamental understanding of object manipulation while learning gestures specific to the new product.

Replay is particularly suited here: a few examples of each previous task are sufficient to maintain skills during the acquisition of new ones.


See Also

About Salah YAHIAOUI

Salah YAHIAOUI publie sur The Pingouin des tutoriels Python et des décryptages sur l’intelligence artificielle. Les cas pratiques récents précisent leurs données, leurs tests et leurs limites. À propos, méthode éditoriale et contact.

View all posts by Salah YAHIAOUI →