apply()care una funzione lungo una dimensione di un array, senza perderne la struttura

Ovvero: a cosa potrebbe servire sotto-selezionare… nulla?

Il problema

apply(array(1:27, dim = c(3, 3, 3)), 3, function(x) x)
#>       [,1] [,2] [,3]
#>  [1,]    1   10   19
#>  [2,]    2   11   20
#>  [3,]    3   12   21
#>  [4,]    4   13   22
#>  [5,]    5   14   23
#>  [6,]    6   15   24
#>  [7,]    7   16   25
#>  [8,]    8   17   26
#>  [9,]    9   18   27

Come si può vedere, l’input dato alla funzione apply() è un array a tre dimensioni. Ad apply() chiediamo di prendere semplicemente gli elementi lungo la terza dimensione (quindi 3 matrici) e lasciarle così come stanno :-). Come mai allora non ci viene restituito un array?

Innanzitutto verifichiamo che la nostra ipotesi sul funzionamento di apply() sia corretta. Per farlo, un modo possibile, è andare a chiedere allo stesso apply() di mostrarci la struttura dei vari input che riceve.

arr <- array(1:27, dim = c(3, 3, 3))
apply(arr, 3, function(x) message(str(x)))
#>  int [1:3, 1:3] 1 2 3 4 5 6 7 8 9
#> 
#>  int [1:3, 1:3] 10 11 12 13 14 15 16 17 18
#> 
#>  int [1:3, 1:3] 19 20 21 22 23 24 25 26 27
#> 
#> NULL

Ok, come immaginavamo l’input è formato da tre matrici \(3x3\). Quello che vorremmo è avere in output le stesse tre matrici \(3x3\), allineate in un array, lungo la terza dimensione.

Primo suggerimento

Hadley Wickham suggerisce di usare pryr::aapern(). proviamo a vedere come funziona (?plyr::aapply). La cui prima riga di descrizione sembra (come raramente accade con le sui funzioni…) risolvere precisamente e specificatamente il nostro problema:

“For each slice of an array, apply function, keeping results as an array.”!

E infatti

hw_sol <- plyr::aaply(arr, 3, function(x) x)
str(hw_sol)
#>  int [1:3, 1:3, 1:3] 1 10 19 2 11 20 3 12 21 4 ...
#>  - attr(*, "dimnames")=List of 3
#>   ..$ X1: chr [1:3] "1" "2" "3"
#>   ..$   : chr [1:3] "1" "2" "3"
#>   ..$   : chr [1:3] "1" "2" "3"

identical(hw_sol, arr)
#> [1] FALSE

Il problema sembra in parte risolto, in quanto l’output è coerentemente un array di dimensione 3 come ci aspettavamo. Ma la funzione che noi applichiamo al nostro array è l’identità e ci si potrebbe aspettare quindi di ottenere in output lo stesso identico oggetto, cosa che non accade. L’output inoltre è differente anche non solo riguardo i nomi delle dimensioni ma proprio anche nel contenuto.

all.equal(hw_sol, arr)
#> [1] "Attributes: < Length mismatch: comparison on first 1 components >"
#> [2] "Mean relative difference: 0.547619"

Opzione da Advanced-R

Vediamo se riusciamo a farlo in un altro modo, ottenendo il risultato che desideriamo, identico.

Innanzitutto, ricordiamo che un array non è altro che un vettore atomico a cui è stato assegnato un attributo dim; nel caso in cui dim sia di lunghezza \(2\), verrà chiamato matrix.1.

Inoltre, dalla sezione https://adv-r.hadley.nz/subsetting.html#subassignment di Advanced R (Wickham (2019)) sappiamo che sotto-selezionare un oggetto con “nulla” ci permette di mantenerne la struttura, ovvero gli attributi, e questo è strautile sopratutto quando abbinato all’operatore di assegnamento <-.

Questo accorgimento potrebbe esserci utile quindi anche in questo caso. Proviamo.

y <- array(dim = c(3, 3, 3))
y[] <- apply(arr, 3, function(x) x)
str(y)
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
identical(arr, y)
#> [1] TRUE

PuffRbacco, sembra proprio di si ;-).

C’è sempre un ma… (generalizzazione)

Siamo sicuri che la soluzione proposta valga in generale? Secondo HW, probabilmente no:

tentative <- function(d, f) {
    y <- array(dim = c(3, 3, 3))
    y[] <- f(arr, d, function(x) x)
    
    message(str(y))
    identical(arr, y)
}

purrr::map_lgl(1:3, tentative, apply)
#>  int [1:3, 1:3, 1:3] 1 4 7 10 13 16 19 22 25 2 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 2 3 10 11 12 19 20 21 4 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#> [1] FALSE FALSE  TRUE

Pare infatti che valga solo se applicato lungo la terza dimensione. Ma cosa è successo? (A parte che HW ha ragione, come di consueto…) Proviamo a guardare l’help ?apply e vedere se ci è di aiuto.

If each call to FUN returns a vector of length n, then apply returns an array of dimension c(n, dim(X)[MARGIN]) if n > 1. If n equals 1, apply returns a vector if MARGIN has length 1 and an array of dimension dim(X)[MARGIN] otherwise. If n is 0, the result has length 0 but not necessarily the ‘correct’ dimension.

Dunque, apply(), di suo, ritorna coerentemente con la sua descrizione una matrice \(9x3\) visto che la dimensione su cui si applica apply ha lunghezza \(3\), e ogni esecuzione ha come risultato un vettore (atomico) di lunghezza \(9\).

Proviamo la stessa procedura utilizzando plyr::aaply().

purrr::map_lgl(1:3, tentative, plyr::aaply)
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 4 7 2 5 8 3 6 9 10 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 10 19 2 11 20 3 12 21 4 ...
#> 
#> [1]  TRUE FALSE FALSE

Ri-puffRbacco! Qui la situazione sembra addirittura invertita, arrivando al risultato che immaginiamo nel primo caso ma non negli altri. Inoltre pare che i risultati siano proprio differenti rispetto a quanto ottenuto in precedenza. Il mistero si infittisce. E il problema pare ancora irrisolto.

Finalmente: una soluzione (fino a prova contraria… :-))

Restiamo per il momento sulle funzioni base. Abbiamo capito che il problema risiede nel fatto che viene restituita una matrice \(9x3\), la quale, nel caso volessimo mantenere la struttura originale di array, viene “distribuita” in un array \(3x3\) in ordine ma non secondo l’ordine che vogliamo noi. E come potrebbe saperlo… Quello che vogliamo è che tale insieme di 3 matrici (i vettori \(9x1\) restituiti da apply() riconvertiti in \(3x3\)) vengano “disposti” seguendo la dimensione su cui è applicata apply() stessa.2

In pratica, la dimensione lungo la quale viene applicata apply() diventa la terza dell’array di output (se assegnato a una struttura array): se apply()chiamo lungo la prima dimensione ci vengono restituiti tre vettori (“lungo” la prima dimensione) lunghi \(9\), che sono poi re-arrangiatati in matrici \(3x3\) (“lungo” la seconda e terza dimensione). Il problema, è che vengono restituiti (come letto nell’ help) in un “array of dimension c(n, dim(X)[MARGIN])”, e quindi le 3 matrici (che corrispondono alla seconda e terza dimensione) vengono proposti sulla prima dimensione (divenendo quindi, una volta re-distribuiti in una matrice, la prima e seconda dimensione dell’array risultante). Lo stesso dicasi se apply()chiamo la nostra funzione lungo le altre dimensioni: la dimensione su cui lavoriamo diventa sempre l’ultima (la terza) del nostro risultato, mentre le altre due vengono ridistribuite correttamente ordinate ma all’inizio. Si tratta quindi solo di redistribuire l’array risultante in modo corretto.

Per fare questo ci viene in aiuto la funzione di R base aperm(), che fa proprio quello che ci serve! unitamente alla funzione

tentative_adj <- function(d, f) {
    y <- array(dim = c(3, 3, 3))
    y[] <- f(arr, d, function(x) x)
    
    axes <- integer(3)
    axes[[d]] <- 3
    axes[axes != 3] <- 1:2
    
    adj_y <- aperm(y, axes)
    
    message(str(adj_y))
    identical(arr, adj_y)
}

purrr::map_lgl(1:3, tentative_adj, apply)
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#> [1] TRUE TRUE TRUE

Ok, questo funziona, ma resta il sospetto che funzioni solamente per array “quadrati” (senza contare che funziona solo per array a tre dimensioni). Proviamo con un array “rettangolare”. Aggiustiamo gli input della funzione di prova per includere anche l’input in modo così da poter “catturarne” le dimensioni esatte.

Creiamo inoltre una funzione di controllo generale.

check <- function(d, f, data) {
    y <- f(data, d, function(x) x)
    message(str(y))
    identical(data, y)
}


aapply <- function(x, d, f) {
  # inizializzamo l'output coi metadata dell'input in modo da mantenerne
  # sia la strutturea che le relative informazioni in fase di
  # assegnamento.
  y <- array(NA, dim(x), dimnames(x))
  
  # eseguiamo `apply()` in modo standard e ristrutturiamo le dimensioni
  # considerando che l'output di `apply()` ha (come si legge nell'help)
  # nell'ultima dimensione quella usata come margine, dobbiamo quindi
  # ristrutturare il risultato con le dimensioni corrette (seppur
  # disordinate)
  tmp <- apply(x, d, f)
  dim(tmp) <- c(dim(x)[-d], dim(x)[[d]])
  
  # dobbiamo quindi definire la permutazione che riordinerà le
  # dimensioni correttamente, mettendo quindi quella usata nella
  # sua posizione originale
  size <- length(dim(x))
  d_perm <- integer(size)
  d_perm[[d]] <- size
  d_perm[-d] <- seq_len(size - 1L)
  
  # eseguiamo infine la permutazione sfruttando `aperm()` e assegnamo il
  # risultato al nostro output, di cui manteniamo la struttura e i
  # metadati (cioè gli attributi) grazia a un assegnamento con
  # sottoselezione vuota.
  y[] <- aperm(tmp, d_perm)
  y
}

Controlliamo quindi che tutto funzioni, considerando anche un array che non sia “quadrato”.

purrr::map_lgl(1:3, check, aapply, arr)
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1:3, 1:3, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#> [1] TRUE TRUE TRUE

rect_arr <- array(1:27, c(1, 9, 3))
purrr::map_lgl(1:3, check, aapply, rect_arr)
#>  int [1, 1:9, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1, 1:9, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#>  int [1, 1:9, 1:3] 1 2 3 4 5 6 7 8 9 10 ...
#> 
#> [1] TRUE TRUE TRUE

Sembra tutto a posto. Per il momento, fino all’evidenza di un nuovo problema adottando questo approccio, teniamolo buono.

References

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


  1. https://adv-r.hadley.nz/vectors-chap.html#attributes↩︎

  2. Quello che vogliamo è l’analogo dell’opzione byrow nella costruzione delle matrici, solo che qui vorremmo una sorta di bydim per gli array.↩︎

Avatar
Corrado Lanera
Fellow researcher, data scientist, and trainer
comments powered by Disqus