Programmazione funzionale: il package purrr

Author

Domenico Vistocco

Utilizzo di nomi di colonne come argomenti di una funzione

Per utilizzare i nomi di colonne di una tabella come argomenti di una funzione è necessario utilizzare una strategia diversa. Per mostrare l’approccio da seguire sfruttiamo la tabella diamonds``{ggplot2}, di cui stampiamo le prime righe a video:

library(tidyverse)
diamanti <- diamonds
head(diamanti)
# A tibble: 6 × 10
  carat cut       color clarity depth table price     x     y     z
  <dbl> <ord>     <ord> <ord>   <dbl> <dbl> <int> <dbl> <dbl> <dbl>
1  0.23 Ideal     E     SI2      61.5    55   326  3.95  3.98  2.43
2  0.21 Premium   E     SI1      59.8    61   326  3.89  3.84  2.31
3  0.23 Good      E     VS1      56.9    65   327  4.05  4.07  2.31
4  0.29 Premium   I     VS2      62.4    58   334  4.2   4.23  2.63
5  0.31 Good      J     SI2      63.3    58   335  4.34  4.35  2.75
6  0.24 Very Good J     VVS2     62.8    57   336  3.94  3.96  2.48

Siamo ora interessati a definire una funzione che calcoli una statistica di sintesi, la media ad esempio, su una variabili numerica per i vari livelli di una variabile di stratificazione, ed una seconda funzione che permetta di ottenere un grafico utilizzando gli argomenti per specificare le variabili da rappresentare.

Costruiamo a titolo di esempio una tabella di sintesi che contenga i prezzi medi dei diamanti (colonna price) per i vari livelli della variabile clarity:

diamanti |> 
  group_by(clarity) |> 
  summarise(price_avg = mean(price))
# A tibble: 8 × 2
  clarity price_avg
  <ord>       <dbl>
1 I1          3924.
2 SI2         5063.
3 SI1         3996.
4 VS2         3925.
5 VS1         3839.
6 VVS2        3284.
7 VVS1        2523.
8 IF          2865.

Costruiamo una rappresentazione grafica organizzata in pannelli dove i livelli della variabile clarity sono associati ai pannelli, in ciascuno dei quali rappresentiamo un diagramma di dispersione delle variabili carat e price:

ggplot(diamonds) +
  aes(x = carat, y = price) +
  geom_point() +
  facet_wrap(facets = vars(clarity))

Per utilizzare i nomi di colonna come argomenti di una funzione è necessario un doppio passaggio: il nome della colonna passato in input deve prima essere trasformato usando la funzione ensym, che indica al sistema che l’argomento contiene un “simbolo” (il nome di una colonna in questo caso), e l’oggetto risultante deve essere utilizzato all’interno di una doppia parentesi graffa per ottenere il risultato desiderato. La seguente funzione prende tre argomenti di input: la tabella dati, la variabile di stratificazione e la variabile da sintetizzare:

calcola_tabella <- function(tb_dati, var_gruppi, var_sintesi){
  
  var_gruppi <- ensym(var_gruppi)
  var_sintesi <- ensym(var_sintesi)
  
  tb_dati |> 
    group_by({{ var_gruppi }}) |> 
    summarise(mean({{ var_sintesi }}))
  
} 

I seguenti esempi di chiamata mostra che i nomi delle colonne possono essere passate in input usando la stessa sintassi delle funzioni del mondo tidyverse (senza racchiuderli tra virgolette) ma anche come stringhe di testo che contengono i nomi delle colonne da utilizzare per il calcolo:

calcola_tabella(diamanti, clarity, price)
# A tibble: 8 × 2
  clarity `mean(price)`
  <ord>           <dbl>
1 I1              3924.
2 SI2             5063.
3 SI1             3996.
4 VS2             3925.
5 VS1             3839.
6 VVS2            3284.
7 VVS1            2523.
8 IF              2865.
calcola_tabella(diamanti, clarity, "price")
# A tibble: 8 × 2
  clarity `mean(price)`
  <ord>           <dbl>
1 I1              3924.
2 SI2             5063.
3 SI1             3996.
4 VS2             3925.
5 VS1             3839.
6 VVS2            3284.
7 VVS1            2523.
8 IF              2865.
calcola_tabella(diamanti, "clarity", price)
# A tibble: 8 × 2
  clarity `mean(price)`
  <ord>           <dbl>
1 I1              3924.
2 SI2             5063.
3 SI1             3996.
4 VS2             3925.
5 VS1             3839.
6 VVS2            3284.
7 VVS1            2523.
8 IF              2865.
calcola_tabella(diamanti, "clarity", "price")
# A tibble: 8 × 2
  clarity `mean(price)`
  <ord>           <dbl>
1 I1              3924.
2 SI2             5063.
3 SI1             3996.
4 VS2             3925.
5 VS1             3839.
6 VVS2            3284.
7 VVS1            2523.
8 IF              2865.

E’ possibile anche definire un nome in modo dinamico componendo una stringa che contiene il nome della colonna passato in input. Ecco un piccolo miglioramento della funzione precedente che usa nella tabella di output il nome della colonna in input con un suffisso che descrive la funzione di sintesi calcolata (da notare che in questo caso è necessario l’utilizzo dell’operatore := invece del classico =:

calcola_tabella <- function(tb_dati, var_gruppi, var_sintesi){
  
  var_gruppi <- ensym(var_gruppi)
  var_sintesi <- ensym(var_sintesi)

  tb_dati |> 
    group_by({{ var_gruppi }}) |> 
    summarise("{{var_sintesi}}_avg"  := mean({{ var_sintesi }}))
  
} 

Anche in questo caso è possibile sfruttare le differenti sintassi di chiamata viste per la precedente versione della funzione:

calcola_tabella(diamanti, clarity, price)
# A tibble: 8 × 2
  clarity price_avg
  <ord>       <dbl>
1 I1          3924.
2 SI2         5063.
3 SI1         3996.
4 VS2         3925.
5 VS1         3839.
6 VVS2        3284.
7 VVS1        2523.
8 IF          2865.
calcola_tabella(diamanti, clarity, "price")
# A tibble: 8 × 2
  clarity price_avg
  <ord>       <dbl>
1 I1          3924.
2 SI2         5063.
3 SI1         3996.
4 VS2         3925.
5 VS1         3839.
6 VVS2        3284.
7 VVS1        2523.
8 IF          2865.
calcola_tabella(diamanti, "clarity", price)
# A tibble: 8 × 2
  clarity price_avg
  <ord>       <dbl>
1 I1          3924.
2 SI2         5063.
3 SI1         3996.
4 VS2         3925.
5 VS1         3839.
6 VVS2        3284.
7 VVS1        2523.
8 IF          2865.
calcola_tabella(diamanti, "clarity", "price")
# A tibble: 8 × 2
  clarity price_avg
  <ord>       <dbl>
1 I1          3924.
2 SI2         5063.
3 SI1         3996.
4 VS2         3925.
5 VS1         3839.
6 VVS2        3284.
7 VVS1        2523.
8 IF          2865.

La funzione così definita può essere utilizzata su tabelle diverse specificando in maniera opportune la colonna di stratificazione e quella di calcolo. Ecco un esempio che calcola la lunghezza media della variabile Sepal.Length per i vari livelli della variabile Species sulla tabella iris disponibile direttamente in R:

calcola_tabella(iris, Species, Sepal.Length)
# A tibble: 3 × 2
  Species    Sepal.Length_avg
  <fct>                 <dbl>
1 setosa                 5.01
2 versicolor             5.94
3 virginica              6.59

Ed ecco il calcolo dell’aspettativa di vita media (lifeExp come variabile di sintesi) per i vari continenti (continent come variabile di stratificazione) sui dati della tabella gapminder``{gapminder}:

calcola_tabella(gapminder::gapminder, continent, lifeExp)
# A tibble: 5 × 2
  continent lifeExp_avg
  <fct>           <dbl>
1 Africa           48.9
2 Americas         64.7
3 Asia             60.1
4 Europe           71.9
5 Oceania          74.3

Un’ulteriore generalizzazione può essere ottenuta inserendo un ulteriore argomento che permetta di personalizzare anche la funzione di sintesi utilizzata, personalizzando di conseguenza anche il nome della colonna di sintesi:

calcola_tabella_fun <- function(tb_data, var_gruppo, var_sintesi, fun = mean){
  
  var_gruppo <- ensym(var_gruppo)
  var_sintesi <- ensym(var_sintesi)
  
  tb_data |> 
    group_by({{var_gruppo}}) |> 
    summarise("{{var_sintesi}}_{{fun}}" := fun({{var_sintesi}}))
}

Se non si utilizza l’argomento fun, la funzione crea_tabella calcola per default la media come funzione di sintesi:

calcola_tabella_fun(diamanti, cut, price)
# A tibble: 5 × 2
  cut       price_mean
  <ord>          <dbl>
1 Fair           4359.
2 Good           3929.
3 Very Good      3982.
4 Premium        4584.
5 Ideal          3458.

Ma è possibile calcolare qualunque funzione di sintesi; ecco un esempio che calcola il minimo della variabile price per i differenti livelli della variabile cut sulla tabella diamonds:

calcola_tabella_fun(diamonds, cut, price, fun = min)
# A tibble: 5 × 2
  cut       price_min
  <ord>         <int>
1 Fair            337
2 Good            327
3 Very Good       336
4 Premium         326
5 Ideal           326

La funzione crea_tabella può essere utilizzata su altre tabelle e all’interno di catene di comandi ottenute usando l’operatore pipe. Ecco il calcolo della tabella delle aspettative di vita media per i vari continenti a partire dai dati contenuti nella tabella gapminder``{gapminder} con riferimento al solo anno 2007:

gapminder::gapminder |> 
  filter(year == 2007) |> 
  calcola_tabella_fun("continent", "lifeExp")
# A tibble: 5 × 2
  continent lifeExp_mean
  <fct>            <dbl>
1 Africa            54.8
2 Americas          73.6
3 Asia              70.7
4 Europe            77.6
5 Oceania           80.7

ed ecco la stessa tabella che invece delle medie calcola gli scarti quadratici medi:

gapminder::gapminder |> 
  filter(year == 2007) |> 
  calcola_tabella_fun(continent, pop, fun = sd)
# A tibble: 5 × 2
  continent     pop_sd
  <fct>          <dbl>
1 Africa     24917726.
2 Americas   68833781.
3 Asia      289673399.
4 Europe     23624744.
5 Oceania    11538855.

Estensioni al caso di funzioni “grafiche”

Lo stesso approccio può essere utilizzato per definire delle funzioni utili a definire una sorta di modello di rappresentazione grafica da utilizzare su differenti tabelle e differenti variabili. A titolo esemplificato, la seguente funzione prende in input tre argomenti, una tabella dati e due variabili in essa contenute e traccia uno scatterplot sfruttando il package ggplot2. Le variabili, come visto nel caso sopra, possono essere passate come simbolo o come stringa:

crea_scatter <- function(tb_data, var_x, var_y){
  
  var_x <- ensym(var_x)
  var_y <- ensym(var_y)
  
  ggplot(data = tb_data) +
    aes(x = {{var_x}}, y = {{var_y}}) +
    geom_point()
}

Utilizziamo la funzione per tracciare un diagramma a dispersione delle variabili carat e price, contenute nella tabella diamanti:

crea_scatter(diamanti, carat, price)

Si ottiene lo stesso risultato passando alla funzione come secondo e terzo argomenti i “nomi” delle variabili (come stringhe):

crea_scatter(diamanti, "carat", "price")

Il modello di grafico può essere utilizzato facilmente su una tabella diversa e/o su variabili diverse:

crea_scatter(iris, Sepal.Length, Sepal.Width)

E’ naturalmente possibile sfruttare la funzione all’interno di una catena di comandi costruita sfruttando l’operatore pipe:

gapminder::gapminder |> 
  filter(continent == "Europe") |> 
  crea_scatter("pop", "lifeExp")

Allo stesso modo è possibile aggiungere altri strati al grafico ottenuto crea_scatter sfruttando la logica dei layer tipica di ggplot2:

gapminder::gapminder |> 
  filter(year == 2007) |> 
  crea_scatter("pop", "lifeExp") +
    facet_wrap(facets = vars(continent)) +
    ggtitle("Un grafico composto che sfrutta `crea_scatter` con altri comandi `ggplot2`")

Per riprodurre lo stesso grafico riportato all’inizio di questo tutorial per il dataset diamanti si può quindi utilizzare la seguente sintassi:

crea_scatter(diamanti, carat, price) +
    facet_wrap(facets = vars(clarity)) 

Iterazione sfruttando un ciclo for

Il ciclo for permette di effettuare un’iterazione su più elementi di una struttura dati.

Consideriamo un semplice esempio in cui sfruttando un semplice esempio una variabile detta contatore (i nell’esempio) viene utilizzata per muoversi lungo un vettore, stampando a video il valore del contatore e l’elemento nella corrispondente posizione:

x <- c(10, 20, 30, 40, 50)
for(i in 1:length(x)){
  print(paste("Posizione", i, "del vettore:"))
  print(paste("Valore alla posizione", i, "del vettore", x[i]))
}
[1] "Posizione 1 del vettore:"
[1] "Valore alla posizione 1 del vettore 10"
[1] "Posizione 2 del vettore:"
[1] "Valore alla posizione 2 del vettore 20"
[1] "Posizione 3 del vettore:"
[1] "Valore alla posizione 3 del vettore 30"
[1] "Posizione 4 del vettore:"
[1] "Valore alla posizione 4 del vettore 40"
[1] "Posizione 5 del vettore:"
[1] "Valore alla posizione 5 del vettore 50"
for(i in seq_along(x)){
  print(paste("Posizione", i, "del vettore:"))
  print(paste("Valore alla posizione", i, "del vettore", x[i]))
}
[1] "Posizione 1 del vettore:"
[1] "Valore alla posizione 1 del vettore 10"
[1] "Posizione 2 del vettore:"
[1] "Valore alla posizione 2 del vettore 20"
[1] "Posizione 3 del vettore:"
[1] "Valore alla posizione 3 del vettore 30"
[1] "Posizione 4 del vettore:"
[1] "Valore alla posizione 4 del vettore 40"
[1] "Posizione 5 del vettore:"
[1] "Valore alla posizione 5 del vettore 50"
for(el in x){
  print(paste("Valore del vettore:", el))
}
[1] "Valore del vettore: 10"
[1] "Valore del vettore: 20"
[1] "Valore del vettore: 30"
[1] "Valore del vettore: 40"
[1] "Valore del vettore: 50"

Proviamo ad usare il ciclo for per definire una funzione che calcoli la sommatoria di un vettore in input:

sommatoria <- function(x){
  
  for(i in seq_along(x)){
    if(i == 1)
      somma = x[i]
    else
      somma = somma + x[i]
  }
  
  return(somma)
}

Seppure la funzione calcola correttamente la sommatoria:

sommatoria(c(10, 20, 30))
[1] 60

sfrutta un if per calcolare il primo valore da attribuire alla somma quando inizia lo scorrimento del vettore. Si può ottenere una struttura più snella senza ricorrere all’if e sfruttando una variabile di accumulo inizializzata a 0 prima dell’iterazione:

sommatoria <- function(x){
  
  somma <- 0
  for(i in seq_along(x)){
      somma = somma + x[i]
  }
  return(somma)
}

sommatoria(c(10, 20, 30))
[1] 60

Usando lo stesso approccio è possibile definire una funzione che calcoli la produttoria, inizializzando questa volta la variabile di accumulo ad 1:

produttoria <- function(x){
  
  prod <- 1
  for(i in seq_along(x)){
    prod = prod + x[i]
  }
  return(prod)
}

produttoria(c(100, 200, 300))
[1] 601

Iterazione sfruttando la funzione map (e varianti)

Il package purrr, disponibile direttamente quando si carica tidyverse introduce la funzione map e le sue varianti che possono essere utilizzate come alternative al ciclo for. Sebbene non ci siano differenze in termini di velocità di esecuzione se il ciclo for è ben strutturato, la sintassi di map spesso permette di ottenere una sintassi più snella.

Consideriamo ad esempio il seguente ciclo for che stampa a video gli elementi di un vettore:

x <- c(10, 11, 12, 13, 14, 15)
for(i in seq_along(x))
  print(x[i])
[1] 10
[1] 11
[1] 12
[1] 13
[1] 14
[1] 15

L’equivalente si può ottenere usando la funzione map:

map(.x = x, .f = print)
[1] 10
[1] 11
[1] 12
[1] 13
[1] 14
[1] 15
[[1]]
[1] 10

[[2]]
[1] 11

[[3]]
[1] 12

[[4]]
[1] 13

[[5]]
[1] 14

[[6]]
[1] 15

La funzione map restituisce sempre una lista della stessa lunghezza del primo argomento. Mostriamo un esempio “inutile” che calcola il logaritmo in base 2 degli elementi di un vettore:

map(c(10, 20, 30), log, base = 2)
[[1]]
[1] 3.321928

[[2]]
[1] 4.321928

[[3]]
[1] 4.906891

E’ naturalmente possibile passare una funzione anonima come secondo argomento di map:

map(c(10, 20, 30), function(numero) log(numero, base = 2))
[[1]]
[1] 3.321928

[[2]]
[1] 4.321928

[[3]]
[1] 4.906891
map(c(10, 20, 30), \(numero) log(numero, base = 2))
[[1]]
[1] 3.321928

[[2]]
[1] 4.321928

[[3]]
[1] 4.906891

Una sintassi più compatta si può ottenere sfruttando la ~ per utilizzare direttamente la sintassi tipica funzionale, usando .x come segnaposto per gli elementi su cui la funzione map effettua l’iterazione:

map(c(10, 20, 30), ~log(.x, base = 2))
[[1]]
[1] 3.321928

[[2]]
[1] 4.321928

[[3]]
[1] 4.906891

La funzione map_vec permette di semplificare la struttura di output in un vettore laddove questo sia possibile:

map_vec(c(10, 20, 30), log, base = 2)
[1] 3.321928 4.321928 4.906891

L’esempio sopra è meramente didascalico ed “inutile” perchè la maggior parte delle funzioni di R, tra cui anche la funzione log, lavorano direttamente elemento ad elemento quando utilizzate su un vettore:

log(c(10, 20, 30), base = 2)
[1] 3.321928 4.321928 4.906891

Esistono diverse varianti della funzione map, map_*, che possono essere utilizzate per cercare di ottenere una struttura vettoriale in output laddove possibile forzando la tipologica di dati ottenuta. Il seguente esempio sfrutta la funzione map_lgl per ottenere un vettore di valori booleani che controllano se un numero è pari (TRUE) o dispari (FALSE):

out <- map_lgl(1:10, \(x) x %% 2 == 0)
out
 [1] FALSE  TRUE FALSE  TRUE FALSE  TRUE FALSE  TRUE FALSE  TRUE
class(out)
[1] "logical"

Si ottiene lo stesso risultato sfruttando la più generale funzione map_vec che determina automaticamente la modalità del vettore in base al tipo di dati:

out <- map_vec(1:10, \(x) x %% 2 == 0)
out
 [1] FALSE  TRUE FALSE  TRUE FALSE  TRUE FALSE  TRUE FALSE  TRUE
class(out)
[1] "logical"

Altri esempi di iterazioni con for e con map

Proviamo ora ad applicare i cicli di iterazionefor e la variante funzionale map a qualcosa di più interessante, ovvero al calcolo delle medie di un gruppo di variabili numeriche i cui nomi sono memorizzati in un vettore di stringhe. Vale la pena evidenziare che a differenza di quanto avviene richiamando una funzione sul prompt senza assegnazione ad un oggetto, se si utilizza una funzione all’interno di un ciclo for l’espressione non produce alcun output a video, come è possibile verificare usando il seguente chunk di codice:

vars_numeriche <- c("price", "x", "y", "z")
for(i in 1:length(vars_numeriche))
  mean(diamonds[[vars_numeriche[i]]])

Per ottenere un output a video, all’interno di un ciclo for è necessario inserire la chiamata esplicita alla funzione print:

vars_numeriche <- c("price", "x", "y", "z")
for(i in 1:length(vars_numeriche))
  print(mean(diamonds[[vars_numeriche[i]]]))
[1] 3932.8
[1] 5.731157
[1] 5.734526
[1] 3.538734

E’ naturalmente possibile usare una delle sintassi alternativa già viste in precedenza per far muovere il contatore del ciclo for, ovvero v_num in vars_numeriche:

vars_numeriche <- c("price", "x", "y", "z")
for(v_num in vars_numeriche)
  print(mean(diamonds[[v_num]]))
[1] 3932.8
[1] 5.731157
[1] 5.734526
[1] 3.538734

oppure sfruttare la funzione seq_along come di seguito:

vars_numeriche <- c("price", "x", "y", "z")
for(i in seq_along(vars_numeriche))
  print(mean(diamonds[[vars_numeriche[i]]]))
[1] 3932.8
[1] 5.731157
[1] 5.734526
[1] 3.538734

Per creare un vettore con le medie delle variabili numeriche di interesse possiamo inizializzare prima del ciclo for un vettore di lunghezza pari al numero di variabili:

vars_numeriche <- c("price", "x", "y", "z")
vet_medie <- numeric(length = length(vars_numeriche))
for(i in seq_along(vars_numeriche))
  vet_medie[i] <- mean(diamonds[[vars_numeriche[i]]])

vet_medie
[1] 3932.799722    5.731157    5.734526    3.538734

Possiamo sfruttare il vettore con i nomi delle variabili per assegnare un nome agli elementi del vettore:

names(vet_medie) <- vars_numeriche
vet_medie
      price           x           y           z 
3932.799722    5.731157    5.734526    3.538734 

La funzione map ci permette di ottenere lo stesso risultato usando un’espressione più concisa. Calcoliamo ad esempio la media di tutte le variabili numeriche presenti nella tabella diamanti:

map(select(diamanti, where(is.numeric)), .f = mean)
$carat
[1] 0.7979397

$depth
[1] 61.7494

$table
[1] 57.45718

$price
[1] 3932.8

$x
[1] 5.731157

$y
[1] 5.734526

$z
[1] 3.538734

Possiamo sfruttare la funzione map_vec per ottenere in output un vettore:

map_vec(select(diamanti, where(is.numeric)), .f = mean)
       carat        depth        table        price            x            y 
   0.7979397   61.7494049   57.4571839 3932.7997219    5.7311572    5.7345260 
           z 
   3.5387338 

Un esempio più articolato? Possiamo sfruttare la funzione map per effettuare un’iterazione su un vettore contenente i nomi delle variabili numeriche:

nomi_numeriche <- diamanti |> select(where(is.numeric)) |> colnames()
nomi_numeriche
[1] "carat" "depth" "table" "price" "x"     "y"     "z"    

per calcolare le medie di ciascuna di queste variabili in corrispondenza dei vari livelli della variabile cut, sfruttando la funzione calcola_tabella creata in precedenza:

map(.x = nomi_numeriche, 
    .f = calcola_tabella, 
    tb_dati = diamanti, 
    var_gruppi = cut)
[[1]]
# A tibble: 5 × 2
  cut       carat_avg
  <ord>         <dbl>
1 Fair          1.05 
2 Good          0.849
3 Very Good     0.806
4 Premium       0.892
5 Ideal         0.703

[[2]]
# A tibble: 5 × 2
  cut       depth_avg
  <ord>         <dbl>
1 Fair           64.0
2 Good           62.4
3 Very Good      61.8
4 Premium        61.3
5 Ideal          61.7

[[3]]
# A tibble: 5 × 2
  cut       table_avg
  <ord>         <dbl>
1 Fair           59.1
2 Good           58.7
3 Very Good      58.0
4 Premium        58.7
5 Ideal          56.0

[[4]]
# A tibble: 5 × 2
  cut       price_avg
  <ord>         <dbl>
1 Fair          4359.
2 Good          3929.
3 Very Good     3982.
4 Premium       4584.
5 Ideal         3458.

[[5]]
# A tibble: 5 × 2
  cut       x_avg
  <ord>     <dbl>
1 Fair       6.25
2 Good       5.84
3 Very Good  5.74
4 Premium    5.97
5 Ideal      5.51

[[6]]
# A tibble: 5 × 2
  cut       y_avg
  <ord>     <dbl>
1 Fair       6.18
2 Good       5.85
3 Very Good  5.77
4 Premium    5.94
5 Ideal      5.52

[[7]]
# A tibble: 5 × 2
  cut       z_avg
  <ord>     <dbl>
1 Fair       3.98
2 Good       3.64
3 Very Good  3.56
4 Premium    3.65
5 Ideal      3.40