Source code for quantum_mind.applications.fusion

r"""Multimodal fusion for robots: from detector scores, speech and gaze to one belief over intents.

A request such as "bring me that cup" is resolved from several sources: an object detector's scores,
the speech recogniser's n-best list, where the person looks and where they point. This module turns
each source into a *likelihood over the same hypotheses* and offers three ways to combine them:

* :func:`bayes_fusion`: independent cues multiply (order-free);
* :func:`dempster_shafer_fusion`: Dempster's rule with discounted sources, which keeps an explicit
  "don't know" mass when sources conflict;
* :class:`~quantum_mind.applications.intent.QuantumIntentResolver`: quantum-like fusion in which incompatible
  cues are applied as rotated Kraus operators, so cue order matters (it reduces to Bayes when every
  incompatibility angle is zero); :func:`fit_incompatibility` estimates the angles from logged
  choices, and :func:`compare_fusion` compares the three rules on held-out data.

Adapters: :func:`detector_likelihood`, :func:`asr_likelihood`, :func:`direction_likelihood` (gaze or
pointing).

Examples
--------
>>> import numpy as np
>>> from quantum_mind.applications.fusion import detector_likelihood, direction_likelihood, bayes_fusion
>>> objects = ['red cup', 'blue cup', 'bowl']
>>> vision = detector_likelihood({'red cup': 0.6, 'blue cup': 0.5, 'bowl': 0.2}, objects)
>>> gaze = direction_likelihood([1, 0, 0], {'red cup': [0.9, 0.1, 0], 'blue cup': [0, 1, 0],
...                                         'bowl': [-1, 0, 0]}, objects, kappa=4)
>>> post = bayes_fusion([vision, gaze])
>>> objects[int(np.argmax(post))]
'red cup'
"""
from __future__ import annotations

import numpy as np
from scipy.optimize import minimize

__all__ = ['detector_likelihood', 'asr_likelihood', 'direction_likelihood', 'bayes_fusion',
           'dempster_shafer_fusion', 'fit_incompatibility', 'compare_fusion']


def _norm(v, floor):
    """Floor and normalise a non-negative vector."""
    v = np.maximum(np.asarray(v, float), 0) + floor
    return v / v.sum()


[docs] def detector_likelihood(scores, hypotheses, temperature=1.0, floor=1e-3): """Likelihood over hypotheses from an object detector's scores. Parameters ---------- scores : dict or array_like ``{hypothesis: score}`` (missing hypotheses score 0) or scores in the order of ``hypotheses``. Scores are confidences in [0, 1] or logits. hypotheses : sequence Hypotheses (object or intent names). temperature : float, optional Softens (> 1) or sharpens (< 1) the scores before normalising. floor : float, optional Added to every entry so that no hypothesis is ruled out by one cue. Returns ------- numpy.ndarray Normalised likelihood, one entry per hypothesis. """ s = np.array([scores.get(h, 0.0) for h in hypotheses], float) if isinstance(scores, dict) else np.asarray(scores, float) if np.all((s >= 0) & (s <= 1)): # confidences: power-sharpen or soften return _norm(s ** (1.0 / temperature), floor) z = s / temperature # logits: softmax return _norm(np.exp(z - z.max()), floor)
[docs] def asr_likelihood(nbest, keywords, hypotheses, floor=0.02): """Likelihood over hypotheses from a speech recogniser's n-best list. Parameters ---------- nbest : sequence of tuple ``[(transcript, confidence), ...]``. keywords : dict ``{hypothesis: [words that refer to it]}``. hypotheses : sequence floor : float, optional Returns ------- numpy.ndarray Each transcript's confidence is shared among the hypotheses it mentions (or spread over all hypotheses when it mentions none). """ L = np.zeros(len(hypotheses)) for text, conf in nbest: words = set(text.lower().replace(',', ' ').split()) hit = np.array([any(k.lower() in words or k.lower() in text.lower() for k in keywords.get(h, [])) for h in hypotheses]) L += conf * (hit / hit.sum() if hit.any() else np.full(len(hypotheses), 1 / len(hypotheses))) return _norm(L, floor)
[docs] def direction_likelihood(direction, targets, hypotheses, kappa=5.0, floor=1e-3): """Likelihood over hypotheses from a gaze or pointing direction (von Mises-Fisher). Parameters ---------- direction : array_like Direction vector (2D or 3D) from the person's head or hand. targets : dict ``{hypothesis: direction to that object}`` from the same origin. hypotheses : sequence kappa : float, optional Concentration: larger values trust the direction more. floor : float, optional Returns ------- numpy.ndarray :math:`\\propto \\exp(\\kappa \\cos \\angle(d, t_h))`. """ d = np.asarray(direction, float) d = d / np.linalg.norm(d) cos = [] for h in hypotheses: t = np.asarray(targets[h], float) cos.append(float(d @ t / np.linalg.norm(t))) return _norm(np.exp(kappa * (np.array(cos) - 1)), floor)
[docs] def bayes_fusion(likelihoods, prior=None): """Posterior of independent cues: prior times the product of the likelihoods. Parameters ---------- likelihoods : sequence of array_like One likelihood vector per cue. prior : array_like, optional Prior over hypotheses (uniform by default). Returns ------- numpy.ndarray """ L = np.asarray(likelihoods, float) post = (np.full(L.shape[1], 1 / L.shape[1]) if prior is None else np.asarray(prior, float)) * np.prod(L, axis=0) return post / post.sum()
[docs] def dempster_shafer_fusion(likelihoods, reliabilities=None): """Dempster's rule for singleton masses with discounting. Each cue becomes a mass function on the single hypotheses, discounted by its reliability r: a fraction 1 - r of its mass goes to "any hypothesis" (ignorance). Conflict between cues is renormalised away by Dempster's rule. Parameters ---------- likelihoods : sequence of array_like One normalised likelihood per cue. reliabilities : sequence of float, optional Reliability of each cue in [0, 1] (default 0.9). Returns ------- belief : numpy.ndarray Pignistic probability over hypotheses (ignorance shared equally). ignorance : float Remaining mass on "any hypothesis". """ L = np.asarray(likelihoods, float) K = L.shape[1] r = np.full(len(L), 0.9) if reliabilities is None else np.asarray(reliabilities, float) m = np.zeros(K) omega = 1.0 # mass on the whole frame (start: total ignorance) for li, ri in zip(L, r): mi = ri * li / li.sum() wi = 1 - ri # combine (m, omega) with (mi, wi) on singletons plus the frame new = m * mi + m * wi + omega * mi new_omega = omega * wi z = new.sum() + new_omega # 1 - conflict m, omega = new / z, new_omega / z return m + omega / K, float(omega)
[docs] def fit_incompatibility(resolver_cls, intents, cue_likelihoods, trials, prior=None, bounds=(0.0, np.pi / 2)): """Fit the incompatibility angle of each cue of a quantum-like resolver from logged choices. Parameters ---------- resolver_cls : type :class:`~quantum_mind.applications.intent.QuantumIntentResolver`. intents : sequence of str Intent names. cue_likelihoods : dict ``{cue name: likelihood over intents}``. trials : sequence of tuple ``[(cue order, chosen intent), ...]``: the order in which cues arrived and what the person actually wanted (or chose). prior : array_like, optional Prior over intents. bounds : tuple, optional Range of the angles. Returns ------- resolver : QuantumIntentResolver Resolver with the fitted angles. angles : dict ``{cue name: fitted angle}``. """ names = list(cue_likelihoods) idx = {h: i for i, h in enumerate(intents)} def build(theta): r = resolver_cls(intents, prior) for n, t in zip(names, theta): r.add_cue(n, cue_likelihoods[n], t) return r def nll(theta): r = build(theta) return -sum(np.log(max(r.posterior(order)[idx[c]], 1e-12)) for order, c in trials) best = min((minimize(nll, np.full(len(names), x0), method='L-BFGS-B', bounds=[bounds] * len(names)) for x0 in (0.05, 0.4, 0.9)), key=lambda r: r.fun) return build(best.x), dict(zip(names, (float(v) for v in best.x)))
[docs] def compare_fusion(intents, cue_likelihoods, train, test, prior=None): """Compare Bayesian, Dempster-Shafer and fitted quantum-like fusion on held-out trials. Parameters ---------- intents : sequence of str cue_likelihoods : dict ``{cue name: likelihood over intents}``. train, test : sequence of tuple ``[(cue order, chosen intent), ...]``; the quantum-like angles are fitted on ``train``. prior : array_like, optional Returns ------- dict ``{rule: {'log_loss': ..., 'accuracy': ...}}`` on ``test`` (lower log loss is better), plus ``'angles'``. """ from .intent import QuantumIntentResolver idx = {h: i for i, h in enumerate(intents)} q, angles = fit_incompatibility(QuantumIntentResolver, intents, cue_likelihoods, train, prior) rules = { 'bayes': lambda order: bayes_fusion([cue_likelihoods[c] for c in order], prior), 'dempster_shafer': lambda order: dempster_shafer_fusion([cue_likelihoods[c] for c in order])[0], 'quantum_like': q.posterior, } out = {} for name, rule in rules.items(): P = [rule(order) for order, _ in test] y = [idx[c] for _, c in test] out[name] = {'log_loss': float(-np.mean([np.log(max(p[i], 1e-12)) for p, i in zip(P, y)])), 'accuracy': float(np.mean([int(np.argmax(p)) == i for p, i in zip(P, y)]))} out['angles'] = angles return out