Source code for quantum_mind.applications.personalisation

r"""Personalised human models: start from the population, adapt to each person.

Fitting one model to everyone's answers assumes that people are alike; fitting each person separately
needs thousands of answers (see example 11). Partial pooling sits in between: the population's spread
of parameters becomes a prior, and each person's parameters are the maximum a posteriori (MAP) estimate

.. math:: \hat x_i = \arg\min_x\; -\log L_i(x) + \tfrac12 (x - \mu)^T \Sigma^{-1} (x - \mu),

in the unconstrained parameter space of the model (see :meth:`quantum_mind.core.Param.to_free`). With few
answers the estimate stays near the population; with many it follows the person. A robot can therefore
personalise its human model from the first few interactions without over-fitting.

* :class:`PopulationPrior`: mean and covariance of the free parameters, estimated from per-person fits
  (empirical Bayes) or set by hand.
* :func:`fit_map`: MAP fit of one model class to one person's data.
* :class:`PersonalisedHumanModel`: keeps each person's answer counts and their personalised model,
  either as a MAP point estimate or, with ``method='online'``, as a full posterior updated after every
  answer (:class:`~quantum_mind.core.online.OnlinePersonModel` started from the population prior), so
  the robot also knows how sure it is about this person.

Examples
--------
>>> import numpy as np
>>> from quantum_mind.families.order_effects import BayesOrderModel
>>> from quantum_mind.applications.personalisation import PopulationPrior, fit_map
>>> prior = PopulationPrior(BayesOrderModel, mean=[0.0, 0.0, 0.0], cov=np.eye(3) * 0.5)
>>> few = {'AB': np.array([3, 0, 0, 1]), 'BA': np.array([2, 1, 0, 1])}
>>> r = fit_map(BayesOrderModel, few, prior)
>>> 0.5 < r.model.pA < 0.9                     # pulled toward the population mean 0.5
True
"""
from __future__ import annotations

import numpy as np
from scipy.optimize import minimize
from ..core.fit import FitResult, fit, _n_obs
from ..core.online import OnlinePersonModel

__all__ = ['PopulationPrior', 'fit_map', 'PersonalisedHumanModel']


[docs] class PopulationPrior: """Gaussian prior on a model's free (unconstrained) parameters. Parameters ---------- model_cls : type Model class whose ``free_params()`` the prior describes. mean : array_like Prior mean in the free space. cov : array_like Prior covariance (positive definite). """ def __init__(self, model_cls, mean, cov): self.model_cls = model_cls self.mean = np.asarray(mean, float) self.cov = np.atleast_2d(np.asarray(cov, float)) k = len(model_cls.free_params()) if self.mean.shape != (k,) or self.cov.shape != (k, k): raise ValueError('mean must have %d entries and cov shape (%d, %d)' % (k, k, k)) self.prec = np.linalg.inv(self.cov)
[docs] @classmethod def from_fits(cls, fits, shrink=1e-2): """Empirical-Bayes prior from per-person fits. Parameters ---------- fits : sequence of FitResult Maximum-likelihood fits of the same model class to different people (for example ``fit_individuals(...).results.values()``). shrink : float, optional Added to the covariance diagonal so it stays invertible with few people. Returns ------- PopulationPrior """ fits = list(fits) if not fits: raise ValueError('need at least one fit') model_cls = type(fits[0].model) X = np.array([f.model.to_vector() for f in fits]) cov = np.cov(X.T) if len(X) > 1 else np.zeros((X.shape[1], X.shape[1])) return cls(model_cls, X.mean(0), np.atleast_2d(cov) + shrink * np.eye(X.shape[1]))
[docs] def online(self, n_particles=500, design=None, rng=None, **options): """A per-person posterior that starts from this prior. Parameters ---------- n_particles : int, optional Number of parameter samples. design : iterable, optional Design passed to ``predict``. rng : numpy.random.Generator, optional Random number generator. **options Structural options of the model. Returns ------- OnlinePersonModel """ return OnlinePersonModel(self.model_cls, prior=self.mean, prior_cov=self.cov, n_particles=n_particles, design=design, rng=rng, **options)
[docs] def neg_log_prior(self, x): """Negative log prior density, up to a constant. Parameters ---------- x : array_like Free parameter vector. Returns ------- float """ d = np.asarray(x, float) - self.mean return 0.5 * float(d @ self.prec @ d)
[docs] def fit_map(model_cls, data, prior, design=None, restarts=4, rng=None, **options): """Maximum a posteriori fit of one person's data under a population prior. Parameters ---------- model_cls : type Model class (multinomial loss). data : dict The person's answer counts. prior : PopulationPrior Prior on the free parameters. design : iterable, optional Conditions passed to ``predict``. restarts : int, optional Random restarts (the first start is the prior mean). rng : numpy.random.Generator, optional **options Structural options of the model. Returns ------- FitResult ``loss`` is the negative log posterior; ``loglik`` the log-likelihood at the MAP estimate. """ if model_cls.LOSS != 'multinomial': raise ValueError('fit_map supports multinomial models') rng = np.random.default_rng(0) if rng is None else rng def objective(x): m = model_cls.from_vector(x, **options) return -m.loglik(data, design) + prior.neg_log_prior(x) starts = [prior.mean] + [prior.mean + rng.multivariate_normal(np.zeros(len(prior.mean)), prior.cov) for _ in range(restarts - 1)] best = min((minimize(objective, x0, method='Nelder-Mead', options=dict(maxiter=4000, xatol=1e-7, fatol=1e-9)) for x0 in starts), key=lambda r: r.fun) model = model_cls.from_vector(best.x, **options) return FitResult(model=model, loss=float(best.fun), loglik=float(model.loglik(data, design)), k=len(model_cls.free_params()), n=_n_obs(model_cls, data), options=options)
[docs] class PersonalisedHumanModel: """Per-person human models with partial pooling. Parameters ---------- model_cls : type Model class (for example :class:`~quantum_mind.families.order_effects.QuantumOrderModel4D`). prior : PopulationPrior Population prior; build it with :meth:`PopulationPrior.from_fits` from earlier people. design : iterable, optional Conditions. outcomes : int, optional Number of outcome cells per condition (4 for two yes/no questions). method : {'map', 'online'}, optional ``'map'`` refits a MAP estimate after every answer; ``'online'`` keeps a particle posterior per person (faster per answer, and gives :meth:`credible_interval`). n_particles : int, optional Particles per person for ``method='online'``. Examples -------- >>> svc = PersonalisedHumanModel(BayesOrderModel, prior) # doctest: +SKIP >>> svc.add('alice', 'AB', [1, 0]) # doctest: +SKIP >>> svc.predict('alice')['AB'] # doctest: +SKIP """ CELLS = {(1, 1): 0, (1, 0): 1, (0, 1): 2, (0, 0): 3} def __init__(self, model_cls, prior, design=None, outcomes=4, method='map', n_particles=300): if method not in ('map', 'online'): raise ValueError("method must be 'map' or 'online'") self.model_cls, self.prior, self.design, self.outcomes = model_cls, prior, design, outcomes self.method, self.n_particles = method, n_particles self.counts = {} self.models = {} self.posteriors = {}
[docs] def add(self, person, condition, answers): """Record one answer pair and refit that person. Parameters ---------- person : hashable Person identifier. condition : hashable Condition (for example the question order ``'AB'``). answers : sequence of int or int The answers (two yes/no answers for order designs) or an outcome index. Returns ------- Model The person's updated model. """ c = self.counts.setdefault(person, {}) cell = self.CELLS[tuple(int(a) for a in answers)] if np.ndim(answers) else int(answers) c.setdefault(condition, np.zeros(self.outcomes, int))[cell] += 1 if self.method == 'online': post = self.posteriors.get(person) if post is None: post = self.posteriors[person] = self.prior.online(self.n_particles, self.design, np.random.default_rng(len(self.posteriors))) post.update(condition, cell) return post self.models[person] = fit_map(self.model_cls, c, self.prior, self.design, restarts=2).model return self.models[person]
[docs] def model(self, person): """The person's model (the population mean model for a new person). Parameters ---------- person : hashable Returns ------- Model """ if person in self.models: return self.models[person] return self.model_cls.from_vector(self.prior.mean)
[docs] def predict(self, person, design=None): """Predicted answer distribution for one person. Parameters ---------- person : hashable design : iterable, optional Returns ------- dict """ if person in self.posteriors: return self.posteriors[person].predict() return self.model(person).predict(design if design is not None else self.design)
[docs] def credible_interval(self, person, name, level=0.9): """Posterior interval of one of a person's parameters (``method='online'`` only). Parameters ---------- person : hashable name : str Parameter name. level : float, optional Returns ------- tuple of float Raises ------ ValueError If the model keeps MAP estimates, or the person has not answered yet. """ if person not in self.posteriors: raise ValueError('no posterior for %r (use method=\'online\' and add answers first)' % (person,)) return self.posteriors[person].credible_interval(name, level)
[docs] def population_prior(model_cls, data_by_person, design=None, restarts=4, rng=None): """Convenience: fit every person by maximum likelihood and build the empirical-Bayes prior. Parameters ---------- model_cls : type data_by_person : dict ``{person: data}``. design : iterable, optional restarts : int, optional rng : numpy.random.Generator, optional Returns ------- PopulationPrior """ fits = [fit(model_cls, d, design, restarts=restarts, rng=rng) for d in data_by_person.values()] return PopulationPrior.from_fits(fits)
__all__.append('population_prior')