bmmtools (development version)
recovery_grid()can split one grid over several R processes (#19). The setup call,cells = integer(0), writesgrid.rds, runs the preflight and the prior fits ofprior_sd, and simulates every cell, but fits none. Each process then runs the same call withcellsset to one element of the newgrid_tasks(grid, reps, n), which cuts the cells intonruns of consecutive cells. A cell keeps the seed and the file names the full grid gives it, so the slices together write the cells one sequential run writes, andcollect_grid()reads them as one grid. A slice checks its call againstgrid.rds, including the prior, model, formula, fitter arguments andprior_sdthe setup call recorded, and stops if they differ or if there is no setup. A slice may name fewercores; its sidecars then record thosecoresamong the sampler arguments, which no result reads. It never writesgrid.rdsorprior-sd.rds, and it never runs a preflight. A full run (cells = NULL) in adirthat a setup call wrote, with the setup call’s arguments, leavesgrid.rdsas it is. One with another prior, model, formula, fitter arguments (other thancores) orprior_sdrewritesgrid.rdswithout the key the slices are checked against, so later slices stop until the setup call is run again. One with anotherseedorrepskeeps the key: it rewritesgrid.rdswith its own values, and slices started after it has returned are checked against those; one with othercoreskeeps it too.cellsneeds a single number asseed; the component form andsmoke = TRUEdo not take it.A launcher for split grids ships in
inst/templates/vm/. You copy the files and editgrid-args.R, which holds the grid’s call once.launch.shruns the setup call, then starts K slices in the background undernohup, each logging to a file that ends withEXIT <status>. It sets K to the machine’s cores divided by chains × threads and refuses a larger K without--force.--statusshows each slice as finished, running or stopped,--rerun krestarts one, and a launch is refused while a slice of the last one still runs. One launch runs at a time: a lock in<dir>/logs/refuses a second launch while one sets up or starts the same grid, or while the setup call of a launch killed withkill -9still runs, and a lock whose launch and setup are both gone is taken over. Stopping the launcher stops its setup call. A slice counts as running while its R process does, also after akill -9on its recorded pid. Wherepsworks, a pid that another process has taken since (as after a restart, or by another grid’s slice) does not count, and akillof a slice’s pid stops its R and the processes R started, Stan chains included; without a workingpsthe launcher says so.--statusalso reports the setup call, a~indiris expanded as R expands it, and unknown options are refused. The new article “Running a grid on a cloud VM” walks through it, including a SLURM array sketch.Every
recovery_grid()call that reaches its cells now leaves a record in<dir>/run-info/(the component form does not), so a grid run by several processes says which process ran which cells.recovery_grid()andcollect_grid()return these records asattr(x, "run_info"), one row per call. A row holds the cells the call ran, how many sidecars it reused and how many fits failed, its start and end, the scheduler job and array task when SLURM, PBS, LSF or SGE set them, and everyrun_info()column. The end and the two counts areNAwhile the call runs, and stayNAfor a call that never finished its cells: a process that was stopped, or an R error outside a cell’s fit once the cells had started.run_info()itself is unchanged.plot_design()plots recovery across the designs of a grid: a metric against trials per subject on a log axis, one line per number of subjects and a panel per term (type = "profile"), for population means, SDs, subject parameters or model correlations; the share of fits passing the convergence gate and the checks the others failed, per design ("convergence"); and coverage pooled over every design per term and level ("coverage"). Seconds per fit and the pass rate are profiles too. Each error bar has one documented source: ±1 Monte Carlo standard error for bias, RMSE, interval width and contraction, a 95 % Wilson interval for rates, andr_lowtor_highforr(#43).The article “Recovering the EZ diffusion model on the standard grid” now opens each section with a figure from
plot_design()orplot_recovery(): the convergence gate and the seconds per fit by design, the population, SD, subject and correlation profiles, coverage pooled over the designs, and one replication of the smallest and largest design. Its tables are folded under the figures. It now says which parameter has the lowest bulk effective sample size across the fits (in most fits, passing the gate or not, a between-subject correlation), read from the stored fits by a new precompute step. It also gives the pooled coverage of the populationndt50 % intervals, which in this run are wider than their nominal mass. It checks its read-off of trials againstsmallest_design(coverage_check = TRUE)(#43).A fit is now reproducible from its
seed, which fixes the 0.3.0 known limitation. bmm draws the initial values of every chain from R’s random number generator inside the fit, andfit_cached()passed the seed to the sampler only, so two runs of a grid with the sameseedsimulated the same data but drew different chains.fit_cached()now calls the fitter underwithr::with_seed(seed), which restores the caller’s random number stream afterwards; so dobenchmark()around each fit andsbc()aroundSBC::compute_SBC(). On anezdmcell fitted with the cmdstanr backend the sameseednow gives identical initial values and identical draws, with 1 or 4 cores, and so does amixture2pfit with 2 cores under rstan 2.32.7 and cmdstanr 0.9.0, run in two fresh R sessions. All three functions refuse to fit under aseedlarger than.Machine$integer.maxin absolute value, which R’s generator does not take;fit_cached()still reads back a fit already cached under one. Not covered:brms::threading()with its defaultstatic = FALSEonce the data are split (static = TRUErepeats), the caller’sRNGkind()(a future withfuture.seed = TRUEuses L’Ecuyer-CMRG), and another platform’s random number arithmetic. Inbenchmark()with one data frame and aseed, every run is the same fit, so the interval on the ratios reflects only timing noise. The cache key is unchanged, so a fit cached by 0.3.0 or earlier is still read back as it was; the cache’s meta file and the fit’sbmmtools_cacheattribute now recordrng_seeded, which isNAfor those fits.The
ezdmentry ofvalidation_defaults()now runs its calibration (SBC) under a narrower prior than bmm’s:normal(0, 2)on the drift intercept and half-normal SDs (0.5 on drift, 0.3 on bound and non-decision time), scaled so that the entry’s SDs sit near the prior medians; the bound and non-decision time intercepts keep bmm’s priors. Under bmm’scauchy(0, 1)drift intercept and exponential SD priors, 2 of 8 calibration fits failed the card’s convergence criterion (Rhat 2.13 and 1.06), 4 had divergences (up to 109) and 5 hit the maximum tree depth (up to 499 of 500), at parameter draws such as a drift of -31 and an SD of 2.03 on log bound. The entry records why it replaced bmm’s priors, and the card prints it. Under the narrower prior the 100-simulation calibration passes its ECDF check but still fails the convergence criterion, on 5 of 100 fits: 4 at Rhat above 1.05 (up to 1.63) and 2 at a rank ESS ratio below 0.5 (one fit both), the two worst at small boundaries, whose intercept prior is still bmm’s. How to answer that is open. The entry’s prior predictive verdict is now judged under this prior instead of bmm’s defaults. The entry’s version is now 0.2.0.The
mixture2pentry’s reason for its narrower calibration prior now names the bmm build it was measured on: the halfstudent_t(3, 0, 2.5)SD prior on log kappa that madermixture2p()overflow is a development build’s (1.4.1.9000, built 2026-09-04), while the defaults of bmm 1.4.1 from CRAN (anormal(2, 1)kappa intercept andexponential(1)SDs) rarely reach such values. The prior itself is unchanged and kept, so that the calibration does not move with bmm’s defaults. A reason is not part of the plan hash, so a stored calibration is read back as it was.The card states the prior the calibration ran under, its rows or “bmm’s default priors”, with its reason or the words that none was given, and that a calibration speaks for the region of its prior only while the recovery stage fits under bmm’s default priors. For a stale calibration it shows the current plan’s prior and says the stored result may have run under another; for an excluded, not applicable or not built calibration it describes no prior there. The prior predictive section says which prior set its verdict is judged under, or that it was not judged, and lists the calibration’s prior itself when its draws used it and no calibration section lists it. The SBC convergence criterion now says that its value counts failed checks, not fits: a fit failing on Rhat and on rank ESS counts twice.
In
validation_plan(), asbc$priorother than the entry’s given without aprior_reasonno longer inherits the entry’s reason, so the card does not explain a prior that did not run; the entry’s own prior given again keeps it. Aprior_check$rangeand itsrange_reasonfollow the same rule. Aprior_reasonwithout aprior, or arange_reasonwithout arange, is an error, and so is a reason that is notNULLor one string with more than white space in it.run_validation()refuses apriorin its..., andvalidation_plan()refuses one insampler,sbc$samplerandprior_check$sampler, for every plan, including one that runs the recovery stage only; an abbreviation such aspri, whichbmm::bmm()would match toprior, is refused too.run_validation()also checks the sampler lists of the plan it is given, so a plan read back from an olderwrite_plan()or edited by hand is refused in the same way. Given there, a prior reached every recovery fit, which the card says fits under bmm’s default priors, or collided with the prior the prior check and the calibration pass themselves, so that they failed.The reasons (
sbc$prior_reason,prior_check$range_reason) are no longer part of a plan’s hash or of a stage’s key, so rewording one reruns nothing. Plan hashes, and the keys of the prior predictive and calibration stages, change once because of this: a run directory made before it reruns those two stages once underrefit = "on_change", and reads them as stale underrefit = "never".render_card()renders the validation card of arun_validation()result to one self-contained HTML file: the headline, the run record and the plan, each stage’s verdict beside its criterion, a section per stage, and the numbers that moved since an earlier run. Every number on the card is read off the stored result; the template types none. It needs the quarto package and the Quarto CLI. The recovery and individual-differences criteria pool each parameter’s intervals over the designs and fail it by a one-sided binomial test atcoverage_alpha(0.05) over the number of parameters; held design by design, a calibrated model failed them most of the time at 10 replications. Theezdmentry checks prior predictive mean response times against a plausibility range of 0.15 to 5 s (#21).run_validation()runs the data requirements stage: the smallest number of trials at which every subject-varying parameter’s person-level correlation with its generating value has a lower bound (r_low) of at least 0.80 at 50 subjects, read off the recovery grid bysmallest_design(), with the read-off at 0.70 and 0.90 beside it. Both the bar and the number of subjects are criteria of the plan. Its coverage condition is the recovery stage’s pooled test: held design by design, it changed or removed the read-off of a calibratedezdmmodel in 24 % to 56 % of runs at 10 replications. The stage is not applicable to a model without a subject-varying parameter or a grid without two numbers of trials at that many subjects (#21, #17).
bmmtools 0.3.0
Breaking changes
-
A recovery saved by 0.2.0 no longer satisfies the recovery contract. A
bmmtools_recoverylacksci_low_50,ci_high_50andcovered_50(#1) andpost_mean_link,post_sd_link,zandcontraction(#16); abmmtools_cor_recoverylacks the same columns butcontraction. Printing or summarising one is an error that names the missing columns. The link-scale moments need the draws, so there is no in-place fix: runrecovery_grid()again on the grid’s directory, which re-extracts every cell from its cached fit without refitting, or callrecover(),recover_subjects()orrecover_correlations()on the fits again. -
A summary saved by 0.2.0 still prints but lacks the columns 0.3.0 adds:
coverage_50,ci_width_50andcalibration_intercept(#1), the z-score and contraction columns (#16), the reliabilities, the Monte Carlo standard errors of the rates,ci_leveland a grid’s design columns (#17). Summarise the recovery again to get them. -
criterionofsdt_ynmeans something else under bmm 1.4.1. A simulation or grid stored under 0.2.0 with a development build of bmm holdscriteriontruths measured from the midpoint between the noise and signal means; bmm 1.4.1 estimatescriterionfrom the noise mean, so scoring such truths against a new fit reads as bias. Simulate again withbiasinparsto keep the old quantity (#30). - SBC results for
sdt_yn,sdt_mafcandm3with numbers of options from 0.2.0 were computed on fewer trials thandatadescribed (#26), and a fit cached without abackendis refitted once (#24); both are described under their changes below. -
summary()of a recovery refuses an argument it does not know instead of ignoring it (#17).
Planning a study
simulate_recovery(),recovery_grid(),sbc(),prior_check()andfit_ml(method = "optim")now work without ageneratorfor two more bmm models (#8). The four-parameterezdmsimulates its response-time summaries per boundary fromzr; a boundary reached fewer than twice leaves themNA, and bmm (from 1.4.1) keeps such rows.sdt_rankingtakes its set sizemfrom the model, or per row from a column oftrial_designwhenmnames one, and each row’s number of trials fromn_trialsthere. Itssdratiois the log of the SD ratio, so the adapter hands bmm’s generator the ratio itself.sdt_ratingandsdt_cdpstill need ageneratorof your own: bmm does not yet export the map from their parameters to thresholds. A model version bmmtools does not know now gets no adapter rather than another version’s, so a later version ofezdmasks for agenerator.sbc()now reads anNAcount inm3data with numbers of options as 0, as bmm does, and takes the row’s number of trials as its total over the other counts. It used to stop with an error that the row’s total was not a whole number of trials.A new article, “Recovering the EZ diffusion model on the standard grid”, reports a recovery study of the three-parameter
ezdmunder bmm 1.4.1: 20 or 50 subjects, 30, 100 or 300 trials, 10 replications each. It covers population values, between-subject SDs, subject parameters and the drift-boundary correlation. It also says how many trials a person-level correlation of 0.70, 0.80 or 0.90 needs. The Monte Carlo standard errors, reliabilities and that read-off are computed by hand in the article untilsummary()reports them (#17), and a validation card will replace the article (#21).A validation of a bmm model is now one plan and one run.
validation_plan()resolves everything a validation needs before anything is fitted: the generating values, a grid of designs with its replications, the sampler budget, the design point and prior of the simulation-based calibration, the prior-check layout, an optional reference implementation, the criteria, the seed, the owner and a version, with a content hash that changes only when what is planned changes.validation_defaults()holds the values a plan fills from; entries ship formixture2pandezdm, and a model without one needspars.run_validation()runs the stages in order (run record, prior predictive check, SBC, recovery grid, computation, identifiability, individual differences, agreement) into one directory, each cached under its own folder. A rerun reads every stage whose inputs did not change, a stage that fails is recorded with its message while the others run, and stages that do not exist yet (data requirements, model recovery, posterior checks) are listed as not run with their issue. The result keeps every stage’s own object and addscard_numbers(), every number a card prints with its Monte Carlo standard error,verdict_table(), each criterion in words with its value and verdict, and a headline that says “validated” only with the plan version beside it.compare_validations()flags the numbers that moved between two runs by more than their Monte Carlo error (#21, #9).bmmtools works with bmm 1.4.1, which offers
criterionandbiasofsdt_yn()andsdt_rating()as alternative parameterizations, of which a fit estimates one. It read both as free parameters, sosimulate_recovery()demanded a value forbiasandrecovery_formula()wrote a formula bmm rejects. Now one member of each alternative group is free: the oneparsnames, or bmm’s default,criterion. Naming both is an error, and the other member appears in no truth table, formula or score.recovery_formula()gainspars, whose names choose the member (recovery_formula(sim$model, pars = sim$pars));recovery_grid(), the components andfit_ml()in a grid follow each cell’spars,sbc()andfit_ml()follow their formula (orfit_ml()’sstart), and a formula that estimates the other member is refused before anything is fitted. Thesdt_ynadapter and density passbiasto bmm when it is the chosen member (#30).bmmtools now declares
bmm (>= 1.4.1)in Suggests, the first CRAN release with the signal-detection models. Note what bmm 1.4.1 changed forsdt_yn:criterionis measured from the noise mean, and the criterion measured from the midpoint between the noise and signal means, which development builds of bmm calledcriterion, is nowbias. A script written against such a build that simulatesc(d = , criterion = )keeps running and simulates a different quantity; writebiasto keep the old meaning (#30).A benchmark module compares implementations of a model on the same data:
benchmark()runs them interleaved (A, B, A, B) and returns one row per run,benchmark_metrics()measures a single fit, andbenchmark_compile()times a forced compile on its own. Compile time and sampling time are kept apart, and every rate divides by seconds summed over chains rather than by wall time, which would make the same fit look cheaper on more cores: a run reportsus_per_gradient(the median over chains of chain sampling time over that chain’s leapfrog steps) and ESS per chain-second for bulk and tail, over the post-warmup phase and over warmup plus sampling.summary()reports the spread beside the centre per implementation, and the ratio between implementations as a geometric mean of per-pair ratios with a t interval on the logs – never a bare quotient. No benchmark fit is cached, so none can be served to a recovery grid (#4).Estimates, recovery rows and correlation estimates gain
post_mean_linkandpost_sd_link, the posterior mean and SD of the draws on the link scale. They stay on the link scale whateverscalea recovery is scored on, because neither is invariant under a nonlinear link;estimatestays the posterior median. Maximum- likelihood rows carry the point estimate and its standard error, andpointcorrelationsNA. They are the groundwork for posterior z-scores and contraction insummary(). A grid cell file without them is re-extracted from its cached fit on the nextrecovery_grid()run, without refitting;collect_grid()reads it withNAand says so (#16).Recovery rows gain
z, the posterior z-score(post_mean_link - truth) / post_sd_link, andcontraction,1 - post_sd_link^2 / prior_sd_link^2, both on the link scale;summary()reportsz_mean,z_sdand the meancontraction, withz_mean_mcseandz_sd_mcse, and its print shows the first three aftercoverage_50. Together they tell a posterior that covers the truth because the data located it from one that covers it because it never left a prior that already held it. The prior SD comes from the newprior_sdargument ofrecover()andrecovery_grid(): a table, a fit withsample_prior = "only", or a one-setprior_check();recovery_grid(prior_sd = "fit")fits the prior once per distinct prior set and caches it, and"analytic"uses the newprior_sd_table(), which gives the prior SD in closed form for normal, logistic and Student t priors, their halves on standard deviations, and the exponential prior bmm puts on them.collect_grid()reuses the table the grid scored with and takesprior_sdto replace it. Without a source,contractionisNA; subject rows and maximum-likelihood rows never have one. Correlation recoveries gainzand the z summary columns. The article “Recovery summary columns” defines them and shows, on a grid with one to 100 trials per subject, intervals that cover the truth while the posterior is still mostly the prior (#16).summary()of a recovery reports how reliably a design measures individual differences, on subject rows:reliability_true, the squared correlation of the posterior means with the generating values, andreliability_model, which needs no truth and so is the number real data can report –var(mean) / (var(mean) + mean(sd^2))for posterior means and1 - mean(se^2) / var(estimate)for maximum-likelihood rows. Both are computed per replication on the link scale and averaged;reliability_gapis their paired difference, which shows whether the model’s own reliability can be trusted for that design. Each comes with a Monte Carlo standard error, and so now do the rates:coverage_mcseandcoverage_50_mcse(binomial at the nominal mass, so a coverage of 100 % still has one; between replications on subject rows),detected_mcseandsign_recovery_mcse. The print shows both reliabilities when there are subject rows (#17).summary()of a recovery takesprecision, a target width for theci_levelinterval, and reportsprecision_target, the share of intervals no wider than it (precision_met) and that share’s Monte Carlo standard error. Targets are a table ofterm,levelandwidth(optionally percondition), or a named vector for population rows (precision = c(kappa = 1)), and are read on each row’s own scale. On subject rows the share is over every subject interval – the chance that one participant’s interval is that narrow – with an MCSE between replications.summary()now refuses an argument it does not know instead of ignoring it (#17).smallest_design()reads the smallest design of a grid that meets a target, such asc(r_low = 0.8)orc(reliability_model = 0.8, precision_met = 0.8): the fewest trials (or any other grid column) at which the target is met, per number of subjects, and met at every larger value in the grid too, so noise that lets 30 trials pass and 100 fail does not answer 30. A design meets a target when the lower end of its 95 % Monte Carlo interval clears it – the Wilson bound for rates, so a perfect rate certifies 0.80 only from 16 replications on – and the answer says when the grid’s smallest design already met it, when the target was not reached, and when the point estimate meets it but the replications do not, with the number of replications a perfect rate would need.coverage_check = TRUEalso requires population coverage within one MCSE of nominal. To support it, a grid summary now carries the grid’s design columns aftercondition(n_subjects,n_trials, …), and shows them in its print, and every summary aci_levelcolumn (#17).The README and DESCRIPTION name both groups bmmtools serves: model developers and methodologists, and researchers planning a study. A new README section, “Planning a study”, shows how
recovery_grid()andsummary()already answer how many subjects and trials a design needs (#14).A new article, “Recovering the EZ diffusion model on the standard grid”, reports a recovery study of the three-parameter
ezdmunder bmm 1.4.1: 20 or 50 subjects, 30, 100 or 300 trials, 10 replications each. It covers population values, between-subject SDs, subject parameters and the drift-boundary correlation. It also says how many trials a person-level correlation of 0.70, 0.80 or 0.90 needs. The article computes its Monte Carlo standard errors, reliabilities and that read-off by hand, as it was written beforesummary()andsmallest_design()reported them (#17); a validation card will replace it (#21).
Score recovery
- Every estimate now carries a central 50 % interval,
ci_low_50andci_high_50, beside theci_levelinterval: the 25th and 75th percentiles of the draws, a Wald interval atqnorm(0.75)standard errors onfit_ml(method = "optim"), and a Fisher-z interval for thepointcorrelation estimator. Recovery rows gaincovered_50, andsummary()of a recovery or a correlation recovery gainscoverage_50andci_width_50: the 95 % interval checks the tails of the posterior, the 50 % interval its centre. The mass is fixed and carried in the name, so it does not followci_level. An estimates tibble without the inner bounds still scores, withcoverage_50NA.cross_check()keeps one interval. -
recovery_grid()stores the 50 % interval in each cell’s file. A cell file written before it is re-extracted from its cached fit on the next run, without refitting;collect_grid()reads such files with the inner columnsNAand says how many there were. -
summary()of a recovery object andrecovery_ccc()gaincalibration_intercept, aftercalibration_slope: the intercept of the generating value regressed on the estimate, so that the two columns give the whole calibration line. It is read on the scale of the row, and at subject level the replications’ intercepts are averaged. It isNAwherever the slope is (#1). - Printing a recovery summary shows a core set of columns (the row,
n,bias,rmse, both coverages,r,cccand the calibration columns) and names how many it hides;tibble::as_tibble()prints all of them. The summary itself still has every column. Tracked in #1. - Estimates, recovery rows and correlation estimates gain
post_mean_linkandpost_sd_link, the posterior mean and SD of the draws on the link scale. They stay on the link scale whateverscalea recovery is scored on, because neither is invariant under a nonlinear link;estimatestays the posterior median. Maximum- likelihood rows carry the point estimate and its standard error, andpointcorrelationsNA. They are the groundwork for posterior z-scores and contraction insummary(). A grid cell file without them is re-extracted from its cached fit on the nextrecovery_grid()run, without refitting;collect_grid()reads it withNAand says so (#16). - Recovery rows gain
z, the posterior z-score(post_mean_link - truth) / post_sd_link, andcontraction,1 - post_sd_link^2 / prior_sd_link^2, both on the link scale;summary()reportsz_mean,z_sdand the meancontraction, withz_mean_mcseandz_sd_mcse, and its print shows the first three aftercoverage_50. Together they tell a posterior that covers the truth because the data located it from one that covers it because it never left a prior that already held it. The prior SD comes from the newprior_sdargument ofrecover()andrecovery_grid(): a table, a fit withsample_prior = "only", or a one-setprior_check();recovery_grid(prior_sd = "fit")fits the prior once per distinct prior set and caches it, and"analytic"uses the newprior_sd_table(), which gives the prior SD in closed form for normal, logistic and Student t priors, their halves on standard deviations, and the exponential prior bmm puts on them.collect_grid()reuses the table the grid scored with and takesprior_sdto replace it. Without a source,contractionisNA; subject rows and maximum-likelihood rows never have one. Correlation recoveries gainzand the z summary columns. The article “Recovery summary columns” defines them and shows, on a grid with one to 100 trials per subject, intervals that cover the truth while the posterior is still mostly the prior (#16).
Simulate
-
simulate_recovery(),recovery_grid()andrecovery_component()gaintrial_design, the per-trial variables a generator needs and the model does not hold, such as set sizes and non-target locations: a data frame shared by every subject, a data frame with anidcolumn giving each subject its own trials, or a function ofn_trialscalled once per subject under the seed. Each generator call receives its rows as a fourth argument,trial_design, and the same rows are bound into the data. Without it nothing changes: a generator written for 0.2.0 is called as before, and a seeded simulation gives the same data and the same cache key.sbc()takes the design from itsdatafor a model whose built-in generator reads one (#15). -
simulate_recovery()has built-in generators formixture3pand forimmin all three versions (full,bsc,abc). Both read the set size, the non-target locations and, forimm, the distances per trial fromtrial_design, and draw each trial with the weights bmm’s likelihood gives it.prior_check()knows their response range.mixture3p’sthetatandthetanthave bmm’ssoftmaxlink, which has no inverse for one term: they reach a generator on the link scale, andrecover(),recover_subjects(),cross_check(),subject_table()and the correlation scorers keep them there underscale = "natural"with a message, where they used to stop with an error;print()andplot_recovery()name those terms (#15). -
simulate_recovery()has a built-in generator form3in all three versions (ss,cs,custom). With numbers of options on the model, a subject has one row of category counts overn_trialstrials; withnum_optionsnaming columns, those columns come fromtrial_designand each row is one trial. The custom version’s activation formulas exist only in the formula it is fitted with, sosimulate_recovery()gainsformula, read by that generator alone;recovery_grid(),recovery_component()andsbc()pass on the formula they fit with. A generator you write never receives it and is called as before. Categories land in the columnsresp_catsnames however the activations or the numbers of options are ordered.prior_check()takes each row’s total as the ceiling of every category’s count (#15). -
sbc()simulates a count model (sdt_yn,sdt_mafc,ezdm, andm3with numbers of options) with the trials each row ofdataholds. Before, its default generator gave every row as many trials asdatahad rows per subject: 2 forsdt_ynand 1 forsdt_mafcandm3, so SBC results for these three models from 0.2.0 were computed on a smaller design thandatadescribed, andezdmcould not run, with an error that blamed the prior. Rows may now hold different numbers of trials, such as 100 signal and 50 noise trials, and a count that cannot be simulated is an error before the prior is fitted. The per-trial models are unaffected: their simulated data sets are the same as in 0.2.0. For this,simulate_recovery(),recovery_grid()andrecovery_component()take an optionaltrial_designfor these four models, one design row per row of data with its own number of trials (and, forsdt_yn, its stimulus); without one, nothing changes (#26). -
coding = "contrast"with the default contrasts (stats::contr.treatment), or any contrast matrix with column names, now recovers: the matrix’s column names made brms call the contrasttask2where the truth saystask1, and scoring stopped with “No term intruthmatches an estimated parameter”. The column names are dropped, so the contrast is always<task_col>1…<task_col>(k-1). Unnamed contrasts such asbayestestR::contr.equalpriorwere not affected (#1).
bmm 1.4.1
- bmmtools now declares
bmm (>= 1.4.1)in Suggests, the first CRAN release with the signal-detection models. Note what bmm 1.4.1 changed forsdt_yn:criterionis measured from the noise mean, and the criterion measured from the midpoint between the noise and signal means, which development builds of bmm calledcriterion, is nowbias. A script written against such a build that simulatesc(d = , criterion = )keeps running and simulates a different quantity; writebiasto keep the old meaning (#30). - bmmtools works with bmm 1.4.1, which offers
criterionandbiasofsdt_yn()andsdt_rating()as alternative parameterizations, of which a fit estimates one. It read both as free parameters, sosimulate_recovery()demanded a value forbiasandrecovery_formula()wrote a formula bmm rejects. Now one member of each alternative group is free: the oneparsnames, or bmm’s default,criterion. Naming both is an error, and the other member appears in no truth table, formula or score.recovery_formula()gainspars, whose names choose the member (recovery_formula(sim$model, pars = sim$pars));recovery_grid(), the components andfit_ml()in a grid follow each cell’spars,sbc()andfit_ml()follow their formula (orfit_ml()’sstart), and a formula that estimates the other member is refused before anything is fitted. Thesdt_ynadapter and density passbiasto bmm when it is the chosen member (#30).
Running a study
-
fit_cached()keys a fit run without abackendon the Stan toolchain bmm actually uses: thebrms.backendoption if set, otherwise cmdstanr if it is installed, otherwise rstan. Before, it assumed rstan, so a fit bmm ran with CmdStan was keyed on the rstan version: a CmdStan upgrade did not invalidate it, and an rstan upgrade refitted it. Every cache written without abackend, without thebrms.backendoption, and with cmdstanr installed is refitted once, with a message naming thetoolchaincomponent; this includesrecovery_grid()cells and the prior fit ofsbc()andprior_check(). A grid cell’s stored estimates carry its key too, so a grid whose fit files were deleted after it ran is refitted, not re-read. Calls that passbackend, or set the option, as every article anddata-raw/script does, keep their keys (#24). - The help page of
recovery_grid()gains a section “Backend”,?sbcpoints to it, and “Running a recovery study” a paragraph: use cmdstanr for grids and SBC. Under rstan every fit compiles its model again (19–23 s per fit, measured for a smallmixture2pgrid); cmdstanr compiles once and reuses the executable, andoptions(cmdstanr_write_stan_file_dir = )keeps it across R sessions (#18). - “Running a recovery study” gains a section on a combined design: one grid that recovers population intercepts, a contrast-coded task effect, between-subject SDs, correlated subject values and their correlations, reported as one table per estimand family, from 20 sampled fits of
mixture2p(#1).
Comparing implementations
- A benchmark module compares implementations of a model on the same data:
benchmark()runs them interleaved (A, B, A, B) and returns one row per run,benchmark_metrics()measures a single fit, andbenchmark_compile()times a forced compile on its own. Compile time and sampling time are kept apart, and every rate divides by seconds summed over chains rather than by wall time, which would make the same fit look cheaper on more cores: a run reportsus_per_gradient(the median over chains of chain sampling time over that chain’s leapfrog steps) and ESS per chain-second for bulk and tail, over the post-warmup phase and over warmup plus sampling.summary()reports the spread beside the centre per implementation, and the ratio between implementations as a geometric mean of per-pair ratios with a t interval on the logs – never a bare quotient. No benchmark fit is cached, so none can be served to a recovery grid (#4).
Validating a model (experimental)
- A validation of a bmm model is now one plan and one run.
validation_plan()resolves everything a validation needs before anything is fitted: the generating values, a grid of designs with its replications, the sampler budget, the design point and prior of the simulation-based calibration, the prior-check layout, an optional reference implementation, the criteria, the seed, the owner and a version, with a content hash that changes only when what is planned changes.validation_defaults()holds the values a plan fills from; entries ship formixture2pandezdm, and a model without one needspars.run_validation()runs the stages in order (run record, prior predictive check, SBC, recovery grid, computation, identifiability, individual differences, agreement) into one directory, each cached under its own folder. A rerun reads every stage whose inputs did not change, a stage that fails is recorded with its message while the others run, and stages that do not exist yet (data requirements, model recovery, posterior checks) are listed as not run with their issue. The result keeps every stage’s own object and addscard_numbers(), every number a card prints with its Monte Carlo standard error,verdict_table(), each criterion in words with its value and verdict, and a headline that says “validated” only with the plan version beside it.compare_validations()flags the numbers that moved between two runs by more than their Monte Carlo error (#21, #9). - The six validation functions above are experimental, and their help pages say so: no validation card has been made with them yet, so their arguments, the stages a run holds and the shape of its result can still change.
Infrastructure and documentation
- The R-CMD-check workflow now checks bmmtools against both released bmm and bmm’s
developbranch (weekly on a schedule, and on every push and pull request), and installs SBC sotest-sbc.R’s 47 previously-skipped tests run on the runner. Signal-detection tests guard per model with the newskip_if_no_bmm_model(), replacingskip_if_no_bmm_sdt(), which only ever checked forsdt_yn. Tracked in #7. - The README states what bmmtools reads off a fitted bmm model beyond its exported API (
$links,$fixed_parameters,$resp_vars,$other_vars, and the generator adapters), and why a bmm parameter rename can break an adapter. Tracked in #10.
Known limitations
- A grid cell is not reproducible from its
seedalone. Two runs of the sameezdmgrid with the sameseedsimulated identical data and passed the same sampler seed, but gave different chains: estimates differed by up to 0.036, and 2 of 360 population interval verdicts changed. The likely cause, not yet confirmed, is that bmm draws the initial values from R’s random number generator, whichfit_cached()does not seed. A cached fit is read back as it was; a refit is a new draw.
bmmtools 0.2.0
Simulate
-
simulate_recovery(coding = "contrast")andrecovery_grid(coding = "contrast")generate a task design as an intercept and a contrast rather than as cell means, withcontraststaking any contrast matrix — a design’s own, or one ofbayestestR’s equal-prior codings. The truth is transformed with the design, so the intercept and the effect have true values, true SDs and a true correlation matrix of their own, andrecovery_formula(coding = "contrast")writes the matching formula.contrastsis deliberately not an argument ofrecovery_formula(): a factor’s contrast matrix reaches brms through the data, not through the formula. -
"effect"joins thelevelvocabulary ofextract_estimates(),recover()andrecovery_grid(), so a contrast coefficient can be scored on its own, away from the intercept it shares a parameter with. It is kept on the link scale, as"sd"is. - A generator and a density adapter for bmm’s censored-shifted Wald (
cswald), both thesimpleand thecriskversion, bringing the adapter table to seven models.bmm::rcswald()takes the boundary separation of the diffusion it draws from, while thesimpleversion’s ownboundis the distance from an unbiased start to one boundary, so the adapter converts between them: generated data profiles back to the model’sbound, not to twice it.
Running a study
run_info()records the machine and the toolchain a study ran on — R version, platform, host, cores, CPU model, memory, the installed versions of bmm, brms, cmdstanr, posterior and bmmtools, and the CmdStan version — as one row, so a runtime table can say what the times it reports were measured on. It never errors: a field whose source is missing on the platform isNA.collect_grid()rebuilds whatrecovery_grid()returned from the files it left behind, without fitting anything and without reading a fit. The per-cell sidecars hold everything that was scored, so a study can run on a server and be assembled on a laptop that has neither the fits nor the versions of bmm, brms and Stan that produced them. It is a reader, not a resume: it checks neither the cache key nor the version, and a resume is still rerunning the identical call in the same directory.scale,levelsandcorrelationsre-score the stored rows, so a grid run on the link scale can be read again on the natural one.recovery_grid(convergence = )givescheck_convergence()its thresholds, checked before any cell runs. The gate is computed once per cell and its whole diagnostics row is stored in the cell’s sidecar, soattr(x, "cells")now carriesmax_rhat,min_ess_bulk,min_ess_tail,n_divergent,n_max_treedepth,n_variablesandfailed, together withfit_seconds,chains,iterandthreads— all of it without reading a fit.elapsedkeeps its meaning: how long the cell took in this run, read time included, whilefit_secondsis how long the fit itself took.recovery_grid()writes<dir>/grid.rds, the record of what the grid was called with, which is whatcollect_grid()reads. A directory reused for a different design has its record rewritten, with a message naming what differs.fit_cached()records how long the fit took. The time is written to<file>.meta.rdsbeside the fit and comes back in thebmmtools_cacheattribute asseconds, including when the call reused a cached fit, so a resumed study still reports the time each fit once took rather than the time it took to read it. The time is deliberately not part of the cache key: a key component that changed with every run would differ from every stored key and refit everything. A fit cached before this release has no meta file and reportsNA.
Comparing estimators
fit_ml()estimates a model subject by subject with no pooling and returns the estimates in the same tibble shape a hierarchical fit gives, sorecover_subjects()can score both against one truth. It optimises bmm’s own generated likelihood throughalgorithm = "laplace"and re-implements no model. Flat priors are the default, so the mode is a maximum-likelihood estimate rather than a penalised one;prior = "default"keeps bmm’s priors. It exists to measure what hierarchical estimation buys, not to recommend maximum likelihood for inference.fit_ml(method = "optim")is a second route to the same estimates, maximising bmm’s R density for the model directly withoptim()and taking its interval from the Hessian (ci_method = "wald"). It needs no compiler and no Stan, which makes it the route that runs anywhere, and it optimises each subject independently, so one subject at a boundary cannot stall the rest. It is capped at the models bmmtools carries a density for — the same seven the simulation adapters cover — andnllsupplies one for anything else. Measured against the Stan route on 40 subjects ofmixture2p: the same estimates to Monte Carlo error, standard errors agreeing to a mean ratio of 1.000, and the samecoverageandcalibration_slopeto three decimals, in 0.35 s against 8 s.With
method = "stan", the default,fit_ml()runs one fit for the whole data set rather than one per subject. Underp ~ 0 + idthe log posterior is a sum of per-subject terms, so the joint mode is the vector of per-subject modes; this was checked against independent optimisation of bmm’s own density and agreed to 3e-06 on the link scale.A subject whose estimate leaves a finite range on the link scale, or whose optimiser reports failure on the
optimroute, is reported withestimate = NAandconverged = FALSE, never dropped: the recovery metrics drop incomplete pairs silently, so dropping would score maximum likelihood on the easiest subjects and the hierarchical fit on all of them.attr(x, "ml_cells")andprint()carry the per-subject counts.recovery_grid(ml = TRUE)fits every cell subject by subject as well, on the same simulated data, and scores both estimators against one truth at the subject level, so the shrinkage comparison can be read over a whole design rather than one data set. A Stan-route ML fit is cached beside the cell’s other files; either route’s rows go into the sidecar with the request that made them, and a resume reads them back without refitting. A cell whose ML fit fails keeps its hierarchical rows and is named in a warning, with the per-cell record inattr(x, "ml_cells").ml = list(...)passes arguments tofit_ml(), soml = list(method = "optim")runs the whole design without a compiler. The sidecar records which route made its rows and a resume that asks for the other one refits.A new article, “Hierarchical estimation against subject-wise maximum likelihood”, runs the comparison end to end on
mixture2p: one data set fitted both ways and scored against one truth, then the same contrast over a grid of trial counts. It is where the estimator table of “Recovery summary columns” stops being a conjugate toy.Recovery objects carry an
estimatorcolumn, andsummary()groups by it. Two estimators of the same parameter — a hierarchical posterior and a subject-wise maximum-likelihood fit, say — can be bound together and scored against one truth without being pooled into a single bias and RMSE.extract_estimates()gains anestimatorargument (default"bayes"), and a hand-built estimates tibble may carry the column itself.plot_recovery(color_by = "estimator", annotate = TRUE)labels each panel once per estimator instead of refusing to annotate it.recover_subjects()warns when two estimators were not scored on the same subjects, because the metrics drop incomplete pairs in silence and the rows would otherwise not be comparable.
Breaking
-
summary()on a recovery object now returns its rows in the order the estimates arrived rather than sorted by term, so that adding a second estimator does not reshuffle the rows of the first. A summary of one estimator holds the same numbers as before, in a different order. -
estimatoris a required column of the recovery contract, so abmmtools_recoveryobject saved by 0.1.0 no longer satisfies it. Restore one withdplyr::mutate(old, estimator = "bayes"). Objects built byrecover(),recover_subjects()andrecovery_grid()are filled automatically and are unaffected.
Score recovery
-
summary()reportsmae, the mean absolute error, besidermse. The two differ in how much one badly recovered subject moves them, which is worth seeing rather than inferring. - Population and effect rows gain
detected, the share of intervals that exclude zero, andsign_recovery, the share that exclude zero and fall on the side of it the truth is on. At a true value of zerosign_recoveryisNA, because zero has no sign, anddetectedis then a false-positive rate rather than power — a page reporting them has to say which of the two a row is. Subject rows carry both columns asNA.
bmmtools 0.1.0
First release. bmmtools validates cognitive measurement models fitted with bmm: one engine — simulate, fit, score — and a scorer per question.
Simulate
-
simulate_recovery()turns a bmm model and population values on the link scale into a data set and the truth that produced it. Subject values can be correlated (cors) and drawn together with observed covariates (covariates);pars,sdsandcorsmay be functions evaluated under the seed.tasksandtask_colgive every subject every task, so each free parameter has one value per task under the termkappa_task1, brms’s name for the cell mean. The truth lists the population values, the between-subject SDs, every correlation pair, the subject values and the covariates. -
cors_from_factors()builds a correlation matrix from factor loadings. -
recovery_formula()writes the formula a recovery study needs: a random intercept overidfor every free parameter, correlated across parameters withre_cor = "all", within each parameter’s tasks withre_cor = "within". Withtask_colit writes cell-means formulas such askappa ~ 0 + task + (0 + task || id). -
recovery_component()andsimulate_components()simulate several models for the same simulated people, so parameters of different models can be correlated. The subject values of all components and the covariates are one multivariate normal draw, and terms are prefixed with the component name. - Six generator adapters ship —
sdt_yn,sdt_mafc,ezdm,ddm,mixture2pandsdm— and a model without one works the same way once you supplygenerator.
Fit and run a study
-
fit_cached()fits once and refits only when something that determines the fit changes. The key covers the formula, data, model, prior, seed, sampler settings, backend and the installed versions of bmm, brms and the Stan toolchain, and the message names what changed. -
fit_components()fits each component of a set on its own, with an optional prior per component. -
check_convergence()summarises rhat, bulk and tail ESS, divergent transitions and tree-depth hits into one row and a pass verdict. A fit that fails is still scored, and the verdict travels with the estimates as theconvergedcolumn. -
recovery_grid()runs the loop over a design grid of subjects, trials and replications: one durable file per cell, a resume that needs no temporary state, a smoke mode, a preflight fit that catches a compile error before any cell runs, and cells ordered so the first completed block spans the design. Each cell writes a sidecar holding everything scored from the fit, so a resume can read the sidecars and the fits can be deleted. Grid columns set population values, SDs, task terms and correlations per row, andmodel,formula,pars,sdsandcorsmay be functions of the row. A list of components asmodelsimulates one set per cell and fits every component separately.
Score recovery
-
extract_estimates()returns the population- and subject-level estimates of a fit as a tibble;level = "sd"andlevel = "cor"add the between-subject standard deviations and correlations on the link scale, with correlations namedkappa__thetatwhichever order brms used. -
extract_subject_draws()returns the per-draw subject values as an array of iterations, chains, subjects and parameters. -
recover()andrecover_subjects()score estimates against the generating values, on the natural scale by default.recover(level = c("population", "sd"))also scores the SDs, always on the link scale. -
summary()of a recovery reports bias, RMSE, credible-interval coverage, interval width and correlations, including Lin’s concordance with a 95% interval, its accuracy factor, the scale and location shifts, a calibration slope and the spread of the generating values. At subject level the concordance is pooled across replications on Lin’s Z scale; the geometric-mean pooling of the scale shift and slope has not been checked by simulation. -
extract_correlations()estimates between-subject correlations three ways — the model’s own group-level correlation, the per-draw correlation across subjects, and the correlation of posterior means — andrecover_correlations()scores them against both the generating correlation and the one the simulated subjects actually had. Itssummary()adds the rate at which intervals exclude zero, as a false-positive rate or as power. Across separate fits these correlations are attenuated by the reliabilities of both estimates, and the documentation says which routes are not. -
subject_table()puts true and estimated subject values side by side, one row per subject, for a simulation and its fit or for a whole grid. It is the input for a structural equation model of true against estimated values. -
recovery_ccc()computes the same concordance columns for any pair of vectors. -
inverse_link()transforms values from the link scale for the twelve links bmm uses.
Prior predictive checks
-
prior_check()samples from the prior only and summarises the prior-predictive draws on the scale of the response: floor and ceiling rates, a quantile profile, or a statistic you write yourself. Several prior sets can be compared in one call.
Correctness
-
sbc()runs simulation-based calibration for a bmm model: it fits the prior once, turns its draws into data sets, fits each of them, and returns the SBC package’s ownSBC_results, soSBC::plot_rank_hist()and the rest apply unchanged.levelpicks which draws are ranked — the population parameters, the between-subject SDs, their correlations, and withlevel = "subject"the subject-level draws, one per subject and varying parameter. Whatever is ranked, everything the formula implies is always drawn from the prior and simulated from, because the ranks are uniform only when the data come from the joint prior. -
sbc(generator =)lifts the restriction onformula. The function receives everyb_,sd_,cor_andr_draw of the prior fit as one named vector, under the fit’s own draw names, and returns the data frame to fit, so any formula works and the truth names equal the draw names by construction. -
sbc()says what went wrong. When a prior draw is one the model cannot generate from, the error names the simulation and what that draw held. When any fit exceeds bmmtools’ rhat 1.05 or falls below SBC’s rank-ESS 0.5, one warning reports both counts and names SBC’s stricter rhat. -
cross_check()compares a fit’s estimates with a reference — a closed form such asbmm::sdt_d(), another implementation, or published values — and returnsbias, whether the fit’s interval covers the reference and whether the two intervals overlap, in the same tibble shape the other scorers return. The reference is a comparison, not a truth, and the documentation says so.
Plots
-
plot_recovery()plots estimates against generating values, one panel per parameter, with credible intervals and a line at equality, and withannotate = TRUElabels each panel with r and the concordance. It is a generic and also plots a correlation recovery, coloured by estimator, and a cross-check, with the reference on x. -
plot_prior_check()plots prior-predictive draws against the observed data.
Data, documentation and infrastructure
-
recovery_mixture2pandprior_check_sdt_ynare example results, so the examples run without Stan. - A pkgdown website with six articles: https://www.gfrischkorn.org/bmmtools/.
- A hex logo,
man/figures/logo.png. - DESCRIPTION carries
Remotes: hyunjimoon/SBC, sopakandremotesresolve the SBC entry in Suggests; SBC is not on CRAN. - Licensed under GPL (>= 2), compatible with bmm’s GPL-2.
- The signal-detection adapters resolve bmm’s
rsdt_yn(),rsdt_mafc(),dsdt_yn()anddsdt_mafc()by name at call time. No released bmm exports them, so a literalbmm::rsdt_yn()madeR CMD checkreport a missing object wherever a released bmm was installed. Behaviour is unchanged where the functions exist, and an absent one is now a named error rather than R’s bare “not an exported object”.
Known limitations
- bmm’s default prior on the group-level SD of a log-link parameter such as
kappais wide enough thatbmm::rmixture2p()can fail on draws from it, sosbc()onmixture2pneeds a prior given explicitly. The walkthrough article shows one. -
cross_check()takes one fit and compares at the population level;"sd"and"cor"references, and a list of fits, are not supported. -
subjects = "fixed"is an error for component sets.
