diff --git a/NEWS.md b/NEWS.md index bf6e9168..d1a61d5c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -3,6 +3,21 @@ Version: 4.0.0 ggRandomForests v4.0.0 (development) ==================================== +* `gg_partial_varpro(scale = "prob")` now restores each subject's level + before averaging, so the curve is the expected proportion it is documented + as. `partialpro()` fits each subject's curve separately but returns every + row at the cohort-mean intercept, keeping only the subject's slope. That put + every subject at the average log-odds, so `"prob"` nearly matched + `"prob_typical"` and missed the proportion it claims to estimate. Each + subject's curve is now shifted to pass through its own out-of-bag log-odds + at its observed value, and the shape `partialpro()` fitted is kept. On + simulated data with widely spread subjects, this cut the error against the + true partial dependence from 0.105 to 0.033 (8 of 8 seeds). **`"prob"` + curves change**, most on heterogeneous cohorts. The restoration needs + `object`; without it `"prob"` warns and returns the old curve. It is also + skipped when `...` passes a custom `learner` or `newdata`, and for binary + variables, which `partialpro()` already returns per subject. The provenance + records `anchored`. * `gg_partial_varpro(scale = "surv")` no longer returns survival above 1. `partialpro()` smooths each case's S(tau) with an unbounded polynomial, so where survival is near 1 (early horizons, before most events) the averaged @@ -71,11 +86,11 @@ ggRandomForests v4.0.0 (development) They are different estimands and they disagree. The inverse logit is concave above zero and convex below it, so by Jensen `"prob"` is pulled toward 0.5 at - both ends, by more the more heterogeneous the cohort. Where the per-subject - log-odds carry an SD near 4.5, a point reading 0.96 under `"prob_typical"` - reads 0.74 under `"prob"` -- large enough to change how a figure is read, so - the choice should be deliberate. A figure captioned as a percentage of - patients wants `"prob"`. `?gg_partial_varpro` sets out both. + both ends, by more the more heterogeneous the cohort. In a simulation with + per-subject log-odds SD near 4, a point reading 0.13 under `"prob_typical"` + reads 0.35 under `"prob"` (true value 0.38) -- large enough to change how a + figure is read, so the choice should be deliberate. A figure captioned as a + percentage of patients wants `"prob"`. `?gg_partial_varpro` sets out both. The distinction applies only to the `continuous` frame; the `categorical` frame keeps values unaveraged, so both scales return the same numbers there. diff --git a/R/gg_partial_varpro.R b/R/gg_partial_varpro.R index 987ee265..a6e637c0 100644 --- a/R/gg_partial_varpro.R +++ b/R/gg_partial_varpro.R @@ -258,7 +258,28 @@ #' back-transforms to probability \eqn{P(Y = \mathrm{target})}, \code{"odds"} to #' the odds, and \code{"logodds"} keeps the raw scale. The back-transform is #' applied per observation \emph{before} averaging, so the curve is the mean -#' predicted probability, not the probability of the mean log-odds. The +#' predicted probability, not the probability of the mean log-odds. +#' +#' **Restoring each subject's level (scale = "prob"):** \code{partialpro} fits +#' each subject's curve separately but returns every row at the cohort-mean +#' intercept, keeping only the subject's own slope. On the log-odds scale that +#' sets every subject to the average log-odds, and averaging per-subject +#' probabilities then no longer gives the expected proportion. So for +#' \code{"prob"}, \code{gg_partial_varpro} shifts each subject's curve to pass +#' through that subject's own out-of-bag log-odds at its observed value of the +#' variable, and then back-transforms and averages. The shape +#' \code{partialpro} fitted is unchanged. This needs \code{object}, the +#' classification fit, and is recorded as \code{anchored} in the provenance. +#' Without \code{object}, or when \code{...} passes \code{partialpro} a custom +#' \code{learner} or \code{newdata}, the curve is left as \code{partialpro} +#' returned it (with a warning when \code{object} is missing). A precomputed +#' \code{part_dta} is anchored whenever \code{object} is supplied, which +#' assumes it came from \code{partialpro(object)} with its default learner; a +#' \code{part_dta} built with your own learner should be passed with +#' \code{scale = "logodds"} instead. A subject that was never out of bag takes +#' its in-bag prediction as its anchor. Binary variables +#' are never shifted, because \code{partialpro} already returns per-subject +#' levels for them. The #' \code{causal} contrast is shown only on \code{"logodds"} (see #' \code{\link{plot.gg_partial_varpro}}). #' @@ -285,9 +306,13 @@ #' Jensen's inequality \code{"prob"} is pulled toward \eqn{0.5} relative to #' \code{"prob_typical"}, at both ends of the curve. The gap widens with the #' spread of per-subject log-odds, and on a heterogeneous cohort it is not -#' small: where the per-subject log-odds carry an SD near 4.5, a point reading -#' \eqn{0.96} under \code{"prob_typical"} reads \eqn{0.74} under -#' \code{"prob"}. +#' small. In a simulation where a second variable spreads the subjects' +#' log-odds to an SD near 4, one point reads \eqn{0.13} under +#' \code{"prob_typical"} and \eqn{0.35} under \code{"prob"}, against a true +#' partial dependence of \eqn{0.38}. Before the level restoration described +#' above, \code{"prob"} read \eqn{0.14} there: with every subject at the mean +#' log-odds, the two scales nearly coincide. \code{"prob_typical"} uses +#' \code{partialpro}'s values as returned. #' #' Which to report is a question about the claim, not about the code. If the #' sentence is "what fraction of these patients would wean", that is @@ -381,7 +406,8 @@ #' ) #' ## The two probability scales differ by the ORDER of averaging and #' ## back-transform, and disagree whenever subjects are heterogeneous. -#' pa <- gg_partial_varpro(mock_data, scale = "prob") +#' ## Mock data has no fit, so "prob" cannot restore subject levels and warns. +#' pa <- suppressWarnings(gg_partial_varpro(mock_data, scale = "prob")) #' pt <- gg_partial_varpro(mock_data, scale = "prob_typical") #' head(data.frame(prob = pa$continuous$parametric, #' prob_typical = pt$continuous$parametric)) @@ -479,6 +505,9 @@ gg_partial_varpro <- function(part_dta = NULL, ## 'value_scale' differs from the reported 'scale' only for surv recomputed ## here: those values are S(tau) from our learner and get clamped to [0, 1]. value_scale <- scale + ## '...' reaches partialpro() only when part_dta is computed here; otherwise + ## it is warned as ignored above and must not change the result. + pp_dots <- if (is.null(part_dta)) names(list(...)) else character(0) if (is.null(part_dta)) { if (scale == "surv") value_scale <- "surv_learner" learner <- switch(scale, @@ -506,6 +535,10 @@ gg_partial_varpro <- function(part_dta = NULL, prov <- .varpro_provenance(object, scale, time, path = "A", target = .varpro_target(object, list(...))) + anc <- .anchor_prob_scale(part_dta, object, scale, prov$target, pp_dots) + part_dta <- anc$part_dta + prov$anchored <- anc$anchored + dfs <- .build_varpro_dfs(part_dta, nvars, cat_limit, value_scale) continuous <- dfs$continuous categorical <- dfs$categorical @@ -738,6 +771,76 @@ gg_partial_varpro <- function(part_dta = NULL, ## Classification target class label: the `target` passed through ... if any, ## else the last factor level of the response (partialpro's default target). ## NA for non-classification fits or when only part_dta is supplied. +## partialpro() fits each case's curve separately but returns every case at the +## cohort-mean intercept (yhat.par = global.mean + B %*% x^k). On the log-odds +## scale that pins every case to the mean log-odds, so "prob"'s per-case +## plogis-then-average no longer gives the expected proportion of the cohort. +## Put each case's level back: shift its curve so it passes through the case's +## own OOB log-odds at its observed x. The shape (the slopes partialpro kept) is +## untouched. Binary variables are skipped: partialpro returns per-case level +## means for them, without the swap. +#' @keywords internal +.anchor_varpro_levels <- function(part_dta, anchor) { + for (k in seq_along(part_dta)) { + feat <- part_dta[[k]] + if (length(unique(feat$xorg)) == 2L || is.null(feat$case)) next + offset <- vapply(seq_along(feat$case), function(i) { + r <- feat$case[i] + anchor[r] - stats::approx(feat$xvirtual, feat$yhat.par[i, ], feat$xorg[r], + rule = 2)$y + }, numeric(1)) + feat$yhat.par <- feat$yhat.par + offset + feat$yhat.nonpar <- feat$yhat.nonpar + offset + part_dta[[k]] <- feat + } + part_dta +} + +## "prob" averages per-case probabilities, which needs each case's own level; +## see .anchor_varpro_levels(). The anchors are the forest's OOB predictions, +## so a caller's own learner or newdata puts the curves on a footing the +## anchors don't share, and those are left as partialpro returned them. +#' @keywords internal +.anchor_prob_scale <- function(part_dta, object, scale, target, dot_names) { + if (scale != "prob") return(list(part_dta = part_dta, anchored = FALSE)) + if (!is.null(object$rf) && identical(object$family, "class") && + !any(c("learner", "newdata") %in% dot_names)) { + return(list(part_dta = .anchor_varpro_levels( + part_dta, .varpro_oob_logodds(object, target)), + anchored = TRUE)) + } + if (is.null(object)) { + warning("gg_partial_varpro: scale = 'prob' without 'object' cannot ", + "restore each case's level, so the curve averages cases pinned ", + "to the cohort-mean log-odds and is not the expected ", + "proportion. Supply 'object' (the classification varpro fit).", + call. = FALSE) + } + list(part_dta = part_dta, anchored = FALSE) +} + +## Per-row OOB log-odds of the target class, clamped at 0.001 as partialpro's +## own mylogodds() is, so the anchor and the curves share a scale. A case that +## was never out of bag (common with few trees) has no OOB prediction; it takes +## its in-bag one instead, since an NA anchor would drop the case from the +## average without saying so. +#' @keywords internal +.varpro_oob_logodds <- function(object, target) { + pr <- randomForestSRC::predict.rfsrc(object$rf, perf.type = "none") + pick <- function(p) { + if (is.null(p) || is.null(dim(p))) return(p) + ## 'target' is a class label, or an index when passed as a number. + col <- if (target %in% colnames(p)) target else as.integer(target) + p[, col] + } + p <- pick(pr$predicted.oob) + inb <- pick(pr$predicted) + if (is.null(p)) p <- inb + miss <- is.na(p) + p[miss] <- inb[miss] + stats::qlogis(pmin(pmax(p, 1e-3), 1 - 1e-3)) +} + #' @keywords internal .varpro_target <- function(object, dots) { if (is.null(object) || !identical(object$family, "class")) @@ -972,8 +1075,10 @@ gg_partial_varpro <- function(part_dta = NULL, ## plogis is concave above 0 and convex below it, so by Jensen these are not ## the same curve: "prob" is pulled toward 0.5 relative to "prob_typical", and ## the gap widens with the spread of per-subject log-odds. On a heterogeneous -## cohort it is large -- a point where "prob_typical" reads 0.96 can read 0.74 -## under "prob" when the per-subject log-odds carry an SD near 4.5. +## cohort it is large: 0.13 under "prob_typical" against 0.35 under "prob" at +## a per-subject log-odds SD near 4 (see the roxygen). "prob" needs the +## per-case levels .anchor_varpro_levels() restores, or it collapses toward +## "prob_typical". #' @keywords internal .varpro_column_summary <- function(mat, scale) { if (identical(scale, "prob_typical")) { diff --git a/R/plot.gg_partial_varpro.R b/R/plot.gg_partial_varpro.R index 2a575341..02bbd67b 100644 --- a/R/plot.gg_partial_varpro.R +++ b/R/plot.gg_partial_varpro.R @@ -230,7 +230,8 @@ #' plot(pp, type = "parametric", panels = spec) & ggplot2::theme_minimal() #' #' ## complement = TRUE reads a failure model as its success probability. -#' pp_prob <- gg_partial_varpro(mock_data, scale = "prob") +#' ## (Mock data has no fit to restore subject levels from, hence the warning.) +#' pp_prob <- suppressWarnings(gg_partial_varpro(mock_data, scale = "prob")) #' plot(pp_prob, type = "parametric", complement = TRUE) #' #' @importFrom ggplot2 .data ggplot aes geom_line geom_boxplot facet_wrap labs diff --git a/man/gg_partial_varpro.Rd b/man/gg_partial_varpro.Rd index f82f5fa4..f848ed9b 100644 --- a/man/gg_partial_varpro.Rd +++ b/man/gg_partial_varpro.Rd @@ -245,7 +245,28 @@ of the target class. \code{scale = "prob"} (the classification default) back-transforms to probability \eqn{P(Y = \mathrm{target})}, \code{"odds"} to the odds, and \code{"logodds"} keeps the raw scale. The back-transform is applied per observation \emph{before} averaging, so the curve is the mean -predicted probability, not the probability of the mean log-odds. The +predicted probability, not the probability of the mean log-odds. + +\strong{Restoring each subject's level (scale = "prob"):} \code{partialpro} fits +each subject's curve separately but returns every row at the cohort-mean +intercept, keeping only the subject's own slope. On the log-odds scale that +sets every subject to the average log-odds, and averaging per-subject +probabilities then no longer gives the expected proportion. So for +\code{"prob"}, \code{gg_partial_varpro} shifts each subject's curve to pass +through that subject's own out-of-bag log-odds at its observed value of the +variable, and then back-transforms and averages. The shape +\code{partialpro} fitted is unchanged. This needs \code{object}, the +classification fit, and is recorded as \code{anchored} in the provenance. +Without \code{object}, or when \code{...} passes \code{partialpro} a custom +\code{learner} or \code{newdata}, the curve is left as \code{partialpro} +returned it (with a warning when \code{object} is missing). A precomputed +\code{part_dta} is anchored whenever \code{object} is supplied, which +assumes it came from \code{partialpro(object)} with its default learner; a +\code{part_dta} built with your own learner should be passed with +\code{scale = "logodds"} instead. A subject that was never out of bag takes +its in-bag prediction as its anchor. Binary variables +are never shifted, because \code{partialpro} already returns per-subject +levels for them. The \code{causal} contrast is shown only on \code{"logodds"} (see \code{\link{plot.gg_partial_varpro}}). @@ -272,9 +293,13 @@ These are different estimands and they do not agree. Jensen's inequality \code{"prob"} is pulled toward \eqn{0.5} relative to \code{"prob_typical"}, at both ends of the curve. The gap widens with the spread of per-subject log-odds, and on a heterogeneous cohort it is not -small: where the per-subject log-odds carry an SD near 4.5, a point reading -\eqn{0.96} under \code{"prob_typical"} reads \eqn{0.74} under -\code{"prob"}. +small. In a simulation where a second variable spreads the subjects' +log-odds to an SD near 4, one point reads \eqn{0.13} under +\code{"prob_typical"} and \eqn{0.35} under \code{"prob"}, against a true +partial dependence of \eqn{0.38}. Before the level restoration described +above, \code{"prob"} read \eqn{0.14} there: with every subject at the mean +log-odds, the two scales nearly coincide. \code{"prob_typical"} uses +\code{partialpro}'s values as returned. Which to report is a question about the claim, not about the code. If the sentence is "what fraction of these patients would wean", that is @@ -416,7 +441,8 @@ mock_data <- list( ) ## The two probability scales differ by the ORDER of averaging and ## back-transform, and disagree whenever subjects are heterogeneous. -pa <- gg_partial_varpro(mock_data, scale = "prob") +## Mock data has no fit, so "prob" cannot restore subject levels and warns. +pa <- suppressWarnings(gg_partial_varpro(mock_data, scale = "prob")) pt <- gg_partial_varpro(mock_data, scale = "prob_typical") head(data.frame(prob = pa$continuous$parametric, prob_typical = pt$continuous$parametric)) diff --git a/man/plot.gg_partial_varpro.Rd b/man/plot.gg_partial_varpro.Rd index e4ebace8..cc0a5d7d 100644 --- a/man/plot.gg_partial_varpro.Rd +++ b/man/plot.gg_partial_varpro.Rd @@ -270,7 +270,8 @@ plot(pp, type = c("parametric", "nonparametric")) plot(pp, type = "parametric", panels = spec) & ggplot2::theme_minimal() ## complement = TRUE reads a failure model as its success probability. -pp_prob <- gg_partial_varpro(mock_data, scale = "prob") +## (Mock data has no fit to restore subject levels from, hence the warning.) +pp_prob <- suppressWarnings(gg_partial_varpro(mock_data, scale = "prob")) plot(pp_prob, type = "parametric", complement = TRUE) } diff --git a/tests/testthat/test_gg_partial_varpro.R b/tests/testthat/test_gg_partial_varpro.R index 8966f88f..047d2228 100644 --- a/tests/testthat/test_gg_partial_varpro.R +++ b/tests/testthat/test_gg_partial_varpro.R @@ -291,7 +291,7 @@ test_that(".is_bounded_scale flags prob/odds/surv only", { ## ── v3.3.0 conversion in the extractor (mean of probabilities) ─────────────── test_that("gg_partial_varpro: scale='prob' is mean of plogis, causal NA", { d <- make_mock_vpro_data() - res <- gg_partial_varpro(d, scale = "prob") + res <- suppressWarnings(gg_partial_varpro(d, scale = "prob")) # no object age <- res$continuous[res$continuous$name == "age", ] expected <- colMeans(stats::plogis(d$age$yhat.par), na.rm = TRUE) expect_equal(age$parametric, expected) @@ -335,7 +335,7 @@ test_that(".partial_varpro_ylabel: prob/odds/logodds/surv labels", { ## ── v3.3.0 plot: causal hidden on bounded scales ───────────────────────────── test_that("plot.gg_partial_varpro: bounded scale drops causal, warns if asked", { d <- make_mock_vpro_data() - res <- gg_partial_varpro(d, nvars = 1, scale = "prob") + res <- suppressWarnings(gg_partial_varpro(d, nvars = 1, scale = "prob")) expect_s3_class(plot(res), "ggplot") expect_warning(plot(res, type = "causal"), regexp = "causal") }) @@ -551,7 +551,7 @@ test_that("gg_partial_varpro: scale='surv' stays in [0,1] near S = 1", { r <- suppressWarnings(gg_partial_varpro(object = vp, scale = "surv", time = tau)) vals <- c(r$continuous$parametric, r$continuous$nonparametric, - r$categorical$parametric, r$categorical$nonparametric) + r$categorical[["parametric"]], r$categorical[["nonparametric"]]) expect_true(length(vals) > 0L) expect_true(all(vals >= 0 & vals <= 1)) }) @@ -586,6 +586,118 @@ test_that("surv_learner: continuous curve is averaged, then clamped", { expect_equal(sex$parametric, pmin(pmax(as.vector(d$sex$yhat.par), 0), 1)) }) +## ── 4.0.0 "prob" anchored at each case's own level ────────────────────────── +## partialpro returns every case at the cohort-mean intercept; "prob" restores +## each case's level from the forest's OOB log-odds before plogis-then-average. +test_that(".anchor_varpro_levels passes each curve through its anchor", { + d <- make_mock_vpro_data() + d$age$case <- seq_len(nrow(d$age$yhat.par)) + d$sex$case <- seq_len(nrow(d$sex$yhat.par)) + anchor <- seq(-2, 2, length.out = nrow(d$age$yhat.par)) + out <- ggRandomForests:::.anchor_varpro_levels(d, anchor) + at_obs <- vapply(seq_along(anchor), function(i) { + stats::approx(out$age$xvirtual, out$age$yhat.par[i, ], d$age$xorg[i], + rule = 2)$y + }, numeric(1)) + expect_equal(at_obs, anchor) + ## Shape is kept: each row moves by one constant. + shift <- out$age$yhat.par - d$age$yhat.par + expect_equal(apply(shift, 1, stats::sd), rep(0, nrow(shift))) + expect_equal(out$age$yhat.nonpar - d$age$yhat.nonpar, shift) + expect_equal(out$age$yhat.causal, d$age$yhat.causal) + ## Binary variables carry per-case levels already and are left alone. + expect_identical(out$sex, d$sex) +}) + +test_that("gg_partial_varpro: 'prob' is anchored and tracks the true PD", { + skip_on_cran() + skip_if_not_installed("varPro") + set.seed(3) + n <- 300 + d <- data.frame(x1 = stats::rnorm(n), x2 = stats::rnorm(n), + x3 = stats::rnorm(n)) + ## x2 spreads the cases' levels widely; that is where the swap bites. + d$y <- factor(stats::rbinom(n, 1, stats::plogis(1.5 * d$x1 + 3 * d$x2))) + vp <- varPro::varpro(y ~ ., d, ntree = 100) + set.seed(1) + pp <- varPro::partialpro(vp, xvar.names = "x1") + r <- gg_partial_varpro(part_dta = pp, object = vp, scale = "prob") + expect_true(attr(r, "provenance")$anchored) + un <- suppressWarnings(gg_partial_varpro(part_dta = pp, scale = "prob")) + grid <- stats::quantile(d$x1, seq(0.05, 0.95, by = 0.1)) + truth <- vapply(grid, function(g) mean(stats::plogis(1.5 * g + 3 * d$x2)), + numeric(1)) + err <- function(res) { + cont <- res$continuous + mean(abs(stats::approx(cont$variable, cont$parametric, grid, + rule = 2)$y - truth)) + } + expect_lt(err(r), err(un)) + expect_lt(err(r), 0.06) +}) + +test_that("gg_partial_varpro: 'prob' without object warns and is not anchored", { + expect_warning(res <- gg_partial_varpro(make_mock_vpro_data(), + scale = "prob"), + "cannot restore each case's level") + expect_false(attr(res, "provenance")$anchored) +}) + +test_that("gg_partial_varpro: a custom learner in ... is not anchored", { + skip_on_cran() + skip_if_not_installed("varPro") + set.seed(4) + dat <- data.frame(y = factor(rep(c("a", "b"), 60)), + x1 = stats::rnorm(120), x2 = stats::rnorm(120)) + vp <- varPro::varpro(y ~ ., dat, ntree = 40, nvar = 2) + lrn <- function(newx) { + if (missing(newx)) newx <- vp$x + randomForestSRC::predict.rfsrc(vp$rf, newx, perf.type = "none")$predicted + } + r <- suppressMessages(gg_partial_varpro(object = vp, scale = "prob", + nvars = 1, learner = lrn)) + expect_false(attr(r, "provenance")$anchored) +}) + +test_that("gg_partial_varpro: ignored ... cannot switch anchoring off", { + skip_on_cran() + skip_if_not_installed("varPro") + set.seed(6) + ## x1 carries real signal, so partialpro() reliably returns it. On a pure + ## noise outcome it can come back empty (seen on R 4.5), and part_dta = NULL + ## would then mean "compute it", which is not the path under test. + x1 <- stats::rnorm(120) + dat <- data.frame(y = factor(stats::rbinom(120, 1, stats::plogis(2 * x1))), + x1 = x1, x2 = stats::rnorm(120)) + vp <- varPro::varpro(y ~ ., dat, ntree = 40, nvar = 2) + set.seed(1) + pp <- varPro::partialpro(vp, xvar.names = "x1") + expect_true("x1" %in% names(pp)) + base <- gg_partial_varpro(part_dta = pp, object = vp, scale = "prob") + ## With part_dta supplied, '...' is reported as ignored, so it must be. + r <- suppressWarnings(gg_partial_varpro(part_dta = pp, object = vp, + scale = "prob", + learner = function(newx) 0)) + expect_true(attr(r, "provenance")$anchored) + expect_equal(r$continuous, base$continuous) +}) + +test_that(".varpro_oob_logodds keeps never-OOB cases via in-bag predictions", { + skip_on_cran() + skip_if_not_installed("varPro") + set.seed(1) + d <- data.frame(y = factor(stats::rbinom(200, 1, 0.5)), + x1 = stats::rnorm(200), x2 = stats::rnorm(200)) + vp <- varPro::varpro(y ~ ., d, ntree = 5) + pr <- randomForestSRC::predict.rfsrc(vp$rf, perf.type = "none") + expect_true(anyNA(pr$predicted.oob)) # the case being tested + a <- ggRandomForests:::.varpro_oob_logodds(vp, "1") + expect_false(anyNA(a)) + ok <- !is.na(pr$predicted.oob[, "1"]) + expect_equal(stats::plogis(a[ok]), + pmin(pmax(pr$predicted.oob[ok, "1"], 1e-3), 1 - 1e-3)) +}) + test_that("gg_partial_varpro: precomputed part_dta on 'surv' is not clamped", { ## A precomputed part_dta carries only the label; its values may not be on ## the S scale at all, so they pass through untouched. diff --git a/vignettes/explainability.qmd b/vignettes/explainability.qmd index 87f412c1..93ed5aa6 100644 --- a/vignettes/explainability.qmd +++ b/vignettes/explainability.qmd @@ -328,9 +328,16 @@ and the *order* of those two steps is a modelling choice: once. That is the probability for a subject at the mean log-odds. They disagree, and by more the more heterogeneous your cohort is, because the -inverse logit bends. On a real fit whose per-subject log-odds carried a -standard deviation near 4.5, a point reading 0.96 under `"prob_typical"` read -0.74 under `"prob"`. A figure captioned as a percentage of patients wants +inverse logit bends. In a simulation where a second variable spreads the +subjects' log-odds to a standard deviation near 4, one point reads 0.13 under +`"prob_typical"` and 0.35 under `"prob"`, against a true partial dependence of +0.38. A figure captioned as a percentage of patients wants `"prob"`. + +One catch makes `"prob"` depend on the fit. `partialpro()` returns every +subject at the cohort-mean level and keeps only each subject's slope, which on +its own would put every subject at the average log-odds and make the two +scales nearly agree. `gg_partial_varpro()` restores each subject's level from +the forest's out-of-bag predictions, so pass `object =` whenever you want `"prob"`. See `?gg_partial_varpro` for the full argument. ## Which one do you reach for? diff --git a/vignettes/varpro_precomputed.rds b/vignettes/varpro_precomputed.rds index 4ec86acb..7d759a9b 100644 Binary files a/vignettes/varpro_precomputed.rds and b/vignettes/varpro_precomputed.rds differ