Functions that take variables

Writing functions for your data, not just your vectors

So far, our functions have taken vectors

new_mean(x), prop(x), and your new_sd(x) all take a vector of numbers.

But most of what you actually do all day takes a dataset and a variable:

summarize(nlsy, mean = mean(income, na.rm = TRUE))
summarize(nlsy, mean = mean(age_bir, na.rm = TRUE))
summarize(nlsy, mean = mean(nsibs, na.rm = TRUE))

This is the “copied it more than twice” signal. Silly example, as you could do it all in one summarize() call, but let’s write a function.

The obvious attempt

summarize_var <- function(data, variable) {
  data |>
    summarize(mean = mean(variable, na.rm = TRUE))
}

summarize_var(nlsy, income)
Error in `summarize()`:
ℹ In argument: `mean = mean(variable, na.rm =
  TRUE)`.
Caused by error:
! object 'income' not found

Well, that didn’t work.

Why not?

Here’s what we know works:

summarize(nlsy, mean = mean(income))

But income isn’t an object in your environment – if you typed income in the console you’d get an error. It only means something inside nlsy.

  • {dplyr} does this on purpose. It’s called data masking, and it’s why you don’t have to write nlsy$income in the mean() call.
  • But that means that when you write your own function, {dplyr} looks for a column literally named variable (that’s what you have in your function body!), doesn’t find one, and gives up.

The fix: {{ }}

Wrap the argument in curly-curly (I didn’t make up this name, it’s actually what they call it!) to say “don’t look for a column called variable, look for whatever the user passed in”:

summarize_var <- function(data, variable) {
  data |>
    summarize(
      n    = sum(!is.na({{ variable }})),
      mean = mean({{ variable }}, na.rm = TRUE),
      sd   = sd({{ variable }}, na.rm = TRUE)
    )
}

Now it works

summarize_var(nlsy, income)
# A tibble: 1 × 3
      n   mean     sd
  <int>  <dbl>  <dbl>
1 10195 14707. 12507.
summarize_var(nlsy, age_bir)
# A tibble: 1 × 3
      n  mean    sd
  <int> <dbl> <dbl>
1  5943  24.4  6.11

And because it takes the data first, it pipes:

nlsy |> summarize_var(nsibs)

{{ }} works anywhere {dplyr} does

Including .by =:

summarize_by <- function(data, variable, group) {
  data |>
    summarize(
      mean = mean({{ variable }}, na.rm = TRUE),
      n    = n(),
      .by  = {{ group }}
    )
}

summarize_by(nlsy, income, sex_cat)
# A tibble: 2 × 3
  sex_cat   mean     n
  <fct>    <dbl> <int>
1 Female  14523.  6283
2 Male    14880.  6403

You can even name the output after the variable

Use "{{ variable }}" inside a string, with := instead of =:

mean_named <- function(data, variable) {
  data |>
    summarize("mean_{{ variable }}" := mean({{ variable }}, na.rm = TRUE))
}

mean_named(nlsy, income)
# A tibble: 1 × 1
  mean_income
        <dbl>
1      14707.

It works in ggplot() too

plot_hist <- function(data,
                      variable) {
  ggplot(
    data,
    aes(x = {{ variable }})) +
    geom_histogram(bins = 30) +
    theme_minimal()
}

plot_hist(nlsy, income)

And in {gtsummary}

table_by <- function(data,
                     group) {
  data |>
    tbl_summary(
      by = {{ group }},
      include = c(
        race_eth_cat,
        eyesight_cat, age_bir)
    ) |>
    add_overall() |>
    bold_labels()
}

table_by(nlsy, sex_cat)
Characteristic Overall
N = 12,6861
Male
N = 6,4031
Female
N = 6,2831
race_eth_cat


    Hispanic 2,002 (16%) 1,000 (16%) 1,002 (16%)
    Black 3,174 (25%) 1,613 (25%) 1,561 (25%)
    Non-Black, Non-Hispanic 7,510 (59%) 3,790 (59%) 3,720 (59%)
eyesight_cat


    Excellent 2,916 (35%) 1,582 (38%) 1,334 (31%)
    Very good 2,970 (35%) 1,470 (35%) 1,500 (35%)
    Good 1,794 (21%) 792 (19%) 1,002 (23%)
    Fair 632 (7.5%) 267 (6.4%) 365 (8.5%)
    Poor 132 (1.6%) 47 (1.1%) 85 (2.0%)
    Unknown 4,242 2,245 1,997
age_bir 23 (20, 28) 25 (21, 29) 22 (19, 27)
    Unknown 6,743 3,652 3,091
1 n (%); Median (Q1, Q3)

Same function, different grouping variable

table_by(nlsy, region_cat)
Characteristic Overall
N = 12,4471
Northeast
N = 2,5501
North Central
N = 2,9341
South
N = 4,5681
West
N = 2,3951
race_eth_cat




    Hispanic 1,968 (16%) 388 (15%) 153 (5.2%) 529 (12%) 898 (37%)
    Black 3,128 (25%) 552 (22%) 561 (19%) 1,764 (39%) 251 (10%)
    Non-Black, Non-Hispanic 7,351 (59%) 1,610 (63%) 2,220 (76%) 2,275 (50%) 1,246 (52%)
eyesight_cat




    Excellent 2,870 (35%) 586 (38%) 738 (36%) 998 (32%) 548 (35%)
    Very good 2,929 (35%) 542 (35%) 749 (36%) 1,103 (35%) 535 (34%)
    Good 1,762 (21%) 298 (19%) 426 (21%) 691 (22%) 347 (22%)
    Fair 621 (7.5%) 101 (6.5%) 131 (6.3%) 270 (8.7%) 119 (7.6%)
    Poor 130 (1.6%) 30 (1.9%) 29 (1.4%) 50 (1.6%) 21 (1.3%)
    Unknown 4,135 993 861 1,456 825
age_bir 23 (20, 28) 25 (21, 30) 24 (20, 28) 22 (19, 27) 24 (20, 28)
    Unknown 6,593 1,502 1,448 2,372 1,271
1 n (%); Median (Q1, Q3)

You only have to write the formatting once! Changing the stratifying variable is now just one word, not a copy-paste.

Careful: {{ }} is a tidyverse feature

It works because dplyr, ggplot2, and gtsummary are built to understand it. Base R functions are not.

fit_income <- function(data, predictor) {
  lm(income ~ {{ predictor }}, data = data)
}

fit_income(nlsy, age_bir)
Error:
! object 'age_bir' not found

For lm(), pass a string and build the formula

reformulate() takes character predictors and a character response:

fit_income <- function(data, predictors) {
  lm(reformulate(predictors, response = "income"), data = data)
}

fit_income(nlsy, "age_bir")

Call:
lm(formula = reformulate(predictors, response = "income"), data = data)

Coefficients:
(Intercept)      age_bir  
     1706.6        594.9  

Multivariable lm() is easy too

It takes a vector, so you get multivariable models for free:

fit_income(nlsy, c("age_bir", "sex_cat"))

Call:
lm(formula = reformulate(predictors, response = "income"), data = data)

Coefficients:
  (Intercept)        age_bir  sex_catFemale  
       1386.0          600.8          335.7  

A note on asking AI about this

This is a corner of R that changed a lot, and models are frequently out of date here.

If you see aes_string(), !!sym(), .dots =, or standardise_call() in a suggestion, it’s giving you a solution from before {{ }} existed (2019).

Warning

Those approaches mostly still run. But aes_string() has been formally deprecated in ggplot2 since 3.4, and the “curly-curly” approach was created due to frustrations with those older approaches, so learn the easier way to do it!

Exercises

The website has a script to download with these examples, plus some additional exercises.