set.seed(1)
options(digits = 5)\[ \newcommand{\x}{\mathbf{x}} \newcommand{\bfbeta}{\boldsymbol{\beta}} \newcommand{\betabar}{\bar{\bfbeta}} \newcommand{\bftheta}{\boldsymbol{\theta}} \newcommand{\iid}{\stackrel{iid}{\sim}} \newcommand{\E}{\operatorname{E}} \newcommand{\coloneqq}{\mathrel{:=}} \]
What this notebook does
Every result so far assumes the model is true. This notebook breaks that assumption five times, in the five ways a sceptical reader would break it, and measures the damage. In each case the data are generated from something the model does not describe, and then fit with the model anyway.
| # | the data really are… | the worry |
|---|---|---|
| 1 | generated by the other link | we cannot tell the links apart, so the behavioral story is untestable |
| 2 | generated with respondent-specific cut points | people use scales differently and we pool them |
| 3 | generated with normal rather than Gumbel errors | the whole derivation rests on Gumbel |
| 4 | generated with correlated alternatives | independence of irrelevant alternatives is false in real choice sets |
| 5 | generated with the outside good drifting across tasks | respondents change as they work through a survey |
A recurring pattern emerges, and it is worth stating before the evidence rather than after: the damage lands on the cut points, not on the preference parameters or the demand predictions built from them. That is reassuring for the quantities practitioners use and a genuine caveat for the structural reading of the scale labels.
This notebook is the source of five results files, hb_simstudy_{wronglink,hetcut,nongumbel,nested,drift}.rds.
In [1]:
1. Machinery
The simulator here is the fullest version: it carries normal errors, nested (GEV) errors, heterogeneous cut points, and two kinds of task-order drift. The sampler gains the heterogeneous-cut-point mode.
In [2]:
Designs, GEV errors, the full panel simulator, and the sampler
rgumbel <- function(n) -log(-log(runif(n)))
# Positive-stable draws (Chambers-Mallows-Stuck). Conditional on a positive
# stable draw, within-nest errors are Gumbel with a common shifted location,
# which reproduces the nested-logit joint distribution while leaving each
# marginal standard Gumbel -- so nested and independent errors are on the same
# scale and need no variance matching.
rposstable <- function(n, alpha) {
U <- runif(n, 0, pi); W <- rexp(n)
sin(alpha * U) / (sin(U))^(1 / alpha) *
(sin((1 - alpha) * U) / W)^((1 - alpha) / alpha)
}
rnested <- function(n, J, nest) {
eta <- matrix(NA_real_, n, J)
for (g in sort(unique(nest$groups))) {
idx <- which(nest$groups == g); lam <- nest$lambda[g]
loc <- lam * log(rposstable(n, lam))
for (j in idx) eta[, j] <- loc + lam * rgumbel(n)
}
eta
}
# Attribute levels are drawn at random rather than from an efficiency-optimized
# catalogue of the kind commercial practice uses (one-way and two-way level
# balance, D-efficiency, utility balance). Nothing here needs one. The headline
# comparisons are paired -- competing specifications are fit to the same
# simulated data on the same design -- so design efficiency cancels from them,
# and recovery needs only the rank condition, which id_rank_check() verifies.
# Optimizing for the forced choice alone would not obviously serve the second
# response in any case: it is identified by variation in task attractiveness
# across tasks, which within-task utility balance does nothing to produce.
make_design <- function(n_tasks, J, seed) {
set.seed(seed)
n_rows <- n_tasks * J
a <- sample(1:3, n_rows, replace = TRUE); b <- sample(1:3, n_rows, replace = TRUE)
price <- runif(n_rows, 0.5, 2.5)
X <- cbind(a2 = as.numeric(a == 2), a3 = as.numeric(a == 3),
b2 = as.numeric(b == 2), b3 = as.numeric(b == 3), price = price)
list(X = X, n_tasks = n_tasks, J = J, P = ncol(X),
task_id = rep(seq_len(n_tasks), each = J), # task of each X row
alt_id = rep(seq_len(J), times = n_tasks)) # alternative within task
}
make_panel_design <- function(N, T_tasks, J, seed) {
des <- make_design(N * T_tasks, J, seed = seed)
des$N <- N; des$T_tasks <- T_tasks
des$resp <- rep(seq_len(N), each = T_tasks)
des$respondent_id <- rep(des$resp, each = J)
des
}
par_to_cut <- function(par, P, W) {
if (W == 2) return(par[P + 1])
par[P + 1] + c(0, cumsum(exp(par[(P + 2):(P + W - 1)])))
}
cut_to_par <- function(cut) if (length(cut) == 1) cut else c(cut[1], log(diff(cut)))
row_max <- function(M) do.call(pmax, as.data.frame(M))
ord_prob_z <- function(lo, hi, link) {
if (link == "logit") plogis(hi) * plogis(-lo) * (-expm1(lo - hi))
else { ea <- exp(-lo); eb <- exp(-hi)
p <- exp(-eb) * (-expm1(-(ea - eb))); p[!is.finite(eb)] <- 0; p }
}
draw_betas <- function(N, beta_bar, Sigma, seed) {
set.seed(seed); P <- length(beta_bar)
sweep(matrix(rnorm(N * P), N, P) %*% chol(Sigma), 2, beta_bar, "+")
}
simulate_dual_hb <- function(design, Bmat, cut, link = c("cloglog","logit"), seed,
shock = c("gumbel","normal"), drift = NULL, nest = NULL) {
link <- match.arg(link); shock <- match.arg(shock)
set.seed(seed)
n <- design$n_tasks; J <- design$J; N <- design$N
cutmat <- if (is.matrix(cut)) cut else matrix(cut, N, length(cut), byrow = TRUE)
W <- ncol(cutmat) + 1L
Bx <- Bmat[design$respondent_id, , drop = FALSE]
V <- matrix(rowSums(design$X * Bx), nrow = n, ncol = J, byrow = TRUE)
u <- if (!is.null(nest)) V + rnested(n, J, nest) else {
eta <- if (shock == "gumbel") rgumbel(n * J) else rnorm(n * J, sd = pi / sqrt(6))
V + matrix(eta, n, J)
}
jstar <- max.col(u, ties.method = "first")
ustar <- u[cbind(seq_len(n), jstar)]
m <- row_max(V); mubar <- m + log(rowSums(exp(V - m)))
latent <- if (link == "cloglog") ustar else ustar - rgumbel(n)
shift <- numeric(n)
if (!is.null(drift)) {
T_tasks <- design$T_tasks
tt <- rep(seq_len(T_tasks), times = N)
if (drift$type == "linear") shift <- drift$delta * (tt - 1)
else if (drift$type == "update") {
psi <- numeric(n)
for (i in seq_len(N)) {
rows <- which(design$resp == i)
for (k in seq_along(rows)[-1])
psi[rows[k]] <- if (runif(1) < plogis(drift$rho)) mubar[rows[k-1]] else psi[rows[k-1]]
}
shift <- psi
}
}
eff_cut <- cutmat[design$resp, , drop = FALSE] + shift
list(design = design, resp = design$resp, respondent_id = design$respondent_id, N = N,
jstar = jstar, y = as.integer(1L + rowSums(latent >= eff_cut)), W = W,
link = link, Bmat_true = Bmat, cut_true = cut, shock = shock,
drift = drift, nest = nest)
}
split_holdout <- function(dat, n_holdout) {
des <- dat$design; T_tasks <- des$T_tasks
keep_t <- rep(seq_len(T_tasks) <= T_tasks - n_holdout, times = des$N)
sub <- function(keep) {
rows <- rep(keep, each = des$J); d <- des
d$X <- des$X[rows, , drop = FALSE]; d$n_tasks <- sum(keep)
d$T_tasks <- d$n_tasks / des$N; d$resp <- des$resp[keep]
d$respondent_id <- rep(d$resp, each = des$J)
out <- dat; out$design <- d; out$resp <- d$resp; out$respondent_id <- d$respondent_id
out$jstar <- dat$jstar[keep]; out$y <- dat$y[keep]; out
}
list(est = sub(keep_t), holdout = sub(!keep_t))
}
phi_to_cutmat <- function(Phi, P, W) {
c1 <- Phi[, P + 1]
if (W == 2) return(matrix(c1, ncol = 1))
D <- exp(Phi[, (P + 2):(P + W - 1), drop = FALSE])
cm <- matrix(0, nrow(Phi), W - 1); cm[, 1] <- c1
for (w in 2:(W - 1)) cm[, w] <- cm[, w - 1] + D[, w - 1]
cm
}
resp_loglik_all <- function(dat, Bmat, cutmat, choice_only = FALSE) {
des <- dat$design; n <- des$n_tasks; J <- des$J
Bx <- Bmat[dat$respondent_id, , drop = FALSE]
V <- matrix(rowSums(des$X * Bx), nrow = n, ncol = J, byrow = TRUE)
m <- row_max(V); mubar <- m + log(rowSums(exp(V - m)))
ll <- V[cbind(seq_len(n), dat$jstar)] - mubar
if (!choice_only) {
ca <- cbind(-Inf, cutmat, Inf)
ll <- ll + log(pmax(ord_prob_z(ca[cbind(dat$resp, dat$y)] - mubar,
ca[cbind(dat$resp, dat$y + 1L)] - mubar,
dat$link), 1e-312))
}
as.numeric(rowsum(ll, dat$resp, reorder = TRUE))
}
default_hb_priors <- function(d) list(phibar0 = rep(0, d), A = 1/100,
nu = d + 3, V0 = (d + 3) * diag(d), zeta_prec = 1/100)
draw_phibar <- function(Phi, Sigma, pr) {
N <- nrow(Phi); Si <- chol2inv(chol(Sigma))
Vb <- chol2inv(chol(N * Si + pr$A * diag(ncol(Phi))))
as.numeric(Vb %*% (Si %*% (N * colMeans(Phi)) + pr$A * pr$phibar0) +
t(chol(Vb)) %*% rnorm(ncol(Phi)))
}
draw_sigma <- function(Phi, phibar, pr) {
Vn <- pr$V0 + crossprod(sweep(Phi, 2, phibar))
chol2inv(chol(rWishart(1, pr$nu + nrow(Phi), chol2inv(chol(Vn)))[,,1]))
}
fit_dual_hb <- function(dat, mcmc = list(R=30000, burn=10000, thin=10),
het_cut = FALSE, choice_only = FALSE, priors = NULL,
keep_phi = TRUE, seed = NULL, verbose = TRUE) {
if (!is.null(seed)) set.seed(seed)
des <- dat$design; N <- dat$N; P <- des$P; W <- dat$W
dim_phi <- if (het_cut) P + W - 1 else P
if (is.null(priors)) priors <- default_hb_priors(dim_phi)
R_iter <- mcmc$R; burn <- mcmc$burn; thin <- mcmc$thin
nkeep <- floor((R_iter - burn) / thin)
Phi <- matrix(0, N, dim_phi)
freq <- tabulate(dat$y, nbins = W)
cumq <- pmin(pmax(cumsum(freq)[1:(W-1)]/sum(freq), 1e-4), 1-1e-4)
ginv <- if (dat$link == "logit") qlogis else function(q) -log(-log(q))
cut0 <- log(des$J) + ginv(cumq)
if (W > 2) for (k in 2:(W-1)) cut0[k] <- max(cut0[k], cut0[k-1] + 1e-3)
zeta <- cut_to_par(cut0)
if (het_cut) Phi[, (P+1):(P+W-1)] <- matrix(zeta, N, W-1, byrow = TRUE)
phibar <- colMeans(Phi); Sigma <- diag(dim_phi)
cur_cutmat <- if (het_cut) phi_to_cutmat(Phi, P, W) else
matrix(par_to_cut(zeta, 0, W), N, W-1, byrow = TRUE)
cur_ll <- resp_loglik_all(dat, Phi[, 1:P, drop=FALSE], cur_cutmat, choice_only)
s_phi <- rep(2.93/sqrt(dim_phi), N); acc_phi <- rep(0, N)
s_zeta <- 0.05; acc_zeta <- 0; window <- 100
keep_betabar <- matrix(NA_real_, nkeep, dim_phi)
keep_Sigma <- array(NA_real_, c(nkeep, dim_phi, dim_phi))
keep_cut <- if (!het_cut && !choice_only) matrix(NA_real_, nkeep, W-1) else NULL
keep_ll <- numeric(nkeep); keep_ll_resp <- matrix(NA_real_, nkeep, N)
keep_Phi <- if (keep_phi) array(NA_real_, c(nkeep, N, dim_phi)) else NULL
t0 <- Sys.time(); ki <- 0
for (it in seq_len(R_iter)) {
U <- chol(Sigma)
Prop <- Phi + (matrix(rnorm(N*dim_phi), N, dim_phi) %*% U) * s_phi
prop_cutmat <- if (het_cut) phi_to_cutmat(Prop, P, W) else cur_cutmat
prop_ll <- resp_loglik_all(dat, Prop[, 1:P, drop=FALSE], prop_cutmat, choice_only)
Si <- chol2inv(chol(Sigma))
dcur <- sweep(Phi, 2, phibar); dprop <- sweep(Prop, 2, phibar)
la <- (prop_ll - 0.5*rowSums((dprop %*% Si)*dprop)) -
(cur_ll - 0.5*rowSums((dcur %*% Si)*dcur))
acc <- log(runif(N)) < la
Phi[acc, ] <- Prop[acc, ]; cur_ll[acc] <- prop_ll[acc]
if (het_cut && any(acc)) cur_cutmat[acc, ] <- prop_cutmat[acc, , drop=FALSE]
acc_phi <- acc_phi + acc
if (!het_cut && !choice_only) {
zp <- zeta + rnorm(W-1, sd = s_zeta)
cp <- matrix(par_to_cut(zp, 0, W), N, W-1, byrow = TRUE)
lp_ll <- resp_loglik_all(dat, Phi[, 1:P, drop=FALSE], cp, choice_only)
if (log(runif(1)) < (sum(lp_ll) - 0.5*priors$zeta_prec*sum(zp^2)) -
(sum(cur_ll) - 0.5*priors$zeta_prec*sum(zeta^2))) {
zeta <- zp; cur_cutmat <- cp; cur_ll <- lp_ll; acc_zeta <- acc_zeta + 1
}
}
phibar <- draw_phibar(Phi, Sigma, priors); Sigma <- draw_sigma(Phi, phibar, priors)
if (it <= burn && it %% window == 0) {
s_phi <- pmin(pmax(s_phi*exp(1.5*(acc_phi/window - 0.23)), 0.01), 10)
acc_phi <- rep(0, N)
if (!het_cut && !choice_only) {
s_zeta <- min(max(s_zeta*exp(1.5*(acc_zeta/window - 0.30)), 1e-3), 2); acc_zeta <- 0
}
}
if (it == burn) { acc_phi <- rep(0, N); acc_zeta <- 0 }
if (it > burn && (it - burn) %% thin == 0) {
ki <- ki + 1
keep_betabar[ki, ] <- phibar; keep_Sigma[ki, , ] <- Sigma
if (!is.null(keep_cut)) keep_cut[ki, ] <- par_to_cut(zeta, 0, W)
keep_ll[ki] <- sum(cur_ll); keep_ll_resp[ki, ] <- cur_ll
if (keep_phi) keep_Phi[ki, , ] <- Phi
}
}
post <- R_iter - burn
structure(list(draws = list(phibar = keep_betabar, Sigma = keep_Sigma,
cut = keep_cut, Phi = keep_Phi),
ll = keep_ll, ll_resp = keep_ll_resp,
accept = list(phi = mean(acc_phi/post),
zeta = if (het_cut || choice_only) NA else acc_zeta/post),
settings = list(mcmc = mcmc, het_cut = het_cut,
choice_only = choice_only, P = P, W = W, N = N,
link = dat$link, priors = priors),
runtime_min = as.numeric(difftime(Sys.time(), t0, units="mins"))),
class = "dr_hb_fit")
}
hb_beta_i <- function(fit)
apply(fit$draws$Phi[, , 1:fit$settings$P, drop = FALSE], c(2,3), mean)
logmeanexp <- function(x) { m <- max(x); m + log(mean(exp(x - m))) }
waic <- function(ll) {
lppd <- sum(apply(ll, 2, logmeanexp)); p <- sum(apply(ll, 2, var))
list(waic = -2*(lppd - p), lppd = lppd, p_waic = p, elpd = lppd - p)
}In [3]:
N <- 300; T_EST <- 12; T_HOLD <- 2; J <- 4; W <- 5
beta_bar <- c(a2 = 0.8, a3 = -0.5, b2 = 0.4, b3 = 1.0, price = -0.9)
Sigma_true <- diag(c(0.6, 0.6, 0.6, 0.6, 0.3))
cut_true <- c(-1.0, 0.2, 1.2, 2.2)
P <- length(beta_bar)
KBUY <- 4 # "purchase" means top-2-box throughout
REPS <- 12
ITER <- 20000
MCMC <- list(R = ITER, burn = floor(ITER * 0.4), thin = 10)
gen_panel <- function(seed, link = "logit", cutmat = NULL, ...) {
des <- make_panel_design(N, T_EST + T_HOLD, J, seed = seed)
B <- draw_betas(N, beta_bar, Sigma_true, seed = seed + 1)
cutarg <- if (is.null(cutmat)) cut_true else cutmat
split_holdout(simulate_dual_hb(des, B, cutarg, link = link, seed = seed + 2, ...), T_HOLD)
}
task_mubar <- function(design, Bmat) {
Bx <- Bmat[design$respondent_id, , drop = FALSE]
V <- matrix(rowSums(design$X * Bx), ncol = design$J, byrow = TRUE)
m <- row_max(V); m + log(rowSums(exp(V - m)))
}
pred_exceed_panel <- function(fit, sp, k = KBUY) {
d <- sp$holdout$design
mubar <- task_mubar(d, hb_beta_i(fit))
if (fit$settings$het_cut) {
cutm <- phi_to_cutmat(apply(fit$draws$Phi, c(2,3), mean),
fit$settings$P, fit$settings$W)
cc <- cutm[d$resp, k - 1]
} else {
cv <- colMeans(fit$draws$cut); cc <- rep(cv[min(k - 1, length(cv))], length(mubar))
}
G <- if (fit$settings$link == "logit") plogis else function(z) exp(-exp(-z))
1 - G(cc - mubar)
}
hit_rate <- function(fit, sp) {
d <- sp$holdout$design
Bx <- hb_beta_i(fit)[d$respondent_id, , drop = FALSE]
V <- matrix(rowSums(d$X * Bx), ncol = d$J, byrow = TRUE)
mean(max.col(V, ties.method = "first") == sp$holdout$jstar)
}
res_all <- list()2. Wrong link
Fit the logistic link to data from our model and vice versa. The question is whether the two links are distinguishable, and whether getting it wrong matters for demand.
In [4]:
m <- matrix(NA_real_, REPS, 6, dimnames = list(NULL, c(
"bias_ex_correct_cloglog","bias_ex_wrong_cloglog","bias_ex_correct_logit","bias_ex_wrong_logit",
"waic_picks_cloglog","waic_picks_logit")))
for (r in seq_len(REPS)) {
row <- numeric(6)
for (truth in c("cloglog","logit")) {
sp <- gen_panel(seed = 70000 + 20*r + (truth == "cloglog"), link = truth)
d <- sp$holdout$design
mu_true <- task_mubar(d, sp$holdout$Bmat_true)
Gt <- if (truth == "logit") plogis else function(z) exp(-exp(-z))
tex <- 1 - Gt(cut_true[KBUY - 1] - mu_true)
fits <- list()
for (fl in c("cloglog","logit")) {
datf <- sp$est; datf$link <- fl
fits[[fl]] <- fit_dual_hb(datf, mcmc = MCMC,
seed = 71000 + 2*r + (fl == "cloglog"), verbose = FALSE)
}
wrong <- setdiff(c("cloglog","logit"), truth)
bc <- mean(pred_exceed_panel(fits[[truth]], sp) - tex)
bw <- mean(pred_exceed_panel(fits[[wrong]], sp) - tex)
picked <- if (waic(fits$cloglog$ll_resp)$elpd > waic(fits$logit$ll_resp)$elpd) "cloglog" else "logit"
if (truth == "cloglog") { row[1:2] <- c(bc, bw); row[5] <- picked == "cloglog" }
else { row[3:4] <- c(bc, bw); row[6] <- picked == "logit" }
}
m[r, ] <- row
}
res_all$wronglink <- list(metrics = m, mean = colMeans(m),
se = apply(m, 2, sd)/sqrt(REPS))
knitr::kable(rbind(mean = res_all$wronglink$mean, mc_se = res_all$wronglink$se),
digits = 4)| bias_ex_correct_cloglog | bias_ex_wrong_cloglog | bias_ex_correct_logit | bias_ex_wrong_logit | waic_picks_cloglog | waic_picks_logit | |
|---|---|---|---|---|---|---|
| mean | -0.0193 | -0.0228 | -0.0187 | -0.0252 | 1 | 1 |
| mc_se | 0.0016 | 0.0019 | 0.0031 | 0.0030 | 0 | 0 |
In [5]:
w <- res_all$wronglink$mean
cat(sprintf("demand bias, correct link: up to %.4f on the probability scale\n",
max(abs(c(w["bias_ex_correct_cloglog"], w["bias_ex_correct_logit"])))))demand bias, correct link: up to 0.0193 on the probability scale
cat(sprintf("demand bias, wrong link : up to %.4f\n",
max(abs(c(w["bias_ex_wrong_cloglog"], w["bias_ex_wrong_logit"])))))demand bias, wrong link : up to 0.0252
cat(sprintf("WAIC selects the true link in %.0f%% of replications\n",
100 * mean(c(w["waic_picks_cloglog"], w["waic_picks_logit"]))))WAIC selects the true link in 100% of replications
Link misspecification is a second-order concern for demand. The two links agree closely over the range of inclusive values this design produces, and diverge only in the tails, which is where extrapolation lives. Notebook 07 shows that the ability to tell the links apart is governed by how much the design varies task attractiveness, not by sample size.
3. Ignored scale-usage heterogeneity
Now respondents genuinely differ in how they use the scale: each gets her own cut points. Fit the common-cut model (wrong) and the heterogeneous-cut model (right), and compare.
In [6]:
m <- matrix(NA_real_, REPS, 6, dimnames = list(NULL, c(
"rmse_bi_common","rmse_bi_het","rmse_ex_common","rmse_ex_het",
"hit_common","hit_het")))
theta_mu <- c(-1, log(1.2), log(1.0), log(1.0))
theta_sd <- c(0.35, 0.25, 0.25, 0.25)
for (r in seq_len(REPS)) {
set.seed(80000 + 10*r)
Theta <- sweep(matrix(rnorm(N*4), N, 4) %*% diag(theta_sd), 2, theta_mu, "+")
cutmat <- t(apply(Theta, 1, function(th) th[1] + c(0, cumsum(exp(th[2:4])))))
sp <- gen_panel(seed = 80000 + 10*r + 1, cutmat = cutmat)
f_com <- fit_dual_hb(sp$est, mcmc = MCMC, seed = 81000 + r, verbose = FALSE)
f_het <- fit_dual_hb(sp$est, mcmc = MCMC, het_cut = TRUE,
seed = 82000 + r, verbose = FALSE)
d <- sp$holdout$design
tex <- 1 - plogis(cutmat[d$resp, KBUY - 1] - task_mubar(d, sp$holdout$Bmat_true))
m[r, ] <- c(sqrt(mean((hb_beta_i(f_com) - sp$est$Bmat_true)^2)),
sqrt(mean((hb_beta_i(f_het) - sp$est$Bmat_true)^2)),
sqrt(mean((pred_exceed_panel(f_com, sp) - tex)^2)),
sqrt(mean((pred_exceed_panel(f_het, sp) - tex)^2)),
hit_rate(f_com, sp), hit_rate(f_het, sp))
}
res_all$hetcut <- list(metrics = m, mean = colMeans(m),
se = apply(m, 2, sd)/sqrt(REPS),
theta_mu = theta_mu, theta_sd = theta_sd)
knitr::kable(rbind(mean = res_all$hetcut$mean, mc_se = res_all$hetcut$se), digits = 4)| rmse_bi_common | rmse_bi_het | rmse_ex_common | rmse_ex_het | hit_common | hit_het | |
|---|---|---|---|---|---|---|
| mean | 0.5315 | 0.5347 | 0.1194 | 0.1222 | 0.5124 | 0.5150 |
| mc_se | 0.0042 | 0.0044 | 0.0014 | 0.0012 | 0.0061 | 0.0064 |
The common-cut model loses almost nothing, even though the data-generating process really does give every respondent her own thresholds with a standard deviation of 0.35 on the first one. At twelve tasks per person there is not enough information to estimate four extra parameters per respondent, so the extra flexibility buys little and costs variance. The heterogeneous-cut extension is robustness insurance and a diagnostic, not a default.
4. Non-Gumbel errors
The entire derivation rests on Gumbel errors. Replace them with normals matched on variance and refit.
In [7]:
m <- matrix(NA_real_, REPS, 4,
dimnames = list(NULL, c("rmse_bi","rmse_ex","hit","bias_c3")))
for (r in seq_len(REPS)) {
sp <- gen_panel(seed = 90000 + 10*r, shock = "normal")
f <- fit_dual_hb(sp$est, mcmc = MCMC, seed = 91000 + r, verbose = FALSE)
# With normal errors there is no closed form for demand, so the truth is
# obtained by simulating the behavioral process at the holdout tasks.
set.seed(92000 + r)
d2 <- sp$holdout$design
Bx <- sp$holdout$Bmat_true[d2$respondent_id, , drop = FALSE]
V <- matrix(rowSums(d2$X * Bx), ncol = J, byrow = TRUE)
cnt <- numeric(nrow(V))
for (mc in seq_len(1000)) {
u <- V + matrix(rnorm(length(V), sd = pi/sqrt(6)), nrow(V), J)
ustar <- u[cbind(seq_len(nrow(V)), max.col(u, ties.method = "first"))]
cnt <- cnt + ((ustar - rgumbel(nrow(V))) >= cut_true[KBUY - 1])
}
ex_true <- cnt / 1000
m[r, ] <- c(sqrt(mean((hb_beta_i(f) - sp$est$Bmat_true)^2)),
sqrt(mean((pred_exceed_panel(f, sp) - ex_true)^2)),
hit_rate(f, sp),
mean(f$draws$cut[, KBUY - 1]) - cut_true[KBUY - 1])
}
res_all$nongumbel <- list(metrics = m, mean = colMeans(m),
se = apply(m, 2, sd)/sqrt(REPS))
knitr::kable(rbind(mean = res_all$nongumbel$mean, mc_se = res_all$nongumbel$se),
digits = 4)| rmse_bi | rmse_ex | hit | bias_c3 | |
|---|---|---|---|---|
| mean | 0.5137 | 0.1099 | 0.5162 | 0.6307 |
| mc_se | 0.0031 | 0.0018 | 0.0053 | 0.0239 |
Preference recovery and demand prediction survive. The misspecification is absorbed almost entirely by bias_c3, the displacement of the top-2-box cut point. This is the pattern promised at the top: the cut points act as a sponge.
6. Task-order drift
Respondents are not static across a survey. The outside good may drift as they work through it, which is the phenomenon documented in the no-choice literature. Two variants: a deterministic linear drift, and Hung-style probabilistic updating toward the previous task’s inclusive value.
In [9]:
variants <- list(linear = list(type = "linear", delta = 0.06),
update = list(type = "update", rho = -2.5))
drift_out <- list()
for (vn in names(variants)) {
m <- matrix(NA_real_, REPS, 4,
dimnames = list(NULL, c("rmse_bi","bias_c3","order_slope","order_t")))
for (r in seq_len(REPS)) {
sp <- gen_panel(seed = 95000 + 10*r, drift = variants[[vn]])
f <- fit_dual_hb(sp$est, mcmc = MCMC, seed = 96000 + r, verbose = FALSE)
d <- sp$est$design
mubar <- task_mubar(d, hb_beta_i(f))
chat <- colMeans(f$draws$cut)[KBUY - 1]
resid <- as.numeric(sp$est$y >= KBUY) - (1 - plogis(chat - mubar))
tt <- rep(seq_len(T_EST), times = N)
reg <- summary(lm(resid ~ tt))
m[r, ] <- c(sqrt(mean((hb_beta_i(f) - sp$est$Bmat_true)^2)),
chat - cut_true[KBUY - 1],
reg$coefficients[2,1], reg$coefficients[2,3])
}
drift_out[[vn]] <- list(metrics = m, mean = colMeans(m),
se = apply(m, 2, sd)/sqrt(REPS))
}
res_all$drift <- drift_out
knitr::kable(do.call(rbind, lapply(names(drift_out), function(v)
data.frame(variant = v, t(round(drift_out[[v]]$mean, 4))))), row.names = FALSE)| variant | rmse_bi | bias_c3 | order_slope | order_t |
|---|---|---|---|---|
| linear | 0.5260 | 0.3427 | -0.0104 | -5.1126 |
| update | 0.5489 | 0.3154 | -0.0102 | -4.9105 |
The static model’s cut points absorb the average drift while part-worths stay accurate. More useful for practice: a one-line diagnostic detects it. Regress the residual exceedance on task position, and under linear drift the \(t\) statistic is large and consistently signed, so an analyst can find this in real data without fitting a dynamic model.
7. What the five have in common
In [10]:
knitr::kable(data.frame(
misspecification = c("wrong link","heterogeneous cuts","normal errors",
"correlated alternatives","task-order drift"),
`preference recovery` = c("unaffected","unaffected","unaffected",
"slightly worse","unaffected"),
`demand prediction` = c("second-order","unaffected","unaffected",
"unaffected","unaffected"),
`where the damage lands` = c("tails only","nowhere material","cut points",
"cut points","cut points"),
check.names = FALSE), row.names = FALSE)| misspecification | preference recovery | demand prediction | where the damage lands |
|---|---|---|---|
| wrong link | unaffected | second-order | tails only |
| heterogeneous cuts | unaffected | unaffected | nowhere material |
| normal errors | unaffected | unaffected | cut points |
| correlated alternatives | slightly worse | unaffected | cut points |
| task-order drift | unaffected | unaffected | cut points |
Four of the five leave the quantities practitioners actually use essentially intact, and deposit their damage in the cut points. The reason is structural rather than lucky: the forced choice identifies preferences and is untouched by any of these, while the cut points are the model’s only free location and so absorb whatever the second stage cannot express.
The implication cuts both ways. Demand estimates are robust. The structural reading of the cut points, as probability thresholds that give scale labels quantitative meaning, is the fragile part, and it is exactly what our model’s probability-scale interpretation asks a reader to believe.
8. Results written
In [11]:
PROJ <- if (file.exists("_quarto.yml")) "." else ".."
OUT <- file.path(PROJ, "R", "output")
dir.create(OUT, showWarnings = FALSE, recursive = TRUE)
settings <- list(N = N, T_EST = T_EST, T_HOLD = T_HOLD, J = J, W = W,
beta_bar = beta_bar, Sigma = Sigma_true, cut = cut_true,
REPS = REPS, MCMC = MCMC, KBUY = KBUY)
for (cell in c("wronglink","hetcut","nongumbel","nested","drift")) {
saveRDS(list(cell = cell, res = res_all[[cell]], settings = settings),
file.path(OUT, paste0("hb_simstudy_", cell, ".rds")))
}
cat("wrote five hb_simstudy_*.rds files\n")wrote five hb_simstudy_*.rds files