‘Schreiben ist wichtig’… Writing is important - A german teacher

‘ggplot2 extension for everyone’

Goals

  1. Gentler introduction to layer extension: Easy Geom Recipes, and Easy Geom Recipes Python adaptation…

  2. Articulate value of extension, and by extension … ’Why am I even here? Why do other extenders show up? Motivated by conversational data viz - being able to write down (transcribe! 📝🤯) visual narratives

  3. Extensions targeted at new audiences 🤷‍♀️

    1. AP High School Stats, Intro College Stats
    2. Kids data viz
    3. Dimension Reduction and ML Crowd…

All the things to prepare for:

  1. Tomorrow, Posit Data Science Lab: ‘Easy geom recipes’, July 21 👩‍🏫
  2. Debut plotnine’s Easy Geom Recipes… 🤸🚀✈️
  3. JSM activities:
    1. EAPOST Workshop, July 31-Aug 1 https://sites.google.com/view/eaapost/project-overview https://www.nsf.gov/awardsearch/show-award?AWD_ID=2235355
    2. Prepare to Teach, Aug 2nd https://preparingtoteach.org/
    3. ‘Past Present and Future of ggplot2 extensions’ with Cory, Cynthia, Joyce, James, Heike, Naomi, JSM Boston 2026, https://ww3.aievolution.com/JSMAnnual2026/index.cfm?do=ev.viewEv&ev=5265
  4. Posit Conf: ‘Epic Graphical Poems and Conversations’
  5. Future of the ggplot2 extenders club… 🤷‍♀️

New tech that supports this

2026-07-21 - Posit’s Data Science Lab X ggplot2 extenders - keeping it conversational

So tomorrow, Data Science Lab we’ll go through an ‘easy geom recipes’ thing. And we’ll debut a plotnine version!!!!!

And in preparing for that Isabella, Libby and I met up a few weeks ago, and I asked, ‘could I present a few slides talking about why I think ggplot2 extension is important’.

But we settled on ‘No’. That’s not the DS Lab vibe. “DS Lab isn’t monologues, it’s conversation.”

And I think, this is, like, really a very good practice. And that’s exactly what we are going to do tomorrow. 💯🙌🚀

Where we are heading

Where we are heading

Where we are heading

First, A little bit of background: Anatomy of geom_*()s 🫁🧠🫀

The anatomy of ’geom_*()’s like geom_point, geom_jitter, geom_histogram

Every geom_*() acutally contains three underlying characters!

And underlying layer() function wraps this - three underlying ‘characters’ to work together.*

  • Stat - precompute done prior to drawing (count, bin)

  • Geom - a ‘mark’, the drawn thing… (point, bar, segment)

  • Position - “Makes the marks dance” - Hassan Kibirige … maybe actually a minor character… typically helps with overplotting

  • This is also true of stat_*() functions!

Toddlers in a trench coat - ChatGPT interpretation
Toddlers in a trench coat - ChatGPT interpretation
ggplot() +
labs(title = three_guys_title) +
stamp_png(png = inside_out_png) +
stamp_label(0.2, -0.2, "1. Stat") +
stamp_label(-0.15, -0.15, "2. Geom") +
stamp_label(0.45, -0.19, "3. position") +
stamp_circle(0.18, -0.1, fill = NA, color = "red", radius = 0.18) +
labs(subtitle = three_guys_subtitle) +
labs(caption = three_guys_caption) +
stamp_label(label = three_guys_conclusing, size = 10)

geom_point: 1) StatIdentity, 2) GeomPoint, 3) position_identity()

library(ggplot2)
geom_point
## function (mapping = NULL, data = NULL, stat = "identity", position = "identity", 
##     ..., na.rm = FALSE, show.legend = NA, inherit.aes = TRUE) 
## {
##     layer(mapping = mapping, data = data, geom = "point", stat = stat, 
##         position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
##         params = list2(na.rm = na.rm, ...))
## }
## <bytecode: 0x11712f2e0>
## <environment: 0x11712cc50>

geom_jitter: 1) StatIdentity, 2) GeomPoint, 3) position_jitter()

geom_jitter
## function (mapping = NULL, data = NULL, stat = "identity", position = "jitter", 
##     ..., width = NULL, height = NULL, na.rm = FALSE, show.legend = NA, 
##     inherit.aes = TRUE) 
## {
##     if (!missing(width) || !missing(height)) {
##         if (!missing(position)) {
##             cli::cli_abort(c("Both {.arg position} and {.arg width}/{.arg height} were supplied.", 
##                 i = "Choose a single approach to alter the position."))
##         }
##         position <- position_jitter(width = width, height = height)
##     }
##     layer(data = data, mapping = mapping, stat = stat, geom = GeomPoint, 
##         position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
##         params = list2(na.rm = na.rm, ...))
## }
## <bytecode: 0x115a91ec0>
## <environment: namespace:ggplot2>

geom_histogram: 1) StatBin, 2) GeomBar, 3) position_stack()

geom_histogram
## function (mapping = NULL, data = NULL, stat = "bin", position = "stack", 
##     ..., binwidth = NULL, bins = NULL, orientation = NA, lineend = "butt", 
##     linejoin = "mitre", na.rm = FALSE, show.legend = NA, inherit.aes = TRUE) 
## {
##     layer(mapping = mapping, data = data, geom = "bar", stat = stat, 
##         position = position, show.legend = show.legend, inherit.aes = inherit.aes, 
##         params = list2(na.rm = na.rm, binwidth = binwidth, bins = bins, 
##             orientation = orientation, lineend = lineend, linejoin = linejoin, 
##             ...))
## }
## <bytecode: 0x13277ecb0>
## <environment: 0x132780728>

But, monologues have a special place in sense-making: Why even do the Posit DS Lab? Why ggplot2 extension? Why ggplot2 extenders? And in 2026?

And monologues sometimes compress conversations, right?

Many of [my New York Times] columns were the products of long telephone conversations with my friends - Anna Quindlen in ‘Living Out Loud’

We do monologues at ggplot2 extenders club (but like they are often pretty ‘hero’s journey’ stories - speaker talks about motivation and challenges… My eyes never glaze over during a hero’s journey story.)

And anyway, what I’d been thinking about for slides was a celebration of visual conversation, and that as motivation for ggplot2 extension.

So people like Thomas Lin Pedersen have talked about ggplot2 as enabling you to ‘Speak you plots into existence.’ And this sounds right, and powerful and awesome - just like ggplot2 is.

But, what I think is actually even more powerful and motivating for me is conversing with your data and recording that conversation - verbatim. transcribing conversations you have with data and transcribing the trains-of-thought you have with data.

Is ggplot2 can be kind-of a transcriber of human-data interactions… (if we let loose, and if we extend)

‘spontaneous language for conversation’

If you’ve learned a foreign language (or watched a kid learn their native language…)

And I think many of us are thinking more and more about the power of language and conversation… Like because of LLMS, you may have been thinking, “like at what point did Natural Language emerge in the history of the universe, and why does it even work? How did we go from grunts to meaning” If I utter ‘potato’ does that conjure up in the mind the ‘potato’?

“I could literally write anything and you’d read it …Think about your belly button. Boom, now you’r thinking about your belly button… That’s the power of writing. I love the power. I’m drunk on the stuff.” - Sarah Cooper, ‘Foolish’ 2023

Language is profoundly powerful.

Grammar is profoundly powerful.

Conversation is profoundly powerful.

I’m personally not so interested in a final, gorgeous plot, (though I’m often dazzled by them) or the pretty, reorganized ggplot2 syntax that you get after having arriving at a final viz product.

I am crazy about the conversations with data that ggplot2 allows!

It’s great that ggplot2 syntax can be concise, but I am a bigger fan of the fact that ‘inconcision’ is permitted, that lets us record ideas practically verbatim. (Like the Data Science Lab conversations transcripts!)

‘Ums’ and ‘Uhs’ tend to occur when we are doing really hard cognative lifting … to kind of give us a processing moment … with Valerie Fridland, author of ‘Like, Literally, Dude.’ thinking about inconcision as functional.

So… that was a huge monologue for someone who says they are crazy about conversation. 😂

Toward (more) conversational ggplot2: Native inconsicion in ggplot2

library(tidyverse)

Here’s the sanctioned way of doing things…

gapminder::gapminder |> 
  filter(year == 2002) |> 
  ggplot(aes(x = gdpPercap,
             y = lifeExp, 
             color = continent,
             size = pop)) + 
  geom_point()

ggplot() +
stamp_png(png = hans_png_path_y_axis) +
stamp_png(png = hans_png_path_x_axis) +
stamp_png(png = hans_png_path_color) +
stamp_png(png = hans_png_path_size) +
stamp_label(label = lab, size = 10)

https://www.youtube.com/watch?v=jbkSRLYSojo&t=37s

ggplot(filter(gapminder::gapminder, year == 2002)) +
ggchalkboard:::theme_rosling() +
aes(y = lifeExp) +
aes(x = gdpPercap) +
geom_point() +
aes(color = continent) +
aes(size = pop)
ggplot() +
stamp_png(png = spears_png) +
stamp_png(png = cowell_png) +
stamp_png(png = tompkins_png) +
stamp_label(label = "🤷‍♀️", size = 20) +
stamp_png(png = pooh_png)

‘Data visualization is right at the heart of my work.’ - Hans Rosling’s introduction to ‘200 Countries, 200 Years, 4 Minutes - The Joy of Stats - BBC’


And aesthetic mapping is right at the heart to data visualization - so maybe giving those decisions full voice is actually kind of cool?

Lightly wrapped inconcision: ggkids (maybe this is okay for kids)

Tim Baller’s Daugher…

library(ggkids)

repair_data <- 
   write_table(~days, ~feeling,
                1,      "😩",
                2,      "😞",
                3,      "😕",
                4,      "😐",
                5,      "😏",
                6,      "😊",
                7,      "😁")
ggkids(data = repair_data) +
use_x(days) +
use_y(feeling) +
chart_point() +
chart_line() +
scale_x_counting() +
theme_kids(ink = "midnightblue")

Long form poems… Transcribing statistical narratives…

Extension and statistical narratives …

2020: Stats instructor teaching “Introduction to Statistical Investigations”

loved,

but… missed coded workflow (there was some, but less that I’d have liked)

There’s something really grounding (and powerful? 3400 to 3100 BCE) about going back to a code narrative, being able to review the steps of what you did.

(quotes from Winston, Paul)

You know people were doing their statistics most of the people in my department were using SPSS um and I found that very confusing SPSS you know quote syntax um where they it provides some code to do your analysis, you’d um you know you’d bring in your data and you’d um you click some buttons and you’d check some boxes for turning


Some instructors may train students in using a specific software package, but mastery of advanced programming skills should not be allowed to crowd out data analysis skills or statistical thinking.


Could we have the best of both worlds?

‘Schreiben ist wichtig’… Writing is important - Herr Heineke

So writing things down in code, with a set of functions crafted for the task, with just give you just the right amount of friction. Slow you down just enough to


Could we do what we were doing on chalkboards (love a chalk board!! analogue gold standard), but also do that w/ code, consistent w/ the touch of ggplot2 students were learning anyway, and code that would serve the concepts being learned instead of shoehorning functions around the statistical narrative.


Chalkboard version went like this…

library(ggprop.test)
ggplot(donor_data) +
labs(title = "🫀🫁Donor Y/N response 50/50?") +
aes(x = decision) +
geom_stack() +
geom_stack_label() +
geom_support() +
geom_prop() +
geom_prop_label() +
stamp_prop(0.5) +
geom_binomial_null() +
geom_normal_prop_null() +
geom_normal_prop_null_sds() +
stamp_eq_norm_prop() +
labs(caption = "isi-stats.com")
library(ggprop.test)
ggplot(dolphin_data) +
labs(title = "Can 🐬Doris & 🐬Buzz communicate? 🐟🪣") +
aes(x = observed) +
geom_stack() +
geom_stack_label() +
geom_support() +
geom_prop() +
geom_prop_label() +
stamp_prop(0.5) +
geom_binomial_null() +
labs(caption = "isi-stats.com")
library(ggprop.test)
ggplot(scissors_data) +
labs(title = "🪨📃✂️ Rock Paper Scissors pattern?") +
aes(x = thrown) +
geom_stack() +
geom_stack_label() +
geom_support() +
geom_prop() +
geom_prop_label() +
stamp_prop(0.333) +
stamp_prop_label(0.333) +
geom_binomial_null(prob = 0.333) +
labs(caption = "isi-stats.com")
library(ggt.test)
ggplot(data_grit_undergrad) +
labs(title = "🪖😬💪🧗🏽‍♂️ Gritty as West Point Cadets?") +
aes(x = score) +
geom_stacks() +
geom_support() +
geom_mean() +
geom_mean_label() +
stamp_mean(3.75) +
stamp_mean_label(3.75) +
geom_tdist_null(value = 3.75) +
labs(caption = "Duckworth et al. 2007, Grit...")
library(ggxmean)
ggplot(cars) +
labs(title = "🚙 Cars data - stopping distance") +
aes(x = speed, y = dist) +
geom_point() +
geom_lm() +
geom_lm(y ~ 1) +
geom_lm_fitted() +
geom_lm_residuals() +
geom_lm_conf_int() +
geom_lm_pred_int() +
geom_lm_intercept() +
geom_lm_intercept_label() +
geom_lm_formula() +
labs(caption = "R Core Team, datasets")

Code and viz first dimensionality reduction…

ggkmeans in draft stages

set_num_centers(4)
ggplot(mammoth_df) +
aes(x = V1, y = V2) +
geom_point() +
geom_kmeans() +
geom_kmeans_lengths(linewidth = 0.2, alpha = 0.2) +
geom_kmeans_center()

ggdims: This project breaks new ground in the ggplot2 ecosystem so that ‘aggregation’ can be as easily handled across features (columns) as it is to aggregate across samples (rows).

ggplot(ggdims::two_clusters) +
aes(dim1, dim2) +
geom_point(shape = 21) +
aes(fill = type)
ggplot(ggdims::two_clusters) +
labs(title = "t-SNE on two clusters - vary perplexities") +
aes(dims = dims(dim1:dim2)) +
geom_tsne(perplexity = 2) +
aes(fill = type) +
ggplyr::layers_wipe() +
geom_tsne(perplexity = 5) +
ggplyr::layers_wipe() +
geom_tsne(perplexity = 30) +
ggplyr::layers_wipe() +
geom_tsne(perplexity = 50)
library(ggdims)
ggplot(mammoth_df) +
aes(dims = dims(V1:V3)) +
geom_pca() +
ggplyr::layers_wipe() +
geom_tsne() +
ggplyr::layers_wipe() +
geom_umap()

And extendersXggcube (html widgit)

library(tidyverse)
library(ggcube)
ggplot(data = mammoth_df) +
aes(x = V1) +
aes(y = V2) +
geom_point() +
aes(shape = I(21)) +
aes(fill = I(confetti)) +
aes(color = I("white")) +
aes(stroke = 0.2) +
aes(z = V3) +
coord_3d(ratio = c(2, 1.4, 1), yaw = 0, roll = 10, pitch = 5) +
geom_point_3d() +
labs(title = mammoth_title, subtitle = mammoth_subtitle, caption = mammoth_caption)

So the motivation today, is to show you some ggplot2 extension moves - but also to think about how we can create new vocabulary to write down the statistical narratives that we are telling all the time - be it in the classroom or elsewhere - with greater precision and using new bespoke vocabulary (functions) if needed.


Isn’t this like a lot of effort to be able to ‘write down a statistical poem’ something down?

Why don’t we just have students use base ggplot2, and do the wrangling/calculations by hand?

Some instructors may train students in using a specific software

package, but *mastery of advanced programming skills should not be

allowed to crowd out data analysis skills or statistical thinking.*


Mastry of the minutia of ggplot2 and data manipulation and should not

be allowed to crowd out statistical thinking.


Leland McInnes’ (UMAP dimentionality reduction technique) red star…

necessarily particular details of how any given technique works so that means I’m going to introduce a little bit of extra notation which is gonna be really useful repeatedly and that’s the red star.

So sometimes technical details

really really matter but they’re not worth getting into because by the

time you’ve explained all the technical details you have lost the point

of what you are trying to explain in the first place so if I just want

the big picture idea 1:22 and there are some technical details that make

that statement not technically true but morally it’s the right thing

The goldilocks situation: Write it, and don’t loose the train of thought by bespoke packages like ggprop.test, ggt.test. ggols…


Huge audiences:

1000 cadets every year…

EAPOST - network of 600 instructors… x20-200 students

100000 AP stats high school students per year

Epic poems… + aes() + geom_?() + ggplyr::layers_wipe()

But allowing aesthetic mappings to be expressed independently, and given some extension, greater concision may actually possible. Via a conversational approach… Beyond ‘base’ ggplot2… let’s you keep the conversation going where train of thought is just one grammar move away…

No logical leaps!

Circlepack, swarm poem
Circlepack, swarm poem

Let’s have a look at “Visualizing Why Yesterdays Stock Market Reversal Was So Weird and Unnerving” by David Crowther. This is an ‘epic graphical poem’. And yet, the fact that it’s not making logical leaps means that we are often just a grammar of graphics ‘move’ (or a couple) away from the next plot that he builds.

https://sherwood.news/markets/visualizing-why-yesterdays-stock-market-reversal-was-so-weird-and-unnerving/

# remotes::install_github("https://github.com/EvaMaeRey/ggplyr")
# remotes::install_github("https://github.com/EvaMaeRey/ggcirclepack")

# this block from an ellmerXgemini

library(tidyquant)
library(dplyr)
library(lubridate) # For today()

# --- 1. Define Parameters ---
symbol <- "^GSPC" # S&P 500 Yahoo Finance symbol
start_date <- "2015-01-01" # Corrected from 20215
end_date <- "2025-11-21" 

# --- 2. Load Data ---
# tq_get fetches financial data
sp500_data <- tq_get(symbol,
                     get = "stock.prices", # Specify we want stock prices
                     from = start_date,
                     to = end_date)

# this block from an ellmerXgemini

library(tidyverse)
library(ggplyr)
library(ggcirclepack)

# tq_get fetches financial data
sp500_data <- tq_get(symbol,
                     get = "stock.prices", # Specify we want stock prices
                     from = start_date,
                     to = end_date)

sp_500_data_calcs <- 
  sp500_data  |>
  mutate(
    year = year(date), 
    return = (close - lag(close))/lag(close)*100,
    open_pct = (open - lag(close))/lag(close)*100,
    intraday_pct = 100*(close-open)/open,
    ind_yesterday = date == "2025-11-20"
         )

library(RColorBrewer)
library(tidyverse)
library(ggplyr) # hey
library(ggcirclepack)
library(RColorBrewer)

library(tidyverse)
library(ggcirclepack)
(theme_gray() + 
    theme(palette.fill.discrete = brewer.pal(8, "Dark2")[c(1:8, 1:3)],
          palette.color.discrete = c("transparent", "magenta"),
          palette.fill.continuous = "viridis")
  ) |>
  theme_set()
sp_500_data_calcs |> head()
## # A tibble: 6 × 13
##   symbol date        open  high   low close     volume adjusted  year return
##   <chr>  <date>     <dbl> <dbl> <dbl> <dbl>      <dbl>    <dbl> <dbl>  <dbl>
## 1 ^GSPC  2015-01-02 2059. 2072. 2046. 2058. 2708700000    2058.  2015 NA    
## 2 ^GSPC  2015-01-05 2054. 2054. 2017. 2021. 3799120000    2021.  2015 -1.83 
## 3 ^GSPC  2015-01-06 2022. 2030. 1992. 2003. 4460110000    2003.  2015 -0.889
## 4 ^GSPC  2015-01-07 2006. 2030. 2006. 2026. 3805480000    2026.  2015  1.16 
## 5 ^GSPC  2015-01-08 2031. 2064. 2031. 2062. 3934010000    2062.  2015  1.79 
## 6 ^GSPC  2015-01-09 2063. 2064. 2038. 2045. 3364140000    2045.  2015 -0.840
## # ℹ 3 more variables: open_pct <dbl>, intraday_pct <dbl>, ind_yesterday <lgl>
library(RColorBrewer)
ggplot(sp_500_data_calcs) +
aes(id = date) +
ggcirclepack::geom_circlepack() +
aes(color = ind_yesterday) +
aes(fill = as_factor(year)) +
coord_equal() +
aes(fill = return) +
scale_fill_gradient2(mid = "snow", low = "#410000", high = "darkblue") +
facet_wrap(~year) +
facet_null() +
ggplyr::data_filter(open_pct > 1) +
ggplyr::layers_wipe() +
aes(y = "All", x = open_pct) +
ggbeeswarm::geom_beeswarm(shape = 21) +
coord_cartesian() +
aes(size = I(5)) +
scale_color_manual(values = c("whitesmoke", "magenta")) +
aes(x = return) +
aes(y = "") +
facet_grid(rows = vars(year)) +
facet_null() +
aes(y = intraday_pct, x = open_pct)

https://evamaerey.github.io/mytidytuesday/2025-11-24-s-and-p-since-2015/s-and-p-since-2015.html

Do we even have the tools for nearly verbatim recording of data analysis? Concise, in-sequence, trains-of-thought. Transcription…

Who are the ggplot2 extenders (in graphical poems?)

deserve bespoke vocab that allows us to write down statistical narrative elegantly!

library(tidyverse)
library(ggraph)

ggraph_to_threejs <- function(edgelist = package_author_edgelist, 
                              plot = last_plot()){
    
  g <-  edgelist |> select(1:2) |> as.matrix() |> 
    graph_from_edgelist()
  
  node_data <- layer_data(i = 2, plot = plot)
  
  graph_attr(g, "layout") <- NULL    
    
  node_id <- V(g) |> names()
  
  joined <- tibble(node_id) |> 
    left_join(node_data, by = "node_id")
  
  from_list <- edgelist |> pull(1)
  
  V(g)$size <- joined$size/3.66
  V(g)$shape <- joined$label
  V(g)$color <- joined$colour
   
  graphjs(g = g, bg = "gray10",  
          showLabels = T,  
          layout = list(  
            layout_with_fr(g, dim = 3)),  
                    main = "")

}

vct_packages <- c("dplyr", "ggplot2", "ellmer",
                        "tidyr", "tidymodels", "recipes",
                        "readr", "tibble", "stringr", "forcats",
                        "purrr", "lubridate")

# data cleaning
as_tibble(tools::CRAN_package_db()) |>
  filter(Package %in% vct_packages) |>
  mutate(Author = str_replace_all(Author, ' \\[|\\]', "*" )) |>
  select(Package, Author) |>
  mutate(Author = map(Author, ~ str_split_1(.x,"\n"))) |>
  unnest() |> 
  mutate(Author = Author |> str_remove('\\*.+' )) |>
  mutate(Author = Author |> str_trim()) |>
  filter_out(Author |> str_detect("orcid.org")) |>
  filter_out(Author == "Posit Software, PBC") |>
  filter_out(Author == "Posit, PBC") ->
package_author_edgelist
ggraph(mutate(tidygraph::as_tbl_graph(package_author_edgelist),
is_package = name %in% vct_packages), layout = "kk") +
geom_edge_link() +
geom_node_label(aes(label = name, node_id = name, color = is_package))
library(threejs)
library(igraph)

ggraph_to_threejs(package_author_edgelist)

ggregions: deteministic semantic positional aesthetic mapping (like ggsql!)

ggplot(tribble(~place, ~conf, "Texas", TRUE, "North Carolina",
FALSE)) +
stamp_region() +
aes(region = place) +
geom_region() +
aes(fill = conf)
ggplot(ggswitzerland::muni_income_data) +
theme_map() +
stamp_mountains() +
aes(region = municipality) +
geom_muni() +
aes(fill = mean_quantiles) +
stamp_canton_border(color = "grey95") +
stamp_lake(fill = "lightblue")

knitr::knit_exit()