R/diagnostics_generic.R

Defines functions model_average print.post_hoc_lm post_hoc_lm .nl_ar_p_hyper_summary temporal_corr .wtd_grid_summary .ps_wtd_quantile .hyperparam_summary .quantile_colnames .spatial_range_kernel_units .hyper_empty_msg .range_to_data_units spatial_range .nested_hyper_summary .gp_phi_from_logit .gp_phi_bounds .nested_axis_map .tulpa_pbma_weights .tulpa_stacking_weights compare_models .model_criterion .compare_fit_loglik .name_models

Documented in compare_models model_average post_hoc_lm print.post_hoc_lm spatial_range temporal_corr

# ============================================================================
# Generic spatial/temporal diagnostics and model comparison
# These operate on residuals or fit objects -- not model-specific.
# ============================================================================

# Assign default model<N> names to an unnamed / partially-named `list(...)`.
.name_models <- function(models) {
  nm <- names(models)
  auto <- paste0("model", seq_along(models))
  names(models) <- if (is.null(nm)) auto else ifelse(nzchar(nm), nm, auto)
  # A non-fit in `...` is almost always a mistyped option (e.g. passing
  # `criterion =` to model_average(), whose option is `weights =`); reject it
  # loudly rather than weight a character string as a candidate model.
  for (k in seq_along(models)) {
    if (is.atomic(models[[k]])) {
      stop(sprintf(paste0("Argument `%s` is not a fitted model. Model-",
                          "comparison options are named parameters (e.g. ",
                          "`criterion` / `weights`), not `...` entries."),
                   names(models)[k]), call. = FALSE)
    }
  }
  models
}

# A fit's [n_draws x n_obs] pointwise log-likelihood, or NULL when it carries
# none (a deterministic / point fit). Dispatches through pointwise_loglik()
# so a model package's own method (e.g. tobs_fit) is reached, not just the
# engine's tulpa_fit layout.
.compare_fit_loglik <- function(fit) {
  tryCatch(pointwise_loglik(fit), error = function(e) NULL)
}

# Per-model criterion summary (elpd, its SE, the effective number of parameters,
# and the pointwise elpd vector for SE-of-difference / stacking). Computed
# natively from the pointwise log-likelihood via tulpa_criteria() + tulpa_psis();
# falls back to a model-supplied WAIC (fit$waic_fn) when there are no pointwise
# draws, and to all-NA when neither is available -- so the comparison degrades
# gracefully to one row per model rather than erroring.
.model_criterion <- function(fit, criterion) {
  na <- list(elpd = NA_real_, se_elpd = NA_real_, p_eff = NA_real_,
             elpd_pw = NULL)
  ll <- .compare_fit_loglik(fit)
  if (!is.null(ll)) {
    cr <- tulpa_criteria(ll, criteria = criterion, pointwise = TRUE)
    if (criterion == "loo")
      return(list(elpd = cr$elpd_loo, se_elpd = cr$se_elpd_loo,
                  p_eff = cr$p_loo, elpd_pw = cr$pointwise$elpd_loo))
    return(list(elpd = cr$elpd_waic, se_elpd = cr$se_elpd_waic,
                p_eff = cr$p_waic, elpd_pw = cr$pointwise$elpd_waic))
  }
  if (criterion == "waic" && !is.null(fit$waic_fn)) {
    w <- tryCatch(fit$waic_fn(fit), error = function(e) NULL)
    if (!is.null(w))
      return(list(elpd = -0.5 * w$waic, se_elpd = NA_real_,
                  p_eff = w$p_waic %||% NA_real_, elpd_pw = NULL))
  }
  na
}

#' Compare models by information criteria
#'
#' @description
#' Rank fitted models best-first by an information criterion. `"waic"` and
#' `"loo"` use the native pointwise log-likelihood layer ([tulpa_criteria()] /
#' [tulpa_psis()]) -- WAIC and PSIS-LOO respectively, computed from each fit's
#' `[n_draws x n_obs]` pointwise log-likelihood (`fit$draws$log_lik`), with no
#' `loo` package dependency. `"loglik"` returns the (integrated) joint
#' log-likelihood with the parameter count. A fit carrying no pointwise
#' log-likelihood (a deterministic / point approximation) yields `NA` criterion
#' columns rather than an error, so the table always has one row per model.
#'
#' `"wbic"` ranks by [wbic()], an estimate of each fit's negative log evidence,
#' and needs every fit to carry its tempered draws (`mode = "smc"`,
#' `control = list(wbic = TRUE)`); a fit without them gets an `NA` row.
#'
#' @param ... Named `tulpa_fit` objects.
#' @param criterion `"waic"` (default), `"loo"`, `"wbic"`, or `"loglik"`.
#' @return A data frame. For `"wbic"` (ranked best-first): `model`, `wbic`,
#'   `delta` (WBIC gap to the best model, >= 0), and `weight`
#'   (`exp(-delta)` normalized: the posterior model probability under equal
#'   prior model weights, with each log evidence read off its WBIC).
#'   For `"loglik"`: `model`, `n_params`, `logLik`,
#'   `quantity` and `conditioned_on` (which log-scale quantity [logLik()]
#'   reports, and the hyperparameters it holds fixed; fits that disagree on
#'   either are refused rather than ranked). For
#' `"waic"` / `"loo"` (ranked best-first): `model`, `elpd`, `se_elpd`,
#' `p_eff`, `ic` (`-2 * elpd`), `delta` (elpd gap to the best model),
#' `se_diff` (SE of that pointwise elpd difference), and `weight` (the
#' Akaike-style weight on the criterion).
#' @seealso [model_average()] for model-averaged predictions, [tulpa_criteria()]
#' and [tulpa_psis()] for the native criteria layer.
#' @examples
#' \donttest{
#' set.seed(1)
#' df <- data.frame(x = rnorm(120))
#' df$y <- rpois(120, exp(0.4 + 0.5 * df$x))
#' f1 <- tulpa(y ~ x, data = df, family = "poisson")
#' f2 <- tulpa(y ~ 1, data = df, family = "poisson")
#' compare_models(full = f1, null = f2, criterion = "waic")
#' }
#' @export
compare_models <- function(..., criterion = c("waic", "loo", "wbic", "loglik")) {
  criterion <- match.arg(criterion)
  models <- .name_models(list(...))

  if (criterion == "wbic") {
    w <- vapply(models, function(fit) {
      tryCatch(wbic(fit)$wbic, error = function(e) NA_real_)
    }, numeric(1))
    ord   <- order(w, na.last = TRUE)
    delta <- w - w[ord[1]]
    rel   <- exp(-delta)
    out <- data.frame(model = names(models), wbic = w, delta = delta,
                      weight = rel / sum(rel, na.rm = TRUE),
                      row.names = NULL, stringsAsFactors = FALSE)
    return(out[ord, , drop = FALSE])
  }

  if (criterion == "loglik") {
    rows <- lapply(names(models), function(nm) {
      fit <- models[[nm]]
      l <- tryCatch(logLik(fit), error = function(e) NULL)
      n_par <- fit$n_params %||% fit$n_fixed %||%
        (if (is.matrix(fit$draws)) ncol(fit$draws) else NA_integer_)
      cond <- if (is.null(l)) character(0) else attr(l, "conditioned_on") %||% character(0)
      data.frame(model = nm, n_params = n_par,
                 logLik = if (is.null(l)) NA_real_ else as.numeric(l),
                 quantity = if (is.null(l)) NA_character_
                            else attr(l, "quantity") %||% NA_character_,
                 conditioned_on = paste(sort(cond), collapse = ", "),
                 stringsAsFactors = FALSE)
    })
    out <- do.call(rbind, rows)
    # A mean log posterior, a log evidence and a log marginal likelihood
    # conditional on some hyperparameters are different quantities on different
    # scales, and so are two conditional ones holding different hyperparameters
    # fixed. Ranking them in one table is a model choice made on an artefact of
    # how each was fitted (gcol33/tulpa#712, #723). Refuse rather than order.
    key <- ifelse(is.na(out$quantity), NA_character_,
                  paste0(out$quantity, " | ", out$conditioned_on))
    if (length(unique(key[!is.na(key)])) > 1L) {
      lab <- ifelse(nzchar(out$conditioned_on),
                    sprintf("%s = %s (conditional on %s)", out$model,
                            out$quantity, out$conditioned_on),
                    sprintf("%s = %s", out$model, out$quantity))
      stop("compare_models(criterion = \"loglik\") was given fits reporting ",
           "different quantities under logLik(): ", paste(lab, collapse = ", "),
           ". They are not on one scale. Use criterion = \"waic\" or ",
           "\"loo\", which score the pointwise predictive density on every ",
           "tier.", call. = FALSE)
    }
    return(out)
  }

  per     <- lapply(models, .model_criterion, criterion = criterion)
  elpd    <- vapply(per, `[[`, numeric(1), "elpd")
  se_elpd <- vapply(per, `[[`, numeric(1), "se_elpd")
  p_eff   <- vapply(per, `[[`, numeric(1), "p_eff")
  elpd_pw <- lapply(per, `[[`, "elpd_pw")

  ord  <- order(elpd, decreasing = TRUE, na.last = TRUE)
  best <- ord[1]
  delta   <- elpd - elpd[best]
  se_diff <- vapply(seq_along(models), function(k) {
    if (k == best || is.null(elpd_pw[[k]]) || is.null(elpd_pw[[best]]))
      return(NA_real_)
    dd <- elpd_pw[[k]] - elpd_pw[[best]]
    sqrt(length(dd) * stats::var(dd))
  }, numeric(1))
  rel <- exp(delta)                             # delta <= 0 -> Akaike weight on elpd
  weight <- rel / sum(rel, na.rm = TRUE)

  out <- data.frame(model = names(models), elpd = elpd, se_elpd = se_elpd,
                    p_eff = p_eff, ic = -2 * elpd, delta = delta,
                    se_diff = se_diff, weight = weight,
                    row.names = NULL, stringsAsFactors = FALSE)
  out[ord, , drop = FALSE]
}

# Native stacking weights (Yao, Vehtari, Simpson & Gelman 2018): the
# simplex-constrained model combination maximizing the leave-one-out stacked log
# predictive score, parameterized by a softmax of K-1 free coordinates so the
# optimization is unconstrained. `lpd` is the [n_obs x n_models] pointwise elpd.
# No `loo` dependency.
.tulpa_stacking_weights <- function(lpd) {
  K <- ncol(lpd)
  if (K == 1L) return(1)
  m <- apply(lpd, 1L, max)
  E <- exp(lpd - m)                             # exp(lpd_ik - max_k); + m_i drops out
  neg_score <- function(par) {
    a <- c(0, par); w <- exp(a - max(a)); w <- w / sum(w)
    -sum(log(pmax(as.numeric(E %*% w), 1e-300)))
  }
  opt <- stats::optim(rep(0, K - 1L), neg_score, method = "BFGS")
  a <- c(0, opt$par); w <- exp(a - max(a)); w / sum(w)
}

# Native pseudo-BMA / pseudo-BMA+ weights (Yao et al. 2018): a softmax of the
# per-model summed elpd; `bb = TRUE` (pseudo-BMA+) averages that softmax over
# Bayesian-bootstrap (Dirichlet(1)) reweightings of the observations to propagate
# the elpd's sampling uncertainty.
.tulpa_pbma_weights <- function(lpd, bb = FALSE, n_boot = 1000L) {
  K <- ncol(lpd); N <- nrow(lpd)
  softmax <- function(v) { e <- exp(v - max(v)); e / sum(e) }
  if (!bb) return(softmax(colSums(lpd)))
  W <- matrix(0, n_boot, K)
  for (b in seq_len(n_boot)) {
    g <- stats::rexp(N); g <- N * g / sum(g)    # Dirichlet(1,...,1) * N
    W[b, ] <- softmax(colSums(lpd * g))
  }
  colMeans(W)
}

# Latent block types (lower-case) that classify an UNTAGGED nested-Laplace
# block as a spatial or a temporal field (`.nl_block_roles()`); a block tulpa()
# built for `spatial =` / `temporal =` carries its role instead. `iid` is not
# temporal: it is how a `(1 | g)` term rides the nested path, and reading it as
# one reported a random-intercept SD as `sigma_temporal` (gcol33/tulpa#906).
.SPATIAL_NL_TYPES <- c("icar", "bym2", "car_proper", "gp", "nngp", "hsgp",
                       "spde", "rsr", "rsr_spde")
.TEMPORAL_NL_TYPES <- c("rw1", "rw2", "ar1", "seasonal")

# One row per outer-grid axis of a nested-Laplace fit: the bare hyperparameter
# name, the role of the field it belongs to ("spatial" / "temporal" /
# "other"), that block's type, and its index in the prior list. Multi-block
# grids prefix each axis with its block (`b<k>.<axis>`); a single block's axes
# are bare and belong to it; a fit_st_nested() grid is read through its own map.
.nested_axis_map <- function(object, axes) {
  if (inherits(object, "tulpa_st_nested")) return(.st_axis_map(object, axes))
  pr <- object$prior
  blocks <- if (is.null(pr)) list() else if (!is.null(pr$type)) list(pr) else pr
  roles <- .nl_block_roles(pr)
  types <- vapply(blocks, function(b) tolower(b$type %||% ""), character(1))
  bi <- suppressWarnings(as.integer(sub("^b([0-9]+)\\..*$", "\\1", axes)))
  bi[is.na(bi) & length(blocks) >= 1L] <- 1L
  ok <- !is.na(bi) & bi >= 1L & bi <= length(blocks)
  out <- data.frame(bare  = sub("^b[0-9]+\\.", "", axes),
                    role  = ifelse(ok, roles[pmax(bi, 1L)], "other"),
                    type  = ifelse(ok, types[pmax(bi, 1L)], NA_character_),
                    block = ifelse(ok, bi, NA_integer_),
                    lo = NA_real_, hi = NA_real_,
                    stringsAsFactors = FALSE)
  # A proper-CAR correlation lives on its adjacency's eigenvalue interval, which
  # the block carries.
  for (j in which(ok & out$type == "car_proper" & out$bare == "rho")) {
    rb <- blocks[[out$block[j]]]$rho_bounds
    if (length(rb) == 2L) { out$lo[j] <- min(rb); out$hi[j] <- max(rb) }
  }
  out
}

# Natural-scale -> interpretable-quantity maps for the nested-Laplace grid axes,
# so spatial_range() / temporal_corr() report the SAME quantity + row label at
# Tier 2 (nested grid) as at Tier 1 (sampler draws). Each entry composes the
# sampler's own log/logit -> interpretable transform with exp/expit, evaluated
# on the natural-scale grid axis. The derived quantity is computed PER GRID CELL
# and then weighted-summarized (marginalize the derived quantity, not the raw
# axis). Spatial: tau (precision) -> sigma = 1/sqrt(tau); sigma2 -> sigma =
# sqrt(sigma2); phi_gp / lengthscale (GP lengthscale) -> range = 3 * ell (the
# distance at which exp(-d/ell) ~ 0.05). Temporal mirrors the temporal sampler
# (tau reported as precision, ar1 rho, GP sigma / lengthscale).
# Natural-scale maps from a hyperparameter to its interpretable quantity.
# Single-sourced so the nested-grid path (the registries below) and the
# sampler-draw path (the transform_fn closures in spatial_range/temporal_corr)
# cannot drift -- both consume these, differing only in whether they first
# strip a log/logit link.
.hyper_nat <- list(
  range_from_lengthscale = function(v) 3 * v,   # exp(-d/ell) ~ 0.05 at d ~= 3*ell
  # The HSGP / HSGP-SVC basis approximates a SQUARED-EXPONENTIAL kernel,
  # k(d) = sigma^2 exp(-d^2 / (2 ell^2)) (src/hsgp_spectral.h), a different
  # decay convention from the exponential kernel range_from_lengthscale()
  # calibrates: exp(-d^2/(2 ell^2)) = 0.05 at d = ell * sqrt(-2 log(0.05)).
  range_from_se_lengthscale = function(v) sqrt(-2 * log(0.05)) * v,
  sigma_from_var         = function(v) sqrt(v),
  sigma_from_precision   = function(v) 1 / sqrt(v),
  identity               = function(v) v
)

# A continuous-time GP's lengthscale is sampled as a logit onto a declared
# interval, so the draw column is a position in that interval and not the
# lengthscale. These are the engine's own defaults
# (`temporal_gp_phi_prior_lower` / `_upper` and the TVC pair beside them in
# `inst/include/tulpa/model_data.h`), stated for STANDARDIZED times: both GP
# doors lay the interval out as `.gp_phi_bounds()` of the data's spread in the
# units the kernel measures lag in, and send it on the spec.
.GP_PHI_PRIOR_BOUNDS <- c(lower = 0.01, upper = 10)

# The lengthscale support in kernel units, for times whose spread (sd) in those
# units is `spread`. Scaled times (`scale_coords = TRUE`) have spread 1 and get
# the defaults; raw times get the same interval measured in their own spread,
# so the support is one statement about the data whichever units the kernel
# runs in. It used to stay (0.01, 10) in raw units, where a true lengthscale of
# 15 was unreachable (gcol33/tulpa#907).
.gp_phi_bounds <- function(spread = 1) {
  s <- if (is.numeric(spread) && length(spread) == 1L && is.finite(spread) &&
           spread > 0) spread else 1
  .GP_PHI_PRIOR_BOUNDS * s
}

# A lengthscale draw on the natural scale, in the USER's time units: the logit
# mapped onto the spec's own support (kernel units), times the scale the times
# were divided by before they reached the kernel (`time_scale`, 1 when they
# were not). `spec` is the validated temporal_gp() / temporal_tvc() spec.
#' @keywords internal
.gp_phi_from_logit <- function(raw, spec = NULL) {
  lo <- spec$phi_prior_lower %||% .GP_PHI_PRIOR_BOUNDS[["lower"]]
  hi <- spec$phi_prior_upper %||% .GP_PHI_PRIOR_BOUNDS[["upper"]]
  (lo + (hi - lo) / (1 + exp(-raw))) * (spec$time_scale %||% 1)
}
#
# A transformed entry also names the DOMAIN of the quantity it produces, which
# is what a moment-matched interval is formed on when the grid is a quadrature
# design rather than a discretized density (`.nl_summary_quantile`).
# A standard deviation and a range are `positive` whatever
# axis they came from. An identity entry carries no domain of its own and takes
# the AXIS's, from the same registry the outer Pareto-k unconstrains with, so a
# proper-CAR `rho` on the adjacency eigenvalue interval is declined rather than
# guessed.
.SPATIAL_HYPER_TRANSFORM <- list(
  tau         = list(name = "sigma", fn = .hyper_nat$sigma_from_precision,
                     domain = "positive"),
  sigma2      = list(name = "sigma", fn = .hyper_nat$sigma_from_var,
                     domain = "positive"),
  phi_gp      = list(name = "range", fn = .hyper_nat$range_from_lengthscale,
                     domain = "positive"),
  lengthscale = list(name = "range", fn = .hyper_nat$range_from_lengthscale,
                     domain = "positive"),
  sigma       = list(name = "sigma", fn = .hyper_nat$identity),
  range       = list(name = "range", fn = .hyper_nat$identity),
  rho         = list(name = "rho",   fn = .hyper_nat$identity)
)
.TEMPORAL_HYPER_TRANSFORM <- list(
  tau         = list(name = "precision",      fn = .hyper_nat$identity),
  rho         = list(name = "rho_ar1",        fn = .hyper_nat$identity),
  sigma       = list(name = "sigma_temporal", fn = .hyper_nat$identity),
  lengthscale = list(name = "lengthscale",    fn = .hyper_nat$identity)
)

# Weighted per-axis mean / sd / `probs`-quantile summary of a nested-Laplace
# fit's hyperparameter posterior, read from the outer grid + weights. With
# `transform` (an axis-name -> list(name, fn) map) each axis is mapped to its
# interpretable quantity PER CELL before the weighted summary, and the row is
# renamed; axes absent from the map are summarized raw. NULL when the grid is
# not retained.
.nested_hyper_summary <- function(object, probs, transform = NULL,
                                  keep_role = NULL) {
  tg <- object$theta_grid
  w  <- object$weights
  if (is.null(tg) || is.null(w)) return(NULL)
  w <- w / sum(w)
  if (!is.matrix(tg)) {
    tg <- matrix(tg, ncol = 1L,
                 dimnames = list(NULL, object$theta_names %||% "theta"))
  }
  lmd <- object$log_marginal
  if (length(lmd) != nrow(tg)) lmd <- NULL
  axes <- colnames(tg) %||% object$theta_names %||%
    paste0("theta", seq_len(ncol(tg)))
  # Map every axis to the field it belongs to, so a mixed / spatiotemporal fit
  # can be restricted to just its spatial (or temporal) axes -- by the block's
  # ROLE, since its type does not say (an s(x) smoother is an rw2 block).
  amap <- .nested_axis_map(object, axes)
  bare_axes <- amap$bare
  keep <- if (is.null(keep_role)) seq_along(axes)
          else which(amap$role %in% keep_role)
  if (length(keep) == 0L) return(NULL)
  blocks <- object$prior
  if (!is.null(blocks$type)) blocks <- list(blocks)
  # The outer integrator decides how a quantile may be read off these weights: a
  # tensor grid's uniform cells discretize the density, a CCD is a moment rule
  # whose node positions carry no mass of their own.
  support <- .nl_node_support(object$integration, object$weight_kind)
  # Supplied whatever the support: a moment rule needs the domain to form its
  # interval at all, and a density read needs it to place its outer cell edges
  # inside the quantity's own support.
  geo <- .joint_axis_geometry(object)
  # And the WITHIN-CELL construction the fit's own reported intervals were read
  # with, so a derived-axis summary and `theta_ci_lo` /
  # `theta_ci_hi` on the same fit cannot be built two different ways.
  within <- .nl_within_cell_mode(object$within_cell_requested)
  rows <- lapply(keep, function(j) {
    v   <- tg[, j]
    tr  <- if (!is.null(transform)) transform[[bare_axes[j]]] else NULL
    nm  <- bare_axes[j]
    dm  <- if (length(geo$domain) < j) NA_character_ else geo$domain[[j]]
    at  <- if (length(geo$atom) < j) NA_real_ else geo$atom[[j]]
    if (!is.null(tr)) {                                 # marginalize derived
      v  <- tr$fn(v)
      nm <- tr$name
      if (!is.null(tr$domain)) dm <- tr$domain
      # A declared point mass is a LEVEL of the axis, so it maps with it -- the
      # same rule the cell values take.
      if (is.finite(at)) at <- tr$fn(at)
    }
    m  <- sum(w * v)
    s  <- sqrt(max(0, sum(w * v^2) - m^2))
    # A correlation on a bounded interval other than (0, 1) -- a proper CAR's
    # eigenvalue interval, a spatiotemporal AR1's (-1, 1) -- has no name in the
    # domain vocabulary, so the axis is undeclared and its outer cells were
    # mirrored past the support (q97.5 = 1.04 on an interval ending at 1;
    # gcol33/tulpa#906). Read it on that interval mapped to the unit one, whose
    # domain the partition does honour, and map back.
    lo <- amap$lo[j]; hi <- amap$hi[j]
    dcl_j  <- .nl_axis_declared_levels(tg, j, object$refining_axis)
    rid_j  <- .nl_axis_row_id(tg, j)
    qs <- if (is.finite(lo) && is.finite(hi) && hi > lo &&
              all(v > lo & v < hi)) {
      lo + (hi - lo) * .nl_summary_quantile((v - lo) / (hi - lo), w, probs,
                                            "unit", support, within, NA_real_,
                                            dcl_j, rid_j, lmd)
    } else {
      .nl_summary_quantile(v, w, probs, dm, support, within, at, dcl_j, rid_j,
                           lmd)
    }
    out <- data.frame(mean = m, sd = s, stringsAsFactors = FALSE)
    out[.quantile_colnames(probs)] <- as.list(qs)
    list(row = out, name = nm)
  })
  out <- do.call(rbind, lapply(rows, `[[`, "row"))
  # Two fields reporting the same quantity (the per-column blocks of an inline
  # spatial() / temporal() field, each with its own `sigma`) are told apart by
  # the block they came from, not by a positional suffix.
  nms <- vapply(rows, `[[`, character(1), "name")
  dup <- nms %in% nms[duplicated(nms)]
  if (any(dup)) {
    tag <- vapply(keep, function(j) {
      b <- amap$block[j]
      bn <- if (!is.na(b) && length(blocks) >= b) blocks[[b]]$name else NULL
      if (is.character(bn) && length(bn) == 1L) bn
      else if (!is.na(b)) paste0("b", b) else axes[j]
    }, character(1))
    nms[dup] <- paste0(nms[dup], "[", tag[dup], "]")
  }
  rownames(out) <- make.unique(nms)
  out
}

#' Extract spatial range and variance from a fitted spatial model
#'
#' Summarises the posterior of spatial hyperparameters. For sampler-tier fits
#' this reads the raw hyperparameter draws; for a nested-Laplace spatial fit it
#' summarises the outer hyperparameter grid. Works with ICAR, BYM2, GP (NNGP),
#' CAR, SPDE, and SVC spatial types.
#'
#' A range is reported in the units of the coordinates as supplied, also when
#' the field was fitted on standardized coordinates (`scale_coords = TRUE`,
#' the default of [spatial_gp()] and [spatial_svc()]). A `mode = "laplace"` fit
#' conditions on the field's hyperparameters rather than inferring them, so it
#' carries no posterior for this function to summarise.
#'
#' @param object A `tulpa_fit` object fitted with a spatial component.
#' @param probs Quantile probabilities for the summary (default 0.025, 0.975).
#' @return A data.frame with rows for each spatial hyperparameter and columns
#'   `mean`, `sd`, and one quantile column per entry of `probs` (named from the
#'   probability, e.g. `q2.5`, `q97.5` for the defaults).
#' @examples
#' \donttest{
#' set.seed(1)
#' S <- 12L
#' adj <- matrix(0, S, S)
#' for (i in 1:(S - 1)) adj[i, i + 1] <- adj[i + 1, i] <- 1
#' d <- data.frame(region = factor(rep(1:S, each = 5)), x = rnorm(S * 5))
#' field <- as.numeric(scale(cumsum(rnorm(S, 0, 0.5))))
#' d$y <- rpois(nrow(d), exp(0.3 + 0.4 * d$x + field[d$region]))
#' fit <- tulpa(y ~ x + spatial(region), data = d, family = "poisson",
#'              spatial = spatial_car(adj, level = "group", group_var = "region"))
#' spatial_range(fit)
#' }
#' @export
spatial_range <- function(object, probs = c(0.025, 0.975)) {
  # The kernel ran on coordinates divided by one common factor; a range is a
  # distance, so it converts back to the user's units by that factor
  # (gcol33/tulpa#907).
  .range_to_data_units(.spatial_range_kernel_units(object, probs),
                       .coord_scale(object$spatial))
}

# Multiply the `range*` rows of a hyperparameter summary by the coordinate
# scale `k`. Every column is a location or scale statistic of a positive
# quantity, so each maps by the same factor.
.range_to_data_units <- function(s, k) {
  if (!is.data.frame(s) || identical(k, 1)) return(s)
  r <- startsWith(rownames(s), "range")
  s[r, ] <- s[r, , drop = FALSE] * k
  s
}

# The empty-draws message of spatial_range() / temporal_corr(). A fit that
# carries the field (`what` names it) but no hyperparameter posterior is one
# that conditioned on the hyperparameters -- `mode = "laplace"` -- which is what
# to say rather than asking whether the model has the field at all
# (gcol33/tulpa#906).
.hyper_empty_msg <- function(object, field, what) {
  if (is.null(object[[field]])) {
    return(sprintf("No %s hyperparameters found. Is this a %s model?", what, what))
  }
  sprintf(paste0(
    "This fit carries a %s field but no posterior over its hyperparameters: ",
    "mode = \"laplace\" conditions on them rather than inferring them. Refit ",
    "with mode = \"nested_laplace\" (integrates them) or a sampler mode ",
    "(e.g. \"hmc\") to summarise them."), what)
}

.spatial_range_kernel_units <- function(object, probs) {
  # Nested-Laplace fits carry the hyperparameter posterior on the outer grid,
  # not as draw columns; summarize the grid for a pure-spatial nested fit.
  if (!is.null(object$theta_grid)) {
    s <- .nested_hyper_summary(object, probs,
                               transform = .SPATIAL_HYPER_TRANSFORM,
                               keep_role = "spatial")
    if (!is.null(s)) return(s)
  }
  # fit_spde()'s CCD / grid path (the "auto"/nested SPDE backend) carries its
  # own (range, sigma) outer-grid posterior in $nested rather than a generic
  # $theta_grid or draw columns: R/fit_spde_nested.R stores $range / $sigma on
  # the fit as the single anchor point the fixed effects were refit at, so
  # reading those instead of $nested would report a point, not a posterior.
  if (!is.null(object$nested) && !is.null(object$nested$range_grid) &&
      !is.null(object$nested$sigma_grid) && !is.null(object$nested$weights)) {
    return(.wtd_grid_summary(
      list(range = object$nested$range_grid, sigma = object$nested$sigma_grid),
      object$nested$weights, probs))
  }
  # The sampler's own column names (src/sampler_model_data.h): BYM2 samples
  # its total SD as `log_sigma_spatial` and its mixing weight as
  # `logit_rho_bym2`; a proper CAR its precision as `log_tau_spatial` and its
  # correlation as `logit_rho_car`. The BYM2 SD and the proper-CAR correlation
  # were missing from this table, so those fits reported one hyperparameter of
  # two (gcol33/tulpa#906).
  patterns <- c(
    range = "^(log_phi_gp|log_phi_gp_local|phi_gp)$",
    sigma = "^(log_sigma2_gp|log_sigma_bym2|log_sigma_spatial|log_tau_spatial)$",
    rho   = "^(logit_rho_bym2|logit_rho_car)$",
    sigma_local  = "^(log_sigma2_gp_local)$",
    sigma_regional = "^(log_sigma2_gp_regional)$",
    range_local  = "^(log_phi_gp_local)$",
    range_regional = "^(log_phi_gp_regional)$",
    # HSGP squared-exponential basis: src/tulpa_priors_hsgp.h.
    sigma_hsgp = "^log_sigma2_hsgp$",
    range_hsgp = "^log_lengthscale_hsgp$",
    # SVC: src/tulpa_priors_svc.h. The NNGP approximation's phi is a direct
    # exponential-kernel range like log_phi_gp; the HSGP approximation's is a
    # squared-exponential lengthscale like log_lengthscale_hsgp above -- same
    # draw-column name, different kernel, so the transform below reads
    # object$spatial$approx (spatial_svc()'s own spec, carried on every SVC
    # fit regardless of inference mode) to pick the right one.
    sigma_svc = "^log_sigma2_svc\\[[0-9]+\\]$",
    range_svc = "^log_phi_svc\\[[0-9]+\\]$"
  )
  transform_fn <- function(nm, raw, label) {
    if (grepl("^log_lengthscale_hsgp$", label)) {
      list(vals = .hyper_nat$range_from_se_lengthscale(exp(raw)), row = "range")
    } else if (grepl("^log_phi_svc", label)) {
      se <- identical(tolower(object$spatial$approx %||% "nngp"), "hsgp")
      fn <- if (se) .hyper_nat$range_from_se_lengthscale
            else .hyper_nat$range_from_lengthscale
      list(vals = fn(exp(raw)), row = sub("phi", "range", nm))
    } else if (grepl("^log_phi_gp", label)) {
      # phi is the range: every kernel is exp(-d / phi), so correlation decays
      # to ~0.05 at d = -phi * log(0.05) ~= 3 * phi.
      list(vals = .hyper_nat$range_from_lengthscale(exp(raw)),
           row = sub("phi", "range", nm))
    } else if (grepl("^log_sigma2", label)) {
      list(vals = .hyper_nat$sigma_from_var(exp(raw)), row = nm)
    } else if (grepl("^log_tau", label)) {
      list(vals = .hyper_nat$sigma_from_precision(exp(raw)), row = "sigma")
    } else if (grepl("^log_sigma", label)) {
      list(vals = exp(raw), row = nm)                  # log_sigma -> sigma
    } else if (identical(label, "logit_rho_car")) {
      # rho = lower + (upper - lower) * invlogit, on the adjacency's eigenvalue
      # interval (src/tulpa_priors_icar.h), not the unit one.
      rb <- object$spatial$rho_bounds %||%
        compute_car_rho_bounds(object$spatial$adjacency)
      list(vals = min(rb) + abs(diff(rb)) / (1 + exp(-raw)), row = nm)
    } else if (grepl("^logit_rho", label)) {
      list(vals = 1 / (1 + exp(-raw)), row = nm)       # logit_rho -> rho
    } else {
      list(vals = raw, row = nm)
    }
  }
  .hyperparam_summary(.fit_draws(object), patterns, transform_fn, probs,
                      .hyper_empty_msg(object, "spatial", "spatial"))
}

# Column names for a set of quantile probabilities, e.g. c(0.025, 0.975) ->
# c("q2.5", "q97.5"). Shared by spatial_range() and temporal_corr() so the
# reported columns always match the requested `probs`.
.quantile_colnames <- function(probs) {
  pct <- sprintf("%.6f", probs * 100)
  paste0("q", sub("\\.?0+$", "", pct))
}

# Shared scaffold for the spatial_range() / temporal_corr() hyperparameter
# summaries: find the first draw column matching each anchored `patterns` entry,
# map its draws to the natural scale via `transform_fn(nm, raw, label)` (returns
# `list(vals, row)`), and stack the per-parameter mean / sd / `probs`-quantile
# summaries. Errors with `empty_msg` when no hyperparameter column is present.
.hyperparam_summary <- function(draws, patterns, transform_fn, probs, empty_msg) {
  cn <- colnames(draws)
  found <- list()
  for (nm in names(patterns)) {
    idx <- grep(patterns[[nm]], cn)
    if (length(idx) > 0) found[[nm]] <- idx[1]
  }
  if (length(found) == 0) stop(empty_msg, call. = FALSE)
  rows <- lapply(names(found), function(nm) {
    tr  <- transform_fn(nm, draws[, found[[nm]]], cn[found[[nm]]])
    qs  <- stats::quantile(tr$vals, probs = probs)
    out <- data.frame(mean = mean(tr$vals), sd = stats::sd(tr$vals),
                      row.names = tr$row, stringsAsFactors = FALSE)
    out[.quantile_colnames(probs)] <- as.list(qs)
    out
  })
  do.call(rbind, rows)
}

# Weighted quantile by interpolated ECDF, for a weighted outer-grid posterior
# that carries no density/volume of its own (a CCD moment-rule design), so the
# grid-cell machinery `.nl_summary_quantile()` reads is not applicable. `v` and
# `w` are the grid's own points and (already-normalized) weights.
.ps_wtd_quantile <- function(v, w, probs) {
  ord <- order(v)
  v <- v[ord]; w <- w[ord]
  cw <- cumsum(w) / sum(w)
  stats::approx(cw, v, xout = probs, rule = 2, ties = "ordered")$y
}

# Weighted mean / sd / `probs`-quantile summary row for a named [range, sigma,
# ...] list of outer-grid quantities sharing one weight vector `w` -- the SPDE
# `$nested` CCD/grid representation, which carries neither `$theta_grid`
# (the generic nested-Laplace multi-block grid) nor draw columns.
.wtd_grid_summary <- function(named_vals, w, probs) {
  w <- w / sum(w)
  rows <- lapply(names(named_vals), function(nm) {
    v  <- named_vals[[nm]]
    m  <- sum(w * v)
    qs <- .ps_wtd_quantile(v, w, probs)
    out <- data.frame(mean = m, sd = sqrt(max(0, sum(w * v^2) - m^2)),
                      row.names = nm, stringsAsFactors = FALSE)
    out[.quantile_colnames(probs)] <- as.list(qs)
    out
  })
  do.call(rbind, rows)
}


#' Extract temporal correlation parameters from a fitted model
#'
#' Returns posterior summary for temporal hyperparameters (tau, rho for AR1,
#' sigma/lengthscale for temporal GP).
#'
#' @param object A `tulpa_fit` object fitted with a temporal component.
#' @param probs Quantile probabilities (default 0.025, 0.975).
#' @return A data.frame with rows for each temporal hyperparameter.
#' @examples
#' \donttest{
#' set.seed(127)
#' df <- data.frame(year = rep(1:20, each = 3), x = rnorm(60))
#' f <- as.numeric(arima.sim(list(ar = 0.7), 20, sd = 0.4))
#' df$count <- rpois(60, exp(1 + 0.3 * df$x + f[df$year]))
#' fit <- tulpa(count ~ x, data = df, family = "poisson",
#'              temporal = temporal_ar1("year"))
#' temporal_corr(fit)
#' }
#' @export
temporal_corr <- function(object, probs = c(0.025, 0.975)) {
  # Nested-Laplace fits carry the hyperparameter posterior on the outer grid,
  # not as draw columns; summarize the grid for a pure-temporal nested fit.
  if (!is.null(object$theta_grid)) {
    s <- .nested_hyper_summary(object, probs,
                               transform = .TEMPORAL_HYPER_TRANSFORM,
                               keep_role = "temporal")
    if (!is.null(s)) return(s)
    # latent(temporal_ar2()) / latent(temporal_ar()): a user-defined tgmrf
    # block tagged tulpa_temporal_latent_block (R/temporal_ar2.R) rather than
    # one of the built-in temporal block types above, so `.nl_block_roles()`
    # classifies its generic type = "tgmrf" as neither field.
    s <- .nl_ar_p_hyper_summary(object, probs)
    if (!is.null(s)) return(s)
  }
  patterns <- c(
    tau   = "^log_tau_temporal$",
    rho   = "^logit_rho_ar1$",
    sigma = "^log_sigma2_temporal_gp$",
    lengthscale = "^logit_phi_temporal_gp$",
    # temporal_multiscale(): src/tulpa_priors_mstemporal.h.
    sigma_trend    = "^log_sigma2_trend$",
    sigma_seasonal = "^log_sigma2_seasonal$",
    sigma_short    = "^log_sigma2_short$",
    rho_short      = "^logit_rho_short$",
    # temporal_tvc(): src/tulpa_priors_tvc.h. The discrete structures sample a
    # log-precision (+ AR1's correlation); a GP-evolving coefficient samples an
    # amplitude and a lengthscale per coefficient instead (gcol33/tulpa#847).
    tau_tvc = "^log_tau_tvc\\[[0-9]+\\]$",
    rho_tvc = "^logit_rho_tvc\\[[0-9]+\\]$",
    sigma_tvc_gp       = "^log_sigma2_tvc_gp\\[[0-9]+\\]$",
    lengthscale_tvc_gp = "^logit_phi_tvc_gp\\[[0-9]+\\]$"
  )
  transform_fn <- function(nm, raw, label) {
    if (nm == "tau") {
      list(vals = exp(raw), row = "precision")          # log_tau -> tau (precision)
    } else if (nm == "rho") {
      list(vals = 1 / (1 + exp(-raw)), row = "rho_ar1") # logit -> rho in (0,1)
    } else if (nm == "sigma") {
      list(vals = .hyper_nat$sigma_from_var(exp(raw)), row = "sigma_temporal")
    } else if (nm == "lengthscale") {
      list(vals = .gp_phi_from_logit(raw, object$temporal), row = "lengthscale")
    } else if (grepl("^log_sigma2_tvc_gp", label)) {
      list(vals = .hyper_nat$sigma_from_var(exp(raw)), row = nm)
    } else if (grepl("^logit_phi_tvc_gp", label)) {
      list(vals = .gp_phi_from_logit(raw, object$temporal), row = nm)
    } else if (grepl("^log_sigma2_(trend|seasonal|short)$", label)) {
      list(vals = .hyper_nat$sigma_from_var(exp(raw)), row = nm)
    } else if (nm == "rho_short") {
      # rho = 2 * invlogit(logit_rho_short) - 1, mapped to (-1, 1)
      # (src/tulpa_priors_mstemporal.h), not the (0, 1) of logit_rho_ar1 above.
      list(vals = 2 / (1 + exp(-raw)) - 1, row = "rho_short")
    } else if (grepl("^log_tau_tvc", label)) {
      list(vals = exp(raw), row = nm)                   # log_tau -> tau (precision)
    } else if (grepl("^logit_rho_tvc", label)) {
      list(vals = 2 / (1 + exp(-raw)) - 1, row = nm)     # (-1, 1), like rho_short
    } else {
      list(vals = raw, row = nm)
    }
  }
  .hyperparam_summary(.fit_draws(object), patterns, transform_fn, probs,
                      .hyper_empty_msg(object, "temporal", "temporal"))
}

# spatial_range()/temporal_corr()'s draw-column table and `.nl_block_roles()`
# both key off a block's string `type` or role tag; a latent(temporal_ar2()
# / temporal_ar()) block is a generic tgmrf() (type = "tgmrf") rather than one of
# the named built-in types, so it matches neither and is found instead via its
# class tag (R/temporal_ar2.R, the same tag temporal.tulpa_fit() uses through
# .nl_temporal_latent_block(), R/temporal_rtr_posteriors.R). Its grid axes are on
# the block's own unconstrained scale (log_tau, atanh_psi1..atanh_psi<p>) rather
# than the natural scale the built-in temporal blocks lay their grid on, so the
# transform composes exp() / tanh() with the natural-quantity map instead of
# reading .TEMPORAL_HYPER_TRANSFORM's identity entries.
.nl_ar_p_hyper_summary <- function(object, probs) {
  blocks <- object$blocks
  tg <- object$theta_grid
  w  <- object$weights
  if (!is.list(blocks) || !length(blocks) || is.null(tg) || is.null(w)) {
    return(NULL)
  }
  k <- which(vapply(blocks, inherits, logical(1),
                    what = "tulpa_temporal_latent_block"))
  if (!length(k)) return(NULL)
  k <- k[1L]
  if (!is.matrix(tg)) {
    tg <- matrix(tg, ncol = 1L,
                 dimnames = list(NULL, object$theta_names %||% "theta"))
  }
  axes <- colnames(tg) %||% paste0("theta", seq_len(ncol(tg)))
  sel  <- grepl(sprintf("^b%d\\.", k), axes)
  if (!any(sel)) return(NULL)
  bare <- sub(sprintf("^b%d\\.", k), "", axes[sel])
  cols <- which(sel)
  w    <- w / sum(w)
  rows <- lapply(seq_along(bare), function(j) {
    raw <- tg[, cols[j]]
    if (identical(bare[j], "log_tau")) {
      list(vals = exp(raw), row = "precision")
    } else if (grepl("^atanh_psi[0-9]+$", bare[j])) {
      list(vals = tanh(raw), row = sub("^atanh_", "", bare[j]))
    } else {
      list(vals = raw, row = bare[j])
    }
  })
  .wtd_grid_summary(stats::setNames(lapply(rows, `[[`, "vals"),
                                    vapply(rows, `[[`, character(1), "row")),
                    w, probs)
}


#' Fit a post-hoc linear model on estimated parameters
#'
#' Useful for exploring drivers of occupancy/detection/abundance variation
#' after model fitting. Fits a weighted linear model and optionally generates
#' bootstrap confidence intervals.
#'
#' @param formula Model formula (e.g., `psi_hat ~ trait1 + trait2`).
#' @param data A data.frame with response and predictors.
#' @param weights Optional weights (e.g., inverse of standard errors).
#' @param n_boot Number of bootstrap replicates for CI (default 1000, 0 to skip).
#' @param probs Quantile probabilities for bootstrap CI (default 0.025, 0.975).
#'
#' @importFrom stats lm quantile
#' @return A list of class `"post_hoc_lm"` with:
#'   \describe{
#'     \item{summary}{data.frame of coefficient estimates and CIs}
#'     \item{lm_fit}{the underlying `lm` object}
#'     \item{boot_coefs}{matrix of bootstrap coefficient samples (if `n_boot > 0`)}
#'     \item{R2}{R-squared from the fitted model}
#'   }
#' @examples
#' # Explore drivers of per-site estimates after fitting a model.
#' site <- data.frame(
#'   psi_hat = c(0.2, 0.5, 0.8, 0.4, 0.6, 0.3),
#'   se      = c(0.05, 0.04, 0.06, 0.05, 0.03, 0.05),
#'   trait   = c(1.0, 2.5, 3.8, 1.9, 3.1, 1.2)
#' )
#' fit <- post_hoc_lm(psi_hat ~ trait, data = site,
#'                    weights = 1 / site$se^2, n_boot = 200L)
#' fit
#' @export
post_hoc_lm <- function(formula, data, weights = NULL,
                        n_boot = 1000L, probs = c(0.025, 0.975)) {
  # lm() resolves a bare `weights` symbol in the formula's environment, not this
  # function's, so a local weights vector is passed as a data column referenced
  # by name in the model frame.
  lm_fit <- if (is.null(weights)) {
    lm(formula, data = data)
  } else {
    fit_data <- data
    fit_data[[".phl_w"]] <- weights
    lm(formula, data = fit_data, weights = .phl_w)
  }

  coefs <- summary(lm_fit)$coefficients
  result_summary <- data.frame(
    term = rownames(coefs),
    estimate = coefs[, "Estimate"],
    std.error = coefs[, "Std. Error"],
    statistic = coefs[, "t value"],
    p.value = coefs[, "Pr(>|t|)"],
    stringsAsFactors = FALSE, row.names = NULL
  )

  boot_coefs <- NULL
  if (n_boot > 0) {
    n <- nrow(data)
    boot_coefs <- matrix(NA_real_, n_boot, length(coef(lm_fit)))
    colnames(boot_coefs) <- names(coef(lm_fit))
    for (b in seq_len(n_boot)) {
      idx <- sample.int(n, n, replace = TRUE)
      boot_data <- data[idx, , drop = FALSE]
      boot_wts <- if (!is.null(weights)) weights[idx] else NULL
      boot_fit <- tryCatch(
        if (is.null(boot_wts)) {
          lm(formula, data = boot_data)
        } else {
          boot_data[[".phl_w"]] <- boot_wts
          lm(formula, data = boot_data, weights = .phl_w)
        },
        error = function(e) NULL
      )
      if (!is.null(boot_fit)) boot_coefs[b, ] <- coef(boot_fit)
    }
    # Add bootstrap CIs to summary
    for (j in seq_len(ncol(boot_coefs))) {
      qs <- quantile(boot_coefs[, j], probs = probs, na.rm = TRUE)
      result_summary$conf.low[j] <- qs[1]
      result_summary$conf.high[j] <- qs[2]
    }
  }

  out <- list(
    summary = result_summary,
    lm_fit = lm_fit,
    boot_coefs = boot_coefs,
    R2 = summary(lm_fit)$r.squared
  )
  class(out) <- "post_hoc_lm"
  out
}

#' @rdname post_hoc_lm
#' @param x A `post_hoc_lm` result.
#' @param ... Ignored.
#' @export
print.post_hoc_lm <- function(x, ...) {
  cat("Post-hoc linear model\n")
  cat(sprintf("R-squared: %.3f\n\n", x$R2))
  print(x$summary, row.names = FALSE, ...)
  invisible(x)
}


#' Model-averaged predictions
#'
#' @description
#' Combine fitted values from several models using native model weights computed
#' from the pointwise PSIS-LOO (or WAIC) elpd via [tulpa_psis()] -- no `loo`
#' package dependency. `"loo"` / `"waic"` give stacking weights (the
#' simplex-optimal predictive combination); `"pbma"` / `"pbma+"` give
#' pseudo-BMA(+) weights. Every model must carry an `[n_draws x n_obs]` pointwise
#' log-likelihood (`fit$draws$log_lik`) over the same observations.
#'
#' @param ... Named `tulpa_fit` objects fitted to the same observations.
#' @param weights `"loo"` (stacking, default), `"waic"`, `"pbma"`, or `"pbma+"`.
#' @param fitted_fn Function extracting a length-`n_obs` fitted vector from a fit
#' (default [fitted()]).
#' @return A list with `averaged` (the weighted fitted vector), `weights` (the
#' named model weights), and `comparison` (the [compare_models()] table).
#' @seealso [compare_models()].
#' @references Yao, Vehtari, Simpson & Gelman (2018). Using stacking to average
#' Bayesian predictive distributions. \emph{Bayesian Analysis} 13(3):917-1007.
#' @examples
#' \donttest{
#' set.seed(1)
#' df <- data.frame(x = rnorm(120))
#' df$y <- rpois(120, exp(0.4 + 0.5 * df$x))
#' f1 <- tulpa(y ~ x, data = df, family = "poisson", mode = "hmc",
#'             control = list(n_iter = 500L, warmup = 250L, seed = 1L))
#' f2 <- tulpa(y ~ 1, data = df, family = "poisson", mode = "hmc",
#'             control = list(n_iter = 500L, warmup = 250L, seed = 1L))
#' ma <- model_average(full = f1, null = f2, weights = "waic")
#' ma$weights
#' }
#' @export
model_average <- function(..., weights = c("loo", "waic", "pbma", "pbma+"),
                          fitted_fn = fitted) {
  weights <- match.arg(weights)
  models  <- .name_models(list(...))
  crit    <- if (weights == "waic") "waic" else "loo"

  pw <- lapply(models, function(fit) {
    ll <- .compare_fit_loglik(fit)
    if (is.null(ll))
      stop("Model averaging needs every fit's pointwise log-likelihood ",
           "(fit$draws$log_lik); a model without it cannot be weighted.",
           call. = FALSE)
    cr <- tulpa_criteria(ll, criteria = crit, pointwise = TRUE)
    if (crit == "loo") cr$pointwise$elpd_loo else cr$pointwise$elpd_waic
  })
  if (length(unique(vapply(pw, length, integer(1)))) != 1L)
    stop("All models must be fitted to the same observations ",
         "(pointwise log-likelihoods differ in length).", call. = FALSE)
  lpd <- do.call(cbind, pw)                     # [n_obs x n_models]

  w <- switch(weights,
              loo     = .tulpa_stacking_weights(lpd),
              waic    = .tulpa_stacking_weights(lpd),
              pbma    = .tulpa_pbma_weights(lpd, bb = FALSE),
              `pbma+` = .tulpa_pbma_weights(lpd, bb = TRUE))
  names(w) <- names(models)

  fits <- lapply(models, fitted_fn)
  first_vals <- lapply(fits, function(f) if (is.list(f)) f[[1]] else f)
  n <- length(first_vals[[1]])
  avg <- numeric(n)
  for (k in seq_along(models)) avg <- avg + w[k] * first_vals[[k]]
  list(averaged = avg, weights = w,
       comparison = compare_models(..., criterion = crit))
}

Try the tulpa package in your browser

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

tulpa documentation built on Oct. 19, 2026, 5:09 p.m.