Skip to content

Add the Log-Normal Race Model (lnr) on the shared race core (#352) - #501

Open
GidonFrischkorn wants to merge 21 commits into
dev-1.6.0from
race-lnr
Open

GidonFrischkorn wants to merge 21 commits into
dev-1.6.0from
race-lnr

Conversation

@GidonFrischkorn

@GidonFrischkorn GidonFrischkorn commented Oct 4, 2026 •

Copy link
Copy Markdown
Collaborator

Add the Log-Normal Race Model (lnr) on the shared race core (#352)

The Log-Normal Race Model (Rouder et al., 2015) for choice response times with two or more options. Each option has an accumulator whose finishing time is lognormal; the first to finish gives the response, and a non-decision time is added. lnr() has two versions: simple (one meanlog for the correct response and one shared by the errors, responses coded as integers) and custom (one meanlog per named response category, with optional per-category accumulators). The sdlog s is shared by all accumulators. Also adds dlnr(), plnr(), qlnr(), rlnr() and an article.

This PR now targets dev-1.6.0, since the race core (#499) is merged. It adds only the model's own files, plus its NEWS bullet, the article, its _pkgdown.yml entry and its references.

What changed since the last description

  • The article fits are no longer committed. The five cached fits of the article (one of them 3.3 MB, above the 1 MiB limit of the file-size check) moved to the article-fits release, as ADR 0003 asks for new fits. The article reads them as file = "fits/<name>.rds". I rewrote the branch so that no commit adds them, because the check looks at every commit, not only the tip. Patches, messages and authors of all other commits are unchanged.
  • Category names. The generated likelihood's local variables now carry an lnr_ prefix, so categories such as t, m or n no longer collide with them. The model itself reserves only rt and response. The names brms declares (n in the random-effects loop, seq/start/end/ptarget/nn under threading) are refused for every race model by the race core. That needs the small core PR that adds n, ptarget and nn, which is merged into this branch: merge that PR first. The generated Stan programs are identical to the old ones after mapping the names back.
  • Default priors. The random-effect SDs of ndt and s now get exponential(2) instead of exponential(4). That is the rate lba(), rdm(), ddm(), cswald() and ezdm() give the same parameters (one rate per parameter meaning). The meanlogs keep exponential(2).
  • Test fixture. The pp_check fixture had 5 of 150 divergent transitions. It is refitted with adapt_delta = 0.95 and now has none; its design is unchanged.

Checks (local, macOS, R 4.6.1)

  • Full test suite (NOT_CRAN=true, Stan tests on, 4 workers): 2012 tests, 0 failed, 0 skipped.
  • R CMD check --no-manual: 0 errors, 0 warnings, 1 NOTE. The NOTE ("Cannot extract version info" for NEWS.md) comes from the two development headings that ADR 0002 puts on dev-* branches. Every dev-* branch has it.
  • The file-size check failed on this PR after the retarget, because of the old fit. It passes on the rewritten branch: no commit adds a file above 1 MiB.

Not run: a render of the article (the fits must be in the release first), and the recovery and cross-validation scripts of the original branch. The likelihood has not changed since they ran.

Merging next to #500 and #502

The three model PRs merge in any order. The second and third conflict in NEWS.md (keep both bullets), NAMESPACE and man/bmm.Rd (run devtools::document()), and tests/testthat/test-conventions.R (keep both files' entries). This PR and #502 also both add a fixture block to tests/local/make-fixtures.R; keep both, each with its own closing brace.

For the reviewer

Most of the code was written with AI assistance and has had AI-assisted reviews. What it needs from a person is judgement on the model: the likelihood against the source, the parameterisation and defaults, and whether the documentation makes sense to a user. About 3 hours.

Pass 1: likelihood and Stan (about 1 h). R/model_lnr.R: the generated likelihood (.lnr_stan_code()) and configure_model(). The simple version's likelihood is the probability of the observed category (correct, or any error). It therefore differs from a per-accumulator likelihood by log(K - 1) per error trial, which shifts loo() but not the posterior. Is that the right choice for the default?

Pass 2: R distribution functions and postprocessing (about 1 h). R/distributions-lnr.R (dlnr(), plnr(), qlnr(), rlnr()), then log_lik, posterior_predict and posterior_epred in R/model_lnr.R. Tests: tests/testthat/test-distributions-lnr.R, test-model_lnr.R, test-model_lnr-stan.R (the Stan/R parity tests).

Pass 3: docs and article (about 1 h). ?lnr, ?lnr_dist, vignettes/articles/bmm_lnr.Rmd.

Skim or skip: man/, NAMESPACE, tests/testthat/helper-model-lnr.R (cases for the cross-model tests), the test-conventions.R counts.

Questions where an outside view helps most:

  1. s is shared by all accumulators. The distribution functions accept one per accumulator. Should the model offer per-category s, or say plainly that it does not?
  2. In the custom version every category gets the error prior normal(0, 0.5) for its meanlog. Should a fast category get a different default, or should the article show how to set one?
  3. A zero count is refused as a constant (accumulators = c(a = 0)) but allowed in a column. Is the asymmetry right?

Already checked, so no need to repeat: the rebuild from the original branch is bit-identical (dlnr/plnr/qlnr/rlnr, log_lik, posterior_predict, posterior_epred, pp_simulate, and the generated Stan code in six configurations). The generated Stan likelihood matches an independent R implementation to 9e-16 on trials with zero counts. Every identifier in the generated program was tested as a category name against stanc. The citation was checked.

…cture (#352)

Rebuilt from 352-log-normal-race-model (c01cfd1) on race-model-core: the model,
its distribution functions (now R/distributions-lnr.R), tests, article and
fixtures, with the coding, validation, revert, observables and epred
integrator taken from the shared race helpers. The model registers its
shared-test cases in helper-model-lnr.R and its column roles and response
annotations in its own file.

Changes against the branch: accumulator columns may give a category zero
accumulators on a trial it does not win (the likelihood and simulators
already skipped them); error messages of the accumulator checks follow the
shared wording; a category named Intercept, or after an identifier of the
generated likelihood (rt, t, m, n, lp, ...), is refused instead of failing
in stanc.
…ect the accumulators docs and article

Categories named start, end, seq, ptarget or nn are refused: brms declares them in threaded programs and stanc rejects the program.
plnr(q, response) no longer returns NaN near the lower support edge and no longer warns for a vector ndt. plnr(response = NA) fails with the dlnr message.
qlnr() works on the log survivor, so NA propagates and tiny upper-tail probabilities invert.
The accumulators docs say a column may hold 0 on trials the category does not win.
The lnr() examples run as written. The article gains a custom hierarchical example, a prior override example and reader-facing definitions.
New tests cover the Stan likelihood with zero counts (including a trial where the loser's log survivor underflows), the reserved names, log_lik with zero counts and the distribution fixes.
race-model-core now uses develop's test-case registry, which removed
load_ppcheck_fit(); the two tests that read the file by path load it the
same way (R5).
…ures.R

Rule R4 now requires sd_default, init and links cases; the values are the
ones default_prior() and settable_links() give, written out. The stored fit
becomes a fixture case and is made by tests/local/make-fixtures.R with one
chain and 150 draws (R5); the old file had two chains and 400 draws.
The core now lists brms's threading identifiers among the Stan reserved
words, so check_model() refuses them before it reaches the names of the
lnr likelihood.
The file's own skip_unless_cmdstan() did not honour BMM_TEST_STAN=false.
Base automatically changed from race-model-core to dev-1.6.0 October 18, 2026 11:31
The five cached fits of the LNR article move to the article-fits release
(ADR 0003): the article reads them from fits/, which the website build
downloads, instead of from tracked files in assets/.
`seq`, `start` and `end` are in the shared list of Stan reserved names, which
the category check reads before the model's own list, so the three entries in
the LNR list could never be reached. `ptarget` and `nn` stay: a threaded
program declares them and the shared list does not hold them. The test that
expects the core's message for the three names is unchanged.
The fixture had 5 divergent transitions in its 150 draws at the default
adapt_delta of 0.8. Refit at 0.95 with the same data, seed, chain and
draws, it has none (max Rhat 1.017).
The likelihood declared t, m, n, win, lp, reps, i and j, so a custom-version
category with one of those names was refused. The locals are now lnr_dt,
lnr_m, lnr_cnt, lnr_win, lnr_lp, lnr_reps, lnr_i and lnr_j; no category can
carry an underscore, so none can shadow them. The generated programs are
identical up to these names in eight configurations (simple and custom, with
random effects, threading, a fixed s, count columns, one category).

t, m, i, j, win, lp and reps are accepted as categories now. n stays
refused: brms adds random effects in a loop over n, and stanc then refuses a
category of that name.
ndt and s had exponential(4) on their group-level standard deviations, while
lba(), cswald() and ezdm() give the same parameters exponential(2) (ndt also
in ddm()). One rate per parameter meaning across the response-time models:
ndt and s now get exponential(2) too; the meanlogs keep it. The "Default
priors" section said the rates were shared with the other racing models,
which was not true against lba(); it now states the common rate and that the
between-subject SDs of 0.17 to 0.19 estimated for ndt and s on simulated data
lie well inside it.
n, ptarget and nn are now refused for every race model by the shared list, so the LNR list keeps only the generated likelihood's own arguments.
…o race-lnr

# Conflicts:
#	tests/testthat/test-conventions.R
The tests below draw random numbers and had no seed, so each CI run
tested different values. They now open with withr::local_seed(445), as
develop's tests do since the seeding change (ADR 0004, R8).
Resolved tests/local/make-fixtures.R as the union of the lnr and the mpt
fixture blocks; NAMESPACE and man/ regenerated with devtools::document().
built_data_columns() names the columns a model's check_data() adds, so
standata() routes newdata in the layout given to bmm() through check_data()
and pp_check(group = ) hides them (#550, #552). lnr declares .lnr_cat and
.lnr_n1 to .lnr_n<k>, two counts for the simple version and one per
category for the custom one, as race_bf() hands them to vint().

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[new-model] Implement Log-Normal Race (lnr) model

1 participant