Source code for quantum_mind.applications.intent

r"""Intent resolution from ambiguous commands and context cues (robots, assistants, interfaces).

A user's intent is one of K options ("fetch the red cup", "fetch the blue cup", ...). Each context cue
(a word, a gesture, a gaze direction, the time of day) carries a likelihood over intents. Classical
Bayesian updating multiplies the likelihoods, so the order in which cues arrive does not matter.

:class:`QuantumIntentResolver` represents belief as amplitudes :math:`\psi = \sqrt{\text{prior}}` and
applies each cue c as a Kraus operator

.. math:: K_c = R(\theta_c)\,\mathrm{diag}\bigl(\sqrt{L_c}\bigr)\,R(\theta_c)^T,

where :math:`R(\theta_c)` rotates the intent basis by the cue's incompatibility angle
:math:`\theta_c`, in the plane of the cue's two most likely intents. With every
:math:`\theta_c = 0` the posterior is exactly Bayes' rule; :math:`\theta_c \neq 0` makes cues
incompatible, so their order matters, as in question-order effects. The angles can be fitted to
observed choices.

Examples
--------
>>> from quantum_mind.applications.intent import QuantumIntentResolver
>>> r = QuantumIntentResolver(['red cup', 'blue cup', 'bowl'])
>>> _ = r.add_cue('points left', [0.70, 0.20, 0.10], theta=0.5)
>>> _ = r.add_cue('looks right', [0.25, 0.60, 0.15], theta=0.5)
>>> r.posterior(['points left', 'looks right']).round(3)
array([0.482, 0.498, 0.02 ])
>>> round(r.order_effect('points left', 'looks right'), 3)
0.079
"""
from __future__ import annotations

import numpy as np

__all__ = ['QuantumIntentResolver', 'BayesIntentResolver']


[docs] class BayesIntentResolver: """Classical baseline: posterior proportional to the prior times the cue likelihoods. Parameters ---------- intents : sequence of str Names of the K possible intents. prior : array_like, optional Prior over intents (normalised); uniform by default. Attributes ---------- cues : dict Registered cues, by name. """ def __init__(self, intents, prior=None): self.intents = list(intents) K = len(self.intents) self.prior = np.full(K, 1 / K) if prior is None else _prob(prior, K) self.cues = {}
[docs] def add_cue(self, name, likelihood, theta=0.0): """Register a cue. Parameters ---------- name : str Cue name. likelihood : array_like Non-negative likelihood of the cue under each intent. theta : float, optional Ignored here (classical cues commute); used by :class:`QuantumIntentResolver`. Returns ------- BayesIntentResolver ``self``, so calls can be chained. """ self.cues[name] = _prob(likelihood, len(self.intents), normalise=False) return self
[docs] def posterior(self, cues): """Intent probabilities after a sequence of cues. Parameters ---------- cues : sequence of str Names of registered cues. Returns ------- numpy.ndarray Posterior over intents (independent of the order of ``cues``). """ p = self.prior.copy() for c in cues: p = p * self.cues[c] p = p / p.sum() return p
[docs] def order_effect(self, a, b): """Size of the order effect between two cues. Parameters ---------- a, b : str Cue names. Returns ------- float Total variation distance between the posteriors for orders (a, b) and (b, a); always 0 for the Bayesian resolver. """ return 0.5 * float(np.abs(self.posterior([a, b]) - self.posterior([b, a])).sum())
[docs] class QuantumIntentResolver(BayesIntentResolver): """Quantum-like intent resolver; it reduces to :class:`BayesIntentResolver` when every angle is 0. Parameters ---------- intents : sequence of str Names of the K possible intents. prior : array_like, optional Prior over intents; the initial amplitudes are its square roots. """
[docs] def add_cue(self, name, likelihood, theta=0.0): """Register a cue as a Kraus operator. Parameters ---------- name : str Cue name. likelihood : array_like Non-negative likelihood of the cue under each intent. theta : float, optional Incompatibility angle in radians (0: the cue commutes with every other cue). Returns ------- QuantumIntentResolver ``self``. """ L = _prob(likelihood, len(self.intents), normalise=False) i, j = np.argsort(L)[::-1][:2] # rotate in the plane of the two leading intents R = np.eye(len(L)) c, s = np.cos(theta), np.sin(theta) R[[i, i, j, j], [i, j, i, j]] = [c, -s, s, c] self.cues[name] = R @ np.diag(np.sqrt(L)) @ R.T return self
[docs] def posterior(self, cues): """Intent probabilities :math:`|\\psi|^2` after applying the cues in order. Parameters ---------- cues : sequence of str Names of registered cues, in the order they arrived. Returns ------- numpy.ndarray """ psi = np.sqrt(self.prior) for c in cues: psi = self.cues[c] @ psi psi = psi / np.linalg.norm(psi) return psi ** 2
[docs] def resolve(self, cues): """Most likely intent after the cues. Parameters ---------- cues : sequence of str Names of registered cues, in order. Returns ------- intent : str Most likely intent. probability : float Its posterior probability. entropy : float Entropy of the posterior in bits (high values suggest asking for clarification). """ p = self.posterior(cues) k = int(np.argmax(p)) H = float(-np.sum(p[p > 0] * np.log2(p[p > 0]))) return self.intents[k], float(p[k]), H
def _prob(v, K, normalise=True): """Validate K non-negative weights and optionally normalise them.""" v = np.asarray(v, float) if v.shape != (K,) or np.any(v < 0) or v.sum() <= 0: raise ValueError('expected %d non-negative weights' % K) return v / v.sum() if normalise else v