9  Lab 9: Causal Inference in NeuroAI

Open In Colab

Lab 9 Overview: Causal Inference in Neuroscience

9.1 Learning Objectives

  1. Distinguish correlation from causation in neural data
  2. Implement Granger causality and transfer entropy
  3. Apply interventional analysis and counterfactual reasoning
  4. Visualize causal graphs and directed information flow
  5. Design experiments to test causal hypotheses

9.2 Prerequisites

  • Reading: Chapter 9: Causal Inference
  • Libraries: NumPy, Matplotlib, NetworkX, Statsmodels, Scikit-learn, PyTorch
  • Concepts: Time series analysis, conditional independence, DAGs

9.3 Setup

Run this cell to import all required libraries and set up the environment:

import numpy as np
import matplotlib.pyplot as plt
import networkx as nx
from statsmodels.tsa.stattools import grangercausalitytests
from scipy.stats import pearsonr
from sklearn.linear_model import LinearRegression

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

9.4 Part 1: Building Causal DAGs

Causal graphs (Directed Acyclic Graphs) provide a formal language for representing causal relationships. Run this cell to visualize a causal DAG for a neuroscience experiment.

# Run this cell to visualize a causal DAG for a neuroscience experiment
# Example: Does V1 activity cause motor cortex activity during a visual task?
# - Stimulus causes both V1 and motor cortex activity (confounder)
# - V1 may also directly influence motor cortex

from matplotlib.patches import FancyBboxPatch

def plot_causal_dag():
    """
    Visualize a causal DAG for a neuroscience experiment.

    Example: Does V1 activity cause motor cortex activity during a visual task?
    - Stimulus causes both V1 and motor cortex activity (confounder)
    - V1 may also directly influence motor cortex
    """
    G = nx.DiGraph()

    # Add nodes
    nodes = ['Stimulus', 'V1', 'Motor', 'Behavior']
    G.add_nodes_from(nodes)

    # Add edges (causal relationships)
    edges = [
        ('Stimulus', 'V1'),
        ('Stimulus', 'Motor'),
        ('V1', 'Motor'),
        ('Motor', 'Behavior')
    ]
    G.add_edges_from(edges)

    # Layout
    pos = {
        'Stimulus': (0, 1),
        'V1': (1, 1.5),
        'Motor': (2, 1),
        'Behavior': (3, 1)
    }

    # Plot
    fig, ax = plt.subplots(figsize=(10, 6))

    # Draw nodes
    nx.draw_networkx_nodes(G, pos, node_color='#cc0000',
                           node_size=3000, alpha=0.9, ax=ax)

    # Draw edges
    nx.draw_networkx_edges(G, pos, width=2, alpha=0.6,
                           edge_color='#333333',
                           arrowsize=20, arrowstyle='->', ax=ax)

    # Draw labels
    nx.draw_networkx_labels(G, pos, font_size=12,
                            font_weight='bold', font_color='white', ax=ax)

    ax.set_xlim(-0.5, 3.5)
    ax.set_ylim(0.5, 2)
    ax.axis('off')
    ax.set_title('Causal DAG: Visual Task Experiment', fontsize=14, pad=20)

    plt.tight_layout()
    plt.show()

plot_causal_dag()

Interpretation: In this DAG, the Stimulus is a confounder - it causes both V1 and Motor activity. If we only observe correlations between V1 and Motor, we cannot tell if V1 causes Motor (direct effect), Stimulus causes both (confounding), or both mechanisms are present.


Open In Colab

9.5 Part 2: Optogenetics as Intervention

This simulation demonstrates how optogenetics enables causal inference by comparing observational vs. interventional data.

# Run this cell to simulate an optogenetics experiment
# This demonstrates causal inference by comparing observational vs. interventional data

def simulate_optogenetics_experiment(n_trials=1000):
    """
    Simulate an optogenetics experiment demonstrating causal inference.

    Scenario:
    - Stimulus → V1 activity
    - Stimulus → Motor activity
    - V1 → Motor activity (direct effect we want to measure)

    We compare:
    1. Observational: P(Motor | V1=high)
    2. Interventional: P(Motor | do(V1=high)) using optogenetics
    """
    np.random.seed(42)

    # Generate data
    stimulus_strength = np.random.randn(n_trials)

    # Observational data (natural correlations)
    v1_natural = 0.8 * stimulus_strength + np.random.randn(n_trials) * 0.3
    motor_natural = 0.5 * stimulus_strength + 0.3 * v1_natural + np.random.randn(n_trials) * 0.3

    # Interventional data (optogenetics forces V1 activity)
    v1_opto = np.random.choice([0, 2], n_trials)  # Either off or strongly activated
    motor_opto = 0.5 * stimulus_strength + 0.3 * v1_opto + np.random.randn(n_trials) * 0.3

    # Analysis
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))

    # Observational correlation
    axes[0].scatter(v1_natural, motor_natural, alpha=0.3, c='#0066cc')
    axes[0].set_xlabel('V1 Activity (observed)')
    axes[0].set_ylabel('Motor Activity')
    axes[0].set_title('Observational Data - (Correlation includes confounding)')

    # Add regression line
    z = np.polyfit(v1_natural, motor_natural, 1)
    p = np.poly1d(z)
    x_line = np.linspace(v1_natural.min(), v1_natural.max(), 100)
    axes[0].plot(x_line, p(x_line), "r-", linewidth=2,
                 label=f'Slope: {z[0]:.2f}')
    axes[0].legend()

    # Interventional (optogenetics)
    for opto_level in [0, 2]:
        mask = v1_opto == opto_level
        label = 'Opto OFF' if opto_level == 0 else 'Opto ON'
        color = '#cccccc' if opto_level == 0 else '#cc0000'
        axes[1].scatter(v1_opto[mask], motor_opto[mask],
                       alpha=0.3, c=color, label=label)

    axes[1].set_xlabel('V1 Activity (manipulated)')
    axes[1].set_ylabel('Motor Activity')
    axes[1].set_title('Interventional Data (Optogenetics) - (Direct causal effect)')
    axes[1].legend()

    # Calculate causal effect
    motor_opto_on = motor_opto[v1_opto == 2].mean()
    motor_opto_off = motor_opto[v1_opto == 0].mean()
    causal_effect = motor_opto_on - motor_opto_off

    fig.suptitle(f'True Causal Effect: {0.3 * 2:.2f}, Estimated: {causal_effect:.2f}',
                 fontsize=12, y=1.02)
    plt.tight_layout()
    plt.show()

simulate_optogenetics_experiment()

Key Insight: The observational correlation includes both the direct effect (V1 → Motor) and confounding (Stimulus → V1, Stimulus → Motor). The interventional approach isolates the direct causal effect.


9.6 Part 3: Controlling for Confounders in Regression

Run this cell to see how controlling for confounders in regression reveals true causal effects.

# Run this cell to demonstrate confounding control in regression

def demonstrate_confounding_control():
    """
    Show how controlling for confounders in regression reveals causal effects.
    """
    np.random.seed(42)
    n = 500

    # True causal structure:
    # Confounder → Treatment
    # Confounder → Outcome
    # Treatment → Outcome (this is what we want to estimate)

    confounder = np.random.randn(n)
    treatment = 0.8 * confounder + np.random.randn(n) * 0.5
    outcome = 0.5 * treatment + 1.2 * confounder + np.random.randn(n) * 0.3

    # Naive regression (ignoring confounder)
    model_naive = LinearRegression()
    model_naive.fit(treatment.reshape(-1, 1), outcome)
    effect_naive = model_naive.coef_[0]

    # Proper regression (controlling for confounder)
    X_controlled = np.column_stack([treatment, confounder])
    model_controlled = LinearRegression()
    model_controlled.fit(X_controlled, outcome)
    effect_controlled = model_controlled.coef_[0]

    print(f"True causal effect: 0.50")
    print(f"Naive estimate (no control): {effect_naive:.2f}")
    print(f"Controlled estimate: {effect_controlled:.2f}")

    return effect_naive, effect_controlled

demonstrate_confounding_control()

Open In Colab

9.7 Part 4: Instrumental Variable Estimation

Run this cell to see instrumental variable estimation using two-stage least squares.

# Run this cell to demonstrate instrumental variable estimation
# This method helps estimate causal effects when there are unmeasured confounders

def instrumental_variable_example():
    """
    Demonstrate instrumental variable estimation.
    """
    np.random.seed(42)
    n = 1000

    # True causal structure:
    # Instrument → Treatment
    # Treatment → Outcome
    # Unmeasured confounder → Treatment
    # Unmeasured confounder → Outcome

    instrument = np.random.randn(n)  # e.g., genetic variant
    unmeasured = np.random.randn(n)  # e.g., socioeconomic factors

    treatment = 0.6 * instrument + 0.5 * unmeasured + np.random.randn(n) * 0.3
    outcome = 0.8 * treatment + 0.7 * unmeasured + np.random.randn(n) * 0.3

    # Naive OLS (biased due to unmeasured confounder)
    model_ols = LinearRegression()
    model_ols.fit(treatment.reshape(-1, 1), outcome)
    effect_ols = model_ols.coef_[0]

    # Two-stage least squares (IV estimation)
    # Stage 1: Regress treatment on instrument
    model_stage1 = LinearRegression()
    model_stage1.fit(instrument.reshape(-1, 1), treatment)
    treatment_predicted = model_stage1.predict(instrument.reshape(-1, 1))

    # Stage 2: Regress outcome on predicted treatment
    model_stage2 = LinearRegression()
    model_stage2.fit(treatment_predicted.reshape(-1, 1), outcome)
    effect_iv = model_stage2.coef_[0]

    print(f"True causal effect: 0.80")
    print(f"OLS estimate (biased): {effect_ols:.2f}")
    print(f"IV estimate (unbiased): {effect_iv:.2f}")

    return effect_ols, effect_iv

instrumental_variable_example()

9.8 Part 5: Causal Discovery with the PC Algorithm

Run this cell to see a simplified illustration of the PC algorithm for causal discovery.

# Run this cell to demonstrate causal discovery using the PC algorithm
# Note: This is a simplified illustration. Real applications should use
# dedicated libraries like causal-learn or py-causal.

def demonstrate_pc_algorithm():
    """
    Demonstrate causal discovery using the PC algorithm.

    Note: This is a simplified illustration. Real applications should use
    dedicated libraries like causal-learn or py-causal.
    """
    np.random.seed(42)
    n = 1000

    # Generate data from known causal structure
    # True structure: X → Y → Z, X → Z
    X = np.random.randn(n)
    Y = 0.7 * X + np.random.randn(n) * 0.3
    Z = 0.5 * Y + 0.4 * X + np.random.randn(n) * 0.3

    data = np.column_stack([X, Y, Z])
    var_names = ['X', 'Y', 'Z']

    # Step 1: Start with fully connected graph
    G = nx.DiGraph()
    G.add_nodes_from(var_names)
    for i in range(len(var_names)):
        for j in range(len(var_names)):
            if i != j:
                G.add_edge(var_names[i], var_names[j])

    # Step 2: Remove edges based on conditional independence
    # (Simplified version - real PC algorithm is more sophisticated)

    def partial_correlation(x, y, z):
        """Compute partial correlation of x and y given z."""
        model_xz = LinearRegression().fit(z.reshape(-1, 1), x)
        residual_x = x - model_xz.predict(z.reshape(-1, 1))

        model_yz = LinearRegression().fit(z.reshape(-1, 1), y)
        residual_y = y - model_yz.predict(z.reshape(-1, 1))

        corr, p_value = pearsonr(residual_x, residual_y)
        return corr, p_value

    # Test: Are X and Z independent given Y?
    corr_xz_given_y, p_value = partial_correlation(X, Z, Y)
    print(f"Partial correlation X-Z | Y: {corr_xz_given_y:.3f} (p={p_value:.3f})")
    print(f"X and Z are {'independent' if p_value > 0.05 else 'dependent'} given Y")
    print("Therefore, there must be a direct edge X → Z in the true graph")

    return G

demonstrate_pc_algorithm()

Open In Colab

9.9 Part 6: Neural Network Ablation as Intervention

Run this cell to see how ablation studies in neural networks mirror lesion studies in neuroscience.

# Run this cell to demonstrate causal inference in neural networks through ablation
# This requires PyTorch - if not installed, run: pip install torch

def neural_network_ablation_example():
    """
    Demonstrate causal inference in neural networks through ablation.

    Question: Does a particular layer/neuron cause the network's prediction?
    Method: Ablate (set to zero) and measure effect on output.
    """
    import torch
    import torch.nn as nn

    # Simple neural network
    model = nn.Sequential(
        nn.Linear(10, 20),
        nn.ReLU(),
        nn.Linear(20, 10),
        nn.ReLU(),
        nn.Linear(10, 2)
    )

    # Generate random input
    x = torch.randn(1, 10)

    # Normal forward pass
    output_normal = model(x)
    pred_normal = torch.argmax(output_normal, dim=1).item()

    # Ablate second layer (set to zero)
    def forward_with_ablation(model, x, layer_idx):
        """Forward pass with specified layer ablated."""
        with torch.no_grad():
            out = x
            for i, layer in enumerate(model):
                out = layer(out)
                if i == layer_idx:
                    out = torch.zeros_like(out)  # Ablation
            return out

    output_ablated = forward_with_ablation(model, x, layer_idx=2)
    pred_ablated = torch.argmax(output_ablated, dim=1).item()

    print(f"Normal prediction: class {pred_normal}")
    print(f"Ablated prediction: class {pred_ablated}")
    print(f"Causal effect of layer 2: {'None' if pred_normal == pred_ablated else 'Significant'}")

    return pred_normal, pred_ablated

neural_network_ablation_example()

9.10 Part 7: Correlation vs. Causation

Run this cell to visualize spurious correlations vs. true causal relationships.

# Run this cell to generate data demonstrating spurious correlation

## Generate data with spurious correlation
n = 1000
time = np.arange(n)

## Common cause Z drives both X and Y
Z = np.cumsum(np.random.randn(n))
X = Z + np.random.randn(n) * 0.5
Y = Z + np.random.randn(n) * 0.5

## X and Y are correlated but not causally related
plt.figure(figsize=(14, 10))

plt.subplot(2, 2, 1)
plt.plot(time[:200], X[:200], label='X', alpha=0.7)
plt.plot(time[:200], Y[:200], label='Y', alpha=0.7)
plt.legend()
plt.title('Time Series X and Y', fontweight='bold')

plt.subplot(2, 2, 2)
plt.scatter(X, Y, alpha=0.5)
plt.xlabel('X')
plt.ylabel('Y')
plt.title(f'Correlation: {np.corrcoef(X, Y)[0,1]:.3f}\\n(Spurious!)', fontweight='bold')

plt.subplot(2, 2, 3)
## Now with true causal relationship: X causes Y
Y_causal = 0.8 * np.roll(X, 1) + np.random.randn(n) * 0.3
plt.plot(time[:200], X[:200], label='X (cause)', alpha=0.7)
plt.plot(time[:200], Y_causal[:200], label='Y (effect)', alpha=0.7)
plt.legend()
plt.title('Causal Relationship: X → Y', fontweight='bold')

plt.subplot(2, 2, 4)
## Cross-correlation
from scipy import signal
cross_corr = signal.correlate(X - X.mean(), Y_causal - Y_causal.mean(), mode='same')
lags = signal.correlation_lags(len(X), len(Y_causal), mode='same')
plt.plot(lags[450:550], cross_corr[450:550])
plt.axvline(x=0, color='red', linestyle='--')
plt.xlabel('Lag')
plt.ylabel('Cross-correlation')
plt.title('X leads Y (lag = -1)', fontweight='bold')

plt.tight_layout()
plt.show()

Open In Colab

9.11 Part 8: Granger Causality

Run this cell to test Granger causality on time series data.

# Run this cell to test Granger causality

## Generate data where X Granger-causes Y
n = 500
X = np.random.randn(n)
Y = np.zeros(n)

for t in range(2, n):
    X[t] = 0.5 * X[t-1] + np.random.randn()
    Y[t] = 0.7 * X[t-1] + 0.3 * Y[t-1] + np.random.randn() * 0.5

## Test Granger causality
data = np.column_stack([Y, X])
results = grangercausalitytests(data, maxlag=5, verbose=False)

## Extract p-values
p_values = [results[lag][0]['ssr_ftest'][1] for lag in range(1, 6)]

plt.figure(figsize=(12, 5))
plt.bar(range(1, 6), p_values, color='skyblue')
plt.axhline(y=0.05, color='red', linestyle='--', label='p=0.05')
plt.xlabel('Lag')
plt.ylabel('p-value')
plt.title('Granger Causality Test: X → Y', fontweight='bold')
plt.legend()
plt.tight_layout()
plt.show()

print(f"X Granger-causes Y: {p_values[0] < 0.05}")

9.12 Part 9: Causal Graph Visualization

Run this cell to visualize a more complex causal graph representing the visual hierarchy.

# Run this cell to visualize a causal graph of the visual hierarchy

## Build a causal DAG
G = nx.DiGraph()
G.add_edges_from([
    ('Stimulus', 'V1'),
    ('V1', 'V2'),
    ('V2', 'V4'),
    ('V4', 'IT'),
    ('V1', 'V4'),  # Skip connection
    ('Context', 'V4'),
])

plt.figure(figsize=(10, 8))
pos = nx.spring_layout(G, seed=42)
nx.draw(G, pos, with_labels=True, node_size=3000, node_color='lightblue',
        font_size=12, font_weight='bold', arrows=True,
        edge_color='gray', width=2, arrowsize=20)
plt.title('Causal Graph: Visual Hierarchy', fontsize=16, fontweight='bold')
plt.tight_layout()
plt.show()

Open In Colab

9.13 Exercises

  1. Implement transfer entropy - Measure directed information flow between time series
  2. Test conditional independence - Build tests for d-separation in causal graphs
  3. Perform interventional analysis - Simulate different intervention strategies
  4. Build structural equation models - Create and fit SEMs to neural data

9.14 Summary

In this lab, you: - Built and visualized causal DAGs for neuroscience experiments - Compared observational vs. interventional data using optogenetics simulations - Applied confounding control in regression analysis - Used instrumental variables for unbiased causal effect estimation - Explored causal discovery with the PC algorithm - Performed neural network ablation studies - Tested Granger causality on time series data