for (element in array) {
# opero su element
}R-recap
Programmazione funzionale e manipolazione delle tabelle con Tidyverse
Questo documento è una ricapitolazione delle librerie purrr, dplyr e tidyr del Tidyverse, con esempi pratici e suggerimenti per l’uso. Si tratta di una guida pratica, non esaustiva, che si concentra sugli aspetti più comuni e utili delle due librerie, che saranno utili durante il corso.
1 La libreria purrr
purrr è parte del Tidyverse e può essere caricata da sola (library(purrr)) oppure assieme alle altre librerie del Tidyverse (library(tidyverse)).
È utile tenere sotto mano il relativo cheat sheet: https://rstudio.github.io/cheatsheets/purrr.pdf (o cliccando sull’icona a fianco).
1.1 Scopo
Lo scopo di purrr è fornire un’interfaccia funzionale (cioè basata sulle funzioni) per ogni procedura che richieda l’iterazione di un’operazione. In altre parole, usiamo una funzione al posto del corpo di un ciclo for. Invece di:
usiamo:
array %>% map(funzione)dove funzione può essere:
- il nome di una funzione esistente (ad es.
mean) - una funzione anonima (cioè senza nome) definita con
function(argomento) { espressione(argomento) } - una funzione anonima definita con
\(argomento) espressione(argomento) - una funzione anonima definita con la scorciatoia
~ espressione(.)
Quest’ultima sintassi è la più compatta e quella di solito utilizzata. Si noti che se il contenuto della funzione è un’unica espressione si può fare a meno delle parentesi graffe; se invece la funzione richiede più passaggi, le espressioni vanno racchiuse in {} (andando a capo tra un’espressione e l’altra, oppure separandole con ;).
1.2 Vantaggi
In generale: velocità , flessibilità e leggibilità del codice.
Ad esempio, consideriamo la tabella mtcars: vogliamo regredire il modello mpg ~ wt (correlazione tra consumo e massa) per ciascuna classe di numero di cilindri. Con purrr possiamo scrivere:
- Ottenere lo stesso risultato con una serie di cicli
for: confrontare i due approcci. - È possibile usare un’unica chiamata a
map()? se sì, che vantaggi ci sono?
Un ciclo for:
Un’unica map():
1.3 Varianti
1.3.1 Mappe
La funzione base map opera su un unico vettore o lista e restituisce sempre una lista. È cioè una mappa \(n\rightarrow n\) dove \(n\) è la lunghezza dell’input, uguale a quella dell’output:
Come si vede, il risultato di map è una lista di 10 elementi, ciascuno dei quali è il quadrato di un elemento del vettore in ingresso generato da sample(10).
Nei casi pratici spesso è necessario operare su due o più vettori in ingresso e si desidera ottenere, anziché una lista, un vettore di uno specifico tipo. Per queste operazioni ci sono varianti di map che hanno il nome così costituito:
map_<tipo>(ad es.map_dbl,map_chr,map_int,map_lgl), per restituire un vettore di numeri, caratteri, interi o logici, rispettivamentemap2e le variantimap2_<tipo>per operare su due vettori in ingresso (stessa lunghezza)pmape le variantipmap_<tipo>per operare su una lista di vettori (stessa lunghezza) in ingresso (in parallelo)
Il cheat sheet di purrr è utile per visualizzare le differenze.
1.3.2 Cicli muti
A volte è necessario iterare su una lista di elementi, ma non si desidera ottenere un output. In questi casi si può usare walk e le sue varianti walk2, pwalk. Ad esempio:
Consultare il cheat sheet, modificare l’ultimo chunk e ottenere una riga di messaggi come:
Lettera 1: A
Lettera 2: B
Lettera 3: C
...
1.3.3 Modifica
Per modificare gli elementi di un insieme (lista o vettore), eventualmente secondo un criterio, si possono usare le funzioni modify, modify_if, modify_at. Ad esempio:
Si noti che modify_if prende due funzioni come argomenti: la prima è il criterio di selezione, chiamato predicato, la seconda è la trasformazione da applicare agli elementi selezionati. Il predicato deve essere una funzione che restituisce un valore logico (TRUE o FALSE).
A dispetto del nome, modify non modifica l’oggetto in ingresso, ma ne restituisce una copia modificata.
1.3.4 Riduzione/Accumulo
Infine, purrr offre anche funzioni di riduzione, come reduce, accumulate, reduce2, accumulate2. Queste funzioni permettono di ridurre una lista di elementi a un singolo valore (quindi sono una mappa \(n\rightarrow 1\)), secondo un criterio specificato. Ad esempio, applico la funzione addizione per accumulare tutti i numeri da 1 a 10:
1.3.5 Nota sulle funzioni vettorializzate
Gli esempi semplici sopra riportati sono in realtà inutili, perché in R tutte le funzioni base sono già vettorializzate, cioè in grado di operare su tutti gli elementi di un vettore atomico (atomic vector).
In R, un vettore atomico è un vettore di elementi di tipo base. Ad esempio, un vettore di numeri interi, di numeri reali, di caratteri, di logici, ecc.
Le funzioni base, invece, non sono in grado di operare su liste o su vettori di oggetti complessi. Per esempio, se si vuole calcolare la media di una lista di valori numerici, non si può fare semplicemente mean(lista), ma bisogna usare map_dbl(lista, mean).
Una funzione vettorializzata è sempre più veloce di un ciclo for o di una qualsiasi mappa purrr, quindi è opportuno usare purrr solo quando necessario, cioè solo quando:
- si deve operare su liste o su vettori di oggetti complessi
- la funzione da applicare NON è vettorializzata, ad esempio perché contiene un condizionale
ifinterno (anche seifelse()è vettorializzata)
1.4 Esempi
Definiamo anzitutto una tabella dati di esempio:
1.4.1 Esempio map
Applichiamo una lista di modelli di regressione ad un set di dati. Questo esempio non è vettorializzabile, perché la funzione lm non è vettorializzata sull’argomento formula, cioè non accetta un vettore di formule in ingresso. Quindi passiamo la lista delle formule da regredire alla funzione map:
L’abbreviazione ~. per le funzioni anonime consente di usare il punto . come riferimento all’oggetto in ingresso. In certi casi, però (come in quello qui sopra), il punto è necessario anche per riferirsi all’oggetto passato dalla pipe data %>% ..., quindi ci sarebbe un’ambiguità . In questi casi si può utilizzare .x per riferirsi all’argomento della funzione anonima, e . per riferirsi all’oggetto passato dalla pipe, come sopra fatto.
In alternativa, e per maggior chiarezza, si può usare la forma \(x) ... per la funzione anonima, e . per riferirsi all’oggetto passato dalla pipe.
Si lascia per esercizio modificare il codice di conseguenza
Vogliamo rendere più generale il codice precedente, consentendo di costruire automaticamente la lista delle formule a partire da un vettore di gradi polinomiali. Modificare il codice di conseguenza, ad es. per verificare i polinomi di grado 2, 5, e 3 (in quest’ordine).
Suggerimento: ricordare la funzione as.formula().
c(2, 5, 3) %>%
... # completareUsando lo stesso set di dati e un’ulteriore chiamata a map(), ripetere l’analisi su entrambe le serie x1 e x2.
Suggerimento: consultare la guida per split() e usare la notazione \(arg) ... per la funzione anonima.
1.4.2 Esempio map_dbl
La funzione map restituisce sempre una lista. Se si desidera un vettore di un tipo specifico, si può usare map_dbl, map_chr, map_int, map_lgl, ecc. Ad esempio, dalle regressioni estraiamo il valore di \(R^2\):
Modificare il codice precedente in modo da eseguire una sola chiamata a una funzione purrr, ottenendo lo stesso risultato.
1.4.3 Esempio: reduce
Le operazioni di riduzione sono abbastanza comuni. Di fatto ogni statistica (cioè indicatore comune) è una riduzione di un insieme di valori. Ad esempio, calcoliamo la media e la deviazione standard di una serie di dati mediante un loop (e senza ricorrere alle funzioni native mean() e sd()):
Ora facciamo la stessa cosa con purrr, usando reduce(). In questo caso la funzione anonima, come specificato dall’help in linea, prende due argomenti: il primo, .x, è il valore accumulato, il secondo, .y, è il valore corrente. Il risultato della funzione anonima viene passato all’iterazione successiva come .x.
Il codice è decisamente più semplice, tuttavia il valore della deviazione standard è errato. Come mai?
Consultando l’help di reduce(), provare a comprendere il motivo dell’errore nel calcolo della deviazione standard. Usare anche la funzione accumulate() sotto illustrata come versione di debug di reduce().
È necessario prestare attenzione all’inizializzazione dell’accumulatore. Come visto sopra:
- la funzione anonima a due parametri (
.xe.y) è chiamata per ogni elemento della lista - il primo valore della lista originale è usato come
.xiniziale (cioè alla prima iterazione) - ad ogni iterazione successiva,
.xcontiene il risultato della precedente
A volte è necessario inizializzare l’accumulatore con un valore diverso da quello del primo elemento della lista, ad esempio perché il risultato finale di reduce deve essere di tipo diverso dall’elemento della lista, o con un valore diverso da quello del primo elemento. In questi casi si può usare il parametro .init di reduce(), passandogli il valore iniziale della variabile di accumulo. Ad esempio, confrontare quanto segue:
La prima corrisponde a \(\sum_{i=1}^{10} i\), la seconda a \(\sum_{i=11}^{20} i\).
È forse più utile quando il tipo desiderato in uscita è diverso. Ad esempio, supponiamo di voler accodare ad una lista, inizialmente vuota, una serie di coppie nome-valore in cui i nomi sono le prime 10 lettere, e i valori sono i primi 10 numeri interi. Usiamo reduce2() in modo da poter lavorare sui due vettori (numeri e lettere) in parallelo:
Nell’ultimo esempio:
LETTERS[.]è una scorciatoia perLETTERS[1:10], dove1:10arriva come argomento dalla pipe. In questo modo possiamo cambiare1:10con1:20senza dover cambiare anche il vettore di lettere (i due vettori devono avere la stessa lunghezza!).\(acc, n, letter) { ... }è una funzione anonima che prende tre argomenti: l’accumulatore, il valore corrente di del primo argomento direduce2(cioè1:10) e il valore corrente del secondo argomento direduce2(cioèLETTERS[.]).- La funzione aggiunge un elemento alla lista
acccon chiavelettere valoren. return(acc)è necessaria, perché l’assegnazioneacc[[letter]] <- nnon restituisceacc. La funzione anonima deve ritornare il valore accumulato.- È necessario usare la sintassi
\(...)per la funzione anonima, perché non c’è la versione~con 3 argomenti.
1.4.4 Esempio: accumulate
Accumulate si comporta come reduce(), ma restituisce una lista di valori intermedi. Ad esempio, calcoliamo il prodotto cumulativo di una serie di dati, osservando la differenza con reduce():
Questa funzione è spesso utile come debug per reduce(), dato che consente di osservare i valori intermedi.
Invece che una funzione anonima avremmo potuto usare direttamente la funzione * che accetta due argomenti:
1.5 Uso con dplyr::mutate
purrr è particolarmente utile in combinazione con mutate(), per eseguire operazioni complesse su colonne di un data frame. Ad esempio, consideriamo il data frame data definito sopra. Vogliamo aggiungere alla colonna valore una valore in funzione della colonna serie. Dato che if non accetta vettori, dobbiamo usare map():
Possiamo essere più sintetici e scrivere:
Però ifelse() è vettorializzata, quindi in realtà non c’è bisogno di map():
Se invece avessimo più casi, allora dovremmo usare map():
In generale, quindi, valutare sempre attentamente se sia realmente necessario utilizzare una mappa purrr, e non sia invece sufficiente una funzione vettorializzata, anche se in certe condizioni una mappa o una riduzione possono sono l’unica soluzione per operazioni complesse.
2 La libreria dplyr
dplyr è una libreria del Tidyverse che fornisce un insieme di funzioni per manipolare dati in modo efficiente. È possibile caricare dplyr da solo (library(dplyr)) oppure assieme alle altre librerie del Tidyverse (library(tidyverse)).
L’obiettivo di dplyr è semplificare le operazioni su data frame e tibble, operando per riga o per colonna e permettendo il raggruppamento e la sommarizzazione dei dati.
2.1 Funzioni principali
Le funzioni principali di dplyr sono presentate efficacemente nel cheat sheet https://rstudio.github.io/cheatsheets/data-transformation.pdf (o cliccando sull’icona qui a fianco).
Qui riprendiamo le più comuni, cioè:
- per righe:
filter(): seleziona righe in base a condizioniarrange(): ordina le righe
- per colonne:
select(): seleziona colonnemutate(): crea nuove colonne o modifica colonne esistentirename(): rinomina colonnerelocate(): sposta colonne (e se necessario le rinomina)
- raggruppamento:
group_by(): raggruppa i datisummarise(): sommarizza i dati
- unione di più tabelle:
bind_rows(): unisce due tabelle per righe (una sopra l’altra)join(): unisce due tabelle per colonne (una accanto all’altra, con colonne comuni)
2.1.1 Operazioni per righe
Le operazioni più comuni sono il filtraggio e il riordino delle righe, anche chiamate casi o osservazioni:
Si noti anche la funzione slice_head() che restituisce le prime n righe di una tabella. Sono utili anche slice_tail() (ultime righe) e slice_sample() (un campione di righe estratte casualmente).
- Cosa succede se cambio l’ordine dei vari passi?
- Provare a costruire criteri di selezione più complicati, ad esempio condizione
Be seriex2.
2.1.2 Operazioni per colonne
Le colonne di una tabella sono anche chiamate variabili o campi. Le operazioni più comuni sono la selezione, la creazione e la modifica di variabili:
Tutte queste operazioni accettano come argomento il nome o i nomi delle colonne su cui operare. La sintassi rename(`tempo (s)` = t) ad esempio crea una nuova colonna chiamata tempo (s) a partire dalla colonna t, usando i backtick perché il nuovo nome contiene uno spazio e delle parentesi.
In particolare, select() accetta una sintassi molto flessibile per specificare le colonne, ben illustrata sull’help in linea di select(). Ad esempio, è possibile indicare
- intervalli di colonne:
select(t:valore)seleziona le colonne dallatallavalore - combinazioni di colonne:
select(c(t, serie))seleziona le colonneteserie(è possibile usare anche operatori logici&,|,!) - in base al nome:
select(starts_with("x"))seleziona le colonne che iniziano conx,select(ends_with("e"))quelle che finiscono cone,select(contains("1"))quelle che contengono1,select(contains("t")), quelle che contengonot, ecc.
La funzione relocate() serve infine per spostare una colonna, con l’opzione di rinominarla:
Anche con più colonne contemporaneamente:
2.1.3 Raggruppamento e sommarizzazione
Il raggruppamento e la sommarizzazione sono operazioni molto comuni in statistica. Ad esempio, vogliamo calcolare la media e la deviazione standard di valore per ciascuna serie:
Calcolare media e deviazione standard per ciascuna serie utilizzando invece una funzione map().
2.1.4 Unione di tabelle
L’operazione più semplice tra due tabelle è unirle una sopra l’altra, cioè per righe. È il caso in cui si vogliano aggiungere delle osservazioni ad una tabella già esistente. Questa operazione si fa con bind_rows(). Per fare un esempio, cominciamo col separare la tabella data in due parti:
Se ora volgiamo riunire le due tabelle, possiamo fare:
Se in una delle due tabelle mancano una o più colonne, i valori mancanti sono inseriti come NA:
L’operazione ortogonale a bind_rows() è bind_cols(), che unisce due tabelle per colonne. Questa operazione è ovviamente possibile solo se le due tabelle hanno lo stesso numero di righe, ma presenta possibili problemi logici: cosa succede se le due tabelle, pur avendo lo stesso numero di righe, hanno righe in ordine diverso? In questo caso, bind_cols() unisce le colonne in base all’ordine delle righe, non in base al contenuto, con risultato inatteso e errato.
Per questo motivo è sempre da preferire left_join(), che unisce due tabelle per colonne in base al contenuto di una o più colonne comuni. Questa operazione è molto comune in analisi dati, ad esempio per unire dati provenienti da fonti diverse. La funzione left_join() accetta come argomenti la tabella di sinistra, la tabella di destra, e le colonne su cui fare il join.
Il join è un’operazione molto comune nell’analisi dei dati, e presente in molti linguaggi e database (SQL). Lo schema a lato presenta un caso esemplare: si hanno due tabelle, una con i clienti e una con gli ordini. Si vuole unire le due tabelle in modo da avere una tabella con tutti gli ordini, e per ciascun ordine il nome del cliente che l’ha effettuato. Questo è un left join, cioè si vogliono tutti gli ordini, anche quelli per cui non c’è un cliente associato. Esistono ovviamente anche right join (tieni tutte le righe della tabella di destra), inner join (tieni solo le righe presenti in entrambe le tabelle), e full join (tieni tutte le righe di entrambe le tabelle).
erDiagram
Clienti {
int ID
string Nome
string Email
}
Ordini {
int ID_Ordine
int Cliente_ID
string Prodotto
}
Clienti ||--o{ Ordini : "LEFT JOIN ON Cliente_ID"
In dplyr il left join si fa con left_join(), il right join con right_join(), l’inner join con inner_join(), e il full join con full_join().
Il caso più comune è il left join:
Nell’ultimo chunk, notare l’uso di relocate() per spostare e rinominare la colonna condizione dopo la colonna t.
3 La libreria tidyr
L’ultima libreria del Tidyverse che presentiamo è tidyr, che fornisce funzioni per manipolare la struttura dei dati, cioè per trasformare i dati da wide a long e viceversa.
Il cheat sheet https://rstudio.github.io/cheatsheets/tidyr.pdf) di tidyr è molto utile per capire le differenze tra le funzioni principali. Sebbene la libreria fornisca numerose funzioni, le più comuni sono pivot_longer() e pivot_wider(). Le altre funzioni illustrate nel cheat sheet sono utili per operazioni più specifiche, le useremo meno spesso (o mai), ma vale comunque la pena sapere che esistono.
3.1 Formati wide e long
Raccogliere dati provenienti da diverse sorgenti in una tabella in formato wide è piuttosto comune e intuitivo, ed ha il vantaggio di raggruppare visivamente dati coerenti. Per questo motivo è un modo molto comune di presentare i dati.
Se ad esempio stiamo raccogliendo ogni 0.1 s le misure di accelerazione da un sensore triassiale (assi \(X, Y, Z\)), è naturale avere una tabella con una riga per ciascun campionamento e una colonna per ciascun asse. Questo formato è detto wide:
Per mettere in grafico i dati con ggplot2 dobbiamo trattare ogni asse (colonna) come una serie differente:
Tuttavia, per analizzare i dati è spesso più comodo avere i dati in formato long, cioè con una riga per ogni osservazione e una colonna per ogni variabile. Questo formato è più flessibile e permette di usare le funzioni di dplyr e purrr, oltre a ggplot2, in modo più efficiente.
Per convertire i dati da wide a long si usa pivot_longer(). Questa funzione accetta come argomenti la tabella da trasformare, le colonne da mantenere invariate, e le colonne da trasformare in righe. Ad esempio, per trasformare la tabella accel in formato long:
Realizzare il grafico con i dati in formato long è più semplice (si noti il riuso dell’estetica e che sono necessarie solo due geometrie al posto delle sei necessarie con i dati in formato wide per ottenere lo stesso grafico):
Lo stesso vantaggio si ha ad esempio per le sommarizzazioni:
In generale, il formato wide è più compatto, più adatto alla visualizzazione in tabella e alla presentazione dei dati, ma il formato long è più flessibile e permette di usare le funzioni di manipolazione dei dati in modo più efficiente.
È quindi consigliabile generare e memorizzare le tabelle in formato long, e convertirle in wide solo quando necessario per la visualizzazione.


