R/person_fit.R

Defines functions .person_fit_plot .person_fit_output .maybe_parallel_lapply .esf_convolve .sim_conditional .sim_theta .mc_pvalue .person_fit_resample .msq_to_zstd .person_lz .person_fit_stats .conditional_residuals .cond_moments RMpersonFit

Documented in RMpersonFit

#' Person-Fit Statistics for a Rasch / Partial Credit Model
#'
#' Computes per-respondent person-fit statistics and resampling-based
#' p-values. Three statistics are reported: conditional **infit** and
#' **outfit** mean-squares (MSQ) -- the magnitude (effect-size) measures
#' familiar from Rasch analysis -- and the standardized log-likelihood
#' statistic **lz**. The conditional MSQ statistics use response
#' probabilities conditional on the total score, which require no person
#' estimate and are therefore unbiased (Kreiner & Christensen, 2011); they are
#' computed on each person's observed response pattern, so partial
#' missingness is handled directly.
#'
#' Statistical significance is assessed by Monte-Carlo resampling under
#' the fitted model rather than by an assumed asymptotic distribution.
#' The asymptotic null distributions of MSQ and lz are known to be
#' unreliable -- the Wilson-Hilferty (ZSTD) transformation of MSQ in
#' particular adds nothing once conditional estimation is used (Müller,
#' 2020) -- so resampling provides the valid reference (Sinharay, 2016).
#'
#' @param data A data.frame or matrix of item responses. Items must be
#'   scored starting at 0 (non-negative integers). Missing values (`NA`)
#'   are allowed.
#' @param statistics Character vector. Which statistics to report; any of
#'   `"infit"`, `"outfit"`, `"lz"`. Defaults to all three.
#' @param estimator Character. How item parameters are estimated:
#'   `"CML"` (default) or `"MML"` (via \pkg{mirt}).
#' @param theta_method Character. Person-location estimator used for the
#'   lz statistic: `"WLE"` (default) or `"EAP"`. Ignored if `"lz"` is not
#'   requested.
#' @param iterations Integer. Number of Monte-Carlo replications per
#'   person. `0` skips resampling and returns statistics only. Default
#'   `500`.
#' @param flag_alpha Numeric in (0, 1). Significance level for the `flagged`
#'   column and the plot highlighting. Default `0.05`.
#' @param flag Character. Which misfit direction drives flagging for the MSQ
#'   statistics: `"both"` (default) flags either direction with a two-sided
#'   p-value; `"underfit"` flags only underfit (MSQ > 1, noisy responding)
#'   with a one-sided upper p-value, which is more powerful for detecting it
#'   and ignores (benign) overfit. The reported `p_infit` / `p_outfit` follow
#'   this choice. lz is one-sided regardless. Overfit can occasionally be
#'   suspicious (e.g. fabricated or copied response patterns), so `"both"` is
#'   the default.
#' @param zstd Logical. If `TRUE`, also report the Wilson-Hilferty
#'   standardized (ZSTD) versions of the MSQ statistics. These are
#'   provided only as a familiar cross-person comparability metric and
#'   are **not** used for inference (see Müller, 2020). Default `FALSE`.
#' @param parallel,n_cores Logical / integer. Parallelise the resampling
#'   across persons via \pkg{mirai} when available. Default sequential.
#'   When `n_cores` is `NULL`, `getOption("mc.cores")` is used, and two
#'   workers when that is unset too.
#' @param seed Optional integer for reproducible resampling. See
#'   [easyRasch2-reproducibility] for what this guarantees and how it
#'   interacts with `parallel`.
#' @param output Character. `"kable"` (default), `"dataframe"`,
#'   `"ggplot"`, or `"list"`. For `"ggplot"`, a **named list** of
#'   person-fit maps is returned -- one per requested statistic (e.g.
#'   `$infit`, `$outfit`, `$lz`) -- each plotting the statistic against
#'   person location, with
#'   respondents flagged by *that* statistic highlighted and a caption
#'   reporting the assessed sample size and the proportion flagged. For the
#'   MSQ maps the flagged count is split into underfit (MSQ > 1, noisy/erratic
#'   responding) and overfit (MSQ < 1, overly deterministic responding).
#'
#' @return
#' For `output = "dataframe"`, a data.frame with one row per respondent
#' (input order): `id`, `n_answered`, `sum_score`, the requested
#' statistics (`infit_msq`, `outfit_msq`, `lz`), their resampled
#' p-values (`p_infit`, `p_outfit`, `p_lz`) when `iterations > 0`,
#' `flagged`, and -- if `zstd = TRUE` -- `infit_zstd`, `outfit_zstd`.
#' The `flagged` column is `TRUE` when the marginal (uncorrected) p-value of
#' **any** requested statistic is below `flag_alpha` for that respondent; it
#' is therefore a per-person screening flag, not corrected for the number of
#' respondents tested (so under fit expect about `flag_alpha` of respondents
#' to be flagged by chance). Each `output = "ggplot"` map instead colours and
#' counts respondents flagged by its **own** statistic alone. Extreme scorers
#' (minimum or maximum possible given the items they answered) receive `NA`
#' statistics and are not assessed. For `output = "kable"` the same content as
#' a `knitr_kable`; for `output = "ggplot"` the named list of maps described
#' under that argument; for `output = "list"`, a list with both views from a
#' single computation: `fit` (the data.frame) and `plots` (the named list of
#' maps).
#'
#' @details
#' \strong{Conditional MSQ.} For person \eqn{v} with answered items
#' \eqn{A_v} and total score \eqn{r_v}, the conditional residual is
#' \eqn{z_{vi} = (x_{vi} - E(X_{vi} \mid R_v = r_v)) /
#' \sqrt{\mathrm{Var}(X_{vi} \mid R_v = r_v)}}, with conditional moments
#' obtained from elementary symmetric functions of the answered items'
#' parameters. Outfit is the unweighted mean of \eqn{z_{vi}^2}; infit is
#' the information-weighted mean. Because the conditional moments use only
#' item parameters, no (biased) person estimate enters the residual.
#'
#' \strong{Interpreting MSQ direction.} Values near 1 indicate fit. MSQ > 1
#' (\emph{underfit}) means the responses are noisier / more erratic than the
#' model expects -- the validity-relevant direction, indicating careless or
#' aberrant responding. MSQ < 1 (\emph{overfit}) means responses are overly
#' deterministic (Guttman-like); usually benign for score validity, though a
#' suspiciously perfect pattern can occasionally signal copied or fabricated
#' data. Use `flag = "underfit"` to flag only the former. lz is one-sided:
#' low (negative) values flag the aberrant (underfit-like) direction.
#'
#' \strong{lz.} The standardized log-likelihood of the response pattern
#' evaluated at the estimated person location (Drasgow, Levine &
#' Williams, 1985); small (negative) values indicate misfit. Because the
#' location is estimated rather than known, the variance of lz is below 1
#' and its asymptotic standard-normal null is invalid, which makes a
#' naive test conservative (Snijders, 2001; Sinharay, 2016). Instead of
#' applying the analytic lz\* standardization -- derived by Snijders (2001)
#' for dichotomous items and extended to polytomous / mixed-format items by
#' Sinharay (2016, 2026) -- easyRasch2 obtains the reference distribution by
#' resampling with the person location \strong{re-estimated for every
#' simulated pattern} (see \strong{Resampling}). This reproduces the
#' ability-estimation effect and is the resampling analogue of lz\*, so the
#' resampled p-value is well calibrated.
#'
#' \strong{Resampling.} Each statistic is referenced against the null that
#' matches it, removing the need to choose a scheme. The conditional MSQ
#' statistics use patterns sampled **conditional on the person's total
#' score** -- the exact Rasch-native null, requiring no person estimate and
#' fully consistent with the (conditional) statistic. lz, which is defined at
#' the estimated location, uses patterns **simulated at that location and then
#' scored with the location re-estimated from each simulated pattern**, so the
#' null carries the same ability-estimation effect as the observed lz (for a
#' Rasch / PCM model the location is a function of the total score, so this
#' re-estimation is a cheap score-based lookup). Both schemes are computed per
#' person over the items actually answered, so partial missingness is handled
#' by either. The p-value is the proportion of the `iterations` replicates at
#' least as extreme as the observed value. This follows the resampling-based
#' person-fit approach (Sinharay, 2016) and the bootstrap recommendation of
#' Müller (2020).
#'
#' @references
#' de la Torre, J., & Deng, W. (2008). Improving person-fit assessment by
#' correcting the ability estimate and its reference distribution.
#' *Journal of Educational Measurement, 45*(2), 159-177.
#' \doi{10.1111/j.1745-3984.2008.00058.x}
#'
#' Drasgow, F., Levine, M. V., & Williams, E. A. (1985). Appropriateness
#' measurement with polychotomous item response models and standardized
#' indices. *British Journal of Mathematical and Statistical Psychology,
#' 38*(1), 67-86. \doi{10.1111/j.2044-8317.1985.tb00817.x}
#'
#' Kreiner, S., & Christensen, K. B. (2011). Exact evaluation of bias in
#' Rasch model residuals. *Advances in Mathematics Research, 12*,
#' 19-40.
#'
#' Müller, M. (2020). Item fit statistics for Rasch analysis: can we
#' trust them? *Journal of Statistical Distributions and Applications,
#' 7*(5). \doi{10.1186/s40488-020-00108-7}
#'
#' Sinharay, S. (2016). Assessment of person fit using resampling-based
#' approaches. *Journal of Educational Measurement, 53*(1), 63-85.
#' \doi{10.1111/jedm.12101}
#'
#' Sinharay, S. (2016). Asymptotically correct standardization of
#' person-fit statistics beyond dichotomous items. *Psychometrika,
#' 81*(4), 992-1013. \doi{10.1007/s11336-015-9465-x}
#'
#' Sinharay, S. (2026). Refining the asymptotically correct
#' standardization of person-fit statistics for mixed-format tests.
#' *British Journal of Mathematical and Statistical Psychology*.
#' \doi{10.1111/bmsp.70049}
#'
#' Snijders, T. A. B. (2001). Asymptotic null distribution of person fit
#' statistics with estimated person parameter. *Psychometrika, 66*(3),
#' 331-342. \doi{10.1007/BF02294440}
#'
#' @seealso [RMpersonParameters()], [RMitemInfit()], [RMitemParameters()]
#'
#' @importFrom rlang .data
#' @export
#'
#' @examples
#' \donttest{
#' set.seed(1)
#' dat <- as.data.frame(
#'   matrix(sample(0:2, 200 * 8, replace = TRUE), nrow = 200, ncol = 8)
#' )
#' colnames(dat) <- paste0("Item", 1:8)
#'
#' # Conditional infit/outfit MSQ + lz with resampled p-values
#' RMpersonFit(dat, iterations = 200, output = "dataframe") |> head()
#'
#' # Person-fit maps: a named list with one plot per statistic
#' if (requireNamespace("ggplot2", quietly = TRUE)) {
#'   plots <- RMpersonFit(dat, iterations = 200, output = "ggplot")
#'   plots$infit    # infit map (each plot's caption reports the % flagged)
#' }
#'
#' # Flag only underfit (noisy responding), the validity-relevant direction
#' RMpersonFit(dat, iterations = 200, flag = "underfit",
#'             output = "dataframe") |> head()
#' }
RMpersonFit <- function(
  data,
  statistics = c("infit", "outfit", "lz"),
  estimator = c("CML", "MML"),
  theta_method = c("WLE", "EAP"),
  iterations = 500L,
  flag_alpha = 0.05,
  flag = c("both", "underfit"),
  zstd = FALSE,
  parallel = FALSE,
  n_cores = NULL,
  seed = NULL,
  output = c("kable", "dataframe", "ggplot", "list")
) {
  statistics <- match.arg(
    statistics,
    c("infit", "outfit", "lz"),
    several.ok = TRUE
  )
  estimator <- match.arg(estimator)
  theta_method <- match.arg(theta_method)
  flag <- match.arg(flag)
  output <- match.arg(output)
  # Underfit-only flagging uses a one-sided (upper) MSQ test; lz is always
  # one-sided (low = aberrant) and is unaffected.
  msq_tail <- if (flag == "underfit") "upper" else "two.sided"

  validate_response_data(data)
  if (
    !is.numeric(flag_alpha) ||
      length(flag_alpha) != 1L ||
      flag_alpha <= 0 ||
      flag_alpha >= 1
  ) {
    stop("`flag_alpha` must be a single number in (0, 1).", call. = FALSE)
  }
  if (!is.null(seed)) {
    set.seed(seed)
  }

  data <- as.data.frame(data)
  n_total <- nrow(data)
  has_na <- anyNA(data)
  data <- .drop_empty_respondents(data)

  # --- Item parameters --------------------------------------------------------
  fit <- if (estimator == "CML") {
    .rasch_fit_cml(data, se = FALSE)
  } else {
    .rasch_fit_mml(data, se = FALSE)
  }
  # Grand-mean-zero identification so the lz location matches
  # RMpersonParameters(); conditional MSQ is invariant to this shift.
  thr_list <- .center_thresholds(fit$thr_list)
  data <- .align_items(data, thr_list)
  data_mat <- as.matrix(data)
  n <- nrow(data_mat)

  # --- Person bookkeeping -----------------------------------------------------
  n_answered <- rowSums(!is.na(data_mat))
  sum_score <- rowSums(data_mat, na.rm = TRUE)
  max_score <- vapply(
    seq_len(n),
    function(p) {
      ans <- !is.na(data_mat[p, ])
      sum(vapply(thr_list[ans], length, integer(1)))
    },
    numeric(1)
  )
  extreme <- n_answered > 0L & (sum_score == 0 | sum_score == max_score)

  # --- Person location -------------------------------------------------------
  # Needed for lz (both the statistic and its theta-based resampling) and for
  # the ggplot x-axis. The MSQ statistics and their conditional resampling
  # need no person estimate.
  need_theta <- ("lz" %in% statistics) || (output %in% c("ggplot", "list"))
  theta <- rep(NA_real_, n)
  if (need_theta) {
    # Shared engine (utils-theta.R): WLE caches by (answered-set, score); EAP
    # uses its quadrature grid and estimates the prior SD from the data. The
    # wider c(-10, 10) range locates extreme scorers (person-reporting scale).
    theta <- .estimate_thetas(
      data_mat,
      thr_list,
      method = theta_method,
      theta_range = c(-10, 10)
    )$theta
  }

  # --- Observed statistics ----------------------------------------------------
  obs <- .person_fit_stats(data_mat, thr_list, theta, statistics)

  out <- data.frame(
    id = if (!is.null(rownames(data))) rownames(data) else seq_len(n),
    n_answered = n_answered,
    sum_score = sum_score,
    stringsAsFactors = FALSE
  )
  if ("infit" %in% statistics) {
    out$infit_msq <- obs$infit
  }
  if ("outfit" %in% statistics) {
    out$outfit_msq <- obs$outfit
  }
  if ("lz" %in% statistics) {
    out$lz <- obs$lz
  }

  # --- Resampling p-values ----------------------------------------------------
  if (iterations > 0L) {
    pv <- .person_fit_resample(
      data_mat,
      thr_list,
      theta,
      statistics,
      obs,
      iterations = iterations,
      theta_method = theta_method,
      parallel = parallel,
      n_cores = n_cores,
      msq_tail = msq_tail
    )
    if ("infit" %in% statistics) {
      out$p_infit <- pv$infit
    }
    if ("outfit" %in% statistics) {
      out$p_outfit <- pv$outfit
    }
    if ("lz" %in% statistics) {
      out$p_lz <- pv$lz
    }

    p_cols <- intersect(c("p_infit", "p_outfit", "p_lz"), names(out))
    out$flagged <- apply(out[p_cols], 1L, function(p) {
      any(p < flag_alpha, na.rm = TRUE)
    })
  }

  # --- Optional ZSTD (non-inferential) ----------------------------------------
  if (zstd) {
    if ("infit" %in% statistics) {
      out$infit_zstd <- .msq_to_zstd(obs$infit, n_answered)
    }
    if ("outfit" %in% statistics) {
      out$outfit_zstd <- .msq_to_zstd(obs$outfit, n_answered)
    }
  }

  # Extreme scorers carry no fit information
  stat_cols <- intersect(
    c(
      "infit_msq",
      "outfit_msq",
      "lz",
      "p_infit",
      "p_outfit",
      "p_lz",
      "infit_zstd",
      "outfit_zstd"
    ),
    names(out)
  )
  out[extreme, stat_cols] <- NA
  if ("flagged" %in% names(out)) {
    out$flagged[extreme] <- NA
  }

  .person_fit_output(
    out,
    theta,
    extreme,
    statistics,
    output,
    flag_alpha,
    iterations,
    flag,
    n_total = n_total,
    has_na = has_na
  )
}

# ---------------------------------------------------------------------
# Internal: conditional moments by total score (missingness-aware core)
# ---------------------------------------------------------------------

#' Conditional expected score and variance by total score
#'
#' For a set of items (a single missingness pattern), returns lookup
#' matrices of \eqn{E(X_i \mid R = r)} and \eqn{\mathrm{Var}(X_i \mid
#' R = r)} for every attainable total score \eqn{r}, computed from
#' elementary symmetric functions of the item parameters.
#'
#' @param thr_sub List of threshold vectors for the answered items.
#' @return List with `E` and `V` matrices (rows = total score 0..max,
#'   cols = items) and `max_score`.
#' @keywords internal
#' @noRd
.cond_moments <- function(thr_sub) {
  steps <- vapply(thr_sub, length, integer(1))
  is_poly <- any(steps > 1L)
  k <- length(thr_sub)
  max_sc <- sum(steps)

  pars <- if (is_poly) lapply(thr_sub, cumsum) else unlist(thr_sub)
  esf <- psychotools::elementary_symmetric_functions(pars, order = 1L)
  g <- esf[[1L]]
  dg <- esf[[2L]]

  E <- matrix(NA_real_, max_sc + 1L, k)
  V <- matrix(NA_real_, max_sc + 1L, k)

  if (!is_poly) {
    for (r in seq_len(max_sc - 1L)) {
      # 1..max-1 (extremes degenerate)
      e <- dg[r + 1L, ] / g[r + 1L]
      E[r + 1L, ] <- e
      V[r + 1L, ] <- e * (1 - e)
    }
  } else {
    col0 <- c(0L, cumsum(steps))
    for (r in seq_len(max_sc - 1L)) {
      for (i in seq_len(k)) {
        cols <- (col0[i] + 1L):col0[i + 1L]
        p_ge <- dg[r + 1L, cols] / g[r + 1L] # P(X_i = c | R = r), c = 1..m_i
        p <- c(1 - sum(p_ge), p_ge)
        cats <- 0:steps[i]
        ev <- sum(cats * p)
        E[r + 1L, i] <- ev
        V[r + 1L, i] <- sum(cats^2 * p) - ev^2
      }
    }
  }
  list(E = E, V = V, max_score = max_sc)
}

#' Conditional residuals for one response matrix (grouped by NA pattern)
#'
#' @param data_mat Numeric response matrix.
#' @param thr_list Threshold list aligned with the columns.
#' @return List of length nrow with, per person, the conditional
#'   residuals `z` and variance weights `w` for their answered items;
#'   `NULL` for extreme/empty rows.
#' @keywords internal
#' @noRd
.conditional_residuals <- function(data_mat, thr_list) {
  n <- nrow(data_mat)
  res <- vector("list", n)

  pattern <- apply(!is.na(data_mat), 1L, function(a) {
    paste0(which(a), collapse = ",")
  })
  for (pat in unique(pattern)) {
    rows <- which(pattern == pat)
    items <- as.integer(strsplit(pat, ",", fixed = TRUE)[[1L]])
    if (length(items) == 0L) {
      next
    }
    mom <- .cond_moments(thr_list[items])

    for (p in rows) {
      x <- data_mat[p, items]
      r <- sum(x)
      if (r == 0L || r == mom$max_score) {
        next
      } # extreme: no information
      e <- mom$E[r + 1L, ]
      v <- mom$V[r + 1L, ]
      z <- (x - e) / sqrt(v)
      res[[p]] <- list(z = z, w = v)
    }
  }
  res
}

# ---------------------------------------------------------------------
# Internal: observed statistics
# ---------------------------------------------------------------------

#' @keywords internal
#' @noRd
.person_fit_stats <- function(data_mat, thr_list, theta, statistics) {
  n <- nrow(data_mat)
  infit <- outfit <- lz <- rep(NA_real_, n)

  if (any(c("infit", "outfit") %in% statistics)) {
    resid <- .conditional_residuals(data_mat, thr_list)
    for (p in seq_len(n)) {
      r <- resid[[p]]
      if (is.null(r)) {
        next
      }
      outfit[p] <- mean(r$z^2)
      infit[p] <- sum(r$w * r$z^2) / sum(r$w)
    }
  }
  if ("lz" %in% statistics) {
    for (p in seq_len(n)) {
      lz[p] <- .person_lz(data_mat[p, ], thr_list, theta[p])
    }
  }
  list(infit = infit, outfit = outfit, lz = lz)
}

#' Standardized log-likelihood (lz) for one response pattern at theta
#' @keywords internal
#' @noRd
.person_lz <- function(resp, thr_list, theta) {
  if (is.na(theta) || !is.finite(theta)) {
    return(NA_real_)
  }
  ok <- !is.na(resp)
  if (!any(ok)) {
    return(NA_real_)
  }
  resp <- resp[ok]
  thr_list <- thr_list[ok]

  l <- 0
  El <- 0
  Vl <- 0
  for (i in seq_along(thr_list)) {
    p <- .pcm_cat_probs(theta, thr_list[[i]])
    lp <- log(p)
    l <- l + lp[resp[i] + 1L]
    m1 <- sum(p * lp)
    El <- El + m1
    Vl <- Vl + sum(p * lp^2) - m1^2
  }
  if (Vl <= 0) {
    return(NA_real_)
  }
  (l - El) / sqrt(Vl)
}

#' Wilson-Hilferty (ZSTD) transform of an MSQ value (non-inferential)
#' @keywords internal
#' @noRd
.msq_to_zstd <- function(msq, df) {
  q <- pmax(df, 1L)
  (msq^(1 / 3) - 1) * (3 / sqrt(2 / q)) + (sqrt(2 / q) / 3)
}

# ---------------------------------------------------------------------
# Internal: resampling
# ---------------------------------------------------------------------

#' @keywords internal
#' @noRd
.person_fit_resample <- function(
  data_mat,
  thr_list,
  theta,
  statistics,
  obs,
  iterations,
  theta_method = "WLE",
  parallel,
  n_cores,
  msq_tail = "two.sided"
) {
  n <- nrow(data_mat)
  want_msq <- any(c("infit", "outfit") %in% statistics)
  want_lz <- "lz" %in% statistics

  # .cond_moments() depends only on the answered-item set (not the person or
  # the observed score), so compute it once per distinct answered-set and reuse
  # it -- on complete data a single build instead of one per respondent. It is
  # deterministic, so caching does not touch the resampling RNG stream.
  ans_sets <- lapply(seq_len(n), function(p) which(!is.na(data_mat[p, ])))
  ans_key <- vapply(
    ans_sets,
    function(a) paste0(a, collapse = ","),
    character(1L)
  )
  mom_cache <- NULL
  if (want_msq) {
    mom_cache <- list()
    for (k in unique(ans_key)) {
      a <- ans_sets[[match(k, ans_key)]]
      if (length(a) > 0L) mom_cache[[k]] <- .cond_moments(thr_list[a])
    }
  }

  # Each statistic uses its matching null: the conditional MSQ statistics are
  # referenced against patterns sampled conditional on the total score (no
  # person estimate, consistent with the statistic); lz, which is defined at
  # the estimated location, is referenced against patterns simulated there and
  # scored with a location *re-estimated* from each simulated pattern (see the
  # lz block below).
  # One seed per respondent, drawn from the caller's stream. Without this the
  # parallel path is not reproducible at all: each mirai daemon carries its
  # own L'Ecuyer-CMRG stream and the assignment of respondents to daemons is
  # not fixed, so `seed` controlled the sequential path only. Seeding per
  # respondent instead of per path also makes the two agree exactly.
  person_seeds <- sample.int(.Machine$integer.max, n)

  one_person <- function(p) {
    set.seed(
      person_seeds[p],
      kind = "Mersenne-Twister",
      normal.kind = "Inversion",
      sample.kind = "Rejection"
    )
    ans <- ans_sets[[p]]
    res <- c(infit = NA_real_, outfit = NA_real_, lz = NA_real_)
    if (length(ans) == 0L) {
      return(res)
    }
    thr_sub <- thr_list[ans]
    r_obs <- sum(data_mat[p, ans])

    # MSQ: conditional-on-total-score resampling (moments cached per answered-set)
    if (want_msq) {
      mom <- mom_cache[[ans_key[p]]]
      if (r_obs > 0L && r_obs < mom$max_score) {
        sims <- .sim_conditional(thr_sub, r_obs, iterations)
        if (!is.null(sims)) {
          stat <- t(apply(sims, 1L, function(xs) {
            rr <- sum(xs)
            if (rr == 0L || rr == mom$max_score) {
              return(c(NA, NA))
            }
            e <- mom$E[rr + 1L, ]
            v <- mom$V[rr + 1L, ]
            z <- (xs - e) / sqrt(v)
            c(mean(z^2), sum(v * z^2) / sum(v)) # outfit, infit
          }))
          if ("outfit" %in% statistics) {
            res["outfit"] <- .mc_pvalue(stat[, 1L], obs$outfit[p], msq_tail)
          }
          if ("infit" %in% statistics) {
            res["infit"] <- .mc_pvalue(stat[, 2L], obs$infit[p], msq_tail)
          }
        }
      }
    }

    # lz: simulate at the estimated location, then *re-estimate* the location
    # from each simulated pattern and score lz there (lower tail = misfit).
    # Re-estimation is essential: the observed lz carries the ability-estimation
    # effect (its variance is < 1; Snijders 2001), so the null must reproduce it
    # or the test is conservative (Sinharay 2016). For a Rasch/PCM model the WLE
    # / EAP location is a function of the total score, so this re-estimation is a
    # cheap score-based lookup (cached inside .estimate_thetas()), and it is the
    # resampling analogue of the analytic lz* standardization.
    if (want_lz && is.finite(theta[p])) {
      sims <- .sim_theta(thr_sub, theta[p], iterations)
      if (!is.null(sims)) {
        full <- matrix(NA_real_, iterations, ncol(data_mat))
        full[, ans] <- sims
        th_sim <- .estimate_thetas(
          full,
          thr_list,
          method = theta_method,
          theta_range = c(-10, 10)
        )$theta
        lz_sim <- vapply(
          seq_len(iterations),
          function(b) {
            .person_lz(full[b, ], thr_list, th_sim[b])
          },
          numeric(1L)
        )
        res["lz"] <- .mc_pvalue(lz_sim, obs$lz[p], tail = "lower")
      }
    }
    res
  }

  rows <- .maybe_parallel_lapply(seq_len(n), one_person, parallel, n_cores)
  m <- do.call(rbind, rows)
  list(infit = m[, "infit"], outfit = m[, "outfit"], lz = m[, "lz"])
}

#' Empirical p-value for an MSQ-type statistic against its simulated null
#'
#' @param sim Numeric vector of simulated null values.
#' @param obs Observed value.
#' @param tail `"two.sided"` (deviation in either direction), `"upper"`
#'   (underfit only -- the validity-relevant direction), or `"lower"` (e.g.
#'   the lz statistic, where small values indicate misfit).
#' @keywords internal
#' @noRd
.mc_pvalue <- function(sim, obs, tail = "two.sided") {
  sim <- sim[!is.na(sim)]
  B <- length(sim)
  if (B == 0L || is.na(obs)) {
    return(NA_real_)
  }
  # (1 + count) / (B + 1) Monte-Carlo p-value, consistent with
  # .bootstrap_pvalues() in utils-multiplicity.R: never exactly 0, and the
  # denominator counts only the valid (non-NA) simulations.
  if (tail == "upper") {
    return((1 + sum(sim >= obs)) / (B + 1))
  }
  if (tail == "lower") {
    return((1 + sum(sim <= obs)) / (B + 1))
  }
  p_upper <- (1 + sum(sim >= obs)) / (B + 1)
  p_lower <- (1 + sum(sim <= obs)) / (B + 1)
  min(1, 2 * min(p_upper, p_lower))
}

#' Simulate response patterns at a fixed theta (per-person MC scheme)
#' @keywords internal
#' @noRd
.sim_theta <- function(thr_sub, theta, B) {
  if (is.na(theta) || !is.finite(theta)) {
    return(NULL)
  }
  k <- length(thr_sub)
  out <- matrix(0L, B, k)
  for (i in seq_len(k)) {
    p <- .pcm_cat_probs(theta, thr_sub[[i]])
    out[, i] <- sample.int(length(p), B, replace = TRUE, prob = p) - 1L
  }
  out
}

#' Simulate response patterns conditional on a fixed total score
#'
#' Sequential exact sampling via suffix elementary symmetric functions:
#' draws each item in turn from its conditional distribution given the
#' remaining target score and the remaining items.
#' @keywords internal
#' @noRd
.sim_conditional <- function(thr_sub, total, B) {
  k <- length(thr_sub)
  steps <- vapply(thr_sub, length, integer(1))
  if (total <= 0L || total >= sum(steps)) {
    return(NULL)
  }

  # per-item category weights w_{i,c} = exp(-eta_{i,c}), c = 0..m_i
  wlist <- lapply(thr_sub, function(thr) exp(-c(0, cumsum(thr))))
  # suffix ESF: gamma of items i..k, for i = 1..k+1 (k+1 = empty -> gamma_0 = 1)
  suffix_g <- vector("list", k + 1L)
  suffix_g[[k + 1L]] <- 1 # empty product
  for (i in k:1) {
    suffix_g[[i]] <- .esf_convolve(suffix_g[[i + 1L]], wlist[[i]])
  }

  out <- matrix(0L, B, k)
  for (b in seq_len(B)) {
    s <- total
    for (i in seq_len(k)) {
      w_i <- wlist[[i]]
      g_rest <- suffix_g[[i + 1L]] # gamma of items i+1..k
      cats <- 0:steps[i]
      # P(x_i = c) propto w_{i,c} * gamma_{s-c}(rest)
      probs <- vapply(
        cats,
        function(c) {
          idx <- s - c
          if (idx < 0L || idx >= length(g_rest)) {
            return(0)
          }
          w_i[c + 1L] * g_rest[idx + 1L]
        },
        numeric(1)
      )
      if (sum(probs) <= 0) {
        out[b, i] <- 0L
        next
      }
      xc <- sample.int(length(probs), 1L, prob = probs) - 1L
      out[b, i] <- xc
      s <- s - xc
    }
  }
  out
}

#' Convolve an ESF vector with one item's category weights
#'
#' Given gamma (ESF of a set of items, indexed by score 0..S) and an
#' item's weights w (score 0..m), returns the ESF of the combined set.
#' @keywords internal
#' @noRd
.esf_convolve <- function(gamma, w) {
  S <- length(gamma) - 1L
  m <- length(w) - 1L
  out <- numeric(S + m + 1L)
  for (s in 0:S) {
    gs <- gamma[s + 1L]
    if (gs == 0) {
      next
    }
    for (c in 0:m) {
      out[s + c + 1L] <- out[s + c + 1L] + gs * w[c + 1L]
    }
  }
  out
}

#' Optionally parallel lapply via mirai
#' @keywords internal
#' @noRd
.maybe_parallel_lapply <- function(x, fun, parallel, n_cores) {
  if (parallel && requireNamespace("mirai", quietly = TRUE)) {
    # Same resolution order as the cutoff functions: the argument first, then
    # `options(mc.cores)`. Unlike them this one has a working default rather
    # than a warning, since the per-respondent seeds make the result
    # independent of how many workers run it.
    if (is.null(n_cores)) {
      n_cores <- getOption("mc.cores")
    }
    workers <- if (is.null(n_cores)) 2L else n_cores
    workers <- min(workers, length(x))
    mirai::daemons(workers)
    on.exit(mirai::daemons(0L), add = TRUE)
    res <- mirai::mirai_map(x, fun)[]
    return(res)
  }
  lapply(x, fun)
}

# ---------------------------------------------------------------------
# Internal: output formatting
# ---------------------------------------------------------------------

#' @keywords internal
#' @noRd
.person_fit_output <- function(
  out,
  theta,
  extreme,
  statistics,
  output,
  flag_alpha,
  iterations,
  flag = "both",
  n_total = nrow(out),
  has_na = FALSE
) {
  # Assessed = respondents that received a fit statistic (extreme scorers get
  # NA). n_total is the raw supplied row count (before all-NA rows were
  # dropped), so the caption anchors to the full sample. Each parenthetical
  # qualifier appears only when it applies (missingness / extreme scorers).
  n_assessed <- sum(!extreme)
  pf_quals <- c(
    if (has_na) "incomplete responses retained",
    if (any(extreme)) "extreme scores excluded"
  )
  n_clause <- paste0(
    " ",
    .n_caption(n_assessed, n_total, pf_quals, noun = "respondents assessed"),
    "."
  )
  if (output == "dataframe") {
    # Unrounded: rounding is a display concern (the kable path rounds a
    # display copy below).
    return(out)
  }

  if (output %in% c("ggplot", "list")) {
    if (!requireNamespace("ggplot2", quietly = TRUE)) {
      stop(
        "Package 'ggplot2' is required for output = \"", output, "\". ",
        "Install it with: install.packages(\"ggplot2\")",
        call. = FALSE
      )
    }
    # One plot per requested statistic, returned as a named list.
    stat_map <- list(
      infit = list(
        col = "infit_msq",
        pcol = "p_infit",
        label = "Infit MSQ",
        ref = 1,
        split = TRUE
      ),
      outfit = list(
        col = "outfit_msq",
        pcol = "p_outfit",
        label = "Outfit MSQ",
        ref = 1,
        split = TRUE
      ),
      lz = list(col = "lz", pcol = "p_lz", label = "lz", ref = 0, split = FALSE)
    )
    plots <- lapply(statistics, function(s) {
      m <- stat_map[[s]]
      .person_fit_plot(
        out,
        theta,
        m$col,
        m$pcol,
        m$label,
        m$ref,
        m$split,
        flag_alpha,
        iterations,
        flag,
        n_total = n_total,
        qualifiers = pf_quals
      )
    })
    names(plots) <- statistics
    if (output == "list") {
      # One computation, both views: the per-person results and the maps.
      return(list(fit = out, plots = plots))
    }
    return(plots)
  }

  display <- out
  num <- vapply(display, is.numeric, logical(1))
  display[num] <- lapply(display[num], function(x) round(x, 3))
  caption <- paste0(
    "Person fit (conditional MSQ + lz). ",
    if (iterations > 0L) {
      msq_desc <- if (flag == "underfit") {
        "one-sided (underfit) MSQ p-values"
      } else {
        "two-sided MSQ p-values"
      }
      paste0(
        msq_desc,
        " from ",
        iterations,
        " Monte-Carlo replications (MSQ conditional on the total score, ",
        "lz simulated at the estimated location); ",
        "flagged at alpha = ",
        flag_alpha,
        "."
      )
    } else {
      "No resampling (iterations = 0)."
    },
    n_clause
  )
  knitr::kable(display, format = "pipe", caption = caption, row.names = FALSE)
}

#' Build one person-fit map (statistic vs person location)
#'
#' @param out The assembled person-fit data.frame.
#' @param theta Person locations (x-axis).
#' @param ycol,pcol Column names for the statistic and its marginal p-value.
#' @param ylab Axis label for the statistic.
#' @param ref Horizontal reference line (expected value), or `NA` for none.
#' @param split Logical. If `TRUE` (the MSQ statistics), flagged respondents
#'   are split into underfit (above `ref`) and overfit (below `ref`) for both
#'   the point colour and the caption. `FALSE` for one-sided statistics (lz).
#' @param flag_alpha Flagging threshold on the marginal p-value.
#' @param iterations Number of resampling iterations (0 = no p-values).
#' @param flag `"both"` (two-sided MSQ test) or `"underfit"` (one-sided);
#'   controls the caption wording for the MSQ maps.
#' @return A `ggplot`. Caption reports the assessed sample size and the
#'   proportion flagged by this statistic.
#' @keywords internal
#' @noRd
.person_fit_plot <- function(
  out,
  theta,
  ycol,
  pcol,
  ylab,
  ref,
  split,
  flag_alpha,
  iterations,
  flag = "both",
  n_total = NULL,
  qualifiers = character()
) {
  has_p <- iterations > 0L && pcol %in% names(out)
  df <- data.frame(
    theta = theta,
    y = out[[ycol]],
    flagged = if (has_p) out[[pcol]] < flag_alpha else FALSE
  )
  df <- df[is.finite(df$theta) & is.finite(df$y), , drop = FALSE]
  n_assessed <- nrow(df)
  n_lead <- .n_caption(
    n_assessed,
    if (is.null(n_total)) n_assessed else n_total,
    qualifiers,
    noun = "respondents assessed"
  )

  # Classify each respondent for colouring.
  df$status <- "Not flagged"
  if (split && is.finite(ref)) {
    df$status[df$flagged & df$y > ref] <- "Underfit"
    df$status[df$flagged & df$y < ref] <- "Overfit"
  } else {
    df$status[df$flagged] <- "Flagged"
  }
  pal <- c(
    "Not flagged" = "grey55",
    "Underfit" = "#e41a1c",
    "Overfit" = "#377eb8",
    "Flagged" = "#e41a1c"
  )
  present <- intersect(names(pal), unique(df$status))
  df$status <- factor(df$status, levels = present)

  if (has_p) {
    n_flag <- sum(df$flagged, na.rm = TRUE)
    pct <- if (n_assessed > 0L) {
      round(100 * n_flag / n_assessed, 1)
    } else {
      NA_real_
    }
    if (split && is.finite(ref)) {
      n_under <- sum(df$flagged & df$y > ref, na.rm = TRUE) # MSQ > 1: noisy
      n_over <- sum(df$flagged & df$y < ref, na.rm = TRUE) # MSQ < 1: muted
      caption <- if (flag == "underfit") {
        paste0(
          n_lead,
          "; ",
          n_flag,
          " (",
          pct,
          "%) flagged as underfit (MSQ > ",
          ref,
          "; one-sided p < ",
          flag_alpha,
          ")."
        )
      } else {
        paste0(
          n_lead,
          "; ",
          n_flag,
          " (",
          pct,
          "%) flagged: ",
          n_under,
          " underfit (MSQ > ",
          ref,
          "), ",
          n_over,
          " overfit (MSQ < ",
          ref,
          ") (p < ",
          flag_alpha,
          ")."
        )
      }
    } else {
      caption <- paste0(
        n_lead,
        "; ",
        n_flag,
        " (",
        pct,
        "%) flagged as misfitting (p < ",
        flag_alpha,
        ")."
      )
    }
  } else {
    caption <- paste0(
      n_lead,
      " (no resampling; iterations = 0)."
    )
  }

  p <- ggplot2::ggplot(df, ggplot2::aes(x = .data$theta, y = .data$y))
  if (is.finite(ref)) {
    p <- p +
      ggplot2::geom_hline(
        yintercept = ref,
        linetype = "dashed",
        colour = "grey60"
      )
  }
  if (has_p && length(present) > 1L) {
    p <- p +
      ggplot2::geom_point(ggplot2::aes(colour = .data$status), alpha = 0.7) +
      ggplot2::scale_colour_manual(
        values = pal,
        name = "Person fit",
        breaks = present
      )
  } else {
    p <- p + ggplot2::geom_point(colour = "grey45", alpha = 0.7)
  }
  p +
    ggplot2::labs(
      x = "Person location (logits)",
      y = ylab,
      caption = er2_caption(caption)
    ) +
    ggplot2::theme_bw() +
    er2_axis_margins() +
    er2_plot_caption()
}

Try the easyRasch2 package in your browser

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

easyRasch2 documentation built on Sept. 13, 2026, 1:07 a.m.