Source code for psp.models.alcove

"""ALCOVE (Kruschke, 1992) on the six category structures of Shepard, Hovland and Jenkins (1961).

This is a batched port of ``slpALCOVE()`` and ``nosof94train()`` from the R
package catlearn (Wills et al., 2025), wrapped for :func:`psp.PSP` as in
the ALCOVE recipe of the R package psp. Many parameter sets are run through the
same training sequences at once, which is what makes a long PSP run affordable
on a GPU.

The training orders are the ones catlearn generates for its seeds, shipped with
the package, so that results can be compared with the R analysis trial for trial.
"""

from __future__ import annotations

import csv
import gzip
from dataclasses import dataclass
from importlib import resources
from typing import Literal

import numpy as np
import torch

__all__ = [
    "ALCOVE_PARAMETERS",
    "ALCOVE_PARAMETERS_FULL",
    "ALCOVE_COLUMNS",
    "ALCOVE_STIMULI",
    "TYPES",
    "AlcoveParameters",
    "alcove",
    "alcove_curves",
    "alcove_training",
    "nosof94",
    "nosof94train",
    "human_curves",
    "sequences",
    "to_alcove",
    "weak_order",
    "describe_order",
    "type_ranks",
]

#: The six category structures, Types I to VI.
TYPES = ("I", "II", "III", "IV", "V", "VI")

#: Columns of a training matrix, as catlearn's ``nosof94train()`` produces them.
ALCOVE_COLUMNS = ("ctrl", "cond", "blk", "stim", "x1", "x2", "x3", "t1", "t2", "m1", "m2", "m3")

#: The eight stimuli as hidden nodes: three binary features each, one column per stimulus.
ALCOVE_STIMULI = torch.tensor(
    [[0, 0, 0, 0, 1, 1, 1, 1], [0, 0, 1, 1, 0, 0, 1, 1], [0, 1, 0, 1, 0, 1, 0, 1]],
    dtype=torch.float64,
)


[docs] @dataclass(frozen=True) class AlcoveParameters: """The four free parameters with the ranges of Pitt, Kim, Navarro and Myung (2006). The lower bounds are kept above 0 because ALCOVE rejects a learning rate of 0. """ name: tuple[str, ...] = ("c", "phi", "lw", "la") lower: tuple[float, ...] = (0.01, 0.01, 0.001, 0.001) upper: tuple[float, ...] = (20.0, 6.0, 0.2, 0.2)
ALCOVE_PARAMETERS = AlcoveParameters() #: The full ranges of the learning rates, 0 to 1, with the same specificity and consistency ranges. ALCOVE_PARAMETERS_FULL = AlcoveParameters(upper=(20.0, 6.0, 1.0, 1.0)) # category A (t1 = 1) of each type, by stimulus number 1-8 (catlearn's numbering) _CATEGORY_A = { 1: (1, 2, 3, 4), 2: (1, 2, 7, 8), 3: (1, 2, 3, 6), 4: (1, 2, 3, 5), 5: (1, 2, 3, 8), 6: (1, 4, 6, 7), } def _shj61(cond: int) -> np.ndarray: """The eight stimuli of one type: columns stim, x1, x2, x3, t1, t2, m1, m2, m3.""" rows = [] for stim in range(1, 9): x = [(stim - 1) >> 2 & 1, (stim - 1) >> 1 & 1, (stim - 1) & 1] in_a = stim in _CATEGORY_A[cond] rows.append([stim, *x, 1 if in_a else -1, -1 if in_a else 1, 0, 0, 0]) return np.array(rows, dtype=np.float64)
[docs] def nosof94train( cond: int = 1, blocks: int = 16, absval: float = -1, subjs: int = 1, seed: int | None = 7624, blkstyle: Literal["accurate", "eights"] = "accurate", ) -> np.ndarray: """Randomised training blocks for one category structure, like catlearn's ``nosof94train()``. Each block presents the eight stimuli twice. In block 1 (or in every block with ``blkstyle="eights"``) each half of the block is shuffled separately; later blocks shuffle all sixteen trials together. Each simulated subject starts with a reset trial (``ctrl = 1``). The shuffling uses NumPy's generator, so the orders differ from R's for the same seed. For the orders catlearn produces, use :func:`alcove_training`. Returns ------- numpy.ndarray Shape ``(subjs * blocks * 16, 12)`` with the columns of :data:`ALCOVE_COLUMNS`. """ rng = np.random.default_rng(seed) items = _shj61(cond) subjects = [] for _ in range(subjs): trials = [] for blk in range(1, blocks + 1): if blk == 1 or blkstyle == "eights": block = np.vstack([items[rng.permutation(8)], items[rng.permutation(8)]]) else: block = np.vstack([items, items])[rng.permutation(16)] trials.append(np.column_stack([np.full(16, cond), np.full(16, blk), block])) subject = np.vstack(trials) ctrl = np.zeros((subject.shape[0], 1)) ctrl[0, 0] = 1 subjects.append(np.hstack([ctrl, subject])) out = np.vstack(subjects) if absval != -1: for column in (7, 8): out[out[:, column] == -1, column] = absval return out
[docs] def alcove_training(learners: int = 5, seed: int = 7624) -> torch.Tensor: """One training sequence per type and simulated learner, in catlearn's trial order. The orders catlearn's ``nosof94train()`` generates with R's random number generator are shipped for ``seed`` 7624 with 5 and 20 learners, and for seeds 11, 22, 33 and 44 with 5 learners (the settings of the ALCOVE recipe). Other settings fall back to :func:`nosof94train` and NumPy's generator. Returns ------- torch.Tensor Shape ``(6 * learners * 16 * 8 * 2, 12)``, ``float64``: types I to VI in turn, each with ``learners`` sequences of 16 blocks. """ name = f"nosof94train_seed{seed}_learners{learners}.csv.gz" resource = resources.files("psp.data").joinpath(name) if resource.is_file(): with resource.open("rb") as raw, gzip.open(raw, "rt", encoding="utf-8") as text: reader = csv.reader(text) header = next(reader) assert tuple(header) == ALCOVE_COLUMNS, header rows = [[float(v) for v in row] for row in reader] return torch.tensor(rows, dtype=torch.float64) return torch.from_numpy( np.vstack( [ nosof94train(cond, blocks=16, absval=-1, subjs=learners, seed=seed) for cond in range(1, 7) ] ) )
[docs] def sequences(training: torch.Tensor) -> torch.Tensor: """Split a training matrix into equal-length sequences at every reset trial. Returns a tensor of shape ``(sequences, trials, columns)``. """ starts = torch.nonzero(training[:, 0] == 1).reshape(-1) if starts.numel() == 0 or int(starts[0]) != 0: raise ValueError("a training matrix must start with a reset trial (ctrl = 1)") length = int(training.shape[0]) // int(starts.numel()) if length * int(starts.numel()) != int(training.shape[0]) or not bool( (starts == torch.arange(0, training.shape[0], length, device=starts.device)).all() ): raise ValueError("every sequence must have the same number of trials") return training.reshape(int(starts.numel()), length, -1)
[docs] def to_alcove( unit: torch.Tensor, parameters: AlcoveParameters = ALCOVE_PARAMETERS ) -> dict[str, torch.Tensor]: """Stretch points of the unit hypercube onto ALCOVE's parameter ranges. ``unit`` has shape ``(n, 4)``; the result maps ``c``, ``phi``, ``lw`` and ``la`` to tensors of shape ``(n,)``. ``parameters`` gives the ranges: those of Pitt et al. by default, or :data:`ALCOVE_PARAMETERS_FULL`. """ unit = unit.reshape(-1, 4) lower = torch.tensor(parameters.lower, dtype=unit.dtype, device=unit.device) upper = torch.tensor(parameters.upper, dtype=unit.dtype, device=unit.device) values = lower + unit * (upper - lower) return {name: values[:, i] for i, name in enumerate(parameters.name)}
[docs] def alcove( params: dict[str, torch.Tensor], training: torch.Tensor, *, h: torch.Tensor, alpha: torch.Tensor, w: torch.Tensor, colskip: int, r: float = 1.0, q: float = 1.0, dec: Literal["ER", "BN"] = "ER", humble: bool = True, attcon: bool = False, absval: float = -1.0, ) -> torch.Tensor: """Run ALCOVE for a batch of parameter sets over a batch of training sequences. A port of catlearn's ``slpALCOVE()``. Every parameter set runs through every sequence, all in parallel, and the state is carried from trial to trial within a sequence. Parameters ---------- params ``c``, ``phi``, ``lw`` and ``la``, each a tensor of shape ``(batch,)``. training Shape ``(sequences, trials, columns)``, see :func:`sequences`. Column 0 holds the control code (0 normal, 1 reset the network, 2 freeze learning); the inputs, teaching signals and missing-dimension flags follow ``colskip`` columns, as in catlearn. h Hidden node positions, shape ``(inputs, hidden)``. alpha Initial attention weights, shape ``(inputs,)``. w Initial association weights, shape ``(outputs, hidden)``. colskip Number of columns before the inputs, counting the control column. r, q Distance metric (1 city-block, 2 Euclidean) and similarity gradient (1 exponential, 2 Gaussian). dec ``"ER"`` for the exponential ratio rule, ``"BN"`` for the background noise rule. humble Use a humble teacher (Kruschke, 1992, Eq. 4b). attcon Constrain the attention weights to sum to 1. absval Teaching value for an absent category. Returns ------- torch.Tensor Response probabilities, shape ``(batch, sequences, trials, outputs)``. Notes ----- The exponential ratio rule is computed as a numerically stable softmax. Where catlearn's ``exp(phi * o) / sum(exp(phi * o))`` overflows (the association weights diverge when the specificity is tiny and the association learning rate large, so every exemplar responds alike and the updates overshoot), catlearn returns ``NaN`` and this function returns the limiting probabilities. Everywhere else the two agree to machine precision. """ c = params["c"].reshape(-1, 1, 1) phi = params["phi"].reshape(-1, 1, 1) lw = params["lw"].reshape(-1, 1, 1, 1) la = params["la"].reshape(-1, 1, 1) device, dtype = c.device, c.dtype training = training.to(device, dtype) h = h.to(device, dtype) nin, nhid = h.shape nout = int(w.shape[0]) batch = int(c.shape[0]) n_seq, n_trials, _ = training.shape alpha0 = alpha.to(device, dtype).reshape(1, 1, nin).expand(batch, n_seq, nin) w0 = w.to(device, dtype).reshape(1, 1, nout, nhid).expand(batch, n_seq, nout, nhid) attention = alpha0.clone() weights = w0.clone() probabilities = torch.empty((batch, n_seq, n_trials, nout), device=device, dtype=dtype) ctrl = training[:, :, 0] inputs = training[:, :, colskip : colskip + nin] teachers = training[:, :, colskip + nin : colskip + nin + nout] present = training[:, :, colskip + nin + nout : colskip + 2 * nin + nout] == 0 for trial in range(n_trials): reset = (ctrl[:, trial] == 1).reshape(1, n_seq, 1) attention = torch.where(reset, alpha0, attention) weights = torch.where(reset.unsqueeze(-1), w0, weights) learn = (ctrl[:, trial] != 2).to(dtype).reshape(1, n_seq, 1) x = inputs[:, trial] # (seq, nin) t = teachers[:, trial] # (seq, nout) hmx = (h.unsqueeze(0) - x.unsqueeze(2)).abs() # (seq, nin, nhid) hmx = hmx * present[:, trial].unsqueeze(2) # hidden node activations: exp(-c * (sum_i alpha_i |h_ji - x_i|^r)^(q/r)) distance = torch.einsum("bsi,sij->bsj", attention, hmx**r) if r != 1.0: distance = distance ** (1.0 / r) if q != 1.0: distance = distance**q ah = torch.exp(-c * distance) # (batch, seq, nhid) ao = torch.einsum("bskj,bsj->bsk", weights, ah) # (batch, seq, nout) if dec == "ER": probabilities[:, :, trial] = torch.softmax(phi * ao, dim=-1) elif dec == "BN": positive = ao.clamp_min(0) probabilities[:, :, trial] = (positive + phi) / ( positive.sum(-1, keepdim=True) + nout * phi ) else: raise ValueError(f"dec must be 'ER' or 'BN', not {dec!r}") target = t.unsqueeze(0).expand_as(ao) if humble: target = torch.where(target == 1.0, torch.maximum(target, ao), target) target = torch.where(target == absval, torch.minimum(target, ao), target) error = target - ao # (batch, seq, nout) delta_w = lw * learn.unsqueeze(-1) * error.unsqueeze(-1) * ah.unsqueeze(-2) back = torch.einsum("bsk,bskj->bsj", error, weights) * ah # (batch, seq, nhid) delta_alpha = -la * learn * c * torch.einsum("bsj,sij->bsi", back, hmx) weights = weights + delta_w if attcon: delta_alpha = delta_alpha - delta_alpha.mean(-1, keepdim=True) attention = (attention + delta_alpha).clamp_min(0) attention = attention / attention.sum(-1, keepdim=True) else: attention = (attention + delta_alpha).clamp_min(0) return probabilities
[docs] def alcove_curves( unit: torch.Tensor, training: torch.Tensor, *, chunk: int = 256, parameters: AlcoveParameters = ALCOVE_PARAMETERS, ) -> torch.Tensor: """Error rate of each type in each block for parameter sets on the unit hypercube. This is the ``model`` of the ALCOVE recipe. ``unit`` has shape ``(n, 4)`` and is mapped onto ALCOVE's ranges, ``parameters``, with :func:`to_alcove`. ``training`` is a matrix from :func:`alcove_training`. The result has shape ``(n, 96)``: the 16 blocks of Type I, then of Type II, and so on, each averaged over the simulated learners. ``chunk`` bounds how many parameter sets run at once, to limit memory. """ unit = unit.reshape(-1, 4) seq = sequences(training.to(unit.device, unit.dtype)) cond = seq[:, 0, 1].long() - 1 # type of each sequence, 0-5 block = seq[:, :, 2].long() - 1 # block of each trial, 0-15 correct_is_first = seq[:, :, 7] == 1 # t1 == 1: category A is correct n_types, n_blocks = len(TYPES), int(block.max()) + 1 outputs = [] for start in range(0, unit.shape[0], chunk): part = unit[start : start + chunk] prob = alcove( to_alcove(part, parameters), seq, h=ALCOVE_STIMULI, alpha=torch.full((3,), 1 / 3, dtype=torch.float64), w=torch.zeros((2, 8), dtype=torch.float64), colskip=4, ) correct = torch.where( correct_is_first.unsqueeze(0), prob[..., 0], prob[..., 1] ) # (b, seq, trials) error = 1 - correct # mean error over the trials of each (type, block) cell, across learners cell = (cond.unsqueeze(1) * n_blocks + block).reshape(-1) # (seq * trials,) sums = torch.zeros( (part.shape[0], n_types * n_blocks), device=unit.device, dtype=unit.dtype ) sums.index_add_(1, cell, error.reshape(part.shape[0], -1)) counts = torch.bincount(cell, minlength=n_types * n_blocks).to(unit.dtype) outputs.append(sums / counts) return torch.cat(outputs)
[docs] def weak_order(curves: torch.Tensor, gap: float = 0.025) -> torch.Tensor: """The weak order of the six types by mean error, as a 6 x 6 comparison matrix. This is the ``discretize`` of the ALCOVE recipe. The six mean errors are sorted and a new group starts wherever the gap to the next type exceeds ``gap``. The entry ``(i, j)`` is 1 when type ``i`` is harder than type ``j``, -1 when easier and 0 when they are tied. ``curves`` may be ``(96,)``, ``(n, 96)`` or ``(n, 6)`` (mean errors); the result has shape ``(n, 6, 6)``, or ``(6, 6)`` for a vector. """ single = curves.ndim == 1 curves = curves.reshape(1, -1) if single else curves n_types = len(TYPES) if curves.shape[1] == n_types: error = curves else: error = curves.reshape(curves.shape[0], n_types, -1).mean(dim=2) sorted_error, order = error.sort(dim=1) new_group = torch.ones_like(sorted_error, dtype=torch.int64) new_group[:, 1:] = (sorted_error.diff(dim=1) > gap).long() group_sorted = new_group.cumsum(dim=1) group = torch.empty_like(group_sorted).scatter_(1, order, group_sorted) pattern = torch.sign(group.unsqueeze(2) - group.unsqueeze(1)).to(curves.dtype) return pattern[0] if single else pattern
[docs] def describe_order(pattern: torch.Tensor) -> str: """Write a weak order the usual way, easiest first: ``"I < II < (III, IV, V) < VI"``.""" rank = (torch.as_tensor(pattern) > 0).sum(dim=1).tolist() # how many types each is harder than groups: dict[int, list[str]] = {} for name, r in zip(TYPES, rank, strict=True): groups.setdefault(int(r), []).append(name) parts = [] for r in sorted(groups): names = groups[r] parts.append(f"({', '.join(names)})" if len(names) > 1 else names[0]) return " < ".join(parts)
[docs] def type_ranks(errors: torch.Tensor, gap: float = 0.025) -> torch.Tensor: """Average rank of each type in its weak order, 1 for the easiest, ties sharing ranks. ``errors`` holds the six mean errors, shape ``(6,)`` or ``(n, 6)``. """ single = errors.ndim == 1 pattern = weak_order(errors.reshape(1, -1) if single else errors, gap=gap) group = (pattern > 0).sum(dim=2) # (n, 6): 0 for the easiest group n = group.shape[0] ranks = torch.empty(group.shape, dtype=torch.float64) for i in range(n): g = group[i] for value in g.unique(): members = g == value below = int((g < value).sum()) size = int(members.sum()) ranks[i, members] = below + (size + 1) / 2 return ranks[0] if single else ranks
[docs] def nosof94() -> np.ndarray: """Nosofsky et al.'s (1994) replication: columns ``type`` (1-6), ``block`` (1-16), ``error``.""" resource = resources.files("psp.data").joinpath("nosof94.csv") with resource.open("r", encoding="utf-8") as text: reader = csv.reader(text) header = next(reader) assert header == ["type", "block", "error"], header rows = [[float(v) for v in row] for row in reader] return np.array(rows, dtype=np.float64)
[docs] def human_curves() -> torch.Tensor: """The human error rates in the order :func:`alcove_curves` uses, shape ``(96,)``.""" data = nosof94() order = np.lexsort((data[:, 1], data[:, 0])) return torch.from_numpy(data[order, 2].copy())