r"""COBRA: a context-rotated density-matrix belief filter for robots that read people.
A robot watching a person receives a stream of sensor frames (gaze, hand motion, force). What a reading
*means* often depends on what came just before it: the same hand movement signals a hand-over after a
look at the robot and a retrieval after a look away. A classical Bayes filter treats the frames as
conditionally independent and cannot represent that. COBRA keeps the robot's belief as a density matrix
:math:`\rho` over :math:`d` hypotheses and updates it with a *generalised measurement* whose basis is
rotated by the previous frame:
.. math::
M_t = U(x_{t-1})\,\mathrm{diag}\bigl(e^{(Wx_t + b)/2}\bigr)\,U(x_{t-1})^\dagger , \qquad
\rho_t \propto M_t \rho_{t-1} M_t ,
U(x) = \exp\bigl(-i\,[\,H_0 + \textstyle\sum_k \tanh(\beta x_k)\,H_k\,]\bigr),
followed by partial dephasing :math:`\rho \leftarrow (1-g)\rho + g\,\mathrm{diag}(\rho)`. The probability of
hypothesis :math:`z` is :math:`\rho_{zz}` (Born rule). The :math:`H_k` are learned Hermitian matrices.
Two special cases make the model honest about what is quantum-like in it. With :math:`H = 0` it is a
classical Bayes filter with log-linear likelihoods. With :math:`g = 1` it is a classical *incoherent*
context-dependent filter: the previous frame still changes the meaning of the next one, but no phase
survives between frames. Comparing the three isolates the effect of the context rotation and of the
coherence.
This is a classical simulation of the quantum-probability formalism; it needs only NumPy and does not claim
a quantum computational advantage. Training uses finite-difference Adam with symmetry-breaking restarts,
because the rotation and the evidence weights must grow together (a product of two near-zero terms is a
saddle); the benchmark in ``benchmarks/context_intent_benchmark.py`` shows that the context mechanism is
found on some restarts and seeds and not on others.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
__all__ = ['COBRA', 'BeliefTrace', 'OnlineBelief', 'GaussianBayesFilter', 'min_cost_action']
_EXPM_SCALE = 3 # scaling-and-squaring levels
_EXPM_ORDER = 9 # Taylor order
def _hermitian(vec, d):
"""Hermitian matrices from real parameters of shape (..., d*d)."""
out = np.zeros(vec.shape[:-1] + (d, d), dtype=complex)
idx = np.arange(d)
out[..., idx, idx] = vec[..., :d]
iu, ju = np.triu_indices(d, 1)
n_off = len(iu)
re, im = vec[..., d:d + n_off], vec[..., d + n_off:d + 2 * n_off]
out[..., iu, ju] = re + 1j * im
out[..., ju, iu] = re - 1j * im
return out
def _expm_skew(h):
"""exp(-i h) for batched Hermitian h, by scaling and squaring."""
scale = 2 ** _EXPM_SCALE
a = (-1j / scale) * h
term = np.broadcast_to(np.eye(h.shape[-1], dtype=complex), h.shape).copy()
total = term.copy()
for k in range(1, _EXPM_ORDER + 1):
term = term @ a / k
total += term
for _ in range(_EXPM_SCALE):
total = total @ total
return total
[docs]
def min_cost_action(probs, cost):
"""Action with minimum expected cost under a belief.
Parameters
----------
probs : array_like, shape (N, d) or (d,)
Belief over the hypotheses.
cost : array_like, shape (n_actions, d)
``cost[a, z]`` is the cost of action ``a`` when hypothesis ``z`` is true. Include an "ask" or
"wait" row so the robot can hedge under ambiguity.
Returns
-------
numpy.ndarray of int
Index of the best action for each belief.
Examples
--------
>>> from quantum_mind.belief import min_cost_action
>>> cost = [[0, 6, 10], [1.5, 0, 0], [0.8, 0.8, 0.8]] # hand over, wait, ask
>>> min_cost_action([[0.97, 0.02, 0.01], [0.7, 0.2, 0.1]], cost).tolist()
[0, 2]
"""
probs, cost = np.atleast_2d(np.asarray(probs, float)), np.asarray(cost, float)
if probs.shape[1] != cost.shape[1]:
raise ValueError('cost must have one column per hypothesis')
return np.argmin(probs @ cost.T, axis=1)
[docs]
@dataclass(frozen=True)
class BeliefTrace:
"""Per-frame outputs of a COBRA forward pass.
Attributes
----------
probs : numpy.ndarray, shape (N, T, d)
Hypothesis probabilities after each frame.
coherence : numpy.ndarray, shape (N, T)
l1 coherence (sum of off-diagonal moduli) of :math:`\\rho` after each frame.
rho : numpy.ndarray, shape (N, T, d, d)
Density matrix after each frame.
"""
probs: np.ndarray
coherence: np.ndarray
rho: np.ndarray
[docs]
class OnlineBelief:
"""A COBRA belief updated one frame at a time, as a robot would run it.
Create it with :meth:`COBRA.start`. Call :meth:`update` with each new sensor frame; read
:attr:`probs` and :attr:`coherence` after every update.
Examples
--------
>>> import numpy as np
>>> from quantum_mind.belief import COBRA
>>> belief = COBRA(3, 3, restarts=1, steps=1).start()
>>> p = belief.update([0.5, -0.2, 0.1])
>>> bool(abs(p.sum() - 1) < 1e-9)
True
"""
def __init__(self, model, rho, prev):
self._model, self.rho, self._prev = model, rho, prev
self.frames = 0
@property
def probs(self):
p = np.clip(np.real(np.diag(self.rho)), 1e-9, None)
return p / p.sum()
@property
def coherence(self):
return float(np.abs(self.rho).sum() - np.abs(np.diag(self.rho)).sum())
[docs]
def update(self, frame):
"""Fold one sensor frame into the belief and return the new probabilities."""
x = np.asarray(frame, float).reshape(1, -1)
if x.shape[1] != self._model.m or not np.all(np.isfinite(x)):
raise ValueError('frame must be finite with %d features' % self._model.m)
self.rho = self._model._step(self._model.theta, self.rho[None], x, self._prev)[0]
self._prev = x
self.frames += 1
return self.probs
[docs]
class COBRA:
"""Context-rotated density-matrix belief filter (see the module docstring for the equations).
Parameters
----------
n_hyp : int, optional
Number of latent hypotheses (the Hilbert-space dimension), for example intents.
n_features : int, optional
Sensor features per frame.
beta : float, optional
Gain of the tanh squashing applied before the rotation generator.
steps, batch, lr : optional
Adam settings. Gradients are central finite differences, which is exact enough for the few dozen
parameters.
l2 : float, optional
Weight decay on all parameters.
restarts : int, optional
Symmetry-breaking initialisations tried before full training; the one with the best validation
loss after a short run is trained fully.
seed : int, optional
Deterministic initialisation and minibatch order.
Attributes
----------
theta : numpy.ndarray
All learned parameters (``n_params`` values).
Examples
--------
>>> import numpy as np
>>> from quantum_mind.belief import COBRA
>>> rng = np.random.default_rng(0)
>>> x = rng.normal(size=(60, 3, 2)); y = (x[:, :, 0].sum(1) > 0).astype(int)
>>> model = COBRA(2, 2, steps=20, restarts=1).fit(x, y)
>>> model.predict_proba(x).shape
(60, 2)
"""
def __init__(self, n_hyp=3, n_features=3, *, beta=2.0, steps=100, batch=160, lr=0.1, l2=1e-4, restarts=4, seed=0):
if n_hyp < 2 or n_features < 1:
raise ValueError('need n_hyp >= 2 and n_features >= 1')
self.d, self.m = int(n_hyp), int(n_features)
self.beta, self.steps, self.batch = float(beta), int(steps), int(batch)
self.lr, self.l2, self.seed = float(lr), float(l2), int(seed)
self.restarts, self.trial_div = int(restarts), 4
rng = np.random.default_rng(seed)
self._n_gen = self.d * self.d
self._shapes = [('gens', (self.m + 1, self._n_gen)), ('W', (self.d, self.m)), ('b', (self.d,)),
('prior', (self.d,)), ('gamma', (1,))]
self.theta = np.concatenate([0.15 * rng.standard_normal((self.m + 1) * self._n_gen),
0.10 * rng.standard_normal(self.d * self.m),
np.zeros(self.d), np.zeros(self.d), np.zeros(1)])
# ------------------------------------------------------------------ parameters
@property
def n_params(self):
"""Number of learned parameters."""
return int(self.theta.size)
def _unpack(self, theta):
out, pos = {}, 0
for name, shape in self._shapes:
size = int(np.prod(shape))
out[name] = theta[pos:pos + size].reshape(shape)
pos += size
return out
@property
def dephasing(self):
"""The learned dephasing strength :math:`g` in (0, 1)."""
return float(1.0 / (1.0 + np.exp(-self._unpack(self.theta)['gamma'][0])))
# ------------------------------------------------------------------ filtering
def _prior_rho(self, theta, n):
p = self._unpack(theta)
prior = np.exp(p['prior'] - p['prior'].max())
prior /= prior.sum()
return np.broadcast_to(np.diag(prior).astype(complex), (n, self.d, self.d)).copy()
def _step(self, theta, rho, xt, prev):
"""One frame: context-rotated generalised measurement, then dephasing."""
p = self._unpack(theta)
gamma = 1.0 / (1.0 + np.exp(-p['gamma'][0]))
herm = _hermitian(p['gens'], self.d)
logit = xt @ p['W'].T + p['b']
w = np.exp(0.5 * (logit - logit.max(axis=1, keepdims=True)))
gen = herm[0] + np.einsum('nk,kij->nij', np.tanh(self.beta * prev), herm[1:])
u = _expm_skew(gen)
meas = (u * w[:, None, :]) @ u.conj().transpose(0, 2, 1)
rho = meas @ rho @ meas.conj().transpose(0, 2, 1)
rho /= np.einsum('nii->n', rho).real[:, None, None]
diag = np.einsum('nii->ni', rho)
return (1.0 - gamma) * rho + gamma * (diag[:, :, None] * np.eye(self.d))
def _forward(self, theta, x, *, trace=False):
n, t_len, _ = x.shape
rho = self._prior_rho(theta, n)
probs = np.empty((n, t_len, self.d))
coh = np.empty((n, t_len))
rhos = np.empty((n, t_len, self.d, self.d), dtype=complex) if trace else None
for t in range(t_len):
prev = x[:, t - 1] if t else np.zeros_like(x[:, t])
rho = self._step(theta, rho, x[:, t], prev)
diag = np.einsum('nii->ni', rho)
if trace:
rhos[:, t] = rho
probs[:, t] = np.clip(diag.real, 1e-9, None)
probs[:, t] /= probs[:, t].sum(axis=1, keepdims=True)
coh[:, t] = np.abs(rho).sum(axis=(1, 2)) - np.abs(diag).sum(axis=1)
return (probs, coh, rhos) if trace else probs
def _loss(self, theta, x, y):
probs = self._forward(theta, x)
wt = np.arange(1, x.shape[1] + 1, dtype=float)
wt /= wt.sum()
nll = -np.log(probs[np.arange(len(y)), :, y])
return float((nll * wt).sum(axis=1).mean() + self.l2 * float(theta @ theta))
# ------------------------------------------------------------------ training
def _warm_start(self, x, y):
"""Start the evidence step at the classical Gaussian log-likelihood (a Bayes filter at ``H = 0``)."""
flat, lab = x.reshape(-1, self.m), np.repeat(y, x.shape[1])
var = flat.var(axis=0) + 1e-3
mu = np.stack([flat[lab == k].mean(0) if np.any(lab == k) else flat.mean(0) for k in range(self.d)])
counts = np.maximum(np.bincount(y, minlength=self.d), 1)
p = self._unpack(self.theta)
p['W'][...] = mu / var
p['b'][...] = -0.5 * (mu ** 2 / var).sum(axis=1)
p['prior'][...] = np.log(counts / counts.sum())
def _train(self, theta, x, y, tr, val, steps, rng):
theta, m1, m2 = theta.copy(), np.zeros_like(theta), np.zeros_like(theta)
best_theta, best_val, eps = theta.copy(), np.inf, 1e-4
for step in range(1, steps + 1):
idx = rng.choice(tr, size=min(self.batch, len(tr)), replace=False)
xb, yb = x[idx], y[idx]
grad = np.empty_like(theta)
for k in range(theta.size):
old = theta[k]
theta[k] = old + eps
hi = self._loss(theta, xb, yb)
theta[k] = old - eps
lo = self._loss(theta, xb, yb)
theta[k] = old
grad[k] = (hi - lo) / (2 * eps)
m1 = 0.9 * m1 + 0.1 * grad
m2 = 0.999 * m2 + 0.001 * grad ** 2
lr = self.lr * (0.5 * (1 + np.cos(np.pi * step / steps)) * 0.9 + 0.1)
theta -= lr * (m1 / (1 - 0.9 ** step)) / (np.sqrt(m2 / (1 - 0.999 ** step)) + 1e-8)
if len(val) and (step % 10 == 0 or step == steps):
v = self._loss(theta, x[val], y[val])
if v < best_val:
best_val, best_theta = v, theta.copy()
if not len(val):
best_theta, best_val = theta, self._loss(theta, x[tr], y[tr])
return best_theta, best_val
[docs]
def fit(self, x, y):
"""Train on sequences ``x`` of shape (N, T, n_features) and integer labels ``y`` of shape (N,).
Returns
-------
COBRA
``self``, so calls can be chained.
Raises
------
ValueError
If the shapes do not match the model or a label is outside ``range(n_hyp)``.
"""
x, y = np.asarray(x, float), np.asarray(y, int)
if x.ndim != 3 or x.shape[2] != self.m or len(x) != len(y):
raise ValueError('x must be (N, T, n_features) with len(y) == N')
if y.min() < 0 or y.max() >= self.d:
raise ValueError('labels must be integers in range(n_hyp)')
rng = np.random.default_rng(self.seed + 1)
n_val = max(len(x) // 6, 1) if len(x) >= 30 else 0
order = rng.permutation(len(x))
val, tr = order[:n_val], order[n_val:]
n_gen = (self.m + 1) * self._n_gen
starts = []
for r in range(max(self.restarts, 1)):
r_rng = np.random.default_rng(self.seed * 101 + r)
self._warm_start(x, y)
th = self.theta.copy()
if self.restarts > 1 or r > 0:
th[:n_gen] = 0.7 * r_rng.standard_normal(n_gen)
self._unpack(th)['W'][...] += 0.8 * r_rng.standard_normal((self.d, self.m))
starts.append(th)
if len(starts) == 1:
self.theta, _ = self._train(starts[0], x, y, tr, val, self.steps, rng)
return self
trial = max(self.steps // self.trial_div, 10)
best = min((self._train(th, x, y, tr, val, trial, rng) for th in starts), key=lambda s: s[1])[0]
self.theta, _ = self._train(best, x, y, tr, val, self.steps, rng)
return self
# ------------------------------------------------------------------ inference
[docs]
def predict_proba(self, x):
"""Hypothesis probabilities after the last frame, shape (N, d)."""
return self._forward(self.theta, np.asarray(x, float))[:, -1]
[docs]
def trace(self, x):
"""Probabilities, coherence and density matrix after every frame (a :class:`BeliefTrace`)."""
probs, coh, rhos = self._forward(self.theta, np.asarray(x, float), trace=True)
return BeliefTrace(probs=probs, coherence=coh, rho=rhos)
[docs]
def coherence(self, x):
"""l1 coherence of the final belief (0 for a classical belief), shape (N,)."""
return self.trace(x).coherence[:, -1]
[docs]
def start(self):
"""Begin an :class:`OnlineBelief` for streaming use."""
return OnlineBelief(self, self._prior_rho(self.theta, 1)[0], np.zeros((1, self.m)))
[docs]
class GaussianBayesFilter:
"""Commutative naive-Bayes filter with independent Gaussian features per hypothesis.
The classical reference for :class:`COBRA`: frames are conditionally independent given the
hypothesis, so reordering them cannot change the belief.
Examples
--------
>>> import numpy as np
>>> from quantum_mind.belief import GaussianBayesFilter
>>> rng = np.random.default_rng(0)
>>> y = rng.integers(0, 2, 80); x = rng.normal(size=(80, 3, 2)) + y[:, None, None]
>>> GaussianBayesFilter(2).fit(x, y).predict_proba(x).shape
(80, 2)
"""
def __init__(self, n_hyp=3):
self.d = int(n_hyp)
self.n_params = 0
def fit(self, x, y):
x, y = np.asarray(x, float), np.asarray(y, int)
flat, lab = x.reshape(-1, x.shape[2]), np.repeat(y, x.shape[1])
self.mu = np.stack([flat[lab == k].mean(0) if np.any(lab == k) else flat.mean(0) for k in range(self.d)])
self.var = np.stack([flat[lab == k].var(0) + 1e-3 if np.sum(lab == k) > 1 else flat.var(0) + 1e-3
for k in range(self.d)])
self.logp = np.log(np.maximum(np.bincount(y, minlength=self.d), 1) / len(y))
self.n_params = int(self.mu.size + self.var.size + self.d)
return self
def predict_proba(self, x):
x = np.asarray(x, float)
ll = -0.5 * (((x[:, :, None, :] - self.mu) ** 2) / self.var + np.log(self.var))
z = self.logp + ll.sum(axis=(1, 3))
z = np.exp(z - z.max(axis=1, keepdims=True))
return z / z.sum(axis=1, keepdims=True)