Creare una colonna condizionatamente ad altre

Ovvero: gestire condizioni vettorializzate

Introduzione

Ciao a tutti, oggi una collega ha avuto un problema con le età in una base di dati. In particolare, esistevano due colonne con tale informazione: in una l’età era stata inserita direttamente da chi aveva compilato il format di inserimento dati, nell’altra l’età era stata calcolata (in automatico suppongo) a partire dalla data di nascita e un giorno noto di riferimento.

Supponendo che le informazioni fossero tutte giuste, le due colonne sarebbero dovute essere uguali, e complete.

Tralasciando il primo problema (controllare che le informazioni, quando entrambe presenti, fossero coerenti), la questione di oggi era che le età riportate non erano presenti per tutti i soggetti, e nemmeno quelle calcolate. L’interesse era quindi:

creare una colonna riportante il valore dell’età riportata (ritenuto di qualità superiore), quando presente, o quello dell’età calcolata in assenza del primo.

Per poterci ragionare sopra, creiamo una base di dati di esempio che riporti i possibili casi, e carichiamo anche i pacchetti che ci saranno utili. Impostiamo anche un seed per la riproducibilità.

suppressPackageStartupMessages(library(dplyr))   # gestione basi di dati
suppressPackageStartupMessages(library(lubridate))       # gestione date
set.seed(1)
foreveryoung <- tribble(
  ~reported_age, ~computed_age, ~wanted_results,
             18,            18,  18,
             NA,            18,  18,
             18,            NA,  18,
             NA,            NA,  NA,
             18,            19,  18          # possibilmente con warning
)

foreveryoung

if - else

La prima opzione che mi viene in mente, è quella di usare una struttura if-else. Vediamo qual’è il risultato applicando il tutto nel modo (che a me sembra) più diretto e intuitivo.

foreveryoung %>% 
  mutate(
    final_age = if (!is.na(reported_age)) reported_age else computed_age
  )
#> Warning: Problem with `mutate()` input `final_age`.
#> ℹ the condition has length > 1 and only the first element will be used
#> ℹ Input `final_age` is `if (!is.na(reported_age)) reported_age else computed_age`.
#> Warning in if (!is.na(reported_age)) reported_age else computed_age: the
#> condition has length > 1 and only the first element will be used

PuffRbacco!!!, è sbagliato. Come mai? La risposta possiamo trovarla in Invalid inputs (Wickham 2019). Insieme a un modo per accorgersene ed evitare che accada:1

Sys.setenv("_R_CHECK_LENGTH_1_CONDITION_" = "true")
foreveryoung %>% 
  mutate(
    final_age = if (!is.na(reported_age)) reported_age else computed_age
  )
#> Error: Problem with `mutate()` input `final_age`.
#> x the condition has length > 1
#> ℹ Input `final_age` is `if (!is.na(reported_age)) reported_age else computed_age`.

Eccolo li infatti!! Il problema è proprio che una condizione if non è vettorializzata, ovvero, restituisce solo il primo risultato se al suo interno ne ha di più.

Rimettiamo le variabili ambientali alla normalità e vediamo esplicitamente cosa accade riprendendo lo stesso esempio della sezione segnalata.

Sys.setenv("_R_CHECK_LENGTH_1_CONDITION_" = "false")

if (c(TRUE, FALSE)) 1 else 2
#> Warning in if (c(TRUE, FALSE)) 1 else 2: the condition has length > 1 and only
#> the first element will be used
#> [1] 1
if (c(TRUE, TRUE)) 1 else 2
#> Warning in if (c(TRUE, TRUE)) 1 else 2: the condition has length > 1 and only
#> the first element will be used
#> [1] 1

if (c(FALSE, TRUE)) 1 else 2
#> Warning in if (c(FALSE, TRUE)) 1 else 2: the condition has length > 1 and only
#> the first element will be used
#> [1] 2
if (c(FALSE, FALSE)) 1 else 2
#> Warning in if (c(FALSE, FALSE)) 1 else 2: the condition has length > 1 and only
#> the first element will be used
#> [1] 2

E in effetti, come leggiamo dal warning, solo la prima condizione viene usata e tutto il resto della condizione ignorato. Del resto chiediamo di eseguire un istruzione se una condizione è vera, come potrebbe R sapere cosa fare con molteplici condizioni?

D’altro canto R è dotata di una struttura adeguata specificatamente allo scopo!

ifelse

La funzione ifelse() è proprio quella messa a disposizione da R per gestire un vettore di condizioni a cui associare, quindi, un vettore di risultati (di eguale lunghezza)!

Vediamo come funziona

ifelse(c(TRUE, FALSE, TRUE), yes = c(1, 2, 3), no = c(4, 5, 6))
#> [1] 1 5 3

Vediamo immediatamente che otteniamo direttamente quello che vogliamo: fornendo due vettori di possibilità di eguale lunghezza e fornendo un primo vettore logico (solitamente il risultato di un test sugli stessi vettori) vengono usate le componenti corrispondenti ai TRUE del primo vettore, e quelli corrispondenti ai FALSE del secondo vettore.

Un uso più frequente, come accennavo, potrebbe essere una cosa del tipo:

some_integers <- c(-1, -2, 0, 4, 7)
some_integers
#> [1] -1 -2  0  4  7

all_to_positive <- ifelse(some_integers >= 0,
  yes =  some_integers,
  no  = -some_integers
)
all_to_positive
#> [1] 1 2 0 4 7

Si, sembra proprio fare al caso nostro! Proviamo ad applicarlo!

foreveryoung %>% 
  mutate(
      final_age = ifelse(!is.na(reported_age),
          yes = reported_age,
          no  = computed_age
      )
  )

Benissimo! Ma vediamo un’altra funzione che potrebbe essere utile considerare al posto di ifelse() e, ovviamente, vediamo il perché!

if_else

La funzione ifelse() ha un piccolo problema: è conforme alle regole di coercizione di R per le classi di dati.^{Vedi https://adv-r.hadley.nz/vectors-chap.html#testing-and-coercion}

Questo significa che gli output yes e no tra cui la funzione pesca per costruire l’output possono essere di classe differente senza problemi: R provvederà a riportare tutti alla classe più generale.2

Questo permette di fare cose del tipo:

some_integers <- c(-1, -2, 0, 4, 7)
some_characters <- c("one", "two", "three", "four", "five")

strange_things_happens <- ifelse(some_integers >= 0,
  yes = some_integers,
  no  = some_characters
)
strange_things_happens
#> [1] "one" "two" "0"   "4"   "7"

A vote, questo comportamento è proprio quello che vogliamo, ma in generale (e parlo per esperienza personale) dietro a un risultato di questo tipo si nasconde un errore.

Solitamente infatti, come nel caso delle nostre età, vogliamo fare dei confronti su un oggetto per produrre un oggetto dello stesso tipo: un numero, una stringa, un logico, … ma dello stesso tipo!

Come di consueto, è sempre avere a disposizione meno flessibilità possibile fintanto che non è strettamente necessaria. Questo atteggiamento infatti fa risparmiare un sacco di grattacapi e non toglie nulla alle nostre possibilità: se vogliamo un comportamento “insolito” e riceviamo un errore, stiamo un attimo a passare a una funzione più flessibile che ci peretta di adeguare il nostro codice all’eccezione. Se, invece, non otteniamo un errore quando vorremmo un comportamento “usuale”, ma per una svista abbiamo sbagliato qualcosa, ed R ci restituisce comunque qualcosa senza farci sapere che ha eseguito un’operazione non usuale, ebbene, il propagarsi di questo errore (ignoto) potrebbe arrivare a provocare danni molto elevati, analisi molto errate, valutazioni molto sfalsate… senza nemmeno darci un segno che quanto ottenuto potrebbe non essere corretto!

cosa succederebbe infatti se invece delle età calcolate per errore passassimo la colonna delle date di nascita?

birth_foreveryoung <- foreveryoung %>% 
  mutate(
    date_of_birth = today() - years(computed_age)
  )

birth_foreveryoung

birth_foreveryoung %>% 
  mutate(
    final_age = ifelse(!is.na(reported_age),
        yes = reported_age,
        no  = date_of_birth
    )
  )

Come vediamo, nemmeno un errore, nemmeno un warning, ma il risultato (nella colonna final_age) sembra essere piuttosto lontano da quanto atteso, nonostante che resti compatibile formalmente con quanto attesto. Immaginare che il tutto possa essere incluso in uno script di cui questo non è che un risultato intermedio, che magari non verrà nemmeno mai visualizzato, porta a disegnare scenari variabili dall’imbarazzante al problematico.

if_else

Il pacchetto dplyr mette a disposizione una semplicissima funzione che nel modo più ingenuo e semplice possibile, nel caso in cui i due vettori tra cui prendere gli elementi per costruire l’output non siano della stessa classe, restituisce un errore!

birth_foreveryoung %>% 
  mutate(
    final_age = if_else(!is.na(reported_age),
        true  = reported_age,
        false = date_of_birth
    )
  )
#> Error: Problem with `mutate()` input `final_age`.
#> x `false` must have class `numeric`, not class `Date`.
#> ℹ Input `final_age` is `if_else(!is.na(reported_age), true = reported_age, false = date_of_birth)`.

Questo ci permette di identificare subito l’errore e poterlo correggere efficacentemente col cuore in pace :-).3

birth_foreveryoung %>% 
  mutate(
    final_age = if_else(!is.na(reported_age),
        true  = reported_age,
        false = computed_age
    )
  )

Inoltre, if_else() permette anche gestire il caso in cui il test risulti in un valore mancante! Per esempio, nel caso di due NA potremmo voler assegnare “a forza” il valore -1. Usando if_else() possiamo farlo senza problemi (a patto che -1 sia, chiaramente, dello stessa classe dei due vettori passati per costruire l’output!)

birth_foreveryoung %>% 
  mutate(
    final_age = if_else(!is.na(reported_age),
        true  = reported_age,
        false = computed_age,
        missing = -1
    )
  )

Come mai non funziona? Beh, la risposta in effetti è semplice: non abbiamo nessun NA nel risultato della condizione, infatti l’NA che vediamo nel risultato è il valore di computed_age, quando reported_age è NA la seconda volta. Quindi nulla di strano.

Per vedere l’opzione missing all’opera, proviamo per esempio a esplicitare un valore non noto nella classificazione di una variabile.

Supponiamo, per esempio di avere una misura rilevata solo se presente e riportata con valore \(1\) quando “poca” e \(2\) quando “tanta”. Visto che la codifica numerica non ci garba vogliamo assegnare le etichette, riportando “assente” quando il dato manca (ovvero, non è stato rilevato).

measure_foreveryoung <- foreveryoung %>% 
  mutate(measure = sample(c(1, 2, NA), size = nrow(.), replace = TRUE))

measure_foreveryoung

measure_foreveryoung %>% 
  mutate(
    measure_class = if_else(measure > 1,
        true  = "tanta",
        false = "poca",
        missing = "assente"
    )
  )

Prima di concludere esaminiamo un ultimo caso: cosa succede se le condizioni e le opzioni non sono due ma molteplici? Una soluzione potrebbe essere annidare istruzioni if_else() come se non ci fosse un domani… ma la strategia potrebbe non essere delle migliori o tra le più efficaci. Per questo, ci viene in aiuto la funzione case_when().

case_when

La funzione case_when() del pacchetto dplyr implementa una versione vettorializzata e generalizzata della funzione if(). In particolare è utilissima in tutte quelle situazioni in cui abbiamo condizioni complesse e molteplici.

Per esempio:

foreveryoung %>% 
  mutate(
    complete_columns = case_when(
      !is.na(reported_age) & !is.na(computed_age) ~ reported_age,
      !is.na(reported_age) | !is.na(computed_age) ~ 1,
                          TRUE                    ~ 0 
    )
  )

Per quanto non sia un esempio particolarmente brillante, ci permette di mettere in luce qualche caratteristica di case_when(), la prima è che possiamo vettorializzare sia la parte di condizione (a sinistra della ~) sia quella di destra, infatti reported_age è usato, solo per le righe che restituiscono TRUE alla prima condizione. Dopodiché, che le condizioni vengono eseguite in ordine e quindi possiamo considerare nella seconda riga (condizione con |) che la prima abbia restituito FALSE, o meglio… considereremo la seconda condizione solo per le righe che avranno restituito FALSE per la prima condizione!

In breve

Attenzione a usare if - else per definire nuove colonne di una base di dati in quanto la funzione non è vettorializzata e userà solo il primo risultato del vettore logico risultante (probabilmente) nella condizione per l’esecuzione. Per ovviare ed essere sicuri che questo non accada attivare Sys.setenv("_R_CHECK_LENGTH_1_CONDITION_" = "false").

Per condizioni vettorializzate utilizzare la versione vettorializzata ifelse(), ancora meglio se nella sua versione più rigida if_else() messa a disposizione dal pacchetto dplyr.

Per istruzioni condizionate complesse, e vettorializzate, una possibile opzione è usare case_when(), funzione messa a disposizione dallo stesso pacchetto dplyr.

Bene, spero di aver fatto una panoramica sufficiente sulle varie opzioni per poter definire in modo efficacente una nuova colonna di una base di dati a partire da condizioni su colonne già esistenti.

Se avete suggerimenti, o richieste, lasciateli pure nei commenti! Alla prossima!

Saaalvé!

Appendice

Come ultima nota rimasta in sospeso c’era la restituzione del warning nel caso in cui fossero presenti entrambi i valori di eta ma questi fossero discordi.

Per fare questo possiamo definire una funzione che mandi in output il warning desiderato (da personalizzare a piacere) e poi restituisca l’oggetto che ci interessa senza modificarlo.

Usiamo case_when(), ricordandoci che le condizioni sono calcolate in ordine (e quindi un’istruzione vale solo quando (nei casi in cui) tutte le precedenti risultino FALSE).

return_with_warning <- function(x) {
  
  warning(paste(
    "Alcuni soggetti hanno `reported_age` e `computed_age`",
    "entrambi presenti ma diversi.\n",
    "Il valore utilizzato è `reported_age`"
    
  ), call. = FALSE)
  
  x
}


foreveryoung %>% 
  mutate(
    final_age = case_when(
      is.na(reported_age)          ~ computed_age,
      is.na(computed_age)          ~ reported_age,
      reported_age != computed_age ~ return_with_warning(reported_age),
      TRUE                         ~ reported_age
    )
  )
#> Warning: Problem with `mutate()` input `final_age`.
#> ℹ Alcuni soggetti hanno `reported_age` e `computed_age` entrambi presenti ma diversi.
#>  Il valore utilizzato è `reported_age`
#> ℹ Input `final_age` is `case_when(...)`.
#> Warning: Alcuni soggetti hanno `reported_age` e `computed_age` entrambi presenti ma diversi.
#>  Il valore utilizzato è `reported_age`

Bibliografia

Wickham, H. 2019. Advanced R, Second Edition. Chapman & Hall/Crc the R Series. Taylor & Francis. https://adv-r.hadley.nz.


  1. Per rendere automatica l’attivazione della restituzione di un errore in caso in cui la condizione sia multipla possiamo impostare la variabile ambientale direttamente nel file .Renviron, a cui possiamo accedere tramite l’esecuzione di edit_r_environ() del pacchetto usethis. Da notare che questa opzione è tra quelle “sane” da poter attivare come opzione globale e persistente in quanto crea solo una limitazione che non si ripercuote in eventuali altri sistemi: qualunque script “funzioni” nel nostro sistema con tale opzione attiva, funzionerà anche sul sistema di qualunque nostro collega anche se non sappiamo se ha o meno tale opzione attiva!↩︎

  2. character > double > integer > logical.↩︎

  3. Efficacente, com’è noto, significa contemporaneamente efficace (“fare la cosa giusta”) ed efficiente (“farlo nel modo giusto”). NdC.↩︎

Avatar
Corrado Lanera
Research Fellow (RTD-A), data scientist, and trainer

Related

comments powered by Disqus