fit_demand_tmb() fits continuous mixed-effects demand
models using Template Model
Builder (TMB). It is the modern alternative to
fit_demand_mixed() (which uses nlme) and
provides several advantages:
nlmeThis vignette covers fit_demand_tmb() for continuous
consumption data. For two-part hurdle models that
explicitly model zero consumption, see
vignette("hurdle-demand-models"). For individual
NLS curves, see vignette("fixed-demand").
fit <- fit_demand_tmb(
dat,
y_var = "y", x_var = "x", id_var = "id",
equation = "exponential",
random_effects = c("q0", "alpha"),
verbose = 0
)
fit
#>
#> TMB Mixed-Effects Demand Model
#>
#> Call:
#> fit_demand_tmb(data = dat, y_var = "y", x_var = "x", id_var = "id",
#> equation = "exponential", random_effects = c("q0", "alpha"),
#> verbose = 0)
#>
#> Equation: exponential
#> Convergence: Yes
#> Number of subjects: 99
#> Number of observations: 1131
#> Observations dropped (zeros): 569
#> Random effects: 2 (q0, alpha)
#> Log-likelihood: -268.54
#> AIC: 551.07
#>
#> Fixed Effects:
#> Q0.0 alpha.0 log_k logsigma logsigma logsigma_e rho_raw
#> 1.6944 -4.5060 0.3399 -0.2940 -0.0952 -1.4906 -0.4812
#>
#> Use summary() for full results.summary(fit)
#>
#> TMB Mixed-Effects Demand Model Summary
#> ==================================================
#>
#> Equation: exponential
#> Backend: TMB_mixed
#> Convergence: Yes
#> Subjects: 99 Observations: 1131
#>
#> --- Fixed Effects ---
#> term estimate std.error statistic p.value
#> Q0:(Intercept) 5.4434 0.4171 22.1140 < 2e-16
#> alpha:(Intercept) 0.0110 0.0012 -42.4332 < 2e-16
#> log_k 0.3399 0.0276 12.2980 < 2e-16
#> logsigma -0.2940 0.0748 -3.9297 8.51e-05
#> logsigma -0.0952 0.0807 -1.1796 0.238169
#> logsigma_e -1.4906 0.0231 -64.4070 < 2e-16
#> rho_raw -0.4812 0.1310 -3.6742 0.000239
#>
#> --- Variance Components ---
#> (Q0/alpha RE SDs on log10 scale; residual SD on likelihood scale)
#> Component Estimate
#> sigma_b (Q0 RE SD) 0.3237
#> sigma_c (alpha RE SD) 0.3948
#> sigma_e (Residual SD) 0.2252
#>
#> --- RE Correlations ---
#> Component Estimate
#> rho_bc (Q0-alpha correlation) -0.4472
#>
#> --- Fit Statistics ---
#> Log-likelihood: -268.54
#> AIC: 551.07
#> BIC: 586.29
#>
#> --- Population Demand Metrics ---
#> Pmax: 8.6507 Omax: 12.6853 Method: analytic_lambert_w
#>
#> --- Individual Parameter Summaries ---
#> Q0: Min=1.0764 Med=5.8144 Mean=6.9916 Max=27.2637
#> alpha: Min=0.0012 Med=0.0117 Mean=0.0180 Max=0.1803
#> Pmax: Min=0.3701 Med=8.9913 Mean=10.5038 Max=54.8223
#> Omax: Min=0.7770 Med=11.9321 Mean=17.2419 Max=120.2688
#>
#> Notes:
#> * 569 zero-consumption observations dropped for equation='exponential'.Population demand curve from the exponential equation.
The exponential equation (Hursh & Silberberg, 2008)
models log-transformed consumption and is the most reliable choice for
2-random-effect models. It automatically drops zero-consumption
observations.
fit_demand_tmb() supports four demand equations:
| Equation | Response | Zeros | k | Best for |
|---|---|---|---|---|
"exponential" |
log(Q) | Dropped | Estimated or fixed | Most datasets; robust 2-RE convergence |
"exponentiated" |
Raw Q | Allowed | Estimated or fixed | Data with few zeros; 1-RE models |
"simplified" |
Raw Q | Allowed | None | Simpler model without k |
"zben" |
LL4(Q) | Allowed (via transform) | None | Wide dynamic range with LL4 compression |
Exponential (Hursh & Silberberg, 2008): \[\log(Q_{ij}) = \log(Q_{0i}) + k \left(e^{-\alpha_i \cdot Q_{0i} \cdot C_j} - 1\right) + \varepsilon_{ij}\]
Exponentiated (Koffarnus et al., 2015): \[Q_{ij} = Q_{0i} \cdot 10^{k \left(e^{-\alpha_i \cdot Q_{0i} \cdot C_j} - 1\right)} + \varepsilon_{ij}\]
Simplified: \[Q_{ij} = Q_{0i} \cdot e^{-\alpha_i \cdot Q_{0i} \cdot C_j} + \varepsilon_{ij}\]
Zero-bounded exponential (zben) (see
src/MixedDemand.h for full specification): \[\log_{10}(Q_{0i}) \cdot
e^{-\frac{\alpha_i}{\log_{10}(Q_{0i})} \cdot Q_{0i} \cdot C_j} +
\varepsilon_{ij}\]
where \(Q_{0i} = \exp(\mathbf{x}_i^\top \boldsymbol{\beta}_{Q_0} + b_i)\), \(\alpha_i = \exp(\mathbf{x}_i^\top \boldsymbol{\beta}_\alpha + c_i)\), and \((b_i, c_i)\) are correlated random effects.
fit_exp <- fit_demand_tmb(dat, equation = "exponential",
random_effects = c("q0", "alpha"), verbose = 0)# exponentiated converges reliably with 1-RE
fit_expon <- fit_demand_tmb(dat, equation = "exponentiated",
random_effects = "q0", verbose = 0)dat$y_ll4 <- ll4(dat$y)
fit_zben <- fit_demand_tmb(dat, y_var = "y_ll4", equation = "zben",
random_effects = "q0", verbose = 0)data.frame(
equation = c("exponential", "exponentiated", "simplified", "zben"),
random_effects = c("2-RE", "1-RE", "1-RE", "1-RE"),
converged = c(fit_exp$converged, fit_expon$converged,
fit_simp$converged, fit_zben$converged),
AIC = round(c(AIC(fit_exp), AIC(fit_expon),
AIC(fit_simp), AIC(fit_zben)), 1)
)
#> equation random_effects converged AIC
#> 1 exponential 2-RE TRUE 551.1
#> 2 exponentiated 1-RE TRUE 6744.5
#> 3 simplified 1-RE TRUE 6743.4
#> 4 zben 1-RE TRUE -893.6Note: AIC values are not directly comparable across equations because they model different response scales (log Q vs raw Q vs LL4(Q)).
Convergence tip: The exponential
equation is the most reliable for 2-random-effect models. The
exponentiated equation works well with 1-RE but can
struggle to converge with 2-RE, especially with smaller samples.
fit_demand_tmb() supports two configurations:
random_effects = "q0" – 1-RE: random
intercept on \(Q_0\) only; \(\alpha\) is constant across subjectsrandom_effects = c("q0", "alpha") –
2-RE: random effects on both \(Q_0\) and \(\alpha\) with an estimated correlationfit_1re <- fit_demand_tmb(dat, equation = "exponential",
random_effects = "q0", verbose = 0)
fit_2re <- fit # reuse from Quick Start (exponential, 2-RE)compare_models(fit_1re, fit_2re)
#>
#> Model Comparison
#> ==================================================
#>
#> Model Class Backend nobs df logLik AIC BIC
#> Model_1 beezdemand_tmb TMB_mixed 1131 5 -639.5903 1289.1807 1314.3350
#> Model_2 beezdemand_tmb TMB_mixed 1131 7 -268.5364 551.0729 586.2889
#> delta_AIC delta_BIC
#> 738.1078 728.0461
#> 0.0000 0.0000
#>
#> Best model by BIC: Model_2
#>
#> Likelihood Ratio Tests:
#> ----------------------------------------
#> Comparison LR_stat df p_value
#> Model_1 vs Model_2 742.1078 2 <2e-16
#>
#> Notes:
#> - LRT nesting assumption not verified.A significant LRT p-value and substantially lower AIC/BIC for the 2-RE model indicate that subjects differ meaningfully in both intensity (\(Q_0\)) and elasticity (\(\alpha\)).
head(nlme::ranef(fit_2re))
#> id b_i c_i q0_(Intercept) alpha_(Intercept)
#> 1 16 -0.8553425 0.38606925 -0.8553425 0.38606925
#> 2 24 0.0892061 -0.04866633 0.0892061 -0.04866633
#> 3 33 0.6114842 -0.35230990 0.6114842 -0.35230990
#> 4 40 0.1527007 0.50021504 0.1527007 0.50021504
#> 5 42 0.7378500 -0.89299758 0.7378500 -0.89299758
#> 6 49 -0.2956502 0.40088468 -0.2956502 0.40088468The b_i column is the random deviation on log(\(Q_0\)) and c_i is the random
deviation on log(\(\alpha\)) for each
subject.
For the exponential and exponentiated
equations, \(k\) scales the range of
the demand curve. By default it is estimated
(estimate_k = TRUE). You can fix it at the conventional
value of 2 (Hursh & Silberberg, 2008):
fit_k_free <- fit_demand_tmb(dat, equation = "exponential",
random_effects = c("q0", "alpha"),
estimate_k = TRUE, verbose = 0)
fit_k_fixed <- fit_demand_tmb(dat, equation = "exponential",
random_effects = c("q0", "alpha"),
estimate_k = FALSE, k = 2, verbose = 0)
data.frame(
k = c("estimated", "fixed at 2"),
converged = c(fit_k_free$converged, fit_k_fixed$converged),
AIC = round(c(AIC(fit_k_free), AIC(fit_k_fixed)), 1),
k_value = round(c(exp(coef(fit_k_free)[["log_k"]]), 2), 3)
)
#> k converged AIC k_value
#> 1 estimated TRUE 551.1 1.405
#> 2 fixed at 2 TRUE 607.1 2.000The simplified and zben equations do not
use a \(k\) parameter.
summary(fit_2re)
#>
#> TMB Mixed-Effects Demand Model Summary
#> ==================================================
#>
#> Equation: exponential
#> Backend: TMB_mixed
#> Convergence: Yes
#> Subjects: 99 Observations: 1131
#>
#> --- Fixed Effects ---
#> term estimate std.error statistic p.value
#> Q0:(Intercept) 5.4434 0.4171 22.1140 < 2e-16
#> alpha:(Intercept) 0.0110 0.0012 -42.4332 < 2e-16
#> log_k 0.3399 0.0276 12.2980 < 2e-16
#> logsigma -0.2940 0.0748 -3.9297 8.51e-05
#> logsigma -0.0952 0.0807 -1.1796 0.238169
#> logsigma_e -1.4906 0.0231 -64.4070 < 2e-16
#> rho_raw -0.4812 0.1310 -3.6742 0.000239
#>
#> --- Variance Components ---
#> (Q0/alpha RE SDs on log10 scale; residual SD on likelihood scale)
#> Component Estimate
#> sigma_b (Q0 RE SD) 0.3237
#> sigma_c (alpha RE SD) 0.3948
#> sigma_e (Residual SD) 0.2252
#>
#> --- RE Correlations ---
#> Component Estimate
#> rho_bc (Q0-alpha correlation) -0.4472
#>
#> --- Fit Statistics ---
#> Log-likelihood: -268.54
#> AIC: 551.07
#> BIC: 586.29
#>
#> --- Population Demand Metrics ---
#> Pmax: 8.6507 Omax: 12.6853 Method: analytic_lambert_w
#>
#> --- Individual Parameter Summaries ---
#> Q0: Min=1.0764 Med=5.8144 Mean=6.9916 Max=27.2637
#> alpha: Min=0.0012 Med=0.0117 Mean=0.0180 Max=0.1803
#> Pmax: Min=0.3701 Med=8.9913 Mean=10.5038 Max=54.8223
#> Omax: Min=0.7770 Med=11.9321 Mean=17.2419 Max=120.2688
#>
#> Notes:
#> * 569 zero-consumption observations dropped for equation='exponential'.The fixed-effect coefficient table reports estimate and
std.error on the scale set by report_space:
the default, "natural", back-transforms \(Q_0\), \(\alpha\), and \(k\) off the log estimation scale. The
statistic and p.value columns are always
computed on the estimation (log) scale, where the Wald test is well
defined; they are not recomputed from the back-transformed
estimate and std.error (that recompute is
degenerate for a strictly positive parameter – it would test “ratio = 0”
rather than “ratio = 1”). One consequence:
estimate / std.error from the default table does not
reproduce the reported statistic. This matches the
broom and emmeans convention; pass
report_space = "internal" (or "log10") to read
the estimate and SE on the same scale as the test.
The variance_components block reports the \(Q_0\) and \(\alpha\) random-effect standard deviations
on the log10 scale. TMB estimates these SDs on the
natural-log scale internally; summary() divides them by
\(\log(10)\) so they are directly
comparable with nlme::VarCorr() on a
fit_demand_mixed() fit using the default
param_space = "log10". The residual SD is reported on the
model’s likelihood scale, which is equation-dependent (log-consumption
for exponential, the LL4-transformed y_var for
zben); the random-effect correlations are
scale-invariant.
For users coming from nlme or lme4, the
VarCorr() accessor returns these same variance components
in the familiar nlme::VarCorr() matrix layout – a
Variance / StdDev matrix (with a
Corr column for pdSymm fits) and a final
Residual row:
VarCorr(fit_2re)
#> Variance StdDev Corr
#> Q0 0.1050 0.324
#> alpha 0.1560 0.395 -0.447
#> Residual 0.0507 0.225Note that tidy() (shown next) reports the raw internal
optimizer parameters instead – the logsigma rows are the
natural log of each RE SD, not the log10-scale SDs from
summary()$variance_components.
tidy(fit_2re)
#> # A tibble: 6 × 9
#> term estimate std.error statistic p.value component estimate_scale
#> <chr> <dbl> <dbl> <dbl> <dbl> <chr> <chr>
#> 1 Q0:(Intercep… 5.44 0.417 22.1 2.32e-108 fixed natural
#> 2 alpha:(Inter… 0.0110 0.00117 -42.4 0 fixed natural
#> 3 log_k 0.340 0.0276 12.3 9.28e- 35 fixed log
#> 4 sigma_b (Q0 … 0.324 NA NA NA variance log10
#> 5 sigma_c (alp… 0.395 NA NA NA variance log10
#> 6 sigma_e (Res… 0.225 NA NA NA variance natural
#> # ℹ 2 more variables: term_display <chr>, estimate_internal <dbl>Each subject gets empirical Bayes estimates of \(Q_0\), \(\alpha\), \(P_{max}\), and \(O_{max}\):
spars <- get_subject_pars(fit_2re)
head(spars)
#> id b_i c_i Q0 alpha Pmax Omax
#> 1 16 -0.8553425 0.38606925 2.314197 0.016246302 13.830982 8.622529
#> 2 24 0.0892061 -0.04866633 5.951310 0.010518438 8.306997 13.317967
#> 3 33 0.6114842 -0.35230990 10.033098 0.007763911 6.675631 18.042997
#> 4 40 0.1527007 0.50021504 6.341441 0.018210732 4.502903 7.692399
#> 5 42 0.7378500 -0.89299758 11.384528 0.004521295 10.102527 30.983205
#> 6 49 -0.2956502 0.40088468 4.050155 0.016488790 7.786593 8.495724spars |>
summarise(
across(c(Q0, alpha, Pmax, Omax),
list(median = median, mean = mean, sd = sd),
.names = "{.col}_{.fn}")
) |>
tidyr::pivot_longer(everything(), names_to = c("parameter", "stat"),
names_sep = "_") |>
tidyr::pivot_wider(names_from = stat, values_from = value) |>
mutate(across(where(is.numeric), \(x) round(x, 4)))
#> # A tibble: 4 × 4
#> parameter median mean sd
#> <chr> <dbl> <dbl> <dbl>
#> 1 Q0 5.81 6.99 5.01
#> 2 alpha 0.0117 0.018 0.0238
#> 3 Pmax 8.99 10.5 7.53
#> 4 Omax 11.9 17.2 17.3calculate_amplitude_persistence() collapses the
per-subject parameters into two latent factors used in behavioral
economic indices: Amplitude (intensity of demand,
primarily \(Q_0\)) and
Persistence (sensitivity to price, drawn from \(P_{max}\), \(O_{max}\), and \(1/\alpha\)). The TMB method extracts
subject parameters from fit$subject_pars and delegates to
the default Z-score implementation, so the result is comparable across
model tiers.
ap <- calculate_amplitude_persistence(fit_2re)
head(ap)
#> id z_Q0 z_Pmax z_Omax z_inv_alpha Amplitude Persistence
#> 1 16 -0.9332306 0.44158011 -0.49884140 -0.49884140 -0.9332306 -0.1853676
#> 2 24 -0.2075582 -0.29156364 -0.22709436 -0.22709436 -0.2075582 -0.2485841
#> 3 33 0.6068353 -0.50807870 0.04636527 0.04636527 0.6068353 -0.1384494
#> 4 40 -0.1297198 -0.79644335 -0.55267242 -0.55267242 -0.1297198 -0.6339294
#> 5 42 0.8764710 -0.05326084 0.79527589 0.79527589 0.8764710 0.5124303
#> 6 49 -0.5868744 -0.36063167 -0.50618019 -0.50618019 -0.5868744 -0.4576640ap |>
summarise(
Amplitude_mean = mean(Amplitude, na.rm = TRUE),
Persistence_mean = mean(Persistence, na.rm = TRUE),
Amplitude_sd = sd(Amplitude, na.rm = TRUE),
Persistence_sd = sd(Persistence, na.rm = TRUE)
)
#> Amplitude_mean Persistence_mean Amplitude_sd Persistence_sd
#> 1 7.065932e-18 -2.846441e-18 1 0.8497968By construction, both factors are sample-standardized (mean 0, SD 1
within the fitted dataset). For cross-sample comparisons, supply
external basis_means and basis_sds so the
standardization uses a fixed reference.
confint(fit_2re)
#> # A tibble: 7 × 5
#> term estimate conf.low conf.high level
#> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Q0:(Intercept) 1.69 1.54 1.84 0.95
#> 2 alpha:(Intercept) -4.51 -4.71 -4.30 0.95
#> 3 log_k 0.340 0.286 0.394 0.95
#> 4 logsigma -0.294 -0.441 -0.147 0.95
#> 5 logsigma -0.0952 -0.254 0.0630 0.95
#> 6 logsigma_e -1.49 -1.54 -1.45 0.95
#> 7 rho_raw -0.481 -0.738 -0.225 0.95By default, confidence intervals are on the internal (log) scale. Use
report_space = "natural" for back-transformed
intervals:
confint(fit_2re, report_space = "natural")
#> # A tibble: 7 × 5
#> term estimate conf.low conf.high level
#> <chr> <dbl> <dbl> <dbl> <dbl>
#> 1 Q0:(Intercept) 5.44 4.68 6.33 0.95
#> 2 alpha:(Intercept) 0.0110 0.00897 0.0136 0.95
#> 3 log_k 1.40 1.33 1.48 0.95
#> 4 logsigma -0.294 -0.441 -0.147 0.95
#> 5 logsigma -0.0952 -0.254 0.0630 0.95
#> 6 logsigma_e -1.49 -1.54 -1.45 0.95
#> 7 rho_raw -0.481 -0.738 -0.225 0.95For a quick diagnostic on the Gaussian (Wald) approximation, request
Monte Carlo intervals with method = "simulate". These draw
R samples from the joint asymptotic posterior
N(coef, vcov) and report empirical quantiles. They are
asymptotically Wald-equivalent, so a large discrepancy between the two
flags a fit where the Gaussian approximation is suspect. This is a
diagnostic comparison, not an accuracy improvement: the
simulate method does not capture non-Gaussian posterior
shape and carries no positivity guarantee on the internal scale. Set
seed for reproducibility.
ci_wald <- confint(fit_2re)
ci_sim <- confint(fit_2re, method = "simulate", R = 2000, seed = 42)
data.frame(
term = ci_wald$term,
width_wald = ci_wald$conf.high - ci_wald$conf.low,
width_sim = ci_sim$conf.high - ci_sim$conf.low
)
#> term width_wald width_sim
#> 1 Q0:(Intercept) 0.30035058 0.29461890
#> 2 alpha:(Intercept) 0.41625530 0.42113249
#> 3 log_k 0.10833786 0.10835459
#> 4 logsigma 0.29327780 0.29006254
#> 5 logsigma 0.31651456 0.31452214
#> 6 logsigma_e 0.09071999 0.09080796
#> 7 rho_raw 0.51338445 0.52553410vcov() returns the fixed-effect variance-covariance
matrix from the TMB sdreport — the inverse of the negative Hessian at
the MLE, restricted to fixed effects after Laplace-marginalizing the
random effects. Combined with the optimizer’s internal parameter vector
(coef(fit, type = "internal")), it lets you apply the delta
method to any nonlinear function of the parameters via
car::deltaMethod. Pass the parameter vector explicitly so
the call is stable across the planned coef() default change
in a future release.
beta <- coef(fit_2re, type = "internal")
car::deltaMethod(
beta,
paste0(names(beta)[1], " * 1"),
vcov. = vcov(fit_2re)
)
#> Estimate SE 2.5 % 97.5 %
#> beta_q0 * 1 1.694405 0.076621 1.544230 1.8446fitted(fit) and residuals(fit) are also
exposed as direct accessors, matching the model-scale convention used by
broom::augment() (log scale for "exponential",
natural/LL4 scale for others). Pass scale = "natural" to
back-transform.
Three prediction types are available:
# Fitted values for observed data
pred_resp <- predict(fit_2re, type = "response")
head(pred_resp)
#> # A tibble: 6 × 9
#> id gender age binges totdrinks tothours x y .fitted
#> <fct> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 16 Male 30 0 1 1 0 2 0.839
#> 2 16 Male 30 0 1 1 0.25 2 0.809
#> 3 16 Male 30 0 1 1 0.5 2 0.779
#> 4 16 Male 30 0 1 1 1 2 0.720
#> 5 16 Male 30 0 1 1 1.5 2 0.662
#> 6 16 Male 30 0 1 1 2 2 0.605# Population demand curve at specific prices
predict(fit_2re, type = "demand", prices = c(0, 0.5, 1, 2, 5, 10, 20))
#> # A tibble: 7 × 2
#> price .fitted
#> <dbl> <dbl>
#> 1 0 1.69
#> 2 0.5 1.60
#> 3 1 1.51
#> 4 2 1.33
#> 5 5 0.855
#> 6 10 0.233
#> 7 20 -0.568# Subject-level parameter estimates (same as get_subject_pars)
pred_pars <- predict(fit_2re, type = "parameters")
head(pred_pars)
#> # A tibble: 6 × 7
#> id b_i c_i Q0 alpha Pmax Omax
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 16 -0.855 0.386 2.31 0.0162 13.8 8.62
#> 2 24 0.0892 -0.0487 5.95 0.0105 8.31 13.3
#> 3 33 0.611 -0.352 10.0 0.00776 6.68 18.0
#> 4 40 0.153 0.500 6.34 0.0182 4.50 7.69
#> 5 42 0.738 -0.893 11.4 0.00452 10.1 31.0
#> 6 49 -0.296 0.401 4.05 0.0165 7.79 8.50For type = "response", the level argument
controls whether predictions condition on subject random effects.
level = "subject" (the default) conditions on each
subject’s random effects and needs an id column in
newdata; level = "population" sets the random
effects to zero and needs no id. Requesting both at once
returns predict.fixed (population) and
predict.id (subject) side by side, matching the
nlme::predict.lme(level = 0:1) layout so
nlme-based plotting code runs unchanged.
px <- exp(seq(log(0.05), log(20), length.out = 60))
ids_show <- unique(dat$id)[1:6]
pred_levels <- predict(
fit_2re,
newdata = expand.grid(x = px, id = ids_show),
level = c("population", "subject"),
scale = "natural"
)
head(pred_levels)
#> # A tibble: 6 × 4
#> x id predict.fixed predict.id
#> <dbl> <fct> <dbl> <dbl>
#> 1 0.05 16 5.53 2.36
#> 2 0.0553 16 5.52 2.36
#> 3 0.0613 16 5.52 2.36
#> 4 0.0678 16 5.51 2.35
#> 5 0.0751 16 5.50 2.35
#> 6 0.0831 16 5.49 2.35The population-mean curve is identical for every subject, so a single
predict.fixed column overlays the per-subject
predict.id curves.
ggplot(pred_levels, aes(x = x)) +
geom_line(aes(y = predict.id, group = id), colour = "grey65") +
geom_line(
data = subset(pred_levels, id == ids_show[1]),
aes(y = predict.fixed), colour = "#2c3e50", linewidth = 1.2
) +
scale_x_log10() +
labs(x = "Price", y = "Predicted consumption") +
theme_minimal()Subject-conditional demand curves (grey) around the population-mean curve (dark).
After fitting, assess model health with the built-in diagnostic tools.
The fit object exposes a hessian_pd field that flags
whether the Hessian returned by TMB::sdreport() is positive
definite. When FALSE, standard errors, p-values, and Wald
confidence intervals derived from the Hessian should be treated as
unreliable; the warning is also surfaced as a note in
summary() output and as a hessian_warning
attribute on tidy() output.
# Model health check — convergence, variance components, residual stats,
# and (since 0.3.0) Hessian positive-definiteness.
check_demand_model(fit_2re)
#>
#> Model Diagnostics
#> ==================================================
#> Model class: beezdemand_tmb
#>
#> Convergence:
#> Status: Converged
#>
#> Random Effects:
#> sigma_b variance: 0.3237
#> sigma_c variance: 0.3948
#>
#> Residuals:
#> Mean: -0.0001171
#> SD: 0.2069
#> Range: [-0.9401, 0.7515]
#> Outliers: 12 observations
#>
#> --------------------------------------------------
#> Issues Detected (1):
#> 1. Detected 12 potential outliers (|resid| > 3 SD)
#>
#> Recommendations:
#> - Investigate outlying observations# Augment with fitted values and residuals
aug <- augment(fit_2re)
head(aug[, c("id", "x", "y", ".fitted", ".resid", ".std_resid")])
#> # A tibble: 6 × 6
#> id x y .fitted .resid .std_resid
#> <fct> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 16 0 2 0.839 -0.146 -0.648
#> 2 16 0.25 2 0.809 -0.116 -0.513
#> 3 16 0.5 2 0.779 -0.0857 -0.380
#> 4 16 1 2 0.720 -0.0266 -0.118
#> 5 16 1.5 2 0.662 0.0315 0.140
#> 6 16 2 2 0.605 0.0884 0.392Q-Q plots of subject-level random effects.
Random effects diagnostic panels.
Residuals vs fitted values for the 2-RE exponential model.
These diagnostics help identify:
check_demand_model(): Convergence
issues, boundary estimates, residual patternsbeezdemand provides several specialized plots beyond the
standard plot() method. These work with any
beezdemand_tmb object.
The expenditure curve shows total spending (\(P \times Q\)) as a function of price, with vertical and horizontal reference lines at \(P_{max}\) and \(O_{max}\):
Expenditure curve with Pmax and Omax reference lines.
The elasticity curve shows how responsive demand is to price changes. The dashed line at \(-1\) marks unit elasticity — prices above this threshold produce elastic demand:
Own-price elasticity curve with unit elasticity reference.
The loss surface visualizes the sum-of-squared-residuals landscape over a grid of \((Q_0, \alpha)\) values, with the MLE marked. This helps assess identifiability — a sharp, well-defined minimum indicates good identification:
2D loss surface (SSR) over the Q0-alpha parameter space.
Profile plots show 1D slices through the loss surface, fixing one parameter at its MLE and varying the other:
1D loss profiles for Q0 and alpha.
Visualize the distribution of subject-level \(\alpha\) estimates to assess heterogeneity in price sensitivity:
Distribution of subject-level alpha (elasticity) estimates.
Overlay demand curves from multiple models to visualize how different specifications affect the predicted demand function:
Demand curves from 1-RE and 2-RE models overlaid.
Compare parameter distributions side by side:
Side-by-side parameter estimates from 1-RE and 2-RE models.
To test whether demand parameters differ by group, pass factor
variables via the factors argument. This adds fixed effects
to the design matrices for both \(Q_0\)
and \(\alpha\).
# Filter to Male/Female for a clean two-level comparison
dat_mf <- dat |> filter(gender %in% c("Male", "Female"))
fit_gender <- fit_demand_tmb(
dat_mf,
y_var = "y", x_var = "x", id_var = "id",
equation = "exponential",
factors = "gender",
random_effects = c("q0", "alpha"),
verbose = 0
)
fit_gender
#>
#> TMB Mixed-Effects Demand Model
#>
#> Call:
#> fit_demand_tmb(data = dat_mf, y_var = "y", x_var = "x", id_var = "id",
#> equation = "exponential", random_effects = c("q0", "alpha"),
#> factors = "gender", verbose = 0)
#>
#> Equation: exponential
#> Convergence: Yes
#> Number of subjects: 99
#> Number of observations: 1131
#> Observations dropped (zeros): 569
#> Random effects: 2 (q0, alpha)
#> Log-likelihood: -267.38
#> AIC: 552.76
#>
#> Fixed Effects:
#> Q0.0 Q0.1 alpha.0 alpha.1 log_k logsigma logsigma
#> 1.6093 0.1941 -4.4026 -0.2493 0.3404 -0.3021 -0.1074
#> logsigma_e rho_raw
#> -1.4905 -0.4591
#>
#> Use summary() for full results.anova(fit) reports a joint Wald-χ² test for each
parameter × factor block — here, whether gender shifts
\(Q_0\) and \(\alpha\). Pass additional fits for a nested
likelihood-ratio test.
get_demand_param_emms(fit_gender, param = "Q0")
#> # A tibble: 2 × 6
#> level estimate estimate_log std.error conf.low conf.high
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 gender=Female 5.00 1.61 0.102 4.10 6.10
#> 2 gender=Male 6.07 1.80 0.115 4.85 7.60get_demand_param_emms(fit_gender, param = "alpha")
#> # A tibble: 2 × 6
#> level estimate estimate_log std.error conf.low conf.high
#> <chr> <dbl> <dbl> <dbl> <dbl> <dbl>
#> 1 gender=Female 0.0122 -4.40 0.133 0.00943 0.0159
#> 2 gender=Male 0.00954 -4.65 0.155 0.00705 0.0129EMMs are reported on both the natural scale (estimate)
and log scale (estimate_log). The natural-scale estimates
represent the population-average \(Q_0\) or \(\alpha\) for each group.
get_demand_comparisons(fit_gender, param = "Q0")
#> Demand Parameter Comparisons (tmb backend)
#> EMMs computed over: all fitted factors
#> Contrast type: pairwise
#> P-value adjustment method: holm
#> ==================================================
#>
#> Q0 (log10-scale contrasts):
#> contrast estimate std.error conf.low conf.high p.value
#> Female - Male -0.084 0.067 -0.215 0.046 0.205get_demand_comparisons(fit_gender, param = "alpha")
#> Demand Parameter Comparisons (tmb backend)
#> EMMs computed over: all fitted factors
#> Contrast type: pairwise
#> P-value adjustment method: holm
#> ==================================================
#>
#> alpha (log10-scale contrasts):
#> contrast estimate std.error conf.low conf.high p.value
#> Female - Male 0.108 0.085 -0.058 0.275 0.202The estimate_ratio column gives the multiplicative ratio
between groups on the natural scale (e.g., a ratio of 1.34 means Group
A’s \(Q_0\) is 34% higher than Group
B’s).
Factor interaction and continuous covariate sections are skipped
in fast render mode. Set BEEZDEMAND_VIGNETTE_MODE=full to
include them.
Use compare_models() to compare nested TMB models via
likelihood ratio test, AIC, and BIC:
compare_models(fit_1re, fit_2re)
#>
#> Model Comparison
#> ==================================================
#>
#> Model Class Backend nobs df logLik AIC BIC
#> Model_1 beezdemand_tmb TMB_mixed 1131 5 -639.5903 1289.1807 1314.3350
#> Model_2 beezdemand_tmb TMB_mixed 1131 7 -268.5364 551.0729 586.2889
#> delta_AIC delta_BIC
#> 738.1078 728.0461
#> 0.0000 0.0000
#>
#> Best model by BIC: Model_2
#>
#> Likelihood Ratio Tests:
#> ----------------------------------------
#> Comparison LR_stat df p_value
#> Model_1 vs Model_2 742.1078 2 <2e-16
#>
#> Notes:
#> - LRT nesting assumption not verified.Valid comparisons require models fit on the same data with the same equation and response scale. Models with different equations (e.g., exponential vs exponentiated) model different responses and cannot be compared via AIC.
update()update(fit, ...) re-fits with named arguments
substituted into the original fit_demand_tmb() call. Use it
to build a candidate fit and drop or add factors / covariates / random
effects without typing the full fit call again:
# Drop the gender factor to test its joint significance:
fit_gender_null <- update(fit_gender, factors = NULL)
compare_models(fit_gender_null, fit_gender)
#>
#> Model Comparison
#> ==================================================
#>
#> Model Class Backend nobs df logLik AIC BIC delta_AIC
#> Model_1 beezdemand_tmb TMB_mixed 1131 7 -268.5364 551.0729 586.2889 0.0000
#> Model_2 beezdemand_tmb TMB_mixed 1131 9 -267.3790 552.7580 598.0357 1.6851
#> delta_BIC
#> 0.0000
#> 11.7468
#>
#> Best model by BIC: Model_1
#>
#> Likelihood Ratio Tests:
#> ----------------------------------------
#> Comparison LR_stat df p_value
#> Model_1 vs Model_2 2.3149 2 0.314
#>
#> Notes:
#> - LRT nesting assumption not verified.evaluate = FALSE returns the unevaluated call (for
inspection) instead of re-fitting, matching the convention of
stats::update.default.
formula(fit) and model.matrix(fit) round
out the introspection API. formula(fit) returns a named
list of one-sided formulas for Q0 and alpha
plus the random-effect spec; model.matrix(fit) returns the
four design matrices the TMB template consumed (X_q0,
X_alpha, Z_q0, Z_alpha) — a named
list rather than the single matrix lm or lme4
return, because the TMB tier truly has two fixed-effect linear
predictors.
If a model fails to converge, try these strategies in order:
| Strategy | How | When |
|---|---|---|
| Reduce random effects | random_effects = "q0" |
2-RE models struggling |
| Use exponential equation | equation = "exponential" |
Exponentiated/simplified not converging |
| Fix k | estimate_k = FALSE, k = 2 |
k estimate drifting to extreme values |
| Increase iterations | tmb_control = list(iter_max = 2000) |
“false convergence” messages |
| Try L-BFGS-B | tmb_control = list(optimizer = "L-BFGS-B") |
nlminb not making progress |
| Set parameter bounds | tmb_control = list(lower = ..., upper = ...) |
Estimates at boundaries |
| Warm start | tmb_control = list(warm_start = prev_fit$opt$par) |
Refining a near-converged fit |
# Switch optimizer
fit <- fit_demand_tmb(
dat, equation = "exponential",
tmb_control = list(optimizer = "L-BFGS-B"),
verbose = 0
)
# Warm-start from a previous fit
fit2 <- fit_demand_tmb(
dat, equation = "exponential",
tmb_control = list(warm_start = fit$opt$par),
verbose = 0
)
# Apply parameter bounds
fit3 <- fit_demand_tmb(
dat, equation = "exponential",
tmb_control = list(
lower = c(log_k = -2),
upper = c(log_k = 4)
),
verbose = 0
)
# Disable multi-start for faster iteration during exploration
fit4 <- fit_demand_tmb(
dat, equation = "exponential",
multi_start = FALSE,
verbose = 2
)| Field | Default | Description |
|---|---|---|
optimizer |
"nlminb" |
"nlminb" or "L-BFGS-B" |
iter_max |
1000 | Maximum iterations |
eval_max |
2000 | Maximum evaluations (nlminb only) |
rel_tol |
1e-10 | Relative tolerance (nlminb only) |
lower / upper |
NULL | Named numeric bounds on log-scale parameters |
warm_start |
NULL | Starting values from a previous fit$opt$par |
trace |
0 | Optimizer trace level |
| Feature | fit_demand_tmb() |
fit_demand_mixed() |
|---|---|---|
| Backend | TMB (C++, automatic differentiation) | nlme (R, numerical gradients) |
| Equations | exponential, exponentiated, simplified, zben | zben, simplified |
| k parameter | Estimated or fixed | Not available |
| Random effects | 1 or 2 (Q0, alpha) | Configurable via nlme |
| Convergence | Robust (AD + Laplace + multi-start) | Can struggle with nonlinear equations |
| Speed | Fast (compiled C++) | Variable |
| Post-hoc EMMs | get_demand_param_emms() |
get_demand_param_emms() (via emmeans) |
| Factors/covariates | Design matrices | Formula-based |
Prefer fit_demand_tmb() for new work.
fit_demand_mixed() remains useful when you need specific
nlme features like custom correlation structures or when
working with existing pipelines.
Hursh, S. R., & Silberberg, A. (2008). Economic demand and essential value. Psychological Review, 115(1), 186–198.
Koffarnus, M. N., Franck, C. T., Stein, J. S., & Bickel, W. K. (2015). A modified exponential behavioral economic demand model to better describe consumption data. Experimental and Clinical Psychopharmacology, 23(6), 504–512.
Kristensen, K., Nielsen, A., Berg, C. W., Skaug, H., & Bell, B. M. (2016). TMB: Automatic differentiation and Laplace approximation. Journal of Statistical Software, 70(5), 1–21.
vignette("fixed-demand") – Individual NLS demand
curvesvignette("mixed-demand") – NLME-based mixed-effects
modelsvignette("mixed-demand-advanced") – Advanced topics:
multi-factor designs, collapse_levelsvignette("hurdle-demand-models") – Two-part hurdle
models for zero-heavy datavignette("model-selection") – Choosing the right demand
model