20  Lab 20: Brain-Computer Interfaces: The New Frontier of Human-AI Interaction

Open In Colab

20.1 Learning Objectives

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

20.2 Prerequisites

  • Reading: Chapter 20: Brain-Computer Interfaces
  • Libraries: NumPy, Matplotlib, scikit-learn
  • Concepts: BCI systems, neural decoding, co-adaptation

20.3 Setup

import numpy as np
import matplotlib.pyplot as plt
from sklearn.linear_model import Ridge

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

20.4 Part 1: Fundamentals

Core concepts and theory from Brain-Computer Interfaces.

20.4.1 1.1 Understanding Neural Decoding

The fundamental challenge in BCI is mapping neural activity to intended actions. Run the cell below to set up our basic decoder architecture.

# Run this cell to define the AI-Powered Motor Decoder class
import time

class AI_Powered_Motor_Decoder:
    """
    A conceptual demonstration of an adaptive AI decoder for a motor BCI.

    This decoder learns to map neural activity patterns to intended movements
    using Ridge regression, with the ability to adapt online as new data arrives.
    """
    def __init__(self, n_channels=100, n_dof=2):
        self.n_channels = n_channels
        self.n_dof = n_dof  # Degrees of freedom (e.g., x, y cursor movement)
        # The decoder is a simple linear model for this example
        self.decoder = Ridge(alpha=1.0)
        # Initialize with random weights
        self.decoder.fit(np.random.randn(10, n_channels), np.random.randn(10, n_dof))
        self.adaptation_buffer = []
        self.buffer_size = 100
        print("AI Decoder initialized. Ready for co-adaptation.")

    def decode_intent(self, neural_activity):
        """Decode movement from a snapshot of neural activity."""
        # In a real system, neural_activity would be features like firing rates
        return self.decoder.predict(neural_activity.reshape(1, -1))

    def adapt(self, neural_activity, intended_movement):
        """Collect data for online adaptation of the decoder."""
        self.adaptation_buffer.append((neural_activity, intended_movement))
        if len(self.adaptation_buffer) > self.buffer_size:
            self.adaptation_buffer.pop(0)

        # Periodically re-train the decoder on recent data
        if len(self.adaptation_buffer) > 10 and len(self.adaptation_buffer) % 10 == 0:
            X = np.array([item[0] for item in self.adaptation_buffer])
            y = np.array([item[1] for item in self.adaptation_buffer])
            self.decoder.fit(X, y)
            print(f"[{time.time():.0f}] Decoder adapted with {len(self.adaptation_buffer)} recent examples.")

print("AI_Powered_Motor_Decoder class loaded successfully!")

20.5 Part 2: Hands-On Implementation

20.5.1 2.1 Running a BCI Simulation

Run this cell to simulate a closed-loop BCI where a user controls a cursor through thought.

# Run this cell to simulate closed-loop BCI cursor control with co-adaptation

def run_bci_simulation():
    """
    Simulate a user trying to move a cursor to a target using a BCI.
    This demonstrates the co-adaptation loop where both the user's brain
    and the AI decoder learn from each other.
    """
    decoder = AI_Powered_Motor_Decoder()

    # Simulate a user trying to move a cursor to a target
    target_position = np.array([10.0, 10.0])
    cursor_position = np.array([0.0, 0.0])

    print()
    print("--- Starting BCI Simulation ---")
    print(f"User intends to move cursor from {cursor_position} to {target_position}")

    for i in range(50):
        # 1. User thinks about moving towards the target
        intended_movement = target_position - cursor_position

        # 2. Brain generates neural activity corresponding to that intent
        # (Simplified: ideal signal + noise)
        ideal_signal = intended_movement * 0.1
        noise = np.random.randn(decoder.n_channels) * 0.5
        # We need to simulate the mapping from intent to neural activity.
        # For this, we'll use the transpose of the decoder's learned weights (a simplification).
        # This simulates the brain learning what patterns the decoder understands.
        neural_activity = np.dot(decoder.decoder.coef_.T, intended_movement) + noise

        # 3. BCI decodes the neural activity
        decoded_movement = decoder.decode_intent(neural_activity)

        # 4. Cursor moves based on decoded intent
        cursor_position += decoded_movement.flatten()

        # 5. User sees the cursor move and adjusts their thoughts (closing the loop)
        # The AI decoder also adapts
        decoder.adapt(neural_activity, intended_movement)

        if i % 10 == 0:
            print(f"Step {i}: Cursor at {cursor_position.round(2)}")

    print("--- Simulation Finished ---")
    print(f"Final cursor position: {cursor_position.round(2)}")
    return decoder

# Run the simulation
decoder = run_bci_simulation()

20.5.2 2.2 Visualizing Decoder Performance

# Run this cell to visualize how the decoder improves over time

def visualize_adaptation(n_runs=5):
    """
    Run multiple BCI simulations and track performance improvements.
    """
    distances_to_target = []

    for run in range(n_runs):
        decoder = AI_Powered_Motor_Decoder()
        target = np.array([10.0, 10.0])
        cursor = np.array([0.0, 0.0])
        trajectory = [cursor.copy()]

        for i in range(50):
            intended = target - cursor
            noise = np.random.randn(decoder.n_channels) * 0.5
            neural = np.dot(decoder.decoder.coef_.T, intended) + noise
            decoded = decoder.decode_intent(neural)
            cursor += decoded.flatten()
            trajectory.append(cursor.copy())
            decoder.adapt(neural, intended)

        trajectory = np.array(trajectory)
        final_distance = np.linalg.norm(cursor - target)
        distances_to_target.append(final_distance)

        plt.plot(trajectory[:, 0], trajectory[:, 1], alpha=0.5,
                label=f'Run {run+1} (dist={final_distance:.2f})')

    plt.scatter([0], [0], c='green', s=100, marker='o', label='Start')
    plt.scatter([10], [10], c='red', s=100, marker='*', label='Target')
    plt.xlabel('X Position')
    plt.ylabel('Y Position')
    plt.title('BCI Cursor Trajectories with Co-Adaptation')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.axis('equal')
    plt.show()

    print(f"\nAverage final distance to target: {np.mean(distances_to_target):.2f}")

visualize_adaptation()

20.6 Part 3: Analysis

20.6.1 3.1 Exploring Adaptation Strategies

# Compare different adaptation strategies

class StaticDecoder:
    """Decoder without adaptation - for comparison."""
    def __init__(self, n_channels=100, n_dof=2):
        self.decoder = Ridge(alpha=1.0)
        self.decoder.fit(np.random.randn(10, n_channels), np.random.randn(10, n_dof))
        self.n_channels = n_channels

    def decode_intent(self, neural_activity):
        return self.decoder.predict(neural_activity.reshape(1, -1))

    def adapt(self, neural_activity, intended_movement):
        pass  # No adaptation


def compare_strategies(n_trials=30):
    """
    Compare static vs. adaptive decoder performance.
    """
    target = np.array([10.0, 10.0])

    static_errors = []
    adaptive_errors = []

    for trial in range(n_trials):
        # Static decoder
        static = StaticDecoder()
        cursor = np.array([0.0, 0.0])
        for _ in range(50):
            intended = target - cursor
            noise = np.random.randn(static.n_channels) * 0.5
            neural = np.dot(static.decoder.coef_.T, intended) + noise
            decoded = static.decode_intent(neural)
            cursor += decoded.flatten()
        static_errors.append(np.linalg.norm(cursor - target))

        # Adaptive decoder
        adaptive = AI_Powered_Motor_Decoder()
        cursor = np.array([0.0, 0.0])
        for _ in range(50):
            intended = target - cursor
            noise = np.random.randn(adaptive.n_channels) * 0.5
            neural = np.dot(adaptive.decoder.coef_.T, intended) + noise
            decoded = adaptive.decode_intent(neural)
            cursor += decoded.flatten()
            adaptive.adapt(neural, intended)
        adaptive_errors.append(np.linalg.norm(cursor - target))

    # Visualize comparison
    plt.figure(figsize=(10, 5))
    plt.boxplot([static_errors, adaptive_errors], labels=['Static', 'Adaptive'])
    plt.ylabel('Final Distance to Target')
    plt.title('Decoder Performance: Static vs. Adaptive')
    plt.grid(True, alpha=0.3, axis='y')
    plt.show()

    print(f"Static decoder mean error: {np.mean(static_errors):.2f}")
    print(f"Adaptive decoder mean error: {np.mean(adaptive_errors):.2f}")
    print(f"Improvement: {(1 - np.mean(adaptive_errors)/np.mean(static_errors))*100:.1f}%")

compare_strategies()

20.7 Exercises

20.7.1 Exercise 1: Simple Neural Decoder

Implement a basic linear decoder for a simulated motor BCI: - Generate synthetic neural data: 50 neurons, 1000 time points, where firing rates are noisy linear functions of intended movement direction (2D) - Train a linear regression model to predict movement from neural activity - Test the decoder and calculate prediction accuracy - Visualize: scatter plot of predicted vs. actual movements

# Your implementation here

# Step 1: Generate synthetic neural data
n_neurons = 50
n_timepoints = 1000

# True decoder weights (what we're trying to learn)
true_weights = np.random.randn(n_neurons, 2) * 0.1

# Generate intended movements (random 2D directions)
# Hint: use np.random.randn

# Generate neural activity as linear function + noise
# neural = intended @ true_weights.T + noise

# Step 2: Split into train/test

# Step 3: Train linear regression

# Step 4: Evaluate and visualize

20.7.2 Exercise 2: Online Adaptation Strategies

Extend the code from Part 2 to implement and compare different adaptation strategies: - No adaptation (static decoder) - Batch adaptation (retrain every N trials) - Online adaptation (update continuously with exponential weighting)

# Your implementation here

class ExponentialAdaptiveDecoder:
    """
    Decoder with exponential moving average adaptation.
    """
    def __init__(self, n_channels=100, n_dof=2, alpha=0.1):
        # alpha controls how quickly the decoder adapts (0 = slow, 1 = instant)
        pass

    def decode_intent(self, neural_activity):
        pass

    def adapt(self, neural_activity, intended_movement):
        pass

# Compare all three strategies

20.7.3 Exercise 3: P300 Speller Simulation

Implement a simplified P300 speller BCI: - Simulate a 6x6 letter grid - Generate synthetic EEG data with P300 responses - Implement a classifier to detect P300 - Calculate typing speed and accuracy

# Your implementation here

def simulate_p300_speller():
    """
    Simulate a P300 speller paradigm.
    """
    # Create 6x6 letter grid
    letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
    grid = np.array(list(letters)).reshape(6, 6)

    # Simulate EEG response to flashing
    # P300 is a positive deflection ~300ms after stimulus

    pass

20.7.4 Exercise 4: Information Transfer Rate

Research and implement key BCI performance metrics: - Calculate ITR (bits per minute) - Compare across different paradigms - Plot ITR vs. accuracy and selection time

# Your implementation here

def calculate_itr(accuracy, n_choices, selection_time_seconds):
    """
    Calculate Information Transfer Rate in bits per minute.

    ITR = (1/selection_time) * [log2(n_choices) + accuracy*log2(accuracy) +
         (1-accuracy)*log2((1-accuracy)/(n_choices-1))]

    Parameters:
    -----------
    accuracy : float
        Classification accuracy (0 to 1)
    n_choices : int
        Number of possible selections
    selection_time_seconds : float
        Time per selection in seconds

    Returns:
    --------
    itr : float
        Bits per minute
    """
    pass

# Plot ITR as function of accuracy for different selection times

20.7.5 Exercise 5: NeuroAI Connections

Discuss how BCI relates to broader themes in neuroscience and AI.

# Discussion points:
# 1. How does neural decoding relate to representation learning in AI?
# 2. What can AI learn from how the brain encodes information?
# 3. How does co-adaptation mirror human-AI collaboration?

20.8 Challenge Problems

20.8.1 Challenge 1: Kalman Filter Decoder

Implement a Kalman filter for continuous cursor control - the classical approach used in many BCI systems.

# Implement Kalman filter decoder
# State: cursor position and velocity
# Observation: neural activity

20.8.2 Challenge 2: Neural Network Decoder

Replace the linear decoder with a simple neural network (MLP or RNN) and compare performance.

# Use PyTorch or TensorFlow to implement a neural network decoder

20.8.3 Challenge 3: Simulated Electrode Drift

Implement a scenario where electrode signals drift over time, and show which adaptation strategy is most robust.

# Simulate non-stationary neural signals
# Compare robustness of different adaptation methods

Open In Colab

20.9 Discussion Questions

20.9.1 Question 1

How does co-adaptation in BCIs relate to the concept of human-in-the-loop machine learning?

20.9.2 Question 2

What are the ethical implications of BCIs that can “read thoughts”? How should we balance utility with privacy?

20.9.3 Question 3

How might BCI technology change the nature of human-AI collaboration in the future?


20.10 Summary

Explored BCI systems through theory and practice, focusing on: - Neural decoding with adaptive AI models - Co-adaptation between user and decoder - Performance metrics and evaluation strategies

Key Takeaways: - Adaptive decoders significantly outperform static decoders - Co-adaptation is essential for robust BCI performance - Information Transfer Rate provides a principled way to compare BCI systems - BCI represents a unique intersection of neuroscience and AI

Connections to Chapter 20: - The AI_Powered_Motor_Decoder class demonstrates the co-adaptation concept discussed in Section 20.4 - These exercises provide hands-on experience with the core BCI pipeline - Performance comparisons illustrate why modern AI-powered BCIs outperform earlier systems