Source code for superstats.simulation.cognitive.cdm
"""Circular Diffusion Model simulator."""
import numpy as np
from numba import njit, prange
[docs]
@njit(parallel=True, fastmath=True)
def sample_cdm(
v_angle: np.ndarray,
v_length: np.ndarray,
a: np.ndarray,
tau: np.ndarray,
sigma: float = 1.0,
dt: float = 0.001,
max_steps: int = 10000,
) -> dict[str, np.ndarray]:
"""Sample from the Circular Diffusion Model (CDM).
Simulates a 2D diffusion process starting from the origin, with a
constant drift specified in polar form, evolving until it crosses a
circular boundary of radius `a`. The crossing point determines the
response angle and the number of steps determines the response time.
On each trial the drift vector has length `v_length` and points in
direction `v_angle`; the two Cartesian components diffuse independently
with noise SD `sigma` until the squared radius reaches `a ** 2`.
Parameters
----------
v_angle : np.ndarray of shape (num_trials,)
Direction of the drift vector (in radians) for each trial.
v_length : np.ndarray of shape (num_trials,)
Magnitude of the drift vector for each trial. The Cartesian drift
components are `v_length * cos(v_angle)` and `v_length * sin(v_angle)`.
a : np.ndarray of shape (num_trials,)
Radius of the circular decision boundary for each trial.
tau : np.ndarray of shape (num_trials,)
Non-decision times for each trial.
sigma : float, optional, default: 1.0
Diffusion noise standard deviation, shared by both Cartesian
components. Fixed (not estimated per trial) for identifiability,
since the boundary radius `a` and drift set the overall scale.
dt : float, optional, default: 0.001
Time step size.
max_steps : int, optional, default: 10000
Maximum number of diffusion steps per trial before timing out.
Returns
-------
data : dict of np.ndarray
Named decision data. `"response_time"` contains response times
(or -5.0 on timeout) and `"choice"` contains response angles in
radians (or -5.0 on timeout). Each array has shape (num_trials,).
"""
num_trials = v_angle.shape[0]
response_time = np.empty(num_trials)
choice = np.empty(num_trials)
noise_scale = sigma * np.sqrt(dt)
mu_cos = np.cos(v_angle)
mu_sin = np.sin(v_angle)
for idx in prange(num_trials):
mu0 = v_length[idx] * mu_cos[idx]
mu1 = v_length[idx] * mu_sin[idx]
a_i = a[idx]
a_sq = a_i * a_i
x0 = 0.0
x1 = 0.0
rt = -5.0
angle = -5.0
for i in range(max_steps):
x0 += mu0 * dt + noise_scale * np.random.randn()
x1 += mu1 * dt + noise_scale * np.random.randn()
if x0 * x0 + x1 * x1 >= a_sq:
rt = tau[idx] + (i + 1) * dt
angle = np.arctan2(x1, x0)
break
response_time[idx] = rt
choice[idx] = angle
return {"response_time": response_time, "choice": choice}