Skip to content

Commit b79f6fa

Browse files
authored
Merge pull request #70 from ehrlinger/fix/empty-figure-rfsrc-data-transforms
Fix empty-figure bugs in gg_partial_rfsrc (release v2.7.1)
2 parents 5468748 + 3216c92 commit b79f6fa

8 files changed

Lines changed: 454 additions & 37 deletions

File tree

DESCRIPTION

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
Package: ggRandomForests
22
Type: Package
33
Title: Visually Exploring Random Forests
4-
Version: 2.7.0.9001
5-
Date: 2026-03-27
4+
Version: 2.7.1
5+
Date: 2026-04-27
66
Authors@R: person("John", "Ehrlinger",
77
role = c("aut", "cre"),
88
email = "john.ehrlinger@gmail.com")

NEWS.md

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,33 @@
11
Package: ggRandomForests
2-
Version: 2.8.0
2+
Version: 2.7.1
33

4-
ggRandomForests v2.8.0
4+
ggRandomForests v2.7.1
5+
=====================
6+
* Fix `gg_partial_rfsrc()` for survival forests: `partial.rfsrc()` was being
7+
called without `partial.type`, causing a zero-length comparison
8+
(`if (partial.type == "rel.freq") ...`) inside the C-level prediction
9+
routine and aborting the call. Survival forests now pass
10+
`partial.type = "surv"` (default; configurable via the new `partial.type`
11+
argument accepting `"surv"`, `"chf"`, or `"mort"`). This unblocks the
12+
`partial-dep` chunk in the survival vignette.
13+
* Fix `gg_partial_rfsrc()` for survival forests with multiple
14+
`partial.time` values: `get.partial.plot.data()` returns yhat as an
15+
`[length(partial.values) x length(partial.time)]` matrix, but the previous
16+
code assumed a vector and crashed on column-mismatch when assigning
17+
`time`. The result is now reshaped to long form so each `(x, time)` pair
18+
is a single row.
19+
* Improve `plot.gg_partial_rfsrc()` survival layout: predictor value is now
20+
on the x-axis with one curve per (rounded) time point coloured by `Time`,
21+
faceted by variable name. The previous default put time on the x-axis
22+
and one curve per predictor value, producing a saturated legend with
23+
dozens of nearly-identical lines.
24+
* Add `tests/testthat/test_plot_layer_data.R`: regression suite that uses
25+
`ggplot2::layer_data()` to verify each `plot.gg_*()` method renders
26+
non-empty layers for every supported forest family. Catches the
27+
empty-figure class of bug (transform/plot column-name mismatch) without
28+
requiring visual inspection.
29+
30+
ggRandomForests v2.7.0
531
=====================
632
* S3 design overhaul: `gg_partial()`, `gg_partialpro()`, and
733
`gg_partial_rfsrc()` now stamp their return values with S3 classes

R/gg_partial_rfsrc.R

Lines changed: 49 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@
4747
#' snapped to the nearest entry in \code{rf_model$time.interest} — see the
4848
#' \strong{Survival forests} section below. When \code{NULL} (default),
4949
#' three quartile points of \code{time.interest} are used.
50+
#' @param partial.type Character; type of predicted value for survival
51+
#' forests, passed through to \code{\link[randomForestSRC]{partial.rfsrc}}.
52+
#' One of \code{"surv"} (default), \code{"chf"}, or \code{"mort"}. Ignored
53+
#' for non-survival forests. \code{partial.rfsrc()} requires a non-\code{NULL}
54+
#' value for survival families; supplying it here avoids a cryptic
55+
#' \dQuote{argument is of length zero} error from the underlying C code.
5056
#' @param cat_limit Variables with fewer than \code{cat_limit} unique values in
5157
#' \code{newx} are treated as categorical; all others are continuous.
5258
#' Defaults to 10.
@@ -89,6 +95,7 @@ gg_partial_rfsrc <- function(rf_model,
8995
xvar2.name = NULL,
9096
newx = NULL,
9197
partial.time = NULL,
98+
partial.type = c("surv", "chf", "mort"),
9299
cat_limit = 10,
93100
n_eval = 25) {
94101
if (is.null(newx)) {
@@ -112,17 +119,28 @@ gg_partial_rfsrc <- function(rf_model,
112119
is_surv <- !is.null(rf_model$family) && grepl("surv", rf_model$family)
113120
if (is_surv) {
114121
partial.time <- snap_partial_time(rf_model, partial.time)
122+
# partial.rfsrc() requires a non-NULL partial.type for survival forests;
123+
# NULL triggers a zero-length comparison inside the C code.
124+
partial.type <- match.arg(partial.type)
125+
} else {
126+
partial.type <- NULL
115127
}
116128

117129
if (is.null(xvar2.name)) {
118130
pdta <- partial_no_group(xvar.names, newx, rf_model,
119-
cat_limit, n_eval, is_surv, partial.time)
131+
cat_limit, n_eval, is_surv, partial.time,
132+
partial.type)
120133
} else {
121134
pdta <- partial_with_group(xvar.names, xvar2.name, newx, rf_model,
122-
cat_limit, n_eval, is_surv, partial.time)
135+
cat_limit, n_eval, is_surv, partial.time,
136+
partial.type)
123137
}
124138

125-
split_partial_result(do.call("rbind", pdta))
139+
result <- split_partial_result(do.call("rbind", pdta))
140+
# Carry partial.type so plot.gg_partial_rfsrc() can pick the correct
141+
# y-axis label (Survival / CHF / Mortality).
142+
attr(result, "partial.type") <- partial.type
143+
result
126144
}
127145

128146
## ---- unexported helpers -------------------------------------------------------
@@ -184,7 +202,7 @@ make_eval_grid <- function(xname, newx, cat_limit, n_eval) {
184202

185203
## Thin wrapper around partial.rfsrc that builds the argument list.
186204
call_partial_rfsrc <- function(rf_model, xname, xval,
187-
is_surv, partial.time,
205+
is_surv, partial.time, partial.type,
188206
xvar2.name = NULL, x2val = NULL) {
189207
args <- list(
190208
object = rf_model,
@@ -197,44 +215,62 @@ call_partial_rfsrc <- function(rf_model, xname, xval,
197215
}
198216
if (is_surv) {
199217
args$partial.time <- partial.time
218+
args$partial.type <- partial.type
200219
}
201220
do.call(randomForestSRC::partial.rfsrc, args)
202221
}
203222

204223
## Process a single predictor variable and return a tidy data.frame (or NULL).
205224
partial_one_var <- function(xname, newx, rf_model,
206225
cat_limit, n_eval, is_surv, partial.time,
226+
partial.type,
207227
xvar2.name = NULL, x2val = NULL) {
208228
eg <- make_eval_grid(xname, newx, cat_limit, n_eval)
209229
if (is.null(eg)) return(NULL)
210230
xval <- eg$xval
211231
gr <- eg$categorical
212232
partial.obj <- call_partial_rfsrc(rf_model, xname, xval,
213-
is_surv, partial.time,
233+
is_surv, partial.time, partial.type,
214234
xvar2.name, x2val)
215235
pout <- randomForestSRC::get.partial.plot.data(partial.obj, granule = gr)
216-
out_dta <- data.frame(x = pout$x, yhat = pout$yhat)
236+
# Survival forests with >1 partial.time return yhat as an
237+
# [length(partial.values) x length(partial.time)] matrix; expand to long form
238+
# so each (x, time) pair is its own row. For non-survival or single-time
239+
# cases yhat is already a vector of length(partial.values).
240+
if (is.matrix(pout$yhat)) {
241+
pt <- if (!is.null(pout$partial.time)) pout$partial.time else seq_len(ncol(pout$yhat))
242+
out_dta <- data.frame(
243+
x = rep(pout$x, times = length(pt)),
244+
yhat = as.numeric(pout$yhat),
245+
time = rep(pt, each = length(pout$x))
246+
)
247+
} else {
248+
out_dta <- data.frame(x = pout$x, yhat = pout$yhat)
249+
if (!is.null(pout$partial.time)) {
250+
out_dta$time <- pout$partial.time
251+
}
252+
}
217253
out_dta$name <- xname
218254
out_dta$type <- c("continuous", "categorical")[gr + 1L]
219-
if (!is.null(pout$partial.time)) {
220-
out_dta$time <- pout$partial.time
221-
}
222255
out_dta
223256
}
224257

225258
## Compute partial dependence across xvar.names (no grouping variable).
226259
partial_no_group <- function(xvar.names, newx, rf_model,
227-
cat_limit, n_eval, is_surv, partial.time) {
260+
cat_limit, n_eval, is_surv, partial.time,
261+
partial.type) {
228262
pdta <- lapply(xvar.names, partial_one_var,
229263
newx = newx, rf_model = rf_model,
230264
cat_limit = cat_limit, n_eval = n_eval,
231-
is_surv = is_surv, partial.time = partial.time)
265+
is_surv = is_surv, partial.time = partial.time,
266+
partial.type = partial.type)
232267
Filter(Negate(is.null), pdta)
233268
}
234269

235270
## Compute partial dependence across xvar.names for each level of xvar2.name.
236271
partial_with_group <- function(xvar.names, xvar2.name, newx, rf_model,
237-
cat_limit, n_eval, is_surv, partial.time) {
272+
cat_limit, n_eval, is_surv, partial.time,
273+
partial.type) {
238274
xv2 <- unique(newx[[xvar2.name]])
239275
xv2 <- xv2[!is.na(xv2)]
240276
if (length(xv2) == 0L) {
@@ -248,6 +284,7 @@ partial_with_group <- function(xvar.names, xvar2.name, newx, rf_model,
248284
newx = newx, rf_model = rf_model,
249285
cat_limit = cat_limit, n_eval = n_eval,
250286
is_surv = is_surv, partial.time = partial.time,
287+
partial.type = partial.type,
251288
xvar2.name = xvar2.name, x2val = x2val)
252289
p1dta <- Filter(Negate(is.null), p1dta)
253290
if (length(p1dta) == 0L) return(NULL)

R/plot.gg_partial.R

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@
1212
####
1313
####**********************************************************************
1414
####**********************************************************************
15+
16+
# Map partial.type ("surv" / "chf" / "mort") to a human y-axis label.
17+
# Falls back to "Predicted Survival" when the attribute is absent (e.g. an
18+
# object built before this attribute was introduced).
19+
partial_surv_y_label <- function(partial.type) {
20+
if (is.null(partial.type)) return("Predicted Survival")
21+
switch(partial.type,
22+
surv = "Predicted Survival",
23+
chf = "Predicted CHF",
24+
mort = "Predicted Mortality",
25+
"Predicted Survival")
26+
}
27+
1528
#' Plot a \code{\link{gg_partial}} object
1629
#'
1730
#' Produces ggplot2 partial dependence curves from the named list returned by
@@ -85,8 +98,11 @@ plot.gg_partial <- function(x, ...) {
8598
#' For standard (non-survival) forests: continuous predictors are line plots,
8699
#' categorical predictors are bar charts, both faceted by variable name.
87100
#'
88-
#' For survival forests (when a \code{time} column is present): each predictor
89-
#' value is a separate curve over time, faceted by variable name.
101+
#' For survival forests (when a \code{time} column is present): each evaluation
102+
#' time point is a separate curve over the predictor's value, faceted by
103+
#' variable name. The y-axis label adapts to the \code{partial.type} stored on
104+
#' the object (\dQuote{Predicted Survival}, \dQuote{Predicted CHF}, or
105+
#' \dQuote{Predicted Mortality}).
90106
#'
91107
#' For two-variable surface plots (when a \code{grp} column is present):
92108
#' each group level is a separate line, faceted by primary predictor name.
@@ -109,19 +125,27 @@ plot.gg_partial_rfsrc <- function(x, ...) {
109125
cont <- gg_dta$continuous
110126

111127
if (!is.null(cont$time)) {
112-
## Survival forest: predictor value is the grouping variable; x-axis is time
128+
## Survival forest: predictor value on x-axis, one curve per time point.
129+
## Group/colour by the *full-precision* time so distinct horizons that
130+
## happen to round to the same value are not silently merged. The legend
131+
## is relabelled with rounded values for readability.
132+
time_levels <- sort(unique(cont$time))
133+
cont$.time_factor <- factor(cont$time, levels = time_levels)
134+
legend_labels <- format(round(time_levels, 2), trim = TRUE)
135+
y_lab <- partial_surv_y_label(attr(gg_dta, "partial.type"))
113136
gg_cont <- ggplot2::ggplot(
114137
cont,
115138
ggplot2::aes(
116-
x = .data$time,
139+
x = .data$x,
117140
y = .data$yhat,
118-
color = factor(.data$x),
119-
group = factor(.data$x)
141+
color = .data$.time_factor,
142+
group = .data$.time_factor
120143
)
121144
) +
122145
ggplot2::geom_line() +
123-
ggplot2::facet_wrap(~name, scales = "free") +
124-
ggplot2::labs(x = "Time", y = "Partial Effect", color = "Predictor value")
146+
ggplot2::facet_wrap(~name, scales = "free_x") +
147+
ggplot2::scale_color_discrete(labels = legend_labels) +
148+
ggplot2::labs(x = NULL, y = y_lab, color = "Time")
125149

126150
} else if (!is.null(cont$grp)) {
127151
## Two-variable surface: group is xvar2; x-axis is the primary predictor

cran-comments.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,28 @@
1-
This is ggRandomForests package submission v2.7.0
1+
This is ggRandomForests package submission v2.7.1
22
-------------------------------------------------------------------------
3-
This is a bug-fix and code-quality release. Key changes:
3+
This is a bug-fix release. Key changes:
44

5-
* Fix critical visual bug: `aes()` calls throughout `plot.gg_rfsrc` and
6-
`plot.gg_roc` used bare string literals instead of `.data[[col]]`,
7-
causing aesthetics to map to constant strings rather than data columns.
8-
* Fix `bootstrap_survival` CI-band indexing and `gg_rfsrc.randomForest`
9-
incorrect use of non-existent `object$xvar` field.
10-
* Fix `seq_len(nvar)` vs `1:nvar` silent bug in `gg_vimp` and `plot.gg_vimp`.
11-
* Full test suite migration to testthat 3.x API.
12-
* Improved GitHub Actions CI (lintr enforcement, warnings-as-errors).
5+
* Fix `gg_partial_rfsrc()` for survival forests: `partial.rfsrc()` is now
6+
called with `partial.type = "surv"` (default; also accepts `"chf"` /
7+
`"mort"`). Without this, a zero-length comparison inside the underlying
8+
C code aborted the call and left the survival-vignette partial-dep chunks
9+
empty.
10+
* Fix `gg_partial_rfsrc()` for multiple `partial.time` values: yhat is
11+
reshaped from the matrix returned by `get.partial.plot.data()` into
12+
long form so each `(x, time)` pair is one row.
13+
* Improve `plot.gg_partial_rfsrc()` survival layout: predictor on the
14+
x-axis with one curve per time point coloured by `Time`, faceted by
15+
variable name.
16+
* New regression test file `test_plot_layer_data.R` uses
17+
`ggplot2::layer_data()` to verify each `plot.gg_*()` method renders
18+
non-empty layers across all forest families, catching empty-figure
19+
regressions without visual inspection.
1320

1421
## R CMD check results
1522
0 errors | 0 warnings | 0 notes
1623

1724
## Test environments
18-
* local R installation (R 4.4, macOS)
25+
* local R installation (R 4.5, macOS)
1926
* GitHub Actions: ubuntu-latest (R devel)
2027
* GitHub Actions: ubuntu-latest (R release)
2128
* GitHub Actions: ubuntu-latest (R oldrel-1)

man/gg_partial_rfsrc.Rd

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/plot.gg_partial_rfsrc.Rd

Lines changed: 5 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)