Source code for quantum_mind.families.order_effects.models

r"""Question-order effects: two yes/no questions A and B asked in either order.

**Design.** Conditions ``'AB'`` (A asked first) and ``'BA'`` (B asked first). The outcomes of each
condition are the four answer pairs ``[yy, yn, ny, nn]``, where the first letter is the answer to the
question asked first.

**Models.**

* :class:`QuantumOrderModel`: three-dimensional real quantum-like model (Lüders rule), with the rank
  (1 or 2) of each question's "yes" subspace as a structural option; three parameters; satisfies the
  QQ equality.
* :class:`QuantumOrderModel4D`: four-dimensional model with an incompatibility angle; ``phi = 0`` is
  exactly the Bayesian model, so it nests it (four parameters). Recommended default.
* :class:`BayesOrderModel`: one order-free joint distribution (no order effect).
* :class:`AnchoringOrderModel`: the second answer is pulled toward (``w > 0``) or away from
  (``w < 0``) the first, with a weight per question; violates the QQ equality when the weights differ.
* :class:`SaturatedOrderModel`: a free distribution per order (6 parameters; reference only).
* :class:`ProjectiveQuestionModel`: any state and any projectors in any dimension (not fitted).

**QQ equality.** Every projective model satisfies

.. math:: p_{AB}(yn) + p_{AB}(ny) = p_{BA}(yn) + p_{BA}(ny),

a parameter-free prediction tested by :func:`qq_test` (Wang and Busemeyer, 2013).
"""
from __future__ import annotations

import itertools
import numpy as np
from ...core import Model, Param, projector, sequence_probabilities, normalize

#: The two question orders of the design.
ORDERS = ('AB', 'BA')
#: Every combination of "yes"-subspace ranks for :class:`QuantumOrderModel` (pass to ``fit(structures=...)``).
RANK_STRUCTURES = [{'ranks': r} for r in itertools.product((1, 2), repeat=2)]
DESIGN = ORDERS


def _cells(probs):
    """Answer-sequence probabilities as the vector [yy, yn, ny, nn]."""
    return np.array([probs[(1, 1)], probs[(1, 0)], probs[(0, 1)], probs[(0, 0)]])


[docs] class ProjectiveQuestionModel: """General projective model of two questions. Parameters ---------- psi : array_like Belief state (real or complex); normalised on construction. projectors : dict ``{'A': P_A, 'B': P_B}``: projectors of the "yes" answers. """ def __init__(self, psi, projectors): self.psi = normalize(psi) self.projectors = projectors
[docs] def answer_probs(self, order): """Probabilities of all answer sequences. Parameters ---------- order : sequence of str Questions in the order they are asked, for example ``('A', 'B')``. Returns ------- dict ``{(a1, a2): probability}`` from the Lüders rule. """ return sequence_probabilities(self.psi, self.projectors, order)
[docs] def predict(self, design=None): """Answer distribution for each question order. Parameters ---------- design : sequence of str, optional Orders, default ``('AB', 'BA')``. Returns ------- dict ``{order: [yy, yn, ny, nn]}``. """ return {o: _cells(self.answer_probs(tuple(o))) for o in (design or ORDERS)}
[docs] class QuantumOrderModel(Model): """Three-dimensional real quantum-like model of two questions. The belief state is :math:`\\psi = e_1`; the questions are defined by the unit vectors :math:`u_A = (\\cos a, \\sin a, 0)` and :math:`u_B = (\\cos b, \\sin b \\cos g, \\sin b \\sin g)`. Parameters ---------- a, b, g : float Angles (fitted). ranks : tuple of int, optional ``(rA, rB)``. Rank 1: "yes" is the ray of :math:`u_q`; rank 2: the plane orthogonal to :math:`u_q`. Default ``(1, 1)``; fit all four with ``structures=RANK_STRUCTURES``. """ PARAMS = [Param('a', 'angle', 0.8), Param('b', 'angle', 1.2), Param('g', 'angle', 0.3)] name = 'Quantum-like'
[docs] def vectors(self): """Unit vectors that define the two questions. Returns ------- dict ``{'A': u_A, 'B': u_B}``. """ a, b, g = self.a, self.b, self.g return {'A': np.array([np.cos(a), np.sin(a), 0.0]), 'B': np.array([np.cos(b), np.sin(b) * np.cos(g), np.sin(b) * np.sin(g)])}
[docs] def projectors(self): """Projectors of the "yes" answers. Returns ------- dict ``{'A': P_A, 'B': P_B}``, 3 x 3 matrices of rank 1 or 2. """ ranks = dict(zip('AB', self.options.get('ranks', (1, 1)))) out = {} for q, u in self.vectors().items(): P = projector(u) out[q] = P if ranks[q] == 1 else np.eye(3) - P return out
[docs] def as_projective(self): """The same model as a :class:`ProjectiveQuestionModel`. Returns ------- ProjectiveQuestionModel """ return ProjectiveQuestionModel(np.array([1.0, 0.0, 0.0]), self.projectors())
[docs] def predict(self, design=None): """Answer distribution ``[yy, yn, ny, nn]`` for each order (default ``'AB'``, ``'BA'``).""" return self.as_projective().predict(design)
[docs] class QuantumOrderModel4D(Model): """Four-dimensional real model in which the questions can be compatible or incompatible. The basis :math:`e_1, \\dots, e_4` is (yes, yes), (yes, no), (no, yes), (no, no) of a classical 2 x 2 table, and :math:`P_A = \\mathrm{span}(e_1, e_2)`. :math:`P_B = U\\,\\mathrm{span}(e_1, e_3)\\,U^T`, where :math:`U` rotates by the incompatibility angle :math:`\\varphi` in the planes :math:`(e_1, e_4)` and :math:`(e_2, e_3)`. With :math:`\\varphi = 0` the projectors commute and the model is exactly the order-free Bayesian model (:math:`\\psi_k^2` is the joint table), so it nests :class:`BayesOrderModel`; :math:`\\varphi \\neq 0` produces order effects, and the QQ equality still holds. Parameters ---------- t1, t2, t3 : float Hyperspherical angles of the state :math:`\\psi`. phi : float Incompatibility angle. """ PARAMS = [Param('t1', 'angle', 0.8), Param('t2', 'angle', 0.8), Param('t3', 'angle', 0.8), Param('phi', 'angle', 0.2)] name = 'Quantum-like (4D, nests Bayes)'
[docs] def projectors(self): """Projectors of the "yes" answers. Returns ------- dict ``{'A': P_A, 'B': P_B}``, 4 x 4 matrices of rank 2. """ from ...core import angles_to_unit # noqa: F401 c, s_ = np.cos(self.phi), np.sin(self.phi) # U rotates by phi in the (e1, e4) and (e2, e3) planes U = np.eye(4) U[0, 0] = U[3, 3] = c U[0, 3] = -s_ U[3, 0] = s_ U[1, 1] = U[2, 2] = c U[1, 2] = -s_ U[2, 1] = s_ E = np.eye(4) PA = projector([E[0], E[1]]) PB = U @ projector([E[0], E[2]]) @ U.T return {'A': PA, 'B': PB}
[docs] def as_projective(self): """The same model as a :class:`ProjectiveQuestionModel`. Returns ------- ProjectiveQuestionModel """ from ...core import angles_to_unit return ProjectiveQuestionModel(angles_to_unit([self.t1, self.t2, self.t3]), self.projectors())
[docs] def predict(self, design=None): """Answer distribution ``[yy, yn, ny, nn]`` for each order (default ``'AB'``, ``'BA'``).""" return self.as_projective().predict(design)
[docs] class BayesOrderModel(Model): """Classical baseline: one order-free joint distribution. Parameters ---------- pA, pB : float Marginal "yes" probabilities. rho : float Correlation in (-1, 1), scaled between the bounds that the marginals allow. """ PARAMS = [Param('pA', 'prob', 0.5), Param('pB', 'prob', 0.5), Param('rho', 'bounded', 0.0, -1, 1)] name = 'Bayesian (order-free)'
[docs] def joint(self): """Joint distribution with A first. Returns ------- numpy.ndarray ``[yy, yn, ny, nn]``. """ pA, pB = self.pA, self.pB # Fréchet bounds on P(A and B), relative to independence lo, hi = max(0.0, pA + pB - 1) - pA * pB, min(pA, pB) - pA * pB yy = pA * pB + self.rho * (hi if self.rho > 0 else -lo) return np.array([yy, pA - yy, pB - yy, 1 - pA - pB + yy]) # A first: yy, yn, ny, nn
[docs] def predict(self, design=None): """Answer distribution for each order; the BA order swaps the ``yn`` and ``ny`` cells.""" j = self.joint() out = {} for o in (design or ORDERS): out[o] = j if o == 'AB' else j[[0, 2, 1, 3]] return out
[docs] class AnchoringOrderModel(Model): """Classical baseline: the second answer is anchored on the first. The first answer is given at its first-position rate. For ``w >= 0``, P(second yes | first yes) = p2 + w (1 - p2) and P(second yes | first no) = p2 (1 - w); the mirror image applies for ``w < 0``. Parameters ---------- pA, pB : float "Yes" rates of each question when asked first. wA, wB : float Anchoring weight when A (respectively B) is the second question, in (-0.99, 0.99). tied : bool, optional If True, ``wB`` is ignored and ``wA`` is used for both questions. """ PARAMS = [Param('pA', 'prob', 0.5), Param('pB', 'prob', 0.5), Param('wA', 'bounded', 0.2, -0.99, 0.99), Param('wB', 'bounded', 0.2, -0.99, 0.99)] name = 'Anchoring'
[docs] def predict(self, design=None): """Answer distribution ``[yy, yn, ny, nn]`` for each order.""" p = {'A': self.pA, 'B': self.pB} w = {'A': self.wA, 'B': self.wB if not self.options.get('tied') else self.wA} out = {} for o in (design or ORDERS): p1, p2, wq = p[o[0]], p[o[1]], w[o[1]] if wq >= 0: # pulled toward the first answer yy, ny = p2 + wq * (1 - p2), p2 * (1 - wq) else: # pushed away from it yy, ny = p2 * (1 + wq), p2 - wq * (1 - p2) out[o] = np.array([p1 * yy, p1 * (1 - yy), (1 - p1) * ny, (1 - p1) * (1 - ny)]) return out
[docs] class SaturatedOrderModel(Model): """Reference model with a free answer distribution per order. Six parameters (softmax logits, three per order); no model can fit the data better. Parameters ---------- x0, ..., x5 : float Logits of ``yy``, ``yn``, ``ny`` (relative to ``nn``) for AB, then for BA. """ PARAMS = [Param('x%d' % i, 'real', 0.0) for i in range(6)] name = 'Saturated'
[docs] def predict(self, design=None): """Answer distribution for each order (softmax of the logits).""" def sm(v): e = np.exp(np.r_[v, 0.0] - max(np.max(v), 0)) return e / e.sum() v = [getattr(self, 'x%d' % i) for i in range(6)] return {'AB': sm(v[:3]), 'BA': sm(v[3:])}
[docs] def rates(pred): """First- and second-position "yes" rates. Parameters ---------- pred : dict ``{'AB': cells, 'BA': cells}`` from ``predict`` or from data proportions. Returns ------- numpy.ndarray ``[A first, B first, A second, B second]``. """ ab, ba = pred['AB'], pred['BA'] return np.array([ab[0] + ab[1], ba[0] + ba[1], ba[0] + ba[2], ab[0] + ab[2]])
[docs] def qq_statistic(cells_ab, cells_ba): """QQ statistic. Parameters ---------- cells_ab, cells_ba : array_like ``[yy, yn, ny, nn]`` probabilities for each order. Returns ------- float :math:`[p_{AB}(yn) + p_{AB}(ny)] - [p_{BA}(yn) + p_{BA}(ny)]`; zero for every projective model. """ return float((cells_ab[1] + cells_ab[2]) - (cells_ba[1] + cells_ba[2]))
[docs] def qq_test(counts): """Two-sided z test of the QQ equality. Parameters ---------- counts : dict ``{'AB': [yy, yn, ny, nn], 'BA': [...]}`` answer counts. Returns ------- q : float Observed QQ statistic. z : float z score. p_value : float Two-sided p-value; a small value rejects every projective (quantum-like) model. Examples -------- >>> from quantum_mind.families.order_effects import qq_test >>> q, z, p = qq_test({'AB': [40, 10, 10, 40], 'BA': [40, 10, 10, 40]}) >>> q, p (0.0, 1.0) """ from scipy.stats import norm ab, ba = np.asarray(counts['AB'], float), np.asarray(counts['BA'], float) p1, p2 = (ab[1] + ab[2]) / ab.sum(), (ba[1] + ba[2]) / ba.sum() se = np.sqrt(p1 * (1 - p1) / ab.sum() + p2 * (1 - p2) / ba.sum()) z = (p1 - p2) / se if se > 0 else 0.0 return float(p1 - p2), float(z), float(2 * norm.sf(abs(z)))