Skip to main content

Misspecification Robustness

Five ways the model could be wrong, and what each one costs

Authors

Dan Yavorsky

Geoffery Zheng

Published

October 24, 2026

\[ \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]:
set.seed(1)
options(digits = 5)

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()

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.

5. Correlated alternatives

Appendix D proves the framework survives any GEV error structure once the inclusive value is redefined as \(\ln G(e^V)\). Fitting the independent-Gumbel model to nested-logit data therefore misspecifies exactly one thing: it uses \(\ln \sum_j e^{V_j}\) where the truth is \(\ln G(e^V)\).

Four alternatives in two nests at dissimilarity 0.5, which is a within-nest error correlation of 0.75.

In [8]:
NEST <- list(groups = c(1,1,2,2), lambda = c(0.5, 0.5))
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 = 96000 + 10*r, nest = NEST)
  f <- fit_dual_hb(sp$est, mcmc = MCMC, seed = 97000 + r, verbose = FALSE)
  set.seed(98000 + 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 + rnested(nrow(V), J, NEST)
    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$nested <- list(metrics = m, mean = colMeans(m),
                       se = apply(m, 2, sd)/sqrt(REPS), nest = NEST)
knitr::kable(rbind(mean = res_all$nested$mean, mc_se = res_all$nested$se), digits = 4)
rmse_bi rmse_ex hit bias_c3
mean 0.5529 0.1097 0.5719 0.2111
mc_se 0.0051 0.0007 0.0048 0.0229

Again the cut point absorbs it, and in the direction theory predicts. Nesting makes \(G(e^V) < \sum_j e^{V_j}\), so the fitted model overstates the inclusive value, and the top-2-box cut compensates by moving upward. The sign of bias_c3 is therefore a prediction, not a fitted artifact.

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