Repository navigation
mpt(): design-fixed covariates and impossible categories (MPT 3/6, #255) - #486
Merged
Merged
Conversation
GidonFrischkorn
force-pushed
the
255-mpt-design
branch
from
October 4, 2026 08:06
39f5fb1 to
95a26ff
Compare
mpt() and dmpt()/rmpt() gain covariates = (data columns used in branch expressions, with a row-wise branch-sum check in check_data). mpt_tree() gains impossible =: Poss_ row indicators and a -100 linear predictor switch a category off for the trees that cannot produce it; pp_check() and conditional_effects() skip the Poss_ columns, and update() drops them before rebuilding the data.
…and impossible categories (#255) The core now errors on trees whose branches do not sum to 1 and warns on missing counts. Under covariates, a tree such as Gcorr / Gother sums to 1 only with the observed values, so mpt() still warns for trees that use covariates and the row-wise check in check_data() now errors instead of warning. Under impossible categories, a missing count in a category the row's tree cannot produce is a known 0 (oberauer_lewandowsky_2019_e1 records dist as NA in its two no-distractor conditions), so it no longer counts toward the warning.
GidonFrischkorn
force-pushed
the
255-mpt-design
branch
from
October 4, 2026 18:45
95a26ff to
225b0d2
Compare
The restriction-constant error said impossible branches are not supported; at this level mpt_tree(impossible = ) exists, so the message points to it.
check_data() evaluated the branch sums of covariate trees with every parameter at 0.5, where D and 1 - D coincide, so a swapped complement passed and fitted to a wrong value. The data check now shares the construction check's test points. It also stops when a branch is <= 0 or > 1 for a row (a zero branch points to mpt_tree(impossible = )) and when a covariate is NA in a row of a tree that does not use it, which brms would otherwise drop.
The ^Poss_ prefix filters in pp_check() and conditional_effects() also hid user predictors whose names start with Poss_, in any model. Both now drop the names in the model's possibility indicators.
…checks (#255) The four test points cycled four values, so parameters four places apart always took the same value and a typo between them passed. Each symbol now gets its own value from a golden-ratio sequence. check_data() also checks branch ranges with all parameters near 0 and near 1, where a covariate slightly below 0 first pushes a branch out of (0, 1]. An NA covariate is reported as missing before the sum check, and only for covariates some branch uses.
…he tree's covariates (#255) brms evaluates every tree's branch term on every row, so a branch that divides by a covariate equal to 0 in another tree's rows made the likelihood NaN; check_data() now stops there and the NA advice no longer says any value works. Branches that underflow to 0 at the near-boundary points pass. Sum and range messages list only the covariates the tree uses, and point covariate-free trees to their branch expressions.
# Conflicts: # R/model_mpt_expressions.R # tests/testthat/test-model_mpt_expressions.R
The message claimed that 0 or 1 turns a branch into the constant 0, which is false for D = 0 in the 2HTM. It now says the constant must lie strictly between 0 and 1 and points to the reduced tree with mpt_tree(impossible = ).
#255) Models whose branches use covariates got a false identifiability warning (2HTM with g * x, or x * a + (1 - x) * b), and a covariate could be listed as a free parameter. Covariates are now data, and the rank is taken over the covariate values in the data (five test values when printing). The count line says that it holds per design cell when a tree uses covariates.
… the parameters (#255) A non-finite or missing covariate value gave a spurious identifiability warning before check_data()'s own error, and a tree without rows still counted when it used no covariates. With predictors and covariate data, the message pointed to print(), which takes the covariates at test values; it now names the parameters and says at which values the derivatives vanish.
# Conflicts: # R/model_mpt.R # tests/testthat/test-model_mpt.R
…() (#255) mpt() warned that a valid tree such as D + (1 - D) * Gcorr, (1 - D) * Gother did not sum to 1, because synthetic covariate values need not satisfy Gcorr + Gother = 1. It is now silent for trees that use covariates; check_data() still stops when the observed values do not sum to 1.
…s' rows (#255) When the data had no rows for a tree, check_data() skipped that tree entirely, so a branch such as (1 - D) / ss with ss = 0 in the other trees' rows passed and the fit failed in Stan. check_data() now stops with the not-finite message; only the checks on the tree's own rows are skipped.
…255) dmpt(x, pars, model, tree, FALSE) bound FALSE to covariates and silently returned the log density. log (dmpt) and unpack (rmpt) now come right after tree, as in dm3() and rm3(); covariates must be named.
A symbol given in two of pars, covariates and the further arguments silently took the first value. dmpt() and rmpt() now stop and name the duplicated symbols.
…eck (#255) A valid branch (1 - D)^n underflows to 0 at the interior test point D = 0.85 from n = 392, and check_data() then refuses the data. Such sizes are accepted as unrealistic; the comment at the range test says so.
…ds (#255) ?mpt did not say when the covariate values in the data stop bmm(), nor that posterior_predict(newdata = ) needs the columns check_data() builds. Two sentences now cover both.
…rmpt() (#255) Unnamed values in the further arguments were reported as "The value(s) of '' are given more than once". The check now looks at named values only.
The 20-setting spread could drop the one setting that identifies a parameter and report a false 'not identified' warning. A subset can only lower the rank, so every distinct setting is used. The settings are evaluated in one vectorized pass per tree, so 10 000 distinct settings take 12 ms in check_model().
Brings in core's fix round 2 (0f9f9e9 .. 8dc5e4c) through constraints, plus constraints' own 83e27b4 simplex half and 84d05ac docs. Seven conflict hunks in four files; in each one design's side carried the later text from the #486 fix rounds (corner test points, left-out non-finite derivatives, the chunked QR, from_data) and constraints' side carried core's round 2. - NEWS.md: both design bullets kept, the restrictions bullet taking constraints' "file of equalities and constants". - mpt_tree()'s scientific-constant message: core's per-magnitude rescaling example and its paste() of the now pre-quoted .mpt_written_constants(), with design's covariates-column remedy appended. - the @details identifiability paragraph: core's narrowing rule on top of design's sentences about which parameters are named, left-out test values and the count bound; man/mpt.Rd regenerated, not hand-edited. - check_model.mpt(): design's structure (count_only, one unchecked, lead, from_data, .mpt_rank_deficit_text()) with constraints' reached and its simplex-stick loop grafted in. Design's nl_pars clause of unchecked is now gated on the deficit's own parameters being reached, which is core's F1 fix; without a rank there is no involved set, so any non-linear formula still counts there. - .mpt_tree_branch_errors()'s header: design's corner comment with core's concrete underflow example. One change beyond the merge: the large-constant expectation in test-model_mpt_expressions.R anchored the message's end at core's rescaling example, which is no longer last now that the covariates remedy follows it; the expectation matches the full ending instead.
…255) One conflict, the restriction-constant message: constraints' new wording with design's covariate remedy appended, in the order of design's tree message. Probe: both remedies give valid Stan.
# Conflicts: # NEWS.md # tests/testthat/test-model_mpt.R
Take the simplified identifiability messaging from the core branch. The rank is taken at interior test values and, for models with covariates, at five test values of each covariate; bmm() no longer ranks the data, so the per-row covariate-setting rank, the trees-without-rows variant and the per-case wording are removed.
The range check visited five interior points and two corners, so a branch that mixes a parameter with another one's complement (a * (1 - b) * G, or 2 * a * (1 - b) without covariates) could leave (0, 1] at a vertex that none of them reaches. Each branch is now also evaluated at the 2^k vertices of its own k parameters, in check_data() for trees with covariates and in mpt() for trees without. That is exact for branches multilinear in their parameters. A parameter of higher degree, such as u in u * (1 - u), also takes 0.5; a simplex group is one dimension whose vertices put the mass on one member. A branch with more than 1024 grid points falls back to the two corners plus 64 fixed pseudo-random vertices, and one message names the branches that were only sampled. Errors name the vertex at which the branch leaves (0, 1].
) The vertex grid sat at 0.001 and 0.999, so a multilinear branch was seen at (0.999)^k of its extreme: a * (1 - b) * G with G = 1.0015 reaches 1.0015 as a -> 1 and b -> 0 but showed 0.9995 and passed, in check_data() and, without covariates, in mpt(). The grid now sits 1e-9 inside the box, far below the check's tolerance of 1e-6, and the errors name the vertex as 0 and 1. The tests of the vertex check pin the new grid and messages.
?mpt promised that bmm() stops whenever a branch leaves (0, 1]. The vertex check is exact for branches in which no term contains a parameter twice, and for a factor u * (1 - u). A branch such as 7 * u * (1 - u)^2 peaks at 1.037 at u = 1/3 and passes without a message. The covariates argument now says where the check is exact and where it is approximate.
Collaborator
Author
Update 2026-10-12: review fixes landed, constraints merged (head e709186)An independent review of the #528 range check (branches of covariate trees must stay in (0, 1] for every row) derived the multilinearity argument and probed the implementation. Two fixes landed:
The level is merged with the current constraints (#485). The merge met one conflict, the identifiability paragraph of |
test-conventions.R (R8) bars new set.seed() calls; local_seed() gives the same seed and restores the RNG state after the test.
# Conflicts: # tests/testthat/test-model_mpt.R
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of #255.
Base branch:
255-mpt-constraints(stack rebased on develop 6f04c69, 2026-10-04; constraints fffa619 merged in, which carries core 4dfa85d and develop 186250e; develop 222a7ef merged in 2026-10-08; head 2b8c7de, 2026-10-09). PR 3 of 6 in the MPT stack. 20 files, +2244/-204.What this adds
mpt(covariates = ): numeric data columns used in branch expressions, such as design-fixed guessing rates.dmpt()andrmpt()take covariate values too.mpt_tree(impossible = ): response categories a tree cannot produce, such as distractor responses without distractors.pp_check()andconditional_effects()skip thePoss_columns the model generates (by name, so a user column that happens to start withPoss_stays visible);revert_check_data.mpt()drops them. The overview table listscovariates.Design decisions
mpt()cannot know the covariate values, so for trees that use covariates it leaves the sum check tocheck_data(); a tree that uses no covariate is decided at construction instead, at the interior points and the two corners, so that declaring a covariate elsewhere can no longer makempt()accept a tree thatcheck_data()then refuses.check_data.mpt()checks every row of a covariate tree with its observed values, at the five interior parameter test points of the construction check (a golden-ratio sequence, so every parameter gets its own value; one symmetric point would hide a swappedD/1 - D) and at two near-boundary points with every parameter at 0.001 and at 0.999 (simplex groups at a corner), where a covariate slightly outside [0, 1] first pushes a branch out of (0, 1]. It stops when a tree's branches do not sum to 1 on its rows; when a branch is ≤ 0 or > 1 at an interior point, or above 1 or below-1e-6at a corner (a residual branch written as 1 minus the others cancels to just below 0 there — -8.5e-20 for seven stages — so the corners tolerate rounding; a branch that is exactly 0 at an interior point is pointed toimpossible); when a branch isNaNat a test point, naming the category and the values; when a branch of one tree is not finite on another tree's rows (brms evaluates every tree's termIdx_<tree> * (branch)on every row, e.g.1/sswithss = 0where the other tree has no set size); and when a covariate that some branch uses isNA(the tree's branches cannot be computed, or brms would silently drop the row; the message says what to fill in). Messages name the tree, the category, the first row and only the covariates that tree uses.m3device. The category is dropped from that tree, whose remaining branches must sum to 1.check_data()builds a per-row indicator, andbmf2bf.mpt()writesPoss_dist * log(dist) + (1 - Poss_dist) * (-100), asm3does for empty categories. Per row, because a category can be impossible in some trees and reachable in others. Therefcat = NAmultinomial renormalizes, so no branch gives up probability; the leak isexp(-100)= 3.7e-44, and1 + exp(-100) == 1in double precision.NAcounts read as 0, and do not warn in cells the row's tree cannot produce.Validation
Oberauer (2019), Exp. 1, vs the author's JAGS model (uses
covariates): all four estimates inside the JAGS 95% CIs.meanPi2.232 vs 2.420 [1.617, 3.436];meanPb2.437 vs 2.459;dPi-0.176 vs -0.236;dPb-0.592 vs -0.596. P(dPb < 0) = 1.000, max Rhat 1.0057.MPT vs
m3onoberauer_lewandowsky_2019_e1(withimpossible): max difference of posterior means over seven quantities 0.034 (criterion < 0.05; 0.031 on 2026-09-04).update()on a two-tree fit with an impossible category: identical standata. The validation script's FAIL verdict comes from its extra stancode comparison:update()writes the version header twice, which mixture2p shows too, so it is develop behaviour. The review of 2026-10-05 repeated this with an impossible category, two covariates, a factor sharing one tree and the tree column absent from the formulas: standata identical forupdate(fit, iter = )andupdate(fit, formula. = ),log_lik()equal to a hand computation to 5.7e-14, no predicted counts in impossible cells.impossibleagainst the epsilon device it replaced (decided, D4; fresh fits on agit archivesnapshot). The epsilon device is the article's own beforeimpossibleexisted:dist = "(1 - Pi) * 0.001",npl = "(1 - Pi) * (10/15 - 0.001)"in the no-distractor tree. Same OL2019 model, formula and seed otherwise; fitted in the order impossible, epsilon, impossible, epsilon with seeds 2611 and 2612. Epsilon does not do worse:Posterior means of the 14 population effects and SDs differ between the devices by at most 0.0216 logits (seed 2611,
b_Pb_condoldsame) and 0.0125 (seed 2612,b_Pi_condoldsame), at most 0.0045 and 0.0016 on the probability scale. Refitting the same device with the other seed moves the means by up to 0.0138 (impossible) and 0.0125 (epsilon). 11 of the 14 differences keep their sign across the two seeds, as expected for two slightly different likelihoods fitted to the same data; all but one (0.0216,b_Pb_condoldsame, seed 2611) stay within the seed-to-seed band, and the 95% CrIs overlap almost entirely. So the case forimpossibleis that it states the structure and needs no compensating branch, not that it fits better.Since 2026-10-08 (develop sync 2, open items)
Gates on
255-mpt-design(2b8c7de)mpt|distributions|conditional|pp_check|update|data_check|helpers-prior|default-priors: 0 failed, 0 errors, 0 skipped, 2341 passed.check()on anrsynccopy: Status OK, 0 errors, 0 warnings, 0 notes.print()of the seven reference models identical except theparameter_info()line of e7c8504, against d309b8a.document(): no drift.git merge-treeagainst develop 222a7ef: clean.Changes since the review fixes of 2026-10-05
D = 0in the 2HTM. It now says the constant must lie strictly between 0 and 1 and points to the reduced tree withmpt_tree(impossible = )(dd78044).g * xin a 2HTM andx * a + (1 - x) * bgot false warnings. Covariates are now data. Inbmm()the rank is taken over every distinct finite covariate setting in the data (no cap; see the fix-round section below for how the memory is bounded), and over five test values of the covariates when printing (38b6125, 624b1d5). With predictors and covariate data, the message names the parameters itself, becauseprint()takes the covariates at test values. An independent check agreed with the printed rank on 13 of 13 models and found no crash on bad covariate input;check_data()'s own error comes first (stack_cleanup_2026-10-06/verify/round2/).Review focus
-100device and the renormalization argument..mpt_validate_covariate_sums()(sums and branch range at five interior and two near-boundary parameter points, finiteness of each tree's branches on the other trees' rows,NAcovariates). Not covered by design: branches that mix complements of several parameters ((1 - D) + D * d + D * (1 - d) * G) can still leave (0, 1] at a mixed corner; checking those would need 2n more points.m3test, because they use the impossible fixture..mpt_tree_branch_errors()(renamed from.mpt_tree_sum_deviations()by PR 1) gainscovariatesand shares.mpt_test_points()with the data check. Indmpt()andrmpt(),covariatescomes afterlogandunpack(4d4b63b). The test values are a golden-ratio sequence instead of a four-value cycle, sompt()now also rejects a tree with five or more parameters whose typo equates parameters four places apart (accepted at 06aefe8); this also applies to models without covariates; PR 1 now uses the same sequence (1a7a242)..mpt_boundary_points()supplies the two corner points of the data check. A branch(1 - D)^nwith n above about 370 underflows to 0 at an interior test point and is rejected..mpt_covariate_settings(): which covariate values enter the rank, and the fall-back to test values.Decided 2026-10-06, implemented
mpt()no longer warns on covariate trees whose sum at the test values is not 1;check_data()decides for them (item 2, 0fe3dff).dmpt()takeslogandrmpt()takesunpackright aftertree, beforecovariates, asdm3()andrm3()do (item 9a, 4d4b63b). A name given twice acrosspars,covariatesand...is an error (item 9b, cca2c8b, e6fc5dd).?mptsays which covariate checks stopbmm()and thatposterior_predict(newdata = )needsnTrials,Idx_<tree>andPoss_<category>(items 9c, 12, c12a086); a comment notes the accepted(1 - D)^nunderflow (item 11, 1b034b9).Changes since the re-review of 2026-10-09 (7678b20 to 81e65c4, 18 commits)
An internal review (2026-10-09) gave the verdict MERGE AFTER FIXES; four fix rounds, each round's tests mutation-checked, five independent reads. The covariate stacking and the cap removal held under review: an independent central-difference rank matched bmm on all 15 covariate designs.
check_model()is correctly silent, while the same tree restricted toxin {0, 1} is rank 2 of 3 and the deficit is reported (an independent central-difference rank agrees in both). The first version of the lift cost 346 ms at 10 000 settings, so each derivative is now evaluated once over all settings instead of per setting: 12 ms.Dandgand onlyoldrows passed silently while the same likelihood writteng * xwarned. When every tree or no tree has rows, the model rank stands; the message says the rank was taken in the trees with rows.check_model()with LINPACK's "NA/NaN/Inf in foreign function call"; such values are now left out and the text says how many. The rank is reported as not computed only when fewer than two test values are finite, because a single remaining point can sit on a singular set and raise a false warning. Without a rank,bmm()reports the count bound and states the reason instead of pointing atprint(), which cannot give a reason that comes from the data.NaNbranches are refused by name (db8c6b1). See the row-wise checks above.check_model()said "The model is not identified" and then that the predictors were not checked; it now says the tree parameters are not identified with intercept-only formulas. A parameter that only trees without rows contain is named as left open by the data.?mptsays thatprint()andbmm()can disagree in either direction, and that the two read covariate values from different places.bmm:::(acc6965).Changes from the merge of 2026-10-07 (2e346a7)
255-mpt-constraintsat 84d05ac, which carries core's fix round 2, merged in; seven conflict hunks in four files, all resolved. What changes behaviour at this level:h ~ gnext to aD * rdeficit warns again, where before any non-linear formula anywhere downgraded every deficit. Where the rank could not be computed there is no set of involved parameters, so the narrowing is suspended there and any non-linear formula still counts.p / 100000 as p / 1000 / 100for large constants), shows brms's form only where it differs from the written one, and keeps this level's remedy of declaring the value as a covariate column (core 1f2e416).Update 2026-10-10:
bmm()no longer ranks the data (35a19c9)Core's two-rule messaging is merged in (see the update on #484). The rank in
bmm()is now the same rankprint()reports. For models with covariates both use five test values of each covariate, andbmm()does not read the data for identifiability. This supersedes the passages on covariate settings taken from the data, the trees-without-rows variant, the deficit texts that use the data, and the narrowed rank-deficit warning.Why. I logged the model rank next to the data rank for every call with data in the MPT test file (110 calls) and for the article's models (8 calls). The data rank never exceeded the model rank. In 91 of the 110 test calls the two agreed. In 9 the data rank was lower (a tree with no rows in the data, or a covariate with one distinct value), and in 4 only the data rank could not be computed.
bmm()now stays silent in those 13 cases. That is a decided trade: only the data rank saw them, and it needed the covariate-settings code to do so..mpt_covariate_settings(), thedataargument of.mpt_identifiability(), the trees-without-rows handling, and the data-specific wording. The Jacobian rank computation is unchanged.R/model_mpt.Rgoes from 382 to 211 lines.?mptshrinks from 48 lines to 12 lines of prose plus a 4-line example.bmm()ignores the data.?mpt:NOT_CRAN=true devtools::test()FAIL 0, PASS 7371;devtools::check()0 errors, 0 warnings, 0 notes. Filtered MPT tests pass after the trim (370). The same code is tested in full at the head of MPT article, ppp(), mpt_data_reshape() and mpt() API changes (MPT 6/6, #255) #489.Update 2026-10-13: develop through #546 carried up, MPT fits in tests/local (head 084eca2)
This update replaces the "Gates" section above, if any.
tests/local.withr::local_seed()instead ofset.seed().Measured at this head:
document()no drift. The full suite andR CMD checkwere run at the tip of the stack (#489).CI on this head: R-CMD-check passes on macOS, Ubuntu and Windows (Windows check step 11 minutes), tests-source passes within the time budget (suite 439 s), and file-size passes.
Closes #528.