R/hb_postest.R

Defines functions diversion_ratios.choicer_hb elasticities.choicer_hb .hb_perturb_shares consumer_surplus.choicer_hmnp consumer_surplus.choicer_hmnl logsum.choicer_hmnp logsum.choicer_hmnl wtp.choicer_hb predict.choicer_hb .hb_predict_core .hb_task_probs .hb_logit_task_terms .hb_delta_new_draws .hb_delta_row .hb_resolve_newdata .hb_rc_dist .hb_gamma .hb_draw_index .gauss_hermite

Documented in consumer_surplus.choicer_hmnl consumer_surplus.choicer_hmnp diversion_ratios.choicer_hb elasticities.choicer_hb logsum.choicer_hmnl logsum.choicer_hmnp predict.choicer_hb wtp.choicer_hb

# Post-estimation methods for the hierarchical Bayes fits (choicer_hmnl /
# choicer_hmnp), registered on the package's existing generics. Everything
# integrates over posterior draws (and, at the population level, over the
# random-coefficient distribution), returning posterior summaries rather
# than delta-method points.
#
# Probability engines:
#   * HMNL: closed-form softmax against the implicit outside option,
#     P(j) = exp(V_j) / (1 + sum_k exp(V_k)).
#   * HMNP: the iid-normal shocks give the 1-D integral
#     P(j) = int phi(u) prod_{k != j} Phi(V_j - V_k + u) du (outside V = 0,
#     identified scale sigma = 1), evaluated by fixed-node Gauss-Hermite
#     quadrature (.gauss_hermite) — deterministic, no simulation noise.
#     logsum / consumer_surplus stay HMNL-only in v1: the logsum formula is
#     EV1-specific and probit E[max U] has no closed form (simulated-Emax is
#     roadmapped).

# --- internal helpers ---------------------------------------------------------

#' Gauss-Hermite nodes and weights via Golub-Welsch
#'
#' Physicists' convention: int exp(-t^2) f(t) dt = sum w_m f(t_m). For
#' N(0, 1) expectations use int phi(u) f(u) du = sum (w_m / sqrt(pi))
#' f(sqrt(2) t_m).
#' @noRd
.gauss_hermite <- function(n) {
  i <- seq_len(n - 1L)
  Jm <- matrix(0, n, n)
  off <- sqrt(i / 2)
  Jm[cbind(i, i + 1L)] <- off
  Jm[cbind(i + 1L, i)] <- off
  eg <- eigen(Jm, symmetric = TRUE)
  idx <- order(eg$values)
  list(
    nodes = eg$values[idx],
    weights = (eg$vectors[1L, idx])^2 * sqrt(pi)
  )
}

#' Thinned indices into the kept posterior draws
#' @noRd
.hb_draw_index <- function(object, n_draws) {
  R_keep <- nrow(object$draws$b)
  if (n_draws >= R_keep) seq_len(R_keep)
  else unique(round(seq(1L, R_keep, length.out = n_draws)))
}

#' Random-coefficient transform: chain scale -> utility scale
#' @noRd
.hb_gamma <- function(beta, rc_dist) {
  ln <- which(rc_dist == 1L)
  if (length(ln)) beta[ln] <- exp(beta[ln])
  beta
}

#' rc_dist lookup with cross-version fallbacks (HMNP fits are all-normal)
#' @noRd
.hb_rc_dist <- function(object) {
  rc <- object$rc_dist %||% object$data_spec$rc_dist %||%
    object$data$rc_dist
  if (is.null(rc)) rc <- rep(0L, object$K_struct)
  as.integer(rc)
}

#' Resolve prediction data for a hierarchical Bayes fit
#'
#' Builds the prediction panel from `newdata` (or the stored estimation
#' data): the structural design matrix in estimation column order, the task
#' grouping, and the per-row alternative resolution. Alternatives never seen
#' in estimation are matched by their `alt_covariate_cols` values and get a
#' posterior-predictive delta, N(z_new' theta_r, sigma_d2_r) per draw — the
#' entry-counterfactual mechanism of the random-effects delta.
#' @noRd
.hb_resolve_newdata <- function(object, newdata) {
  spec <- object$data_spec
  if (is.null(newdata)) {
    if (is.null(object$data)) {
      stop("No `newdata` supplied and the fit was built with keep_data = ",
           "FALSE; refit with keep_data = TRUE or pass `newdata`.")
    }
    # Rebuild from the stored prep: rows are already sorted and encoded.
    d <- object$data
    return(list(
      X = d$X,
      task_of_row = rep(seq_along(d$M), times = d$M),
      n_tasks = d$n_tasks,
      task_keys = d$task_keys %||% as.character(seq_len(d$n_tasks)),
      task_keys_exact = !is.null(d$task_keys),
      alt_label = as.character(
        d$alt_mapping[d$alt_mapping$alt_int > 0, ][[spec$alt_col]]
      )[d$alt_of_row],
      known_idx = d$alt_of_row,
      z_new = NULL,
      new_labels = character(0),
      person = if (!is.null(spec$person_col)) {
        # one entry per ROW: person per task, expanded by the task sizes
        rep(rep(d$person_ids, times = d$Ti), times = d$M)
      }
    ))
  }

  dt <- data.table::as.data.table(newdata)[]
  needed <- unique(c(spec$person_col, spec$id_col, spec$alt_col,
                     spec$covariate_cols, spec$alt_covariate_cols,
                     spec$cf_residual_col))
  missing_cols <- setdiff(needed, names(dt))
  if (length(missing_cols)) {
    stop("`newdata` is missing columns used in estimation: ",
         paste(missing_cols, collapse = ", "))
  }
  x_cols <- colnames(object$draws$b)      # estimation column order
  if (!all(x_cols %in% names(dt))) {
    stop("`newdata` is missing structural covariate columns: ",
         paste(setdiff(x_cols, names(dt)), collapse = ", "))
  }

  ord_cols <- c(spec$person_col, spec$id_col, spec$alt_col)
  data.table::setorderv(dt, ord_cols)
  task_key <- .hb_task_keys(
    dt[[spec$id_col]],
    if (!is.null(spec$person_col)) dt[[spec$person_col]]
  )
  task_of_row <- as.integer(factor(task_key, levels = unique(task_key)))
  task_keys <- unique(task_key)

  X <- as.matrix(dt[, x_cols, with = FALSE])
  if (!all(is.finite(X))) stop("`newdata` covariates must be finite.")

  am <- object$alt_mapping
  known_labels <- as.character(am[am$alt_int > 0, ][[spec$alt_col]])
  alt_label <- as.character(dt[[spec$alt_col]])
  known_idx <- match(alt_label, known_labels)   # NA = new alternative

  new_labels <- sort(unique(alt_label[is.na(known_idx)]))
  z_new <- NULL
  if (length(new_labels)) {
    z_cols <- setdiff(colnames(object$draws$theta), "(Intercept)")
    if (length(z_cols) && !all(z_cols %in% names(dt))) {
      stop("New alternatives in `newdata` (",
           paste(new_labels, collapse = ", "),
           ") need their alternative-level covariates: ",
           paste(setdiff(z_cols, names(dt)), collapse = ", "))
    }
    z_new <- matrix(1, nrow = length(new_labels), ncol = 1L,
                    dimnames = list(new_labels, "(Intercept)"))
    for (zc in z_cols) {
      vals <- vapply(new_labels, function(lb) {
        v <- unique(dt[[zc]][alt_label == lb])
        if (length(v) != 1L) {
          stop("Alternative-level covariate `", zc,
               "` is not constant within new alternative `", lb, "`.")
        }
        as.numeric(v)
      }, numeric(1L))
      z_new <- cbind(z_new, vals)
      colnames(z_new)[ncol(z_new)] <- zc
    }
  }

  list(
    X = X,
    task_of_row = task_of_row,
    n_tasks = max(task_of_row),
    task_keys = task_keys,
    task_keys_exact = TRUE,
    alt_label = alt_label,
    known_idx = known_idx,
    z_new = z_new,
    new_labels = new_labels,
    person = if (!is.null(spec$person_col)) dt[[spec$person_col]]
  )
}

#' Per-row delta for one posterior draw (posterior-predictive for new alts)
#' @noRd
.hb_delta_row <- function(object, rd, r, delta_new_draws) {
  out <- numeric(length(rd$alt_label))
  known <- !is.na(rd$known_idx)
  out[known] <- object$draws$delta[r, rd$known_idx[known]]
  if (any(!known)) {
    out[!known] <- delta_new_draws[[r]][rd$alt_label[!known]]
  }
  out
}

#' Posterior-predictive delta draws for alternatives outside the estimation
#' sample: delta_new ~ N(z_new' theta_r, sigma_d2_r), one draw per kept
#' posterior draw (set.seed() governs reproducibility).
#' @noRd
.hb_delta_new_draws <- function(object, rd, idx) {
  if (is.null(rd$z_new)) return(NULL)
  theta_cols <- colnames(object$draws$theta)
  Zn <- rd$z_new[, theta_cols, drop = FALSE]
  out <- vector("list", nrow(object$draws$b))
  for (r in idx) {
    mu <- drop(Zn %*% object$draws$theta[r, ])
    out[[r]] <- stats::setNames(
      stats::rnorm(length(mu), mu, sqrt(object$draws$sigma_d2[r])),
      rownames(rd$z_new)
    )
  }
  out
}

#' Task-level choice probabilities for one draw (both engines)
#'
#' V is the inside-row utility index; returns a list with `p_inside` (per
#' row) and `p_outside` (per task).
#' @noRd
.hb_logit_task_terms <- function(V, task_of_row, n_tasks) {
  rows_by_task <- split(seq_along(V), task_of_row)
  vmax <- vapply(rows_by_task, function(rows) max(V[rows]), numeric(1L))
  if (length(vmax) != n_tasks) {
    stop("Internal error: every prediction task must contain an inside row.",
         call. = FALSE)
  }

  # The outside option has systematic utility zero. Subtract the taskwise
  # maximum over {0, V_1, ..., V_J} before exponentiating, so both probabilities
  # and logsums remain finite even for utilities far outside exp()'s range.
  shift <- pmax(0, vmax)
  e_inside <- exp(V - shift[task_of_row])
  e_outside <- exp(-shift)
  denom <- e_outside +
    as.numeric(rowsum(e_inside, task_of_row, reorder = TRUE))

  list(
    p_inside = e_inside / denom[task_of_row],
    p_outside = e_outside / denom,
    logsum = shift + log(denom)
  )
}

#' @noRd
.hb_task_probs <- function(V, task_of_row, n_tasks, model, gh = NULL) {
  if (model == "hmnl") {
    terms <- .hb_logit_task_terms(V, task_of_row, n_tasks)
    return(terms[c("p_inside", "p_outside")])
  }
  # HMNP: Gauss-Hermite over the common shock argument, per task.
  p_inside <- numeric(length(V))
  p_outside <- numeric(n_tasks)
  u <- sqrt(2) * gh$nodes
  w <- gh$weights / sqrt(pi)
  rows_by_task <- split(seq_along(V), task_of_row)
  for (t in seq_len(n_tasks)) {
    rows <- rows_by_task[[t]]
    v <- c(V[rows], 0)                    # outside last
    m <- length(v)
    p <- numeric(m)
    for (j in seq_len(m)) {
      f <- rep(1, length(u))
      for (k in seq_len(m)) {
        if (k != j) f <- f * stats::pnorm(v[j] - v[k] + u)
      }
      p[j] <- sum(w * f)
    }
    p <- p / sum(p)                       # guard quadrature roundoff
    p_inside[rows] <- p[seq_len(m - 1L)]
    p_outside[t] <- p[m]
  }
  list(p_inside = p_inside, p_outside = p_outside)
}

#' Shared prediction engine: aggregate shares (or per-row means) over
#' posterior draws
#' @noRd
.hb_predict_core <- function(object, rd, idx, level, n_gh = 20L) {
  K <- object$K_struct
  rc <- .hb_rc_dist(object)
  gh <- if (object$model == "hmnp") .gauss_hermite(n_gh)
  delta_new <- .hb_delta_new_draws(object, rd, idx)

  use_beta_draws <- identical(level, "individual") &&
    !is.null(object$beta_i$draws)
  if (identical(level, "individual")) {
    if (is.null(rd$person)) {
      stop("level = \"individual\" requires a person column in the ",
           "prediction data (person_col was NULL in estimation).")
    }
    person_pos <- match(as.character(rd$person),
                        colnames(object$beta_i$mean))
    if (anyNA(person_pos)) {
      stop("level = \"individual\": `newdata` contains respondents not in ",
           "the estimation sample.")
    }
    if (!use_beta_draws) {
      message("Using posterior-mean beta_i (keep_beta_i = \"draws\" was not ",
              "set); individual probabilities are plug-in, not fully ",
              "posterior-integrated.")
    }
  }

  labels <- c(sort(unique(rd$alt_label)), "(outside)")
  share_draws <- matrix(0, length(idx), length(labels),
                        dimnames = list(NULL, labels))
  row_prob_sum <- numeric(length(rd$alt_label))

  for (s in seq_along(idx)) {
    r <- idx[s]
    if (identical(level, "population")) {
      # one beta draw from N(b_r, W_r) per posterior draw
      W <- matrix(0, K, K)
      W[lower.tri(W, diag = TRUE)] <- object$draws$w_vech[r, ]
      W <- W + t(W) - diag(diag(W))
      L <- t(chol(W + diag(1e-10, K)))
      beta <- object$draws$b[r, ] + drop(L %*% stats::rnorm(K))
      gamma_row <- matrix(.hb_gamma(beta, rc), nrow = 1L)
      Vx <- drop(rd$X %*% t(gamma_row))
    } else {
      if (use_beta_draws) {
        bmat <- object$beta_i$draws[, person_pos, r, drop = FALSE]
        bmat <- matrix(bmat, nrow = K)
      } else {
        bmat <- object$beta_i$mean[, person_pos, drop = FALSE]
      }
      for (k in which(rc == 1L)) bmat[k, ] <- exp(bmat[k, ])
      Vx <- rowSums(rd$X * t(bmat))
    }
    V <- Vx + .hb_delta_row(object, rd, r, delta_new)
    pr <- .hb_task_probs(V, rd$task_of_row, rd$n_tasks, object$model, gh)

    agg <- rowsum(pr$p_inside, rd$alt_label, reorder = TRUE)
    share_draws[s, rownames(agg)] <- agg[, 1L] / rd$n_tasks
    share_draws[s, "(outside)"] <- mean(pr$p_outside)
    row_prob_sum <- row_prob_sum + pr$p_inside
  }

  list(share_draws = share_draws,
       row_prob_mean = row_prob_sum / length(idx))
}

# --- predict ------------------------------------------------------------------

#' Posterior choice probabilities and shares for hierarchical Bayes fits
#'
#' Computes counterfactual choice probabilities, integrating over the
#' posterior draws and (at the population level) over the random-coefficient
#' distribution: for each kept draw \eqn{(b_r, W_r, \delta_r, \ldots)} one
#' \eqn{\beta \sim N(b_r, W_r)} is drawn and the model probabilities are
#' averaged. Alternatives in `newdata` that were not in the estimation
#' sample receive a posterior-predictive
#' \eqn{\delta_{new} \sim N(z_{new}'\theta_r, \sigma_{d,r}^2)} — the entry
#' counterfactual unlocked by the random-effects \eqn{\delta}. Price or
#' subsidy counterfactuals are just modified covariate columns in `newdata`.
#'
#' HMNL probabilities are closed-form logit; HMNP probabilities use the
#' 1-D Gauss-Hermite representation of the iid-probit integral
#' \eqn{P(j) = \int \phi(u) \prod_{k \ne j} \Phi(V_j - V_k + u) du}.
#'
#' @param object A `choicer_hmnl` or `choicer_hmnp` fit.
#' @param newdata Data frame with the estimation columns (choice column not
#'   required). `NULL` (default) predicts on the estimation data.
#' @param level `"population"` (default) integrates over \eqn{N(b, W)};
#'   `"individual"` uses the respondent-level \eqn{\beta_i} (requires the
#'   prediction rows to belong to estimation respondents; fully
#'   posterior-integrated when the fit kept `keep_beta_i = "draws"`).
#' @param n_draws Number of posterior draws to integrate over (thinned
#'   evenly from the kept draws; default 200).
#' @param aggregate If `TRUE` (default) return a per-alternative posterior
#'   share table (including the outside option); if `FALSE` return the
#'   posterior-mean probability per row of the prediction data.
#' @param ... Ignored.
#' @returns With `aggregate = TRUE`, a `data.table` with columns
#'   `alternative`, `share` (posterior mean), `sd`, `lower`, `upper` (95%
#'   equal-tailed interval); the posterior share draws are attached as
#'   `attr(, "draws")`. With `aggregate = FALSE`, a numeric vector of
#'   posterior-mean choice probabilities, one per prediction row.
#' @examples
#' \donttest{
#' sim <- simulate_hmnl_data(N = 100, T = 3, J = 4, seed = 42)
#' fit <- suppressWarnings(run_hmnlogit(sim$data, "task", "alt", "choice", c("x1", "x2"),
#'                     person_col = "pid", alt_covariate_cols = "z1",
#'                     mcmc = list(R = 500, burn = 200)))
#' predict(fit)                       # posterior shares, estimation data
#' cf <- sim$data
#' cf$x1 <- cf$x1 + 0.5               # a counterfactual attribute change
#' predict(fit, newdata = cf)
#' }
#' @export
predict.choicer_hb <- function(object, newdata = NULL,
                               level = c("population", "individual"),
                               n_draws = 200L, aggregate = TRUE, ...) {
  level <- match.arg(level)
  rd <- .hb_resolve_newdata(object, newdata)
  idx <- .hb_draw_index(object, n_draws)
  core <- .hb_predict_core(object, rd, idx, level)

  if (!aggregate) return(core$row_prob_mean)

  sd_ <- apply(core$share_draws, 2, stats::sd)
  qs <- t(apply(core$share_draws, 2, stats::quantile,
                probs = c(0.025, 0.975)))
  out <- data.table::data.table(
    alternative = colnames(core$share_draws),
    share = colMeans(core$share_draws),
    sd = sd_,
    lower = qs[, 1L],
    upper = qs[, 2L]
  )
  data.table::setattr(out, "draws", core$share_draws)
  out[]
}

# --- wtp ----------------------------------------------------------------------

#' @describeIn wtp Posterior willingness-to-pay for hierarchical Bayes fits:
#'   the per-draw ratio of population-mean utility coefficients,
#'   \eqn{-\bar\gamma_{attr} / \bar\gamma_{price}} (for log-normal
#'   coordinates \eqn{\bar\gamma = \exp(b + W_{kk}/2)}). Ratio posteriors
#'   are heavy-tailed, so the point estimate is the posterior **median**
#'   with equal-tailed quantile intervals — never a posterior mean or a
#'   delta-method SE. A warning is raised when the price coefficient's sign
#'   is not resolved by the posterior. If the price variable was flagged as
#'   endogenous-without-a-control-function at prep time, WTP inherits that
#'   caveat (see `cf_residual_col` in [prepare_hmnl_data()]).
#' @export
wtp.choicer_hb <- function(object, price_var, attr_vars = NULL,
                           level = 0.95, ...) {
  x_names <- colnames(object$draws$b)
  if (!price_var %in% x_names) {
    stop("`price_var` must be one of: ", paste(x_names, collapse = ", "))
  }
  if (is.null(attr_vars)) attr_vars <- setdiff(x_names, price_var)
  if (!all(attr_vars %in% x_names)) {
    stop("Unknown `attr_vars`: ",
         paste(setdiff(attr_vars, x_names), collapse = ", "))
  }
  rc <- .hb_rc_dist(object)
  K <- object$K_struct
  diag_idx <- vapply(seq_len(K), function(k) (k * (k + 1L)) %/% 2L,
                     integer(1L))
  # per-draw population-mean utility coefficients
  gbar <- object$draws$b
  for (k in which(rc == 1L)) {
    gbar[, k] <- exp(object$draws$b[, k] +
                       0.5 * object$draws$w_vech[, diag_idx[k]])
  }
  price_draws <- gbar[, price_var]
  p_pos <- mean(price_draws >= 0)
  if (min(p_pos, 1 - p_pos) > 0.01) {
    warning(sprintf(
      "The sign of `%s` is not resolved by the posterior (P(>= 0) = %.2f): ",
      price_var, p_pos),
      "WTP ratios are ill-defined; consider a log-normal price coefficient ",
      "(rc_dist) or more data.", call. = FALSE)
  }

  alpha <- (1 - level) / 2
  rows <- lapply(attr_vars, function(av) {
    ratio <- -gbar[, av] / price_draws
    qs <- stats::quantile(ratio, probs = c(alpha, 0.5, 1 - alpha))
    data.table::data.table(
      attribute = av, wtp = qs[[2L]], lower = qs[[1L]], upper = qs[[3L]]
    )
  })
  out <- data.table::rbindlist(rows)
  data.table::setattr(out, "price_var", price_var)
  out[]
}

# --- logsum / consumer surplus (HMNL only in v1) -------------------------------

#' @param n_draws Number of posterior draws to integrate over (hierarchical
#'   Bayes methods; thinned evenly from the kept draws).
#' @describeIn logsum Posterior expected logsum for the hierarchical logit:
#'   per choice situation, \eqn{\log(1 + \sum_j \exp V_j)} against the
#'   outside-option anchor, averaged over posterior draws with one
#'   \eqn{\beta \sim N(b_r, W_r)} draw each. Returns the per-task posterior
#'   mean vector.
#' @export
logsum.choicer_hmnl <- function(object, newdata = NULL, n_draws = 200L, ...) {
  rd <- .hb_resolve_newdata(object, newdata)
  idx <- .hb_draw_index(object, n_draws)
  rc <- .hb_rc_dist(object)
  K <- object$K_struct
  delta_new <- .hb_delta_new_draws(object, rd, idx)

  ls_draws <- matrix(0, length(idx), rd$n_tasks)
  for (s in seq_along(idx)) {
    r <- idx[s]
    W <- matrix(0, K, K)
    W[lower.tri(W, diag = TRUE)] <- object$draws$w_vech[r, ]
    W <- W + t(W) - diag(diag(W))
    L <- t(chol(W + diag(1e-10, K)))
    beta <- object$draws$b[r, ] + drop(L %*% stats::rnorm(K))
    V <- drop(rd$X %*% .hb_gamma(beta, rc)) +
      .hb_delta_row(object, rd, r, delta_new)
    ls_draws[s, ] <- .hb_logit_task_terms(
      V, rd$task_of_row, rd$n_tasks
    )$logsum
  }
  out <- colMeans(ls_draws)
  attr(out, "draws") <- ls_draws
  attr(out, "task_keys") <- rd$task_keys
  attr(out, "task_keys_exact") <- rd$task_keys_exact
  out
}

#' @describeIn logsum The probit expected maximum has no closed form;
#'   simulated-Emax surplus for the HMNP is on the roadmap.
#' @export
logsum.choicer_hmnp <- function(object, newdata = NULL, ...) {
  stop("logsum()/consumer_surplus() are logit-only in this version: the ",
       "EV1 logsum formula does not apply to probit shocks, and the ",
       "simulated-Emax variant is on the roadmap. Use the HMNL for welfare ",
       "analysis.")
}

#' @param n_draws Number of posterior draws to integrate over (hierarchical
#'   Bayes methods).
#' @describeIn consumer_surplus Posterior consumer surplus for the
#'   hierarchical logit: per-task logsum divided by the (positive) marginal
#'   utility of income \eqn{-\bar\gamma_{price}}, per posterior draw. With
#'   `newdata`, the return also carries the compensating variation against
#'   the estimation data (`attr(, "cv")`), i.e. the posterior of
#'   \eqn{(\mathrm{logsum}_{new} - \mathrm{logsum}_{base}) /
#'   (-\bar\gamma_{price})} summed over tasks. Requires a fixed-sign price
#'   coefficient; the posterior-median ratio discipline of
#'   [wtp.choicer_hb()] applies. When `newdata` is supplied, `weights` is an
#'   optional non-negative length-`n_tasks` vector used in the aggregate CV;
#'   equal task weights are the default. The counterfactual must contain the
#'   same choice situations as the estimation data, identified by
#'   (`person_col`, `id_col`) (or `id_col` when `person_col = NULL`); rows and
#'   tasks may be reordered. Unnamed `weights` follow the baseline tasks'
#'   sorted (`person_col`, `id_col`) order used by the prepared data.
#' @export
consumer_surplus.choicer_hmnl <- function(object, price_var, newdata = NULL,
                                          level = 0.95, weights = NULL,
                                          n_draws = 200L, ...) {
  x_names <- colnames(object$draws$b)
  if (!price_var %in% x_names) {
    stop("`price_var` must be one of: ", paste(x_names, collapse = ", "))
  }
  rc <- .hb_rc_dist(object)
  K <- object$K_struct
  diag_idx <- vapply(seq_len(K), function(k) (k * (k + 1L)) %/% 2L,
                     integer(1L))
  gprice <- object$draws$b[, price_var]
  if (rc[match(price_var, x_names)] == 1L) {
    gprice <- exp(gprice + 0.5 * object$draws$w_vech[, diag_idx[
      match(price_var, x_names)]])
  }
  p_pos <- mean(gprice >= 0)
  if (min(p_pos, 1 - p_pos) > 0.01) {
    warning("The sign of the price coefficient is not resolved by the ",
            "posterior; surplus ratios are ill-defined.", call. = FALSE)
  }

  idx <- .hb_draw_index(object, n_draws)
  if (!exists(".Random.seed", envir = globalenv())) stats::runif(1L)
  set.seed_state <- .Random.seed          # reuse one beta path for both
  ls_base <- logsum.choicer_hmnl(object, newdata = NULL, n_draws = n_draws)
  cs_target <- if (is.null(newdata)) {
    ls_base
  } else {
    assign(".Random.seed", set.seed_state, envir = globalenv())
    logsum.choicer_hmnl(object, newdata = newdata, n_draws = n_draws)
  }

  # A welfare contrast is defined only for the same choice situations under
  # two states. Match by retained task identity, never merely by column
  # position. Older serialized fits lack task_keys; for those, preserve the
  # historical sorted-order contract but still fail clearly on unequal sizes.
  target_order <- seq_along(cs_target)
  if (!is.null(newdata)) {
    base_keys <- attr(ls_base, "task_keys")
    target_keys <- attr(cs_target, "task_keys")
    exact_keys <- isTRUE(attr(ls_base, "task_keys_exact")) &&
      isTRUE(attr(cs_target, "task_keys_exact"))
    if (length(base_keys) != length(target_keys)) {
      stop("`newdata` must contain exactly the same choice situations as ",
           "the estimation data for compensating variation; expected ",
           length(base_keys), " tasks but found ", length(target_keys), ".",
           call. = FALSE)
    }
    if (exact_keys) {
      target_order <- match(base_keys, target_keys)
      if (anyNA(target_order) || anyDuplicated(target_keys)) {
        stop("`newdata` must contain exactly the same choice situations as ",
             "the estimation data for compensating variation, identified ",
             "by (`person_col`, `id_col`).", call. = FALSE)
      }
    }
  }

  alpha_q <- (1 - level) / 2
  a_draws <- -gprice[idx]                 # marginal utility of income
  target_draws <- attr(cs_target, "draws")[, target_order, drop = FALSE]
  cs_draws <- target_draws / a_draws
  cs_task <- apply(cs_draws, 2, stats::median)
  out <- data.table::data.table(
    task = seq_along(cs_task),
    cs = cs_task,
    lower = apply(cs_draws, 2, stats::quantile, probs = alpha_q),
    upper = apply(cs_draws, 2, stats::quantile, probs = 1 - alpha_q)
  )
  if (!is.null(newdata)) {
    task_weights <- .validate_pred_weights(weights, ncol(cs_draws))
    if (any(task_weights < 0) || sum(task_weights) <= 0) {
      stop("For hierarchical-logit compensating variation, `weights` must ",
           "be non-negative with a positive sum.", call. = FALSE)
    }
    delta_ls <- target_draws - attr(ls_base, "draws")
    cv_draws <- drop(delta_ls %*% task_weights) / a_draws
    data.table::setattr(out, "cv", stats::quantile(
      cv_draws, probs = c(alpha_q, 0.5, 1 - alpha_q)))
    data.table::setattr(out, "cv_weights", task_weights)
  }
  out[]
}

#' @describeIn consumer_surplus Not available for the probit (see
#'   [logsum.choicer_hmnp()]); roadmapped via simulated Emax.
#' @export
consumer_surplus.choicer_hmnp <- function(object, price_var, newdata = NULL,
                                          level = 0.95, weights = NULL, ...) {
  logsum.choicer_hmnp(object)
}

# --- elasticities / diversion --------------------------------------------------

#' Shared perturbation engine: baseline and perturbed shares per alternative
#' @noRd
.hb_perturb_shares <- function(object, elast_var, eps, n_draws) {
  if (is.null(object$data)) {
    stop("Elasticities need the estimation data; refit with keep_data = TRUE.")
  }
  rd <- .hb_resolve_newdata(object, NULL)
  idx <- .hb_draw_index(object, n_draws)
  x_names <- colnames(object$draws$b)
  if (!elast_var %in% x_names) {
    stop("`elast_var` must be one of: ", paste(x_names, collapse = ", "))
  }
  if (!exists(".Random.seed", envir = globalenv())) stats::runif(1L)
  seed_state <- .Random.seed

  base <- .hb_predict_core(object, rd, idx, "population")
  labels <- colnames(base$share_draws)
  inside <- setdiff(labels, "(outside)")
  J <- length(inside)

  # Perturb elast_var by (1 + eps) for one inside alternative at a time,
  # replaying the same beta path (same RNG state) so the contrast is exact.
  pert <- array(0, dim = c(length(idx), length(labels), J),
                dimnames = list(NULL, labels, inside))
  xk <- match(elast_var, x_names)
  for (jj in seq_len(J)) {
    rd_p <- rd
    rows_j <- rd$alt_label == inside[jj]
    rd_p$X[rows_j, xk] <- rd$X[rows_j, xk] * (1 + eps)
    assign(".Random.seed", seed_state, envir = globalenv())
    pert[, , jj] <- .hb_predict_core(object, rd_p, idx, "population")$share_draws
  }
  list(base = base$share_draws, pert = pert, inside = inside,
       labels = labels, idx = idx)
}

#' @param elast_var Structural covariate to perturb (hierarchical Bayes
#'   methods).
#' @param eps Relative perturbation size (default 0.01).
#' @param n_draws Number of posterior draws to integrate over.
#' @describeIn elasticities Posterior-mean aggregate arc elasticities for
#'   hierarchical Bayes fits: each inside alternative's `elast_var` is
#'   perturbed by `eps` (default 1%) and shares are recomputed per posterior
#'   draw with a common random-coefficient path, giving
#'   \eqn{E_{jk} = (\Delta s_j / s_j) / \epsilon}. Rows are responding
#'   alternatives (including the outside option), columns the perturbed
#'   alternative.
#' @export
elasticities.choicer_hb <- function(object, elast_var, eps = 0.01,
                                    n_draws = 100L, ...) {
  ps <- .hb_perturb_shares(object, elast_var, eps, n_draws)
  base_mean <- colMeans(ps$base)
  out <- matrix(
    0, length(ps$labels), length(ps$inside),
    dimnames = list(ps$labels, ps$inside)
  )
  for (jj in seq_along(ps$inside)) {
    d_share <- colMeans(ps$pert[, , jj]) - base_mean
    out[, jj] <- (d_share / base_mean) / eps
  }
  out
}

#' @param elast_var Structural covariate to perturb (hierarchical Bayes
#'   methods).
#' @param eps Relative perturbation size (default 0.01).
#' @param n_draws Number of posterior draws to integrate over.
#' @describeIn diversion_ratios Posterior-mean diversion ratios for
#'   hierarchical Bayes fits, from the same perturbation engine as
#'   [elasticities.choicer_hb()]: \eqn{DR(j \to k)} is the fraction of the
#'   share alternative j loses (when its `elast_var` worsens) that flows to
#'   k — including the outside option. Columns are the perturbed
#'   alternative j, rows the receiving alternative k; the diagonal is 0.
#' @export
diversion_ratios.choicer_hb <- function(object, elast_var, eps = 0.01,
                                        n_draws = 100L, ...) {
  ps <- .hb_perturb_shares(object, elast_var, eps, n_draws)
  base_mean <- colMeans(ps$base)
  out <- matrix(
    0, length(ps$labels), length(ps$inside),
    dimnames = list(ps$labels, ps$inside)
  )
  for (jj in seq_along(ps$inside)) {
    d_share <- colMeans(ps$pert[, , jj]) - base_mean
    own <- d_share[ps$inside[jj]]
    others <- setdiff(ps$labels, ps$inside[jj])
    out[others, jj] <- d_share[others] / (-own)
    out[ps$inside[jj], jj] <- 0
  }
  out
}

Try the choicer package in your browser

Any scripts or data that you put into this service are public.

choicer documentation built on Sept. 5, 2026, 1:07 a.m.