| """ |
| Predictive Coding — Hierarchical Prediction-Error Minimization |
| |
| Implements Friston's Box 3: the canonical microcircuit for hierarchical |
| prediction error processing: |
| - Superficial Pyramidal (SG): prediction errors ε (bottom-up) |
| - Layer 4 (L4): state estimation |
| - Deep Pyramidal (IG): predictions μ (top-down) |
| |
| Recognition dynamics: perception as gradient descent on free energy. |
| μ̇ = Dμ − ∂F/∂μ (internal state update) |
| ε = observed − predicted (prediction error) |
| |
| Reference: Friston (2009) Box 3, Figure I |
| Author: Algorembrant, Rembrant Oyangoren Albeos (2026) |
| """ |
|
|
| import numpy as np |
| from typing import Optional |
|
|
|
|
| class CorticalColumn: |
| """ |
| A single cortical column implementing the canonical microcircuit. |
| |
| Contains: |
| - Superficial granular (SG): prediction error neurons |
| - Layer 4 (L4): state representation neurons |
| - Infragranular (IG): prediction neurons (deep pyramidal) |
| """ |
| |
| def __init__(self, size: int, level: int = 0): |
| """ |
| Args: |
| size: Number of units in this column. |
| level: Hierarchical level (0 = lowest/sensory). |
| """ |
| self.size = size |
| self.level = level |
| |
| |
| self.mu = np.zeros(size) |
| self.mu_dot = np.zeros(size) |
| self.epsilon = np.zeros(size) |
| self.prediction = np.zeros(size) |
| |
| |
| self.precision = np.ones(size) |
| |
| |
| self.forward_weights = None |
| self.backward_weights = None |
| self.lateral_weights = None |
| |
| def initialize_connections(self, input_size: int, output_size: Optional[int] = None): |
| """Initialize synaptic weights for this column's connections.""" |
| self.forward_weights = np.random.randn(self.size, input_size) * 0.1 |
| if output_size is not None: |
| self.backward_weights = np.random.randn(output_size, self.size) * 0.1 |
| self.lateral_weights = np.random.randn(self.size, self.size) * 0.01 |
| np.fill_diagonal(self.lateral_weights, 0) |
|
|
|
|
| class PredictiveCodingHierarchy: |
| """ |
| Full hierarchical predictive coding network. |
| |
| Implements the hierarchical generative model from Friston Box 3: |
| - Each level generates predictions for the level below |
| - Prediction errors propagate upward (superficial pyramidal) |
| - Predictions propagate downward (deep pyramidal) |
| - Recognition dynamics minimize free energy via gradient descent |
| |
| This is NOT variational inference in the ML sense — this is |
| biological free-energy minimization via neural dynamics. |
| """ |
| |
| def __init__(self, layer_sizes: list[int], learning_rate: float = 0.01, |
| dt: float = 0.1, n_iterations: int = 10): |
| """ |
| Args: |
| layer_sizes: Sizes of each hierarchical level [sensory, ..., abstract]. |
| learning_rate: Step size for recognition dynamics. |
| dt: Integration time step. |
| n_iterations: Number of iterations per perception step. |
| """ |
| self.n_levels = len(layer_sizes) |
| self.learning_rate = learning_rate |
| self.dt = dt |
| self.n_iterations = n_iterations |
| |
| |
| self.columns: list[CorticalColumn] = [] |
| for i, size in enumerate(layer_sizes): |
| col = CorticalColumn(size, level=i) |
| self.columns.append(col) |
| |
| |
| for i in range(1, self.n_levels): |
| self.columns[i].initialize_connections( |
| input_size=layer_sizes[i-1], |
| output_size=layer_sizes[i-1] |
| ) |
| |
| self.columns[0].lateral_weights = np.random.randn( |
| layer_sizes[0], layer_sizes[0] |
| ) * 0.01 |
| np.fill_diagonal(self.columns[0].lateral_weights, 0) |
| |
| def _generate_prediction(self, level: int) -> np.ndarray: |
| """ |
| Generate top-down prediction from level i to level i-1. |
| |
| g(μ⁽ⁱ⁾) — the generative model mapping from higher to lower. |
| """ |
| col = self.columns[level] |
| if col.backward_weights is not None: |
| |
| hidden = np.tanh(col.mu) |
| return np.dot(col.backward_weights, hidden) |
| return np.zeros(self.columns[level - 1].size if level > 0 else col.size) |
| |
| def _compute_prediction_errors(self, sensory_input: np.ndarray): |
| """ |
| Compute prediction errors at each level of the hierarchy. |
| |
| ε⁽ⁱ⁾ = μ⁽ⁱ⁻¹⁾ − g(μ⁽ⁱ⁾) |
| |
| Prediction error = what I observe − what I predicted. |
| """ |
| |
| if self.n_levels > 1: |
| prediction_from_above = self._generate_prediction(1) |
| self.columns[0].epsilon = sensory_input - prediction_from_above |
| else: |
| self.columns[0].epsilon = sensory_input - self.columns[0].mu |
| |
| |
| for i in range(1, self.n_levels - 1): |
| prediction_from_above = self._generate_prediction(i + 1) |
| self.columns[i].epsilon = self.columns[i].mu - prediction_from_above |
| |
| def _recognition_dynamics(self): |
| """ |
| Recognition dynamics — gradient descent on free energy. |
| |
| μ̇⁽ⁱ⁾ = Dμ⁽ⁱ⁾ − ∂F/∂μ⁽ⁱ⁾ |
| |
| The internal states update to minimize prediction error, |
| weighted by precision. This IS perception in the Fristonian framework. |
| """ |
| for i in range(self.n_levels): |
| col = self.columns[i] |
| |
| |
| |
| |
| dF_dmu = np.zeros(col.size) |
| |
| |
| if i > 0: |
| lower_col = self.columns[i - 1] |
| if col.forward_weights is not None: |
| |
| weighted_error = lower_col.precision * lower_col.epsilon |
| dF_dmu -= np.dot(col.forward_weights, weighted_error) |
| |
| |
| if i < self.n_levels - 1: |
| dF_dmu += col.precision * col.epsilon |
| |
| |
| if col.lateral_weights is not None: |
| lateral = np.dot(col.lateral_weights, col.mu) |
| dF_dmu -= 0.1 * lateral |
| |
| |
| col.mu_dot = -self.learning_rate * dF_dmu |
| col.mu += col.mu_dot * self.dt |
| |
| |
| col.mu = np.clip(col.mu, -5.0, 5.0) |
| |
| def process(self, sensory_input: np.ndarray) -> dict: |
| """ |
| Run perception: minimize free energy given sensory input. |
| |
| This is "seeing" — the brain settling into an interpretation |
| of sensory data that minimizes surprise (prediction error). |
| |
| Args: |
| sensory_input: Raw sensory data (preprocessed by retina/V1). |
| |
| Returns: |
| Dict with internal states, prediction errors, and free energy. |
| """ |
| free_energy_history = [] |
| |
| for iteration in range(self.n_iterations): |
| |
| self._compute_prediction_errors(sensory_input) |
| |
| |
| self._recognition_dynamics() |
| |
| |
| for i in range(1, self.n_levels): |
| self.columns[i].prediction = self._generate_prediction(i) |
| |
| |
| F = self._compute_free_energy() |
| free_energy_history.append(F) |
| |
| return { |
| 'states': [col.mu.copy() for col in self.columns], |
| 'errors': [col.epsilon.copy() for col in self.columns], |
| 'predictions': [col.prediction.copy() for col in self.columns], |
| 'free_energy': free_energy_history, |
| 'final_F': free_energy_history[-1] if free_energy_history else 0.0 |
| } |
| |
| def _compute_free_energy(self) -> float: |
| """ |
| Compute variational free energy across the hierarchy. |
| |
| F ≈ Σᵢ (εᵢ)ᵀ Πᵢ εᵢ (precision-weighted sum of squared errors) |
| |
| This is the Laplace approximation to true free energy. |
| """ |
| F = 0.0 |
| for col in self.columns: |
| |
| F += 0.5 * np.sum(col.precision * col.epsilon**2) |
| return float(F) |
| |
| def learn(self, learning_rate: float = 0.001): |
| """ |
| Update generative model parameters (synaptic plasticity). |
| |
| This slowly adjusts the top-down generative model to better |
| predict sensory input. Corresponds to synaptic plasticity |
| in the brain (much slower timescale than recognition dynamics). |
| """ |
| for i in range(1, self.n_levels): |
| col = self.columns[i] |
| lower = self.columns[i - 1] |
| |
| if col.backward_weights is not None: |
| |
| |
| hidden = np.tanh(col.mu) |
| dg_dW = np.outer(lower.epsilon, hidden) |
| col.backward_weights += learning_rate * dg_dW |
| |
| if col.forward_weights is not None: |
| |
| dW = np.outer(col.mu, lower.epsilon * lower.precision) |
| col.forward_weights += learning_rate * dW |
| |
| def get_representation(self, level: int) -> np.ndarray: |
| """Get the internal state representation at a given hierarchical level.""" |
| return self.columns[level].mu.copy() |
| |
| def get_total_surprise(self) -> float: |
| """Total surprise = total precision-weighted prediction error.""" |
| return self._compute_free_energy() |
|
|