Skip to main content

The Cost of Dichotomization

What collapsing the scale to a yes/no answer actually 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

The binary dual-response model is the incumbent. If a practitioner fields a five-point purchase-likelihood scale and then collapses it (“top-2-box counts as a buy”), the resulting binary model is correctly specified: collapsing a cumulative-link model at one of its own cut points gives back a cumulative-link model with one cut point. So dichotomization costs no consistency. What it costs is precision, and this notebook measures how much, three ways:

  1. Exactly, from the Fisher information. No simulation noise at all: the asymptotic variance of every estimator is computed in closed form across four parameter configurations.
  2. By paired Monte Carlo, fitting the ordinal model and every binary variant to the same simulated datasets, so nothing differs except the coarsening of the second response.
  3. At the individual level, in the hierarchical model a practitioner would actually estimate, where each respondent contributes only twelve tasks and pooled cut points can no longer rescue a coarsened second stage.

The third is where the argument turns. The aggregate penalties are real but modest, and an honest reading of them alone would say dichotomization is a minor sin. It is not, and the reason is that aggregate prediction variance is dominated by the forced-choice stage that both estimators share.

This notebook is the source of dichotomization_study.rds and hb_simstudy_dichot.rds.

In [1]:
set.seed(1)
options(digits = 5)

1. Why coarsening is lossy but not biased

Under the model, \(\Pr(y \ge k) = 1 - G(c_{k-1} - \overline{\mu})\). Define a binary variable \(b = \mathbb{1}\{y \ge k\}\) and its likelihood is exactly a binary dual-response model with the single cut point \(c_{k-1}\). The parameters \(\bfbeta\) and \(c_{k-1}\) are still identified and still consistently estimated.

What is lost is the information in the other \(W - 2\) thresholds. The paper’s information decomposition makes this precise: the second response contributes \(\omega(\overline{\mu}; \mathbf{c}) \, \bar{x} \bar{x}'\) to the information matrix, where

\[ \omega = \sum_{w} \frac{\left( g_w - g_{w-1} \right)^2}{\pi_w} \]

sums over the categories. Dropping categories drops terms. The quantity \(\omega\) is computable in closed form, so we can say exactly what fraction of the second response’s information each coarsening retains, independent of any particular design.

2. Machinery

Copies of R/lib/dgp.R, R/lib/likelihood.R and R/homogeneous/fisher.R. The Fisher block is the part worth reading: it implements the paper’s information decomposition directly.

In [2]:
Design, simulator, likelihood, and exact Fisher information
rgumbel <- function(n) -log(-log(runif(n)))

# 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
}
id_rank_check <- function(X) {
  aug <- cbind(X, 1); list(rank = qr(aug)$rank, required = ncol(aug),
                           pass = qr(aug)$rank == ncol(aug))
}
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 }
}
ord_prob <- function(mubar, cut, y, link) {
  caug <- c(-Inf, cut, Inf); ord_prob_z(caug[y] - mubar, caug[y + 1L] - mubar, link)
}
negloglik <- function(par, dat) {
  design <- dat$design; P <- design$P; W <- dat$W
  n <- design$n_tasks; J <- design$J
  beta <- par[1:P]; cut <- par_to_cut(par, P, W)
  V <- matrix(design$X %*% beta, nrow = n, ncol = J, byrow = TRUE)
  m <- row_max(V); mubar <- m + log(rowSums(exp(V - m)))
  -(sum(V[cbind(seq_len(n), dat$jstar)] - mubar) +
    sum(log(pmax(ord_prob(mubar, cut, dat$y, dat$link), 1e-312))))
}
num_grad <- function(f, x, eps = 1e-6) {
  vapply(seq_along(x), function(k) {
    h <- eps * max(1, abs(x[k])); xp <- x; xp[k] <- xp[k] + h
    xm <- x; xm[k] <- xm[k] - h; (f(xp) - f(xm)) / (2 * h) }, numeric(1))
}
simulate_dual <- function(design, beta, cut, link = c("cloglog","logit"), seed) {
  link <- match.arg(link); set.seed(seed)
  n <- design$n_tasks; J <- design$J
  V <- matrix(design$X %*% beta, nrow = n, ncol = J, byrow = TRUE)
  u <- V + matrix(rgumbel(n * J), n, J)
  jstar <- max.col(u, ties.method = "first")
  ustar <- u[cbind(seq_len(n), jstar)]
  latent <- if (link == "cloglog") ustar else ustar - rgumbel(n)
  list(design = design, jstar = jstar, y = findInterval(latent, cut) + 1L,
       W = length(cut) + 1L, link = link, beta_true = beta, cut_true = cut)
}
fit_dual_mle <- function(dat, start = NULL) {
  design <- dat$design; P <- design$P; W <- dat$W
  if (is.null(start)) {
    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(design$J) + ginv(cumq)
    if (W > 2) for (k in 2:(W - 1)) cut0[k] <- max(cut0[k], cut0[k - 1] + 1e-3)
    start <- c(rep(0, P), cut_to_par(cut0))
  }
  fn <- function(p) negloglik(p, dat)
  opt <- optim(start, fn, method = "BFGS", control = list(maxit = 1000, reltol = 1e-12))
  H <- optimHess(opt$par, fn); ev <- eigen(H, symmetric = TRUE, only.values = TRUE)$values
  cut_hat <- par_to_cut(opt$par, P, W)
  Jc <- matrix(0, W - 1, length(opt$par)); Jc[, P + 1] <- 1
  if (W > 2) { d <- exp(opt$par[(P + 2):(P + W - 1)])
    for (w in 2:(W - 1)) Jc[w, (P + 2):(P + w)] <- d[1:(w - 1)] }
  Vpar <- tryCatch(solve(H), error = function(e) matrix(NA, length(opt$par), length(opt$par)))
  list(beta = opt$par[1:P], se_beta = sqrt(diag(Vpar)[1:P]), cut = cut_hat,
       se_cut = sqrt(diag(Jc %*% Vpar %*% t(Jc))), nll = opt$value,
       convergence = opt$convergence, grad_max = max(abs(num_grad(fn, opt$par))),
       eigen = ev, min_eig = min(ev),
       cond = max(ev) / max(min(ev), .Machine$double.eps), par = opt$par)
}

# ---- exact Fisher information (R/homogeneous/fisher.R) ---------------------
link_cdf <- function(z, link) if (link == "logit") plogis(z) else exp(-exp(-z))
link_pdf <- function(z, link) if (link == "logit") dlogis(z) else exp(-(z + exp(-z)))

# Information from one task: the MNL block plus the ordinal-stage block.
fisher_task <- function(Xt, beta, cut, link) {
  P <- ncol(Xt); W <- length(cut) + 1L; K <- P + W - 1L
  V <- as.numeric(Xt %*% beta)
  eV <- exp(V - max(V)); p <- eV / sum(eV)
  mubar <- max(V) + log(sum(eV))
  I <- matrix(0, K, K)
  I[1:P, 1:P] <- t(Xt) %*% (diag(p) - tcrossprod(p)) %*% Xt   # choice stage
  z <- cut - mubar
  Gz <- c(0, link_cdf(z, link), 1); gz <- c(0, link_pdf(z, link), 0)
  pi_w <- diff(Gz)
  xbar <- as.numeric(t(p) %*% Xt)
  for (w in seq_len(W)) {                                      # ordinal stage
    s <- numeric(K)
    s[1:P] <- -(gz[w + 1] - gz[w]) / pi_w[w] * xbar
    if (w <= W - 1) s[P + w] <- s[P + w] + gz[w + 1] / pi_w[w]
    if (w >= 2)     s[P + w - 1] <- s[P + w - 1] - gz[w] / pi_w[w]
    I <- I + pi_w[w] * tcrossprod(s)
  }
  I
}
fisher_total <- function(design, beta, cut, link) {
  n <- design$n_tasks; J <- design$J; K <- design$P + length(cut)
  I <- matrix(0, K, K)
  for (t in seq_len(n)) {
    rows <- ((t - 1) * J + 1):(t * J)
    I <- I + fisher_task(design$X[rows, , drop = FALSE], beta, cut, link)
  }
  I
}
# The scalar weight omega: how much the second response contributes per task.
omega_weight <- function(mubar, cut, link) {
  z <- outer(mubar, cut, function(m, c) c - m)
  Gz <- cbind(0, link_cdf(z, link), 1); gz <- cbind(0, link_pdf(z, link), 0)
  W <- length(cut) + 1L
  om <- 0
  for (w in seq_len(W)) om <- om + (gz[, w + 1] - gz[, w])^2 / (Gz[, w + 1] - Gz[, w])
  om
}
In [3]:
MODEL <- "logit"; N_TASKS <- 3600; J <- 4; W <- 5; P <- 5
R_REPS <- 100; N_EVAL <- 200
MC_SETS <- c("set1_moderate", "set4_skewed")

param_sets <- list(
  set1_moderate = list(beta = c(a2=0.8,a3=-0.5,b2=0.4,b3=1.0,price=-0.9),
                       cut = c(-1.0, 0.2, 1.2, 2.2)),
  set2_negative = list(beta = c(a2=-1.2,a3=0.3,b2=-0.6,b3=0.7,price=-1.5),
                       cut = c(-2.5, -1.2, 0.5, 1.0)),
  set3_stress   = list(beta = c(a2=2.0,a3=-1.8,b2=1.2,b3=-0.7,price=-2.2),
                       cut = c(-3.5, -2.2, 0.8, 3.0)),
  set4_skewed   = list(beta = c(a2=0.8,a3=-0.5,b2=0.4,b3=1.0,price=-0.9),
                       cut = c(0.2, 1.2, 2.4, 3.6))
)

design      <- make_design(N_TASKS, J, seed = 3001)
eval_design <- make_design(N_EVAL, J, seed = 777)
stopifnot(id_rank_check(design$X)$pass)

set4_skewed is the configuration that matters most for practice. Its cut points push the response distribution downward, producing the bottom-heavy purchase intent that fielded scales actually show, and leaving the top-2-box convention badly placed.

In [4]:
mubar_of <- function(des, beta) {
  V <- matrix(des$X %*% beta, nrow = des$n_tasks, ncol = des$J, byrow = TRUE)
  m <- row_max(V); m + log(rowSums(exp(V - m)))
}
xbar_of <- function(des, beta) {
  V <- matrix(des$X %*% beta, nrow = des$n_tasks, ncol = des$J, byrow = TRUE)
  m <- row_max(V); pr <- exp(V - m) / rowSums(exp(V - m))
  out <- matrix(0, des$n_tasks, ncol(des$X))
  for (j in seq_len(des$J))
    out <- out + pr[, j] * des$X[seq(j, by = des$J, length.out = des$n_tasks), , drop = FALSE]
  out
}
exceed_pred <- function(des, beta, cut_scalar)
  1 - link_cdf(cut_scalar - mubar_of(des, beta), MODEL)

# Which position in each estimator's cut vector can predict P(y >= k); NA = it cannot.
cutpos <- function(est, k) switch(est,
  ord5 = k - 1, ord3 = if (k %in% 3:4) k - 2 else NA,
  bin2 = if (k == 2) 1 else NA, bin3 = if (k == 3) 1 else NA,
  bin4 = if (k == 4) 1 else NA, bin5 = if (k == 5) 1 else NA)
est_cut <- function(est, cut_true) switch(est,
  ord5 = cut_true, ord3 = cut_true[2:3], bin2 = cut_true[1],
  bin3 = cut_true[2], bin4 = cut_true[3], bin5 = cut_true[4])
EST <- c("ord5", "ord3", "bin2", "bin3", "bin4", "bin5")

grad_exceed <- function(Xt, beta, cut, pos, npar) {
  V <- as.numeric(Xt %*% beta); m <- max(V)
  mubar <- m + log(sum(exp(V - m))); p <- exp(V - mubar)
  g <- link_pdf(cut[pos] - mubar, MODEL)
  gr <- numeric(npar); gr[1:P] <- g * as.numeric(t(p) %*% Xt); gr[P + pos] <- -g
  gr
}

Note what cutpos encodes: a binary estimator cut at \(k\) can answer questions about threshold \(k\) and no other. A top-box model cannot tell you about top-2-box demand. This is not an efficiency loss, it is an inability, and it returns in the pricing counterfactual of notebook 08.

3. Exact efficiency, from the Fisher information

In [5]:
analytic <- list()
for (sn in names(param_sets)) {
  ps <- param_sets[[sn]]
  mu_est_design <- mubar_of(design, ps$beta)
  catprob <- diff(c(0, colMeans(link_cdf(outer(mu_est_design, ps$cut,
                                               function(m, c) c - m), MODEL)), 1))
  u <- colMeans(xbar_of(design, ps$beta)); u <- u / sqrt(sum(u^2))

  Vcov <- list(); sdtab <- list()
  for (e in EST) {
    ct <- est_cut(e, ps$cut)
    Vc <- solve(fisher_total(design, ps$beta, ct, MODEL))
    Vcov[[e]] <- Vc
    Vb <- Vc[1:P, 1:P]
    row <- data.frame(estimator = e, mean_sd_beta = mean(sqrt(diag(Vb))),
                      sd_u_beta = sqrt(t(u) %*% Vb %*% u),
                      mean_omega = mean(omega_weight(mu_est_design, ct, MODEL)))
    for (k in 2:W) {
      pos <- cutpos(e, k)
      if (!is.na(pos)) {
        grads <- vapply(seq_len(N_EVAL), function(t) {
          rows <- ((t - 1) * J + 1):(t * J)
          grad_exceed(eval_design$X[rows, , drop = FALSE], ps$beta, ct, pos, nrow(Vc))
        }, numeric(nrow(Vc)))
        pv <- colSums(grads * (Vc %*% grads))
        gbar <- rowMeans(grads)
        row[[paste0("sd_pred_k", k)]]   <- sqrt(mean(pv))
        row[[paste0("sd_demand_k", k)]] <- sqrt(t(gbar) %*% Vc %*% gbar)
        row[[paste0("sd_c", k - 1)]]    <- sqrt(Vc[P + pos, P + pos])
      } else {
        row[[paste0("sd_pred_k", k)]] <- NA
        row[[paste0("sd_demand_k", k)]] <- NA
        row[[paste0("sd_c", k - 1)]] <- NA
      }
    }
    sdtab[[e]] <- row
  }
  sdtab <- do.call(rbind, sdtab); rownames(sdtab) <- NULL
  analytic[[sn]] <- list(sd = sdtab, u = u, catprob = catprob)
}

Implied response distributions

In [6]:
knitr::kable(
  do.call(rbind, lapply(names(param_sets), function(sn) {
    cp <- analytic[[sn]]$catprob
    data.frame(configuration = sn, `1` = cp[1], `2` = cp[2], `3` = cp[3],
               `4` = cp[4], `5` = cp[5], `top-2-box` = sum(cp[4:5]),
               check.names = FALSE)})),
  row.names = FALSE, digits = 3)
configuration 1 2 3 4 5 top-2-box
set1_moderate 0.145 0.205 0.233 0.202 0.215 0.417
set2_negative 0.152 0.221 0.365 0.080 0.181 0.262
set3_stress 0.082 0.125 0.511 0.225 0.057 0.282
set4_skewed 0.350 0.233 0.233 0.119 0.065 0.184

What each coarsening costs

In [7]:
eff_tab <- do.call(rbind, lapply(names(param_sets), function(sn) {
  a <- analytic[[sn]]$sd
  data.frame(configuration = sn,
             `top-2-box, beta` = round((a$mean_sd_beta[5] / a$mean_sd_beta[1])^2, 3),
             `worst cut, beta` = round(max((a$mean_sd_beta / a$mean_sd_beta[1])^2, na.rm = TRUE), 3),
             `omega share, top-2-box` = round(a$mean_omega[5] / a$mean_omega[1], 2),
             `omega share, worst` = round(min(a$mean_omega[-1] / a$mean_omega[1], na.rm = TRUE), 2),
             check.names = FALSE)}))
knitr::kable(eff_tab, row.names = FALSE)
configuration top-2-box, beta worst cut, beta omega share, top-2-box omega share, worst
set1_moderate 1.041 1.091 0.73 0.38
set2_negative 1.088 1.119 0.59 0.40
set3_stress 1.152 1.327 0.59 0.19
set4_skewed 1.080 1.125 0.48 0.20

Two columns, two different things. The beta columns are variance ratios: a value of 1.05 means the binary analyst needs five percent more sample for the same precision on preference parameters. The omega share columns say what fraction of the second response’s own information survives the coarsening, and those numbers are much worse. The gap between them exists because \(\bfbeta\) is mostly identified by the forced choice, which both estimators keep intact.

The analyst must commit to the cut before seeing the data, so the “worst cut” column is the relevant risk, not the best case.

Full detail, per configuration

In [8]:
for (sn in names(param_sets)) {
  cat("\n###", sn, "\n")
  a <- analytic[[sn]]$sd
  ratio <- function(col) round((a[[col]] / a[[col]][1])^2, 3)
  print(knitr::kable(
    data.frame(estimator = a$estimator,
               beta_avg = round((a$mean_sd_beta / a$mean_sd_beta[1])^2, 3),
               beta_u_dir = ratio("sd_u_beta"),
               omega_share = round(a$mean_omega / a$mean_omega[1], 3),
               pred_k4 = ratio("sd_pred_k4"),
               demand_k4 = ratio("sd_demand_k4")),
    row.names = FALSE))
}

### set1_moderate 


|estimator | beta_avg| beta_u_dir| omega_share| pred_k4| demand_k4|
|:---------|--------:|----------:|-----------:|-------:|---------:|
|ord5      |    1.000|      1.000|       1.000|   1.000|     1.000|
|ord3      |    1.015|      1.014|       0.890|   1.010|     1.006|
|bin2      |    1.091|      1.085|       0.381|      NA|        NA|
|bin3      |    1.043|      1.040|       0.686|      NA|        NA|
|bin4      |    1.041|      1.038|       0.732|   1.023|     1.013|
|bin5      |    1.078|      1.074|       0.516|      NA|        NA|

### set2_negative 


|estimator | beta_avg| beta_u_dir| omega_share| pred_k4| demand_k4|
|:---------|--------:|----------:|-----------:|-------:|---------:|
|ord5      |    1.000|      1.000|       1.000|   1.000|     1.000|
|ord3      |    1.016|      1.019|       0.920|   1.009|     1.005|
|bin2      |    1.119|      1.122|       0.397|      NA|        NA|
|bin3      |    1.056|      1.057|       0.698|      NA|        NA|
|bin4      |    1.088|      1.100|       0.589|   1.039|     1.018|
|bin5      |    1.117|      1.132|       0.460|      NA|        NA|

### set3_stress 


|estimator | beta_avg| beta_u_dir| omega_share| pred_k4| demand_k4|
|:---------|--------:|----------:|-----------:|-------:|---------:|
|ord5      |    1.000|      1.000|       1.000|   1.000|     1.000|
|ord3      |    1.023|      1.016|       0.919|   1.020|     1.014|
|bin2      |    1.265|      1.238|       0.243|      NA|        NA|
|bin3      |    1.159|      1.133|       0.484|      NA|        NA|
|bin4      |    1.152|      1.139|       0.589|   1.073|     1.026|
|bin5      |    1.327|      1.305|       0.186|      NA|        NA|

### set4_skewed 


|estimator | beta_avg| beta_u_dir| omega_share| pred_k4| demand_k4|
|:---------|--------:|----------:|-----------:|-------:|---------:|
|ord5      |    1.000|      1.000|       1.000|   1.000|     1.000|
|ord3      |    1.026|      1.025|       0.828|   1.008|     1.003|
|bin2      |    1.037|      1.034|       0.711|      NA|        NA|
|bin3      |    1.035|      1.032|       0.758|      NA|        NA|
|bin4      |    1.080|      1.076|       0.477|   1.023|     1.008|
|bin5      |    1.125|      1.117|       0.197|      NA|        NA|

beta_u_dir is the variance in the “average attractiveness” direction, the rank-one direction the ordinal stage informs and the forced choice identifies worst. It is consistently the most damaged, which is exactly what the theory predicts: that is where the second response carries information the first does not.

4. Monte Carlo validation

The analytic numbers are asymptotic. Do they describe finite samples? Fit every estimator to the same simulated datasets and compare empirical spread to the predicted spread.

In [9]:
collapse3 <- function(y) 1L + (y >= 3L) + (y >= 4L)
mc <- list()
for (sn in MC_SETS) {
  ps <- param_sets[[sn]]
  mubar_eval_true <- mubar_of(eval_design, ps$beta)
  true_ex <- sapply(2:W, function(k) 1 - link_cdf(ps$cut[k - 1] - mubar_eval_true, MODEL))

  beta_est <- array(NA_real_, c(R_REPS, P, length(EST)),
                    dimnames = list(NULL, names(ps$beta), EST))
  c_own <- array(NA_real_, c(R_REPS, 4, length(EST)),
                 dimnames = list(NULL, paste0("c", 1:4), EST))
  demand <- array(NA_real_, c(R_REPS, W - 1, length(EST)),
                  dimnames = list(NULL, paste0("k", 2:W), EST))
  pred_sse <- matrix(0, length(EST), W - 1, dimnames = list(EST, paste0("k", 2:W)))
  pred_n <- pred_sse

  for (r in seq_len(R_REPS)) {
    dat5 <- simulate_dual(design, ps$beta, ps$cut, link = MODEL, seed = 60000 + r)
    fits <- list()
    fits$ord5 <- fit_dual_mle(dat5)
    dat3 <- dat5; dat3$y <- collapse3(dat5$y); dat3$W <- 3L
    fits$ord3 <- fit_dual_mle(dat3)
    for (k in 2:W) {
      datb <- dat5; datb$y <- 1L + (dat5$y >= k); datb$W <- 2L
      fits[[paste0("bin", k)]] <- fit_dual_mle(datb)
    }
    for (e in EST) {
      beta_est[r, , e] <- fits[[e]]$beta
      for (k in 2:W) {
        pos <- cutpos(e, k); if (is.na(pos)) next
        c_own[r, k - 1, e] <- fits[[e]]$cut[pos]
        pr <- exceed_pred(eval_design, fits[[e]]$beta, fits[[e]]$cut[pos])
        demand[r, k - 1, e] <- mean(pr)
        pred_sse[e, k - 1] <- pred_sse[e, k - 1] + mean((pr - true_ex[, k - 1])^2)
        pred_n[e, k - 1] <- pred_n[e, k - 1] + 1
      }
    }
  }
  pred_rmse <- sqrt(pred_sse / pmax(pred_n, 1)); pred_rmse[pred_n == 0] <- NA
  vr <- sapply(2:W, function(k)
    (pred_rmse[paste0("bin", k), k - 1] / pred_rmse["ord5", k - 1])^2)
  names(vr) <- paste0("k", 2:W)
  mc[[sn]] <- list(beta_est = beta_est, c_own = c_own, demand = demand,
                   pred_rmse = pred_rmse, pred_var_ratio = vr, true_ex = true_ex)
}

Empirical versus analytic

In [10]:
for (sn in MC_SETS) {
  cat("\n###", sn, "-- cut-point sd, empirical vs analytic\n")
  rows <- list()
  for (k in 2:W) for (e in EST) {
    if (!is.na(cutpos(e, k))) {
      rows[[length(rows) + 1]] <- data.frame(
        cut = paste0("c", k - 1), estimator = e,
        empirical = round(sd(mc[[sn]]$c_own[, k - 1, e]), 4),
        analytic = round(analytic[[sn]]$sd[[paste0("sd_c", k - 1)]][match(e, EST)], 4))
    }
  }
  print(knitr::kable(do.call(rbind, rows), row.names = FALSE))
}

### set1_moderate -- cut-point sd, empirical vs analytic


|cut |estimator | empirical| analytic|
|:---|:---------|---------:|--------:|
|c1  |ord5      |    0.0749|   0.0783|
|c1  |bin2      |    0.0748|   0.0806|
|c2  |ord5      |    0.0669|   0.0713|
|c2  |ord3      |    0.0694|   0.0717|
|c2  |bin3      |    0.0673|   0.0725|
|c3  |ord5      |    0.0669|   0.0705|
|c3  |ord3      |    0.0692|   0.0710|
|c3  |bin4      |    0.0720|   0.0716|
|c4  |ord5      |    0.0747|   0.0740|
|c4  |bin5      |    0.0765|   0.0759|

### set4_skewed -- cut-point sd, empirical vs analytic


|cut |estimator | empirical| analytic|
|:---|:---------|---------:|--------:|
|c1  |ord5      |    0.0686|   0.0714|
|c1  |bin2      |    0.0673|   0.0725|
|c2  |ord5      |    0.0683|   0.0707|
|c2  |ord3      |    0.0714|   0.0714|
|c2  |bin3      |    0.0720|   0.0716|
|c3  |ord5      |    0.0757|   0.0754|
|c3  |ord3      |    0.0784|   0.0761|
|c3  |bin4      |    0.0774|   0.0774|
|c4  |ord5      |    0.0964|   0.0916|
|c4  |bin5      |    0.0984|   0.0941|

The two columns track each other closely, which validates both the Fisher calculation and the standard-error formulas.

Is anything biased?

In [11]:
bias_tab <- do.call(rbind, lapply(MC_SETS, function(sn) {
  ps <- param_sets[[sn]]
  data.frame(configuration = sn,
    t(sapply(EST, function(e) {
      b <- colMeans(mc[[sn]]$beta_est[, , e]) - ps$beta
      max(abs(b / (apply(mc[[sn]]$beta_est[, , e], 2, sd) / sqrt(R_REPS))))
    })))}))
knitr::kable(bias_tab, row.names = FALSE, digits = 2)
configuration ord5 ord3 bin2 bin3 bin4 bin5
set1_moderate 1.12 1.21 1.17 1.35 1.22 1.34
set4_skewed 1.05 1.14 1.35 1.22 1.17 1.04

Maximum \(|t|\) statistics for bias, by estimator. Nothing systematic anywhere, confirming that dichotomization costs efficiency and not consistency. This is the claim that lets the rest of the comparison be about precision alone.

5. The individual level, where it matters

Everything above pools 3,600 tasks and estimates one parameter vector. That is not what practitioners do. They estimate a hierarchical model in which each respondent contributes twelve tasks, and their deliverable is individual-level part-worths and individual purchase probabilities.

At twelve tasks per person, there is no pooling to rescue the coarsened second stage.

In [12]:
Panel simulation and the hierarchical sampler
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
}
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) {
  link <- match.arg(link); 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 <- V + matrix(rgumbel(n * J), n, J)
  jstar <- max.col(u, ties.method = "first")
  ustar <- u[cbind(seq_len(n), jstar)]
  latent <- if (link == "cloglog") ustar else ustar - rgumbel(n)
  y <- 1L + rowSums(latent >= cutmat[design$resp, , drop = FALSE])
  list(design = design, resp = design$resp, respondent_id = design$respondent_id, N = N,
       jstar = jstar, y = as.integer(y), W = W, link = link,
       Bmat_true = Bmat, cut_true = cut)
}
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))))
  m <- Vb %*% (Si %*% (N * colMeans(Phi)) + pr$A * pr$phibar0)
  as.numeric(m + 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)
In [13]:
N <- 300; T_EST <- 12; T_HOLD <- 2
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)
KBUY <- 4              # "purchase" means top-2-box throughout
REPS <- 20
ITER <- 20000
MCMC <- list(R = ITER, burn = floor(ITER * 0.4), thin = 10)

gen_panel <- function(seed, link = "logit") {
  des <- make_panel_design(N, T_EST + T_HOLD, J, seed = seed)
  B <- draw_betas(N, beta_bar, Sigma_true, seed = seed + 1)
  split_holdout(simulate_dual_hb(des, B, cut_true, 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)))
}
true_exceed_panel <- function(sp) {
  d <- sp$holdout$design
  mubar <- task_mubar(d, sp$holdout$Bmat_true)
  cutm <- matrix(sp$holdout$cut_true, N, W - 1, byrow = TRUE)
  1 - plogis(cutm[d$resp, KBUY - 1] - mubar)
}
pred_exceed_panel <- function(fit, sp, k = KBUY) {
  d <- sp$holdout$design
  mubar <- task_mubar(d, hb_beta_i(fit))
  cutvec <- colMeans(fit$draws$cut)
  cc <- rep(cutvec[min(k - 1, length(cutvec))], 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)
}
dichotomize <- function(dat, k = KBUY) { dat$y <- 1L + (dat$y >= k); dat$W <- 2L; dat }

Twenty paired replications. Each generates one panel, then fits the ordinal model and the top-2-box binary model to the same forced-choice data, so every difference is attributable to the coarsened second response.

In [14]:
m <- matrix(NA_real_, REPS, 8, dimnames = list(NULL, c(
  "rmse_bi_ord","rmse_bi_bin","hit_ord","hit_bin",
  "rmse_ex_ord","rmse_ex_bin","sd_c3_ord","sd_c3_bin")))
for (r in seq_len(REPS)) {
  sp <- gen_panel(seed = 60000 + 10 * r)
  f_ord <- fit_dual_hb(sp$est, mcmc = MCMC, seed = 61000 + r, verbose = FALSE)
  f_bin <- fit_dual_hb(dichotomize(sp$est), mcmc = MCMC, seed = 62000 + r, verbose = FALSE)
  tex <- true_exceed_panel(sp)
  m[r, ] <- c(
    sqrt(mean((hb_beta_i(f_ord) - sp$est$Bmat_true)^2)),
    sqrt(mean((hb_beta_i(f_bin) - sp$est$Bmat_true)^2)),
    hit_rate(f_ord, sp), hit_rate(f_bin, sp),
    sqrt(mean((pred_exceed_panel(f_ord, sp) - tex)^2)),
    sqrt(mean((pred_exceed_panel(f_bin, sp, k = 2) - tex)^2)),
    sd(f_ord$draws$cut[, KBUY - 1]), sd(f_bin$draws$cut[, 1]))
}
res_dichot <- list(metrics = m, mean = colMeans(m), se = apply(m, 2, sd)/sqrt(REPS))
knitr::kable(rbind(mean = res_dichot$mean, mc_se = res_dichot$se), digits = 4)
rmse_bi_ord rmse_bi_bin hit_ord hit_bin rmse_ex_ord rmse_ex_bin sd_c3_ord sd_c3_bin
mean 0.5237 0.5345 0.5106 0.5099 0.1114 0.1214 0.0763 0.0797
mc_se 0.0036 0.0035 0.0048 0.0045 0.0011 0.0012 0.0009 0.0007
In [15]:
rm_ <- res_dichot$mean
knitr::kable(data.frame(
  quantity = c("individual part-worth RMSE", "individual purchase-probability RMSE",
               "posterior sd of the shared cut point", "holdout hit rate"),
  ordinal = c(rm_["rmse_bi_ord"], rm_["rmse_ex_ord"], rm_["sd_c3_ord"], rm_["hit_ord"]),
  binary  = c(rm_["rmse_bi_bin"], rm_["rmse_ex_bin"], rm_["sd_c3_bin"], rm_["hit_bin"]),
  `variance ratio` = c((rm_["rmse_bi_bin"]/rm_["rmse_bi_ord"])^2,
                       (rm_["rmse_ex_bin"]/rm_["rmse_ex_ord"])^2,
                       (rm_["sd_c3_bin"]/rm_["sd_c3_ord"])^2, NA),
  check.names = FALSE), row.names = FALSE, digits = 4)
quantity ordinal binary variance ratio
individual part-worth RMSE 0.5237 0.5345 1.0417
individual purchase-probability RMSE 0.1114 0.1214 1.1884
posterior sd of the shared cut point 0.0763 0.0797 1.0904
holdout hit rate 0.5106 0.5099 NA

Read the last column. The cut point and the individual purchase probabilities, which are what the dual response exists to measure, degrade substantially. The holdout hit rate does not move at all, and should not: the first response carries the ranking information and dichotomization leaves it untouched.

That is the finding. Dichotomization does no harm to what the forced choice already tells you, and real harm to the one thing the second question was added to measure.

6. Results written

In [16]:
# Quarto executes some passes from the project root and others from this
# file's directory, so anchor the output path on the project marker rather
# than trusting the working directory.
PROJ <- if (file.exists("_quarto.yml")) "." else ".."
OUT  <- file.path(PROJ, "R", "output")
dir.create(OUT, showWarnings = FALSE, recursive = TRUE)
saveRDS(list(analytic = analytic, mc = mc,
             settings = list(MODEL = MODEL, N_TASKS = N_TASKS, J = J, W = W,
                             R_REPS = R_REPS, param_sets = param_sets)),
        file.path(OUT, "dichotomization_study.rds"))
saveRDS(list(cell = "dichot", res = res_dichot,
             settings = list(N = N, T_EST = T_EST, T_HOLD = T_HOLD, J = J, W = W,
                             REPS = REPS, ITER = ITER, KBUY = KBUY)),
        file.path(OUT, "hb_simstudy_dichot.rds"))
cat("wrote dichotomization_study.rds and hb_simstudy_dichot.rds\n")
wrote dichotomization_study.rds and hb_simstudy_dichot.rds