Source code for psp.clobe.gdistance

"""g-distance: accommodation and excess flexibility of a model against human data."""

from __future__ import annotations

import math
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any

import torch

from ._utils import TOLERANCE, _as_patterns, _identify, _upper_cells

__all__ = ["gdistance", "GDistanceResult"]


[docs] @dataclass(frozen=True) class GDistanceResult: """The result of :func:`gdistance`. Attributes ---------- gdistance ``sqrt(weight * (1 - alpha)^2 + (1 - weight) * beta^2)``; without ``beta`` when it is NaN. alpha Accommodation: the summed frequency of the human patterns the model produces. beta Excess flexibility: the unique model patterns that no human shows, as a proportion of the patterns in the universal set that no human shows. NaN when humans show every pattern of the universal set. accommodation Indices (0-based) of the human patterns the model produces, ``(m,)`` int64. """ gdistance: float alpha: float beta: float accommodation: torch.Tensor def __float__(self) -> float: return self.gdistance
[docs] def gdistance( human: torch.Tensor | Any, model: torch.Tensor | Any, universal: float, weight: float, frequencies: torch.Tensor | Sequence[float], ) -> GDistanceResult: """g-distance between the heterogeneity of humans and of a model (Dome and Wills, 2025). Two matrices are the same pattern if their strict upper triangles are equal; any numeric coding of the relations works, not only -1, 0 and 1. Parameters ---------- human The patterns people show, ``(n, k, k)`` or one ``(k, k)`` matrix, each pattern once (see :func:`patterns`). model The patterns the model produces, ``(m, k, k)`` or ``(k, k)``, e.g. :attr:`psp.PSPResult.ordinal_patterns`. universal The number of possible patterns, e.g. ``semiorders(k)`` or ``brutes(...).n``. weight Weight of accommodation, between 0 and 1; ``1 - weight`` is the weight of excess flexibility. frequencies The proportion of people with each human pattern, ``(n,)``. Warns ----- UserWarning If ``frequencies`` do not sum to 1 or ``human`` repeats a pattern, in which case ``alpha`` is not a proportion; or if ``universal`` equals the number of human patterns, in which case ``beta`` is NaN and left out of the distance. Examples -------- >>> from psp.clobe import patterns, semiorders >>> human = patterns(torch.tensor([[0.9, 0.5, 0.1], [0.8, 0.5, 0.2], [0.1, 0.5, 0.9]]), ... (0.1, -0.1)) >>> model = patterns(torch.tensor([[0.9, 0.5, 0.1], [0.5, 0.5, 0.5]]), (0.1, -0.1)) >>> out = gdistance(human.inequality_matrices, model.inequality_matrices, ... universal=semiorders(3), weight=0.5, frequencies=human.frequencies) >>> round(out.gdistance, 4), round(out.alpha, 4), round(out.beta, 4), out.accommodation (0.2393, 0.6667, 0.0588, tensor([0])) """ h = _as_patterns(human, "human") m = _as_patterns(model, "model") if h.shape[1:] != m.shape[1:]: raise ValueError("'human' and 'model' must contain square matrices of the same dimensions") h_cells = _upper_cells(h, "human") m_cells = _upper_cells(m, "model") given = ( frequencies if torch.is_tensor(frequencies) else torch.as_tensor(frequencies, dtype=torch.float64) ) eps = torch.finfo(given.dtype).eps if given.dtype.is_floating_point else 0.0 freq = given.to(torch.float64).reshape(-1).to(h.device) if freq.numel() != h.shape[0]: raise ValueError( f"'frequencies' has {freq.numel()} values, but 'human' has {h.shape[0]} patterns; " "give one frequency per pattern" ) if not torch.isfinite(freq).all() or (freq < 0).any(): raise ValueError("'frequencies' must be non-negative numbers without missing values") h_ids, m_ids = _identify(h_cells, m_cells) h_unique = torch.unique(h_ids) observed = h_unique.numel() if universal < observed: raise ValueError( "'universal' cannot be smaller than the number of unique patterns in 'human'" ) if not 0 <= weight <= 1: raise ValueError("'weight' must be between 0 and 1") # alpha sums the frequency of every accommodated pattern, so it is a proportion # only if the frequencies sum to 1 and each pattern appears once total = float(freq.sum()) # float32 frequencies of n patterns can be off by n eps of float32 if abs(total - 1) > max(TOLERANCE, freq.numel() * eps): warnings.warn( f"'frequencies' sum to {total:.10g}, not 1, so alpha is not a proportion; " "give the proportion of people with each pattern", stacklevel=2, ) duplicated = h.shape[0] - observed if duplicated > 0: warnings.warn( f"'human' has {duplicated} duplicated pattern{'' if duplicated == 1 else 's'}. " "Beta counts each pattern once, but alpha adds the frequency of every copy; " "use patterns() to keep each pattern once with its total frequency", stacklevel=2, ) accommodated = torch.isin(h_ids, m_ids) alpha = float((freq * accommodated).sum()) if universal == observed: # humans show every possible pattern, so there is nothing left to predict beta = math.nan distance = math.sqrt(weight * (1 - alpha) ** 2) warnings.warn( "'beta' cannot be calculated because human = universal, so it was excluded " "from distance", stacklevel=2, ) else: novel = torch.unique(m_ids[~torch.isin(m_ids, h_unique)]).numel() beta = novel / (universal - observed) distance = math.sqrt(weight * (1 - alpha) ** 2 + (1 - weight) * beta**2) return GDistanceResult( gdistance=distance, alpha=alpha, beta=beta, accommodation=torch.nonzero(accommodated).reshape(-1), )