Changelog
Source:NEWS.md
choicer (development version)
Scaling the optimizer’s coordinates (scale_vars): "bhhh" is the new default
-
Default change.
run_mxlogit()andrun_mnlogit()now default toscale_vars = "bhhh", below;run_nestlogit()gainsscale_vars(its last argument) and keeps"none". A fit that uses the default reaches the same estimator’s maximum through other coordinates, so its estimates, iteration counts and convergence codes change. With anyscale_varsbut"none"(so now by default for the mixed and multinomial logits), settings in parameter units incontrol(nloptr’sxtol_abs; optim’sparscale, andpgtolfor the mixed and nested logits, which optim fits by L-BFGS-B) and custom optimizers act on the scaled coordinates.scale_vars = "none"restores the 0.2.x path, for a mixed logit together with the 0.2.x start values (next section); for a saved fit,update(fit, scale_vars = "none")(and, for a mixed logit,theta_init). -
scale_varschooses how the optimizer’s coordinates are scaled; it never changes the estimator or the data. The likelihood is evaluated at the parameters, on the design as supplied, while the optimizer works on rescaled parameters, theta = m * theta_t + c, with the gradient rescaled by the chain rule. Start values and bounds are given in natural units and mapped; estimates and standard errors are reported in natural units. -
scale_vars = "bhhh"scales each parameter by the inverse square root of the diagonal of the BHHH (outer product of gradients) matrix at the start values, rounded to a power of two, from one extra gradient pass. L-BFGS starts every iteration from a single multiple of the identity as its curvature, and in a model with many alternatives the curvatures of the constants (large and small alternatives) and of the Cholesky entries spread over orders of magnitude that no column scale reaches. On simulated designs (census-style, claims-style and small) and on the shippedmode_choicedata, with the default optimizer (nloptr L-BFGS) and a budget of 2,000 likelihood evaluations,"bhhh"reached the maximum (log-likelihood within 1e-6 of a Newton-polished reference, Newton decrement below 1e-6) in (base runs from the 0.2.x start values, which the mixed logit’s default no longer uses (next section); reruns from starts perturbed by 1e-13 moved the counts by up to a quarter, never their order):- mixed logit, census-style (20,000 and 200,000 students, 2,530 and 2,620 parameters): 42 and 49 evaluations, where
"none"and"sd"had not arrived after 2,000 (they stopped up to 90 log-likelihood units short, and one of those runs reported convergence); - mixed logit, claims-style (518 parameters): 198, against 988 for
"none"and 998 for"sd"; - multinomial logit (509 and 2,521 parameters): 26 and 39, against 787 and 1,358 for
"none"; - small models,
mode_choiceincluded: 11 to 30, against 19 to 108 for"none"and 20 to 31 for"sd"; on a small mixed logit"none"and"sd"both failed at once.
"bhhh"took 20% fewer iterations for the multinomial logit (1,532 against 1,915 over 149 fits) and 6% fewer for the mixed logit (3,591 against 3,832 over 146, from the 0.2.x start). The scales fix the optimizer’s metric, not a start that is wrong in a column’s units: with the census-style design’s columns rescaled by 10^3 or 10^-3, no choice reached the mixed logit’s maximum in 2,000 evaluations from the 0.2.x start (Cholesky diagonal 0.5 in every column’s units), which is why the mixed logit’s default start is now in the columns’ units (next section); from the new start"bhhh"reaches the maximum in 54 evaluations ("sd"and"none"still do not). The multinomial logit, whose start does not depend on units, reached its own maximum on that design with"bhhh"in 38 evaluations, against about 1,300 with"sd", while"none"reported convergence 1,920 log-likelihood units short. Where the diagonal is unusable at the start values (no finite, positive entry, as when the likelihood overflows there), the parameters are left unscaled, with a message. - mixed logit, census-style (20,000 and 200,000 students, 2,530 and 2,620 parameters): 42 and 49 evaluations, where
-
What moves with the default. Where the likelihood is well identified, estimates move within the optimizer’s tolerance, and on the designs above (the rescaled one from a start in its units) fits that stopped short of the maximum, or reported a convergence they had not reached, reached it. Standard errors are computed from the equilibrated information (below); they move with the estimates, and where the information is near singular (a variance at its boundary) they can differ widely. Where the data barely identify a direction, estimates can move along it: on a 60-person panel in our reference battery, the constants moved together by 1.0, 0.0005 standard errors, at log-likelihoods 1e-7 apart. On small, degenerate likelihoods the two paths can stop at different points: among the test suite’s fits,
"bhhh"reached a log-likelihood 0.96 higher in one fit whose coefficients diverge (the data separate) and stopped short in two (by 0.09 and 0.02; an alternative never chosen, a random coefficient’s variance going to zero). - With
optimizer = "optim", BFGS for the multinomial logit (its stopping rulereltol, relative to the log-likelihood, about 1.5e-8 of its value) and L-BFGS-B for the mixed and nested logits (factr, about 2.2e-9) can stop before the accuracy above, scaled or not: on the claims-style multinomial logit, optim reported convergence 0.44 log-likelihood units short with"bhhh"(23 evaluations), and 0.52 short with"none"(778 evaluations, withmaxitraised to 2,000).control = list(reltol = )orlist(factr = )tightens them;maxit(100 by default) caps the iterations. -
run_nestlogit()keeps"none"as its default. Its"bhhh"scales are fixed at the start values, and when the nest parameters end far from their start (0.5) they can mislead the optimizer: over the test suite’s nested logit fits,"bhhh"took 14% more iterations than"none"(21,190 against 18,650 over 102 fits; one fit 540 against 29, to the same maximum), although on one simulated design with a covariate in large units"none"failed where"bhhh"converged in 27 evaluations, and"sd"in 35. -
"sd","mad"and"iqr"keep their meaning (the optimizer works on the parameters the design would have with its columns divided by their scales) but take the same route: the data are never divided, andrun_mnlogit()no longer holds a scaled copy ofXwhile it fits (on the claims-style design’s 8.7 million rows and 10 fixed-coefficient columns, a scaled fit’s peak heap fell from 1.39 to 0.10 GB, close to an unscaled fit’s 0.08 GB). Their estimates move within 1.7e-5 standard errors on our reference battery (1.2e-6 over the test suite’s fits). - The standard errors of a scaled fit are computed in natural units, at the natural estimates, and its information matrix is equilibrated before it is inverted: scaled by the powers of two nearest (on a log scale) the inverse square roots of its diagonal’s magnitudes, which adds no rounding. A fit used to invert the information of the scaled design and transform the result back, while
vcov(fit, type = )inverted the natural information as it was; both lost digits when the design’s columns span orders of magnitude (post hoc, the inversion failed as computationally singular on such a design, and a mixed logit’s robust variance from the tests was off by 1.6e-8 relative; now within 1e-15 of an independent computation). A mixed logit’s variance at fit time is computed by the routevcov(fit, type = )takes post hoc ("robust"forse_method = "sandwich"), with the fit’s own draws, so the two agree, bit for bit on one thread (draws supplied througheta_drawsare not regenerated post hoc). An unscaled fit’s inversion is unchanged. - The extra gradient pass of
"bhhh"counts in the reported optimization time. A scaled fit’stheta_initmust have one finite value per parameter; a shorter or longer vector used to be recycled. - Fits record the map in
param_scaleandparam_shift(1s and 0s for"none");sXandsWhold the column scales under"sd","mad"and"iqr", and 1s otherwise.
Mixed logit — the default start is in the columns’ units
-
Default change. With
theta_init = NULL,run_mxlogit()now starts each normal random coefficient at L_pp = 1 / h_p on the Cholesky diagonal, where h_p is the typical step of its column: the distance between its values for a column with two (a dummy: 1), the mean distance from a value that fills more than half of the column over the other rows (a mostly-zero column), and its standard deviation otherwise. It started at 0.5 whatever the column’s units. At the start, a step of h_p in a column now moves the utility by a random amount with standard deviation 1 (the logit error’s is about 1.28), whether a price is in cents or in thousands. Rescaling a column divides its row of the Cholesky factor (and its mean, withrc_mean = TRUE) by the same factor and changes nothing else, and the start now moves with it: in exact arithmetic, withscale_vars = "sd","mad"or"iqr"the optimizer’s problem is the same in any units, and with"bhhh"the same up to the power-of-two rounding of its scales. A log-normal coefficient keeps L_pp = 0.5, the standard deviation of the logarithm of its random part, which has no units; that random part has median one in the covariate’s units, though, so a log-normal covariate still belongs in units where its coefficient is of order one. The estimator is unchanged: a fit that uses the default start reaches the same maximum within the optimizer’s tolerance, except where one start or the other stops short of it, or the likelihood has another local maximum or its supremum on a boundary (below). Atheta_initof zeros with log(0.5) on the Cholesky diagonal restores the 0.2.x start, and withscale_vars = "none"the 0.2.x path. -
Why. The optimizer’s coordinates (
scale_vars, above) cannot repair a start in the wrong units. On simulated designs whose columns we rescaled by up to 10^±3 (census-style with 20,000 students, a cross-section of 10^6 rows) and on rescaled versions of the shippedmode_choicedata, the default"bhhh"from the 0.2.x start stopped 233 to 1,730 log-likelihood units short of the maximum in 4 of 10 rescalings, three of them reporting convergence (the fourth ran out of the budget), and needed 167 evaluations in a fifth, against 42 on the original units. From the new start it reached the maximum in all 10: in 0.84 to 1.09 times the evaluations it took on the original units in 9 of them (54 on the census-style design rescaled by 10^3 and 10^-3, against 56 on its original units), and in 1.8 times as many in the tenth, a rescaling ofmode_choice(58 against 32;"bhhh"’s scales round, and its stopping rule stops, differently in other units, while under"sd"every rescaling ofmode_choicetook 29). Over these and the original designs, 16 in all (claims-style with 518 parameters and census-style with 200,000 students included; nloptr, 50 draws, a budget of 2,000 evaluations, the accuracy criterion above), the new start took at most 1.35 times the evaluations of the best of the five starts we compared on each; the 0.2.x start missed 4 and took up to 3.9 times elsewhere. Against the 0.2.x start: 157 against 219 on the claims-style design (213 in a replicate; 198 in the study above), 44 against 49 on the census-style design at 200,000 students, 56 against 42 at 20,000. Every column of these designs has many values, so their step is the standard deviation. - Dummies. For a dummy with a share q of ones the standard deviation, sqrt(q (1 - q)), understates the step its coefficient multiplies. On the census-style design with a dummy marking one alternative per situation among the random coefficients, 1 / sd(W_p) starts its L_pp at 6.4 and took 90 and 108 evaluations (the dummy’s coefficient with standard deviation 1 and 2), against 44 and 66 from the 0.2.x start and 54 and 64 from the new start, whose step for a dummy is 1. In the test suite’s fits every random coefficient’s column has at least 10 distinct values, none filling more than 29% of the rows, so its step is its standard deviation.
-
Why not a smaller start.
"bhhh"fixes its scales at the start, and with too little heterogeneity there they are too generous for the Cholesky diagonal, whose curvature grows with L_pp: from 0.25 / sd(W_p)"bhhh"reported convergence on the cross-section 554 log-likelihood units short."none"and"sd"have no such scales, but from 0.5 / sd(W_p) both failed at once on that design, where from 1 / sd(W_p) both reached the maximum (in 43 evaluations). -
What moves. Over the test suite’s 177 distinct default-start mixed logit fits, those that reached the same maximum took 6.2% fewer iterations. Fourteen, all with the default
"bhhh", reached a higher log-likelihood (up to 0.81 higher, on a 30-situation design where the 0.2.x start reported convergence short of it); five, all withscale_vars = "none"on deliberately badly scaled designs, a lower one (0.18 to 0.27 lower): there the unscaled optimizer can drive a random coefficient’s variance toward zero, where the likelihood flattens and its gradient vanishes. On a weakly identified design in our reference battery whose supremum lies on the boundary (a degenerate covariance),"none"and"sd"now stop at the interior maximum that"bhhh"reached before and still reaches, 0.021 below the supremum: the second random coefficient’s variance given the first (L_22²) moves there from nearly zero to about 1.4. - Where the default start falls outside
lower/upper, it is moved to the nearest bound, with a message naming the parameters; with the default optimizer such a call used to stop with an error (nloptr refuses a start outside its box). A suppliedtheta_initis used as given. Bounds that hold no start (loweraboveupper,lower = Inf,upper = -Inf) now stoprun_mxlogit()before the fit, naming the parameters, whatever the start; before, nloptr stopped with its own error, while optim’s L-BFGS-B returned a fit with convergence code 52 forloweraboveupperand treated an infinite bound on the wrong side as no bound. - The columns’ steps are read in place, without copying the design (computing standard deviations with
stats::sd()a column at a time would copy each column and an index of its rows: 1.2 GB more at 98 million rows).
Mixed logit — panel likelihood (person_col) and conditional tastes
-
run_mxlogit()/prepare_mxl_data()gainperson_col: the frequentist panel mixed logit of Revelt and Train (1998). When supplied, all choice situations of a decision maker share one draw of the random coefficients, and the likelihood integrates the joint probability of that person’s choices over the taste distribution, instead of integrating every choice situation separately as before.id_colmust still identify choice situations uniquely across the whole data set (ids do not restart within person).NULL(the default) keeps every choice situation its own decision maker, reproducing the existing cross-sectional likelihood.-
simulate_mxl_data()gainsT(choice situations per decision maker; default1, a cross-section) and records the realized random coefficients intrue_params$gamma_i(K_w x N), the realized tastes thatconditional_tastes()‘s conditional means can be compared with (they track them with shrinkage).T = 1reproduces earlier versions’ data exactly. - New exported generic
conditional_tastes()(classchoicer_tastes, method forchoicer_mxl): the mean and SD of a decision maker’s random coefficients conditional on the choices they made (Revelt and Train 2000; Train 2009, ch. 11) — the frequentist analogue of the hierarchical Bayesbeta_isummaries ofrun_hmnlogit(). Itsprint()reports, per coefficient, the share of population taste variance revealed by choices and the residual share (law of total variance), which should sum to about one — a numerical check on convergence and the draw countS, not a specification test (both identities are first-order conditions of the likelihood, so they hold at a converged fit whatever the true taste distribution). - Inference: with
person_col, scores are per decision maker, sose_method = "sandwich"/vcov(type = "robust")is already robust to within-person dependence;cluster_col/clusterlabels must then nest decision makers (be constant within each). Weights become decision-maker weights, constant within person (the objective issum_n w_n log L_n). WESML (choice-based) weighting is not supported withperson_col: a choice-based weight varies with the alternative a person chose, which can differ across their situations. - Fitted-object fields: a panel fit gains
n_persons(the number of decision makers; printed as “Respondents”),data$Ti(choice situations per decision maker, in prepared order), anddata$person_ids.nobs()is unchanged — it still counts choice situations, as for HMNL. - New recovery script
inst/simulations/mxl_panel_simulation.R, in the style ofmxl_simulation.R: simulates a panel, fits it withperson_col, reportsrecovery_table()andconditional_tastes(), and contrasts standard errors against a cluster-robust cross-sectional fit of the same data. - Apart from the corrections below and the new default
scale_vars(see “Scaling the optimizer’s coordinates”), default (cross-sectional) behavior is unchanged up to floating-point reassociation (<= 1e-10 relative in the estimation kernels, measured on the reference battery of test configurations); prediction kernels are bit-identical (see also “Mixed logit — prediction at population scale”). - New input guards: a non-
NULLemptyTiis an error, and a primary-thread memory check now stops when a single decision maker stacks so many alternative rows (tens of millions) that its design rows would need more than 2 GiB of scratch in every thread — a signal thatperson_colidentifies markets or some other high-cardinality grouping rather than decision makers. - Alignment guards for panel fits, whose prepared situations are ordered by decision maker rather than by id: a positional
weightsvector and an unnamed post-hocclustervector are rejected (with a pointer toweights_col/ id-named labels) whenever that order differs from id order, andpredict()/logsum()/consumer_surplus()note whennewdatalacks the person column (rows are then ordered by id rather than in the in-sample, decision-maker-first order). -
conditional_tastes()on other fits errors informatively, pointing hierarchical Bayes users to theirbeta_isummaries. - Supersedes the v0.2.0 “Scope note (MXL)” below: clustering a cross-sectional fit still only repairs the inference, not the efficiency lost by ignoring within-person correlation, but the likelihood itself is now a choice: pass
person_colto use the within-person variation the panel provides.
-
Mixed logit — kernels for population-scale data
- The estimation kernels keep each thread’s working arrays across decision makers instead of allocating them per unit, and the analytical Hessian no longer allocates a temporary for every alternative-and-draw outer product or per-unit block: the draws are read in place from the store-mode cube, and a unit’s design rows and random-coefficient draws go into reused buffers. On a synthetic 10^6-row panel the Hessian’s heap traffic fell from 106 million allocations (374 GB) per call to about three thousand, a gradient evaluation on 10^7 rows allocates a quarter to a third as often, and the kernels ran 3-24% faster in our benchmarks.
- Each decision maker’s draws are now processed in batches sized to a small per-thread memory budget, with the score accumulated across batches by a streaming log-sum-exp, so a thread’s working memory no longer grows with the product of a decision maker’s number of choice situations and the number of draws
S. The draw loop also sweeps a decision maker’s rows in memory order and no longer allocates per decision maker. On a synthetic claims panel with heavy-tailed histories (up to 500 choice situations per decision maker), peak working memory atS = 1000fell from 6.4 GB to 0.12 GB and gradient evaluations ran 40% faster; on a 10^7-row panel they ran 12-25% faster atS = 100-500. The analytical Hessian no longer holds a decision maker’s rows-by-draws matrix either: its peak working memory on the claims panel atS = 500fell from 1.7 GB to 0.2 GB. A decision maker is split into batches only when its stacked alternative rows timesSexceed 2^18 (a long panel, a choice set of hundreds of alternatives, or a very largeS). - The estimation kernels read the stacked design’s integer indices in place instead of copying them on every call, and compute each decision maker’s base utilities (
X beta + W mu + delta) inside the parallel loop instead of for all rows up front, so a likelihood evaluation no longer allocates anything the length of the stacked design (what remains is 8 bytes of row offsets per choice situation, and in a panel 8 per decision maker). On synthetic 10^8-row panels, peak working memory fell from about 3 GB to under 0.2 GB and an evaluation ran 19% faster atS = 25(7-9% atS = 100); fits atS = 50on 10^7 rows took 9% less time per evaluation. The kernels’ offsets into the stacked design are now 64-bit. - Generate-mode draws (
draws = "generate") are now formed in two passes over each block: all the Halton uniforms first, then the inverse normal CDF. The first (base-2) dimension is stepped by an exact integer odometer, and the digits of the next ten (bases 3 to 31) are taken by division by a compile-time constant. The draws are bit-identical to before on 64-bit platforms, so this change by itself alters no estimate, standard error, prediction or other post-estimation quantity; it lowers the cost of regenerating the draws at each likelihood evaluation. With three random coefficients andS = 100, gradient evaluations ran 32-36% faster on a synthetic four-alternative cross-section (10^6 rows at 1 and 11 threads, 10^7 rows at 11), where regenerating the draws had taken about half the time; 10-14% faster on a school-census-like design (thousands of schools, 10-100 per choice set); and showed no measurable change (under 3%, within run-to-run noise) on claims-like panels with 20-200 hospitals per choice set, whose cost lies elsewhere. On the four-alternative data a generate-mode evaluation now takes about 1.35 times as long as a store-mode one, down from about 2 times. The generator’s digit-permutation tables are also smaller: 1.3 MB instead of 11 MB at the 128-dimension maximum. - Together these kernel changes alter results only by floating-point rounding: at most 3.4e-15 relative on our reference battery of kernel configurations, and within the 1e-10 our tests allow for decision makers split into draw batches. The numerical changes of substance are the corrections below and the new default
scale_vars(see “Scaling the optimizer’s coordinates”).
Multinomial and nested logit kernels at population scale
- The multinomial and nested logit kernels now read the stacked design’s alternative codes, the choices and the alternatives’ nests in place, as the mixed logit estimation kernels already did. They used to copy them into Armadillo index vectors on every call (through doubles, again shifted to start at zero, and once more for each choice situation). That covers the kernels behind
run_mnlogit()andrun_nestlogit()(likelihood, gradient, Hessian, BHHH and scores) and behind their post-estimation methods (predictions, shares, elasticities, diversion ratios,blp_contraction()andnl_blp_contraction()), whose contractions also formX betaonce instead of at every iteration. The nested logit kernels also compute each choice situation’s probabilities in buffers allocated once per thread, where they built about a dozen Armadillo vectors per situation. On a synthetic design of 17.5 million rows (500,000 choice situations of 20 to 50 alternatives, 10 covariates), a multinomial logit gradient evaluation took 23% less time at one thread and 39% less at eleven, moved 0.5 GB less through the heap, and peaked at 0.14 GB of working memory instead of 0.57 GB; twenty iterations of the BLP contraction took 42% and 70% less time (medians of six alternated runs). On 7 million rows (200,000 choice situations of 20 to 50 alternatives in 10 nests), a nested logit gradient evaluation took 37% less time at one thread and 46% less at eleven, and peaked at 0.06 GB of working memory instead of 0.23 GB; twenty iterations of its contraction took 47% and 65% less time and moved 0.1 GB through the heap instead of 10.9 GB (medians of three alternated runs; with 40 nests, 42% and 50% for the gradient and 51% and 64% for the contraction). Estimates, standard errors, predictions and every post-estimation quantity are unchanged, bit for bit. - The same kernels now treat malformed index vectors, which only a hand-built
input_datalist or a direct call can supply, in a defined way. A missing or negative alternative code is reported as such, where it went through an undefined conversion (and read as 0 on ARM processors), and a missing nest code always as an invalid one; a missing or negative choice is an error, where on ARM, with an outside option, it counted as choosing that option; and an index beyond the integer range becomesNA, with R’s warning, and is then treated as a missing one.blp_contraction()andnl_blp_contraction()given no choice situations and an emptydeltanow stop at their check of the weights, where they read past the empty vector or, with an outside option, failed an Armadillo bounds check.
Mixed logit — prediction at population scale
- The mixed logit kernels behind
predict()(probabilities and shares),logsum(),elasticities(),diversion_ratios(),blp()and the exportedmxl_blp_contraction()(and soconsumer_surplus(),gof()and the fit statistics ofsummary()) now read the stacked design’s alternative codes in place and simulate each choice situation in buffers allocated once per thread. They used to copy the codes twice on every call and allocate about ten objects per choice situation, among them its rows-by-draws matrix of random utilities and, for the log-sums, a temporary at every draw; with stored draws, the threads also took turns at a lock shared by all of them for every situation’s draws. The BLP contraction also formsX betaandW muonce instead of at every iteration. The working arrays, among them the alternative-by-alternative accumulators of elasticities and diversion ratios, are allocated before the parallel loop, so running out of memory now stops with an R error that says how much each thread needs and suggests fewer threads, instead of possibly aborting R from inside the loop. Results are unchanged, bit for bit, with a BLAS whose results do not depend on where its operands sit in memory, such as the reference BLAS or OpenBLAS. That holds for each choice situation’s probabilities, mean utilities and log-sum at any number of threads, and for sums over situations (shares, elasticities, diversion ratios, and so BLP inversions) at one thread; at more threads such sums vary in their last bits with the order in which the threads take the situations, as before. With other BLAS libraries, results may differ from earlier versions’ in their last bits.- Measured on the kernels themselves, given the draws (stored draws as a cube built beforehand, which the kernels still accept; the methods now form them on the fly, below), on a synthetic school-census design of 9.8 million rows (245,000 choice situations of 10 to 100 schools, three random coefficients,
S = 100), at one and eleven threads, with draws generated on the fly and, in parentheses, stored (medians of three alternated runs). Probabilities took 16% and 20% less time (23% and 39%) and moved 0.16 GB through the heap in 51 allocations instead of 11.8 GB in 2.2 million. Log-sums took 27% and 26% less time (65% and 90%) and moved 0.08 GB in 19 allocations instead of 18.8 GB in 22 million. Shares took 14% and 16% less time (20% and 36%) and moved 0.08 GB in 49 allocations instead of 11.4 GB in 2.0 million. Five iterations of the BLP contraction took 14% and 17% less time (17% and 29%) and moved 0.08 GB instead of 67.5 GB. On a claims-like panel of similar size, the same kernels took 14-27% less time for the probabilities, 24-44% less for the log-sums and 12-23% less for the shares and the contraction. - Elasticities and diversion ratios spend most of their time on the per-draw products of probabilities, which did not change. Measured the same way on the census design at a tenth of that size (0.98 million rows, 24,560 choice situations), diversion ratios took 13-18% less time and elasticities 4-11% less, at one and eleven threads, with stored and generated draws (medians of three alternated runs); on the claims-like panel at 1.06 million rows, diversion ratios took 10-25% less and elasticities between 4% less and 0.3% more (medians of four alternated runs). Both moved 0.02-0.23 GB through the heap at one thread (0.04-0.77 GB at eleven, mostly the threads’ accumulators, which the old code allocated too) in 28 to 501 allocations, instead of 1.9-2.4 GB in 190,000 to 290,000.
- Measured on the kernels themselves, given the draws (stored draws as a cube built beforehand, which the kernels still accept; the methods now form them on the fly, below), on a synthetic school-census design of 9.8 million rows (245,000 choice situations of 10 to 100 schools, three random coefficients,
- The same kernels now treat malformed alternative codes, which only a direct call can supply, in a defined way: a missing or negative code is reported as such, where it went through an undefined conversion (read as 0 on ARM processors, rejected as too large on x86-64), and a code beyond the integer range becomes
NA, with R’s warning, and is then treated as a missing one. With draws generated on the fly, an alternative-levelWwith fewer rows than the largest alternative code is now reported as with stored draws, where it stopped at an Armadillo bounds error. - With stored draws (
draws = "store", the default),predict(),logsum(),consumer_surplus(),elasticities(),diversion_ratios(),blp()andgof()(and so the fit statistics ofsummary()) integrate each choice situation over its own block of Halton draws, and used to build the blocks of all N situations as oneK_w x S x Ncube on every call (for a panel fit too: predictions are unconditional, one block per situation). They now form each situation’s block on the fly, in the thread that simulates the situation, and hold no cube. choicer’s own Halton generator, without its digit permutations, computes the points ofrandtoolbox::halton()digit by digit as randtoolbox does, and R’sqnorm()maps them to normal draws asget_halton_normals()does. The draws, and so the results, are the cube’s bit for bit where randtoolbox and choicer are compiled with the same floating-point contraction, as on CRAN’s platforms: per choice situation at any number of threads, and sums over situations at one thread. Otherwise some of the uniforms differ in their last bit, which moves those draws by at most a few parts in 10^9, in the far tails. Together with the kernel changes above, on the census design at 98 million rows (2.45 million choice situations, whose cube takes 5.9 GB),predict()of choice probabilities with stored draws took 9.0 s instead of 76 s, moved 1.6 GB through the heap in 2,431 allocations instead of 151 GB in 24 million, and its working memory peaked at 1.6 GB, the probabilities and mean utilities it returns, instead of 14.4 GB; on the claims-like panel at 85 million rows (1.5 million choice situations, whose cube takes 3.6 GB), 6.4 s instead of 24 s and 1.4 GB instead of 12.2 GB (medians of three alternated runs at eleven threads, under background load). Without the cube these functions no longer stop past store mode’s 2^31 - 1 points (Stimes the number of choice situations), which a panel fit could reach here although its estimation draws, one block per decision maker, stayed within them. With more than 128 random coefficients (the generator’s table of primes) they build the cube, as before. -
blp()on a store-mode fit used to build the cube once per call and read it at every iteration of the contraction; it now forms the draws on the fly at every iteration, in parallel, aspredict()does. At eleven threads, each evaluation of the shares (one per iteration, and one before the first) took about 8.5 s on the census design and 6.2 s on the claims-like panel, against 8.3 s and 7.1 s before, whenblp()also spent about 60 s and 20 s building the cube once per call (slopes between one and seven iterations, single runs under background load); its working memory peaked at 0.8 GB instead of 12.1 GB, and at 0.7 GB instead of 10.9 GB. The exportedmxl_blp_contraction()acceptsgen_scramble = 2for these store-mode points formed on the fly, and now rejects other codes than 0, 1 and 2, which it treated as 0. -
blp()gainskeep_draws_bytes, the most memory it may use to keep the draws across the iterations of its contraction (default2^32bytes, 4 GiB). When the draws of all N choice situations take at most that (8 * K_w * S * Nbytes), the first evaluation of the shares forms them, in parallel, and the later ones reuse them; above it, every iteration forms them again, as before.0never keeps them, andInfkeeps them whenever they are formed on the fly and can be allocated; the deltas are the same either way. The memory is taken per call, and where it is committed only when used (Linux, macOS, containers and batch jobs with memory limits), a budget above the memory actually free can end the R session rather than fall back. At eleven threads, keeping them took each evaluation of the shares from 8.5 s to 7.2 s on the census design with stored draws and from 9.4 s to 6.9 s with generated draws (9.1 s before this release; its 5.9 GB of draws are above the default, so these runs usedkeep_draws_bytes = Inf), and on the claims-like panel, whose 3.6 GB are within the default, from 6.4 s to 5.2 s with stored draws and from 7.0 s to 5.6 s with generated draws (8.4 s before); single runs under background load, two alternated ones for the claims-like panel’s stored draws. Working memory then holds the draws: it peaked at 6.7 GB and 4.3 GB. -
run_mxlogit(draws = "store")(the convenience workflow) now warns, before it builds its draws, when they would take more than 1 GiB (8 * K_w * S * Ubytes, for the U decision makers of a panel fit or the choice situations of a cross-section): the cube is held while the model is fitted, andvcov(type = ),wesml_vcov()andconditional_tastes()rebuild it whole.draws = "generate"stores no draws; withscramble = "none"it uses the same Halton points, so its likelihood is store mode’s up to rounding error and its estimates agree to the optimizer’s tolerance. The warning does not change the fit; underoptions(warn = 2), which turns warnings into errors, such fits now stop before estimating.
Mixed logit — Hessian, variances and memory at population scale
- The analytical Hessian of the mixed logit (behind
run_mxlogit()‘s default standard errors,vcov(type = "hessian"), and the bread of the robust, WESML and clustered variances) now works, for each decision maker, only on the parameters that decision maker’s likelihood involves: the fixed coefficients, the means and Cholesky factor of the random coefficients, and the constants of the alternatives that decision maker faced; each choice situation’s terms involve its own alternatives’ constants only. It used to carry every alternative’s constant through every decision maker, so that with thousands of alternatives most of its work multiplied zeros; the gain is largest where each decision maker faces a small share of the alternatives. On a synthetic school-census design with 2,620 parameters (2,602 schools, 10 to 100 per choice set, most students with one choice set and some with up to three, three correlated random coefficients,S = 100), where a student’s likelihood involves 59 of the 2,620 parameters on average, the Hessian for 2,000 students took 0.35 s instead of 24.8 s at eleven threads (1.9 s instead of 101 s at one), and for 20,000 students 3.2 s instead of 228 s; its peak working memory fell from 1.9 GB to 0.4 GB. On a claims-like panel with 500 hospitals (518 parameters, 20 to 200 hospitals per choice set, up to 500 visits per patient) it took 3.4 s instead of 7.1 s for 1,500 patients and 26 s instead of 55 s for 15,000 (medians of three alternated runs under background load): a patient’s visits involve about 60 of the hospitals on average, and the work on each visit’s alternatives, which this change leaves as it was, now dominates. - The BHHH (outer product of gradients) matrix of the mixed logit (behind
se_method = "bhhh",vcov(type = "bhhh")and, with squared weights, the meat of the robust and WESML variances) now adds each decision maker’s rank-one update over the same parameters; it used to form every decision maker’s update over all of them. On the school-census design the BHHH matrix for 20,000 students took 0.15 s instead of 25.1 s at eleven threads, and for 200,000 students 1.4 s instead of 246 s; on the claims-like panel, 15,000 patients took 0.88 s instead of 1.14 s (medians of three alternated runs). - Robust and clustered variances computed after the fit (
vcov(type = "robust"),vcov(type = "cluster"),wesml_vcov()) and the clustered variance ofrun_mxlogit(se_method = "cluster")no longer form the matrix of every decision maker’s score, one row per decision maker and one column per parameter: 42 GB for 2 million students with 2,620 parameters, held twice while it was weighted. (se_method = "sandwich"at fit time already took its meat from the BHHH matrix with squared weights.) The robust meat is now that BHHH matrix, the clustered meat sums the weighted scores within clusters in C++ (a new internal kernel that splits a large cluster’s sum across the threads), andvcov(type = "bhhh")uses the BHHH kernel as the fit does. On the school-census design with 200,000 students at eleven threads, the meat of the robust variance took 1.4 s and 0.4 GB of working memory instead of 12.2 s and 8.4 GB; the clustered meat 1.6 s instead of 4.6 s with 2,000 clusters and 1.1 s instead of 3.2 s with five (medians of three alternated runs). These variances also build their store-mode draws once per call instead of twice. The cluster labels they accept, their checks and messages, and the absence of a small-sample correction are unchanged. The internal score kernel now refuses to form a matrix above 2 GiB. - With many thousands of alternatives, and so of alternative-specific constants, the Hessian, the BHHH matrix and the clustered meat no longer keep one copy of the result per thread: the copies are kept only while together they fit in 2 GiB (up to about 7,000 parameters at eleven threads, 10,000 at five); above that the threads add into the result itself, each keeping private only the rows of the fixed coefficients and of the random coefficients’ means and Cholesky factor, which every decision maker touches. On a census-like design with 17,156 parameters, five threads computed the Hessian for 20,000 students in 3.9 s with 2.4 GB of working memory, the result included, where per-thread copies took 4.2 s and 8.3 GB, and the BHHH matrix in 0.42 s where they took 0.68 s (medians of three alternated runs); at eleven threads, where per-thread copies would need 13 GB, the two took 2.9 s and 0.38 s in 2.4 GB (medians of three runs), about what the 2,620-parameter design takes now: a student’s work does not depend on the number of parameters. Inverting a matrix of that size in R, which these times exclude, costs time that grows with the cube of the number of parameters.
- Before allocating their working memory, the mixed logit’s compiled routines (behind the log-likelihood and its gradient, the Hessian, the BHHH matrix, the robust and clustered meats and the conditional tastes) compare an upper estimate of it, for the number of threads set, with the machine’s total physical memory. When it would not fit they stop with an error that says how much they need and how many threads fit; their threads’ working memory used to grow until an allocation failed, which terminates R, or until the operating system stopped it. They do not lower the thread count on their own. Memory already in use is not counted, nor is the inversion in R of the resulting parameters-by-parameters matrices.
options(choicer.max_memory = <bytes>)sets the limit instead, such as a container’s or a cluster job’s, andInflifts the check (?set_num_threads). Results are unchanged. - None of these changes alters an estimator. Standard errors change by floating-point rounding only: on our reference battery the BHHH matrix behind
se_method = "bhhh"is unchanged bit for bit (vcov(type = "bhhh")after the fit, now computed from it, by at most 2e-16), the Hessian differs by at most 3.5e-15 relative (not at all on designs with many alternatives), and the robust and clustered meats by at most 7e-16. As before, at more than one thread, sums over decision makers vary in their last bits with the order in which the threads take them; a decision maker whose utilities overflow is left out of the Hessian and spreadsNaNover its parameters’ rows and columns in the BHHH matrix and the meats; and a non-finite term of a decision maker’s Hessian (an overflowing utility at a draw of zero weight) makes the rows and columns of the parameters concernedNaN.
Data preparation at population scale
-
get_halton_normals()builds the draw cube ofdraws = "store", at fit time and again for post-estimation. It now fills the cube a block of units at a time; before, it generated the whole Halton sequence in one call and copied it into the cube unit by unit in an R loop. The draws are bit-identical wherever the old code was well defined. For 2 million draw units atS = 100with three random coefficients (a 4.8 GB cube), building the cube took 21 s instead of 27 s, peak memory fell from 3.0 to 1.4 times the size of the cube (the rest is R’s garbage-collection slack), and 1.3 thousand allocations replaced 8 million. The single call also formed positions in its output with Cintarithmetic, which is undefined past 2^31 - 1 values (a 17 GB cube); each block now stays far below that. The function now checks thatS,NandK_ware positive whole numbers, and stops before allocating a cube that store mode cannot handle: more than 2^31 - 1 points (S * N), the largest starting indexrandtoolbox::halton()accepts, where the old code failed with an unrelated error.draws = "generate"has no such limit. choicer now requires randtoolbox 1.31.0 or later, the release that addedhalton(start = ). -
prepare_mnl_data(),prepare_mxl_data()andprepare_nl_data()(and sorun_mnlogit(),run_mxlogit()andrun_nestlogit()) copy only the columns the model uses from a data.frame, tibble or data.table, where they copied the whole data set; they scan those columns for missing values one at a time instead of through a rows-by-columnsis.na()matrix, and check that the covariates are numeric without copying them.prepare_nl_data()no longer makes a second full copy to read the alternative-to-nest map. On a synthetic claims-style panel of 8.7 million rows, peak heap use fell from 5.7 to 4.3 times the size of the input forprepare_nl_data()and from 4.5 to 4.0 times forprepare_mxl_data(); the saving grows with the number of columns the model does not use. Apart from the storage change below, the prepared objects are unchanged on our reference battery and on every call the test suite makes. - The same three functions now copy only the index columns (ids, alternatives, choices, and any weight, cluster or decision-maker column) into their working table, and gather the design matrices
XandWin C++ straight from the caller’s covariate columns once the rows are filtered and sorted.XandWare now always double precision: a design matrix whose columns were all integer used to be an integer matrix, which the estimation kernels converted to double on every call. Estimates, standard errors and predictions are unchanged, bit for bit. - The collinearity check that drops dependent covariates (the columns
qr(X, tol = 1e-7)moves past its rank) now factors a design of a million or more values by row chunks and appliesqr()’s own rank rule to the resulting factor, which has the same cross-products. It drops the same columns on our reference battery, on every call the test suite makes and on a sweep of near-collinear designs; the two can disagree only on a column whose dependence lies within rounding of the tolerance, whereqr()’s own answer already changes with the order of the rows. The check no longer makesqr()’s copies of the design (about three times its size), nor stops at its limit of 2^31 - 1 values, which ended the preparation of any larger design (for instance 2.2 * 10^8 rows and 10 covariates) with “too large a matrix for LINPACK”. Such designs can now be prepared for every model. - The checks that a weight, cluster or decision-maker column is constant within each choice situation now count distinct (situation, value) pairs in one grouping, instead of sorting every situation’s values separately, which alone allocated 87 of the 97 GB that
prepare_mxl_data()moved through the heap on the 8.7-million-row claims-style panel; the per-situation value is read from each situation’s first row. With all the changes above, on that panel and on a census-style panel of 9.8 million rows,prepare_mxl_data()now takes 2.9-3.7 s instead of 5.7-7.6 s, allocates 7.5-8.8 GB instead of 97-149 GB, and its peak heap use is 1.9 times the size of its input instead of 4.0-4.5 times; forprepare_mnl_data()andprepare_nl_data()it is 2.0-2.1 times instead of 4.4-5.7 times. Peak resident memory, which also counts memory R has freed but not returned, is 2.3-2.4 times the input for all three instead of 5.3-8.1 times (medians of three runs). -
run_mxlogit(scale_vars = )no longer holds a scaled copy of the design. It used to divideXandWby their column scales withsweep()and keep the results beside the original matrices for the whole fit, which also took two design-sized temporaries to form. The scales themselves came fromapply(), which first copies the whole matrix. Scaling is now a change of the optimizer’s coordinates (see “Scaling the optimizer’s coordinates” above), so the data are never divided, and the scales are computed a column at a time, collecting each column’s temporaries before the next on large designs. On the claims- and census-style panels above (8.7 and 9.8 million rows, 13 covariates), the memory a scaled fit holds while it optimizes fell by 0.9 and 1.0 GB, one copy ofXandW. Fitted from the long data, preparation included, a scaled fit’s peak heap is now an unscaled one’s: it fell from 3.3 to 1.9 times the size of the long data (from 3.4 and 3.9 GB to 2.0 and 2.2 GB), withscale_vars = "sd"and"mad"alike. With the data prepared beforehand,"sd"raises the peak by 0.06 GB at most (it raised it by 1.9 and 1.6 GB) and"mad"by 0.3 GB at most (2.6 and 2.3 GB before). Computing the scales adds 0.5-0.6 s to such a fit with"sd"and 2.8-3.3 s with"mad"(1.3-1.5 s and 3.7-4.2 s before, with the scaled copies), and"bhhh"’s extra gradient pass 0.35-0.46 s (single runs of one likelihood evaluation and the BHHH variance, under background load; the earlier times were medians of three alternated runs). The collection after each column takes longer the more distinct character strings a session holds, the data’s own included: about 0.05 s with 10^7.run_mnlogit(scale_vars = )no longer holds a scaled copy ofXeither (see above). -
prepare_mnp_data(),prepare_hmnl_data()andprepare_hmnp_data()(and sorun_mnprobit(),run_hmnlogit()andrun_hmnprobit()) now also copy only the columns the model uses, scan them for missing values one at a time, and check that the covariates are numeric without copying them. The hierarchical preparations also scan for non-finite covariate values a column at a time, and look up the choice situations to drop only when a row is flagged, where they grouped every row by choice situation twice. Apart from the changes below, the prepared objects are unchanged on every call the test suite makes and on a battery of edge cases; a used column that shared its name with a working column of the old code (HAS_NA,TASK_HAS_NA,HAS_BAD,TASK_HAS_BAD) stopped the preparation with an error, and now works like any other. - The same three functions now copy only the index columns into their working table and gather
Xin C++ from the caller’s covariate columns, as the MNL, MXL and NL preparations do;prepare_mnp_data()writes each choice situation’s differences from its base alternative in the same pass.Xis now always double precision there too (posterior draws are unchanged, bit for bit). The hierarchical preparations also read the alternative-level covariates by source row, call a covariate constant within choice situations when every row equals its situation’s first row, and look for an alternative listed twice in one pass over the keys; both checks used to evaluate R code for every choice situation. A few inputs now prepare differently. The choice-situation id used as a covariate (a task-order term, say) is now recognized as constant within every situation: it is kept with a message, or dropped with a warning when there is no outside option, where it used to be kept silently. Inputs whose column names clashed with the preparations’ own names are covered under Corrections. Integer covariates whose differences or within-situation ranges overflow 32-bit integers no longer fail. On synthetic inputs of 10 million rows in which the model uses every column,prepare_mnp_data()(2.5 million choice situations of four alternatives) now takes 5.0 s instead of 13.8 s, allocates 6.4 GB instead of 9.0 GB, and its peak heap use is 3.0 times the size of its input instead of 4.9 times;prepare_hmnl_data()(500,000 respondents with five choice situations of four alternatives each) takes 14.5 s instead of 31.7 s, allocates 12.2 GB instead of 15.5 GB, and peaks at 3.1 times its input instead of 4.9 times. Peak resident memory is 4.5 times the input instead of 8.0 times forprepare_mnp_data()and 4.7 times instead of 7.6 times forprepare_hmnl_data()(medians of three runs). - All six preparations now count each choice situation’s alternatives and chosen rows on private tables of the columns involved, which data.table sums in one pass, and number the rows within each situation in one pass too (
data.table::rowidv();prepare_mnp_data()has none to number); they used to evaluate R code for every choice situation. On the inputs of the previous item this halves the timeprepare_mnp_data()takes and cuts that ofprepare_hmnl_data()by two fifths; on the claims- and census-style panels above,prepare_mnl_data(),prepare_mxl_data()andprepare_nl_data()take 9-15% less time. Memory traffic grows by up to 3% (data.table’s working arrays for the sums), and peak heap use is unchanged or lower (medians of three alternated runs). The prepared objects are unchanged on every call the test suite makes and on a battery of edge cases.
Corrections
The following were found while implementing the panel likelihood above and the kernel and data-preparation work for population-scale data that followed, and independently verified. Each affected released versions unless it says otherwise.
mxl_hessian_parallel()silently dropped any choice situation whose simulated choice probability, summed over draws, was<= 1e-12, while the objective and gradient kept it. A situation that was merely very poorly fit by the current parameter vector — not one with genuinely zero simulated probability — could therefore be silently excluded from the analytical Hessian, which could then be substantially wrong, and so could the inverse-Hessian and sandwich standard errors built from it. Such situations are now kept; only units whose log-likelihood is not finite, which now takes utilities that overflow (see the log-probability item below), are skipped.When a choice situation’s simulated probability was subnormal (utility gaps of several hundred), the gradient came back identically zero alongside a finite objective value — a false stationary point that could stop the optimizer early — and the score-based variances (BHHH, robust, cluster) were non-finite. The posterior draw weights are now normalized in log space (matching the panel likelihood’s
omega_ns = exp(lambda_ns - LSE_s lambda_n.)), so these quantities are finite whenever the simulated probability is positive (and, with the next item, accurate).The mixed logit kernels took each choice situation’s log probability as
log(P). A choice probability that underflowed to zero, at a utility gap beyond about 745, made its decision maker’s log-likelihood-Infand with it the whole objective the optimizer’s1e10sentinel with a zero gradient; subnormal probabilities, at gaps beyond about 708, lost precision. The chance of hitting this grows with the number of choice situations: on a synthetic claims-style panel of 8.7 million stacked rows with a single choice made against a covariate value of 2000, the fit returned the sentinel at 342 of 540 evaluations and stopped where that choice’s probability underflows (a coefficient of -0.37, truth -0.5). The log probability is now the chosen utility minus the log-sum-exp of the choice set, as in the multinomial logit kernels, so the objective, gradient, Hessian, scores and conditional tastes are exact whenever the utilities are finite, however improbable the observed choices; the same fit now converges (-0.48). The sentinel, the Hessian’s skip of a unit andNAconditional tastes now occur only when the utilities themselves overflow (for instance an exploding Cholesky factor during a line search), and because finite objectives are no longer bounded below the sentinel,run_mxlogit()reports it as ten times the largest objective seen along the optimizer’s path. An explicit kernel overflow flag distinguishes the sentinel from a valid objective equal to1e10. Fits away from such regions agree with earlier versions to optimizer tolerance rather than bit for bit, since the optimizer’s path shifts slightly.mxl_hessian_parallel()now centers the draw scores in the Louis identity,sum_s omega_s (g_s - g_bar)(g_s - g_bar)'in place ofsum_s omega_s g_s g_s' - g_bar g_bar'. The two agree in exact arithmetic, but the former draw weights summed to one only to the precision of their log-scale arguments, and the uncentered form multiplied that error by|g|^2: for a choice far below its competitors, an entry of about 1 came out 0.15 off. Posterior weights now divide max-shifted exponentials by their sum, including in the batched score, and Hessian centering first removes a highest-weight draw’s score. This also prevents spurious curvature when a finite utility gap is so large that subtracting the log-sum-exp loses normalization accuracy. The weights described here were this development version’s log-space weights; released versions normalized linear weights, which sum to one to rounding precision, so there the uncentered form lost accuracy only at far larger scores.run_mxlogit()’s advanced workflow (input_data+eta_draws) recorded theSargument (default 100) instead of the number of draws ineta_draws, so post-hoc recomputation (vcov(fit, type = ), lazily computed standard errors, prediction) could regenerate a different draw set from the one used in estimation.draws_info$Snow recordsdim(eta_draws)[2]. Post-hoc methods reproduce the estimation draws only wheneta_drawswas built withget_halton_normals(S, U, K_w).-
The data preparations (
prepare_mnl_data(),prepare_mxl_data(),prepare_nl_data(),prepare_mnp_data(),prepare_hmnl_data(),prepare_hmnp_data(), and therun_*()functions that call them) could corrupt or misread an input column whose name matched one of their own. They added working columns with fixed names (alt_int,idx_in_group,HAS_NA, and in the hierarchical preparationsHB_PERSON,task_idx,TASK_HAS_NA,HAS_BAD,TASK_HAS_BAD) to a copy of the data, read per-situation results back under names (N,V1,chosen,pos) that an id column of the same name shadowed, and looked up variables of their own (levels,J,outside_opt_label,ids_to_drop,id_col,choice_col, and in the hierarchical preparationsperson_colandtask_by) where a column of the same name took their place.predict(),logsum()andconsumer_surplus()with a data.framenewdata,wesml_weights(),sample_by_choice(), and the hierarchical fits and their post-estimation methods did the same: they looked up variables of their own (am,pos,spec,d,choice_col,id_col,keep_ids) inside the data or the fit’salt_mapping, and read counts and strata back under names (V1,.strat) that an id column of the same name shadowed. These clashes changed the results without an error:- in
prepare_hmnl_data()/prepare_hmnp_data(), a covariate or control-function residual namedalt_int,idx_in_grouportask_idx, or one namedHB_PERSONwhen the respondent ids were numeric, was replaced by the alternative codes, the within-situation positions, the task indices or the respondent ids (values -1.2, -0.69, 2.29 entered the design matrix as 1, 2, 3), as was an alternative-level covariate namedidx_in_groupwhen every choice situation offered the same alternatives;prepare_mnp_data()differenced the codes of a covariate namedalt_int; - in the same two functions, a column named
task_by, other than the task column of a fit withoutperson_col, regrouped the rows by its own values: a covariate or control-function residual whose values differ from row to row made every row a choice situation of its own, without an error whenever the outside option was modelled, as it is by default (repeated values usually stopped the preparation); - in
prepare_mnl_data(),prepare_mxl_data()andprepare_nl_data(), a choice column also listed incovariate_colsleft the working table before the choices were counted: the preparation stopped with an unrelated error or, when an object of the column’s name was visible to the call (a vectorchoicein the workspace, say), counted that object instead, so thatalt_mappingcredited every alternative with every choice and, unless the rows were already in prepared order, the chosen alternatives were wrong; - a choice column named
alt_intoridx_in_grouprecorded the first alternative of every choice situation as chosen (idx_in_groupwas harmless inprepare_mnp_data()), and in the hierarchical preparations one namedtask_idxrecorded the outside good in every task but the first; - an id column named
alt_intoridx_in_groupwith the outside option, and in the hierarchical preparations a task column namedalt_intortask_idx, regrouped the rows into spurious choice situations; - with the outside option, any id, choice, covariate, weight, cluster or decision-maker column named
outside_opt_labelkept the outside-option rows as an extra inside alternative; - an alternative column named
idx_in_groupwith unequal choice sets added unidentified constants, leaving every standard errorNA; - in
predict(),logsum()andconsumer_surplus()with a data.framenewdata, a covariate namedposwhose values were valid positions in the fit’salt_mapping(without an outside option, any permutation of 1 to J within each choice situation, such as a rank) gave each row the alternative at that position, so the alternative-specific constants and the shares went to the wrong alternatives; - a column named
keep_idsmadesample_by_choice()return the wrong choice situations (the whole population when it was the id column).
These changed labels and summaries: an alternative column named
alt_int, or with equal choice setsidx_in_group, had its labels replaced by the codes; one namedTAKE_RATEorMKT_SHAREhad its labels, and so the ASC names, overwritten by the take rates or shares; one namedN_OBSorN_CHOICESwith numeric labels misstated the take rates or shares, and madegof(null = "market_shares")stop (N_OBS) or return a wrong null log-likelihood (N_CHOICES); and one namedJmislabelled the constants ofrun_mnlogit(),run_mxlogit(),run_nestlogit()andprepare_mnp_data()(with three alternatives labelled 1 to 3 or stored as a factor, the third alternative’s constant took the base alternative’s label). Whatever matched alternatives by label inherited the error:prepare_nl_data()assigns nests by label, so it stopped or, with text labels such as “1” to “10”, assigned wrong nests;predict(),logsum()andconsumer_surplus()withnewdatastopped or attached another alternative’s constant (labels 0 to 2 had become 1 to 3); andpredict()on the hierarchical fits, which takes an unknown label for a new alternative, predicted every alternative from posterior-predictive constants without an error.An id column named
Ngave the ids as the choice-set sizesM, in the preparations and inpredict(),logsum()andconsumer_surplus()withnewdata. The kernels’ checks usually stopped the fit or prediction, but a prediction whose ids summed to the number of rows (ids 1 to 11 with 6 alternatives each, for instance) ran on wrong choice sets without an error, and more rarely so could a fit. A covariate namedlevelsinprepare_mnl_data(),prepare_mxl_data()andprepare_nl_data()left every alternative codeNA(or, when its values repeated, stopped the preparation), and with the outside option an id column namedposgave the ids as the chosen positions; fitting then failed. Other clashes stopped with unrelated errors: an id column namedV1(the namedata.table::fread(header = FALSE)andas.data.frame()of an unnamed matrix give the first column) inprepare_mnl_data(),prepare_mxl_data()andprepare_nl_data()whenever a weight or cluster column was given, and always in the hierarchical preparations; a covariate namedalt_intoridx_in_groupinprepare_mnl_data(),prepare_mxl_data()andprepare_nl_data(); a column namedchoice_colthat a model used (other than the nested logit’s nest column), as did one namedid_coloutside the hierarchical preparations and, in those, one namedperson_colwhenperson_colwas given orid_colwhen it was not; an id or alternative column whose name contains a comma, which data.table’s grouping splits into several names; and an id, weight or cluster column named like a working column, among others. So did, outside the preparations, a covariate namedamin a data.framenewdata, most covariates there namedpos, and with an outside option one namedspec; an id column namedV1or.strat, or a choice column namedchoice_col, inwesml_weights()andsample_by_choice(); and an alternative column namedam, which stoppedrun_hmnlogit()andrun_hmnprobit(), ord, which stopped theirpredict()withoutnewdata,elasticities(),diversion_ratios()andppc_shares(), and for the HMNLlogsum()withoutnewdataandconsumer_surplus(). Fits whose data used a name in the first group, and fits with an id column namedNthat ran, should be re-estimated, and the predictions and samples in that group recomputed; for the second group, re-estimate nested logit fits and recompute predictions made withnewdata.Covariates are now read from the data rather than from the working table; the remaining working columns, per-situation results, counts and strata carry the reserved prefix
.choicer_or are read by position; and the preparations, post-estimation and the WESML helpers compute their own values, and read the columns they are given by name, before indexing the data oralt_mapping, so the names listed above are no longer looked up among the columns. Inputs without a clash give the same results as before. A choice column stored as text or as a factor, which the preparations,wesml_weights()andsample_by_choice()never accepted, and inprepare_mnl_data(),prepare_mxl_data()andprepare_nl_data()a covariate that is also the id, alternative, choice, weight, cluster or decision-maker column (see above for the choice column), now stop them with an error that says why. Two inputs are now errors: a column whose name starts with.choicer_, used as an id, alternative, choice, covariate, weight, cluster, nest or decision-maker column; and an alternative column namedalt_int,N_OBS,N_CHOICES,TAKE_RATEorMKT_SHARE, the fixed columns of the returnedalt_mapping, which must now be renamed. Released versions handled.choicer_columns correctly (they used no such names) and, of the alternative-column names, only analt_intcolumn already coded 1 to J, without the outside option. - in
A covariate named like a parameter the fit generates gave two parameters one name.
prepare_mnp_data()(and sorun_mnprobit()) names the constant of each non-base alternativeASC_<label>and buildsparam_mapby column name: since 0.2.0, a covariate such asASC_bnext to alternativeb’s constant left that constant in neither thebetanor theascblock, sorecovery_table()could not align the ASCs, andsummary()stopped with a duplicate row-name error; when the covariate was itself alternativeb’s dummy, one of the two columns was dropped as collinear without the usual message.run_mnlogit(),run_mxlogit()andrun_nestlogit()generateASC_<label>,Lambda_<k>(nested logit),Mu_<variable>andL_<i><j>(mixed logit), and the mixed logit’ssummary()printsSigma_<i><j>forL_<i><j>andexp(Mu_<variable>)for the mean of a log-normal coefficient; a covariate could take any of these names. Estimation addresses their parameters by position, so without named bounds (below) the estimates, standard errors and predictions were right. Butsummary()stopped with a duplicate row-name error, unless it relabelled the generated parameter (L_<i><j>, or a log-normalMu_<variable>), and indexingcoef()orvcov()by name, orwtp(attr_vars = ), found the covariate:wtp(attr_vars = "ASC_b")returned the covariate’s WTP, and the constant’s could not be reached by name. A named bound inrun_mxlogit()goes to the first parameter of that name, and the fixed coefficients come first, so it bounded the covariate’s coefficient and left the generated parameter free. With a covariate namedASC_b,lower = c(ASC_b = 0)held that covariate’s coefficient at 0 whenever its unconstrained estimate was negative, and so moved the other estimates, while alternativeb’s constant stayed unbounded; a covariate namedL_11would likewise have taken the documentedlower = c(L_11 = -5)meant for the first Cholesky diagonal. Such names are now an error that lists them:prepare_mnp_data()stops before buildingX(rename the covariates, or setuse_asc = FALSEfor ASC dummies built by hand), and the three other functions stop before optimizing,run_mxlogit()before building its draws. The same check stops aninput_datawhoseXrepeats a column name, and repeatedrun_nestlogit(param_names = ), which used to fit with the same ambiguities. Preparations and fits whose parameter names andsummary()labels are all distinct are unchanged,summary()included.Columns of class
integer64(bit64), whichdata.table::fread()returns for integers beyond 2^31 - 1 and several database drivers return forBIGINT, passed the check that covariates are numeric but entered the design matrices as their raw 64-bit patterns read as doubles, in every model of released versions. Positive values became tiny numbers (1 read as 4.9e-324, 2^40 as 5.4e-312), so fits, standard errors and predictions were silently wrong. Negative values above -2^52 becameNaN, which stopped the preparation with an unrelated error (NA/NaN/Inf in foreign function call); with bit64 loaded,prepare_mnp_data()differenced these columns as integers and so usually stopped the same way. When bit64 was not loaded, as for data read from an.rdsfile, the check for missing values misread these columns too: it took negative values for missing ones, dropping their choice situations with the warning about missing values, and let a missing value into the design as 0. The design builders now read integer64 columns as their values, asas.double()converts them:prepare_mnl_data(),prepare_mxl_data(),prepare_nl_data(),prepare_mnp_data(),prepare_hmnl_data()andprepare_hmnp_data()(includingalt_covariate_colsandcf_residual_col), and thenewdataofpredict(),logsum()andconsumer_surplus(). The conversion is exact below 2^53 in magnitude; from 2^53 up, values are rounded to the nearest double, with a warning naming the column. An integer64 column among those a model uses now loads bit64 first, so its missing values are found and handled as in other columns; if bit64 is not installed, such a column is an error. Weights (weights_col, aweightsvector) and integer64XandWmatrices in the list form ofnewdatahad the same defect and are now converted the same way, as are predictionweights. bit64 is now a suggested package.sample_by_choice()misreadinteger64ids, asdata.table::fread()returns ids beyond 2^31 - 1, ever since it was added in 0.2.0: it matched the sampled ids back to the rows afterunlist()had dropped their class. With bit64 loaded, the sample came back empty without an error; without it, each id drawn from among ids of large magnitude also selected its neighbours (about six choice situations for each one requested, for ids near 10^18). With the outside option,wesml_weights()returned its ids as raw bits (doubles such as 7.8e-242) whenever a choice situation chose the outside good and bit64 was not loaded, and withattach = TRUEstopped with an unrelated error. Both functions now load bit64 when the id, alternative or choice column isinteger64, as the preparations do, and keep the ids’ class, so they sample and weight such ids by value; if bit64 is not installed, such a column is an error. Samples of other ids are drawn as before.nl_blp_contraction()takeslambdaandnest_idxas given, not from a fitted model, and never checked them against each other: a nest code below 1 or missing, or alambdawith other than one entry per nest, failed an Armadillo index or size check inside the contraction’s parallel loop, which aborted R in builds with OpenMP and was an Armadillo error in builds without it. Both are now errors before the contraction starts. As in the other nested logit kernels, this includes a nest code below 1 on an alternative that no choice situation offers, which used to be ignored.run_mnprobit(),run_hmnlogit()andrun_hmnprobit()miscounted the draws to keep whenR - burn + thin - 1exceeded.Machine$integer.max, as for athinnear it: the count overflowed an integer. Where it came out 0 this aborted R in builds with OpenMP; otherwise the run stopped with an unrelated Armadillo error (withkeep_beta_i = "draws", the memory estimate failed first, with another unrelated error). The count,ceiling((R - burn) / thin), is now formed without overflow.Mixed logit fits with stored draws (
draws = "store", the default) read some units’ draws from the wrong place, without an error, once the draw cube (K_w * S * Nvalues) was large enough that a unit’s draws began past its first 2^32 values. The cube was built for anyS * Nup to 2^31 - 1 points (past that, building it failed). With three random coefficients, the default 100 draws and 15 million choice situations, for instance, it holds 36 GB, and the last 683,442 situations read other units’ draws in place of their own. Those draws entered the estimates and their standard errors, and every result that rebuilds the cube: standard errors recomputed withvcov(type = )orwesml_vcov(), predictions, elasticities, diversion ratios, BLP inversions, log-sums, consumer surplus and fit statistics. Such results should be recomputed. The compiled code used RcppArmadillo’s default 32-bit word, under which the kernels’ in-place view of a matrix or cube of more than 2^32 - 1 values wrapped its element count and element offsets: a unit’s draws, or a choice situation’s covariate values, that began past the first 2^32 values were read from the wrong place. A hand-builteta_drawsthat large was misread the same way. A design matrix that large (for instance 10^9 rows and 5 covariates) reached the kernels only through a hand-builtinput_data, thenewdataof a prediction or welfare function, or a direct call to an exported contraction function: the preparations stopped it earlier, atqr()’s limit of 2^31 - 1 values. The compiled code now uses Armadillo’s 64-bit word (ARMA_64BIT_WORD), so the kernels read matrices and cubes of any size R can hold, and outputs of more than 2^32 - 1 values, such as the output ofconditional_tastes(), are allocated where Armadillo stopped with “requested size is too large”. Below 2^32 values, results are unchanged, bit for bit.
choicer 0.2.1
CRAN release: 2026-09-04
Patch release addressing a compilation warning reported by CRAN’s GCC check flavors. No user-visible behavior, API, or numerical results change.
Compilation
- Replaced the seven
#pragma omp masterdirectives in the Gibbs samplers and thread-introspection helpers with aCHOICER_OMP_MASKEDmacro that emitsmaskedon OpenMP 5.1 and later,masteron earlier versions, and nothing when the package is built without OpenMP. OpenMP 5.1 deprecatedmasterin favor ofmasked, and GCC 16 — now the compiler on ther-devel-linux-x86_64-fedora-gccandr-devel-linux-x86_64-debian-gcccheck flavors — warns on the old spelling, whichR CMD checkreports as a significant install warning, escalating the check result to WARNING.maskedwith nofilterclause is semantically identical tomaster: the block runs on the primary thread with no implied barrier, so the samplers’ fixed-order reductions, barrier structure, and no-R-API-off-the-primary-thread contract are unchanged, as are posterior draws for a given seed and thread count.
choicer 0.2.0
CRAN release: 2026-07-14
Public API cleanup (breaking)
- Low-level C++ likelihood, gradient, Hessian, score, prediction, post-estimation, and Gibbs-sampler wrappers are now internal implementation details rather than exported package functions. High-level fitting, preparation, S3 post-estimation, simulation, and recovery APIs are unchanged. The expert-facing raw BLP contractions,
get_halton_normals(),set_num_threads(), andthread_info()remain public. With no downstream CRAN dependencies, v0.2.0 is the least disruptive point to narrow this surface before applications depend on unstable kernel signatures.
Robust and clustered standard errors (MNL / MXL / NL)
-
vcov()on a fitted MNL, MXL, or NL model gainstype =andcluster =arguments for post-hoc variance recomputation without refitting (requireskeep_data = TRUE):"hessian"(inverse analytical Hessian),"bhhh"(OPG),"robust"(Huber-White / WESML sandwich), and"cluster"(cluster-robust sandwich over within-cluster sums of weighted scores — use it when the same decision maker contributes several choice situations). With no arguments,vcov()returns the as-fitted variance, unchanged. - New
cluster_col=argument onrun_mnlogit()/run_mxlogit()/run_nestlogit()(and the correspondingprepare_*_data()functions): supplies per-situation cluster labels at fit time and selects the newse_method = "cluster". - All score-based variances (BHHH, robust, cluster) are now assembled in R from one per-situation score matrix, computed by new internal C++ kernels that reuse the BHHH loop bodies. The existing
se_method = "sandwich"/wesml_vcov()results are unchanged (the robust meat is thew^2special case of the shared path). - Scope note (MXL): clustering repairs the inference, not the estimand. The MXL simulated likelihood treats each choice situation as an independent draw from the mixing distribution (cross-sectional MSL, not the panel product form), so clustered standard errors on panel data are robust to within-person dependence but do not turn the fit into a panel mixed logit. For panel random coefficients use
run_hmnlogit()(person_col=).
Corrections
-
blp()for an MXL fit usingdraws = "generate"now carries the fitted draw count, seed, and digit-permutation setting through every contraction evaluation. Previously that path could evaluate counterfactual shares with fallback draw metadata rather than the simulation design used for the fit. - On-the-fly MXL draws now accept all 128 prime-base dimensions implemented by the generator (the prior guard rejected the 128th dimension); 129 or more random coefficients still fail early with an explicit limit.
- HMNL prediction and welfare now use a taskwise max-shifted softmax/logsum, so extreme counterfactual utilities remain finite. Counterfactual compensating variation accepts explicit non-negative task weights and matches baseline and policy choice situations by (
person_col,id_col) before subtraction; it now rejects added, dropped, or substituted tasks instead of pairing them silently by sorted position. - The documentation previously claimed that MCMC draws from the Gibbs samplers (
run_mnprobit(),run_hmnlogit(),run_hmnprobit()) are bitwise reproducible regardless of the OpenMP thread count. Independent validation disconfirmed this: draws are reproducible given the seed and a fixed thread count, and across different thread counts are invariant only up to floating-point reduction-order round-off (~1e-15), not bitwise. All man pages, vignettes, and NEWS entries now state the correct guarantee. - Documentation now distinguishes the frequentist models’ analytical derivatives from the Bayesian C++ samplers, labels
run_mxlogit()as a cross-sectional simulated likelihood, and no longer directs users to WTP-space estimation or bounded/censored mixing distributions as if choicer implemented them. The README also labelsmode_choiceshares and welfare as sample-design quantities unless external population shares and WESML weights are supplied. - The MNP tutorial no longer recommends unavailable user-specified starts and now states its current post-estimation boundary. HMNP documentation now makes explicit that it uses iid utility-level normal shocks rather than the full differenced-error covariance estimated by
run_mnprobit().
Hierarchical Bayes (HMNL/HMNP) convergence diagnostics and multi-chain performance
- New
ess(draws)(rank-normalized bulk and tail effective sample size) andmcse(draws, kind = c("mean", "median"))(Monte Carlo standard error of the mean or median), following Vehtari, Gelman, Simpson, Carpenter & Bürkner (2021, Bayesian Analysis).rhat()gains arank = TRUEoption for the same paper’s rank-normalized, folded R-hat; the defaultrank = FALSEreproduces the original split R-hat exactly. - New
traceplot()generic, with achoicer_hbmethod, for visual inspection of theb/theta/sigma_d2chains (and user-selecteddeltacolumns). -
summary()on achoicer_hmnl/choicer_hmnpfit now prints one consolidated multi-chain diagnostics table (R-hat, ESS bulk, ESS tail, and MCSE per parameter block, plus a single worst-case summary row spanning allJdelta_jalternative effects) in place of the previous bare split-R-hat printout. HMNP now always prints an explicit “conjugate — no acceptance step” line where HMNL prints its beta/delta acceptance rates. The fit-time convergence warning now checks rank-R-hat and ESS bulk across every tracked parameter, including allJdelta_jcolumns (previously onlyb/theta/sigma_d2were checked). -
run_hmnlogit()/run_hmnprobit()fits now retain all requested chains’ hierarchical draws in a newobject$chainsfield (a list, one element per chain:b,w_vech,delta,theta,sigma_d2, plusloglikfor HMNL /sigma2for HMNP), which the multi-chain diagnostics above consume.object$draws(chain 1) andobject$beta_i(chain-1-only respondent taste summaries/draws) are unchanged. - Chains requested via
chains=are sampled sequentially, and all of their hierarchical draws are retained (object$chains) for the multi-chain diagnostics above. Parallel multi-chain execution is planned for a future release. - The
keep_beta_i = "draws"memory guard on both models is now based on a measured ~1.9xRcpp::wrap()/R-list overhead factor (the previous formula under-estimated true memory use by roughly 2x) and accounts for all requested chains; it still fails fast before the chain runs rather than after an out-of-memory blow-up.
Hierarchical Bayesian MNL and MNP (BLP-style random-effects ASCs)
- New
run_hmnlogit()(classchoicer_hmnl) andrun_hmnprobit()(classchoicer_hmnp): hierarchical Bayes discrete choice with two decoupled random-effect levels — respondent-level structural tastesbeta_i ~ N(b, W)(WisK x K, no ASC columns) and a global BLP-style alternative effectdelta_j = z_j' theta + xi_j,xi_j ~ N(0, sigma_d^2)(one scalar variance regardless ofJ; partial pooling toward the characteristics-based mean; posterior-predictivedeltafor alternatives outside the estimation sample). Both models carry a first-class implicit outside option (systematic utility 0 plus its own shock) that anchors the location ofdelta— no base alternative or sum-to-zero constraint. Panel and cross-sectional (person_col = NULL,T_i = 1) modes share one code path. - HMNL: adaptive RW-Metropolis-within-Gibbs on the mnprobit engine pattern (single persistent OpenMP region, per-(iteration, unit) RNG streams; draws are reproducible given the seed and a fixed thread count, and invariant across thread counts only up to floating-point reduction-order round-off, ~1e-15), with a strictly serial
deltasweep (the conditionals are coupled through the softmax denominators) at O(1) incremental cost per affected task; supports log-normal coordinates viarc_dist. HMNP: fully conjugate Albert-Chib augmentation in un-differenced utility space with iidN(0, sigma^2)shocks, a non-identifiedsigma^2chain (parameter expansion), and per-draw scale normalization of every identified quantity. - Priors:
b ~ N(b_bar, A^-1),W ~ IW(nu, V),theta ~ N(theta_bar, A_theta^-1), andsigma_d ~ half-Cauchy(0, s_d)via the Makalic-Schmidt conjugate scale mixture (IG fallback available). - New preps
prepare_hmnl_data()/prepare_hmnp_data()(two-level person/task indexing, implicit-outside convention shared withprepare_mnl_data(), alternative-level designZ, optional control-function residual column for price endogeneity per Petrin & Train 2010) and DGPssimulate_hmnl_data()/simulate_hmnp_data(). - Post-estimation on the shared
choicer_hbclass, all posterior-draw based:predict()(population/individual, entry counterfactuals via the posterior-predictivedelta; HMNP probabilities by deterministic 20-node 1-D Gauss-Hermite approximation),wtp()(posterior median + quantile intervals),logsum()/consumer_surplus()(HMNL-only; probit Emax is roadmapped),elasticities()/diversion_ratios()(common-random-path perturbation engine including the outside option),recovery_table(), plusrhat()(split R-hat) andppc_shares()diagnostics. - Two new math articles:
vignettes/articles/hierarchical_mnl_math.Rmdandhierarchical_mnp_math.Rmd; recovery demos ininst/simulations/hmnl_simulation.R/hmnp_simulation.R.
WESML sandwich inference for MNL and nested logit
-
run_mnlogit()andrun_nestlogit()now compute the robust (Huber–White / WESML) sandwich varianceV = A^{-1} B A^{-1}, at full feature parity withrun_mxlogit(). Both gainse_method = "sandwich"(bread = weighted negated Hessian, meat = weight-squared OPG) andse_method = "bhhh"(ordinary outer-product-of-gradients).run_mnlogit()’sse_methodtherefore now accepts"hessian"(default),"bhhh", or"sandwich";run_nestlogit()’s accepts"hessian"(default),"numeric","bhhh", or"sandwich". - New C++ kernels
mnl_bhhh_parallel()andnl_bhhh_parallel()accumulate the weighted outer product of per-individual scores. Their per-individual score is weight-free, so passingweights = wyields the BHHH information andweights = w^2yields the sandwich meat. The NL kernel includes the full beta/lambda/delta score blocks (singleton-nest lambdas fixed to 1 contribute no score). -
run_mnlogit(),run_nestlogit(),prepare_mnl_data()andprepare_nl_data()gain aweights_colargument: a row-level weight column is collapsed to one weight per choice situation (validated constant withinid). A choice-based-sampling provenance guard auto-adopts the recorded WESML weight column and errors rather than silently fitting unweighted under a WESML label; non-uniform weights under a non-sandwichse_methodemit a warning. - Behavior change: weighted fits now emit a steering warning recommending
se_method = "sandwich"when non-uniform weights are supplied with the default ("hessian") or"bhhh"method; the"bhhh"case gets a sharper message explaining that BHHH/OPG is not a valid WESML correction (its meat isw^1, notw^2). Point estimates and standard errors are unchanged. -
wesml_vcov()now dispatches onchoicer_mnlandchoicer_nl(in addition tochoicer_mxl), returning the post-hoc sandwich variance from a fit stored withkeep_data = TRUE. -
summary()for MNL and NL fits now reports the standard-error method and any WESML weighting in the printed footer. - Added “Choice-Based Sampling and WESML Weighting” sections to the multinomial logit and nested logit derivation vignettes.
Weighting safety hardening
- Weights are now validated to be finite and strictly positive in
prepare_mnl_data(),prepare_nl_data(), andprepare_mxl_data()(covering both theweights=andweights_col=paths). Zero, negative, or non-finite weights previously could slip through and silently invalidate weighted and WESML sandwich inference (weightwenters the bread,w^2the meat); they now error with an actionable message. - Advanced-mode fits (
input_data=passed directly torun_mnlogit(),run_nestlogit(), orrun_mxlogit()) that carry WESMLchoice_samplingprovenance but resolve to uniform weights now error instead of warning. The message explains how to proceed: bake the non-uniform WESML weights intoinput_dataviaprepare_*_data(weights=/weights_col=), or strip the provenance withattr(input_data, "choice_sampling") <- NULLfor a deliberate unweighted fit. Convenience-mode behavior is unchanged.
Documentation
- Added eight vignettes: a getting-started tour (“Discrete choice from data to policy, in a dozen lines”), one per model (multinomial logit, mixed logit, nested logit, Bayesian multinomial probit, hierarchical Bayes), one on choice-based sampling and WESML weights, and one on standard errors (Hessian / BHHH / robust / cluster-robust, and when to use which).
- Added the
mode_choicedata set: the classic Greene & Hensher intercity travel-mode choice data (210 travellers x 4 modes), in choicer’s long layout, used by the getting-started vignette.?mode_choiceand the vignettes now document that the sample is choice-based (car under-sampled): slopes and WTP ratios are unaffected in the ASC-saturated logit, while constants, shares, and surplus levels inherit the design — see the WESML vignette for the correction. - Added a pkgdown website configuration, including the model derivation notes as “The math behind choicer” articles.
- Expanded the identification discussions across the documentation: price endogeneity and the control-function route in the getting-started model-choice section (Petrin & Train 2010;
blp()as the Berry 1994 inversion), what identifies the nested-logit dissimilarity parameters, the Keane (1992) covariance-identification caveat for the multinomial probit, and the classical MSL asymptotics (fixed-Sbias,Sgrowth conditions) in the mixed logit math note.
Mixed logit — on-the-fly digit-permuted Halton draws
-
run_mxlogit()gains three new arguments:draws,seed, andscramble.-
draws = "store"(default) keeps the existing behavior: a full K_w × S × N Halton cube is pre-materialized and stored in memory. -
draws = "generate"activates the new mode: each individual’s S draws are computed on the fly in C++ from a compact seed, eliminating the O(N)eta_drawscube. This is the recommended choice when N is large or memory is constrained. -
scramble = "permuted"(default whendraws = "generate") applies one seeded permutation per dimension and base-digit position, shared across sequence indices. This is a deterministic position-wise digit permutation, not Owen’s nested-uniform scramble, and no standard randomized-QMC unbiasedness or replicate-error guarantee is claimed. The historical value"owen"remains as a deprecated alias for compatibility.scramble = "none"reproduces the randtoolbox sequence exactly. -
seedsets the integer master seed for the on-the-fly generator; ifNULL(default), a seed is drawn from R’s RNG soset.seed()governs reproducibility. Ignored whendraws = "store".
-
- The
draws_infofield onchoicer_mxlobjects gains three new elements:mode("store"or"generate"),seed, andscramble. Existing code that accessesdraws_info$S,draws_info$N, ordraws_info$K_wis unaffected; the new fields areNULLfor objects fitted before this release. - All post-estimation generics (
predict(),elasticities(),diversion_ratios(),logsum(),consumer_surplus(),vcov()) propagate the fitted draw mode automatically — no user action required. - Default behavior (
draws = "store") is bitwise unchanged.
Bayesian models
-
run_mnprobit()— Bayesian multinomial probit via Gibbs sampling with data augmentation (Albert & Chib 1993; McCulloch & Rossi 1994). Runs the non-identified chain with conjugate priors and reports identified quantities normalized per draw bysigma_11. Newchoicer_mnpposterior object withsummary()(posterior mean, SD, credible intervals),coef(),vcov(),nobs(); math note invignettes/articles/bayesian_multinomial_probit_math.Rmd - C++ MCMC infrastructure built from scratch (
src/rng.h,src/bayes_samplers.h): xoshiro256++/splitmix64 RNG with one stream per (iteration, observation) — draws are reproducible given the seed and a fixed OpenMP thread count (invariant across thread counts only up to floating-point reduction-order round-off, ~1e-15) — plus exact truncated-normal, multivariate-normal, Wishart (Bartlett), and inverse-Wishart samplers. The truncated normal picks the cheapest exact method per region (naive normal rejection in high-mass regions, Robert 1995 exponential rejection in the tail, inverse CDF for narrow intervals) - The Gibbs chain runs inside a single persistent OpenMP region: the latent-utility sweep and mean refresh are work-shared across choice situations, the conjugate beta/Sigma conditionals run on the master thread between lightweight barriers with hand-rolled fixed-order linear algebra (no BLAS inside the region), and the truncated-normal conditional moments are hoisted per Sigma draw. On the
_benchmarks/MNP preset this samples ~2x faster thanMNP::mnp()and ~3.5x faster thanbayesm::rmnpGibbs()on one thread, and scales with threads on top -
simulate_mnp_data()— probit DGP returning achoicer_simwith truth on the identified scale;recovery_table()gains achoicer_mnpmethod (posterior mean/SD, normal-approximation credible intervals,sigmablock); parameter-recovery walkthrough ininst/simulations/mnp_simulation.R
Post-estimation
-
wtp()— willingness-to-pay for MNL, MXL, and NL with analytic delta-method standard errors. For MXL, log-normal random coefficients report the median WTP under the package’s shifted log-normal parameterization; random price coefficients are rejected -
gof()— goodness of fit: McFadden pseudo R-squared (plain and adjusted) and in-sample hit rate, with"equal_shares"(default) and"market_shares"null models; now also shown in thesummary()footer -
predict(..., newdata = )— counterfactual prediction for all three models, from either a long data.frame in the fit-time format or a modified-design list (X,alt_idx,M, …); works even withkeep_data = FALSE. NL fits now storenest_idxtop-level to support this -
logsum()— expected maximum utility (inclusive value) per choice situation for MNL (closed form), MXL (simulated with a dedicated per-draw kernel, avoiding the Jensen bias of averaging utilities first), and NL (nested inclusive-value formula) -
consumer_surplus()— expected consumer surpluslogsum / (-alpha)(Train 2009, Ch. 3) with a delta-method standard error of the mean CS for MNL; supportsnewdatafor policy ΔCS analysis
choicer 0.1.0
CRAN release: 2026-05-20
Initial CRAN release.
Supported models
-
Multinomial Logit (
run_mnlogit()) — estimation, prediction, elasticities, diversion ratios, BLP contraction -
Mixed Logit (
run_mxlogit()) — normal and log-normal random coefficients, correlated random coefficients via Cholesky, Halton draws, elasticities, BLP contraction -
Nested Logit (
run_nestlogit()) — estimation with nest-specific dissimilarity (lambda) parameters
Post-estimation generics
-
elasticities()— methods for MNL and MXL -
diversion_ratios()— method for MNL -
blp()— BLP contraction for MNL and MXL
API
- Dual workflow for all
run_*logit()functions: convenience (passdata+ column names) or advanced (pass pre-preparedinput_data) - Pluggable optimizer via
optimizer = "nloptr" | "optim" | <function> -
prepare_nl_data()added for nested logit data preparation