A context-aware belief filter (COBRA)#

A person reaches toward a robot. The same hand movement can mean “give me that” or “I am taking my own tool”, and the difference is often a look a moment earlier. A filter that treats each sensor frame as independent evidence cannot use that. COBRA keeps the robot’s belief as a density matrix and lets the previous frame rotate the basis in which the next frame is measured. This tutorial builds a small intent task, trains COBRA and a classical Bayes filter on it, and checks what COBRA learned. The data are simulated, built to contain a context effect, so the result shows what the model can do, not what people do.

import numpy as np
import matplotlib.pyplot as plt
from quantum_mind.viz import use_mpl_style
from quantum_mind.belief import COBRA, GaussianBayesFilter, min_cost_action
theme = use_mpl_style('dark')
INTENTS = ['handover', 'retrieve', 'withdraw']

1. A task where context matters#

Three hidden intents. A frame holds a gaze cue a, a hand cue b and a force cue f. The gaze cue leans positive for a hand-over and negative for a retrieval. The hand cue carries the context effect: its mean is +sign(previous a) for a hand-over and -sign(previous a) for a retrieval, so on its own b says almost nothing, and its meaning flips with the previous look.

MU_A, MU_F, KAPPA = np.array([0.7, -0.7, 0.0]), np.array([0.0, 0.0, 0.9]), np.array([1.0, -1.0, 0.0])

def simulate(n, rng, T=4):
    z = rng.integers(0, 3, n)
    a = MU_A[z][:, None] + rng.standard_normal((n, T))
    f = MU_F[z][:, None] + rng.standard_normal((n, T))
    prev = np.concatenate([np.zeros((n, 1)), a[:, :-1]], axis=1)
    b = KAPPA[z][:, None] * np.sign(prev) + rng.standard_normal((n, T))
    return np.stack([a, b, f], axis=2), z

rng = np.random.default_rng(11)
x_train, z_train = simulate(800, rng)
x_test, z_test = simulate(3000, rng)
print('training sequences', x_train.shape, '| marginal mean of b by intent:',
      [round(float(x_test[z_test == k][:, :, 1].mean()), 2) for k in range(3)])
training sequences (800, 4, 3) | marginal mean of b by intent: [0.4, 0.39, -0.03]

2. A classical baseline and COBRA#

GaussianBayesFilter treats the frames as conditionally independent, so reordering them cannot change its belief. COBRA is trained on the same data. Training is slow (finite-difference gradients with several restarts) and takes about a minute, and it does not always find the context mechanism, so the cell retrains with a new seed if the check in section 3 shows it was missed.

def shift(model, sign):
    """Log-odds shift (handover vs retrieve) from one hand cue b = +1.5 after a gaze cue of +-1.5 (see section 3)."""
    before = np.zeros((1, 1, 3)); before[0, 0, 0] = sign * 1.5
    after = np.zeros((1, 2, 3)); after[0, 0, 0] = sign * 1.5; after[0, 1, 1] = 1.5
    lo = lambda p: float(np.log(p[0, 0] / p[0, 1]))
    return lo(model.predict_proba(after)) - lo(model.predict_proba(before))

bayes = GaussianBayesFilter(3).fit(x_train, z_train)
for seed in range(6):                      # the context mechanism is found on some starts and not on others: retrain if missed
    cobra = COBRA(3, 3, steps=80, batch=100, restarts=6, seed=seed).fit(x_train, z_train)
    if shift(cobra, +1) > 1.0 and shift(cobra, -1) < -1.0:
        break
print('training seed that found the context mechanism: %d (of up to 6 tried)' % seed)
logloss = {}
for name, m in (('Bayes filter', bayes), ('COBRA', cobra)):
    p = m.predict_proba(x_test)
    logloss[name] = -np.log(p[np.arange(len(z_test)), z_test]).mean()
    print('%-13s accuracy %.3f   log loss %.3f' % (name, (p.argmax(1) == z_test).mean(), logloss[name]))
training seed that found the context mechanism: 0 (of up to 6 tried)
Bayes filter  accuracy 0.840   log loss 0.416
COBRA         accuracy 0.868   log loss 0.362

3. Did it learn to use context?#

Feed the same hand cue b = +1.5 after a gaze cue of +1.5 or -1.5, and measure only how much the hand cue moves the log-odds of “handover” against “retrieve” (the gaze cue is informative by itself, so it must be subtracted out). The Bayes filter must add the same amount either way. A model that uses context adds an amount whose sign depends on the previous look.

shifts = {name: (shift(m, +1), shift(m, -1)) for name, m in (('Bayes filter', bayes), ('COBRA', cobra))}
fig, ax = plt.subplots(figsize=(5.6, 3.0))
for i, (name, (up, down)) in enumerate(shifts.items()):
    ax.bar(i - 0.18, up, 0.34, color=theme['palette'][0], label='previous a = +1.5' if i == 0 else None)
    ax.bar(i + 0.18, down, 0.34, color=theme['palette'][1], label='previous a = -1.5' if i == 0 else None)
ax.axhline(0, color=theme['text'], lw=1)
ax.set_xticks(range(len(shifts)), list(shifts)); ax.set_ylabel('log-odds shift from b = +1.5')
ax.set_title('Same hand cue, opposite previous look'); ax.legend(frameon=False)
plt.show()
print({k: tuple(round(v, 2) for v in s) for k, s in shifts.items()})
Figure: Same hand cue, opposite previous look
{'Bayes filter': (0.03, 0.03), 'COBRA': (4.43, -4.08)}

4. Online, and what the density matrix holds#

On a robot the belief is updated one frame at a time. start() returns an online belief; its l1 coherence says how much phase information the density matrix still carries.

episode = x_test[np.flatnonzero(z_test == 0)[0]]
belief = cobra.start()
history, coherence = [], []
for frame in episode:
    history.append(belief.update(frame)); coherence.append(belief.coherence)
history = np.array(history)
fig, ax = plt.subplots(figsize=(5.6, 2.8))
for k in range(3):
    ax.plot(range(1, 5), history[:, k], 'o-', color=theme['palette'][k], label=INTENTS[k])
ax.set_xlabel('frame'); ax.set_ylabel('P(intent)'); ax.set_title('Online belief for a hand-over episode')
ax.legend(frameon=False)
plt.show()
print('l1 coherence per frame:', np.round(coherence, 3), '| learned dephasing g = %.2f' % cobra.dephasing)
assert np.allclose(history[-1], cobra.predict_proba(episode[None])[0])      # online equals batch
Figure: Online belief for a hand-over episode
l1 coherence per frame: [0.155 0.015 0.004 0.009] | learned dephasing g = 0.89

5. From belief to action#

The robot can hand over, wait or ask. Handing over to someone who is not receiving costs a lot. Both filters use the same cost matrix and the same rule.

cost = np.array([[0, 6, 10], [1.5, 0, 0], [0.8, 0.8, 0.8]])      # hand over, wait, ask
costs = {}
for name, m in (('Bayes filter', bayes), ('COBRA', cobra)):
    act = min_cost_action(m.predict_proba(x_test), cost)
    costs[name] = cost[act, z_test]
    print('%-13s expected cost %.3f   ask rate %.2f' % (name, costs[name].mean(), (act == 2).mean()))
diff = costs['COBRA'] - costs['Bayes filter']
print('mean cost difference %.3f, standard error %.3f' % (diff.mean(), diff.std() / np.sqrt(len(diff))))
Bayes filter  expected cost 0.220   ask rate 0.14
COBRA         expected cost 0.212   ask rate 0.16
mean cost difference -0.007, standard error 0.013

Better beliefs do not automatically mean cheaper actions: here the cost difference is within its standard error, because the cost rule only changes the action when a belief crosses a threshold.

The statements below are checked by this cell every time the documentation is built:

b_up, b_down = shifts['Bayes filter']
c_up, c_down = shifts['COBRA']
assert abs(b_up - b_down) < 0.2                      # the commutative filter adds the same evidence either way
assert c_up > 1.0 and c_down < -1.0                  # COBRA's evidence flips sign with the previous look
assert logloss['COBRA'] < logloss['Bayes filter'] - 0.02       # and its beliefs are better calibrated to the data
assert abs(diff.mean()) < 3 * diff.std() / np.sqrt(len(diff))    # but the cost gap here is within noise
print('checked')
checked

Summary. On a task built to contain a context effect, a filter whose measurement basis depends on the previous frame uses the hand cue the way the data require, and a commutative filter cannot, which shows up as a lower log loss. The gain is modest and specific: the decision cost is not clearly lower; with little training data COBRA is worse than the Bayes filter; training sometimes misses the context mechanism (it needs several restarts); the coherence of the learned belief is small; and on a control task with no context effect it is slightly worse than the classical filter. The full benchmark, with ablations and paired tests, is in benchmarks/context_intent_benchmark.py.

References: Busemeyer & Bruza (2012), Quantum Models of Cognition and Decision, Cambridge University Press; Kraus (1983), States, Effects and Operations, Springer.