Source code for psp.models.oscar

"""OSCAR (Dome & Wills, 2026) on the inverse base-rate effect data (dome2025ibre)
from Dome & Wills (2025), batched over sets and participants.

OSCAR is the model of the inverse base-rate effect of Dome and Wills (2026). This module is
a port of its C++ implementation (``architecture.cpp`` and ``model-row-by-row.cpp``) and of
the participant-level PSP routine around it (``convert.R``, ``imac.R``, ``psp.R``):

- :func:`oscar` runs the network for a batch of parameter sets, each row on one
  of a batch of trial sequences, and returns the response probabilities;
- :func:`dome2025ibre` loads the dataset (354 participants) and
  :func:`training_batch` builds the padded trial matrices of
  ``ConvertToStateList()`` for any set of participants;
- :func:`evaluate` is the ``model`` of the PSP routine (P(rare) at test for
  A, B, BC and C behind a 0.5 anchor) and :func:`discretize` its ``discretize``
  (clobe's ``imac()`` with the ±0.125 thresholds).

Every function takes whole batches: no loop runs over parameter sets or
participants, only over trials and over the ten recurrent attention-shift
iterations of a learning trial.

Dome, L., & Wills, A. J. (2025). g-Distance: On the comparison of model and human
heterogeneity. *Psychological Review, 132*(3), 632–655. https://doi.org/10.1037/rev0000550

Dome, L., & Wills, A. J. (2026). Errorless Irrationality: A unified computational
account of the inverse base-rate effect across predictive, observational, and
unsupervised procedures (arXiv:2608.06149). arXiv.
https://doi.org/10.48550/arXiv.2608.06149
"""

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

from .. import clobe

__all__ = [
    "OSCAR_PARAMETERS",
    "OSCAR_BOUNDS",
    "STIMULI",
    "NODES",
    "TRIAL_COLUMNS",
    "IbreData",
    "dome2025ibre",
    "convert_to_state_list",
    "training_batch",
    "oscar",
    "random_initial_state",
    "evaluate",
    "imac",
    "discretize",
    "EVALUATED_STIMULI",
]

#: The five free parameters, in the order PSP() passes them.
OSCAR_PARAMETERS = ("P", "alpha", "mu", "rho", "beta")
#: Their bounds in the PSP routine (run.r): lower, upper.
OSCAR_BOUNDS = ((1.0, 1e-3, 1e-3, 1e-3, 1e-3), (5.0, 1.0, 1.0, 5.0, 5.0))
#: The five nodes of the network: three cues and the two outcomes.
NODES = ("A", "B", "C", "common", "rare")
#: Stimulus names, in the order their integer codes take in a trial matrix.
STIMULI = ("A", "B", "C", "AB", "ABX", "AC", "ACY", "BC")
#: The stimuli whose P(rare) at test enter the ordinal pattern, after the 0.5 anchor.
EVALUATED_STIMULI = ("A", "B", "BC", "C")
#: Columns of a trial matrix, as ``ConvertToStateList()`` lays them out (``colskip = 4``).
TRIAL_COLUMNS = (
    "ctrl",
    "ppt",
    "trials",
    "stim",
    "x1",
    "x2",
    "x3",
    "x4",
    "x5",
    "y1",
    "y2",
    "y3",
    "y4",
    "y5",
    "f1",
    "f2",
    "f3",
    "f4",
    "f5",
)
COLSKIP = 4
#: Control code of padding rows in a batch of sequences of unequal length.
PAD = -1.0
#: Saliences are kept at or above this value after every update, as in the C++.
ETA_FLOOR = 1e-3
#: Recurrent attention-shift iterations per node per learning trial.
SHIFT_ITERATIONS = 10

_STIMULUS_FEATURES = {
    #        A  B  C  X  Y
    "A": (1, 0, 0, 0, 0),
    "B": (0, 1, 0, 0, 0),
    "C": (0, 0, 1, 0, 0),
    "AB": (1, 1, 0, 0, 0),
    "ABX": (1, 1, 0, 1, 0),
    "AC": (1, 0, 1, 0, 0),
    "ACY": (1, 0, 1, 0, 1),
    "BC": (0, 1, 1, 0, 0),
}
_FEEDBACK = {"AB": (0, 0, 0, 1, 0), "AC": (0, 0, 0, 0, 1), "NULL": (0, 0, 0, 0, 0)}


[docs] @dataclass(frozen=True) class IbreData: """The trials of the dataset, one entry per row: participant, stimulus, trial, phase, response. Arrays of equal length. """ ppt: np.ndarray abstim: np.ndarray trial: np.ndarray phase: np.ndarray abresp: np.ndarray @property def participants(self) -> list[str]: """Participant ids in order of first appearance.""" return list(dict.fromkeys(self.ppt.tolist())) def participant(self, ppt: str) -> IbreData: mask = self.ppt == ppt if not mask.any(): raise KeyError(f"no participant {ppt!r}") return IbreData( self.ppt[mask], self.abstim[mask], self.trial[mask], self.phase[mask], self.abresp[mask] )
[docs] def dome2025ibre() -> IbreData: """The inverse base-rate effect data (ply207): 354 participants, training then 72 test trials. ``abresp`` is ``common``, ``rare`` or ``none``; ``phase`` is ``training`` or ``test``. References: Dome, L., & Wills, A. J. (2025). g-Distance: On the Comparison of Model and Human Heterogeneity. Psychological Review, 132(3), 632–655. https://doi.org/10.1037/rev0000550 """ resource = resources.files("psp.data").joinpath("dome2025ibre.csv.gz") with resource.open("rb") as raw, gzip.open(raw, "rt", encoding="utf-8") as text: reader = csv.reader(text) header = next(reader) assert header == ["ppt", "abstim", "trial", "phase", "abresp"], header rows = list(reader) columns = list(zip(*rows, strict=True)) return IbreData( ppt=np.array(columns[0]), abstim=np.array(columns[1]), trial=np.array(columns[2], dtype=np.int64), phase=np.array(columns[3]), abresp=np.array(columns[4]), )
[docs] def convert_to_state_list( schedule: list[str] | np.ndarray, block: list[int] | np.ndarray, trials: list[int] | np.ndarray, ppt: int = 0, design: Literal["supervised", "unsupervised"] = "supervised", ) -> np.ndarray: """One participant's trial matrix, as ``ConvertToStateList()`` in convert.R builds it. Parameters ---------- schedule Stimulus of each trial, names from :data:`STIMULI`. block Phase code of each trial: ``0`` or ``4`` for training (``4`` is what psp.R uses for a supervised design), ``2`` for test. trials Trial numbers, kept as a column. ppt Participant code, kept as a column (an integer here; R keeps the string). design ``"supervised"``: outcomes are eligible outputs in both phases and training trials carry feedback; the first trial resets the network with code 3. ``"unsupervised"``: no feedback and no eligible outputs in training; code 1. Returns ------- numpy.ndarray Shape ``(trials, 19)`` with :data:`TRIAL_COLUMNS`; ``stim`` holds the index of the stimulus in :data:`STIMULI`. """ block = np.asarray(block, dtype=np.float64) rows = [] for idx, (name, phase, trial) in enumerate(zip(schedule, block, trials, strict=True)): x = _STIMULUS_FEATURES[name] test = phase != 0 and phase != 4 if design == "supervised": y = (0, 0, 0, 1, 1) f = _FEEDBACK["NULL"] if test else _FEEDBACK.get(name, _FEEDBACK["NULL"]) elif design == "unsupervised": y = (0, 0, 0, 1, 1) if test else (0, 0, 0, 0, 0) f = _FEEDBACK["NULL"] else: raise ValueError("design must be 'supervised' or 'unsupervised'") ctrl = phase if idx == 0: ctrl = 3 if design == "supervised" else 1 rows.append([ctrl, ppt, trial, STIMULI.index(name), *x, *y, *f]) return np.array(rows, dtype=np.float64)
def participant_training(data: IbreData, ppt: str, code: int = 0) -> np.ndarray: """The trial matrix of one participant, pre-processed as psp.R does for ply207. Training trials get control code 4 (supervised learning with a response), test trials code 2 (respond, no learning), and the first trial code 3 (reset). """ d = data.participant(ppt) block = np.where(d.phase == "test", 2, 4) return convert_to_state_list(d.abstim, block, d.trial, ppt=code, design="supervised")
[docs] def training_batch( data: IbreData, participants: list[str] | None = None, *, dtype: torch.dtype = torch.float64 ) -> tuple[torch.Tensor, list[str]]: """Trial matrices of several participants, padded to the longest with control code ``PAD``. Returns the tensor of shape ``(participants, trials, 19)`` and the participant ids in row order. """ ids = data.participants if participants is None else list(participants) matrices = [participant_training(data, p, code=k) for k, p in enumerate(ids)] length = max(m.shape[0] for m in matrices) out = np.full((len(ids), length, len(TRIAL_COLUMNS)), 0.0, dtype=np.float64) out[:, :, 0] = PAD for k, m in enumerate(matrices): out[k, : m.shape[0]] = m return torch.tensor(out, dtype=dtype), ids
[docs] def random_initial_state( batch: int, *, scale: float = 1e-3, generator: torch.Generator | None = None, device: torch.device | str = "cpu", dtype: torch.dtype = torch.float64, ) -> tuple[torch.Tensor, torch.Tensor]: """Initialize non-zero saliences and weights for a batch of parameter sets. Returns ``eta0`` with a zero diagonal and ``weights0``, both of shape ``(batch, 5, 5)``. """ n = len(NODES) eta0 = torch.rand((batch, n, n), generator=generator, device=device, dtype=dtype) * scale weights0 = torch.rand((batch, n, n), generator=generator, device=device, dtype=dtype) * scale eta0 = eta0 * (1 - torch.eye(n, device=device, dtype=dtype)) return eta0, weights0
def _gating( x: torch.Tensor, eta: torch.Tensor, P: torch.Tensor ) -> tuple[torch.Tensor, torch.Tensor]: """Equation 1, 2, 3: per-row p-norm gating of the input by the saliences. ``x`` is ``(B, n)``, ``eta`` ``(B, n, n)`` (row k: the saliences feeding node k), ``P`` ``(B, 1, 1)``. Returns the gated input ``(B, n, n)`` and the p-norm ``(B, n)``. """ gain = x.unsqueeze(1) * eta pnorm = (gain**P).sum(dim=2) ** (1.0 / P.squeeze(-1)) return gain / pnorm.unsqueeze(2), pnorm
[docs] def oscar( params: dict[str, torch.Tensor], training: torch.Tensor, *, eta0: torch.Tensor, weights0: torch.Tensor, sequence: torch.Tensor | None = None, zero_first_column: bool = True, return_trials: bool = False, ) -> dict[str, torch.Tensor]: """Run OSCAR for a batch of parameter sets, each on one trial sequence. Parameters ---------- params ``P``, ``alpha``, ``mu``, ``rho`` and ``beta``, each of shape ``(B,)``. training Trial matrices, shape ``(S, T, 19)`` with :data:`TRIAL_COLUMNS`, or ``(T, 19)`` for a single sequence shared by the batch. Control codes: 1 and 3 reset the network before the trial; 2 responds without learning; 0, 4 and 5 learn (3 and 4 with feedback as part of the teacher); :data:`PAD` rows are skipped. eta0, weights0 Initial saliences and connection weights, shape ``(B, 5, 5)``; their diagonals are zeroed as in the C++. sequence For each row of the batch, the index of its sequence in ``training``, shape ``(B,)``. Defaults to sequence 0 for every row. zero_first_column Reproduce the C++ ``AttentionShift``, whose ``.diag().zeros()`` on a one-row matrix zeroes the attended activation of node A (column 0) for every row, instead of each row's own node. ``False`` zeroes each row's own node. return_trials Also return the trial-by-trial probabilities, activations and errors. Returns ------- dict ``rare_sum`` ``(B, 8)`` and ``rare_count`` ``(B, 8)``: the sum and count of P(rare) over the test trials (code 2) of each stimulus of :data:`STIMULI`, ``weights`` and ``eta`` ``(B, 5, 5)`` after the last trial, and with ``return_trials`` also ``prob``, ``activations`` and ``error`` of shape ``(B, T, 5)``. """ P = params["P"].reshape(-1, 1, 1) ## attention normalization parameter alpha = params["alpha"].reshape(-1, 1, 1) ## weight learning parameter mu = params["mu"].reshape(-1, 1, 1) ## attention learning parameter rho = params["rho"].reshape(-1, 1, 1) ## attention shift parameter (step-size) beta = params["beta"].reshape(-1, 1) ## inverse temperature ## set up sizes, devices, and dtypes (float32 or float64) for the model run ## this sets up the parameters needed for pytorch to distribute the work B = int(P.shape[0]) ## determine batch size device, dtype = P.device, P.dtype n = len(NODES) ## the number of nodes (layers are fully connected) if training.ndim == 2: training = training.unsqueeze(0) ## move participant data to appropriate device (either CPU or GPU) training = training.to(device, dtype) ## S = number of participants (sequences) ## T = number of trials per sequence (participants) ## _ = number of columns (not needed) S, T, _ = training.shape ## if no sequence is given, e.g. only one participant is run, ## parameters are mapped to sequence 0 if sequence is None: sequence = torch.zeros(B, dtype=torch.int64, device=device) sequence = sequence.to(device) ## move ## create a mask to reset diagonals to 0 eye = torch.eye(n, device=device, dtype=dtype) ## identity matrix off = 1 - eye ## complement of an identity matrix ## copy initial attention and connection weights eta0 = eta0.to(device, dtype) * off weights0 = weights0.to(device, dtype) * off eta = eta0.clone() weights = weights0.clone() ## allocate empty tensors to store output for the current run(s) rare_sum = torch.zeros((B, len(STIMULI)), device=device, dtype=dtype) rare_count = torch.zeros((B, len(STIMULI)), device=device, dtype=dtype) if return_trials: prob_all = torch.zeros((B, T, n), device=device, dtype=dtype) act_all = torch.zeros((B, T, n), device=device, dtype=dtype) err_all = torch.zeros((B, T, n), device=device, dtype=dtype) rare_index = NODES.index("rare") first_column = torch.zeros(n, device=device, dtype=dtype) first_column[0] = 1.0 ## run model on all trials for t in range(T): ## extract trials row = training[sequence, t] # (B, 19) ctrl = row[:, 0] valid = ctrl != PAD ## reset weights for new participant or session reset = ((ctrl == 1) | (ctrl == 3)).reshape(B, 1, 1) eta = torch.where(reset, eta0, eta) weights = torch.where(reset, weights0, weights) x = row[:, COLSKIP : COLSKIP + n] ## extract input vector ## extract excitability vector (binary gates for which output node can respond) y = row[:, COLSKIP + n : COLSKIP + 2 * n] f = row[:, -n:] ## extract feedback vector ## if all excitability is 0, avoid numerical instability in softmax by setting all to 1 y = torch.where((y.sum(dim=1, keepdim=True) == 0), torch.ones_like(y), y) ## if no feedback is provided, the input vector is its own teaching signal f = torch.where((f.sum(dim=1, keepdim=True) == 0), x, f) # Equation 1, 2, 3: gated connection weights, self-connections removed gated, _ = _gating(x, eta, P) # Equation 4: calculate model predictions connection = weights * gated * y.unsqueeze(2) * off activation = connection.sum(dim=2) # (B, n) # Equation 5: calculate choice probabilities over excitable output nodes (gated softmax) power = torch.exp(beta * activation) * y prob = power / power.sum(dim=1, keepdim=True) ## check whether current trial is a valid test trials test = valid & (ctrl == 2) stim = row[:, 3].long() ## extract current trial for all rows in the batch ## save current outputs before learning: ## sums up all probabilities and then divide them by their counts ## to get the average probability of rare for each stimulus rare = torch.where(test, prob[:, rare_index], torch.zeros_like(prob[:, rare_index])) rare_sum.scatter_add_(1, stim.unsqueeze(1), rare.unsqueeze(1)) rare_count.scatter_add_(1, stim.unsqueeze(1), test.to(dtype).unsqueeze(1)) if return_trials: prob_all[:, t] = torch.where(valid.unsqueeze(1), prob, torch.zeros_like(prob)) act_all[:, t] = torch.where( valid.unsqueeze(1), activation, torch.zeros_like(activation) ) # learning on every trial that is neither a test trial nor padding learn = (valid & (ctrl != 2)).reshape(B, 1, 1) supervised = ((ctrl == 3) | (ctrl == 4)).reshape(B, 1) teacher = torch.where(supervised, x + f, x) # (B, n): the complete input + teacher present = (teacher != 0).to(dtype) # (B, n); convert all non-zero teacher to float ## Equation 9-10: ten recurrent attention shifts, all five nodes at once eta_p = eta for _ in range(SHIFT_ITERATIONS): gated_p, pnorm_p = _gating(teacher, eta_p, P) prediction = (weights * gated_p).sum(dim=2) # (B, n), output all ones error_p = teacher - prediction # (B, n); Equation 7: calculate prediction error salience = gated_p ** (P - 1) * teacher.unsqueeze(1) # (B, n, n) activations = weights * teacher.unsqueeze(1) attended = prediction.unsqueeze(2) * salience attended = attended * (1 - first_column) if zero_first_column else attended * off out = (activations - attended) * error_p.unsqueeze(2) delta_eta = torch.tanh(rho / pnorm_p.unsqueeze(2) * out * teacher.unsqueeze(1)) eta_p = (eta_p + delta_eta).clamp_min(ETA_FLOOR) ## Equation 11: state displacement (reconstruction error) update = mu * (eta_p - eta) * present.unsqueeze(1) eta_new = (eta + update).clamp_min(ETA_FLOOR) # Equation 12: the delta rule with the final saliences gated_final, _ = _gating(teacher, eta_new, P) prediction_final = (weights * gated_final * off).sum(dim=2) error = teacher - prediction_final delta_w = alpha * error.unsqueeze(2) * teacher.unsqueeze(1) * gated_final weights_new = (weights + delta_w) * off ## update weights for all current learning trials eta = torch.where(learn, eta_new, eta) weights = torch.where(learn, weights_new, weights) if return_trials: err_all[:, t] = torch.where(learn.reshape(B, 1), error, torch.zeros_like(error)) result: dict[str, torch.Tensor] = { "rare_sum": rare_sum, "rare_count": rare_count, "weights": weights, "eta": eta, } if return_trials: result.update(prob=prob_all, activations=act_all, error=err_all) return result
[docs] def evaluate( par: torch.Tensor, training: torch.Tensor, *, sequence: torch.Tensor | None = None, generator: torch.Generator | None = None, eta0: torch.Tensor | None = None, weights0: torch.Tensor | None = None, zero_first_column: bool = True, ) -> torch.Tensor: """The ``model`` for the PSP routine: 1. Evaluates OSCAR. 2. Adds a 0.5 anchor. 3. Computes P(rare) at test for A, B, BC and C. ``par`` holds P, alpha, mu, rho and beta in its columns, shape ``(B, 5)``. The initial saliences and weights are drawn afresh for every row, as ``evaluate()`` in imac.R does, unless ``eta0`` and ``weights0`` are given. Returns ``(B, 5)``. """ par = par.reshape(-1, len(OSCAR_PARAMETERS)) params = {name: par[:, k] for k, name in enumerate(OSCAR_PARAMETERS)} if eta0 is None or weights0 is None: eta0, weights0 = random_initial_state( par.shape[0], generator=generator, device=par.device, dtype=par.dtype ) out = oscar( params, training, eta0=eta0, weights0=weights0, sequence=sequence, zero_first_column=zero_first_column, ) means = out["rare_sum"] / out["rare_count"] columns = [STIMULI.index(s) for s in EVALUATED_STIMULI] anchor = torch.full((par.shape[0], 1), 0.5, device=par.device, dtype=par.dtype) return torch.cat([anchor, means[:, columns]], dim=1)
[docs] def imac( probabilities: torch.Tensor, thresholds: tuple[float, float] = (0.125, -0.125) ) -> torch.Tensor: """clobe's inequality matrix constructor as the PSP routine used it: :func:`psp.clobe.imac` with ``inclusive=True``. For ``probabilities`` of shape ``(B, m)`` returns ``(B, m, m)``: above the diagonal, 1 where ``p_i - p_j >= thresholds[0]``, -1 where ``<= thresholds[1]``, 0 between; 2 on and below the diagonal. A NaN difference stays NaN. """ return clobe.imac(probabilities, thresholds, inclusive=True)
[docs] def discretize( probabilities: torch.Tensor, thresholds: tuple[float, float] = (0.125, -0.125) ) -> torch.Tensor: """The ``discretize`` of the PSP routine: :func:`imac`, every cell 99 for a row with any NaN.""" matrix = imac(probabilities, thresholds) bad = torch.isnan(probabilities).any(dim=1).reshape(-1, 1, 1) return torch.where(bad, torch.full_like(matrix, 99.0), matrix)