Source code for psp._many

"""Many independent parameter space partitioning runs, advanced in lockstep.

:func:`PSPLockstep` runs one :func:`psp.PSP` search per ``control`` (for example
one per participant, each on its own trial order) and keeps them independent:
each has its own pattern store, its own counts, its own files and its own
result. What they share is the model call: every iteration, the proposals of
all runs are concatenated and ``model`` is called once with the rows and the
index of the run each row belongs to. That is what lets a GPU work on hundreds
of small searches at once.
"""

from __future__ import annotations

from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any

import torch

from ._callbacks import as_numeric_tensor
from ._control import Control
from ._global import PSPResult, clamp_parameters, hyper_points
from ._io import RunWriter
from ._store import PatternStore

__all__ = ["PSPLockstep", "LockstepModel"]

#: A model for :func:`PSPLockstep`: ``(parameters (n, d), run (n,)) -> (n, responses)``.
LockstepModel = Callable[[torch.Tensor, torch.Tensor], Any]


[docs] def PSPLockstep( model: LockstepModel, discretize: Callable[[torch.Tensor], Any], controls: Sequence[Mapping[str, Any] | Control], *, save: bool = False, paths: Sequence[str | Path] | None = None, extension: str = ".csv", quiet: bool = False, device: torch.device | str | None = None, dtype: torch.dtype | None = None, generator: torch.Generator | None = None, ) -> list[PSPResult]: """Run one independent partitioning per control, sharing one model call per iteration. Parameters ---------- model Called once per iteration with the proposals of every run that is still going, ``(n, dimensions)``, and ``run``, an ``(n,)`` int64 tensor giving the index in ``controls`` of the run each row belongs to; returns ``(n, responses)``. discretize As in :func:`psp.PSP`: ``(n, responses)`` to ``(n, D, D)``. controls One control mapping per run, with the keys of :func:`psp.PSP`. Runs may differ in ``init``, bounds and ``radius``; they must share ``iterations`` and ``population`` and the number of parameters. save, paths, extension Write every evaluation of run ``r`` to ``paths[r]`` as :func:`psp.PSP` does. quiet Do not print the iteration number, the number of runs still going and the number of rows evaluated. device, dtype, generator As in :func:`psp.PSP`. Returns ------- list[PSPResult] One result per control, in order. A run's ``iterations`` is the iteration at which it stopped proposing, or the cap. """ if not controls: raise ValueError("controls must hold at least one run") settings = [Control.from_mapping(c, device=device, dtype=dtype) for c in controls] first = settings[0] for k, s in enumerate(settings[1:], 1): if s.dimensions != first.dimensions: raise ValueError(f"run {k} has {s.dimensions} parameters, run 0 has {first.dimensions}") if s.iterations != first.iterations or s.population != first.population: raise ValueError("every run must share the same iterations and population") if save and (paths is None or len(paths) != len(settings)): raise ValueError("save=True needs one path per run") run_device, run_dtype = first.init.device, first.init.dtype responses = first.responses dimensionality = first.dimensionality stores = [PatternStore(s.dimensions, run_device, run_dtype) for s in settings] lower = torch.stack([s.lower for s in settings]) # (runs, d) upper = torch.stack([s.upper for s in settings]) def evaluate(jumping: torch.Tensor, run: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]: nonlocal responses, dimensionality rows = int(jumping.shape[0]) continuous = as_numeric_tensor(model(jumping, run), "model", run_device) if continuous.ndim != 2 or continuous.shape[0] != rows: raise ValueError( f"model must return one row per parameter set, ({rows}, responses), " f"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) ordinal = as_numeric_tensor(discretize(continuous), "discretize", run_device) if ordinal.ndim != 3 or ordinal.shape[0] != rows: raise ValueError( f"discretize must return a matrix per parameter set, ({rows}, D, D), " f"but returned shape {tuple(ordinal.shape)}" ) if dimensionality is None: if ordinal.shape[1] != ordinal.shape[2]: raise ValueError("discretize must return square matrices") dimensionality = int(ordinal.shape[1]) elif ordinal.shape[1] != dimensionality or ordinal.shape[2] != dimensionality: raise ValueError( f"discretize returned {ordinal.shape[1]} x {ordinal.shape[2]} matrices, but " f"control['dimensionality'] is {dimensionality}" ) return continuous, ordinal writers: list[RunWriter | None] = [None] * len(settings) finished = [0] * len(settings) # iteration at which each run stopped proposing active = list(range(len(settings))) iteration = 0 try: # iteration 0: every run's starting points jumping = torch.cat([s.init for s in settings]) run = torch.cat( [ torch.full((int(s.init.shape[0]),), k, dtype=torch.int64, device=run_device) for k, s in enumerate(settings) ] ) continuous, ordinal = evaluate(jumping, run) if save: assert paths is not None for k, s in enumerate(settings): names = s.stimuli_names or tuple( f"stimulus_{i + 1}" for i in range(int(continuous.shape[1])) ) writers[k] = RunWriter(paths[k], extension, s.parameter_names, names) _register(stores, writers, 0, jumping, continuous, ordinal, run, len(settings)) active = [k for k in active if stores[k].underpopulated(first.population).numel() > 0] while active and iteration < first.iterations: iteration += 1 pieces, runs = [], [] for k in active: ids = stores[k].underpopulated(first.population) steps = hyper_points(int(ids.numel()), settings[k].radius, generator=generator) proposals = clamp_parameters(stores[k].centres_of(ids) + steps, lower[k], upper[k]) pieces.append(proposals) runs.append( torch.full((int(ids.numel()),), k, dtype=torch.int64, device=run_device) ) jumping = torch.cat(pieces) run = torch.cat(runs) if not quiet: print( f"Iteration: [{iteration}] runs {len(active)} rows {int(jumping.shape[0])}", flush=True, ) continuous, ordinal = evaluate(jumping, run) _register(stores, writers, iteration, jumping, continuous, ordinal, run, len(settings)) still = [] for k in active: if stores[k].underpopulated(first.population).numel() > 0: still.append(k) else: finished[k] = iteration active = still for k in active: finished[k] = iteration finally: for w in writers: if w is not None: w.close() return [ PSPResult( ordinal_patterns=stores[k].patterns(), ordinal_counts=stores[k].counts.clone(), iterations=finished[k], ) for k in range(len(settings)) ]
def _register( stores: list[PatternStore], writers: list[RunWriter | None], iteration: int, jumping: torch.Tensor, continuous: torch.Tensor, ordinal: torch.Tensor, run: torch.Tensor, n_runs: int, ) -> None: """Hand each run its rows of one batched evaluation.""" run_cpu = run.cpu() for k in range(n_runs): rows = torch.nonzero(run_cpu == k).reshape(-1) if rows.numel() == 0: continue rows_dev = rows.to(jumping.device) ids = stores[k].update(ordinal[rows_dev], jumping[rows_dev]) writer = writers[k] if writer is not None: writer.write(iteration, jumping[rows_dev], continuous[rows_dev], ids)