R/ppass.R

Defines functions PV.ppass JKpp.ppass ppass_estimate as.data.frame.ppass summary.ppass print.ppass ppass_header ppass_flag ppass_intact ppass_build erm_items item_columns ppass_label ppass_fun PPass.eRm PPass.default PPass

Documented in as.data.frame.ppass JKpp.ppass PPass PPass.default PPass.eRm print.ppass PV.ppass summary.ppass

#' Person assessment: estimates and person fit in one step
#'
#' \code{PPass} (\bold{P}erson \bold{P}arameter \bold{ass}essment) estimates the person parameters and computes the person fit in one call, and returns both in one table, next to the other person variables. It takes a data.frame or matrix with item responses and known item parameters, or a model fitted with the \code{eRm} package.
#'
#' \bold{Data with item parameters} (\code{PPass.default}): the columns \code{items} of \code{respdf} are the responses, the other columns are kept as person variables. The item parameters are passed in \code{...} (\code{thres}, \code{slopes}, \code{lowerA}, \code{upperA}), and the model follows from them: \code{thres} alone gives the 1PL model, with \code{slopes} the 2PL, with \code{lowerA} the 3PL and with \code{upperA} the 4PL model, estimated with \code{PP_4pl()}. A threshold matrix with more than one threshold for some item gives the partial credit model (all slopes 1) or the generalized partial credit model, estimated with \code{PP_gpcm()}. With such polytomous items and \code{lowerA} or \code{upperA}, the test is a mixed test (items with one threshold are 4PL items), estimated with \code{PPall()}. The arguments \code{mod} and \code{model2est} of earlier versions are no longer needed; they are ignored with a warning.
#'
#' \bold{eRm models} (\code{PPass.eRm}): all models of the \code{eRm} package are supported, the Rasch model (\code{RM}), the linear logistic test model (\code{LLTM}), the rating scale model (\code{RSM}), the partial credit model (\code{PCM}) and the linear models \code{LRSM} and \code{LPCM}, also with several time points (\code{mpoints}) and groups (\code{groupvec}). The responses and item parameters come from the fitted object. eRm stores easiness parameters per category, \eqn{\beta_{ih} = -\sum_{k \le h} \delta_{ik}}; they are turned into the thresholds \eqn{\delta_{ih} = \beta_{i,h-1} - \beta_{ih}}, so for the dichotomous models the difficulty is \eqn{-\beta_i}. With time points or groups every combination of item, time point and group is a virtual item with its own parameters; a person answers the virtual items of their group, and gets one ability over all time points. The person parameters are estimated with \code{PP_4pl()} (RM, LLTM) or \code{PP_gpcm()} with all slopes 1. The maximum likelihood estimates (\code{type = "mle"}) equal those of \code{eRm::person.parameter()}; unlike \code{eRm}, \code{PPass} also gives estimates for models with group contrasts, and WLE, MAP, EAP and robust estimates, person fit, jackknife and plausible values. The \code{eRm} package is not needed to run \code{PPass} on such an object.
#'
#' Person fit is computed by \code{Pfit()} and needs an estimate of type \code{"mle"}, \code{"wle"} or \code{"map"}. For \code{type = "eap"} and \code{"robust"} \code{PPass} returns the estimates only, with a message.
#'
#' @param \ldots Arguments for the estimation: the item parameters (\code{PPass.default} only) and \code{type}, \code{mu}, \code{sigma2}, \code{theta_start}, \code{range}, \code{maxsteps}, \code{exac}, \code{H} and \code{verbose}, see \code{PP_4pl()}, \code{PP_gpcm()} and \code{PPall()}. The default estimate is WLE.
#'
#' @return A data.frame of class \code{ppass} with one row per person: the person variables (the columns of \code{respdf} that are not items; for eRm models with groups the column \code{group}), \code{estimate}, \code{SE} and \code{status} of the estimate (see \code{PP_4pl()}) and the columns of the chosen person fit indices (see \code{Pfit()}). It can be used like any data.frame. The estimation object is kept as attribute, so \code{JKpp()} and \code{PV()} can be applied to the result directly; \code{print()} and \code{summary()} show an overview with the persons flagged by the person fit, \code{plot()} draws the person fit map, the measurement precision or person profiles (see \link{pp-plots}), and \code{as.data.frame()} returns a plain data.frame.
#'
#' @author Manuel Reif, Jan Steinfeld
#' @seealso \link{PP_4pl}, \link{PP_gpcm}, \link{PPall}, \link{Pfit}, \link{JKpp}, \link{PV}, \link{pp-plots}
#' @rdname PPass
#'@examples
#' ## data with item parameters
#'
#' set.seed(1337)
#' # difficulties and slopes of 15 items
#' diffpar <- seq(-3, 3, length = 15)
#' sl <- round(runif(15, 0.5, 1.5), 2)
#' # responses of 200 persons, plus an id and a group variable
#' awm <- sim_4pl(beta = diffpar, alpha = sl, lowerA = rep(0, 15), upperA = rep(1, 15),
#'                theta = rnorm(200))
#' dat <- data.frame(id = 1:200, group = rep(c("a", "b"), 100), awm)
#'
#' # 2PL, WLE estimates and all person fit indices; id and group are kept
#' res <- PPass(dat, items = 3:17, thres = diffpar, slopes = sl)
#' res
#' summary(res)
#'
#' # person fit map, flagged persons labelled with their id
#' plot(res, label = "id")
#'
#' # a plain data.frame for further use
#' head(as.data.frame(res))
#'
#' # jackknife directly from the result
#' jk <- JKpp(res)
#'
#'
#' ## real data (adaptive test, 1PL)
#'
#' data(pp_amt)
#' d <- pp_amt$daten_amt
#' rd_res <- PPass(d, items = 8:ncol(d), thres = pp_amt$betas[, 2], fitindices = "lz")
#' head(rd_res)
#'
#'
#' ## models fitted with eRm
#'
#' if (requireNamespace("eRm", quietly = TRUE)) {
#'
#'   # Rasch model
#'   my_rm <- eRm::RM(eRm::raschdat1)
#'   res_rm <- PPass(my_rm)
#'   summary(res_rm)
#'
#'   # the maximum likelihood estimates equal eRm::person.parameter()
#'   res_mle <- PPass(my_rm, type = "mle", fitindices = "lzstar")
#'
#'   # partial credit model
#'   set.seed(2751)
#'   thres <- rbind(0, matrix(c(-2, -1, 0.5, -1.2, 0.3, NA, -0.5, 1, NA, 0, 1.5, 2), 3))
#'   simdat <- sim_gpcm(thres = thres, alpha = rep(1, 4), theta = rnorm(300))
#'   res_pcm <- PPass(eRm::PCM(simdat))
#'
#'   # linear partial credit model with two time points and two groups
#'   simdat2 <- sim_gpcm(thres = thres, alpha = rep(1, 4), theta = rnorm(300, 0.5))
#'   my_lpcm <- eRm::LPCM(cbind(simdat, simdat2), mpoints = 2, groupvec = rep(1:2, 150))
#'   res_lpcm <- PPass(my_lpcm)
#'   head(res_lpcm)
#'
#'   # plausible values need an EAP or MAP estimate
#'   pv <- PV(PPass(my_rm, type = "map"))
#' }
#' @export
PPass <- function(...) UseMethod("PPass")


#' @param respdf A data.frame or matrix with one row per person, which contains the item responses and possibly other person variables.
#' @param items The item columns of \code{respdf}: \code{"all"} (default), their positions or their names.
#' @param fitindices The person fit indices to compute, any of \code{"lz"}, \code{"lzstar"}, \code{"infit"} and \code{"outfit"} (default all).
#' @param SE If \code{TRUE}, jackknife standard errors of the fit indices are added (see \code{Pfit()}).
#'
#' @rdname PPass
#' @method PPass default
#' @export
PPass.default <- function(respdf, items = "all", fitindices = c("lz", "lzstar", "infit", "outfit"),
                          SE = FALSE, ...) {
  call <- match.call()
  fitindices <- match.arg(fitindices, several.ok = TRUE)
  if (!is.data.frame(respdf) && !is.matrix(respdf)) {
    stop("'respdf' must be a data.frame or a matrix.", call. = FALSE)
  }
  cols <- item_columns(respdf, items)
  X <- as.matrix(respdf[, cols, drop = FALSE])
  rest <- as.data.frame(respdf[, -cols, drop = FALSE])
  args <- list(...)
  old <- intersect(names(args), c("mod", "model2est"))
  if (length(old)) {
    warning(paste0("'", old, "'", collapse = " and "), if (length(old) == 1) " is" else " are",
            " no longer needed and ignored; the model follows from the item parameters.",
            call. = FALSE)
    args[old] <- NULL
  }
  est <- do.call(ppass_fun(args), c(list(respm = X), args))
  ppass_build(est, rest, fitindices, SE, model = ppass_label(est$model), call = call)
}


#' @param RMobj A model fitted with the \code{eRm} package (\code{RM()}, \code{LLTM()}, \code{RSM()}, \code{PCM()}, \code{LRSM()} or \code{LPCM()}).
#'
#' @rdname PPass
#' @method PPass eRm
#' @export
PPass.eRm <- function(RMobj, fitindices = c("lz", "lzstar", "infit", "outfit"), SE = FALSE, ...) {
  call <- match.call()
  fitindices <- match.arg(fitindices, several.ok = TRUE)
  args <- list(...)
  given <- intersect(names(args), c("respm", "thres", "slopes", "lowerA", "upperA", "model2est", "mod"))
  if (length(given)) {
    stop("The responses and item parameters come from the eRm object, leave out ",
         paste0("'", given, "'", collapse = ", "), ".", call. = FALSE)
  }
  it <- erm_items(RMobj)
  est <- if (it$dichotomous) {
    do.call(PP_4pl, c(list(respm = it$X, thres = it$thres[2, ]), args))
  } else {
    do.call(PP_gpcm, c(list(respm = it$X, thres = it$thres, slopes = rep(1, ncol(it$X))), args))
  }
  rest <- if (is.null(it$group)) NULL else data.frame(group = it$group)
  ppass_build(est, rest, fitindices, SE, model = it$label, call = call, items = it$items)
}


# The estimation function for the item parameters in args: polytomous items go
# to PP_gpcm, with lowerA/upperA to PPall, dichotomous ones to PP_4pl.
ppass_fun <- function(args) {
  poly <- !is.null(args$thres) && any(normalize_thres(args$thres)$maxsc > 1)
  if (poly && (!is.null(args$lowerA) || !is.null(args$upperA))) {
    PPall
  } else if (poly) {
    PP_gpcm
  } else {
    PP_4pl
  }
}

# Model name for print/summary, from the prepared item parameters
ppass_label <- function(prep) {
  differs <- function(x, v) any(x != v, na.rm = TRUE)
  switch(prep$family,
    "4pl" = if (differs(prep$upperA, 1)) "4PL model"
            else if (differs(prep$lowerA, 0)) "3PL model"
            else if (differs(prep$slopes, 1)) "2PL model"
            else "1PL model",
    "gpcm" = if (differs(prep$slopes, 1)) "generalized partial credit model"
             else "partial credit model",
    "mixed 4PL/GPCM model")
}

# Column indices of the items in respdf
item_columns <- function(respdf, items) {
  nc <- ncol(respdf)
  if (identical(items, "all")) return(seq_len(nc))
  if (is.character(items)) {
    miss <- setdiff(items, colnames(respdf))
    if (length(miss)) stop("'items' names columns that 'respdf' does not have: ",
                           paste(miss, collapse = ", "), ".", call. = FALSE)
    return(match(items, colnames(respdf)))
  }
  if (!is.numeric(items) || !length(items) || anyNA(items) || any(items != round(items)) ||
      any(items < 1 | items > nc) || anyDuplicated(items)) {
    stop("'items' must be \"all\", column names or distinct column positions of 'respdf'.",
         call. = FALSE)
  }
  as.integer(items)
}

# Responses and thresholds of an eRm model. With time points or groups the
# parameters in betapar are ordered by time point, group, item and category,
# one block per virtual item; a person answers the virtual items of their group.
erm_items <- function(obj) {
  models <- c(RM = "Rasch model", LLTM = "linear logistic test model", RSM = "rating scale model",
              PCM = "partial credit model", LRSM = "linear rating scale model",
              LPCM = "linear partial credit model")
  if (is.null(obj$model) || !obj$model %in% names(models)) {
    stop("PPass knows the eRm models ", paste(names(models), collapse = ", "), ".", call. = FALSE)
  }
  X <- as.matrix(obj$X)
  mp <- if (is.null(obj$mpoints)) 1L else as.integer(obj$mpoints)
  ng <- if (is.null(obj$ngroups)) 1L else as.integer(obj$ngroups)
  gv <- if (length(obj$groupvec) == nrow(X)) as.integer(obj$groupvec) else rep(1L, nrow(X))
  k <- ncol(X) %/% mp
  dich <- obj$model %in% c("RM", "LLTM")

  ti <- rep(seq_len(mp), each = ng * k)
  gi <- rep(rep(seq_len(ng), each = k), mp)
  col <- (ti - 1L) * k + rep(seq_len(k), mp * ng)
  ncat <- if (dich) rep(1L, length(col)) else apply(X, 2, max, na.rm = TRUE)[col]
  beta <- obj$betapar
  if (sum(ncat) != length(beta)) {
    stop("The parameters of this eRm object do not match its responses.", call. = FALSE)
  }

  thres <- matrix(NA_real_, max(ncat) + 1L, length(col))
  thres[1, ] <- 0
  last <- cumsum(ncat)
  Xv <- matrix(NA_integer_, nrow(X), length(col))
  for (v in seq_along(col)) {
    b <- beta[last[v] - ncat[v] + seq_len(ncat[v])]
    thres[1 + seq_len(ncat[v]), v] <- -diff(c(0, b))
    Xv[gv == gi[v], v] <- X[gv == gi[v], col[v]]
  }
  cn <- if (is.null(colnames(X))) paste0("I", seq_len(ncol(X))) else colnames(X)
  colnames(Xv) <- paste0(cn[col], if (ng > 1) paste0(" g", gi))

  label <- sprintf("%s (eRm::%s)", models[[obj$model]], obj$model)
  if (mp > 1) label <- paste0(label, ", ", mp, " time points")
  if (ng > 1) label <- paste0(label, ", ", ng, " groups")
  items <- if (mp > 1 || ng > 1) paste(length(col), "virtual items") else paste(length(col), "items")
  list(X = Xv, thres = thres, dichotomous = dich, label = label, items = items,
       group = if (ng > 1) gv)
}

# The ppass result: person variables, estimates and person fit
ppass_build <- function(est, rest, fitindices, SE, model, call,
                        items = paste(ncol(est$model$X), "items")) {
  call[[1]] <- as.name("PPass")
  attributes(call) <- attributes(est$call)
  est$call <- call
  e <- est$estimates
  out <- data.frame(estimate = e$estimate, SE = e$SE, status = e$status)
  if (est$type %in% c("mle", "wle", "map")) {
    fit <- Pfit(pp = est, fitindices = fitindices, SE = SE)
    out <- cbind(out, do.call(cbind, unclass(fit)))
  } else {
    message("Person fit needs an estimate of type 'mle', 'wle' or 'map'; ",
            "PPass returns the ", est$type, " estimates only.")
    fitindices <- character(0)
  }
  if (!is.null(rest) && ncol(rest)) out <- cbind(rest, out)
  rownames(out) <- NULL
  structure(out, class = c("ppass", "data.frame"), ppeo = est,
            info = list(model = model, type = est$type, items = items,
                        fitindices = fitindices))
}

# TRUE if x still has all rows of the estimation in their order
ppass_intact <- function(x) {
  est <- attr(x, "ppeo")
  !is.null(est) && !is.null(attr(x, "info")) && "estimate" %in% names(x) &&
    identical(x$estimate, est$estimates$estimate)
}

# The column used to flag persons and its critical value
ppass_flag <- function(x, alpha) {
  sp <- intersect(c("lzstar", "lz", "in_t", "ou_t"), names(x))[1]
  z <- stats::qnorm(1 - alpha / 2)
  if (is.na(sp)) return(NULL)
  list(column = sp, z = z, flagged = which(is.finite(x[[sp]]) & abs(x[[sp]]) > z))
}

ppass_header <- function(x, alpha) {
  info <- attr(x, "info")
  cat("Person assessment:", nrow(x), "persons,", info$items, "\n")
  cat("Model:", info$model, "|", toupper(info$type), "estimates\n")
  st <- table(x$status)
  st <- st[st > 0]
  cat("Status:", paste(st, names(st), collapse = ", "), "\n")
  if (length(info$fitindices)) cat("Person fit:", paste(info$fitindices, collapse = ", "), "\n")
  fl <- ppass_flag(x, alpha)
  if (!is.null(fl)) {
    cat(sprintf("Flagged (|%s| > %.2f): %d persons\n", fl$column, fl$z, length(fl$flagged)))
  }
}

#' @param x,object A result of \code{PPass()}.
#' @param n Number of persons to print.
#' @param digits Number of significant digits to print.
#' @rdname PPass
#' @export
print.ppass <- function(x, n = 10, digits = 3, ...) {
  if (!ppass_intact(x)) {
    print(as.data.frame(x), digits = digits, ...)
    return(invisible(x))
  }
  ppass_header(x, 0.05)
  cat("\n")
  # the person variables (at most 2), the estimate and the main fit statistics
  first <- match("estimate", names(x))
  main <- c("estimate", "SE", "status", "lz", "lzstar", "infit", "in_t", "outfit", "ou_t",
            "lz_se", "lzs_se", "in_se", "ou_se")
  show <- c(names(x)[seq_len(min(2, first - 1))], intersect(main, names(x)))
  print(utils::head(as.data.frame(x)[, show, drop = FALSE], n), digits = digits, ...)
  if (nrow(x) > n) cat("...", nrow(x) - n, "more persons\n")
  hidden <- setdiff(names(x), show)
  if (length(hidden)) cat("Not shown:", paste(hidden, collapse = ", "), "\n")
  invisible(x)
}

#' @param alpha Significance level for flagging persons: \eqn{|z| > z_{1-\alpha/2}} for lz*, lz, \code{in_t} or \code{ou_t} (the first one computed).
#' @rdname PPass
#' @export
summary.ppass <- function(object, alpha = 0.05, digits = 3, ...) {
  check_alpha(alpha)
  if (!ppass_intact(object)) {
    stop("'object' is not a complete result of PPass (rows were removed or reordered).",
         call. = FALSE)
  }
  ppass_header(object, alpha)
  ok <- object$status == "ok"
  cat("\nEstimates (status ok):\n")
  est <- summary(object$estimate[ok])
  print(est, digits = digits)
  res <- list(estimates = est, status = table(object$status))

  vals <- lapply(stats::setNames(nm = intersect(c("lz", "lzstar", "infit", "in_t", "outfit", "ou_t"),
                                                names(object))), function(sp) object[[sp]])
  if (length(vals)) {
    res$fit <- fit_table(vals, stats::qnorm(1 - alpha / 2))
    cat("\nPerson fit:\n")
    print(res$fit, digits = digits, row.names = FALSE, ...)
    fl <- ppass_flag(object, alpha)
    pv <- utils::head(seq_len(match("estimate", names(object)) - 1L), 2)
    res$flagged <- data.frame(person = fl$flagged,
                              as.data.frame(object)[fl$flagged, pv, drop = FALSE],
                              estimate = object$estimate[fl$flagged],
                              value = object[[fl$column]][fl$flagged])
    names(res$flagged)[ncol(res$flagged)] <- fl$column
    res$flagged <- res$flagged[order(res$flagged[[fl$column]]), , drop = FALSE]
    rownames(res$flagged) <- NULL
    if (nrow(res$flagged)) {
      cat("\nFlagged persons (", fl$column, "):\n", sep = "")
      print(utils::head(res$flagged, 20), digits = digits, row.names = FALSE, ...)
      if (nrow(res$flagged) > 20) cat("...", nrow(res$flagged) - 20, "more\n")
    }
  }
  invisible(res)
}

#' @param row.names,optional Ignored.
#' @rdname PPass
#' @export
as.data.frame.ppass <- function(x, row.names = NULL, optional = FALSE, ...) {
  attr(x, "ppeo") <- NULL
  attr(x, "info") <- NULL
  class(x) <- "data.frame"
  x
}

# The estimation object of a complete PPass result
ppass_estimate <- function(x) {
  if (!ppass_intact(x)) {
    stop("'estobj' is not a complete result of PPass (rows were removed or reordered).",
         call. = FALSE)
  }
  attr(x, "ppeo")
}

#' @rdname Jkpp
#' @method JKpp ppass
#' @export
JKpp.ppass <- function(estobj, ...) JKpp(ppass_estimate(estobj), ...)

#' @rdname PV
#' @method PV ppass
#' @export
PV.ppass <- function(estobj, ...) PV(ppass_estimate(estobj), ...)

Try the PP package in your browser

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

PP documentation built on Oct. 15, 2026, 1:06 a.m.