"""Pattern completion network for olfactory/gustatory modalities with empirical parameters."""
from typing import List, Optional
import numpy as np
from mercurial.params.empirical import HebbianParams
[docs]
class HopfieldPatternCompletion:
"""
Hopfield‑style attractor network for pattern completion,
parameterised by empirical olfactory bulb and piriform cortex data.
"""
[docs]
def __init__(
self,
n_neurons: int = 8000, # approximate human glomeruli count
stored_patterns: Optional[List[np.ndarray]] = None,
tau_a: float = 0.020, # 20 ms neural time constant
tau_f: float = 1.0, # fatigue time constant (s)
beta_f: float = 0.5, # fatigue scaling
noise_amp: float = 0.01,
beta_sigmoid: float = 10.0,
hebb_params: Optional[HebbianParams] = None,
):
"""
Parameters
----------
n_neurons : int
Number of units (default 8000, approximate human olfactory bulb glomeruli).
stored_patterns : list of np.ndarray, optional
Binary patterns {-1,1} or continuous [0,1] to store.
tau_a, tau_f : float
Time constants for activity and fatigue (s).
beta_f : float
Fatigue scaling.
noise_amp : float
Stochastic noise amplitude.
beta_sigmoid : float
Gain of the sigmoid (high for binary attractors).
hebb_params : HebbianParams, optional
Empirical Hebbian learning parameters.
"""
self.N = n_neurons
self.tau_a = tau_a
self.tau_f = tau_f
self.beta_f = beta_f
self.noise_amp = noise_amp
self.beta_sigmoid = beta_sigmoid
self.rng = np.random.default_rng()
if hebb_params is None:
hebb_params = HebbianParams()
self.eta = hebb_params.learning_rate
self.gamma = hebb_params.decay
self.r = np.zeros(n_neurons)
self.f = np.zeros(n_neurons)
self.W = np.zeros((n_neurons, n_neurons))
if stored_patterns is not None:
self.store_patterns(stored_patterns)
[docs]
def store_patterns(self, patterns: List[np.ndarray]) -> None:
pats = []
for p in patterns:
if np.max(p) <= 1.0 and np.min(p) >= 0.0:
pats.append(2 * p - 1)
else:
pats.append(p)
self.W = np.zeros((self.N, self.N))
for mu in range(len(pats)):
xi = pats[mu]
self.W += np.outer(xi, xi)
self.W /= self.N
np.fill_diagonal(self.W, 0.0)
[docs]
def sigmoid(self, x: float) -> float:
arg = self.beta_sigmoid * x
if arg > 500:
return 1.0
if arg < -500:
return 0.0
return 1.0 / (1.0 + np.exp(-arg))
[docs]
def step(self, dt: float, external_input: np.ndarray) -> np.ndarray:
h_eff = external_input - self.f
total_input = self.W @ self.r + h_eff
r_target = np.array([self.sigmoid(x) for x in total_input])
dr = (-self.r + r_target) / self.tau_a
noise = self.noise_amp * np.sqrt(dt) * self.rng.normal(size=self.N)
self.r += dr * dt + noise
self.r = np.clip(self.r, 0.0, 1.0)
df = (-self.f + self.beta_f * self.r) / self.tau_f
self.f += df * dt
self.f = np.clip(self.f, 0.0, 1.0)
return self.r.copy()
[docs]
def get_overlap(self, pattern: np.ndarray) -> float:
if np.max(pattern) <= 1.0 and np.min(pattern) >= 0.0:
pat = 2 * pattern - 1
else:
pat = pattern
r_bipolar = 2 * self.r - 1
return np.dot(r_bipolar, pat) / self.N
[docs]
def reset(self):
self.r.fill(0.0)
self.f.fill(0.0)