Source code for quantum_mind.core.fit

"""Fitting, model comparison and model-recovery studies, common to every family.

* :func:`fit` fits one model class by multi-start optimisation (maximum likelihood or least squares).
* :func:`compare` fits several classes and ranks them by BIC or AIC.
* :func:`recovery` checks that the comparison selects the model that generated simulated data.
* :func:`fit_individuals` and :func:`compare_individuals` fit every person separately, which tests
  whether one set of parameters describes everyone.

Examples
--------
>>> from quantum_mind import fit, compare
>>> from quantum_mind.families.order_effects import QuantumOrderModel4D, BayesOrderModel
>>> data = QuantumOrderModel4D().sample(None, 500)          # doctest: +SKIP
>>> ranking = compare([QuantumOrderModel4D, BayesOrderModel], data)   # doctest: +SKIP
>>> ranking[0].summary()['model']                                     # doctest: +SKIP
'QuantumOrderModel4D'
"""
from __future__ import annotations

from dataclasses import dataclass, field
import numpy as np
from scipy.optimize import minimize

__all__ = ['FitResult', 'fit', 'compare', 'recovery', 'kl', 'tvd', 'IndividualFits', 'fit_individuals', 'compare_individuals']


[docs] @dataclass class FitResult: """Result of :func:`fit`. Attributes ---------- model : Model Fitted model instance. loss : float Negative log-likelihood (multinomial models) or sum of squared errors (``'sse'`` models). loglik : float Log-likelihood. For ``'sse'`` models this is the Gaussian log-likelihood with the error variance profiled out, so that AIC and BIC are comparable across models. k : int Number of fitted parameters. n : int Number of observations. options : dict Structural options of the selected fit. """ model: object loss: float # negative log-likelihood (multinomial) or sum of squared errors (sse) loglik: float # log-likelihood (for sse: Gaussian with the error variance profiled out) k: int # number of fitted parameters n: int # number of observations options: dict = field(default_factory=dict) @property def aic(self): """float: Akaike information criterion :math:`2k - 2\\log L`.""" return 2 * self.k - 2 * self.loglik @property def bic(self): """float: Bayesian information criterion :math:`k\\log n - 2\\log L`.""" return self.k * np.log(max(self.n, 1)) - 2 * self.loglik
[docs] def summary(self): """Summary of the fit. Returns ------- dict Keys ``model``, ``params``, ``options``, ``loglik``, ``k``, ``n``, ``aic`` and ``bic``. """ return {'model': type(self.model).__name__, 'params': self.model.params, 'options': self.options, 'loglik': self.loglik, 'k': self.k, 'n': self.n, 'aic': self.aic, 'bic': self.bic}
def _n_obs(model_cls, data): """Number of observations: data points for 'sse' models, total counts for multinomial ones.""" if model_cls.LOSS == 'sse': return int(sum(np.size(v) for v in data.values())) return int(sum(np.sum(v) for v in data.values()))
[docs] def fit(model_cls, data, design=None, restarts=8, rng=None, structures=None, method='Nelder-Mead', x0=None, **options): """Fit a model class to data by multi-start optimisation. Parameters ---------- model_cls : type Subclass of :class:`~quantum_mind.core.Model`. data : dict ``{condition: counts}`` (multinomial models) or ``{condition: values}`` (``'sse'`` models). design : iterable, optional Conditions passed to ``predict``; most families accept ``None`` for their default design. restarts : int, optional Number of random starting points per structure. rng : numpy.random.Generator, optional Random number generator for the starting points (default: seed 0, so fits are reproducible). structures : list of dict, optional Discrete structural choices (for example projector ranks). Every structure is fitted and the best one kept. method : str, optional :func:`scipy.optimize.minimize` method. x0 : array_like, optional Unconstrained starting point for the first restart of every structure (the other restarts start at random). :func:`~quantum_mind.core.uncertainty.bootstrap` uses it to start each refit at the original estimate. **options Fixed options passed to the model constructor. Returns ------- FitResult Best fit over all structures and restarts. """ rng = np.random.default_rng(0) if rng is None else rng free = model_cls.free_params() best = None for st in (structures or [{}]): opts = dict(options, **st) def objective(x): m = model_cls.from_vector(x, **opts) return m.sse(data, design) if model_cls.LOSS == 'sse' else -m.loglik(data, design) for i in range(restarts): start = np.array([p.random_free(rng) for p in free]) if i == 0 and x0 is not None: # caller's starting point (random draw kept for reproducibility) start = np.asarray(x0, float) if len(start) == 0: # nothing to fit: evaluate once r_fun, r_x = objective(start), start else: r = minimize(objective, start, method=method, options=dict(maxiter=6000, xatol=1e-8, fatol=1e-10)) r_fun, r_x = r.fun, r.x if best is None or r_fun < best[0]: best = (r_fun, r_x, opts) loss, x, opts = best model = model_cls.from_vector(x, **opts) n = _n_obs(model_cls, data) if model_cls.LOSS == 'sse': # Gaussian log-likelihood with the maximum-likelihood variance sse / n plugged in ll = -0.5 * n * (np.log(2 * np.pi * max(loss, 1e-300) / n) + 1) else: ll = -loss return FitResult(model=model, loss=float(loss), loglik=float(ll), k=len(free), n=n, options=opts)
[docs] def compare(candidates, data, design=None, restarts=8, rng=None, criterion='bic'): """Fit several model classes to the same data and rank them. Parameters ---------- candidates : list Model classes, or ``(class, fit_kwargs)`` tuples. data : dict Data passed to :func:`fit`. design : iterable, optional Conditions passed to ``predict``. restarts : int, optional Restarts per fit. rng : numpy.random.Generator, optional Random number generator. criterion : {'bic', 'aic'}, optional Ranking criterion (lower is better). Returns ------- list of FitResult Sorted from best to worst. """ res = [] for c in candidates: cls, kw = (c if isinstance(c, tuple) else (c, {})) res.append(fit(cls, data, design, restarts=restarts, rng=rng, **kw)) return sorted(res, key=lambda r: getattr(r, criterion))
[docs] def recovery(generators, candidates, design, n, reps=20, rng=None, criterion='bic', restarts=8): """Model-recovery study. Data are simulated from each generating model, every candidate is fitted, and the criterion's choice is counted. A good design selects the generating model most of the time. Parameters ---------- generators : dict ``{name: model instance}`` used to simulate data. candidates : list Model classes (or ``(class, fit_kwargs)``) to compare. design : iterable Conditions to simulate. n : int Observations per condition. reps : int, optional Simulated data sets per generator. rng : numpy.random.Generator, optional Random number generator. criterion : {'bic', 'aic'}, optional Selection criterion. restarts : int, optional Restarts per fit. Returns ------- dict ``{generator: {candidate class name: times selected}}``. """ rng = np.random.default_rng(0) if rng is None else rng names = [c[0].__name__ if isinstance(c, tuple) else c.__name__ for c in candidates] out = {g: {nm: 0 for nm in names} for g in generators} for g, gen in generators.items(): for _ in range(reps): data = gen.sample(design, n, rng) best = compare(candidates, data, design, restarts=restarts, rng=rng, criterion=criterion)[0] out[g][type(best.model).__name__] += 1 return out
[docs] def kl(p, q): """Kullback-Leibler divergence. Parameters ---------- p, q : array_like Probability vectors (clipped at 1e-12). Returns ------- float :math:`\\mathrm{KL}(p\\,\\Vert\\,q) = \\sum_i p_i \\log(p_i / q_i)` in nats. """ p = np.clip(np.asarray(p, float), 1e-12, 1) q = np.clip(np.asarray(q, float), 1e-12, 1) return float(np.sum(p * np.log(p / q)))
[docs] def tvd(p, q): """Total variation distance. Parameters ---------- p, q : array_like Probability vectors. Returns ------- float :math:`\\tfrac12 \\sum_i |p_i - q_i|`, between 0 and 1. """ return 0.5 * float(np.abs(np.asarray(p, float) - np.asarray(q, float)).sum())
[docs] @dataclass class IndividualFits: """Per-person fits of one model class, returned by :func:`fit_individuals`. Every person has their own parameters, so group criteria are sums over people. Attributes ---------- model_name : str Name of the fitted class. results : dict ``{person: FitResult}``. """ model_name: str results: dict @property def loglik(self): """float: sum of the per-person log-likelihoods.""" return float(sum(r.loglik for r in self.results.values())) @property def k(self): """int: total number of fitted parameters (per person times number of people).""" return int(sum(r.k for r in self.results.values())) @property def n(self): """int: total number of observations.""" return int(sum(r.n for r in self.results.values())) @property def bic(self): """float: group BIC, the sum of the per-person BICs.""" return float(sum(r.bic for r in self.results.values())) @property def aic(self): """float: group AIC, the sum of the per-person AICs.""" return float(sum(r.aic for r in self.results.values()))
[docs] def parameters(self): """Fitted parameter values across people. Returns ------- dict ``{parameter name: numpy.ndarray over people}``. """ names = list(next(iter(self.results.values())).model.params) return {p: np.array([r.model.params[p] for r in self.results.values()]) for p in names}
[docs] def fit_individuals(model_cls, data_by_person, design=None, restarts=4, rng=None, **fit_kwargs): """Fit a model class separately to each person's data. Aggregate fitting assumes that everyone shares one set of parameters; per-person fits test that assumption. Parameters ---------- model_cls : type Model class. data_by_person : dict ``{person: data}``. design : iterable, optional Conditions passed to ``predict``. restarts : int, optional Restarts per fit. rng : numpy.random.Generator, optional Random number generator. **fit_kwargs Passed to :func:`fit`. Returns ------- IndividualFits Raises ------ ValueError If ``data_by_person`` is empty. """ if not data_by_person: raise ValueError('data_by_person is empty') rng = np.random.default_rng(0) if rng is None else rng return IndividualFits(model_cls.__name__, {p: fit(model_cls, d, design, restarts=restarts, rng=rng, **fit_kwargs) for p, d in data_by_person.items()})
[docs] def compare_individuals(candidates, data_by_person, design=None, restarts=4, rng=None, criterion='bic'): """Fit every candidate to every person and compare them. Parameters ---------- candidates : list Model classes, or ``(class, fit_kwargs)`` tuples. data_by_person : dict ``{person: data}``. design : iterable, optional Conditions passed to ``predict``. restarts : int, optional Restarts per fit. rng : numpy.random.Generator, optional Random number generator. criterion : {'bic', 'aic'}, optional Comparison criterion (lower is better). Returns ------- dict ``'group'``: ``[(model name, group criterion)]`` sorted from best; ``'best_counts'``: ``{model name: number of people it fits best}``; ``'fits'``: ``{model name: IndividualFits}``. """ fits = {} for c in candidates: cls, kw = (c if isinstance(c, tuple) else (c, {})) fits[cls.__name__] = fit_individuals(cls, data_by_person, design, restarts, rng, **kw) counts = {m: 0 for m in fits} for p in data_by_person: best = min(fits, key=lambda m: getattr(fits[m].results[p], criterion)) counts[best] += 1 group = sorted(((m, getattr(f, criterion)) for m, f in fits.items()), key=lambda t: t[1]) return {'group': group, 'best_counts': counts, 'fits': fits}