19  Lab 19: The Future of NeuroAI: Three Frontiers

Open In Colab

19.1 Learning Objectives

  1. Understand core concepts from Chapter 19
  2. Implement key algorithms and techniques
  3. Apply methods to practical problems
  4. Analyze results and draw insights
  5. Connect to broader NeuroAI themes

19.2 Prerequisites

  • Reading: Chapter 19: Future Directions
  • Libraries: NumPy, Matplotlib, PyTorch, torchvision
  • Concepts: Neuromorphic computing, continual learning, catastrophic forgetting

19.3 Setup

Run this cell to import the necessary libraries and configure the environment.

import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
import torch.optim as optim
import torch.nn.functional as F
from torch.utils.data import DataLoader, Subset
from torchvision import datasets, transforms

np.random.seed(42)
torch.manual_seed(42)
plt.rcParams['figure.figsize'] = (12, 8)
plt.rcParams['figure.dpi'] = 100

19.4 Part 1: Neuromorphic Computing - Spiking Neurons

This section demonstrates a simple spiking neuron model, the fundamental building block of neuromorphic computing systems.

19.4.1 Exercise 1: Spiking Neuron Simulation

Run this cell to implement and simulate a basic leaky integrate-and-fire (LIF) spiking neuron.

class SpikingNeuron:
    """
    Leaky Integrate-and-Fire (LIF) neuron model.

    This is the fundamental computational unit in neuromorphic systems,
    mimicking how biological neurons process information through spikes.

    Parameters:
    - threshold: Membrane potential threshold for spike generation
    - tau_m: Membrane time constant (controls decay rate)
    """
    def __init__(self, threshold=1.0, tau_m=10.0):
        self.threshold = threshold
        self.tau_m = tau_m
        self.membrane_potential = 0.0

    def update(self, input_current, dt=1.0):
        """
        Update membrane potential and generate spike if threshold reached.

        Returns 1 if spike generated, 0 otherwise.
        """
        # Leaky integration: membrane potential decays toward 0
        d_v = (-self.membrane_potential + input_current) / self.tau_m
        self.membrane_potential += d_v * dt

        # Fire if threshold reached
        if self.membrane_potential >= self.threshold:
            self.membrane_potential = 0.0  # Reset after spike
            return 1  # Spike
        return 0  # No spike

# Simulate the spiking neuron with varying input currents
print("Simulating Spiking Neuron Response to Input Currents")
print("=" * 60)

neuron = SpikingNeuron(threshold=1.0, tau_m=10.0)
time_steps = 100
input_currents = [0.5, 1.2, 2.0]  # Different input levels

fig, axes = plt.subplots(len(input_currents), 1, figsize=(12, 8), sharex=True)

for idx, current in enumerate(input_currents):
    neuron = SpikingNeuron(threshold=1.0, tau_m=10.0)
    membrane_trace = []
    spike_trace = []

    for t in range(time_steps):
        # Step input current
        input_i = current if 20 <= t <= 80 else 0.0
        spike = neuron.update(input_i)
        membrane_trace.append(neuron.membrane_potential)
        spike_trace.append(spike)

    ax = axes[idx]
    ax.plot(membrane_trace, 'b-', linewidth=1.5, label='Membrane Potential')
    ax.axhline(y=1.0, color='r', linestyle='--', label='Threshold')
    ax.fill_between(range(time_steps), 0, spike_trace, alpha=0.3, color='red', label='Spikes')
    ax.set_ylabel(f'Input: {current}')
    ax.legend(loc='upper right')
    ax.grid(True, alpha=0.3)

axes[-1].set_xlabel('Time Step')
plt.suptitle('Spiking Neuron Response to Different Input Currents', fontsize=14, fontweight='bold')
plt.tight_layout()
plt.show()

19.4.2 Exercise 2: Analyze Energy Efficiency

Discuss how spiking neurons achieve energy efficiency compared to traditional artificial neurons.


19.5 Part 2: Continual Learning - Catastrophic Forgetting & Experience Replay

This section demonstrates the catastrophic forgetting problem and how experience replay can mitigate it.

19.5.1 Exercise 3: Setting Up the Continual Learning Experiment

Run this cell to define the neural network and data loading utilities for our continual learning experiment.

# Simple neural network for MNIST classification
class SimpleNet(nn.Module):
    """
    A simple feedforward network for MNIST digit classification.

    Architecture: 784 -> 256 -> 256 -> 10
    """
    def __init__(self, input_size=784, hidden_size=256, num_classes=10):
        super(SimpleNet, self).__init__()
        self.fc1 = nn.Linear(input_size, hidden_size)
        self.relu = nn.ReLU()
        self.fc2 = nn.Linear(hidden_size, hidden_size)
        self.fc3 = nn.Linear(hidden_size, num_classes)

    def forward(self, x):
        x = x.view(x.size(0), -1)
        x = self.relu(self.fc1(x))
        x = self.relu(self.fc2(x))
        x = self.fc3(x)
        return x


# Experience Replay Buffer
class ReplayBuffer:
    """
    Stores samples from previous tasks for replay during new task training.

    This mimics hippocampal replay in biological systems, where recent
    memories are reactivated during rest/sleep to consolidate learning.
    """
    def __init__(self, capacity=1000):
        self.capacity = capacity
        self.buffer = []
        self.position = 0

    def push(self, data, target):
        """Add a sample to the buffer with circular overwriting."""
        if len(self.buffer) < self.capacity:
            self.buffer.append((data, target))
        else:
            self.buffer[self.position] = (data, target)
        self.position = (self.position + 1) % self.capacity

    def sample(self, batch_size):
        """Sample a random batch from the buffer."""
        indices = np.random.choice(len(self.buffer), batch_size, replace=False)
        batch = [self.buffer[i] for i in indices]
        data = torch.stack([item[0] for item in batch])
        targets = torch.tensor([item[1] for item in batch])
        return data, targets

    def __len__(self):
        return len(self.buffer)


# Load and prepare MNIST
print("Loading MNIST dataset...")
transform = transforms.Compose([transforms.ToTensor(),
                                transforms.Normalize((0.1307,), (0.3081,))])
train_dataset = datasets.MNIST('./data', train=True, download=True, transform=transform)
test_dataset = datasets.MNIST('./data', train=False, transform=transform)

# Create task-specific datasets
def create_task_dataset(dataset, task_labels):
    """Filter dataset to include only specified labels."""
    indices = [i for i, (_, label) in enumerate(dataset) if label in task_labels]
    return Subset(dataset, indices)

task1_labels = [0, 1, 2, 3, 4]
task2_labels = [5, 6, 7, 8, 9]

task1_train = create_task_dataset(train_dataset, task1_labels)
task2_train = create_task_dataset(train_dataset, task2_labels)
task1_test = create_task_dataset(test_dataset, task1_labels)
task2_test = create_task_dataset(test_dataset, task2_labels)

print(f"Task 1 (digits 0-4): {len(task1_train)} training, {len(task1_test)} test samples")
print(f"Task 2 (digits 5-9): {len(task2_train)} training, {len(task2_test)} test samples")

19.5.2 Exercise 4: Training Functions

Run this cell to define the training and evaluation functions.

# Training function with optional experience replay
def train_epoch(model, dataloader, optimizer, criterion, replay_buffer=None, replay_ratio=0.5):
    """
    Train for one epoch with optional experience replay.

    Parameters:
    - replay_buffer: If provided, mixes replay samples with current task data
    - replay_ratio: Fraction of batch to fill with replay samples
    """
    model.train()
    total_loss = 0
    for data, target in dataloader:
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)

        # Add replay samples if buffer exists
        if replay_buffer is not None and len(replay_buffer) > 0:
            replay_batch_size = int(len(data) * replay_ratio)
            replay_data, replay_target = replay_buffer.sample(
                min(replay_batch_size, len(replay_buffer))
            )
            replay_output = model(replay_data)
            replay_loss = criterion(replay_output, replay_target)
            loss = loss + replay_loss

        loss.backward()
        optimizer.step()
        total_loss += loss.item()

        # Store some samples in replay buffer
        if replay_buffer is not None:
            for i in range(min(5, len(data))):  # Store 5 samples per batch
                replay_buffer.push(data[i].cpu(), target[i].item())

    return total_loss / len(dataloader)


# Evaluation function
def evaluate(model, dataloader):
    """Evaluate model accuracy on a dataset."""
    model.eval()
    correct = 0
    total = 0
    with torch.no_grad():
        for data, target in dataloader:
            output = model(data)
            _, predicted = torch.max(output.data, 1)
            total += target.size(0)
            correct += (predicted == target).sum().item()
    return 100 * correct / total


# Create data loaders
task1_loader = DataLoader(task1_train, batch_size=64, shuffle=True)
task2_loader = DataLoader(task2_train, batch_size=64, shuffle=True)
task1_test_loader = DataLoader(task1_test, batch_size=64, shuffle=False)
task2_test_loader = DataLoader(task2_test, batch_size=64, shuffle=False)

print("Data loaders created successfully.")

19.5.3 Exercise 5: Experiment 1 - Without Experience Replay

Run this cell to observe catastrophic forgetting in action.

print("=" * 70)
print("EXPERIMENT 1: WITHOUT Experience Replay (Catastrophic Forgetting)")
print("=" * 70)

model_no_replay = SimpleNet()
optimizer = optim.Adam(model_no_replay.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()

# Track performance over time
history_no_replay = {'task1': [], 'task2': []}

# Train on Task 1 (digits 0-4)
print("\nTraining on Task 1 (digits 0-4)...")
for epoch in range(5):
    train_epoch(model_no_replay, task1_loader, optimizer, criterion)
    task1_acc = evaluate(model_no_replay, task1_test_loader)
    task2_acc = evaluate(model_no_replay, task2_test_loader)
    history_no_replay['task1'].append(task1_acc)
    history_no_replay['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 Acc = {task1_acc:.2f}%, Task 2 Acc = {task2_acc:.2f}%")

# Train on Task 2 (digits 5-9) - WITHOUT replay
print("\nTraining on Task 2 (digits 5-9) WITHOUT replay...")
for epoch in range(5):
    train_epoch(model_no_replay, task2_loader, optimizer, criterion)
    task1_acc = evaluate(model_no_replay, task1_test_loader)
    task2_acc = evaluate(model_no_replay, task2_test_loader)
    history_no_replay['task1'].append(task1_acc)
    history_no_replay['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 Acc = {task1_acc:.2f}%, Task 2 Acc = {task2_acc:.2f}%")

19.5.4 Exercise 6: Experiment 2 - With Experience Replay

Run this cell to see how experience replay mitigates forgetting.

print("\n" + "=" * 70)
print("EXPERIMENT 2: WITH Experience Replay (Forgetting Mitigated)")
print("=" * 70)

model_replay = SimpleNet()
optimizer = optim.Adam(model_replay.parameters(), lr=0.001)
replay_buffer = ReplayBuffer(capacity=2000)

history_replay = {'task1': [], 'task2': []}

# Train on Task 1 (digits 0-4)
print("\nTraining on Task 1 (digits 0-4)...")
for epoch in range(5):
    train_epoch(model_replay, task1_loader, optimizer, criterion, replay_buffer)
    task1_acc = evaluate(model_replay, task1_test_loader)
    task2_acc = evaluate(model_replay, task2_test_loader)
    history_replay['task1'].append(task1_acc)
    history_replay['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 Acc = {task1_acc:.2f}%, Task 2 Acc = {task2_acc:.2f}%")

print(f"\nReplay buffer size after Task 1: {len(replay_buffer)} samples")

# Train on Task 2 (digits 5-9) - WITH replay
print("\nTraining on Task 2 (digits 5-9) WITH replay...")
for epoch in range(5):
    train_epoch(model_replay, task2_loader, optimizer, criterion, replay_buffer)
    task1_acc = evaluate(model_replay, task1_test_loader)
    task2_acc = evaluate(model_replay, task2_test_loader)
    history_replay['task1'].append(task1_acc)
    history_replay['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 Acc = {task1_acc:.2f}%, Task 2 Acc = {task2_acc:.2f}%")

19.5.5 Exercise 7: Visualize Results

Run this cell to visualize and compare the results.

# Visualize results
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# Plot without replay
epochs = range(1, 11)
ax1.plot(epochs, history_no_replay['task1'], 'b-', label='Task 1 (digits 0-4)', linewidth=2)
ax1.plot(epochs, history_no_replay['task2'], 'r-', label='Task 2 (digits 5-9)', linewidth=2)
ax1.axvline(x=5, color='gray', linestyle='--', label='Switch to Task 2')
ax1.set_xlabel('Epoch', fontsize=12)
ax1.set_ylabel('Test Accuracy (%)', fontsize=12)
ax1.set_title('WITHOUT Experience Replay\n(Catastrophic Forgetting)', fontsize=14, fontweight='bold')
ax1.legend(fontsize=10)
ax1.grid(True, alpha=0.3)
ax1.set_ylim([0, 100])

# Plot with replay
ax2.plot(epochs, history_replay['task1'], 'b-', label='Task 1 (digits 0-4)', linewidth=2)
ax2.plot(epochs, history_replay['task2'], 'r-', label='Task 2 (digits 5-9)', linewidth=2)
ax2.axvline(x=5, color='gray', linestyle='--', label='Switch to Task 2')
ax2.set_xlabel('Epoch', fontsize=12)
ax2.set_ylabel('Test Accuracy (%)', fontsize=12)
ax2.set_title('WITH Experience Replay\n(Forgetting Mitigated)', fontsize=14, fontweight='bold')
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
ax2.set_ylim([0, 100])

plt.tight_layout()
plt.show()

# Calculate forgetting metrics
task1_peak_no_replay = max(history_no_replay['task1'][:5])
task1_final_no_replay = history_no_replay['task1'][-1]
forgetting_no_replay = task1_peak_no_replay - task1_final_no_replay

task1_peak_replay = max(history_replay['task1'][:5])
task1_final_replay = history_replay['task1'][-1]
forgetting_replay = task1_peak_replay - task1_final_replay

print("\n" + "=" * 60)
print("CATASTROPHIC FORGETTING ANALYSIS")
print("=" * 60)
print(f"\nWithout Replay:")
print(f"  Peak Task 1 Accuracy: {task1_peak_no_replay:.2f}%")
print(f"  Final Task 1 Accuracy: {task1_final_no_replay:.2f}%")
print(f"  Forgetting: {forgetting_no_replay:.2f}%")
print(f"\nWith Experience Replay:")
print(f"  Peak Task 1 Accuracy: {task1_peak_replay:.2f}%")
print(f"  Final Task 1 Accuracy: {task1_final_replay:.2f}%")
print(f"  Forgetting: {forgetting_replay:.2f}%")
print(f"\nImprovement: {forgetting_no_replay - forgetting_replay:.2f}% reduction in forgetting")
print("=" * 60)

Open In Colab

19.6 Part 3: Continual Learning - Elastic Weight Consolidation (EWC)

This section demonstrates EWC, an alternative approach that protects important weights instead of storing past data.

19.6.1 Exercise 8: EWC Implementation

Run this cell to implement the Elastic Weight Consolidation algorithm.

class EWC:
    """
    Elastic Weight Consolidation implementation.

    EWC identifies important weights for a task using the Fisher Information
    Matrix and penalizes changes to these weights during future learning.

    This is inspired by synaptic consolidation in the brain, where important
    synapses become more stable over time.
    """
    def __init__(self, model, dataloader, device='cpu'):
        self.model = model
        self.device = device
        # Store current parameters (important weights to protect)
        self.params = {n: p.clone().detach() for n, p in model.named_parameters() if p.requires_grad}
        # Compute Fisher Information (measure of weight importance)
        self.fisher = self._compute_fisher(dataloader)

    def _compute_fisher(self, dataloader):
        """
        Compute Fisher Information Matrix.

        The Fisher Information measures how sensitive the model output is
        to changes in each weight. Higher values = more important weights.
        """
        fisher = {n: torch.zeros_like(p) for n, p in self.model.named_parameters() if p.requires_grad}

        self.model.eval()
        for data, target in dataloader:
            data, target = data.to(self.device), target.to(self.device)
            self.model.zero_grad()
            output = self.model(data)

            # Use negative log-likelihood as loss
            loss = F.cross_entropy(output, target)
            loss.backward()

            # Accumulate squared gradients (Fisher Information)
            for n, p in self.model.named_parameters():
                if p.requires_grad and p.grad is not None:
                    fisher[n] += p.grad.pow(2).clone().detach()

        # Average over dataset
        for n in fisher:
            fisher[n] /= len(dataloader)

        return fisher

    def penalty(self, model):
        """
        Compute EWC penalty term.

        Penalizes deviation from stored parameters, weighted by importance.
        """
        loss = 0
        for n, p in model.named_parameters():
            if p.requires_grad:
                loss += (self.fisher[n] * (p - self.params[n]).pow(2)).sum()
        return loss


# Training function with EWC
def train_epoch_ewc(model, dataloader, optimizer, criterion, ewc=None, ewc_lambda=1000):
    """
    Train for one epoch with optional EWC regularization.

    Parameters:
    - ewc: EWC object containing Fisher Information from previous task
    - ewc_lambda: Strength of the consolidation penalty
    """
    model.train()
    total_loss = 0
    total_task_loss = 0
    total_ewc_loss = 0

    for data, target in dataloader:
        optimizer.zero_grad()
        output = model(data)

        # Task loss
        task_loss = criterion(output, target)

        # EWC penalty
        ewc_loss = 0
        if ewc is not None:
            ewc_loss = ewc.penalty(model)

        # Total loss
        loss = task_loss + ewc_lambda * ewc_loss

        loss.backward()
        optimizer.step()

        total_loss += loss.item()
        total_task_loss += task_loss.item()
        total_ewc_loss += ewc_loss.item() if isinstance(ewc_loss, torch.Tensor) else ewc_loss

    return (total_loss / len(dataloader),
            total_task_loss / len(dataloader),
            total_ewc_loss / len(dataloader))


print("EWC implementation defined successfully.")

19.6.2 Exercise 9: EWC Experiment - Without Consolidation

Run this cell to train without EWC and observe forgetting.

print("=" * 70)
print("EXPERIMENT 1: Training WITHOUT EWC")
print("=" * 70)

model_no_ewc = SimpleNet()
optimizer = optim.Adam(model_no_ewc.parameters(), lr=0.001)
criterion = nn.CrossEntropyLoss()

history_no_ewc = {'task1': [], 'task2': []}

# Train on Task 1
print("\nTask 1 training (digits 0-4)...")
for epoch in range(5):
    train_epoch_ewc(model_no_ewc, task1_loader, optimizer, criterion)
    task1_acc = evaluate(model_no_ewc, task1_test_loader)
    task2_acc = evaluate(model_no_ewc, task2_test_loader)
    history_no_ewc['task1'].append(task1_acc)
    history_no_ewc['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 = {task1_acc:.2f}%, Task 2 = {task2_acc:.2f}%")

# Compute Fisher Information after Task 1 (but don't use it)
ewc = EWC(model_no_ewc, task1_loader)
print(f"\nFisher Information computed (not used in this experiment)")

print("\nTask 2 training (digits 5-9) WITHOUT EWC...")
for epoch in range(5):
    train_epoch_ewc(model_no_ewc, task2_loader, optimizer, criterion)
    task1_acc = evaluate(model_no_ewc, task1_test_loader)
    task2_acc = evaluate(model_no_ewc, task2_test_loader)
    history_no_ewc['task1'].append(task1_acc)
    history_no_ewc['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 = {task1_acc:.2f}%, Task 2 = {task2_acc:.2f}%")

19.6.3 Exercise 10: EWC Experiment - With Consolidation

Run this cell to train with EWC and observe how it protects previous knowledge.

print("\n" + "=" * 70)
print("EXPERIMENT 2: Training WITH EWC")
print("=" * 70)

model_ewc = SimpleNet()
optimizer = optim.Adam(model_ewc.parameters(), lr=0.001)

history_ewc = {'task1': [], 'task2': []}

# Train on Task 1
print("\nTask 1 training (digits 0-4)...")
for epoch in range(5):
    train_epoch_ewc(model_ewc, task1_loader, optimizer, criterion)
    task1_acc = evaluate(model_ewc, task1_test_loader)
    task2_acc = evaluate(model_ewc, task2_test_loader)
    history_ewc['task1'].append(task1_acc)
    history_ewc['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 = {task1_acc:.2f}%, Task 2 = {task2_acc:.2f}%")

# Compute Fisher Information and store weights
ewc_object = EWC(model_ewc, task1_loader)
print(f"\nFisher Information computed. Starting Task 2 with EWC penalty...")

ewc_lambda = 5000  # Consolidation strength
print(f"\nTask 2 training (digits 5-9) WITH EWC (lambda={ewc_lambda})...")
for epoch in range(5):
    task_loss, ewc_loss, penalty = train_epoch_ewc(
        model_ewc, task2_loader, optimizer, criterion,
        ewc=ewc_object, ewc_lambda=ewc_lambda
    )
    task1_acc = evaluate(model_ewc, task1_test_loader)
    task2_acc = evaluate(model_ewc, task2_test_loader)
    history_ewc['task1'].append(task1_acc)
    history_ewc['task2'].append(task2_acc)
    print(f"Epoch {epoch+1}: Task 1 = {task1_acc:.2f}%, Task 2 = {task2_acc:.2f}%")

19.6.4 Exercise 11: Visualize EWC Results

Run this cell to visualize the EWC results and weight importance analysis.

# Visualize weight importance
fig, axes = plt.subplots(2, 2, figsize=(14, 10))

# Plot 1: Learning curves without EWC
ax = axes[0, 0]
epochs = range(1, 11)
ax.plot(epochs, history_no_ewc['task1'], 'b-', linewidth=2, label='Task 1')
ax.plot(epochs, history_no_ewc['task2'], 'r-', linewidth=2, label='Task 2')
ax.axvline(x=5, color='gray', linestyle='--', alpha=0.7)
ax.set_xlabel('Epoch')
ax.set_ylabel('Test Accuracy (%)')
ax.set_title('WITHOUT EWC', fontweight='bold')
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_ylim([0, 100])

# Plot 2: Learning curves with EWC
ax = axes[0, 1]
ax.plot(epochs, history_ewc['task1'], 'b-', linewidth=2, label='Task 1')
ax.plot(epochs, history_ewc['task2'], 'r-', linewidth=2, label='Task 2')
ax.axvline(x=5, color='gray', linestyle='--', alpha=0.7)
ax.set_xlabel('Epoch')
ax.set_ylabel('Test Accuracy (%)')
ax.set_title('WITH EWC', fontweight='bold')
ax.legend()
ax.grid(True, alpha=0.3)
ax.set_ylim([0, 100])

# Plot 3: Fisher Information distribution
ax = axes[1, 0]
fisher_values = []
for n, f in ewc_object.fisher.items():
    fisher_values.extend(f.cpu().numpy().flatten())
fisher_values = np.array(fisher_values)
ax.hist(np.log10(fisher_values + 1e-10), bins=50, color='purple', alpha=0.7, edgecolor='black')
ax.set_xlabel('Log10(Fisher Information)')
ax.set_ylabel('Number of Weights')
ax.set_title('Weight Importance Distribution', fontweight='bold')
ax.grid(True, alpha=0.3)

# Plot 4: Protected vs Plastic weights
ax = axes[1, 1]
# Calculate weight changes
weight_changes = []
weight_importances = []
for n, p in model_ewc.named_parameters():
    if p.requires_grad:
        change = (p - ewc_object.params[n]).abs().cpu().detach().numpy().flatten()
        importance = ewc_object.fisher[n].cpu().numpy().flatten()
        weight_changes.extend(change)
        weight_importances.extend(importance)

weight_changes = np.array(weight_changes)
weight_importances = np.array(weight_importances)

# Sample for visualization
sample_size = min(5000, len(weight_changes))
indices = np.random.choice(len(weight_changes), sample_size, replace=False)
ax.scatter(weight_importances[indices], weight_changes[indices],
           alpha=0.3, s=1, c='blue')
ax.set_xlabel('Fisher Information (Weight Importance)')
ax.set_ylabel('Weight Change After Task 2')
ax.set_title('Protected vs Plastic Weights', fontweight='bold')
ax.set_xscale('log')
ax.set_yscale('log')
ax.grid(True, alpha=0.3)

plt.tight_layout()
plt.show()

# Summary statistics
print("\n" + "=" * 60)
print("EWC ANALYSIS")
print("=" * 60)
print(f"\nWithout EWC:")
print(f"  Task 1 forgetting: {max(history_no_ewc['task1'][:5]) - history_no_ewc['task1'][-1]:.2f}%")
print(f"\nWith EWC (lambda={ewc_lambda}):")
print(f"  Task 1 forgetting: {max(history_ewc['task1'][:5]) - history_ewc['task1'][-1]:.2f}%")
print(f"\nWeight Statistics:")
print(f"  Highly protected (top 10%): {np.percentile(weight_importances, 90):.2e}")
print(f"  Plastic (bottom 10%): {np.percentile(weight_importances, 10):.2e}")
print(f"  Mean weight change: {np.mean(weight_changes):.2e}")
print("=" * 60)

19.7 Additional Exercises

19.7.1 Exercise 12: Parameter Exploration

Experiment with different replay buffer sizes (500, 1000, 2000, 5000) and analyze the trade-off between memory usage and forgetting prevention.

19.7.2 Exercise 13: EWC Lambda Sensitivity

Explore different values of the EWC lambda parameter (100, 1000, 5000, 10000) and observe how it affects the balance between stability and plasticity.

19.7.3 Exercise 14: Multi-Task Learning

Extend the experiments to 3 or more sequential tasks and compare the performance of experience replay vs EWC.

19.7.4 Exercise 15: Combined Approach

Implement a hybrid method that combines experience replay with EWC. Does this provide better performance than either method alone?


Open In Colab

19.8 Challenge Problems

19.8.1 Challenge 1: Progressive Neural Networks

Implement Progressive Neural Networks, which avoid forgetting by adding new network columns for each task while freezing previous columns.

19.8.2 Challenge 2: Generative Replay

Instead of storing examples, train a generative model (VAE or GAN) on Task 1 data and use it to generate pseudo-samples for replay during Task 2 training.

19.8.3 Challenge 3: Online EWC

Implement Online EWC, which maintains a running estimate of the Fisher Information rather than recomputing it from scratch for each task.


19.9 Discussion Questions

19.9.1 Question 1

How does the hippocampal-cortical memory system in the brain relate to experience replay in AI? What are the similarities and differences?

19.9.2 Question 2

Why might EWC be preferable to experience replay in certain scenarios? Consider privacy, memory constraints, and computational efficiency.

19.9.3 Question 3

How do neuromorphic computing and continual learning intersect? What advantages might spiking neural networks have for continual learning?

19.9.4 Question 4

What are the ethical implications of AI systems that can learn continuously without forgetting? Consider both benefits (personalization, adaptation) and risks (bias accumulation, unwanted persistence).


Open In Colab

19.10 Summary

This lab explored three key frontiers in NeuroAI:

Part 1: Neuromorphic Computing - Implemented a leaky integrate-and-fire spiking neuron - Understood how event-driven computation enables energy efficiency - Connected to biological neuron models

Part 2: Experience Replay - Demonstrated catastrophic forgetting in sequential learning - Implemented experience replay to mitigate forgetting - Connected to hippocampal replay in memory consolidation

Part 3: Elastic Weight Consolidation - Implemented EWC using Fisher Information - Analyzed weight importance and selective protection - Connected to synaptic consolidation in the brain

Key Takeaways: - Neuromorphic systems offer orders of magnitude energy savings through event-driven computation - Continual learning requires mechanisms to protect previously learned knowledge - Both replay-based and regularization-based approaches have trade-offs - Biological systems provide inspiration for AI architectures and learning algorithms