Clase 9 Redes convolucionales

Las redes convolucionales son un tipo de arquitectura de red que utiliza ciertos supuestos acerca de los pesos, en contraste a las redes totalmente conexas donde los pesos pueden tomar cualquier valor. Esos supuestos están adaptados para explotar la estructura señales, por ejemplo: sonido o imágenes. En estos dos casos, se trata de entradas que tienen una estructura adicional de proximidad (es decir, hay un concepto de pixeles cercanos y lejanos, igual de tiempos cercanos o lejanos). Las redes convolucionales son la arquitectura más exitosa para tratar con este tipo de problemas con estructura espacial o temporal.

Hay tres consecuencias básicos que producen el uso de convoluciones, que explicamos primero intuitivamente:

  • Conexiones ralas: existen unidades que solo están conectadas a una fracción relativamente chica de las unidades de la capa anterior (en lugar de todas, como en redes totalmente conexas). Por ejemplo: una unidad que busca detectar una forma en una esquina de una imagen no necesita estar conectada a pixeles de otras partes de la imagen.

  • Parámetros compartidos: diferentes unidades tienen pesos compartidos. Por ejemplo: una unidad que quiere detectar el sonido de cierto animal al principio de la grabación puede utilizar los mismos pesos aplicados a otra parte de la grabación. Podemos “mover” el detector (con los mismos pesos) a lo largo de la grabación para ver en dónde detecta el sonido que nos interesa.

  • Equivarianza: Una translación de una entrada (en tiempo o espacio), produce una traslación equivalente en la salida. Por ejemplo, Si una unidad asociada a la esquina superior derecha de una imagen detecta un número, entonces habrá otra unidad que puede detectar el número en la esquina inferior.

Todas estas propiedades inducen estructura en comparación con una red totalmente conexa. Cuando esa estructura es la apropiada, no introduce sesgo adicional y reduce considerablemente la varianza. El éxito de este tipo de redes (como las convolucionales) es encontrar la estructura apropiada para el problema que estamos tratando.

9.1 Filtros convolucionales

Filtros en una dimensión

Comenzamos por considerar filtros para una serie de tiempo.

Un filtro es una transformación de una señal que pretende extraer ciertas características y suprimir otras.

Por ejemplo, consideramos la siguiente serie, y promedios móviles centrados de longitud 5. Los promedios móviles filtran las componentes de frecuencia alta (variaciones en tiempos cortos), y nos dejan con la variación de mayor frecuencia:

library(tidyverse)
library(RcppRoll)
h <- function(x){ifelse(x>0,x,0)}
datos <- data_frame(t = 1:length(BJsales),
                    serie = as.numeric(BJsales) + rnorm(length(BJsales), 0, 10)) %>%
          mutate(promedio_mov = roll_mean(serie, 5, align='center', fill = NA))
ggplot(filter(datos, t < 100), aes(x=t, y=serie)) + geom_line() + 
  geom_line(aes(y=promedio_mov), colour='red', size=1.2)

Podemos escribir este filtro de la siguiente manera: si \(x_t\) representa la serie original, y \(y_t\) la serie filtrada, entonces \[ y_t = \frac{1}{5}(x_{t-2} + x_{t-1} + x_t + x_{t+1}+x_{t+2})\]

Podemos escribir esta operación poniendo \[f =\frac{1}{5} (\ldots, 0,0,1,1,1,1,1,0,0,\ldots)\]

donde \(f_s=1/5\) para \(s=-2,-1,0,1,2\) y cero en otro caso.

Entonces \[y_t = \cdots + x_{t-2}f_{-2} + x_{t-1}f_{-1} + x_{t}f_{0} +x_{t+1}f_{1} +x_{t+2}f_{2}\] Que también se puede escribir como \[\begin{equation} y_t = \sum_{s=-\infty}^{\infty} x_s f_{s-t} \end{equation}\] Nótese que estamos moviendo el filtro \(f\) a lo largo de la serie (tiempo) y aplicándolo cada vez.

Observación: en matemáticas y procesamiento de señales, la convolución es más comunmente \[\begin{equation} y_t = \sum_{s=-\infty}^{\infty} x_s f_{t-s}, \end{equation}\] mientras que la fórmula que nosotros usamos se llama correlación cruzada. En redes neuronales se dice filtro convolucional, aunque estrictamente usa la correlación cruzada (por ejemplo en Tensorflow).

Este es un ejemplo de filtro convolucional del tipo que se usa en redes neuronales: es una vector \(f\) que se aplica a la serie \(x\) como en la ecuación anterior para obtener una serie transformada (filtrada) \(y\). El vector se desplaza a lo largo de la serie par obtener los distintos valores filtrados.

Otro ejemplo son las primeras diferencias: la diferencia del valor actual menos el anterior. Este filtro toma valores altos cuando la serie crece y bajos cuando decrece:

datos <- datos %>% mutate(dif = promedio_mov - lag(promedio_mov)) 
ggplot(datos, aes(x=t, y=dif)) + geom_line() + geom_abline(slope=0, intercept=0)
## Warning: Removed 5 rows containing missing values (geom_path).

¿Cuál es el filtro \(f\) en este caso?

Filtros convolucionales en dos dimensiones

En dos dimensiones, nuestro filtro es una matriz \(f_{i,j}\), que se aplica a una matriz \(x_{i,j}\) (podemos pensar que es una imagen) alrededor de cada posible pixel, para obtener la matriz (imagen) filtrada \(y_{i,j}\) dada por

\[\begin{equation} y_{a,b} = \sum_{s,t=-\infty}^{\infty} x_{s,t} f_{s-a,t-b} \end{equation}\]

A la matriz \(f\) se le llama matriz convolucional, kernel o máscara del filtro

Por ejemplo, consideremos el filtro de 3x3

filtro_difuminar <- matrix(rep(1/9,9), 3,3, byrow=T)
filtro_difuminar
##           [,1]      [,2]      [,3]
## [1,] 0.1111111 0.1111111 0.1111111
## [2,] 0.1111111 0.1111111 0.1111111
## [3,] 0.1111111 0.1111111 0.1111111

El centro de este filtro se sobrepone sobre la cada pixel de la imagen \(x\), se multiplican los valores de la imagen por los del filtro y se suma para obtener el nuevo pixel de la imagen \(y\).

¿Qué efecto tiene este filtro? Este filtro promedia los pixeles de un parche de 3x3 de la imagen, o suaviza la imagen. Es el análogo en 2 dimensiones del filtro de promedios móviles que vimos arriba.

library(imager)
estatua <- load.image('figuras/escultura.jpg') %>% grayscale
plot(estatua, axes=FALSE)

estatua_mat <- as.array(estatua)
dim(estatua_mat)
## [1] 174 240   1   1
estatua_dif <- array(0, c(dim(estatua)[1]-1, dim(estatua)[2]-1, 1, 1))
# Ojo: esta manera es muy lenta: si necesitas convoluciones a mano busca
# paquetes apropiados
for(i in 2:dim(estatua_dif)[1]){
  for(j in 2:dim(estatua_dif)[2]){
    estatua_dif[i, j, 1, 1] <- sum(filtro_difuminar*estatua[(i-1):(i+1), (j-1):(j+1), 1, 1])
  }
}
plot(as.cimg(estatua_dif), axes=FALSE)

Podemos intentar otro filtro, que detecta bordes de arriba hacia abajo (es decir, cambios de intensidad que van de bajos a altos conforme bajamos en la imagen):

filtro_borde <- (matrix(c(-1, -1, -1, 0, 0, 0, 1, 1, 1),  3, 3, byrow=T))
filtro_borde
##      [,1] [,2] [,3]
## [1,]   -1   -1   -1
## [2,]    0    0    0
## [3,]    1    1    1
estatua_filtrada <- array(0, c(dim(estatua_dif)[1]-1, dim(estatua_dif)[2]-1, 1, 1))
for(i in 2:dim(estatua_filtrada)[1]){
  for(j in 2:dim(estatua_filtrada)[2]){
    estatua_filtrada[i,j,1,1] <- sum(t(filtro_borde)*estatua_dif[(i - 1):(i + 1),(j - 1):(j + 1), 1, 1])
  }
}
plot(as.cimg(estatua_filtrada))

Este filtro toma valores altos cuando hay un gradiente de intensidad de arriba hacia abajo.

¿Cómo harías un filtro que detecta curvas? Considera el siguiente ejemplo, en donde construimos un detector de diagonales:

library(keras)
mnist <- dataset_mnist()
digito <- t(mnist$train$x[10,,])
plot(as.cimg(digito))

filtro_diag <- matrix(rep(-1,25), 5, 5)
diag(filtro_diag) <- 2
for(i in 1:4){
  filtro_diag[i, i+1] <- 1
  filtro_diag[i+1, i] <- 1
}
filtro_diag_1 <- filtro_diag[, 5:1]
filtro_diag_1
##      [,1] [,2] [,3] [,4] [,5]
## [1,]   -1   -1   -1    1    2
## [2,]   -1   -1    1    2    1
## [3,]   -1    1    2    1   -1
## [4,]    1    2    1   -1   -1
## [5,]    2    1   -1   -1   -1
digito_f <- array(0, c(dim(digito)[1]-2, dim(digito)[2]-2, 1, 1))
for(i in 3:dim(digito_f)[1]){
  for(j in 3:dim(digito_f)[2]){
    digito_f[i,j,1,1] <- sum((filtro_diag_1)*digito[(i-2):(i+2),(j-2):(j+2)])
  }
}
plot(as.cimg(digito_f))

9.2 Filtros convolucionales para redes neuronales

En redes neuronales, la idea es que que qeremos aprender estos filtros a partir de los datos. La imagen filtrada nos da las entradas de la siguiente capa.

Entonces, supongamos que un filtro de 3x3 está dado por ciertos pesos

\[ f = \left[ {\begin{array}{ccccc} \theta_{1,1} & \theta_{1,2} & \theta_{1,3} \\ \theta_{2,1} & \theta_{2,2} & \theta_{2,3} \\ \theta_{3,1} & \theta_{3,2} & \theta_{3,3} \\ \end{array} } \right] \]

Este filtro lo aplicaremos a cada parche de la imagen de entrada. Empezamos aplicando el filtro sobre la parte superior izquierda de la imagen para calcular la primera unidad de salida \(a_1\)

knitr::include_graphics('./figuras/conv_1.png')

Ahora nos movemos un pixel a la derecha y aplicamos el filtro para obtener la unidad \(a_2\). Podemos poner las unidades en el orden de la imagen para entender mejor las unidades:

knitr::include_graphics('./figuras/conv_2.png')

Al aplicar el filtro a lo largo de toda la imagen, obtenemos 9 unidades de salida:

knitr::include_graphics('./figuras/conv_3.png')

Finalmente, podemos agregar más parámetros para otros filtros:

knitr::include_graphics('./figuras/conv_4.png')

9.3 Capas de agregación (pooling)

En procesamiento de imágenes y redes convolucionales también se utilizan capas de pooling. Estas se encargan de resumir pixeles adyacentes. Una de las más populares es el max pooling, donde en cada parche de la imagen tomamos el máximo.

knitr::include_graphics('./figuras/pooling_1.png')

Hay dos razones para usar estas agregaciones:

  • Obtener invarianza a translaciones adicional (en un parche de la imagen, solo importa si alguno de las unidades agregadas está activa para que el max-pooling esté activo)
  • Reduce el tamaño de la imagen (o de una capa de convolución) y en consecuencia tenemos menos parámetros que tratar en las siguientes capas

9.4 Ejemplo (arquitectura LeNet):

Las capas de pooling generalmente se aplican después de las convoluciones, y hacia al final usamos capas totalmente conexas. Estas últimas capas se encargan de combinar la información de las capas de convolución anteriores, que detectan patrones simples, para obtener unidades que se encargan de detectar patrones más complejos.

knitr::include_graphics('./figuras/lenet_1.png')

## [1] 7291  257
## 
##    0    1    2    3    4    5    6    7    8    9 
## 1194 1005  731  658  652  556  664  645  542  644

Ponemos el rango entre [0,1] (pixeles positivos) y usamos codificación dummy

x_train <- digitos_entrena %>% select(contains('pixel')) %>% as.matrix + 1
x_train <- x_train/2
dim(x_train) <- c(nrow(x_train), 16, 16, 1)
x_test <- digitos_prueba %>% select(contains('pixel')) %>% as.matrix + 1
x_test <- x_test/2
dim(x_test) <- c(nrow(x_test), 16, 16, 1)
y_train <- to_categorical(digitos_entrena$digito, 10)
y_test <- to_categorical(digitos_prueba$digito, 10)

Para fines de interpretación, agregaremos regularización ridge además de dropout (puedes obtener buen desempeño usando solamente dropout), y usaremos una arquitectura un poco más simple:

# reproducibilidad en keras es más difícil, pues hay varias capas de software
# set.seed no funciona. Podemos usar use_session_with_seed https://keras.rstudio.com/articles/faq.html#how-can-i-obtain-reproducible-results-using-keras-during-development
#
use_session_with_seed(72881)
## Set session seed to 72881 (disabled GPU, CPU parallelism)
usar_cache <- TRUE
if(!usar_cache){
    # correr modelo y guardarlo serializado
    set.seed(213)
    lambda <- 0.01
    model_2 <- keras_model_sequential() 
    model_2 %>%
      layer_conv_2d(filters = 8, kernel_size = c(5,5), 
                    activation = 'relu',
                    input_shape = c(16,16,1), 
                    padding ='same',
                    kernel_regularizer = regularizer_l2(lambda),
                    name = 'conv_1') %>%
      layer_max_pooling_2d(pool_size = c(2, 2)) %>% 
      layer_dropout(rate = 0.25) %>% 
      layer_conv_2d(filters = 12, kernel_size = c(3,3), 
                    activation = 'relu',
                    kernel_regularizer = regularizer_l2(lambda),
                    name = 'conv_2') %>% 
      layer_max_pooling_2d(pool_size = c(2, 2)) %>% 
      layer_dropout(rate = 0.25) %>% 
      layer_flatten() %>% 
      layer_dense(units = 50, activation = 'relu',
                  kernel_regularizer = regularizer_l2(lambda)) %>%
      layer_dropout(rate = 0.5) %>%
      layer_dense(units = 10, activation = 'softmax', 
                  kernel_regularizer = regularizer_l2(lambda))
    
    model_2 %>% compile(
      loss = 'categorical_crossentropy',
      optimizer = optimizer_sgd(lr = 0.02,  momentum = 0.5),
      metrics = c('accuracy','categorical_crossentropy')
    )
    history <- model_2 %>% fit(
      x_train, y_train, 
      epochs = 600, batch_size = 256, 
      verbose = 0,
      validation_data = list(x_test, y_test)
    )
    model_serialized <- serialize_model(model_2)
    saveRDS(model_serialized, file= 'cache_obj/red_conv_ser.rds')
    
} else {
  # cargar modelo serializado
  model_serialized <- readRDS(file = 'cache_obj/red_conv_ser.rds')
  model_2 <- unserialize_model(model_serialized)
}
score <- model_2 %>% evaluate(x_test, y_test)
score
## $loss
## [1] 0.5679085
## 
## $acc
## [1] 0.9387145
## 
## $categorical_crossentropy
## [1] 0.2172532
score_entrena <- model_2 %>% evaluate(x_train, y_train)
score_entrena
## $loss
## [1] 0.4763389
## 
## $acc
## [1] 0.9709231
## 
## $categorical_crossentropy
## [1] 0.1256836

Conteo de parámetros

En pimer lugar, contemos el número de parámetros. Podemos ver los matrices donde están guardados los pesos:

wts <- get_weights(model_2)
lapply(wts, dim)
## [[1]]
## [1] 5 5 1 8
## 
## [[2]]
## [1] 8
## 
## [[3]]
## [1]  3  3  8 12
## 
## [[4]]
## [1] 12
## 
## [[5]]
## [1] 108  50
## 
## [[6]]
## [1] 50
## 
## [[7]]
## [1] 50 10
## 
## [[8]]
## [1] 10
  1. La primera capa convolucional está construida con 8 filtros, cada uno de tamaño 5, 5. Adicionalmente, tenemos los 8 sesgos.
  2. La segunda capa convolucional está construida con 12 filtros, cada uno de tamaño 3, 3, para cada una de las 12 imágenes filtradas de la capa anterior. Adicionalmente, tenemos los 12 sesgos.
num_params <- sapply(wts, function(x){ length(as.numeric(x))})
num_params
## [1]  200    8  864   12 5400   50  500   10
  1. En la primera capa convolucional tenemos 200 pesos más 8 sesgos, para un total de 208 parámetros. Esto es porque tenemos
  2. En la segunda capa convolucional tenemos 864 pesos más 12 sesgos, para un total de 876 parámetros.
  3. En la primera capa densa tenemos 5400 pesos más 50 sesgos, para un total de 5450 parámetros.
  4. En la segunda capa densa tenemos 500 pesos más 10 sesgos, para un total de 510 parámetros.

El total de parámetros es 7044. Recalcula para confirmar este conteo de número de parámetros.

Observación: la segunda capa convolucional trata cada una de las 8 “imágenes” filtradas como una nueva imagen. Cada una de ella tendrá 12 filtros asociados, y estos 12 filtros se suman para producir las activaciones de la capa de salida de esta segunda convolución.

Pesos y activaciones

Y ahora graficamos los filtros aprendidos en la primera capa:

library(scales)
capa_1 <- wts[[1]] 
capa_list <- lapply(1:8, function(i){
    data_frame(val = as.numeric(t(capa_1[,,1,i])), pixel = 1:25, unidad=i)
}) %>% 
    bind_rows %>% 
    mutate(y = (pixel-1) %% 5, x = (pixel-1) %/% 5) %>%
     group_by(unidad)
capa_list
## # A tibble: 200 x 5
## # Groups:   unidad [8]
##         val pixel unidad     y     x
##       <dbl> <int>  <int> <dbl> <dbl>
##  1  0.131       1      1     0     0
##  2  0.278       2      1     1     0
##  3  0.246       3      1     2     0
##  4 -0.00461     4      1     3     0
##  5 -0.144       5      1     4     0
##  6 -0.165       6      1     0     1
##  7  0.0589      7      1     1     1
##  8  0.417       8      1     2     1
##  9  0.268       9      1     3     1
## 10 -0.0650     10      1     4     1
## # ... with 190 more rows
graficar_pesos <- function(capa_list, ncol = 4, blank = FALSE){
    g_salida <- ggplot(capa_list, aes(x=x, y=-y)) + 
        geom_raster(aes(fill=val), interpolate=FALSE) + 
        facet_wrap(~unidad, ncol = ncol) + 
        coord_equal() + 
        scale_fill_gradient2(low = "red", mid='gray80',high = "black")   
    if(blank){
        g_salida <- g_salida + 
        theme(strip.background = element_blank(), strip.text = element_blank()) 
    }
    g_salida
}
graficar_pesos(capa_list)

Nota que estos son detectores de formas geométricas simples (diagonales, rectas).

  • 1, 2 y 7 detectan diagonales.
  • 5,6 y 8 detectan bordes horizontales
  • 3 y 4 detectan bordes verticales

Podemos ver las activaciones de la primera capa para algunos dígitos:

red_conv_1 <- keras_model(inputs = model_2$input,
    outputs = get_layer(model_2, 'conv_1')$output)
activaciones_1 <- predict(red_conv_1, x_train[1:50,,,,drop=FALSE])

graficar_activaciones <- function(activaciones, ind){
  probas_ind <- activaciones[ind,,,] # drop primera dimensión
  x_tamaño <- dim(probas_ind)[1]
  y_tamaño <- dim(probas_ind)[2]
  num_filtros <- dim(probas_ind)[3]
  unidades_df <- lapply(1:dim(probas_ind)[3], function(i){
    mat <- t(probas_ind[,,i])
    data_frame(val = as.numeric(mat), pixel = 1:(x_tamaño*y_tamaño), 
               unidad = i) %>%
      mutate(y = (pixel-1) %% x_tamaño, x = (pixel-1) %/% y_tamaño)  %>%
      group_by(unidad) 
  })
  dat <- bind_rows(unidades_df)
  ggplot(dat, aes(x=x, y=-y, fill=val)) + geom_tile() + 
    facet_wrap(~unidad, ncol = 4) +
    scale_fill_gradient2(low = "gray60", mid = "white", high = "blue", midpoint = 0.5) + 
    coord_equal()
}
graficar_activaciones(activaciones_1, 4)

graficar_activaciones(activaciones_1, 5)

graficar_activaciones(activaciones_1, 15)

graficar_activaciones(activaciones_1, 8)

graficar_activaciones(activaciones_1, 33)

La segunda es capa de convolución es más difícil de interpretar. En esta capa, cada unidad de salida es combinación del filtrado de las 8 “imágenes” de la capa anterior.

Los filtros aprendidos en la segunda capa son:

capa_2 <- wts[[3]] 
out <- list()
for(j in 1:8){
  out_temp <- list()
  for(i in 1:12){
  dat_lay <- data_frame(val = as.numeric(capa_2[,,j,i]), 
    pixel = 1:9, unidad=i, origen = j) %>% 
    mutate(y = (pixel-1) %% 3, x = (pixel-1) %/% 3) 
  out_temp[[i]] <- dat_lay
  }
  out[[j]] <- bind_rows(out_temp)
}
capa_out <- bind_rows(out)
library(gridExtra)
## 
## Attaching package: 'gridExtra'
## The following object is masked from 'package:dplyr':
## 
##     combine
g_1 <- graficar_pesos(capa_list, ncol = 1, blank = TRUE)
g_2 <- ggplot(capa_out, aes(x = x, y = -y)) + 
  geom_tile(aes(fill = (val))) + 
  facet_grid(origen ~ unidad) + 
  coord_equal() +
  scale_fill_gradient2(low = "red", mid='gray90',high = "black") +
  theme(strip.background = element_blank(), strip.text = element_blank())
g_1

g_2

Nótese que en esta gráfica los filtros claramente tienen una estructura espacial (en general no se ven ruidosos). En esta gráfica:

  • los renglones son las unidades de la capa origen (después de la primera convolución),
  • las columnas son las unidades de salida de la segunda capa de convolución.

Por ejemplo, consideremos los filtros de la primera y la segunda unidad de salida:

ggplot(capa_out %>% filter(unidad %in% c(1, 2, 3)), 
    aes(x = x, y = -y)) + geom_tile(aes(fill = (val))) + 
  facet_grid(origen ~ unidad) + coord_equal()+
  scale_fill_gradient2(low = "red", mid='gray90',high = "black") + 
  theme(strip.background = element_blank(), strip.text = element_blank())    

Veamos las activaciones de las siguiente capa convolucional:

red_conv_2 <- keras_model(inputs = model_2$input,
    outputs = get_layer(model_2, 'conv_2')$output)
activaciones_2 <- predict(red_conv_2, x_train[1:50,,,,drop=FALSE])
dim(activaciones_2)
## [1] 50  6  6 12
require(gridExtra)
grid.arrange(graficar_activaciones(activaciones_1, 4), 
             graficar_activaciones(activaciones_2, 4), ncol=2)

grid.arrange(graficar_activaciones(activaciones_1, 5), 
             graficar_activaciones(activaciones_2, 5), ncol=2)

grid.arrange(graficar_activaciones(activaciones_1, 6), 
             graficar_activaciones(activaciones_2, 6), ncol=2)

grid.arrange(graficar_activaciones(activaciones_1, 15), 
             graficar_activaciones(activaciones_2, 15), ncol=2)

grid.arrange(graficar_activaciones(activaciones_1, 30), 
             graficar_activaciones(activaciones_2, 30), ncol=2)

grid.arrange(graficar_activaciones(activaciones_1, 50), 
             graficar_activaciones(activaciones_2, 50), ncol=2)