Reproducing Hong et al. (2025)¶
This tutorial is accompanied by a
runnable script:
hong2025_reproduction.py.
How to run the script
An install of psyphy on its own is not enough: the figures here need a plotting backend, which psyphy does not depend on.
viz is matplotlib and nothing else. (There is also a broader examples
extra that adds seaborn and JupyterLab for the other pages; this one does
not need it.) The refit at the paper's settings wants an NVIDIA GPU .
Everything else on this page runs on a laptop.
Everything on this page comes from one script. Pick a mode by how much compute you want to spend:
To check the code path runs on your laptop before committing to any of that:
Can psyphy reproduce a published result, starting from the raw data? This page answers that for Hong et al. (2025): refit the model from their trials, invert it to discrimination thresholds, and compare against the figure they published. The answer is yes: our threshold contours fall inside the authors' own 95% bootstrap interval at all of their 49 reference points.
Disclaimer: Their experiment was about color, so this page is too; but the model and psyphy's implmentation generalize! See Scope below.
How this tutorial is laid out First, we introduce the task and show the headline result: the paper's Figure 2B, reproduced. Then we cover the practical parts, e.g., loading the published data, and building paper's model. The reproduction itself is then built up one question at a time, so that a disagreement at any point tells us where it came from:
- Given the published weights, do we compute the same covariance field?
- Given the published weights, do we recover the same threshold contours (Figure 2B)?
- Given only the raw trials, do we refit the same weights?
- Putting 2 and 3 together: from raw trials alone, do we reproduce the same published figure 2B?
- And is that agreement good enough, measured against the paper's own bootstrap confidence interval?
That order is backwards on purpose. Questions 1 and 2 hand psyphy the paper's own weights, so the expensive optimization doesn't run. If they fail, the bug is in our model code. Question 3 is the first that fits anything, and fitting is both the slow part and the part with the most ways to go wrong. Checking the cheap, deterministic parts first means that if question 3 disagrees, the optimizer is the only suspect left.
Who this is for
- You want a worked example of psyphy on real data, with an external ground truth to check against.
- You know the paper and want to see how psyphy reproduces it.
No familiarity with the model is needed to start. The next section introduces it at a high level. See the paper itself for the full reference:
Hong, F., Bouhassira, R., Chow, J., Sanders, C., Shvartsman, M., Guan, P., Williams, A. H., & Brainard, D. H. (2025). Comprehensive characterization of human color discrimination thresholds. eLife 14:RP108943. https://doi.org/10.7554/eLife.108943.2
Background — what the Whishart Psychophysical Process Model (WPPM) is¶
Measuring a discrimination threshold the usual way means fixing one color and asking, over many trials, how far a second color has to move before someone notices the difference. That tells you about one color. Repeating it across a whole plane of colors is impractical: too many locations and far too many trials, so we run into the curse of dimensionality.
The WPPM takes a different approach. It assumes the observer's internal noise changes smoothly across color space: nearby colors are confusable in similar ways. That lets us fit one smooth field over the entire space instead of many separate measurements, so every trial informs the whole picture. Once fit, we can evaluate the model at any point in stimulus space, including those we haven't tested!
psyphy implements the Wishart Psychophysical Process Model (WPPM) in general form: any number of stimulus dimensions, any task you can write a likelihood for. The color setup here is only one configuration of it, which is why this page doubles as an external check on psyphy and a worked example of the general pipeline. The WPPM approach carries beyond color to any domain where the noise limiting performance varies smoothly across the stimulus space.
Hong et al. collect each judgement from the human subjects with an oddity task: on
every trial the observer sees three stimuli (two identical, one different)
and picks the odd one out. Chance is therefore 1/3, and the threshold is placed
at the usual midpoint between chance and perfect performance,
P(correct) = 2/3. That is the 66.7% contour this page reproduces.
Scope
For this tutorial we will describe the WPPM in terms of color, because that is what Hong et al. measured. The WPPM itself is not specific to color: it models noise varying smoothly over any stimulus space, for any task you can write a likelihood for. See Recovering Weber's Law for a one-dimensional example, or the simulated-data walkthrough.
The result¶
Each ellipse is a Just-Noticeable Difference (JND) threshold contour around a reference color at its center: the smallest color difference this observer can reliably detect. Operationally, it is how far a comparison color must move from the reference before they pick it out as the odd one 66.7% of the time. It is an ellipse rather than a circle because sensitivity depends on direction; some color changes are easier to see than others of the same magnitude. The orientation and elongation of each ellipse are exactly what the WPPM estimates. We can also see that the sizes of the ellipses increase as you move away from the origin in the plot below, which corresponds to a gray stimulus. This is a reproduction of the Weber–Fechner law.
See Recovering Weber's Law for a worked example reproducing the classic Weber's Law result on simulated one-dimensional data.
Paper Figure 2B, reproduced end to end for subject 1 (CH). Colored ellipses are the contours psyphy recovers; dashed gray are the published ones. Each ellipse takes the color of its own reference stimulus (center dot). Nothing published enters this chain except the raw trials: psyphy fits the model's weights from those trials, and inverts the oddity task to turn the resulting noise field into 66.7%-correct thresholds (represented as ellipses). The axes are model dimensions, which arbitrary up to an affine transformation of the input (RGB) space.
The whole recipe¶
The block below is the short version: download one observer's data, load the paper's fitted weights, and turn them into threshold contours. It runs as it stands, on a laptop. The sections after it go through the same steps slowly, and add the refit that produces the figure at the top of this page, and reproduce the fit from scratch with psyphy's implementation.
Data¶
Psyphy makes it easy to download the published data:
| Download one observer's files | |
|---|---|
What each data file is, and how big
| File | Size | Used for |
|---|---|---|
trial_data_pooled_by_type_sub1.csv |
1 MB | trials, for the refit |
Bestfit_W_sub1.csv |
212 KB | fitted weights, plus 120 bootstraps |
Thres_ellipses_sub1.csv |
320 KB | the 7x7 grid and published thresholds |
Noise_ellipses_sub1.csv |
68 MB | published \(\Sigma_{\text{noise}}\) on a 103x103 grid |
load_trials loads in the published file and returns psyphy's TrialData object, so it will
work directly with our methods:
| Load the trials | |
|---|---|
The published data holds 12,000 trials in two equal halves: 6,000 AEPsych_*
rows (5,100 adaptive placement plus 900 Sobol) used for fitting, and 6,000
MOCS_* rows held out for validation. For this tutorial and the figures, we
only use the rows used for fitting (by default load_trials loads only the
rows used for fitting). Pass trial_types=("MOCS",) for the held-out half, or
trial_types=None for all 12,000.
Fitting all 12,000 trials does not reproduce the paper
It gives a plausible result that is not the published one.
For more information on how the authors did adaptive trial placement using the library AEPsych, we refer the reader to the paper.
Two conventions worth knowing when you inspect the loaded data
psyphy stores trials as a stimuli array of shape (N, K, d) — trials x
stimuli per trial x stimulus dimensions — alongside responses; see
TrialData.
Oddity trials are stored with K=2, not 3. Each trial shows three
stimuli (reference, reference, comparison)but only two distinct ones,
and K counts distinct stimuli. So data.stimuli comes back (6000, 2, 2)
for a three-interval task. The repetition is applied inside the oddity
likelihood rather than stored on every row.
Model¶
build_paper_model() assembles a WPPM from the settings the paper used, which
we transcribed once into PAPER_HYPERPARAMS.
The paper's hyperparameters in full
Grouped by what each one controls.
Exact check¶
does psyphy build the same covariance field Hong et al published?¶
With the data loaded and the model built, we start with the question that has no moving parts. Hand psyphy the paper's own weights and ask it for the covariance field: no optimizer and nothing random. If this disagrees, the problem is in the model implementation itself, and everything downstream would be built on sand.
In the above, we're simply computing the difference between our computed covariances and the values shared by the paper's authors, for all 42,436 ellipses. The maximum value of the differences are shown below:
Our values agree to all published didgits in 96% of the cases and, in the final 4%, only differ by +/- 1 in the last printed digit. This is agreement to the precision the file can express.
This runs as a test (test_covariance_field_matches_published_sigma_noise),
skipped automatically when the data has not been downloaded, so CI stays
network-free.
That settles the model implementation: given the same weights, psyphy builds the same field. The next question is whether we can turn that field into the thresholds the paper actually reports.
Thresholds (as in Paper Figure 2B)¶
The model is parameterized in \(\Sigma_{\text{noise}}(x)\), the covariance of the observer's internal representation. The paper reports thresholds, i.e., how much do we have to move in stimulus space, until the observer picks it out as the odd one 66.7% of the time. Those are different objects! The map between them runs in two directions, and only the forward pass is easy:
- Forward: given the noise at two points, how often does the observer get the trial right? That is what the model computes directly.
- Inverse: given that they get it right two-thirds of the time, how far apart were the stimuli? That is what Figure 2B plots and it is the direction with no closed form.
Written out:
There is no closed form for the inverse. For the 3-alternative oddity task the observer is correct when the two identical stimuli are nearer to each other than either is to the odd one:
where \(d_{ij}\) is the Mahalanobis distance between the internal representations of stimuli \(i\) and \(j\). This is the distance that measures separation in units of the noise itself, so a step counts as large only relative to how noisy the representation is in that direction. That probability has no analytic form, which is why the paper estimates it by Monte Carlo in the first place. So we have to compute the inverse numerically following the procedure given in the paper:
- Probe
n_thetadirections around each reference point. - Along each, evaluate
P(correct)atn_lengthdistances and keep the one closest to 2/3. We thus have one boundary point per direction. - Fit an ellipse to those
n_thetapoints. This step does have a closed-form solution and so can be done quickly.
Step 3 needs no optimizer, the ellipse fit is closed-form.
To compute this inverse using psyphy, we construct the WPPMPredictivePosterior
object with the threshold_pred argument set to True, passing it the relevant arguments.
Why the ellipse fit is closed-form
A point at radius r in direction u satisfies \(u^TΣ^{-1}u = 1/r^2\), which
is linear in the three free entries of \(Σ^{-1}\). So the fit is least
squares over those three unknowns, followed by a single matrix inverse to
recover \(Σ\) itself. No iteration, and nothing that can fail to converge.
We run the inversion at the paper's own settings (16 directions, 1,000 distances along each, 2,000 Monte Carlo samples per evaluation). That costs about 11 minutes on CPU for the 49 reference points.
Threshold settings
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 | |
300 distances and 500 Monte Carlo samples instead of 1,000 and 2,000,
which runs the same 49 reference points in roughly 20 seconds rather than
11 minutes. It is what --mode quick selects, and it is meant for checking
that the code path works and not for reproducing anything. The script prints
which preset is in effect when it starts.
The loading and the model are the same as in the recipe above; what is new here is asking the predictive posterior for thresholds rather than probabilities:
Both questions so far handed psyphy the paper's own weights, so neither has asked it to fit anything. That is the next step, which is the expensive part.
Refit¶
Does psyphy's fit find the paper's covariance field?¶
Everything above started from the paper's weights. The stronger question is: given only the paper's data, does psyphy's fit find the paper's covariance field?
The following block of code refits the WPPM's weights from the raw data, computes the covariance field and then plots resulting ellipses. Looking at the alignment of the ellipses in the figure below, the answer to that question is yes.
The fit is the only part that needs a GPU, so we write the weights to disk.
\(\Sigma_{\text{noise}}(x)\) for subject 1 (CH): dashed gray is the field
from the authors' published weights, red is our own MAP refit. This is the
paper's supplementary Figure S3.
Note: These ellipses look much like the ones at the top of the page, but they are a
different quantity.
\(\Sigma_{\text{noise}}(x) = U(x)U(x)^{\top} + \delta I\)
is the covariance of the observer's internal representation at stimulus
\(x\); the field the WPPM is
parameterized in, read off at each grid point. No task enters it. The
contours at the top are \(\Sigma_{\text{thres}}\), one step downstream: \(\Sigma_{\text{noise}}\) at a reference
and a comparison feeds the oddity likelihood to give P(correct), and that map
is inverted for the displacement at which P(correct) = 2/3. We use the same grid and
plotting convention, but \(\Sigma_{\text{noise}}\) is the model's parameters evaluated,
while \(\Sigma_{\text{thres}}\) is behavior predicted from them at a criterion, here 2/3.
End to end: from raw trials to Figure 2B¶
This is the figure at the top of the page, and this is where it comes from.
Each of the two steps before it held something fixed. The Figure 2B inversion started from the authors' published weights, so it tested our inversion. The refit went the other way: it fit weights from the raw trials, but only compared noise fields. Neither on its own shows that psyphy can get from raw data to the published figure, but they served individually as important implementation checks.
We now show that together psyphy can go:
raw trials -> fit weights -> derive threshold contours -> the published figure 2B
Plotting it
Both contour fields go onto one axes in a single
plot_ellipses call ( published dashed
underneath, ours on top, each ellipse colored by its own reference
stimulus)
scale comes from auto_scale(coords, thres_published) and colors from
hong2025.w2d_to_rgb(coords, M), the monitor calibration published with the
data. For per-ellipse colors, posterior draws and the rest of the API, see
Plotting ellipse fields.
66.7%-correct threshold contours for subject 1 (CH), computed from the weights we fit to the raw trials. There are no published weights anywhere in this chain. Dashed gray is the authors' published inversion; colored solid is ours, each ellipse taking the color of its reference stimulus.
Is that close enough? The paper's own bootstrap interval¶
How close is close enough? The authors answered that themselves. They resampled the trials 120 times, refit the model to each, and kept the 114 fits (95% of 120) that came out most like their original. The spread of those 114 contours is their 95% confidence interval and we check whether the threshold generated from psyphy's fit is comprised by that confidence interval in the figure below.
The figure below shows that our fit is indistinguishable from their run-to-run variaton at all 49 reference points and every direction tested, and in that sense psyphy's refit is indistinguishable from their fit.
Our end-to-end contours against the paper's own 95% bootstrap interval for subject 1 (CH). The gray band is the 114 retained bootstrap refits, dashed gray the published fit, colored solid ours.
Plotting it:
plot_ellipses takes a whole stack of fields at once, so all 114 retained
refits go on in a single call. The published fit and ours are drawn over
them in the usual convention.
Scope
These results are for one subject (CH, 1 of 8) and a single run on one GPU. They were not repeated for seed stability and not run for the other seven subjects. Read this as "the fitting pipeline reproduces the paper for this subject", not as a claim about all eight.
Runtimes¶
The full refit requires ~16 min on a single GPU. See the following table for a breakdown of how long each step takes.
Measured runtimes, step by step
CPU figures are an Apple Silicon laptop (M5); GPU is one A100 unless otherwise noted.
| Step | Hardware | Wall clock | Details |
|---|---|---|---|
| Exact covariance check | CPU | seconds | 10,609 points, deterministic |
| Thresholds, paper settings | CPU | ~11 min | 49 refs, n_theta=16, n_length=1000, mc=2000 (13.4 s per ref) |
Thresholds, fast preset |
CPU | 20–23 s | n_length=300, mc=500 — smoke tests only |
| Refit — full | 1 GPU | ~8 min | 6,000 trials, 1,500 steps, mc=2000, 3 restarts |
| The paper's own run | H100 | 14 h | one subject: main fit + 120 bootstrap refits |
The 14-hour figure is per observer, not for the whole paper. The WPPM is fit separately for each participant, and the 120 bootstraps resample that participant's own trials, so all eight observers is roughly eight times that.
Watch out for¶
- \(\Sigma_{\text{noise}}\) and \(\Sigma_{\text{thres}}\) are different things. The thresholds
above are \(\Sigma_{\text{thres}}\), as plotted in Figure 2B; the exact check and the
refit compare \(\Sigma_{\text{noise}}\), the noise field, which is plotted in
supplementary Figure S3. Both arrive as
(49, 2, 2)stacks on the same grid, which makes them easy to conflate. - The same seed gives the same answer on the same machine, but not necessarily on a different one. Re-running the inversion here is bit-identical: JAX's PRNG is deterministic given a key, so nothing changes between runs on the same machine. But what changes across machines is the floating-point arithmetic underneath: XLA reassociates or rewrite an expression, and a sum accumulated in a different order lands on a slightly different value (JAX FAQ). The exact check is unaffected, since it compares against a table rounded to 8 decimals. The thresholds and the refit can differ in their low-order digits between a laptop and a GPU
- Loss values are not comparable to the paper's. psyphy's
Prior.log_probdrops a constant, which the paper keeps (still identical gradients but different numbers)
See also¶
- Full WPPM fit (simulated data) — more on the model math with ground truth available
- Quick start — the minimal version
- Plotting ellipse fields —
plot_ellipseson its own, with synthetic data psyphy.data.published.hong2025in Data;WPPMPredictivePosteriorandThresholdConfigin Posterior.