Source code for quantum_mind.families.qlbn.models

r"""Quantum-like Bayesian networks (Moreira and Wichert, 2014, 2016).

A discrete Bayesian network gives every full configuration :math:`x` a classical probability
:math:`P(x)`. The quantum-like network gives it an amplitude :math:`\sqrt{P(x)}\,e^{i\theta(x)}`.
When unobserved (hidden) variables are marginalised, amplitudes rather than probabilities are summed:

.. math:: P_q(\text{query} = v \mid e) = \alpha \,\Bigl|\sum_h \sqrt{P(v, e, h)}\,e^{i\theta_h}\Bigr|^2,

with :math:`\alpha` normalising over the values :math:`v`. Interference terms are proportional to the
cosine of the phase differences. With two hidden configurations a phase difference of :math:`\pi/2`
removes them and gives classical inference; with more configurations no choice of phases removes all
of them in general, so the classical network is kept as a separate baseline.

* :class:`BayesNet`: discrete network (variables, parents, conditional tables).
* :func:`classical_marginal`: exact classical inference by enumeration.
* :func:`quantum_like_marginal`: quantum-like inference with one phase per hidden configuration.
* :class:`QLBNModel`: fittable model whose parameters are the phases.
* :class:`ClassicalBNModel`: baseline with no free parameters.
"""
from __future__ import annotations

import itertools
import numpy as np
from ...core import Model, Param


[docs] class BayesNet: """Discrete Bayesian network. Parameters ---------- variables : dict ``{name: [values]}``. parents : dict ``{name: [parent names]}`` (variables without parents may be omitted). cpt : dict Conditional tables, per variable either a function ``f(value, parent_values_dict) -> probability`` or nested dicts ``{tuple(parent values): {value: probability}}``. Attributes ---------- order : list of str Variables in topological order (parents first). """ def __init__(self, variables, parents, cpt): self.variables, self.parents, self.cpt = variables, parents, cpt self.order = self._topological() def _topological(self): """Order the variables so that every parent comes before its children.""" done, order = set(), [] while len(order) < len(self.variables): for v in self.variables: if v not in done and all(p in done for p in self.parents.get(v, [])): order.append(v) done.add(v) return order
[docs] def prob(self, var, value, assignment): """Conditional probability of one variable. Parameters ---------- var : str Variable name. value : object Value of ``var``. assignment : dict Values of (at least) the parents of ``var``. Returns ------- float :math:`P(\\text{var} = \\text{value} \\mid \\text{parents})`. """ t = self.cpt[var] pv = tuple(assignment[p] for p in self.parents.get(var, [])) return t(value, dict(zip(self.parents.get(var, []), pv))) if callable(t) else t[pv][value]
[docs] def joint(self, assignment): """Joint probability of a full assignment. Parameters ---------- assignment : dict A value for every variable. Returns ------- float Product of the conditional tables. """ p = 1.0 for v in self.order: p *= self.prob(v, assignment[v], assignment) return p
[docs] def configurations(self, names): """Iterate over all assignments of some variables. Parameters ---------- names : sequence of str Variables to enumerate. Yields ------ dict ``{name: value}``, in :func:`itertools.product` order. """ for vals in itertools.product(*(self.variables[n] for n in names)): yield dict(zip(names, vals))
def _hidden(net, query, evidence): """Variables that are neither the query nor observed.""" return [v for v in net.order if v != query and v not in evidence]
[docs] def classical_marginal(net, query, evidence=None): """Classical posterior of one variable by enumeration. Parameters ---------- net : BayesNet The network. query : str Query variable. evidence : dict, optional Observed values ``{variable: value}``. Returns ------- dict ``{value: probability}`` of the query. """ evidence = evidence or {} hid = _hidden(net, query, evidence) out = {} for val in net.variables[query]: out[val] = sum(net.joint({**evidence, **h, query: val}) for h in net.configurations(hid)) z = sum(out.values()) return {k: v / z for k, v in out.items()}
[docs] def quantum_like_marginal(net, query, evidence=None, phases=None): """Quantum-like posterior of one variable (amplitudes summed over hidden configurations). Parameters ---------- net : BayesNet The network. query : str Query variable. evidence : dict, optional Observed values. phases : array_like, optional One phase per hidden configuration, in :func:`itertools.product` order of the hidden variables' values. Default all zero. Returns ------- dict ``{value: probability}`` of the query. """ evidence = evidence or {} hid = _hidden(net, query, evidence) configs = list(net.configurations(hid)) phases = np.zeros(len(configs)) if phases is None else np.asarray(phases, float) out = {} for val in net.variables[query]: # sum of amplitudes, not of probabilities: this is where interference enters amp = sum(np.sqrt(net.joint({**evidence, **h, query: val})) * np.exp(1j * th) for h, th in zip(configs, phases)) out[val] = abs(amp) ** 2 z = sum(out.values()) return {k: v / z for k, v in out.items()}
[docs] def n_hidden_configurations(net, query, evidence=None): """Number of hidden configurations, i.e. of interference phases. Parameters ---------- net : BayesNet The network. query : str Query variable. evidence : dict, optional Observed values. Returns ------- int """ return int(np.prod([len(net.variables[v]) for v in _hidden(net, query, evidence or {})]))
[docs] class QLBNModel(Model): """Fittable quantum-like Bayesian network. The network is fixed; the parameters are the phases of the hidden configurations. Parameters ---------- theta1, ..., theta7 : float Phases :math:`\\theta_1, \\dots, \\theta_{H-1}` (:math:`\\theta_0 = 0`); at most 8 hidden configurations. Use :func:`for_network` to fit only the phases in use. net : BayesNet The network (option). query : str Query variable (option). conditions : dict ``{condition name: evidence dict}`` (option). The design is a list of condition names. """ PARAMS = [Param('theta%d' % i, 'angle', 0.0) for i in range(1, 8)] name = 'Quantum-like Bayesian network'
[docs] def predict(self, design=None): """Distribution of the query variable in each condition.""" net, q, conds = self.options['net'], self.options['query'], self.options['conditions'] out = {} for name in (design or list(conds)): ev = conds[name] H = n_hidden_configurations(net, q, ev) ph = np.r_[0.0, [getattr(self, 'theta%d' % i) for i in range(1, H)]] if H > 1 else np.zeros(1) m = quantum_like_marginal(net, q, ev, ph) out[name] = np.array([m[v] for v in net.variables[q]]) return out
[docs] class ClassicalBNModel(Model): """Classical baseline for :class:`QLBNModel`: the same network without interference. Takes the same options (``net``, ``query``, ``conditions``) and has no free parameters. """ PARAMS = [] name = 'Classical Bayesian network'
[docs] def predict(self, design=None): """Distribution of the query variable in each condition.""" net, q, conds = self.options['net'], self.options['query'], self.options['conditions'] return {n: np.array([classical_marginal(net, q, conds[n])[v] for v in net.variables[q]]) for n in (design or list(conds))}
[docs] def for_network(net, query, conditions): """:class:`QLBNModel` subclass with exactly the phases a network needs. Information criteria then count only the phases in use (the largest number of hidden configurations over the conditions, minus one). Parameters ---------- net : BayesNet The network. query : str Query variable. conditions : dict ``{name: evidence}``. Returns ------- type Subclass of :class:`QLBNModel`. Raises ------ ValueError If more than 8 hidden configurations are needed. """ H = max(n_hidden_configurations(net, query, ev) for ev in conditions.values()) if H > 8: raise ValueError('at most 8 hidden configurations are supported') return type('QLBNModel', (QLBNModel,), {'PARAMS': QLBNModel.PARAMS[:max(H - 1, 0)]})