From 72d41d27a40323395cdccace8ce5de8d26d5378d Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Sun, 14 Jul 2024 20:05:09 -0700 Subject: [PATCH 01/25] base plot method --- DESCRIPTION | 6 ++- NAMESPACE | 2 + R/parttree.R | 16 +++++- R/plot.R | 99 ++++++++++++++++++++++++++++++++++++ man/geom_parttree.Rd | 65 +++++++++++++++++++----- man/plot.parttree.Rd | 54 ++++++++++++++++++++ plot_parttree.R | 116 +++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 342 insertions(+), 16 deletions(-) create mode 100644 R/plot.R create mode 100644 man/plot.parttree.Rd create mode 100644 plot_parttree.R diff --git a/DESCRIPTION b/DESCRIPTION index 6ed4feb..a660479 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,7 +27,7 @@ Description: Simple functions for plotting 2D decision tree partition plots. License: MIT + file LICENSE Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.2.3 +RoxygenNote: 7.3.1 LazyData: true URL: https://github.com/grantmcdermott/parttree, http://grantmcdermott.com/parttree @@ -38,7 +38,8 @@ Imports: rpart, data.table, partykit, - rlang + rlang, + tinyplot (> 0.1.0) Suggests: tinytest, palmerpenguins, @@ -51,4 +52,5 @@ Suggests: patchwork, knitr, rmarkdown +Remotes: grantmcdermott/tinyplot VignetteBuilder: knitr diff --git a/NAMESPACE b/NAMESPACE index edd82b9..9d5f688 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -6,9 +6,11 @@ S3method(parttree,LearnerRegrRpart) S3method(parttree,constparty) S3method(parttree,rpart) S3method(parttree,workflow) +S3method(plot,parttree) export(geom_parttree) export(parttree) import(ggplot2) importFrom(data.table,":=") importFrom(data.table,.SD) importFrom(data.table,fifelse) +importFrom(tinyplot,tinyplot) diff --git a/R/parttree.R b/R/parttree.R index 30796f2..35a4982 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -64,7 +64,8 @@ parttree.rpart = ## Get details about y variable for later ### y variable string (i.e. name) - y_var = attr(tree$terms, "variables")[[2]] + y_var = paste(tree$terms)[2] + # y_var = attr(tree$terms, "variables")[[2]] ### y values yvals = tree$frame[tree$frame$var == "", ]$yval y_factored = attr(tree$terms, "dataClasses")[paste(y_var)] == "factor" @@ -114,6 +115,8 @@ parttree.rpart = vars = c(missing_var, vars) } } + xvar = vars[1] + yvar = vars[2] part_coords = part_dt[ @@ -150,6 +153,17 @@ parttree.rpart = part_coords = as.data.frame(part_coords) } + class(part_coords) = c("parttree", class(part_coords)) + attr(part_coords, "parttree") = list( + xvar = xvar, + yvar = yvar, + xrange = range(eval(tree$call$data)[[xvar]]), + yrange = range(eval(tree$call$data)[[yvar]]), + response = y_var, + call = tree$call, + na.action = tree$na.action + ) + return(part_coords) } diff --git a/R/plot.R b/R/plot.R new file mode 100644 index 0000000..cb501dd --- /dev/null +++ b/R/plot.R @@ -0,0 +1,99 @@ +#' @title Plot decision tree partitions +#' @description Provides a plot method for parttree objects. +#' @returns No return value, called for side effect of producing a plot. +#' @param object A [parttree] data frame. +#' @param raw Logical. Should the raw (i.e., original) data be plotted alongside +#' the tree partitions? Default is `TRUE`. +#' @param border Colour of the partition borders (edges). Default is "black". To +#' remove the borders altogether, specify as `NA`. +#' @param fill_alpha Numeric in the range `[0,1]`. Alpha transparency of the +#' filled partition rectangles. Default is `0.3`. +#' @param ... Additional arguments passed down to +#' \code{\link[graphics]{tinyplot}}[tinyplot]. +#' @param raw Logical. Should the raw (original) data points be plotted too? +#' Default is TRUE. +#' @importFrom tinyplot tinyplot +#' @export +#' @examples +#' ## rpart tree example +#' library("rpart") +#' rp = rpart(Kyphosis ~ Start + Age, data = kyphosis) +#' pt = parttree(rp) +#' +#' ## simple plot +#' plot(pt) +#' +#' ## removing the (recursive) partition borders helps to emphasise overall fit +#' plot(pt, border = NA) +#' +#' ## customize further by passing extra options to (tiny)plot +#' plot( +#' pt, +#' border = NA, # no partition borders +#' pch = 19, # filled points +#' alpha = 0.7, # point transparency +#' grid = TRUE, # background grid +#' palette = "classic", # new colour palette +#' xlab = "Topmost vertebra operated on", # custom x title +#' ylab = "Patient age (months)", # custom y title +#' main = "Tree predictions: Kyphosis recurrence" # custom title +#' ) +plot.parttree = + function(object, raw = TRUE, border = "black", fill_alpha = 0.3, ...) { + + xvar = attr(object, "parttree")[["xvar"]] + yvar = attr(object, "parttree")[["yvar"]] + xrange = attr(object, "parttree")[["xrange"]] + yrange = attr(object, "parttree")[["yrange"]] + response = attr(object, "parttree")[["response"]] + orig_call = attr(object, "parttree")[["call"]] + na.action = attr(object, "parttree")[["na.action"]] + + raw_data = eval(orig_call$data)[, c(response, xvar, yvar)] + plot_fml = reformulate(paste(xvar, "|", response), response = yvar) + + object$response = object[[response]] + + tinyplot( + plot_fml, + data = raw_data, + type = "n", + col = border, + legend = list(pt.cex = 3.5, pch = 22, y.intersp = 1.25, x.intersp = 1.25), + fill = fill_alpha, + ... + ) + + corners = par("usr") + + object$xmin[object$xmin == -Inf] = corners[1] + object$xmax[object$xmax == Inf] = corners[2] + object$ymin[object$ymin == -Inf] = corners[3] + object$ymax[object$ymax == Inf] = corners[4] + object$response = object[[response]] + + with( + object, + tinyplot( + xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, + by = response, + type = "rect", + add = TRUE, + col = border, + fill = fill_alpha, + ... + ) + ) + + if (isTRUE(raw)) { + tinyplot( + plot_fml, + data = raw_data, + type = "p", + add = TRUE, + ... + ) + } + + } + diff --git a/man/geom_parttree.Rd b/man/geom_parttree.Rd index 9a04fa3..ff4a7cb 100644 --- a/man/geom_parttree.Rd +++ b/man/geom_parttree.Rd @@ -27,15 +27,31 @@ mapping.} type (e.g. a decision tree constructed via the \code{partykit}, \code{tidymodels}, or \code{mlr3} front-ends).} -\item{stat}{The statistical transformation to use on the data for this -layer, either as a \code{ggproto} \code{Geom} subclass or as a string naming the -stat stripped of the \code{stat_} prefix (e.g. \code{"count"} rather than -\code{"stat_count"})} - -\item{position}{Position adjustment, either as a string naming the adjustment -(e.g. \code{"jitter"} to use \code{position_jitter}), or the result of a call to a -position adjustment function. Use the latter if you need to change the -settings of the adjustment.} +\item{stat}{The statistical transformation to use on the data for this layer. +When using a \verb{geom_*()} function to construct a layer, the \code{stat} +argument can be used the override the default coupling between geoms and +stats. The \code{stat} argument accepts the following: +\itemize{ +\item A \code{Stat} ggproto subclass, for example \code{StatCount}. +\item A string naming the stat. To give the stat as a string, strip the +function name of the \code{stat_} prefix. For example, to use \code{stat_count()}, +give the stat as \code{"count"}. +\item For more information and other ways to specify the stat, see the +\link[ggplot2:layer_stats]{layer stat} documentation. +}} + +\item{position}{A position adjustment to use on the data for this layer. This +can be used in various ways, including to prevent overplotting and +improving the display. The \code{position} argument accepts the following: +\itemize{ +\item The result of calling a position function, such as \code{position_jitter()}. +This method allows for passing extra arguments to the position. +\item A string naming the position adjustment. To give the position as a +string, strip the function name of the \code{position_} prefix. For example, +to use \code{position_jitter()}, give the position as \code{"jitter"}. +\item For more information and other ways to specify the position, see the +\link[ggplot2:layer_positions]{layer position} documentation. +}} \item{linejoin}{Line join style (round, mitre, bevel).} @@ -59,10 +75,33 @@ plot orientation mismatches depending on how users specify the other layers of their plot. Setting to \code{TRUE} will flip the "x" and "y" variables for the \code{geom_parttree} layer.} -\item{...}{Other arguments passed on to \code{\link[ggplot2:layer]{layer()}}. These are -often aesthetics, used to set an aesthetic to a fixed value, like -\code{colour = "red"} or \code{size = 3}. They may also be parameters -to the paired geom/stat.} +\item{...}{Other arguments passed on to \code{\link[ggplot2:layer]{layer()}}'s \code{params} argument. These +arguments broadly fall into one of 4 categories below. Notably, further +arguments to the \code{position} argument, or aesthetics that are required +can \emph{not} be passed through \code{...}. Unknown arguments that are not part +of the 4 categories below are ignored. +\itemize{ +\item Static aesthetics that are not mapped to a scale, but are at a fixed +value and apply to the layer as a whole. For example, \code{colour = "red"} +or \code{linewidth = 3}. The geom's documentation has an \strong{Aesthetics} +section that lists the available options. The 'required' aesthetics +cannot be passed on to the \code{params}. Please note that while passing +unmapped aesthetics as vectors is technically possible, the order and +required length is not guaranteed to be parallel to the input data. +\item When constructing a layer using +a \verb{stat_*()} function, the \code{...} argument can be used to pass on +parameters to the \code{geom} part of the layer. An example of this is +\code{stat_density(geom = "area", outline.type = "both")}. The geom's +documentation lists which parameters it can accept. +\item Inversely, when constructing a layer using a +\verb{geom_*()} function, the \code{...} argument can be used to pass on parameters +to the \code{stat} part of the layer. An example of this is +\code{geom_area(stat = "density", adjust = 0.5)}. The stat's documentation +lists which parameters it can accept. +\item The \code{key_glyph} argument of \code{\link[ggplot2:layer]{layer()}} may also be passed on through +\code{...}. This can be one of the functions described as +\link[ggplot2:draw_key]{key glyphs}, to change the display of the layer in the legend. +}} } \description{ \code{geom_parttree()} is a simple extension of diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd new file mode 100644 index 0000000..727bc5d --- /dev/null +++ b/man/plot.parttree.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/plot.R +\name{plot.parttree} +\alias{plot.parttree} +\title{Plot decision tree partitions} +\usage{ +\method{plot}{parttree}(object, raw = TRUE, border = "black", fill_alpha = 0.3, ...) +} +\arguments{ +\item{object}{A \link{parttree} data frame.} + +\item{raw}{Logical. Should the raw (original) data points be plotted too? +Default is TRUE.} + +\item{border}{Colour of the partition borders (edges). Default is "black". To +remove the borders altogether, specify as \code{NA}.} + +\item{fill_alpha}{Numeric in the range \verb{[0,1]}. Alpha transparency of the +filled partition rectangles. Default is \code{0.3}.} + +\item{...}{Additional arguments passed down to +\code{\link[graphics]{tinyplot}}\link{tinyplot}.} +} +\value{ +No return value, called for side effect of producing a plot. +} +\description{ +Provides a plot method for parttree objects. +} +\examples{ +## rpart tree example +library("rpart") +rp = rpart(Kyphosis ~ Start + Age, data = kyphosis) +pt = parttree(rp) + +## simple plot +plot(pt) + +## removing the (recursive) partition borders helps to emphasise overall fit +plot(pt, border = NA) + +## customize further by passing extra options to (tiny)plot +plot( + pt, + border = NA, # no partition borders + pch = 19, # filled points + alpha = 0.7, # point transparency + grid = TRUE, # background grid + palette = "classic", # new colour palette + xlab = "Topmost vertebra operated on", # custom x title + ylab = "Patient age (months)", # custom y title + main = "Tree predictions: Kyphosis recurrence" # custom title +) +} diff --git a/plot_parttree.R b/plot_parttree.R new file mode 100644 index 0000000..9512b31 --- /dev/null +++ b/plot_parttree.R @@ -0,0 +1,116 @@ +library(rpart) # For fitting decisions trees +library(parttree) # This package (will automatically load ggplot2 too) + +# install.packages("palmerpenguins") +data("penguins", package = "palmerpenguins") + +tree = rpart(species ~ flipper_length_mm + bill_length_mm, data = penguins) +pt = parttree(tree) + +## Color palette +# pal = palette.colors(4, "R4")[-1] +pal = hcl.colors(3, "Pastel 1") +pal = hcl.colors(3, "Harmonic") + +plot( + bill_length_mm ~ flipper_length_mm, + data = penguins, col = pal[species], pch = 19 +) +pltrng = par("usr") +rect( + pmax(pltrng[1], pt$xmin), pmax(pltrng[3], pt$ymin), + pmin(pltrng[2], pt$xmax), pmin(pltrng[4], pt$ymax), + col = adjustcolor(pal, alpha.f = 0.1)[pt$species] +) + +# plot rectangles only +# dev.new() +# plot( +# bill_length_mm ~ flipper_length_mm, +# data = penguins, col = pal[species], pch = 19 +# ) +# pltrng = par("usr") +# dev.off() +# plot.new() +# plot.window(xlim = pltrng[1:2], ylim = pltrng[3:4]) +# rect( +# pmax(pltrng[1], pt$xmin), pmax(pltrng[3], pt$ymin), +# pmin(pltrng[2], pt$xmax), pmin(pltrng[4], pt$ymax), +# col = adjustcolor(pal, alpha.f = 0.1)[pt$species] +# ) + +# another approach +plot.new() +plot.window( + xlim = range(penguins[["flipper_length_mm"]], na.rm = TRUE), + ylim = range(penguins[["bill_length_mm"]], na.rm = TRUE) +) +pltrng = par("usr") +rect( + pmax(pltrng[1], pt$xmin), pmax(pltrng[3], pt$ymin), + pmin(pltrng[2], pt$xmax), pmin(pltrng[4], pt$ymax), + col = adjustcolor(pal, alpha.f = 0.1)[pt$species] +) +axis(1) +axis(2) +box() +title(xlab = "flipper_length_mm") +title(ylab = "bill_length_mm") +# title(main = "my plot") +points( + bill_length_mm ~ flipper_length_mm, + data = penguins, col = pal[species], pch = 19 +) + + + +pts = lapply(trees, parttree) + +## first plot the downscaled image... +plot(pred_img, axes = FALSE) +## ... then layer the partitions as a series of rectangles +pltrng = par("usr") +lapply( + pts, + function(pt) rect( + pmax(pltrng[1], pt$xmin), pmax(pltrng[4], pt$ymin), + pmin(pltrng[2], pt$xmax), pmin(pltrng[3], pt$ymax), + lwd = 0.06, border = "grey15" + ) +) + + +plot(pred_img, axes = FALSE) +plot.new() +plot.window( + xlim = range(rosalba_ccs[[1]][["x"]]), + ylim = rev(range(rosalba_ccs[[1]][["y"]])) + ) +pltrng = par("usr") +lapply( + pts, + function(pt) rect( + pmax(pltrng[1], pt$xmin), pmax(pltrng[4], pt$ymin), + pmin(pltrng[2], pt$xmax), pmin(pltrng[3], pt$ymax), + lwd = 0.06, border = "grey15" + ) +) + +plot.new() +plot(pred_img, axes = FALSE) +lapply( + 1:3, + function(i) { + plot.window( + xlim = range(rosalba_ccs[[i]][["x"]]), + ylim = rev(range(rosalba_ccs[[i]][["y"]])) + ) + pltrng = par("usr") + rect( + pmax(pltrng[1], pts[[i]]$xmin), pmax(pltrng[4], pts[[i]]$ymin), + pmin(pltrng[2], pts[[i]]$xmax), pmin(pltrng[3], pts[[i]]$ymax), + lwd = 0.06, border = "grey15" + ) + } +) + From ddde2b44064715e2df5fedf8c9f338d2ff12e5ac Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 15 Jul 2024 12:30:10 -0700 Subject: [PATCH 02/25] use tinyplot empty arg - also warn user if orginal data cannot be retrieved --- R/plot.R | 78 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 15 deletions(-) diff --git a/R/plot.R b/R/plot.R index cb501dd..484fb28 100644 --- a/R/plot.R +++ b/R/plot.R @@ -46,31 +46,79 @@ plot.parttree = xrange = attr(object, "parttree")[["xrange"]] yrange = attr(object, "parttree")[["yrange"]] response = attr(object, "parttree")[["response"]] + raw_data = attr(object, "parttree")[["raw_data"]] orig_call = attr(object, "parttree")[["call"]] - na.action = attr(object, "parttree")[["na.action"]] + orig_na_idx = attr(object, "parttree")[["na.action"]] + + if (isTRUE(raw)) { + if (!is.null(raw_data)) { + raw_data = eval(raw_data) + } else { + raw_data = eval(orig_call$data)[, c(response, xvar, yvar)] + if (!is.null(orig_na_idx)) raw_data = raw_data[-orig_na_idx, , drop = FALSE] + } + if (is.null(raw_data)){ + warning( + "\nCould not find original data. Ignoring.", + "\n(Did you delete the original model object?)\n" + ) + raw = FALSE + } + + } - raw_data = eval(orig_call$data)[, c(response, xvar, yvar)] plot_fml = reformulate(paste(xvar, "|", response), response = yvar) object$response = object[[response]] - tinyplot( - plot_fml, - data = raw_data, - type = "n", - col = border, - legend = list(pt.cex = 3.5, pch = 22, y.intersp = 1.25, x.intersp = 1.25), - fill = fill_alpha, - ... + # tinyplot( + # plot_fml, + # data = raw_data, + # type = "n", + # col = border, + # legend = list(pt.cex = 3.5, pch = 22, y.intersp = 1.25, x.intersp = 1.25), + # fill = fill_alpha, + # ... + # ) + + xmin_idxr = object$xmin == -Inf + xmax_idxr = object$xmax == Inf + ymin_idxr = object$ymin == -Inf + ymax_idxr = object$ymax == Inf + + object$response = object[[response]] + + object$xmin[xmin_idxr] = xrange[1] + object$xmax[xmax_idxr] = xrange[2] + object$ymin[ymin_idxr] = yrange[1] + object$ymax[ymax_idxr] = yrange[2] + + with( + object, + tinyplot( + x = xmin, + xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, + by = response, + type = "rect", + col = border, + fill = fill_alpha, + empty = TRUE, + ... + ) ) corners = par("usr") - object$xmin[object$xmin == -Inf] = corners[1] - object$xmax[object$xmax == Inf] = corners[2] - object$ymin[object$ymin == -Inf] = corners[3] - object$ymax[object$ymax == Inf] = corners[4] - object$response = object[[response]] + # object$xmin[object$xmin == -Inf] = corners[1] + # object$xmax[object$xmax == Inf] = corners[2] + # object$ymin[object$ymin == -Inf] = corners[3] + # object$ymax[object$ymax == Inf] = corners[4] + object$xmin[xmin_idxr] = corners[1] + object$xmax[xmax_idxr] = corners[2] + object$ymin[ymin_idxr] = corners[3] + object$ymax[ymax_idxr] = corners[4] + + with( object, From 64cebfc34ba52f96f7124b65936c1b35e6a304a4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 15 Jul 2024 12:31:14 -0700 Subject: [PATCH 03/25] attributes for parttree.constparty --- R/parttree.R | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/R/parttree.R b/R/parttree.R index 35a4982..3a69cf7 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -323,5 +323,17 @@ parttree.constparty = ## turn into data.table? if(keep_as_dt) rval = data.table::as.data.table(rval) + class(rval) = c("parttree", class(rval)) + attr(rval, "parttree") = list( + xvar = mx[1], + yvar = mx[2], + xrange = range(eval(tree$data)[[mx[1]]]), + yrange = range(eval(tree$data)[[mx[2]]]), + response = my, + # call = tree$call, + # na.action = tree$na.action, + raw_data = substitute(tree$data) + ) + return(rval) } From ff00fb18265605feded2643fe76e289e8e0fd9c4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 15 Jul 2024 12:36:19 -0700 Subject: [PATCH 04/25] clean up --- R/plot.R | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/R/plot.R b/R/plot.R index 484fb28..da10c21 100644 --- a/R/plot.R +++ b/R/plot.R @@ -67,20 +67,11 @@ plot.parttree = } - plot_fml = reformulate(paste(xvar, "|", response), response = yvar) + ## First adjust our parttree object to better fit some base R graphics + ## requirements object$response = object[[response]] - # tinyplot( - # plot_fml, - # data = raw_data, - # type = "n", - # col = border, - # legend = list(pt.cex = 3.5, pch = 22, y.intersp = 1.25, x.intersp = 1.25), - # fill = fill_alpha, - # ... - # ) - xmin_idxr = object$xmin == -Inf xmax_idxr = object$xmax == Inf ymin_idxr = object$ymin == -Inf @@ -93,6 +84,12 @@ plot.parttree = object$ymin[ymin_idxr] = yrange[1] object$ymax[ymax_idxr] = yrange[2] + ## Start plotting... + + plot_fml = reformulate(paste(xvar, "|", response), response = yvar) + + # First draw empty plot (since we need the plot corners to correctly + # expand the partition limits to the edges of the plot) with( object, tinyplot( @@ -107,19 +104,14 @@ plot.parttree = ) ) + # Grab the plot corners and adjust the partition limits corners = par("usr") - - # object$xmin[object$xmin == -Inf] = corners[1] - # object$xmax[object$xmax == Inf] = corners[2] - # object$ymin[object$ymin == -Inf] = corners[3] - # object$ymax[object$ymax == Inf] = corners[4] object$xmin[xmin_idxr] = corners[1] object$xmax[xmax_idxr] = corners[2] object$ymin[ymin_idxr] = corners[3] object$ymax[ymax_idxr] = corners[4] - - + # Add the (adjusted) partition rectangles with( object, tinyplot( @@ -133,6 +125,7 @@ plot.parttree = ) ) + # Add the original data points (if requested) if (isTRUE(raw)) { tinyplot( plot_fml, From 046636edec9c98c7898b4125a2ea8117735fa3e0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 15 Jul 2024 21:06:37 -0700 Subject: [PATCH 05/25] better plot logic --- R/plot.R | 56 +++++++++++++++++++++++++++++++++----------- man/plot.parttree.Rd | 12 ++++++++-- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/R/plot.R b/R/plot.R index da10c21..9359d67 100644 --- a/R/plot.R +++ b/R/plot.R @@ -31,7 +31,7 @@ #' pt, #' border = NA, # no partition borders #' pch = 19, # filled points -#' alpha = 0.7, # point transparency +#' alpha = 0.6, # point transparency #' grid = TRUE, # background grid #' palette = "classic", # new colour palette #' xlab = "Topmost vertebra operated on", # custom x title @@ -39,7 +39,14 @@ #' main = "Tree predictions: Kyphosis recurrence" # custom title #' ) plot.parttree = - function(object, raw = TRUE, border = "black", fill_alpha = 0.3, ...) { + function( + object, + raw = TRUE, + border = "black", + fill_alpha = 0.3, + xlab = NULL, + ylab = NULL, + ...) { xvar = attr(object, "parttree")[["xvar"]] yvar = attr(object, "parttree")[["yvar"]] @@ -50,6 +57,9 @@ plot.parttree = orig_call = attr(object, "parttree")[["call"]] orig_na_idx = attr(object, "parttree")[["na.action"]] + if (is.null(xlab)) xlab = xvar + if (is.null(ylab)) ylab = yvar + if (isTRUE(raw)) { if (!is.null(raw_data)) { raw_data = eval(raw_data) @@ -70,15 +80,11 @@ plot.parttree = ## First adjust our parttree object to better fit some base R graphics ## requirements - object$response = object[[response]] - xmin_idxr = object$xmin == -Inf xmax_idxr = object$xmax == Inf ymin_idxr = object$ymin == -Inf ymax_idxr = object$ymax == Inf - object$response = object[[response]] - object$xmin[xmin_idxr] = xrange[1] object$xmax[xmax_idxr] = xrange[2] object$ymin[ymin_idxr] = yrange[1] @@ -89,20 +95,42 @@ plot.parttree = plot_fml = reformulate(paste(xvar, "|", response), response = yvar) # First draw empty plot (since we need the plot corners to correctly - # expand the partition limits to the edges of the plot) - with( - object, - tinyplot( - x = xmin, - xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, - by = response, + # expand the partition limits to the edges of the plot). We'll create a + # dummy object for this task. + dobj = data.frame( + response = rep(object[[response]], 2), + x = c(object[["xmin"]], object[["xmax"]]), + y = c(object[["ymin"]], object[["ymax"]]) + ) + colnames(dobj) = c(response, xvar, yvar) + + tinyplot( + plot_fml, + data = dobj, type = "rect", col = border, fill = fill_alpha, empty = TRUE, ... - ) ) + object$response = object[[response]] + + # # First draw empty plot (since we need the plot corners to correctly + # # expand the partition limits to the edges of the plot) + # with( + # object, + # tinyplot( + # xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, + # by = response, + # type = "rect", + # col = border, + # fill = fill_alpha, + # xlab = xlab, ylab = ylab, + # legend = list(title = NULL), + # empty = TRUE, + # ... + # ) + # ) # Grab the plot corners and adjust the partition limits corners = par("usr") diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd index 727bc5d..63789b2 100644 --- a/man/plot.parttree.Rd +++ b/man/plot.parttree.Rd @@ -4,7 +4,15 @@ \alias{plot.parttree} \title{Plot decision tree partitions} \usage{ -\method{plot}{parttree}(object, raw = TRUE, border = "black", fill_alpha = 0.3, ...) +\method{plot}{parttree}( + object, + raw = TRUE, + border = "black", + fill_alpha = 0.3, + xlab = NULL, + ylab = NULL, + ... +) } \arguments{ \item{object}{A \link{parttree} data frame.} @@ -44,7 +52,7 @@ plot( pt, border = NA, # no partition borders pch = 19, # filled points - alpha = 0.7, # point transparency + alpha = 0.6, # point transparency grid = TRUE, # background grid palette = "classic", # new colour palette xlab = "Topmost vertebra operated on", # custom x title From edeb7c9e220e0c0c4c0d17182ed365fe782695f0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Mon, 15 Jul 2024 21:07:50 -0700 Subject: [PATCH 06/25] comment --- R/plot.R | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/R/plot.R b/R/plot.R index 9359d67..182cba1 100644 --- a/R/plot.R +++ b/R/plot.R @@ -94,7 +94,7 @@ plot.parttree = plot_fml = reformulate(paste(xvar, "|", response), response = yvar) - # First draw empty plot (since we need the plot corners to correctly + # First draw an empty plot (since we need the plot corners to correctly # expand the partition limits to the edges of the plot). We'll create a # dummy object for this task. dobj = data.frame( From d876b83f167ede212aefb2d9be1b1e979057f97a Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 16 Jul 2024 17:10:19 -0700 Subject: [PATCH 07/25] better attribute and plotting logic - ploting the dummy object first with a formula ensures correct axes and legend titles whilst still allowing for customization via ... --- R/parttree.R | 69 +++++++++++++++++++++++++++++++++++++++++++--------- R/plot.R | 34 ++++++++++++++++---------- 2 files changed, 78 insertions(+), 25 deletions(-) diff --git a/R/parttree.R b/R/parttree.R index 3a69cf7..4567cd8 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -46,7 +46,7 @@ parttree = #' @export parttree.rpart = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE) { + function(tree, keep_as_dt = FALSE, flipaxes = FALSE, ...) { ## Silence NSE notes in R CMD check. See: ## https://cran.r-project.org/web/packages/data.table/vignettes/datatable-importing.html#globals V1 = node = path = variable = side = ..vars = xvar = yvar = xmin = xmax = ymin = ymax = NULL @@ -115,8 +115,6 @@ parttree.rpart = vars = c(missing_var, vars) } } - xvar = vars[1] - yvar = vars[2] part_coords = part_dt[ @@ -154,14 +152,48 @@ parttree.rpart = } class(part_coords) = c("parttree", class(part_coords)) + + # attributes (for plot method) + dots = list(...) + if (!is.null(dots[["xvar"]])) { + xvar = dots[["xvar"]] + } else { + xvar = vars[1] + } + if (!is.null(dots[["yvar"]])) { + yvar = dots[["yvar"]] + } else { + yvar = vars[2] + } + if (!is.null(dots[["xrange"]])) { + xrange = dots[["xrange"]] + } else { + # xrange = range(eval(tree$call$data)[[xvar]]) + xrange = range(eval(tree$call$data, envir = attr(tree$terms, ".Environment"))[[xvar]], na.rm = TRUE) + } + if (!is.null(dots[["yrange"]])) { + yrange = dots[["yrange"]] + } else { + # yrange = range(eval(tree$call$data)[[yvar]]) + yrange = range(eval(tree$call$data, envir = attr(tree$terms, ".Environment"))[[yvar]], na.rm = TRUE) + } + raw_data = orig_call = orig_na.action = NULL + if (!is.null(dots[["raw_data"]])) { + raw_data = substitute(dots[["raw_data"]]) + } else { + orig_call = tree$call + orig_na.action = tree$na.action + } + attr(part_coords, "parttree") = list( xvar = xvar, yvar = yvar, - xrange = range(eval(tree$call$data)[[xvar]]), - yrange = range(eval(tree$call$data)[[yvar]]), + xrange = xrange, + yrange = yrange, response = y_var, - call = tree$call, - na.action = tree$na.action + call = orig_call, + na.action = orig_na.action, + raw_data = raw_data ) return(part_coords) @@ -176,7 +208,20 @@ parttree._rpart = "Did you forget to fit a model? See `?parsnip::fit`.") } tree = tree$fit - parttree.rpart(tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes) + # pass some extra attribute arguments through ... to parttree.rpart + raw_data = attr(tree$terms, ".Environment")$data + vars = attr(tree$terms, "term.labels") + xvar = vars[1] + yvar = vars[2] + xrange = range(raw_data[[xvar]]) + yrange = range(raw_data[[yvar]]) + raw_data = raw_data + parttree.rpart( + tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, + raw_data = raw_data, + xvar = xvar, yvar = yvar, + xrange = xrange, yrange = yrange + ) } #' @export @@ -327,11 +372,11 @@ parttree.constparty = attr(rval, "parttree") = list( xvar = mx[1], yvar = mx[2], - xrange = range(eval(tree$data)[[mx[1]]]), - yrange = range(eval(tree$data)[[mx[2]]]), + xrange = range(eval(tree$data)[[mx[1]]], na.rm = TRUE), + yrange = range(eval(tree$data)[[mx[2]]], na.rm = TRUE), response = my, - # call = tree$call, - # na.action = tree$na.action, + call = NULL, + na.action = NULL, raw_data = substitute(tree$data) ) diff --git a/R/plot.R b/R/plot.R index 182cba1..cf526a5 100644 --- a/R/plot.R +++ b/R/plot.R @@ -46,6 +46,8 @@ plot.parttree = fill_alpha = 0.3, xlab = NULL, ylab = NULL, + add = FALSE, + expand = TRUE, ...) { xvar = attr(object, "parttree")[["xvar"]] @@ -97,14 +99,15 @@ plot.parttree = # First draw an empty plot (since we need the plot corners to correctly # expand the partition limits to the edges of the plot). We'll create a # dummy object for this task. - dobj = data.frame( - response = rep(object[[response]], 2), - x = c(object[["xmin"]], object[["xmax"]]), - y = c(object[["ymin"]], object[["ymax"]]) - ) - colnames(dobj) = c(response, xvar, yvar) + if (isFALSE(add)) { + dobj = data.frame( + response = rep(object[[response]], 2), + x = c(object[["xmin"]], object[["xmax"]]), + y = c(object[["ymin"]], object[["ymax"]]) + ) + colnames(dobj) = c(response, xvar, yvar) - tinyplot( + tinyplot( plot_fml, data = dobj, type = "rect", @@ -112,7 +115,9 @@ plot.parttree = fill = fill_alpha, empty = TRUE, ... - ) + ) + } + object$response = object[[response]] # # First draw empty plot (since we need the plot corners to correctly @@ -133,11 +138,14 @@ plot.parttree = # ) # Grab the plot corners and adjust the partition limits - corners = par("usr") - object$xmin[xmin_idxr] = corners[1] - object$xmax[xmax_idxr] = corners[2] - object$ymin[ymin_idxr] = corners[3] - object$ymax[ymax_idxr] = corners[4] + if (isTRUE(expand)) { + corners = par("usr") + object$xmin[xmin_idxr] = corners[1] + object$xmax[xmax_idxr] = corners[2] + object$ymin[ymin_idxr] = corners[3] + object$ymax[ymax_idxr] = corners[4] + } + # Add the (adjusted) partition rectangles with( From 2917dd3bb9d84ec7a8700a78e4297e91588236bb Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 16 Jul 2024 17:17:42 -0700 Subject: [PATCH 08/25] flipaxes catch --- R/parttree.R | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/R/parttree.R b/R/parttree.R index 4567cd8..1114c6a 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -158,12 +158,12 @@ parttree.rpart = if (!is.null(dots[["xvar"]])) { xvar = dots[["xvar"]] } else { - xvar = vars[1] + xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) } if (!is.null(dots[["yvar"]])) { yvar = dots[["yvar"]] } else { - yvar = vars[2] + yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) } if (!is.null(dots[["xrange"]])) { xrange = dots[["xrange"]] @@ -211,8 +211,8 @@ parttree._rpart = # pass some extra attribute arguments through ... to parttree.rpart raw_data = attr(tree$terms, ".Environment")$data vars = attr(tree$terms, "term.labels") - xvar = vars[1] - yvar = vars[2] + xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) + yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) xrange = range(raw_data[[xvar]]) yrange = range(raw_data[[yvar]]) raw_data = raw_data @@ -369,11 +369,13 @@ parttree.constparty = if(keep_as_dt) rval = data.table::as.data.table(rval) class(rval) = c("parttree", class(rval)) + xvar = ifelse(isFALSE(flipaxes), mx[1], mx[2]) + yvar = ifelse(isFALSE(flipaxes), mx[2], mx[1]) attr(rval, "parttree") = list( - xvar = mx[1], - yvar = mx[2], - xrange = range(eval(tree$data)[[mx[1]]], na.rm = TRUE), - yrange = range(eval(tree$data)[[mx[2]]], na.rm = TRUE), + xvar = xvar, + yvar = yvar, + xrange = range(eval(tree$data)[[xvar]], na.rm = TRUE), + yrange = range(eval(tree$data)[[yvar]], na.rm = TRUE), response = my, call = NULL, na.action = NULL, From 74f1c9bd036091ca720a2fd70c813ea75ce32a36 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 16 Jul 2024 21:06:59 -0700 Subject: [PATCH 09/25] attributes catch for mlr3 --- R/parttree.R | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/R/parttree.R b/R/parttree.R index 1114c6a..903670a 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -208,14 +208,15 @@ parttree._rpart = "Did you forget to fit a model? See `?parsnip::fit`.") } tree = tree$fit - # pass some extra attribute arguments through ... to parttree.rpart + # extra attribute arguments to pass through ... to parttree.rpart raw_data = attr(tree$terms, ".Environment")$data vars = attr(tree$terms, "term.labels") xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) xrange = range(raw_data[[xvar]]) yrange = range(raw_data[[yvar]]) - raw_data = raw_data + # raw_data = raw_data + parttree.rpart( tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, raw_data = raw_data, @@ -248,7 +249,21 @@ parttree.LearnerClassifRpart = "Did you forget to assign a learner? See `?mlr3::lrn`.") } tree = tree$model - parttree.rpart(tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes) + + # extra attribute arguments to pass through ... to parttree.rpart + raw_data = eval(tree$call$data) + vars = attr(tree$terms, "term.labels") + xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) + yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) + xrange = range(raw_data[[xvar]]) + yrange = range(raw_data[[yvar]]) + + parttree.rpart( + tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, + raw_data = raw_data, + xvar = xvar, yvar = yvar, + xrange = xrange, yrange = yrange + ) } #' @export From 6b9823ae4908aa5961014ac40fe64b577176b328 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Tue, 16 Jul 2024 21:10:08 -0700 Subject: [PATCH 10/25] clean up --- R/parttree.R | 3 --- 1 file changed, 3 deletions(-) diff --git a/R/parttree.R b/R/parttree.R index 903670a..3415e55 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -168,13 +168,11 @@ parttree.rpart = if (!is.null(dots[["xrange"]])) { xrange = dots[["xrange"]] } else { - # xrange = range(eval(tree$call$data)[[xvar]]) xrange = range(eval(tree$call$data, envir = attr(tree$terms, ".Environment"))[[xvar]], na.rm = TRUE) } if (!is.null(dots[["yrange"]])) { yrange = dots[["yrange"]] } else { - # yrange = range(eval(tree$call$data)[[yvar]]) yrange = range(eval(tree$call$data, envir = attr(tree$terms, ".Environment"))[[yvar]], na.rm = TRUE) } raw_data = orig_call = orig_na.action = NULL @@ -215,7 +213,6 @@ parttree._rpart = yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) xrange = range(raw_data[[xvar]]) yrange = range(raw_data[[yvar]]) - # raw_data = raw_data parttree.rpart( tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, From edec34805667816e38286baab072202a373a86fc Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Wed, 17 Jul 2024 21:25:53 -0700 Subject: [PATCH 11/25] tidymodels workflow attributes --- R/parttree.R | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/R/parttree.R b/R/parttree.R index 3415e55..adc9b66 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -231,10 +231,25 @@ parttree.workflow = "Did you forget to fit a model? See `?workflows::fit`.") } y_name = names(tree$pre$mold$outcomes)[[1]] + raw_data = cbind(tree$pre$mold$predictors, tree$pre$mold$outcomes) tree = workflows::extract_fit_engine(tree) + tree$terms[[2]] = y_name attr(tree$terms, "variables")[[2]] = y_name names(attr(tree$terms, "dataClasses"))[[1]] = y_name - parttree.rpart(tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes) + + # extra attribute arguments to pass through ... to parttree.rpart + vars = attr(tree$terms, "term.labels") + xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) + yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) + xrange = range(raw_data[[xvar]]) + yrange = range(raw_data[[yvar]]) + + parttree.rpart( + tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, + raw_data = raw_data, + xvar = xvar, yvar = yvar, + xrange = xrange, yrange = yrange + ) } #' @export @@ -260,7 +275,7 @@ parttree.LearnerClassifRpart = raw_data = raw_data, xvar = xvar, yvar = yvar, xrange = xrange, yrange = yrange - ) + ) } #' @export From 57e279847259787a2cef21627568d5f48868afdd Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Wed, 24 Jul 2024 17:37:29 -0700 Subject: [PATCH 12/25] flipaxes -> flip - Also, better catch for mlr3 objects --- R/geom_parttree.R | 15 +++---- R/parttree.R | 102 ++++++++++++++++++++++++++-------------------- R/plot.R | 26 +++--------- 3 files changed, 72 insertions(+), 71 deletions(-) diff --git a/R/geom_parttree.R b/R/geom_parttree.R index 6a0b0c6..24576f6 100644 --- a/R/geom_parttree.R +++ b/R/geom_parttree.R @@ -1,4 +1,4 @@ -#' @title Visualise tree partitions +#' @title Visualise tree partitions with ggplot2 #' #' @description `geom_parttree()` is a simple extension of #' [ggplot2::geom_rect()]that first calls @@ -7,12 +7,12 @@ #' @param data An [rpart::rpart.object] or an object of compatible #' type (e.g. a decision tree constructed via the `partykit`, `tidymodels`, or #' `mlr3` front-ends). -#' @param flipaxes Logical. By default, the "x" and "y" axes variables for +#' @param flip Logical. By default, the "x" and "y" axes variables for #' plotting are determined by the first split in the tree. This can cause #' plot orientation mismatches depending on how users specify the other layers #' of their plot. Setting to `TRUE` will flip the "x" and "y" variables for #' the `geom_parttree` layer. -#' @import ggplot2 +#' @importFrom ggplot2 aes aes_all layer GeomRect ggproto #' @inheritParams ggplot2::layer #' @inheritParams ggplot2::geom_point #' @inheritParams ggplot2::geom_segment @@ -40,6 +40,7 @@ #' @export #' @examples #' library(rpart) +#' library(ggplot2) #' #' ### Simple decision tree (max of two predictor variables) #' @@ -67,8 +68,8 @@ #' ## Oops #' p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1) #' -#' ## Fix with 'flipaxes = TRUE' -#' p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flipaxes = TRUE) +#' ## Fix with 'flip = TRUE' +#' p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flip = TRUE) #' #' #' ### Various front-end frameworks are also supported, e.g.: @@ -106,8 +107,8 @@ geom_parttree = function(mapping = NULL, data = NULL, stat = "identity", position = "identity", linejoin = "mitre", na.rm = FALSE, show.legend = NA, - inherit.aes = TRUE, flipaxes = FALSE, ...) { - pdata = parttree(data, flipaxes = flipaxes) + inherit.aes = TRUE, flip = FALSE, ...) { + pdata = parttree(data, flip = flip) mapping_null = is.null(mapping) mapping$xmin = quote(xmin) mapping$xmax = quote(xmax) diff --git a/R/parttree.R b/R/parttree.R index adc9b66..c4096bc 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -1,26 +1,25 @@ #' @title Convert a decision tree into a data frame of partition coordinates -#' @aliases parttree parttree.rpart parttree._rpart parttree.workflow parttree.LearnerClassifRpart parttree.LearnerRegrRpart parttree.constparty -#' -#' @description Extracts the terminal leaf nodes of a decision tree with one or -#' two numeric predictor variables. These leaf nodes are then converted into a data -#' frame, where each row represents a partition (or leaf or terminal node) -#' that can easily be plotted in coordinate space. -#' @param tree A tree object. Supported classes include -#' [rpart::rpart.object], or the compatible classes from -#' from the `parsnip`, `workflows`, or `mlr3` front-ends, or the -#' `constparty` class inheriting from [partykit::party()]. +#' @aliases parttree parttree.rpart parttree._rpart parttree.workflow +#' parttree.LearnerClassifRpart parttree.LearnerRegrRpart parttree.constparty +#' @description Extracts the terminal leaf nodes of a decision tree that +#' contains no more that two numeric predictor variables. These leaf nodes are +#' then converted into a data frame, where each row represents a partition (or +#' leaf or terminal node) that can easily be plotted in 2-D coordinate space. +#' @param tree An \code{\link[rpart]{rpart.object}} or alike. This includes +#' compatible classes from the `mlr3` and `tidymodels` frontends, or the +#' `constparty` class inheriting from \code{\link[partykit]{party}}. #' @param keep_as_dt Logical. The function relies on `data.table` for internal #' data manipulation. But it will coerce the final return object into a #' regular data frame (default behavior) unless the user specifies `TRUE`. -#' @param flipaxes Logical. The function will automatically set the y-axis -#' variable as the first split variable in the tree provided unless -#' the user specifies `TRUE`. -#' @details This function can be used with a regression or classification tree -#' containing one or (at most) two numeric predictors. -#' @seealso [geom_parttree()], [rpart::rpart()], [partykit::ctree()]. -#' @return A data frame comprising seven columns: the leaf node, its path, a set -#' of coordinates understandable to `ggplot2` (i.e., xmin, xmax, ymin, ymax), -#' and a final column corresponding to the predicted value for that leaf. +#' @param flip Logical. Should we flip the "x" and "y" variables in the return +#' data frame? The default behaviour is for the first split variable in the +#' tree to take the "y" slot, and any second split variable to take the "x" +#' slot. Setting to `TRUE` switches these around. +#' @seealso [plot.parttree], [geom_parttree], \code{\link[rpart]{rpart}}, +#' \code{\link[partykit]{ctree}} [partykit::ctree]. +#' @returns A data frame comprising seven columns: the leaf node, its path, a +#' set of rectangle limits (i.e., xmin, xmax, ymin, ymax), and a final column +#' corresponding to the predicted value for that leaf. #' @importFrom data.table := #' @importFrom data.table .SD #' @importFrom data.table fifelse @@ -40,13 +39,13 @@ #' rp2 = as.party(rp) #' parttree(rp2) parttree = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE) { + function(tree, keep_as_dt = FALSE, flip = FALSE) { UseMethod("parttree") } #' @export parttree.rpart = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE, ...) { + function(tree, keep_as_dt = FALSE, flip = FALSE, ...) { ## Silence NSE notes in R CMD check. See: ## https://cran.r-project.org/web/packages/data.table/vignettes/datatable-importing.html#globals V1 = node = path = variable = side = ..vars = xvar = yvar = xmin = xmax = ymin = ymax = NULL @@ -107,7 +106,7 @@ parttree.rpart = ## special case we can assume is likely wrong, notwithstanding ability to still flip axes if (vars[1]=="y" & vars[2]=="x") vars = rev(vars) } - if (flipaxes) { + if (flip) { vars = rev(vars) ## Handle edge cases with only 1 level if (length(vars)==1) { @@ -158,12 +157,12 @@ parttree.rpart = if (!is.null(dots[["xvar"]])) { xvar = dots[["xvar"]] } else { - xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) + xvar = ifelse(isFALSE(flip), vars[1], vars[2]) } if (!is.null(dots[["yvar"]])) { yvar = dots[["yvar"]] } else { - yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) + yvar = ifelse(isFALSE(flip), vars[2], vars[1]) } if (!is.null(dots[["xrange"]])) { xrange = dots[["xrange"]] @@ -199,7 +198,7 @@ parttree.rpart = #' @export parttree._rpart = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE) { + function(tree, keep_as_dt = FALSE, flip = FALSE) { ## parsnip front-end if (is.null(tree$fit)) { stop("No model detected.\n", @@ -209,13 +208,13 @@ parttree._rpart = # extra attribute arguments to pass through ... to parttree.rpart raw_data = attr(tree$terms, ".Environment")$data vars = attr(tree$terms, "term.labels") - xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) - yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) + xvar = ifelse(isFALSE(flip), vars[1], vars[2]) + yvar = ifelse(isFALSE(flip), vars[2], vars[1]) xrange = range(raw_data[[xvar]]) yrange = range(raw_data[[yvar]]) parttree.rpart( - tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, + tree, keep_as_dt = keep_as_dt, flip = flip, raw_data = raw_data, xvar = xvar, yvar = yvar, xrange = xrange, yrange = yrange @@ -224,7 +223,7 @@ parttree._rpart = #' @export parttree.workflow = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE) { + function(tree, keep_as_dt = FALSE, flip = FALSE) { ## workflow front-end if (!workflows::is_trained_workflow(tree)) { stop("No model detected.\n", @@ -239,13 +238,13 @@ parttree.workflow = # extra attribute arguments to pass through ... to parttree.rpart vars = attr(tree$terms, "term.labels") - xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) - yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) + xvar = ifelse(isFALSE(flip), vars[1], vars[2]) + yvar = ifelse(isFALSE(flip), vars[2], vars[1]) xrange = range(raw_data[[xvar]]) yrange = range(raw_data[[yvar]]) parttree.rpart( - tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, + tree, keep_as_dt = keep_as_dt, flip = flip, raw_data = raw_data, xvar = xvar, yvar = yvar, xrange = xrange, yrange = yrange @@ -254,24 +253,39 @@ parttree.workflow = #' @export parttree.LearnerClassifRpart = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE) { + function(tree, keep_as_dt = FALSE, flip = FALSE) { ## mlr3 front-end if (is.null(tree$model)) { stop("No model detected.\n", "Did you forget to assign a learner? See `?mlr3::lrn`.") } + + pars = tree$param_set$get_values() + keep_model = isTRUE(pars$keep_model) + tree = tree$model # extra attribute arguments to pass through ... to parttree.rpart - raw_data = eval(tree$call$data) + # raw_data = eval(tree$call$data) vars = attr(tree$terms, "term.labels") - xvar = ifelse(isFALSE(flipaxes), vars[1], vars[2]) - yvar = ifelse(isFALSE(flipaxes), vars[2], vars[1]) - xrange = range(raw_data[[xvar]]) - yrange = range(raw_data[[yvar]]) + xvar = ifelse(isFALSE(flip), vars[1], vars[2]) + yvar = ifelse(isFALSE(flip), vars[2], vars[1]) + if (keep_model) { + raw_data = tree$model + xrange = range(raw_data[[xvar]]) + yrange = range(raw_data[[yvar]]) + } else { + raw_data = NA + xrange = NA + yrange = NA + message( + "\nUnable to retrieve the original data, which we need for the default plot.parttree method.", + "\nFor mlr3 workflows, we recommended an explicit call to `keep_model = TRUE` when defining your Learner before training the model.\n" + ) + } parttree.rpart( - tree, keep_as_dt = keep_as_dt, flipaxes = flipaxes, + tree, keep_as_dt = keep_as_dt, flip = flip, raw_data = raw_data, xvar = xvar, yvar = yvar, xrange = xrange, yrange = yrange @@ -283,7 +297,7 @@ parttree.LearnerRegrRpart = parttree.LearnerClassifRpart #' @export parttree.constparty = - function(tree, keep_as_dt = FALSE, flipaxes = FALSE) { + function(tree, keep_as_dt = FALSE, flip = FALSE) { ## sanity checks for tree mt = tree$terms mf = attr(mt, "factors") @@ -389,15 +403,15 @@ parttree.constparty = path = labs ) names(rval)[2L] = my - rval = cbind(rval, if(flipaxes) ints[, c(3L:4L, 1L:2L, drop = FALSE)] else ints) + rval = cbind(rval, if(flip) ints[, c(3L:4L, 1L:2L, drop = FALSE)] else ints) colnames(rval)[4L:7L] = c("xmin", "xmax", "ymin", "ymax") ## turn into data.table? if(keep_as_dt) rval = data.table::as.data.table(rval) class(rval) = c("parttree", class(rval)) - xvar = ifelse(isFALSE(flipaxes), mx[1], mx[2]) - yvar = ifelse(isFALSE(flipaxes), mx[2], mx[1]) + xvar = ifelse(isFALSE(flip), mx[1], mx[2]) + yvar = ifelse(isFALSE(flip), mx[2], mx[1]) attr(rval, "parttree") = list( xvar = xvar, yvar = yvar, @@ -406,7 +420,7 @@ parttree.constparty = response = my, call = NULL, na.action = NULL, - raw_data = substitute(tree$data) + raw_data = substitute(tree$data) # Or, partykit::model_frame_rpart? ) return(rval) diff --git a/R/plot.R b/R/plot.R index cf526a5..42a43bc 100644 --- a/R/plot.R +++ b/R/plot.R @@ -8,6 +8,10 @@ #' remove the borders altogether, specify as `NA`. #' @param fill_alpha Numeric in the range `[0,1]`. Alpha transparency of the #' filled partition rectangles. Default is `0.3`. +#' @param add Logical. Add to an existing plot? Default is `FALSE`. +#' @param expand Logical. Should the partition limits be expanded to to meet the +#' edge of the plot axes? Default is `TRUE`. If `FALSE`, then the partition +#' limits will extend only until the range of the raw data. #' @param ... Additional arguments passed down to #' \code{\link[graphics]{tinyplot}}[tinyplot]. #' @param raw Logical. Should the raw (original) data points be plotted too? @@ -69,10 +73,9 @@ plot.parttree = raw_data = eval(orig_call$data)[, c(response, xvar, yvar)] if (!is.null(orig_na_idx)) raw_data = raw_data[-orig_na_idx, , drop = FALSE] } - if (is.null(raw_data)){ + if (is.null(raw_data) || (is.atomic(raw_data) && is.na(raw_data))){ warning( - "\nCould not find original data. Ignoring.", - "\n(Did you delete the original model object?)\n" + "\nCould not find original data. Setting `raw = FALSE`.\n" ) raw = FALSE } @@ -120,23 +123,6 @@ plot.parttree = object$response = object[[response]] - # # First draw empty plot (since we need the plot corners to correctly - # # expand the partition limits to the edges of the plot) - # with( - # object, - # tinyplot( - # xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, - # by = response, - # type = "rect", - # col = border, - # fill = fill_alpha, - # xlab = xlab, ylab = ylab, - # legend = list(title = NULL), - # empty = TRUE, - # ... - # ) - # ) - # Grab the plot corners and adjust the partition limits if (isTRUE(expand)) { corners = par("usr") From 6a591649bf0b073d3fef220fa3a2c93aba7ccb75 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Wed, 24 Jul 2024 17:38:09 -0700 Subject: [PATCH 13/25] update tests --- inst/tinytest/test_ctree.R | 2 ++ inst/tinytest/test_mlr3.R | 14 ++++++++---- inst/tinytest/test_rpart.R | 27 +++++++++++++++------- inst/tinytest/test_tidymodels.R | 40 +++++++++++++++++++++------------ 4 files changed, 57 insertions(+), 26 deletions(-) diff --git a/inst/tinytest/test_ctree.R b/inst/tinytest/test_ctree.R index 3f86e71..4286136 100644 --- a/inst/tinytest/test_ctree.R +++ b/inst/tinytest/test_ctree.R @@ -8,5 +8,7 @@ if (require(partykit)) { data = iris) ct = parttree(ct) row.names(ct) = NULL + attr(ct, "parttree") = NULL + class(ct) = "data.frame" expect_equal(ct, pt_ct_cl_known) } diff --git a/inst/tinytest/test_mlr3.R b/inst/tinytest/test_mlr3.R index a12923b..655f02b 100644 --- a/inst/tinytest/test_mlr3.R +++ b/inst/tinytest/test_mlr3.R @@ -6,16 +6,22 @@ if (require(mlr3)) { # task_cl = tsk("iris", target = "Species") # simpler (but less precise?) version of below task_cl = TaskClassif$new("iris", iris, target = "Species") task_cl$formula(rhs = "Petal.Length + Petal.Width") - fit_cl = lrn("classif.rpart") + fit_cl = lrn("classif.rpart", keep_model = TRUE) fit_cl$train(task_cl) - expect_equal(pt_cl_known, parttree(fit_cl)) + fit_cl_pt = parttree(fit_cl) + attr(fit_cl_pt, "parttree") = NULL + class(fit_cl_pt) = "data.frame" + expect_equal(pt_cl_known, fit_cl_pt) # Regression source('known_output/parttree_rpart_regression.R') task_reg = TaskRegr$new("iris", iris, target = "Sepal.Length") task_reg$formula(rhs = "Petal.Length + Sepal.Width") - fit_reg = lrn("regr.rpart") + fit_reg = lrn("regr.rpart", keep_model = TRUE) fit_reg$train(task_reg) - expect_equal(pt_reg_known, parttree(fit_reg), , tolerance = 1e-7) + fit_reg_pt = parttree(fit_reg) + attr(fit_reg_pt, "parttree") = NULL + class(fit_reg_pt) = "data.frame" + expect_equal(pt_reg_known, fit_reg_pt, tolerance = 1e-7) } diff --git a/inst/tinytest/test_rpart.R b/inst/tinytest/test_rpart.R index 57cd453..feefc09 100644 --- a/inst/tinytest/test_rpart.R +++ b/inst/tinytest/test_rpart.R @@ -7,25 +7,33 @@ source('known_output/parttree_rpart_classification.R') # rpart rp = rpart::rpart(Species ~ Petal.Length + Petal.Width, data = iris) -expect_equal(pt_cl_known, parttree(rp)) +rp_pt = parttree(rp) +attr(rp_pt, "parttree") = NULL +class(rp_pt) = "data.frame" +expect_equal(pt_cl_known, rp_pt) # partykit if (require(partykit)) { rp2 = as.party(rp) - rp2 = parttree(rp2) - row.names(rp2) = NULL + rp2_pt = parttree(rp2) + row.names(rp2_pt) = NULL + attr(rp2_pt, "parttree") = NULL + class(rp2_pt) = "data.frame" expect_equal(pt_cl_known[, setdiff(names(pt_cl_known), 'node')], - rp2[, setdiff(names(rp2), 'node')]) + rp2_pt[, setdiff(names(rp2_pt), 'node')]) } # -# flipaxes +# flip # # Comparison data source('known_output/parttree_rpart_classification_flip.R') -expect_equal(pt_cl_known_flip, parttree(rp, flipaxes = TRUE)) +rp_pt_flip = parttree(rp, flip = TRUE) +attr(rp_pt_flip, "parttree") = NULL +class(rp_pt_flip) = "data.frame" +expect_equal(pt_cl_known_flip, rp_pt_flip) # @@ -35,5 +43,8 @@ expect_equal(pt_cl_known_flip, parttree(rp, flipaxes = TRUE)) # Comparison data source('known_output/parttree_rpart_regression.R') -rp = rpart::rpart(Sepal.Length ~ Petal.Length + Sepal.Width, data = iris) -expect_equal(pt_reg_known, parttree(rp), tolerance = 1e-7) +rp_reg = rpart::rpart(Sepal.Length ~ Petal.Length + Sepal.Width, data = iris) +rp_reg_pt = parttree(rp_reg) +attr(rp_reg_pt, "parttree") = NULL +class(rp_reg_pt) = "data.frame" +expect_equal(pt_reg_known, rp_reg_pt, tolerance = 1e-7) diff --git a/inst/tinytest/test_tidymodels.R b/inst/tinytest/test_tidymodels.R index 6dd7a84..19e04df 100644 --- a/inst/tinytest/test_tidymodels.R +++ b/inst/tinytest/test_tidymodels.R @@ -9,17 +9,23 @@ if (require(tidymodels)) { fml_cl = Species ~ Petal.Length + Petal.Width # parsnip - ps_cl = decision_tree() %>% - set_engine("rpart") %>% - set_mode("classification") %>% - fit(fml_cl, data = iris) - expect_equal(pt_cl_known, parttree(ps_cl)) + ps_cl_pt = decision_tree() |> + set_engine("rpart") |> + set_mode("classification") |> + fit(fml_cl, data = iris) |> + parttree() + attr(ps_cl_pt, "parttree") = NULL + class(ps_cl_pt) = "data.frame" + expect_equal(pt_cl_known, ps_cl_pt) # workflows - wf_spec_cl = decision_tree() %>% set_mode("classification") + wf_spec_cl = decision_tree() |> set_mode("classification") wf_tree_cl = workflow(fml_cl, spec = wf_spec_cl) wf_cl = fit(wf_tree_cl, iris) - expect_equal(pt_cl_known, parttree(wf_cl)) + wf_cl_pt = parttree(wf_cl) + attr(wf_cl_pt, "parttree") = NULL + class(wf_cl_pt) = "data.frame" + expect_equal(pt_cl_known, wf_cl_pt) } # @@ -33,15 +39,21 @@ if (require(tidymodels)) { fml_reg = Sepal.Length ~ Petal.Length + Sepal.Width # parsnip - ps_reg = decision_tree() %>% - set_engine("rpart") %>% - set_mode("regression") %>% - fit(fml_reg, data = iris) - expect_equal(pt_reg_known, parttree(ps_reg), tolerance = 1e-7) + ps_reg_pt = decision_tree() |> + set_engine("rpart") |> + set_mode("regression") |> + fit(fml_reg, data = iris) |> + parttree() + attr(ps_reg_pt, "parttree") = NULL + class(ps_reg_pt) = "data.frame" + expect_equal(pt_reg_known, ps_reg_pt, tolerance = 1e-7) # workflows - wf_spec_reg = decision_tree() %>% set_mode("regression") + wf_spec_reg = decision_tree() |> set_mode("regression") wf_tree_reg = workflow(fml_reg, spec = wf_spec_reg) wf_reg = fit(wf_tree_reg, iris) - expect_equal(pt_reg_known, parttree(wf_reg), tolerance = 1e-7) + wf_reg_pt = parttree(wf_reg) + attr(wf_reg_pt, "parttree") = NULL + class(wf_reg_pt) = "data.frame" + expect_equal(pt_reg_known, wf_reg_pt, tolerance = 1e-7) } From 3273aa00d5221c63f56941c3d078a844a18f7e74 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Wed, 24 Jul 2024 17:38:33 -0700 Subject: [PATCH 14/25] docs and namespace --- .gitignore | 1 + DESCRIPTION | 5 ++--- NAMESPACE | 6 +++++- man/geom_parttree.Rd | 11 ++++++----- man/parttree.Rd | 37 +++++++++++++++++-------------------- man/plot.parttree.Rd | 8 ++++++++ 6 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index f545b2b..8c80211 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .RData docs inst/doc +SCRATCH/ diff --git a/DESCRIPTION b/DESCRIPTION index a660479..7a6306e 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -27,16 +27,15 @@ Description: Simple functions for plotting 2D decision tree partition plots. License: MIT + file LICENSE Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.1 +RoxygenNote: 7.3.2 LazyData: true URL: https://github.com/grantmcdermott/parttree, http://grantmcdermott.com/parttree BugReports: https://github.com/grantmcdermott/parttree/issues -Depends: - ggplot2 (>= 3.4.0) Imports: rpart, data.table, + ggplot2 (>= 3.4.0), partykit, rlang, tinyplot (> 0.1.0) diff --git a/NAMESPACE b/NAMESPACE index 9d5f688..86b8b08 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,8 +9,12 @@ S3method(parttree,workflow) S3method(plot,parttree) export(geom_parttree) export(parttree) -import(ggplot2) importFrom(data.table,":=") importFrom(data.table,.SD) importFrom(data.table,fifelse) +importFrom(ggplot2,GeomRect) +importFrom(ggplot2,aes) +importFrom(ggplot2,aes_all) +importFrom(ggplot2,ggproto) +importFrom(ggplot2,layer) importFrom(tinyplot,tinyplot) diff --git a/man/geom_parttree.Rd b/man/geom_parttree.Rd index ff4a7cb..fe1f584 100644 --- a/man/geom_parttree.Rd +++ b/man/geom_parttree.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/geom_parttree.R \name{geom_parttree} \alias{geom_parttree} -\title{Visualise tree partitions} +\title{Visualise tree partitions with ggplot2} \usage{ geom_parttree( mapping = NULL, @@ -13,7 +13,7 @@ geom_parttree( na.rm = FALSE, show.legend = NA, inherit.aes = TRUE, - flipaxes = FALSE, + flip = FALSE, ... ) } @@ -69,7 +69,7 @@ rather than combining with them. This is most useful for helper functions that define both data and aesthetics and shouldn't inherit behaviour from the default plot specification, e.g. \code{\link[ggplot2:borders]{borders()}}.} -\item{flipaxes}{Logical. By default, the "x" and "y" axes variables for +\item{flip}{Logical. By default, the "x" and "y" axes variables for plotting are determined by the first split in the tree. This can cause plot orientation mismatches depending on how users specify the other layers of their plot. Setting to \code{TRUE} will flip the "x" and "y" variables for @@ -135,6 +135,7 @@ cue regarding the prediction in each partition region)} \examples{ library(rpart) +library(ggplot2) ### Simple decision tree (max of two predictor variables) @@ -162,8 +163,8 @@ p2 = ggplot(iris, aes(x=Petal.Width, y=Petal.Length)) + ## Oops p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1) -## Fix with 'flipaxes = TRUE' -p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flipaxes = TRUE) +## Fix with 'flip = TRUE' +p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flip = TRUE) ### Various front-end frameworks are also supported, e.g.: diff --git a/man/parttree.Rd b/man/parttree.Rd index 94e8fbf..5738e21 100644 --- a/man/parttree.Rd +++ b/man/parttree.Rd @@ -10,36 +10,32 @@ \alias{parttree.constparty} \title{Convert a decision tree into a data frame of partition coordinates} \usage{ -parttree(tree, keep_as_dt = FALSE, flipaxes = FALSE) +parttree(tree, keep_as_dt = FALSE, flip = FALSE) } \arguments{ -\item{tree}{A tree object. Supported classes include -\link[rpart:rpart.object]{rpart::rpart.object}, or the compatible classes from -from the \code{parsnip}, \code{workflows}, or \code{mlr3} front-ends, or the -\code{constparty} class inheriting from \code{\link[partykit:party]{partykit::party()}}.} +\item{tree}{An \code{\link[rpart]{rpart.object}} or alike. This includes +compatible classes from the \code{mlr3} and \code{tidymodels} frontends, or the +\code{constparty} class inheriting from \code{\link[partykit]{party}}.} \item{keep_as_dt}{Logical. The function relies on \code{data.table} for internal data manipulation. But it will coerce the final return object into a regular data frame (default behavior) unless the user specifies \code{TRUE}.} -\item{flipaxes}{Logical. The function will automatically set the y-axis -variable as the first split variable in the tree provided unless -the user specifies \code{TRUE}.} +\item{flip}{Logical. Should we flip the "x" and "y" variables in the return +data frame? The default behaviour is for the first split variable in the +tree to take the "y" slot, and any second split variable to take the "x" +slot. Setting to \code{TRUE} switches these around.} } \value{ -A data frame comprising seven columns: the leaf node, its path, a set -of coordinates understandable to \code{ggplot2} (i.e., xmin, xmax, ymin, ymax), -and a final column corresponding to the predicted value for that leaf. +A data frame comprising seven columns: the leaf node, its path, a +set of rectangle limits (i.e., xmin, xmax, ymin, ymax), and a final column +corresponding to the predicted value for that leaf. } \description{ -Extracts the terminal leaf nodes of a decision tree with one or -two numeric predictor variables. These leaf nodes are then converted into a data -frame, where each row represents a partition (or leaf or terminal node) -that can easily be plotted in coordinate space. -} -\details{ -This function can be used with a regression or classification tree -containing one or (at most) two numeric predictors. +Extracts the terminal leaf nodes of a decision tree that +contains no more that two numeric predictor variables. These leaf nodes are +then converted into a data frame, where each row represents a partition (or +leaf or terminal node) that can easily be plotted in 2-D coordinate space. } \examples{ ## rpart trees @@ -57,5 +53,6 @@ rp2 = as.party(rp) parttree(rp2) } \seealso{ -\code{\link[=geom_parttree]{geom_parttree()}}, \code{\link[rpart:rpart]{rpart::rpart()}}, \code{\link[partykit:ctree]{partykit::ctree()}}. +\link{plot.parttree}, \link{geom_parttree}, \code{\link[rpart]{rpart}}, +\code{\link[partykit]{ctree}} \link[partykit:ctree]{partykit::ctree}. } diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd index 63789b2..486ff56 100644 --- a/man/plot.parttree.Rd +++ b/man/plot.parttree.Rd @@ -11,6 +11,8 @@ fill_alpha = 0.3, xlab = NULL, ylab = NULL, + add = FALSE, + expand = TRUE, ... ) } @@ -26,6 +28,12 @@ remove the borders altogether, specify as \code{NA}.} \item{fill_alpha}{Numeric in the range \verb{[0,1]}. Alpha transparency of the filled partition rectangles. Default is \code{0.3}.} +\item{add}{Logical. Add to an existing plot? Default is \code{FALSE}.} + +\item{expand}{Logical. Should the partition limits be expanded to to meet the +edge of the plot axes? Default is \code{TRUE}. If \code{FALSE}, then the partition +limits will extend only until the range of the raw data.} + \item{...}{Additional arguments passed down to \code{\link[graphics]{tinyplot}}\link{tinyplot}.} } From 5da9cf77173ca665f6f1c4303b7cfd90cee8841c Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Wed, 24 Jul 2024 19:45:02 -0700 Subject: [PATCH 15/25] Support jittering --- R/plot.R | 16 +++++++++++++--- man/plot.parttree.Rd | 12 +++++++++--- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/R/plot.R b/R/plot.R index 42a43bc..cb61165 100644 --- a/R/plot.R +++ b/R/plot.R @@ -8,10 +8,13 @@ #' remove the borders altogether, specify as `NA`. #' @param fill_alpha Numeric in the range `[0,1]`. Alpha transparency of the #' filled partition rectangles. Default is `0.3`. -#' @param add Logical. Add to an existing plot? Default is `FALSE`. #' @param expand Logical. Should the partition limits be expanded to to meet the #' edge of the plot axes? Default is `TRUE`. If `FALSE`, then the partition #' limits will extend only until the range of the raw data. +#' @param jitter Logical. Should the raw points be jittered? Default is `FALSE`. +#' Only evaluated if `raw = TRUE`. +#' @param xlab,ylab Character string(s). Custom labels for the x and y axes. +#' @param add Logical. Add to an existing plot? Default is `FALSE`. #' @param ... Additional arguments passed down to #' \code{\link[graphics]{tinyplot}}[tinyplot]. #' @param raw Logical. Should the raw (original) data points be plotted too? @@ -48,10 +51,11 @@ plot.parttree = raw = TRUE, border = "black", fill_alpha = 0.3, + expand = TRUE, + jitter = FALSE, xlab = NULL, ylab = NULL, add = FALSE, - expand = TRUE, ...) { xvar = attr(object, "parttree")[["xvar"]] @@ -110,6 +114,11 @@ plot.parttree = ) colnames(dobj) = c(response, xvar, yvar) + if (isTRUE(raw) && isTRUE(jitter)) { + dobj[[xvar]] = range(c(dobj[[xvar]], jitter(raw_data[[xvar]])), na.rm = TRUE) + dobj[[yvar]] = range(c(dobj[[yvar]], jitter(raw_data[[yvar]])), na.rm = TRUE) + } + tinyplot( plot_fml, data = dobj, @@ -149,10 +158,11 @@ plot.parttree = # Add the original data points (if requested) if (isTRUE(raw)) { + ptype = ifelse(isTRUE(jitter), "j", "p") tinyplot( plot_fml, data = raw_data, - type = "p", + type = ptype, add = TRUE, ... ) diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd index 486ff56..8a16b13 100644 --- a/man/plot.parttree.Rd +++ b/man/plot.parttree.Rd @@ -9,10 +9,11 @@ raw = TRUE, border = "black", fill_alpha = 0.3, + expand = TRUE, + jitter = FALSE, xlab = NULL, ylab = NULL, add = FALSE, - expand = TRUE, ... ) } @@ -28,12 +29,17 @@ remove the borders altogether, specify as \code{NA}.} \item{fill_alpha}{Numeric in the range \verb{[0,1]}. Alpha transparency of the filled partition rectangles. Default is \code{0.3}.} -\item{add}{Logical. Add to an existing plot? Default is \code{FALSE}.} - \item{expand}{Logical. Should the partition limits be expanded to to meet the edge of the plot axes? Default is \code{TRUE}. If \code{FALSE}, then the partition limits will extend only until the range of the raw data.} +\item{jitter}{Logical. Should the raw points be jittered? Default is \code{FALSE}. +Only evaluated if \code{raw = TRUE}.} + +\item{xlab, ylab}{Character string(s). Custom labels for the x and y axes.} + +\item{add}{Logical. Add to an existing plot? Default is \code{FALSE}.} + \item{...}{Additional arguments passed down to \code{\link[graphics]{tinyplot}}\link{tinyplot}.} } From 7654f598ba4f24840add1377ab697d4cb3ee23c7 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 10:21:27 -0700 Subject: [PATCH 16/25] Update vignettes --- vignettes/parttree-art.Rmd | 87 +++++++++--- vignettes/parttree-intro.Rmd | 268 ++++++++++++++++++++--------------- 2 files changed, 216 insertions(+), 139 deletions(-) diff --git a/vignettes/parttree-art.Rmd b/vignettes/parttree-art.Rmd index b15bbc7..5a8c842 100644 --- a/vignettes/parttree-art.Rmd +++ b/vignettes/parttree-art.Rmd @@ -29,6 +29,8 @@ library(parttree) # This package library(rpart) # For decision trees library(magick) # For reading and manipulating images library(imager) # Another image library, with some additional features + +op = par(mar = c(0,0,0,0)) # Remove plot margins ``` While the exact details will vary depending on the image at hand, the essential @@ -71,7 +73,8 @@ manipulation.] rosalba = image_read("https://upload.wikimedia.org/wikipedia/commons/a/aa/Rembrandt_Peale_-_Portrait_of_Rosalba_Peale_-_Google_Art_Project.jpg") # Crop around the eyes -rosalba = image_crop(rosalba, "855x450+890+1350") +rosalba = image_crop(rosalba, "850x400+890+1350") +# rosalba = image_crop(rosalba, "750x350+890+1350") # Convert to cimg (better for in-memory manipulation) rosalba = magick2cimg(rosalba) @@ -85,7 +88,7 @@ With our cropped image in hand, let's walk through the 4-step recipe from above. Step 1 is converting the image into a data frame. -```{r} +```{r rosalba_df} # Coerce to data frame rosalba_df = as.data.frame(rosalba) @@ -98,7 +101,7 @@ head(rosalba_df) Step 2 is splitting the image by RGB colour channel. This is the `cc` column above, where 1=Red, 2=Green, and 3=Blue. -```{r} +```{r rosalba_ccs} rosalba_ccs = split(rosalba_df, rosalba_df$cc) # We have a list of three DFs by colour channel. Uncomment if you want to see: @@ -112,13 +115,14 @@ we see more variation in the final predictions) and trimming each tree to a maximum depth of 30 nodes. The next code chunk takes about 15 seconds to run on my laptop, but should be much quicker if you downloaded a lower-res image. -```{r} +```{r trees} ## Start creating regression tree for each color channel. We'll adjust some ## control parameters to give us the "right" amount of resolution in the final ## plots. trees = lapply( rosalba_ccs, - function(d) rpart(value ~ x + y, data=d, control=list(cp=0.00001, maxdepth=20)) + # function(d) rpart(value ~ x + y, data=d, control=list(cp=0.00001, maxdepth=20)) + function(d) rpart(value ~ x + y, data=d, control=list(cp=0.00002, maxdepth=20)) ) ``` @@ -126,7 +130,7 @@ Step 4 is using our model (colour) predictions to construct our abstracted art piece. I was bit glib about it earlier, since it really involves a few sub-steps. First, let's grab the predictions for each of our trees. -```{r} +```{r pred_trees} pred = lapply(trees, predict) # get predictions for each tree ``` @@ -138,7 +142,7 @@ overwriting the "value" column of our original (pre-split) `rosalba_df` data frame. We can then coerce the data frame back into a `cimg` object, which comes with a bunch of nice plotting methods. -```{r} +```{r pred_img} # The pred object is a list, so we convert it to a vector before overwriting the # value column of the original data frame rosalba_df$value = do.call("c", pred) @@ -152,10 +156,7 @@ Now we're ready to draw our abstracted art piece. It's also where the partitioned areas of the downscaled pixels. Here's how we can do it using base R plotting functions. -```{r} -## Maximum/minimum for plotting range as base rect() does not handle Inf well -m = 1000 - +```{r rosalba_abstract} # get a list of parttree data frames (one for each tree) pts = lapply(trees, parttree) @@ -164,22 +165,23 @@ plot(pred_img, axes = FALSE) ## ... then layer the partitions as a series of rectangles lapply( pts, - function(pt) rect( - pmax(-m, pt$xmin), pmax(-m, pt$ymin), pmin(m, pt$xmax), pmin(m, pt$ymax), - lwd = 0.06, border = "grey15" - ) + function(pt) plot( + pt, raw = FALSE, add = TRUE, expand = FALSE, + fill_alpha = NULL, lwd = 0.1, border = "grey15" ) +) ``` We can achieve the same effect with **ggplot2** if you prefer to use that. -```{r} +```{r rosalba_abstract_gg} +library(ggplot2) ggplot() + annotation_raster(pred_img, ymin=-Inf, ymax=Inf, xmin=-Inf, xmax=Inf) + lapply(trees, function(d) geom_parttree(data = d, lwd = 0.05, col = "grey15")) + scale_x_continuous(limits=c(0, max(rosalba_df$x)), expand=c(0,0)) + scale_y_reverse(limits=c(max(rosalba_df$y), 0), expand=c(0,0)) + - coord_fixed(ratio = 0.9) + + coord_fixed(ratio = Reduce(x = dim(rosalba)[2:1], f = "/") * 2) + theme_void() ``` @@ -196,7 +198,7 @@ Here are the main image conversion, modeling, and prediction steps. These follow the same recipe that we saw in the previous portrait example, so I won't repeat my explanations. -```{r} +```{r bonzai} bonzai = load.image("https://upload.wikimedia.org/wikipedia/commons/thumb/0/0c/Japanese_Zelkova_bonsai_16%2C_30_April_2012.JPG/480px-Japanese_Zelkova_bonsai_16%2C_30_April_2012.JPG") plot(bonzai, axes = FALSE) @@ -214,7 +216,7 @@ bonzai_ccs = split(bonzai_df, bonzai_df$cc) bonzai_trees = lapply( bonzai_ccs, function(d) rpart(value ~ x + y, data=d, control=list(cp=0.00001, maxdepth=10)) - ) +) # Overwrite value column with predictions vector bonzai_df$value = do.call("c", lapply(bonzai_trees, predict)) @@ -232,7 +234,7 @@ I first saw this trick or adapted this function from. But it works particularly well in cases like this where we want the partition lines to blend in with the main image.] -```{r} +```{r bonzai_mean_cols} mean_cols = function(dat) { mcols = tapply(dat$value, dat$cc, FUN = "mean") col1 = rgb(mcols[1], mcols[2], mcols[3]) @@ -246,9 +248,33 @@ mean_cols = function(dat) { bonzai_mean_cols = mean_cols(bonzai_df) ``` +The penultimate step is to generate `parttree` objects from each of our colour +channel-based trees. + +```{r bonza_pts} +bonzai_pts = lapply(bonzai_trees, parttree) +``` + And now we can plot everything together. +```{r bonza_abstract} +plot(bonzai_pred_img, axes = FALSE) +Map( + function(pt, cols) { + plot( + pt, raw = FALSE, add = TRUE, expand = FALSE, + fill_alpha = 0, lwd = 0.2, border = cols + ) + }, + pt = bonzai_pts, + cols = bonzai_mean_cols +) +``` + +Again, equivalent result for those prefer **ggplot2**. + ```{r} +# library(ggplot2) ggplot() + annotation_raster(bonzai_pred_img, ymin=-Inf, ymax=Inf, xmin=-Inf, xmax=Inf) + Map(function(d, cols) geom_parttree(data = d, lwd = 0.1, col = cols), d = bonzai_trees, cols = bonzai_mean_cols) + @@ -262,7 +288,7 @@ ggplot() + The individual trees for each colour channel make for nice stained glass prints... -```{r} +```{r, eval=FALSE, include=FALSE} library(patchwork) ## Aside: We're reversing the y-scale since higher values actually correspond @@ -286,4 +312,23 @@ g = g[[1]] + g[[2]] + g[[3]] ``` +```{r bonzai_glass} +lapply( + seq_along(bonzai_pts), + function(i) { + plot( + bonzai_pts[[i]], raw = FALSE, expand = FALSE, + axes = FALSE, legend = FALSE, + main = paste0(c("R", "G", "B")[i]), + ## Aside: We're reversing the y-scale since higher values actually + ## correspond to points lower on the image, visually. + ylim = rev(attr(bonzai_pts[[i]], "parttree")[["yrange"]]) + ) + } +) +``` +```{r reset_par} +# reset the plot margins +par(op) +``` diff --git a/vignettes/parttree-intro.Rmd b/vignettes/parttree-intro.Rmd index 3554ec0..878ef6f 100644 --- a/vignettes/parttree-intro.Rmd +++ b/vignettes/parttree-intro.Rmd @@ -15,11 +15,10 @@ knitr::opts_chunk$set( ) ``` -## Basic use +## Motivating example: Classifying penguin species -Let's start by loading the **parttree** package alongside **rpart**, which comes -bundled with the base R installation and is what we'll use for fitting our -decision trees (at least, to start with). For the basic examples that follow, +Start by loading the **parttree** package alongside **rpart**, which comes +bundled with the base R installation. For the basic examples that follow, I'll use the well-known [Palmer Penguins](https://allisonhorst.github.io/palmerpenguins/) dataset to demonstrate functionality. You can load this dataset via the parent package (as @@ -27,74 +26,108 @@ I have here), or import it directly as a CSV [here](https://vincentarelbundock.github.io/Rdatasets/csv/palmerpenguins/penguins.csv). ```{r setup} +library(parttree) # This package library(rpart) # For fitting decisions trees -library(parttree) # This package (will automatically load ggplot2 too) - -theme_set(theme_linedraw()) # install.packages("palmerpenguins") data("penguins", package = "palmerpenguins") head(penguins) ``` -### Categorical predictions +Dataset in hand, let's say that we are interested in predicting penguin +_species_ as a function of 1) flipper length and 2) bill length. We could model +this as a simple decision tree: + +```{r tree} +tree = rpart(species ~ flipper_length_mm + bill_length_mm, data = penguins) +tree +``` -Say we are interested in predicting the penguins _species_ as a function of 1) -flipper length and 2) bill length. We can visualize these relationships as a -simple scatter plot prior to doing any formal modeling. +Like most tree-based frameworks, **rpart** comes with a default `plot` method +for visualizing the resulting node splits. -```{r penguin_plot_cat1} -p = - ggplot(data = penguins, aes(x = flipper_length_mm, y = bill_length_mm)) + - geom_point(aes(col = species)) -p +```{r rpart_plot} +plot(tree, compress = TRUE) +text(tree, use.n = TRUE) ``` -Recasting in terms of a decision tree is easily done (e.g., with `rpart`). -However, visualizing the resulting tree predictions against the raw data is hard -to do out of the box and this where **parttree** enters the fray. The main -function that users will interact with is `geom_parttree()`, which provides a -new geom layer for **ggplot2** objects. +While this is okay, I don't feel that it provides much intuition about the +model's prediction on the _scale of the actual data_. In other words, what I'd +prefer to see is: How has our tree partitioned the original penguin data? +This is where **parttree** enters the fray. The package is named for its primary +workhorse function `parttree()`, which extracts all of the information needed +to produce a nice plot of our tree partitions alongside the original data. -```{r penguin_plot_cat2} -## Fit a decision tree using the same variables as the above plot -tree = rpart(species ~ flipper_length_mm + bill_length_mm, data = penguins) +```{r penguin_cl_plot} +ptree = parttree(tree) +plot(ptree) +``` + +_Et voila!_ Now we can clearly see how our model has divided up the Cartesian +space of the data. Gentoo penguins typically have longer flippers than Chinstrap +or Adelie penguins, while the latter have the shortest bills. + +From the perspective of the end-user, the `ptree` parttree object is not all +that interesting in of itself. It is simply a data frame that contains the basic +information needed for our plot (partition coordinates, etc.). You can think of +it as a helpful intermediate object on our way to the visualization of interest. -## Visualize the tree partitions by adding it to our plot with geom_parttree() -p + - geom_parttree(data = tree, aes(fill=species), alpha = 0.1) + - labs(caption = "Note: Points denote observations. Shaded regions denote model predictions.") +```{r ptree} +# See also `attr(ptree, "parttree")` +ptree ``` -#### Continuous predictions +Speaking of visualization, underneath the hood `plot.parttree` calls the +powerful +[**tinyplot**](https://grantmcdermott.com/tinyplot) +package. All of the latter's various customization arguments can be passed on to +our `parttree` plot to make it look a bit nicer. For example: -Trees with continuous independent variables are also supported. However, I -recommend adjusting the plot fill aesthetic since your model will likely -partition the data into intervals that don't match up exactly with the raw data. -The easiest way to do this is by setting your colour and fill aesthetic together -as part of the same `scale_colour_*` call. +```{r penguin_cl_plot_custom} +plot(ptree, pch = 16, palette = "classic", alpha = 0.75, grid = TRUE) +``` -```{r penguin_plot_con} -tree2 = rpart(body_mass_g ~ flipper_length_mm + bill_length_mm, data=penguins) -ggplot(data = penguins, aes(x = flipper_length_mm, y = bill_length_mm)) + - geom_parttree(data = tree2, aes(fill=body_mass_g), alpha = 0.3) + - geom_point(aes(col = body_mass_g)) + - scale_colour_viridis_c(aesthetics = c('colour', 'fill')) # NB: Set colour + fill together +### Continuous predictions + +In addition to discrete classification problems, **parttree** also supports +regression trees with continuous independent variables. + +```{r penguin_reg_plot} +tree_cont = rpart(body_mass_g ~ flipper_length_mm + bill_length_mm, data = penguins) + +tree_cont |> + parttree() |> + plot(pch = 16, palette = "viridis") ``` + ## Supported model classes -Currently, the package works with decision trees created by the -[**rpart**](https://CRAN.R-project.org/web/package=rpart) and -[**partykit**](https://CRAN.R-project.org/web/package=partykit) packages. -Moreover, it supports other front-end modes that call `rpart::rpart()` as -the underlying engine; in particular the -[**tidymodels**](https://www.tidymodels.org/) ([parsnip](https://parsnip.tidymodels.org/) -or [workflows](https://workflows.tidymodels.org/)) and -[**mlr3**](https://mlr3.mlr-org.com/) packages. Here's a quick example with -**parsnip**. +Alongside the [**rpart**](https://CRAN.R-project.org/web/package=rpart) model +objects that we have been working with thus far, **parttree** also supports +decision trees created by the +[**partykit**](https://CRAN.R-project.org/web/package=partykit) package. Here we +see how the latter's `ctree` (conditional inference tree) algorithm yields a +slightly more sophisticated partitioning that the former's default. + +```{r penguin_ctree_plot} +library(partykit) + +ctree(species ~ flipper_length_mm + bill_length_mm, data = penguins) |> + parttree() |> + plot(pch = 19, palette = "classic", alpha = 0.5) +``` + +**parttree** also supports a variety of "frontend" modes that call +`rpart::rpart()` as the underlying engine. This includes packages from both the +[**mlr3**](https://mlr3.mlr-org.com/) and +[**tidymodels**](https://www.tidymodels.org/) +([parsnip](https://parsnip.tidymodels.org/) +or [workflows](https://workflows.tidymodels.org/)) +ecosystems. Here is a quick demonstration using **parsnip**, where we'll also +pull in a different dataset just to change things up a little. ```{r titanic_plot} set.seed(123) ## For consistent jitter @@ -110,51 +143,81 @@ ti_tree = set_engine("rpart") |> set_mode("classification") |> fit(Survived ~ Pclass + Age, data = titanic_train) +## Now pass to parttree and plot +ti_tree |> + parttree() |> + plot(pch = 16, jitter = TRUE, palette = "dark", alpha = 0.7) +``` + +## ggplot2 + +The default `plot.parttree` method produces a base graphics plot. But we also +support **ggplot2** via with a dedicated `geom_parttree()` function. Here we +demonstrate with our initial classification tree from earlier. + +```{r penguin_cl_ggplot2} +library(ggplot2) +theme_set(theme_linedraw()) -## Plot the data and model partitions -titanic_train |> - ggplot(aes(x=Pclass, y=Age)) + - geom_parttree(data = ti_tree, aes(fill=Survived), alpha = 0.1) + - geom_jitter(aes(col=Survived), alpha=0.7) +## re-using the tree model object from above... +ggplot(data = penguins, aes(x = flipper_length_mm, y = bill_length_mm)) + + geom_point(aes(col = species)) + + geom_parttree(data = tree, aes(fill=species), alpha = 0.1) ``` -## Plot orientation +Compared to the "native" `plot.parttree` method, note that the **ggplot2** +workflow requires a few tweaks: -Underneath the hood, `geom_parttree()` is calling the companion `parttree()` -function, which coerces the **rpart** tree object into a data frame that is -easily understood by **ggplot2**. For example, consider again our first "tree" -model from earlier. Here's the print output of the raw model. +- We need to need to plot the original dataset as a separate layer (i.e., `geom_point()`). +- `geom_parttree()` accepts the tree object _itself_, not the result of `parttree()`.^[This is because `geom_parttree(data = )` calls `parttree()` internally.] -```{r tree} -tree +Continuous regression trees can also be drawn with `geom_parttree`. However, I +recommend adjusting the plot fill aesthetic since your model will likely +partition the data into intervals that don't match up exactly with the raw data. +The easiest way to do this is by setting your colour and fill aesthetic together +as part of the same `scale_colour_*` call. + +```{r penguin_reg_ggplot2} +## re-using the tree_cont model object from above... +ggplot(data = penguins, aes(x = flipper_length_mm, y = bill_length_mm)) + + geom_parttree(data = tree_cont, aes(fill=body_mass_g), alpha = 0.3) + + geom_point(aes(col = body_mass_g)) + + scale_colour_viridis_c(aesthetics = c('colour', 'fill')) # NB: Set colour + fill together ``` -And here's what we get after we feed it to `parttree()`. +### Gotcha: (gg)plot orientation + +As we have already said, `geom_parttree()` calls the companion `parttree()` +function internally, which coerces the **rpart** tree object into a data frame +that is easily understood by **ggplot2**. For example, consider our initial +"ptree" object from earlier. -```{r tree_parted} -parttree(tree) +```{r ptree_redux} +# ptree = parttree(tree) +ptree ``` Again, the resulting data frame is designed to be amenable to a **ggplot2** geom -layer, with columns like `xmin`, `xmax`, etc. specifying aesthetics that -**ggplot2** recognises. (Fun fact: `geom_parttree()` is really just a thin -wrapper around `geom_rect()`.) The goal of the package is to abstract away these -kinds of details -from the user, so we can just specify `geom_parttree()` — with a valid -tree object as the data input — and be done with it. However, while this -generally works well, it can sometimes lead to unexpected behaviour in terms of -plot orientation. That's because it's hard to guess ahead of time what the user -will specify as the x and y variables (i.e. axes) in their other plot layers. To -see what I mean, let's redo our penguin plot from earlier, but this time switch -the axes in the main `ggplot()` call. - -```{r tree_plot_mismatch} +layer, with columns like `xmin`, `xmax`, etc. specifying aesthetics that +**ggplot2** recognizes. (Fun fact: `geom_parttree()` is really just a thin +wrapper around `geom_rect()`.) The goal of **parttree** is to abstract away +these kinds of details from the user, so that they can just specify +`geom_parttree()`—with a valid tree object as the data input—and be +done with it. However, while this generally works well, it can sometimes lead to +unexpected behaviour in terms of plot orientation. That's because it's hard to +guess ahead of time what the user will specify as the x and y variables (i.e. +axes) in their other **ggplot2** layers.^[The default `plot.partree` method +doesn't have this problem because it assigns the x and y variables for both the +partitions and raw data points as part of the same function call.] To see what I +mean, let's redo our penguin plot from earlier, but this time switch the axes in +the main `ggplot()` call. + +```{r penguin_cl_ggplot_mismatch} ## First, redo our first plot but this time switch the x and y variables -p3 = - ggplot( - data = penguins, - aes(x = bill_length_mm, y = flipper_length_mm) ## Switched! - ) + +p3 = ggplot( + data = penguins, + aes(x = bill_length_mm, y = flipper_length_mm) ## Switched! + ) + geom_point(aes(col = species)) ## Add on our tree (and some preemptive titling..) @@ -163,49 +226,18 @@ p3 + labs( title = "Oops!", subtitle = "Looks like a mismatch between our x and y axes..." - ) + ) ``` As was the case here, this kind of orientation mismatch is normally (hopefully) -pretty easy to recognize. To fix, we can use the `flipaxes = TRUE` argument to +pretty easy to recognize. To fix, we can use the `flip = TRUE` argument to flip the orientation of the `geom_parttree` layer. -```{r tree_plot_flip} +```{r penguin_cl_ggplot_mismatch_flip} p3 + geom_parttree( data = tree, aes(fill = species), alpha = 0.1, - flipaxes = TRUE ## Flip the orientation - ) + + flip = TRUE ## Flip the orientation + ) + labs(title = "That's better") ``` - -## Base graphics - -While the package has been primarily designed to work with **ggplot2**, the -`parttree()` infrastructure can also be used to generate plots with base -graphics. Here, the `ctree()` function from **partykit** is used for fitting -the tree. - -```{r ctree_base_graphics} -library(partykit) - -## CTree and corresponding partition -ct = ctree(species ~ flipper_length_mm + bill_length_mm, data = penguins) -pt = parttree(ct) - -## Color palette -pal = palette.colors(4, "R4")[-1] - -## Maximum/minimum for plotting range as rect() does not handle Inf well -m = 1000 - -## scatter plot() with added rect() -plot( - bill_length_mm ~ flipper_length_mm, - data = penguins, col = pal[species], pch = 19 - ) -rect( - pmax(-m, pt$xmin), pmax(-m, pt$ymin), pmin(m, pt$xmax), pmin(m, pt$ymax), - col = adjustcolor(pal, alpha.f = 0.1)[pt$species] - ) -``` From 1c4dcdf29b09687187ee442b20f632d13742d5e4 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 10:21:51 -0700 Subject: [PATCH 17/25] namespace and check fixes --- .Rbuildignore | 1 + DESCRIPTION | 10 +- NAMESPACE | 2 + R/parttree.R | 37 +++++-- R/plot.R | 251 ++++++++++++++++++++----------------------- man/parttree.Rd | 33 +++++- man/plot.parttree.Rd | 55 ++++++---- plot_parttree.R | 116 -------------------- 8 files changed, 218 insertions(+), 287 deletions(-) delete mode 100644 plot_parttree.R diff --git a/.Rbuildignore b/.Rbuildignore index 5c6e83d..d2fede9 100644 --- a/.Rbuildignore +++ b/.Rbuildignore @@ -6,3 +6,4 @@ ^_pkgdown\.yml$ ^docs$ ^pkgdown$ +^SCRATCH$ diff --git a/DESCRIPTION b/DESCRIPTION index 7a6306e..f7b2a9c 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -12,8 +12,7 @@ Authors@R: c( role = "ctb", email = "Achim.Zeileis@R-project.org", comment = c(ORCID = "0000-0003-0918-3766")), - person(given = "Brian", - middle = "Heseung", + person(given = "Brian Heseung", family = "Kim", role = "ctb", email = "brhkim@gmail.com", @@ -32,12 +31,14 @@ LazyData: true URL: https://github.com/grantmcdermott/parttree, http://grantmcdermott.com/parttree BugReports: https://github.com/grantmcdermott/parttree/issues -Imports: - rpart, +Imports: + graphics, + stats, data.table, ggplot2 (>= 3.4.0), partykit, rlang, + rpart, tinyplot (> 0.1.0) Suggests: tinytest, @@ -48,7 +49,6 @@ Suggests: workflows, magick, imager, - patchwork, knitr, rmarkdown Remotes: grantmcdermott/tinyplot diff --git a/NAMESPACE b/NAMESPACE index 86b8b08..3918a51 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -17,4 +17,6 @@ importFrom(ggplot2,aes) importFrom(ggplot2,aes_all) importFrom(ggplot2,ggproto) importFrom(ggplot2,layer) +importFrom(graphics,par) +importFrom(stats,reformulate) importFrom(tinyplot,tinyplot) diff --git a/R/parttree.R b/R/parttree.R index c4096bc..0c5e728 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -20,20 +20,43 @@ #' @returns A data frame comprising seven columns: the leaf node, its path, a #' set of rectangle limits (i.e., xmin, xmax, ymin, ymax), and a final column #' corresponding to the predicted value for that leaf. -#' @importFrom data.table := -#' @importFrom data.table .SD -#' @importFrom data.table fifelse +#' @importFrom data.table := .SD fifelse #' @export #' @examples #' ## rpart trees +#' #' library("rpart") -#' rp = rpart(Species ~ Petal.Length + Petal.Width, data = iris) -#' parttree(rp) +#' rp = rpart(Kyphosis ~ Start + Age, data = kyphosis) +#' +#' # A parttree object is just a data frame with additional attributes +#' (rp_pt = parttree(rp)) +#' attr(rp_pt, "parttree") +#' +#' # simple plot +#' plot(rp_pt) +#' +#' # removing the (recursive) partition borders helps to emphasise overall fit +#' plot(rp_pt, border = NA) +#' +#' # customize further by passing extra options to (tiny)plot +#' plot( +#' rp_pt, +#' border = NA, # no partition borders +#' pch = 19, # filled points +#' alpha = 0.6, # point transparency +#' grid = TRUE, # background grid +#' palette = "classic", # new colour palette +#' xlab = "Topmost vertebra operated on", # custom x title +#' ylab = "Patient age (months)", # custom y title +#' main = "Tree predictions: Kyphosis recurrence" # custom title +#' ) +#' +#' ## conditional inference trees from partyit #' -#' ## conditional inference trees #' library("partykit") #' ct = ctree(Species ~ Petal.Length + Petal.Width, data = iris) -#' parttree(ct) +#' ct_pt = parttree(ct) +#' plot(ct_pt, pch = 19, palette = "okabe", main = "ctree predictions: iris species") #' #' ## rpart via partykit #' rp2 = as.party(rp) diff --git a/R/plot.R b/R/plot.R index cb61165..00e2d0c 100644 --- a/R/plot.R +++ b/R/plot.R @@ -1,7 +1,7 @@ #' @title Plot decision tree partitions #' @description Provides a plot method for parttree objects. #' @returns No return value, called for side effect of producing a plot. -#' @param object A [parttree] data frame. +#' @param x A [parttree] data frame. #' @param raw Logical. Should the raw (i.e., original) data be plotted alongside #' the tree partitions? Default is `TRUE`. #' @param border Colour of the partition borders (edges). Default is "black". To @@ -16,157 +16,136 @@ #' @param xlab,ylab Character string(s). Custom labels for the x and y axes. #' @param add Logical. Add to an existing plot? Default is `FALSE`. #' @param ... Additional arguments passed down to -#' \code{\link[graphics]{tinyplot}}[tinyplot]. +#' \code{\link[tinyplot]{tinyplot}}. #' @param raw Logical. Should the raw (original) data points be plotted too? #' Default is TRUE. +#' @returns No return value; called for its side effect of producing a plot. +#' @importFrom stats reformulate +#' @importFrom graphics par #' @importFrom tinyplot tinyplot +#' @rdname plot.parttree +#' @inherit parttree examples #' @export -#' @examples -#' ## rpart tree example -#' library("rpart") -#' rp = rpart(Kyphosis ~ Start + Age, data = kyphosis) -#' pt = parttree(rp) -#' -#' ## simple plot -#' plot(pt) -#' -#' ## removing the (recursive) partition borders helps to emphasise overall fit -#' plot(pt, border = NA) -#' -#' ## customize further by passing extra options to (tiny)plot -#' plot( -#' pt, -#' border = NA, # no partition borders -#' pch = 19, # filled points -#' alpha = 0.6, # point transparency -#' grid = TRUE, # background grid -#' palette = "classic", # new colour palette -#' xlab = "Topmost vertebra operated on", # custom x title -#' ylab = "Patient age (months)", # custom y title -#' main = "Tree predictions: Kyphosis recurrence" # custom title -#' ) -plot.parttree = - function( - object, - raw = TRUE, - border = "black", - fill_alpha = 0.3, - expand = TRUE, - jitter = FALSE, - xlab = NULL, - ylab = NULL, - add = FALSE, - ...) { - - xvar = attr(object, "parttree")[["xvar"]] - yvar = attr(object, "parttree")[["yvar"]] - xrange = attr(object, "parttree")[["xrange"]] - yrange = attr(object, "parttree")[["yrange"]] - response = attr(object, "parttree")[["response"]] - raw_data = attr(object, "parttree")[["raw_data"]] - orig_call = attr(object, "parttree")[["call"]] - orig_na_idx = attr(object, "parttree")[["na.action"]] - - if (is.null(xlab)) xlab = xvar - if (is.null(ylab)) ylab = yvar - - if (isTRUE(raw)) { - if (!is.null(raw_data)) { - raw_data = eval(raw_data) - } else { - raw_data = eval(orig_call$data)[, c(response, xvar, yvar)] - if (!is.null(orig_na_idx)) raw_data = raw_data[-orig_na_idx, , drop = FALSE] - } - if (is.null(raw_data) || (is.atomic(raw_data) && is.na(raw_data))){ - warning( - "\nCould not find original data. Setting `raw = FALSE`.\n" - ) - raw = FALSE - } - +plot.parttree = function( + x, + raw = TRUE, + border = "black", + fill_alpha = 0.3, + expand = TRUE, + jitter = FALSE, + xlab = NULL, + ylab = NULL, + add = FALSE, + ... + ) { + object = x + xvar = attr(object, "parttree")[["xvar"]] + yvar = attr(object, "parttree")[["yvar"]] + xrange = attr(object, "parttree")[["xrange"]] + yrange = attr(object, "parttree")[["yrange"]] + response = attr(object, "parttree")[["response"]] + raw_data = attr(object, "parttree")[["raw_data"]] + orig_call = attr(object, "parttree")[["call"]] + orig_na_idx = attr(object, "parttree")[["na.action"]] + + if (is.null(xlab)) xlab = xvar + if (is.null(ylab)) ylab = yvar + + if (isTRUE(raw)) { + if (!is.null(raw_data)) { + raw_data = eval(raw_data) + } else { + raw_data = eval(orig_call$data)[, c(response, xvar, yvar)] + if (!is.null(orig_na_idx)) raw_data = raw_data[-orig_na_idx, , drop = FALSE] + } + if (is.null(raw_data) || (is.atomic(raw_data) && is.na(raw_data))) { + warning( + "\nCould not find original data. Setting `raw = FALSE`.\n" + ) + raw = FALSE } + } - ## First adjust our parttree object to better fit some base R graphics - ## requirements + ## First adjust our parttree object to better fit some base R graphics + ## requirements - xmin_idxr = object$xmin == -Inf - xmax_idxr = object$xmax == Inf - ymin_idxr = object$ymin == -Inf - ymax_idxr = object$ymax == Inf + xmin_idxr = object$xmin == -Inf + xmax_idxr = object$xmax == Inf + ymin_idxr = object$ymin == -Inf + ymax_idxr = object$ymax == Inf - object$xmin[xmin_idxr] = xrange[1] - object$xmax[xmax_idxr] = xrange[2] - object$ymin[ymin_idxr] = yrange[1] - object$ymax[ymax_idxr] = yrange[2] + object$xmin[xmin_idxr] = xrange[1] + object$xmax[xmax_idxr] = xrange[2] + object$ymin[ymin_idxr] = yrange[1] + object$ymax[ymax_idxr] = yrange[2] - ## Start plotting... + ## Start plotting... - plot_fml = reformulate(paste(xvar, "|", response), response = yvar) + plot_fml = reformulate(paste(xvar, "|", response), response = yvar) - # First draw an empty plot (since we need the plot corners to correctly - # expand the partition limits to the edges of the plot). We'll create a - # dummy object for this task. - if (isFALSE(add)) { - dobj = data.frame( - response = rep(object[[response]], 2), - x = c(object[["xmin"]], object[["xmax"]]), - y = c(object[["ymin"]], object[["ymax"]]) - ) - colnames(dobj) = c(response, xvar, yvar) - - if (isTRUE(raw) && isTRUE(jitter)) { - dobj[[xvar]] = range(c(dobj[[xvar]], jitter(raw_data[[xvar]])), na.rm = TRUE) - dobj[[yvar]] = range(c(dobj[[yvar]], jitter(raw_data[[yvar]])), na.rm = TRUE) - } - - tinyplot( - plot_fml, - data = dobj, - type = "rect", - col = border, - fill = fill_alpha, - empty = TRUE, - ... - ) - } - - object$response = object[[response]] + # First draw an empty plot (since we need the plot corners to correctly + # expand the partition limits to the edges of the plot). We'll create a + # dummy object for this task. + if (isFALSE(add)) { + dobj = data.frame( + response = rep(object[[response]], 2), + x = c(object[["xmin"]], object[["xmax"]]), + y = c(object[["ymin"]], object[["ymax"]]) + ) + colnames(dobj) = c(response, xvar, yvar) - # Grab the plot corners and adjust the partition limits - if (isTRUE(expand)) { - corners = par("usr") - object$xmin[xmin_idxr] = corners[1] - object$xmax[xmax_idxr] = corners[2] - object$ymin[ymin_idxr] = corners[3] - object$ymax[ymax_idxr] = corners[4] + if (isTRUE(raw) && isTRUE(jitter)) { + dobj[[xvar]] = range(c(dobj[[xvar]], jitter(raw_data[[xvar]])), na.rm = TRUE) + dobj[[yvar]] = range(c(dobj[[yvar]], jitter(raw_data[[yvar]])), na.rm = TRUE) } - - # Add the (adjusted) partition rectangles - with( - object, - tinyplot( - xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, - by = response, - type = "rect", - add = TRUE, - col = border, - fill = fill_alpha, - ... - ) + tinyplot( + plot_fml, + data = dobj, + type = "rect", + col = border, + fill = fill_alpha, + empty = TRUE, + ... ) + } + + object$response = object[[response]] + + # Grab the plot corners and adjust the partition limits + if (isTRUE(expand)) { + corners = par("usr") + object$xmin[xmin_idxr] = corners[1] + object$xmax[xmax_idxr] = corners[2] + object$ymin[ymin_idxr] = corners[3] + object$ymax[ymax_idxr] = corners[4] + } - # Add the original data points (if requested) - if (isTRUE(raw)) { - ptype = ifelse(isTRUE(jitter), "j", "p") - tinyplot( - plot_fml, - data = raw_data, - type = ptype, - add = TRUE, - ... - ) - } + # Add the (adjusted) partition rectangles + with( + object, + tinyplot( + xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax, + by = response, + type = "rect", + add = TRUE, + col = border, + fill = fill_alpha, + ... + ) + ) + + # Add the original data points (if requested) + if (isTRUE(raw)) { + ptype = ifelse(isTRUE(jitter), "j", "p") + tinyplot( + plot_fml, + data = raw_data, + type = ptype, + add = TRUE, + ... + ) } +} diff --git a/man/parttree.Rd b/man/parttree.Rd index 5738e21..1ff8f9e 100644 --- a/man/parttree.Rd +++ b/man/parttree.Rd @@ -39,14 +39,39 @@ leaf or terminal node) that can easily be plotted in 2-D coordinate space. } \examples{ ## rpart trees + library("rpart") -rp = rpart(Species ~ Petal.Length + Petal.Width, data = iris) -parttree(rp) +rp = rpart(Kyphosis ~ Start + Age, data = kyphosis) + +# A parttree object is just a data frame with additional attributes +(rp_pt = parttree(rp)) +attr(rp_pt, "parttree") + +# simple plot +plot(rp_pt) + +# removing the (recursive) partition borders helps to emphasise overall fit +plot(rp_pt, border = NA) + +# customize further by passing extra options to (tiny)plot +plot( + rp_pt, + border = NA, # no partition borders + pch = 19, # filled points + alpha = 0.6, # point transparency + grid = TRUE, # background grid + palette = "classic", # new colour palette + xlab = "Topmost vertebra operated on", # custom x title + ylab = "Patient age (months)", # custom y title + main = "Tree predictions: Kyphosis recurrence" # custom title +) + +## conditional inference trees from partyit -## conditional inference trees library("partykit") ct = ctree(Species ~ Petal.Length + Petal.Width, data = iris) -parttree(ct) +ct_pt = parttree(ct) +plot(ct_pt, pch = 19, palette = "okabe", main = "ctree predictions: iris species") ## rpart via partykit rp2 = as.party(rp) diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd index 8a16b13..6200d4b 100644 --- a/man/plot.parttree.Rd +++ b/man/plot.parttree.Rd @@ -5,7 +5,7 @@ \title{Plot decision tree partitions} \usage{ \method{plot}{parttree}( - object, + x, raw = TRUE, border = "black", fill_alpha = 0.3, @@ -18,7 +18,7 @@ ) } \arguments{ -\item{object}{A \link{parttree} data frame.} +\item{x}{A \link{parttree} data frame.} \item{raw}{Logical. Should the raw (original) data points be plotted too? Default is TRUE.} @@ -41,36 +41,53 @@ Only evaluated if \code{raw = TRUE}.} \item{add}{Logical. Add to an existing plot? Default is \code{FALSE}.} \item{...}{Additional arguments passed down to -\code{\link[graphics]{tinyplot}}\link{tinyplot}.} +\code{\link[tinyplot]{tinyplot}}.} } \value{ No return value, called for side effect of producing a plot. + +No return value; called for its side effect of producing a plot. } \description{ Provides a plot method for parttree objects. } \examples{ -## rpart tree example +## rpart trees + library("rpart") rp = rpart(Kyphosis ~ Start + Age, data = kyphosis) -pt = parttree(rp) -## simple plot -plot(pt) +# A parttree object is just a data frame with additional attributes +(rp_pt = parttree(rp)) +attr(rp_pt, "parttree") + +# simple plot +plot(rp_pt) -## removing the (recursive) partition borders helps to emphasise overall fit -plot(pt, border = NA) +# removing the (recursive) partition borders helps to emphasise overall fit +plot(rp_pt, border = NA) -## customize further by passing extra options to (tiny)plot +# customize further by passing extra options to (tiny)plot plot( - pt, - border = NA, # no partition borders - pch = 19, # filled points - alpha = 0.6, # point transparency - grid = TRUE, # background grid - palette = "classic", # new colour palette - xlab = "Topmost vertebra operated on", # custom x title - ylab = "Patient age (months)", # custom y title - main = "Tree predictions: Kyphosis recurrence" # custom title + rp_pt, + border = NA, # no partition borders + pch = 19, # filled points + alpha = 0.6, # point transparency + grid = TRUE, # background grid + palette = "classic", # new colour palette + xlab = "Topmost vertebra operated on", # custom x title + ylab = "Patient age (months)", # custom y title + main = "Tree predictions: Kyphosis recurrence" # custom title ) + +## conditional inference trees from partyit + +library("partykit") +ct = ctree(Species ~ Petal.Length + Petal.Width, data = iris) +ct_pt = parttree(ct) +plot(ct_pt, pch = 19, palette = "okabe", main = "ctree predictions: iris species") + +## rpart via partykit +rp2 = as.party(rp) +parttree(rp2) } diff --git a/plot_parttree.R b/plot_parttree.R deleted file mode 100644 index 9512b31..0000000 --- a/plot_parttree.R +++ /dev/null @@ -1,116 +0,0 @@ -library(rpart) # For fitting decisions trees -library(parttree) # This package (will automatically load ggplot2 too) - -# install.packages("palmerpenguins") -data("penguins", package = "palmerpenguins") - -tree = rpart(species ~ flipper_length_mm + bill_length_mm, data = penguins) -pt = parttree(tree) - -## Color palette -# pal = palette.colors(4, "R4")[-1] -pal = hcl.colors(3, "Pastel 1") -pal = hcl.colors(3, "Harmonic") - -plot( - bill_length_mm ~ flipper_length_mm, - data = penguins, col = pal[species], pch = 19 -) -pltrng = par("usr") -rect( - pmax(pltrng[1], pt$xmin), pmax(pltrng[3], pt$ymin), - pmin(pltrng[2], pt$xmax), pmin(pltrng[4], pt$ymax), - col = adjustcolor(pal, alpha.f = 0.1)[pt$species] -) - -# plot rectangles only -# dev.new() -# plot( -# bill_length_mm ~ flipper_length_mm, -# data = penguins, col = pal[species], pch = 19 -# ) -# pltrng = par("usr") -# dev.off() -# plot.new() -# plot.window(xlim = pltrng[1:2], ylim = pltrng[3:4]) -# rect( -# pmax(pltrng[1], pt$xmin), pmax(pltrng[3], pt$ymin), -# pmin(pltrng[2], pt$xmax), pmin(pltrng[4], pt$ymax), -# col = adjustcolor(pal, alpha.f = 0.1)[pt$species] -# ) - -# another approach -plot.new() -plot.window( - xlim = range(penguins[["flipper_length_mm"]], na.rm = TRUE), - ylim = range(penguins[["bill_length_mm"]], na.rm = TRUE) -) -pltrng = par("usr") -rect( - pmax(pltrng[1], pt$xmin), pmax(pltrng[3], pt$ymin), - pmin(pltrng[2], pt$xmax), pmin(pltrng[4], pt$ymax), - col = adjustcolor(pal, alpha.f = 0.1)[pt$species] -) -axis(1) -axis(2) -box() -title(xlab = "flipper_length_mm") -title(ylab = "bill_length_mm") -# title(main = "my plot") -points( - bill_length_mm ~ flipper_length_mm, - data = penguins, col = pal[species], pch = 19 -) - - - -pts = lapply(trees, parttree) - -## first plot the downscaled image... -plot(pred_img, axes = FALSE) -## ... then layer the partitions as a series of rectangles -pltrng = par("usr") -lapply( - pts, - function(pt) rect( - pmax(pltrng[1], pt$xmin), pmax(pltrng[4], pt$ymin), - pmin(pltrng[2], pt$xmax), pmin(pltrng[3], pt$ymax), - lwd = 0.06, border = "grey15" - ) -) - - -plot(pred_img, axes = FALSE) -plot.new() -plot.window( - xlim = range(rosalba_ccs[[1]][["x"]]), - ylim = rev(range(rosalba_ccs[[1]][["y"]])) - ) -pltrng = par("usr") -lapply( - pts, - function(pt) rect( - pmax(pltrng[1], pt$xmin), pmax(pltrng[4], pt$ymin), - pmin(pltrng[2], pt$xmax), pmin(pltrng[3], pt$ymax), - lwd = 0.06, border = "grey15" - ) -) - -plot.new() -plot(pred_img, axes = FALSE) -lapply( - 1:3, - function(i) { - plot.window( - xlim = range(rosalba_ccs[[i]][["x"]]), - ylim = rev(range(rosalba_ccs[[i]][["y"]])) - ) - pltrng = par("usr") - rect( - pmax(pltrng[1], pts[[i]]$xmin), pmax(pltrng[4], pts[[i]]$ymin), - pmin(pltrng[2], pts[[i]]$xmax), pmin(pltrng[3], pts[[i]]$ymax), - lwd = 0.06, border = "grey15" - ) - } -) - From 605d661a74fc82b50a909ef6cb11bcf36c4e6d7b Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 10:44:46 -0700 Subject: [PATCH 18/25] pass axes labs through ... --- R/plot.R | 6 ------ man/plot.parttree.Rd | 4 ---- 2 files changed, 10 deletions(-) diff --git a/R/plot.R b/R/plot.R index 00e2d0c..babb849 100644 --- a/R/plot.R +++ b/R/plot.R @@ -13,7 +13,6 @@ #' limits will extend only until the range of the raw data. #' @param jitter Logical. Should the raw points be jittered? Default is `FALSE`. #' Only evaluated if `raw = TRUE`. -#' @param xlab,ylab Character string(s). Custom labels for the x and y axes. #' @param add Logical. Add to an existing plot? Default is `FALSE`. #' @param ... Additional arguments passed down to #' \code{\link[tinyplot]{tinyplot}}. @@ -33,8 +32,6 @@ plot.parttree = function( fill_alpha = 0.3, expand = TRUE, jitter = FALSE, - xlab = NULL, - ylab = NULL, add = FALSE, ... ) { @@ -48,9 +45,6 @@ plot.parttree = function( orig_call = attr(object, "parttree")[["call"]] orig_na_idx = attr(object, "parttree")[["na.action"]] - if (is.null(xlab)) xlab = xvar - if (is.null(ylab)) ylab = yvar - if (isTRUE(raw)) { if (!is.null(raw_data)) { raw_data = eval(raw_data) diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd index 6200d4b..9673797 100644 --- a/man/plot.parttree.Rd +++ b/man/plot.parttree.Rd @@ -11,8 +11,6 @@ fill_alpha = 0.3, expand = TRUE, jitter = FALSE, - xlab = NULL, - ylab = NULL, add = FALSE, ... ) @@ -36,8 +34,6 @@ limits will extend only until the range of the raw data.} \item{jitter}{Logical. Should the raw points be jittered? Default is \code{FALSE}. Only evaluated if \code{raw = TRUE}.} -\item{xlab, ylab}{Character string(s). Custom labels for the x and y axes.} - \item{add}{Logical. Add to an existing plot? Default is \code{FALSE}.} \item{...}{Additional arguments passed down to From 1bd71c46b44f651f28bec98476623d59ddd33023 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 10:46:11 -0700 Subject: [PATCH 19/25] update README --- README.Rmd | 47 ++++++++++++++++---- README.md | 57 +++++++++++++++++++------ man/figures/README-quickstart-1.png | Bin 30479 -> 30595 bytes man/figures/README-quickstart2-1.png | Bin 0 -> 40565 bytes man/figures/README-quickstart_gg-1.png | Bin 0 -> 30479 bytes 5 files changed, 83 insertions(+), 21 deletions(-) create mode 100644 man/figures/README-quickstart2-1.png create mode 100644 man/figures/README-quickstart_gg-1.png diff --git a/README.Rmd b/README.Rmd index cd1a4b2..6160f87 100644 --- a/README.Rmd +++ b/README.Rmd @@ -21,17 +21,17 @@ knitr::opts_chunk$set( Visualize simple 2-D decision tree partitions in R. The **parttree** -package is optimised to work with [**ggplot2**](https://ggplot2.tidyverse.org/), -although it can be used to visualize tree partitions with base R graphics too. +package provides visualization methods for both base R graphics (via +[**tinyplot**](https://grantmcdermott.com/tinyplot/)) and +[**ggplot2**](https://ggplot2.tidyverse.org/). ## Installation -This package is not yet on CRAN, but can be installed from [GitHub](https://github.com/) -with: +This package is not on CRAN yet, but can be installed from +[r-universe](https://grantmcdermott.r-universe.dev/parttree): ``` r -# install.packages("remotes") -remotes::install_github("grantmcdermott/parttree") +install.packages("parttree", repos = "https://grantmcdermott.r-universe.dev") ``` ## Quickstart @@ -42,15 +42,44 @@ quickstart example using the dataset that comes bundled with the **rpart** package. In this case, we are interested in predicting kyphosis recovery after spinal surgery, as a function of 1) the number of topmost vertebra that were operated, and 2) patient age. -The key visualization layer below---provided by this package---is -`geom_partree()`. + +The key function is `parttree()`, which comes with its own plotting method. ```{r quickstart} library(rpart) # For the dataset and fitting decisions trees -library(parttree) # This package (will automatically load ggplot2 too) +library(parttree) # This package fit = rpart(Kyphosis ~ Start + Age, data = kyphosis) +# Grab the partitions and plot +fit_pt = parttree(fit) +plot(fit_pt) +``` + +Customize your plots by passing additional arguments: + +```{r quickstart2} +plot( + fit_pt, + border = NA, # no partition borders + pch = 19, # filled points + alpha = 0.6, # point transparency + grid = TRUE, # background grid + palette = "classic", # new colour palette + xlab = "Topmost vertebra operated on", # custom x title + ylab = "Patient age (months)", # custom y title + main = "Tree predictions: Kyphosis recurrence" # custom title +) +``` + +### ggplot2 + +For **ggplot2** users, we offer an equivalent workflow via the `geom_partree()` +visualization layer. + +```{r quickstart_gg} +library(ggplot2) ## Should be loaded separately + ggplot(kyphosis, aes(x = Start, y = Age)) + geom_parttree(data = fit, alpha = 0.1, aes(fill = Kyphosis)) + # <-- key layer geom_point(aes(col = Kyphosis)) + diff --git a/README.md b/README.md index ff399f2..3a261b3 100644 --- a/README.md +++ b/README.md @@ -10,18 +10,17 @@ Visualize simple 2-D decision tree partitions in R. The **parttree** -package is optimised to work with -[**ggplot2**](https://ggplot2.tidyverse.org/), although it can be used -to visualize tree partitions with base R graphics too. +package provides visualization methods for both base R graphics (via +[**tinyplot**](https://grantmcdermott.com/tinyplot/)) and +[**ggplot2**](https://ggplot2.tidyverse.org/). ## Installation -This package is not yet on CRAN, but can be installed from -[GitHub](https://github.com/) with: +This package is not on CRAN yet, but can be installed from +[r-universe](https://grantmcdermott.r-universe.dev/parttree): ``` r -# install.packages("remotes") -remotes::install_github("grantmcdermott/parttree") +install.packages("parttree", repos = "https://grantmcdermott.r-universe.dev") ``` ## Quickstart @@ -34,16 +33,50 @@ quickstart example using the dataset that comes bundled with the **rpart** package. In this case, we are interested in predicting kyphosis recovery after spinal surgery, as a function of 1) the number of topmost vertebra that were operated, and -2) patient age. The key visualization layer below—provided by this -package—is `geom_partree()`. +2) patient age. + +The key function is `parttree()`, which comes with its own plotting +method. ``` r library(rpart) # For the dataset and fitting decisions trees -library(parttree) # This package (will automatically load ggplot2 too) -#> Loading required package: ggplot2 +library(parttree) # This package fit = rpart(Kyphosis ~ Start + Age, data = kyphosis) +# Grab the partitions and plot +fit_pt = parttree(fit) +plot(fit_pt) +``` + + + +Customize your plots by passing additional arguments: + +``` r +plot( + fit_pt, + border = NA, # no partition borders + pch = 19, # filled points + alpha = 0.6, # point transparency + grid = TRUE, # background grid + palette = "classic", # new colour palette + xlab = "Topmost vertebra operated on", # custom x title + ylab = "Patient age (months)", # custom y title + main = "Tree predictions: Kyphosis recurrence" # custom title +) +``` + + + +### ggplot2 + +For **ggplot2** users, we offer an equivalent workflow via the +`geom_partree()` visualization layer. + +``` r +library(ggplot2) ## Should be loaded separately + ggplot(kyphosis, aes(x = Start, y = Age)) + geom_parttree(data = fit, alpha = 0.1, aes(fill = Kyphosis)) + # <-- key layer geom_point(aes(col = Kyphosis)) + @@ -54,4 +87,4 @@ ggplot(kyphosis, aes(x = Start, y = Age)) + theme_minimal() ``` - + diff --git a/man/figures/README-quickstart-1.png b/man/figures/README-quickstart-1.png index 0445a4f72196f5c236dc6fab20f37eeb8ab4ab0c..bba8a5a013d3565396073db2da25420319f5e866 100644 GIT binary patch literal 30595 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 literal 30479 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

-~m&4IXVxGeuCR7 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 V4AS{n+$zsr1NzA{~8oPLF8}1<)q&5z+m9| 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 MVzOTb%7 diff --git a/man/figures/README-quickstart2-1.png b/man/figures/README-quickstart2-1.png new file mode 100644 index 0000000000000000000000000000000000000000..e5c234e4aea62a77e4e785c1216dd86ad3211b81 GIT binary patch literal 40565 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 SXUEJ7OI1-zp<2#5;y(Zt#^r+m literal 0 HcmV?d00001 diff --git a/man/figures/README-quickstart_gg-1.png b/man/figures/README-quickstart_gg-1.png new file mode 100644 index 0000000000000000000000000000000000000000..0445a4f72196f5c236dc6fab20f37eeb8ab4ab0c GIT binary patch literal 30479 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

-~m&4IXVxGeuCR7 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 V4AS{n+$zsr1NzA{~8oPLF8}1<)q&5z+m9| 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 MVzOTb%7 literal 0 HcmV?d00001 From a71d8773182e299536071163c34c7225f6edc949 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 13:44:26 -0700 Subject: [PATCH 20/25] ggplot2 to suggests --- DESCRIPTION | 2 +- NAMESPACE | 5 ----- R/geom_parttree.R | 31 +++++++++++++++++++++---------- man/geom_parttree.Rd | 9 +++++---- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index f7b2a9c..05f31f0 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -35,13 +35,13 @@ Imports: graphics, stats, data.table, - ggplot2 (>= 3.4.0), partykit, rlang, rpart, tinyplot (> 0.1.0) Suggests: tinytest, + ggplot2 (>= 3.4.0), palmerpenguins, titanic, mlr3, diff --git a/NAMESPACE b/NAMESPACE index 3918a51..601e6f6 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -12,11 +12,6 @@ export(parttree) importFrom(data.table,":=") importFrom(data.table,.SD) importFrom(data.table,fifelse) -importFrom(ggplot2,GeomRect) -importFrom(ggplot2,aes) -importFrom(ggplot2,aes_all) -importFrom(ggplot2,ggproto) -importFrom(ggplot2,layer) importFrom(graphics,par) importFrom(stats,reformulate) importFrom(tinyplot,tinyplot) diff --git a/R/geom_parttree.R b/R/geom_parttree.R index 24576f6..5a3faab 100644 --- a/R/geom_parttree.R +++ b/R/geom_parttree.R @@ -3,7 +3,8 @@ #' @description `geom_parttree()` is a simple extension of #' [ggplot2::geom_rect()]that first calls #' [parttree()] to convert the inputted tree object into an -#' amenable data frame. +#' amenable data frame. Please note that `ggplot2` is not a hard dependency +#' of `parttree` and should thus be installed separately on the user's system. #' @param data An [rpart::rpart.object] or an object of compatible #' type (e.g. a decision tree constructed via the `partykit`, `tidymodels`, or #' `mlr3` front-ends). @@ -12,7 +13,6 @@ #' plot orientation mismatches depending on how users specify the other layers #' of their plot. Setting to `TRUE` will flip the "x" and "y" variables for #' the `geom_parttree` layer. -#' @importFrom ggplot2 aes aes_all layer GeomRect ggproto #' @inheritParams ggplot2::layer #' @inheritParams ggplot2::geom_point #' @inheritParams ggplot2::geom_segment @@ -77,9 +77,9 @@ #' library(parsnip) #' #' iris_tree_parsnip = -#' decision_tree() %>% -#' set_engine("rpart") %>% -#' set_mode("classification") %>% +#' decision_tree() |> +#' set_engine("rpart") |> +#' set_mode("classification") |> #' fit(Species ~ Petal.Length + Petal.Width, data=iris) #' #' p + geom_parttree(data = iris_tree_parsnip, aes(fill=Species), alpha = 0.1) @@ -108,6 +108,15 @@ geom_parttree = stat = "identity", position = "identity", linejoin = "mitre", na.rm = FALSE, show.legend = NA, inherit.aes = TRUE, flip = FALSE, ...) { + + ggplot2_installed = requireNamespace("ggplot2", quietly = TRUE) + if (isFALSE(ggplot2_installed)) { + stop("Please install the ggplot2 package.", .call = FALSE) + } else if (utils::packageVersion("ggplot2") < "3.4.0") { + stop("Please install a newer version of ggplot2 (>= 3.4.0).") + } + + pdata = parttree(data, flip = flip) mapping_null = is.null(mapping) mapping$xmin = quote(xmin) @@ -115,11 +124,11 @@ geom_parttree = mapping$ymin = quote(ymin) mapping$ymax = quote(ymax) if (mapping_null) { - mapping = aes_all(mapping) + mapping = ggplot2::aes_all(mapping) } mapping$x = rlang::quo(NULL) mapping$y = rlang::quo(NULL) - layer( + ggplot2::layer( stat = stat, geom = GeomParttree, data = pdata, mapping = mapping, @@ -130,11 +139,13 @@ geom_parttree = ## Underlying ggproto object GeomParttree = - ggproto( - "GeomParttree", GeomRect, - default_aes = aes(colour = "black", linewidth = 0.5, linetype = 1, + ggplot2::ggproto( + "GeomParttree", ggplot2::GeomRect, + default_aes = ggplot2::aes(colour = "black", linewidth = 0.5, linetype = 1, x=NULL, y = NULL, fill = NA, alpha = NA ), non_missing_aes = c("x", "y", "xmin", "xmax", "ymin", "ymax") ) + + diff --git a/man/geom_parttree.Rd b/man/geom_parttree.Rd index fe1f584..608496a 100644 --- a/man/geom_parttree.Rd +++ b/man/geom_parttree.Rd @@ -107,7 +107,8 @@ lists which parameters it can accept. \code{geom_parttree()} is a simple extension of \code{\link[ggplot2:geom_tile]{ggplot2::geom_rect()}}that first calls \code{\link[=parttree]{parttree()}} to convert the inputted tree object into an -amenable data frame. +amenable data frame. Please note that \code{ggplot2} is not a hard dependency +of \code{parttree} and should thus be installed separately on the user's system. } \details{ Because of the way that \code{ggplot2} validates inputs and assembles @@ -172,9 +173,9 @@ p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flip = TRUE library(parsnip) iris_tree_parsnip = - decision_tree() \%>\% - set_engine("rpart") \%>\% - set_mode("classification") \%>\% + decision_tree() |> + set_engine("rpart") |> + set_mode("classification") |> fit(Species ~ Petal.Length + Petal.Width, data=iris) p + geom_parttree(data = iris_tree_parsnip, aes(fill=Species), alpha = 0.1) From 744a6ca07c60446c8d1f759af555fabf0729ce12 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 14:00:24 -0700 Subject: [PATCH 21/25] update examples --- R/geom_parttree.R | 12 ++++++------ R/parttree.R | 26 ++++++++++++++++++++++++++ man/geom_parttree.Rd | 12 ++++++------ man/parttree.Rd | 26 ++++++++++++++++++++++++++ man/plot.parttree.Rd | 26 ++++++++++++++++++++++++++ 5 files changed, 90 insertions(+), 12 deletions(-) diff --git a/R/geom_parttree.R b/R/geom_parttree.R index 5a3faab..5754015 100644 --- a/R/geom_parttree.R +++ b/R/geom_parttree.R @@ -39,8 +39,9 @@ #' @seealso [parttree()], [ggplot2::geom_rect()]. #' @export #' @examples -#' library(rpart) -#' library(ggplot2) +#' library(parttree) # this package +#' library(rpart) # decision trees +#' library(ggplot2) # ggplot2 must be loaded separately #' #' ### Simple decision tree (max of two predictor variables) #' @@ -69,7 +70,6 @@ #' p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1) #' #' ## Fix with 'flip = TRUE' -#' p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flip = TRUE) #' #' #' ### Various front-end frameworks are also supported, e.g.: @@ -77,9 +77,9 @@ #' library(parsnip) #' #' iris_tree_parsnip = -#' decision_tree() |> -#' set_engine("rpart") |> -#' set_mode("classification") |> +#' decision_tree() %>% +#' set_engine("rpart") %>% +#' set_mode("classification") %>% #' fit(Species ~ Petal.Length + Petal.Width, data=iris) #' #' p + geom_parttree(data = iris_tree_parsnip, aes(fill=Species), alpha = 0.1) diff --git a/R/parttree.R b/R/parttree.R index 0c5e728..ded35f4 100644 --- a/R/parttree.R +++ b/R/parttree.R @@ -23,6 +23,8 @@ #' @importFrom data.table := .SD fifelse #' @export #' @examples +#' library("parttree") +#' #' ## rpart trees #' #' library("rpart") @@ -61,6 +63,30 @@ #' ## rpart via partykit #' rp2 = as.party(rp) #' parttree(rp2) +#' +#' ## various front-end frameworks are also supported, e.g. +#' +#' # tidymodels +#' +#' library(parsnip) +#' +#' decision_tree() |> +#' set_engine("rpart") |> +#' set_mode("classification") |> +#' fit(Species ~ Petal.Length + Petal.Width, data=iris) |> +#' parttree() |> +#' plot(main = "This time brought to you via parsnip...") +#' +#' # mlr3 (NB: use `keep_model = TRUE` for mlr3 learners) +#' +#' library(mlr3) +#' +#' task_iris = TaskClassif$new("iris", iris, target = "Species") +#' task_iris$formula(rhs = "Petal.Length + Petal.Width") +#' fit_iris = lrn("classif.rpart", keep_model = TRUE) # NB! +#' fit_iris$train(task_iris) +#' plot(parttree(fit_iris), main = "... and now mlr3") +#' parttree = function(tree, keep_as_dt = FALSE, flip = FALSE) { UseMethod("parttree") diff --git a/man/geom_parttree.Rd b/man/geom_parttree.Rd index 608496a..cf30bb3 100644 --- a/man/geom_parttree.Rd +++ b/man/geom_parttree.Rd @@ -135,8 +135,9 @@ cue regarding the prediction in each partition region)} } \examples{ -library(rpart) -library(ggplot2) +library(parttree) # this package +library(rpart) # decision trees +library(ggplot2) # ggplot2 must be loaded separately ### Simple decision tree (max of two predictor variables) @@ -165,7 +166,6 @@ p2 = ggplot(iris, aes(x=Petal.Width, y=Petal.Length)) + p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1) ## Fix with 'flip = TRUE' -p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flip = TRUE) ### Various front-end frameworks are also supported, e.g.: @@ -173,9 +173,9 @@ p2 + geom_parttree(data = iris_tree, aes(fill=Species), alpha = 0.1, flip = TRUE library(parsnip) iris_tree_parsnip = - decision_tree() |> - set_engine("rpart") |> - set_mode("classification") |> + decision_tree() \%>\% + set_engine("rpart") \%>\% + set_mode("classification") \%>\% fit(Species ~ Petal.Length + Petal.Width, data=iris) p + geom_parttree(data = iris_tree_parsnip, aes(fill=Species), alpha = 0.1) diff --git a/man/parttree.Rd b/man/parttree.Rd index 1ff8f9e..4839975 100644 --- a/man/parttree.Rd +++ b/man/parttree.Rd @@ -38,6 +38,8 @@ then converted into a data frame, where each row represents a partition (or leaf or terminal node) that can easily be plotted in 2-D coordinate space. } \examples{ +library("parttree") + ## rpart trees library("rpart") @@ -76,6 +78,30 @@ plot(ct_pt, pch = 19, palette = "okabe", main = "ctree predictions: iris species ## rpart via partykit rp2 = as.party(rp) parttree(rp2) + +## various front-end frameworks are also supported, e.g. + +# tidymodels + +library(parsnip) + +decision_tree() |> + set_engine("rpart") |> + set_mode("classification") |> + fit(Species ~ Petal.Length + Petal.Width, data=iris) |> + parttree() |> + plot(main = "This time brought to you via parsnip...") + +# mlr3 (NB: use `keep_model = TRUE` for mlr3 learners) + +library(mlr3) + +task_iris = TaskClassif$new("iris", iris, target = "Species") +task_iris$formula(rhs = "Petal.Length + Petal.Width") +fit_iris = lrn("classif.rpart", keep_model = TRUE) # NB! +fit_iris$train(task_iris) +plot(parttree(fit_iris), main = "... and now mlr3") + } \seealso{ \link{plot.parttree}, \link{geom_parttree}, \code{\link[rpart]{rpart}}, diff --git a/man/plot.parttree.Rd b/man/plot.parttree.Rd index 9673797..29ab8d6 100644 --- a/man/plot.parttree.Rd +++ b/man/plot.parttree.Rd @@ -48,6 +48,8 @@ No return value; called for its side effect of producing a plot. Provides a plot method for parttree objects. } \examples{ +library("parttree") + ## rpart trees library("rpart") @@ -86,4 +88,28 @@ plot(ct_pt, pch = 19, palette = "okabe", main = "ctree predictions: iris species ## rpart via partykit rp2 = as.party(rp) parttree(rp2) + +## various front-end frameworks are also supported, e.g. + +# tidymodels + +library(parsnip) + +decision_tree() |> + set_engine("rpart") |> + set_mode("classification") |> + fit(Species ~ Petal.Length + Petal.Width, data=iris) |> + parttree() |> + plot(main = "This time brought to you via parsnip...") + +# mlr3 (NB: use `keep_model = TRUE` for mlr3 learners) + +library(mlr3) + +task_iris = TaskClassif$new("iris", iris, target = "Species") +task_iris$formula(rhs = "Petal.Length + Petal.Width") +fit_iris = lrn("classif.rpart", keep_model = TRUE) # NB! +fit_iris$train(task_iris) +plot(parttree(fit_iris), main = "... and now mlr3") + } From 9e7adc0c1ee0d4a1260145384c6575f3445180db Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 16:05:25 -0700 Subject: [PATCH 22/25] add tests --- .../_tinysnapshot/iris_classification.svg | 230 +++++++++++++++++ .../_tinysnapshot/iris_regression.svg | 235 ++++++++++++++++++ inst/tinytest/helpers.R | 10 + inst/tinytest/test_rpart.R | 14 ++ 4 files changed, 489 insertions(+) create mode 100644 inst/tinytest/_tinysnapshot/iris_classification.svg create mode 100644 inst/tinytest/_tinysnapshot/iris_regression.svg create mode 100644 inst/tinytest/helpers.R diff --git a/inst/tinytest/_tinysnapshot/iris_classification.svg b/inst/tinytest/_tinysnapshot/iris_classification.svg new file mode 100644 index 0000000..ffb1192 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/iris_classification.svg @@ -0,0 +1,230 @@ + + + + + + + + + + + + + + + +Species +setosa +versicolor +virginica + + + + + + + +Petal.Length +Petal.Width + + + + + + + + + + +1 +2 +3 +4 +5 +6 +7 + + + + + + +0.5 +1.0 +1.5 +2.0 +2.5 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/_tinysnapshot/iris_regression.svg b/inst/tinytest/_tinysnapshot/iris_regression.svg new file mode 100644 index 0000000..a65edc5 --- /dev/null +++ b/inst/tinytest/_tinysnapshot/iris_regression.svg @@ -0,0 +1,235 @@ + + + + + + + + + + + + + + 5.5 + 6.5 + 7.5 +- - +- - +- - +Sepal.Length + + + + + + + +Petal.Length +Sepal.Width + + + + + + + + + + +1 +2 +3 +4 +5 +6 +7 + + + + + + +2.0 +2.5 +3.0 +3.5 +4.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/inst/tinytest/helpers.R b/inst/tinytest/helpers.R new file mode 100644 index 0000000..e0c7db0 --- /dev/null +++ b/inst/tinytest/helpers.R @@ -0,0 +1,10 @@ +library(tinytest) +library(tinysnapshot) + +# # Skip tests if not on Linux +ON_LINUX = Sys.info()["sysname"] == "Linux" +if (!ON_LINUX) exit_file("Linux snapshots") + +options("tinysnapshot_os" = "Linux") +options("tinysnapshot_device" = "svglite") +options("tinysnapshot_device_args" = list(user_fonts = fontquiver::font_families("Liberation"))) diff --git a/inst/tinytest/test_rpart.R b/inst/tinytest/test_rpart.R index feefc09..8caee82 100644 --- a/inst/tinytest/test_rpart.R +++ b/inst/tinytest/test_rpart.R @@ -1,3 +1,7 @@ +# For tinysnapshot +source("helpers.R") +using("tinysnapshot") + # # Classification # @@ -8,10 +12,15 @@ source('known_output/parttree_rpart_classification.R') # rpart rp = rpart::rpart(Species ~ Petal.Length + Petal.Width, data = iris) rp_pt = parttree(rp) +# plot method +f = function() {plot(rp_pt)} +expect_snapshot_plot(f, label = "iris_classification") +# now strip attributes and compare data frames attr(rp_pt, "parttree") = NULL class(rp_pt) = "data.frame" expect_equal(pt_cl_known, rp_pt) + # partykit if (require(partykit)) { rp2 = as.party(rp) @@ -45,6 +54,11 @@ source('known_output/parttree_rpart_regression.R') rp_reg = rpart::rpart(Sepal.Length ~ Petal.Length + Sepal.Width, data = iris) rp_reg_pt = parttree(rp_reg) +# plot method +f = function() {plot(rp_reg_pt)} +expect_snapshot_plot(f, label = "iris_regression") +# now strip attributes and compare data frames attr(rp_reg_pt, "parttree") = NULL class(rp_reg_pt) = "data.frame" expect_equal(pt_reg_known, rp_reg_pt, tolerance = 1e-7) + From e977ffbec569493d8f69e340fab3119ede1f4fb0 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Thu, 25 Jul 2024 16:11:12 -0700 Subject: [PATCH 23/25] NEW and version bump --- DESCRIPTION | 6 +++++- NEWS.md | 27 +++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 05f31f0..47d75c6 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: parttree Title: Visualise simple decision tree partitions -Version: 0.0.1.9004 +Version: 0.0.1.9005 Authors@R: c( person(given = "Grant", family = "McDermott", @@ -41,6 +41,10 @@ Imports: tinyplot (> 0.1.0) Suggests: tinytest, + tinysnapshot (>= 0.0.3), + fontquiver, + rsvg, + svglite, ggplot2 (>= 3.4.0), palmerpenguins, titanic, diff --git a/NEWS.md b/NEWS.md index dbc4feb..47d0414 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,19 +1,34 @@ -# parttree 0.0.1.9004 +# parttree 0.0.1.9005 -To be released as 0.1 +To be released as 0.1.0 + +#### Breaking changes + +* Move ggplot2 to Suggests, following the addition of native (base R) +`plot.parttree` method. The `geom_parttree()` function now checks whether +ggplot2 is available on the user's system before executing any code. (#18) +* The `flipaxes` argument has been renamed to `flip`, e.g. +`parttree(..., flip = TRUE)`. (#18) #### Improvements -* Major speed-up for extracting parttree nodes and coordinates on complicated trees (#15). -* Add method for tidymodels workflows objects fitted with `"rpart"` engine (#7 by @juliasilge). +* Parttree objects now have their own class with a dedicated `plot.parttree` +method, powered by tinyplot. (#18) +* Major speed-up for extracting parttree nodes and coordinates on complicated +trees. (#15) +* Add method for tidymodels workflows objects fitted with `"rpart"` engine. (#7 +by @juliasilge). #### Bug fixes -* Support for negative values (#6 by @pjgeens). -* Better handling of single-level factors and `flipaxes` (#5). +* Support for negative values. (#6 by @pjgeens) +* Better handling of single-level factors and `flip(axes)`. (#5) #### Internals +* Several dependency adjustments, e.g. tinyplot to Imports and ggplot2 to +Suggests. (#18) +* Added SVG snapshots for image-based tests. (#18) * Bump ggplot2 version dependency to match deprecated functions from 3.4.0. * Switched to "main" as primary GitHub branch for development. * Added two dedicated vignettes. From e30f78813ac577bf469297669059ab22866e6d24 Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 26 Jul 2024 18:32:01 -0700 Subject: [PATCH 24/25] ggplot2 back to imports (test) --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index 47d75c6..5167d69 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -38,6 +38,7 @@ Imports: partykit, rlang, rpart, + ggplot2 (>= 3.4.0), tinyplot (> 0.1.0) Suggests: tinytest, @@ -45,7 +46,6 @@ Suggests: fontquiver, rsvg, svglite, - ggplot2 (>= 3.4.0), palmerpenguins, titanic, mlr3, From 88bc031aebd46eb8b24097446c29b71d91c8a61a Mon Sep 17 00:00:00 2001 From: Grant McDermott Date: Fri, 26 Jul 2024 18:34:49 -0700 Subject: [PATCH 25/25] simply action --- .github/workflows/R-CMD-check.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/R-CMD-check.yaml b/.github/workflows/R-CMD-check.yaml index a3ac618..03a22c9 100644 --- a/.github/workflows/R-CMD-check.yaml +++ b/.github/workflows/R-CMD-check.yaml @@ -18,11 +18,11 @@ jobs: fail-fast: false matrix: config: - - {os: macos-latest, r: 'release'} - - {os: windows-latest, r: 'release'} + # - {os: macos-latest, r: 'release'} + # - {os: windows-latest, r: 'release'} - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'} - {os: ubuntu-latest, r: 'release'} - - {os: ubuntu-latest, r: 'oldrel-1'} + # - {os: ubuntu-latest, r: 'oldrel-1'} env: GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}