Full WPPM fit (end-to-end) — simulated 2D data¶
This tutorial explains what the example script full_wppm_fit_example.py is doing, and where the key functions live in the psyphy codebase.
- Goal: Fit a spatially varying covariance field \(\Sigma(x)\) over a 2D stimulus space \(x \in [-1,1]^2\) using the Wishart Process Psychophysical Model (WPPM).
- Data: synthetic oddity-task responses simulated from a ``ground-truth'' WPPM.
- Inference: MAP (maximum a posteriori) optimization of the WPPM parameters.
You can treat this as a ``recipe'' for using the Wishart Psychophysical Process Model (WPPM) in your own project: build a model, initialize parameters, fit the model, and visualize fitted predicted thresholds.
NOTE: Running this script takes about 3 min on a A100 40GB. If you want to accelarate it to for example run it on a CPU, decrease the number of MC-Samples significantly (e.g., 5) and the number of steps the optimizer is running for.
What the Wishart Psychophysical Process Model (WPPM) is in a nutshell¶
WPPM defines a covariance matrix field \(\Sigma(x)\) over stimulus space (e.g. color represented in RGB). Intuitively, \(\Sigma(x)\) describes the local noise/uncertainty ellipse around stimulus \(x\) where stimulus within that ellipse will be perceived as identical to the human observer.
The model represents \(\Sigma(x)\) as
where \(U(x)\) is a smooth, basis-expanded matrix-valued function and \(\varepsilon\) is a small diagonal “jitter” (diag_term) to avoid numerical issues. Alternatively, in Gaussian Process (GP) terms, you can think of \(U(x)\) defining a GP in weight space, i.e., a "Bayesian linear model".
A psychophysical task model (here: OddityTask) uses \(\Sigma\) to compute probability of a correct response on each trial, and MAPOptimizer fits WPPM parameters by maximizing
For more details on how the Wishart Psychophysical Model (WPPM) works, and the psychophysical task used in this example, please checkout the paper by Hong et al (2025) and this tutorial.
Step 0 — Imports and setup¶
The following imports are important to set the model up and fit to date:
| Ground-truth model + prior sample | |
|---|---|
Step 1 — Define the prior (how weights are distributed initially)¶
The WPPM parameters are basis weights stored as a dict:
params = {"W": W}
where W is a tensor of Chebyshev-basis coefficients.
Prior distribution over weights¶
See src/psyphy/model/prior.py:
Prior.sample_params(key)samples weightsWfrom a zero-mean Gaussian with a degree-dependent variance.
For 2D, the weight tensor shape is
where:
- \(d\) =
basis_degree - \(D\) =
input_dim(here 2) - \(E\) =
embedding_dim = input_dim + extra_embedding_dims
The prior variance decays with basis “total degree”. In code:
Prior._compute_basis_degree_grid()constructs degrees \(i+j\) (2D) or \(i+j+k\) (3D).Prior._compute_W_prior_variances()returns
Prior.sample_params(...)then samples
This is the state of the WPPM: before any data, WPPM draws smooth random fields because high-frequency coefficients are shrunk by the decay.
A sample from the prior
Step 2 — Build the model (WPPM + task + noise)¶
We create a model by combining its prior and likelihood.
Note that the task inherently defines the likelihood. Hence, think of them interchangebly.
In psyphy, think of model as simply a container of the prior and the likelihood.
- All prior specific hyerparameters are owned by the Prior.
- Likewise, all likelihood specific hyerparameters are owned by the task.
- besides being the container for prior and likelihood, the model also takes some compute specific arguments, such as diag_term, which ensures numeric stability by ensuring positive-definite matrices.
Step 3 — Evaluate the covariance field \(\Sigma(x)\)¶
The example uses a convenience wrapper:
A sample from the prior
What field(x) does¶
At a high level:
- Input:
xwith shape(D,)or(..., D). - Output: covariance matrix/matrices \(\Sigma(x)\) with shape
(..., D, D).
Mathematically:
- Compute a basis feature vector \(\phi(x)\) (Chebyshev basis products).
- Form a matrix
(where indices suppressed; the actual tensor contraction is done via einsum).
- Produce
In the code, the name “sqrt” is often used for \(U(x)\): it is a square-root factor of the covariance (up to the diagonal term).
If you’re looking for the implementation details of the “sqrt” computation, search in
src/psyphy/model/wppm.pyfor a helper named like_compute_sqrt(or similarly named). That’s where you’ll find theeinsumcontraction turningWand basis features intoU(x).
Corresponding code block in the example¶
- Field wrapper construction:
truth_field = WPPMCovarianceField(truth_model, truth_params)init_field = WPPMCovarianceField(model, init_params)-
map_field = WPPMCovarianceField(model, map_posterior.params) -
Batched evaluation:
gt_covs = truth_field(ref_points)
Step 5 — Fit with MAP optimization¶
We fut parameters with SGD + momentum:
| Fitting with psyphy (MAPOptimizer) | |
|---|---|
What is being optimized¶
MAP fitting finds
- \(\log p(\theta)\) is from
Prior.log_prob(params)(seeprior.py). - \(\log p(\mathcal{D}\mid\theta)\) is computed by the task’s log-likelihood (here via Monte Carlo inside
OddityTask.loglik).
The result in this example is a MAPPosterior object that contains a point estimate map_posterior.params.
Step 6 — Visualize fit vs. truth vs. prior sample¶
Fitted ellipsoids overlayed with ground truth and model initialization, a sample from the prior.
Learning curve.
Minimal recipe (copy/paste mental model)¶
To use WPPM on your own data, these are the essential calls:
- Create task + noise + prior:
task = OddityTask()noise = GaussianNoise(sigma=...)-
prior = Prior(input_dim=..., basis_degree=..., extra_embedding_dims=..., decay_rate=..., variance_scale=...) -
Create WPPM:
-
model = WPPM(input_dim=..., prior=prior, task=task, noise=noise, diag_term=...) -
Initialize parameters:
-
params0 = model.init_params(jax.random.PRNGKey(...))(draws fromPrior.sample_params) -
Load/build a dataset:
-
data = ResponseData(); data.add_trial(ref=..., comparison=..., resp=...) -
Fit:
-
map = MAPOptimizer(...).fit(model, data, init_params=params0, ...) -
Inspect \(\Sigma(x)\):
field = WPPMCovarianceField(model, map.params)Sigmas = field(xs)
Notes and pitfalls¶
- CPU vs GPU: this example can be heavy because the oddity likelihood uses Monte Carlo. A GPU can help a lot.
- Positive definiteness:
diag_termis important. If you ever see a non-PD covariance, increasediag_termslightly. - MC variance: optimization stability depends on
MC_SAMPLES. Too small means noisy gradients.
Next places to explore¶
- Read the API docs in
docs/reference/(especially model + inference sections). - Inspect
src/psyphy/model/prior.pyif you want to change smoothness/regularization. - Inspect
src/psyphy/model/covariance_field.pyif you want faster / vmapped field evaluation patterns.
If your curious about some of the implementation details, checkout these files:¶
- Example script:
docs/examples/wppm/full_wppm_fit_example.py - Prior (how weights are initialized / regularized):
src/psyphy/model/prior.py - Model definition:
src/psyphy/model/wppm.py(seeWPPM) - Covariance field wrapper:
src/psyphy/model/covariance_field.py(seeWPPMCovarianceField) - Task / likelihood:
src/psyphy/model/task.py(seeOddityTask) - Noise model:
src/psyphy/model/noise.py(seeGaussianNoise) - MAP fitting:
src/psyphy/inference/map_optimizer.py(seeMAPOptimizer) - Data container:
src/psyphy/data/dataset.py(seeResponseData)
If you want to “follow the call graph”:
WPPM.init_params(...)(defined insrc/psyphy/model/wppm.py) → delegates to the prior’sPrior.sample_params(...)(defined insrc/psyphy/model/prior.py).OddityTask.predict_with_kwargs(...)/OddityTask.loglik(...)(defined insrc/psyphy/model/task.py) → calls into the model to get \(\Sigma(x)\) and then runs the task’s decision rule (Monte Carlo in the full model).WPPMCovarianceField(model, params)(defined insrc/psyphy/model/covariance_field.py) → provides a callablefield(x)that returns \(\Sigma(x)\) for single points or batches.MAPOptimizer.fit(...)(defined insrc/psyphy/inference/map_optimizer.py) → runs gradient-based optimization of the negative log likelihood.