Source code for psp._global

"""Parameter space partitioning: the :func:`PSP` sampler and its result."""

from __future__ import annotations

from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any

import torch

from ._callbacks import Discretize, Model, as_numeric_tensor, rowwise
from ._control import Control
from ._io import RunWriter
from ._store import PatternStore

__all__ = ["PSP", "PSPResult", "hyper_points", "clamp_parameters"]


[docs] @dataclass(frozen=True) class PSPResult(Mapping[str, Any]): """Container. Data class. It holds and validates the results of a :func:`PSP` run. It behaves like a mapping and a dataclass. Attributes ---------- ordinal_patterns Every pattern found, shape ``(patterns, dimensionality, dimensionality)``, ``float64``, on the CPU. Pattern ``k`` is ``ordinal_patterns[k]``; the R package stores the same matrices as ``ordinal_patterns[, , k + 1]``. ordinal_counts How many evaluated parameter sets produced each pattern, shape ``(patterns,)``. iterations Number of iterations completed before a stopping rule was reached. """ ordinal_patterns: torch.Tensor ordinal_counts: torch.Tensor iterations: int _KEYS = ("ordinal_patterns", "ordinal_counts", "iterations") def __getitem__(self, key: str) -> Any: if key not in self._KEYS: raise KeyError(key) return getattr(self, key) def __iter__(self) -> Iterator[str]: return iter(self._KEYS) def __len__(self) -> int: return len(self._KEYS) @property def patterns(self) -> int: """Number of patterns found.""" return int(self.ordinal_patterns.shape[0]) @property def evaluations(self) -> int: """Number of times the model was evaluated.""" return int(self.ordinal_counts.sum()) def __repr__(self) -> str: return ( f"PSPResult(patterns={self.patterns}, evaluations={self.evaluations}, " f"iterations={self.iterations})" )
[docs] def hyper_points( counts: int, radius: torch.Tensor, *, generator: torch.Generator | None = None, ) -> torch.Tensor: """Random steps of at most ``radius`` in each parameter, shape ``(counts, dimensions)``. This is the jumping distribution: a direction picked uniformly on the unit hypersphere (Weisstein, "Hypersphere Point Picking"), scaled in each dimension by a factor drawn uniformly between 0 and that dimension's radius. The scale factors are drawn once per call and shared by all ``counts`` points, as in the C++ implementation. Parameters ---------- counts Number of points. radius Radius of each dimension, shape ``(dimensions,)``; its device and dtype are used. generator Random number generator on the same device as ``radius``. References ---------- Weisstein, Eric W. "Hypersphere Point Picking." From MathWorld--A Wolfram Resource. https://mathworld.wolfram.com/HyperspherePointPicking.html """ dimensions = int(radius.numel()) direction = torch.randn( (counts, dimensions), generator=generator, device=radius.device, dtype=radius.dtype ) direction = direction / direction.norm(dim=1, keepdim=True).clamp_min( torch.finfo(radius.dtype).tiny ) scale = torch.rand(dimensions, generator=generator, device=radius.device, dtype=radius.dtype) return direction * (scale * radius)
[docs] def clamp_parameters( parameters: torch.Tensor, lower: torch.Tensor, upper: torch.Tensor ) -> torch.Tensor: """Move every parameter set onto the bounds where it left them.""" return torch.maximum(torch.minimum(parameters, upper), lower)
[docs] def PSP( model: Model, discretize: Discretize, control: Mapping[str, Any] | Control, save: bool = False, path: str | Path = ".", extension: str = ".csv", quiet: bool = False, *, batched: bool = True, device: torch.device | str | None = None, dtype: torch.dtype | None = None, generator: torch.Generator | None = None, ) -> PSPResult: """Partition a model's parameter space by the ordinal patterns it produces. A PyTorch implementation of the parameter space partitioning MCMC algorithm of Pitt, Kim, Navarro and Myung (2006). The algorithm: 0. Evaluate ``model`` on every row of ``control["init"]``. The patterns ``discretize`` makes of the outputs are the first regions found. 1. For every pattern with fewer than ``population`` parameter sets, propose a new parameter set at random within ``radius`` of the last parameter set that produced it, and move it onto the bounds if it crossed them. 2. Evaluate the model on all proposals in one batch. 3. Record the patterns found and the parameter sets that produced them, then return to step 1 until every pattern is full or ``iterations`` is reached. Parameters ---------- model Maps parameter sets to the model's continuous outputs. Batched by default: it receives a ``(n, dimensions)`` tensor and returns ``(n, responses)``. With ``batched=False`` it receives one ``(dimensions,)`` tensor at a time and returns ``(responses,)``, as in R. discretize Maps model outputs to an ordinal pattern, a square numeric matrix. Batched by default: ``(n, responses)`` in, ``(n, dimensionality, dimensionality)`` out. With ``batched=False``, ``(responses,)`` in and a matrix out. Two parameter sets produce the same pattern when the matrices are identical, with ``-0.0`` equal to ``0.0`` and ``NaN`` equal to ``NaN``. control The settings as a set of control arguments, either a :class:`Control` or a mapping with the same keys. The required keys are: ``init`` Starting parameter sets, one per row. ``lower``, ``upper`` Bounds of each parameter. ``radius`` Largest step in any one parameter; a single number, or one per parameter. ``population`` Parameter sets each pattern needs; ``0``/``None`` for no limit. ``iterations`` Cap on the number of iterations; ``0``/``None`` for no cap. At least one of ``population`` and ``iterations`` must be set. ``parameter_names``, ``stimuli_names`` Column names of the saved files (optional). ``dimensionality``, ``responses`` Size of the pattern matrix and length of the model output, checked on every evaluation. Optional: taken from the first evaluation if absent. save Write every evaluated parameter set and model output to disk. path Prefix of the two files, ``<path>_parameters<extension>`` and ``<path>_continuous<extension>``. Each row holds the iteration, the values and the 0-based pattern id. extension Extension of the saved files. quiet Do not print the iteration number. batched Whether ``model`` and ``discretize`` take the whole batch (default) or one parameter set at a time. Equivalent to wrapping both with :func:`psp.rowwise`. device, dtype Where the search runs and in what precision. Defaults to the device and dtype of ``control["init"]`` if it is a tensor, otherwise CPU and ``float64``. generator A :class:`torch.Generator` on ``device`` for reproducible proposals. By default the global generator is used, seeded with :func:`torch.manual_seed`. Returns ------- PSPResult ``ordinal_patterns``, ``ordinal_counts`` and ``iterations``. Raises ------ ValueError For invalid control settings, and when ``model`` or ``discretize`` returns something of the wrong type or size. Errors raised by the callbacks propagate. References ---------- Pitt, M. A., Kim, W., Navarro, D. J., & Myung, J. I. (2006). Global model analysis by parameter space partitioning. *Psychological Review, 113*(1), 57-83. https://doi.org/10.1037/0033-295X.113.1.57 Examples -------- >>> import torch >>> from psp import PSP >>> torch.manual_seed(1) # doctest: +ELLIPSIS <torch._C.Generator object at ...> >>> model = lambda par: par # the outputs are the parameters >>> discretize = lambda out: (out > 0.5).double().diag_embed() # which half of each axis >>> result = PSP(model, discretize, { ... "init": [[0.5, 0.5]], "lower": [0, 0], "upper": [1, 1], ... "radius": 0.5, "population": 20, "iterations": 100}, quiet=True) >>> result.patterns 4 """ settings = Control.from_mapping(control, device=device, dtype=dtype) run_device = settings.init.device run_dtype = settings.init.dtype if not batched: model = rowwise(model) discretize = rowwise(discretize) responses = settings.responses ## specify the length of the vector the model returns dimensionality = settings.dimensionality ## specify the size of the discretized square matrix def evaluate(jumping: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: nonlocal responses, dimensionality ## allow evaluate to modify the global values if None rows = int(jumping.shape[0]) ## evaluate the model on the jumping parameter sets ## ensure the output is a numeric tensor on the correct device continuous = as_numeric_tensor(model(jumping), "model", run_device) ## guard against mismatching shapes of the model output and the expected responses if continuous.ndim == 1 and rows == 1: continuous = continuous.unsqueeze(0) if continuous.ndim != 2 or continuous.shape[0] != rows: raise ValueError( f"model must return one row of values per parameter set, a ({rows}, responses) " f"tensor, but returned shape {tuple(continuous.shape)}" ) if responses is None: responses = int(continuous.shape[1]) elif continuous.shape[1] != responses: raise ValueError( f"model returned {continuous.shape[1]} values, but control['responses'] " f"is {responses}" ) continuous = continuous.to(run_dtype) ## ensure the model output is in the correct dtype ## discretize the continuous outputs and ## ensure the result is a numeric tensor on the correct device ordinal = as_numeric_tensor(discretize(continuous), "discretize", run_device) if ordinal.ndim == 2 and rows == 1: ordinal = ordinal.unsqueeze(0) if ordinal.ndim != 3 or ordinal.shape[0] != rows: raise ValueError( f"discretize must return a numeric matrix per parameter set, a " f"({rows}, dimensionality, dimensionality) tensor, but returned shape " f"{tuple(ordinal.shape)}" ) if dimensionality is None: if ordinal.shape[1] != ordinal.shape[2]: raise ValueError( f"discretize must return a square matrix, but returned " f"{ordinal.shape[1]} x {ordinal.shape[2]}" ) dimensionality = int(ordinal.shape[1]) elif ordinal.shape[1] != dimensionality or ordinal.shape[2] != dimensionality: raise ValueError( f"discretize returned a {ordinal.shape[1]} x {ordinal.shape[2]} matrix, but " f"control['dimensionality'] is {dimensionality}" ) return continuous, ordinal ## setup environment for the run store = PatternStore(settings.dimensions, run_device, run_dtype) iteration = 0 writer: RunWriter | None = None try: ## initialize the store with the initial parameter sets and their corresponding patterns jumping = settings.init.clone() continuous, ordinal = evaluate(jumping) ids = store.update(ordinal, jumping) if save: stimuli_names = settings.stimuli_names if stimuli_names is None: stimuli_names = tuple(f"stimulus_{k + 1}" for k in range(int(continuous.shape[1]))) writer = RunWriter(path, extension, settings.parameter_names, stimuli_names) writer.write(0, jumping, continuous, ids) ## index of patterns that have fewer than the required population of parameter sets underpopulated = store.underpopulated(settings.population) ## run parameter space partitioning until all patterns are full or all iterations are done while underpopulated.numel() > 0 and iteration < settings.iterations: iteration += 1 if not quiet: print(f"Iteration: [{iteration}]", flush=True) steps = hyper_points(int(underpopulated.numel()), settings.radius, generator=generator) jumping = clamp_parameters( store.centres_of(underpopulated) + steps, settings.lower, settings.upper ) continuous, ordinal = evaluate(jumping) ids = store.update(ordinal, jumping) if writer is not None: writer.write(iteration, jumping, continuous, ids) underpopulated = store.underpopulated(settings.population) finally: if writer is not None: writer.close() return PSPResult( ordinal_patterns=store.patterns(), ordinal_counts=store.counts.clone(), iterations=iteration, )