Ando falto de ideas, no sé si es la pandemia, el teletrabajo ( o la esclavitud en tiempos modernos como me gusta llamarlo) u otra cosa. Total, que me he puesto a bichear un post antiguo de mi amigo Carlos Gil sobre NMF (factorización no negativa de matrices). Cómo siempre el lo cuenta mucho mejor que yo.
Total, que puede que en breve me toque tener algo a lo que quizá se pueda aplicar este tipo de técnicas, a saber, tener clientes y productos.
De hecho voy a usar su mismo ejemplo.
Nota: La librería es mejor si se instala desde BioConductor con BiocManager.
La librería NMF está bastante bien, utiliza paralelización, por debajo está escrita en C, pero tiene el incoveniente de que aún no está implementado un método predict
para nuevos datos
library(MASS)
library(NMF) # BiocManager::install("NMF")
a <- as.matrix(caith)
res <- nmf(a, rank = 2)
a
## fair red medium dark black
## blue 326 38 241 110 3
## light 688 116 584 188 4
## medium 343 84 909 412 26
## dark 98 48 403 681 85
El caso es factorizar esta matriz en dos matrices no negativas. Otras formas de factorizar esta matriz podría ser con análisis de correspondencias simples. Pero vamos a la descomposición por nmf
w <- res@fit@W
h <- res@fit@H
w
## [,1] [,2]
## blue 23.67054 469.0906
## light 22.21376 1084.0914
## medium 301.81639 736.8388
## dark 495.41699 74.8213
h
## fair red medium dark black
## [1,] 0.08528155 0.07765989 0.8883305 1.2505444 1.399567e-01
## [2,] 0.58485834 0.09325087 0.5869453 0.1423536 3.087490e-10
Reconstruimos a
w %*% h
## fair red medium dark black
## blue 276.37021 45.58136 296.3578 96.37782 3.312852
## light 635.93433 102.81758 656.0355 182.10365 3.108965
## medium 456.68567 92.14988 700.5967 482.32648 42.241237
## dark 86.00979 45.45118 484.0100 630.19204 69.336946
Y si comparamos con a
a - (w %*% h)
## fair red medium dark black
## blue 49.62979 -7.581356 -55.35776 13.622177 -0.3128523
## light 52.06567 13.182418 -72.03547 5.896346 0.8910349
## medium -113.68567 -8.149881 208.40327 -70.326485 -16.2412371
## dark 11.99021 2.548819 -81.01004 50.807961 15.6630545
Bueno, la reconstrucción no es perfecta, pero bueno, no está tan mal.
Bien, tal y como cuenta Carlos en su entrada ahora podemos normalizar las filas de W y de H, de forma que tengamos probabilidades. Dónde entonces H sería funciones de probabilidad sobre las filas de la matriz original y W serán ponderaciones. O como dice él, H es un menú de preferencias (imaginemos que tenemos usuarios en filas y productos en columnas), en este caso hemos hecho una reducción de dimensión para quedarnos en 2 preferencias, (sería el equivalente conceptual al número de componentes en un PCA o en un CA), y W serían las ponderaciones que cada usuario da a cada una de las preferencias (sus coordenadas en un correspondencias siguiendo el símil)
Normalicemos
w_hat <- w / rowSums(w)
w_hat
## [,1] [,2]
## blue 0.04803655 0.9519635
## light 0.02007923 0.9799208
## medium 0.29058383 0.7094162
## dark 0.86878941 0.1312106
h_hat <- h / rowSums(h)
h_hat
## fair red medium dark black
## [1,] 0.03492607 0.03180471 0.3638055 0.512146 5.731767e-02
## [2,] 0.41555704 0.06625716 0.4170398 0.101146 2.193742e-10
Así, el primer “menú” está compuesto por los “productos” fair, red, etc, en proporción a como indica la primera fila de h_hat. Y el individuo “blue” prefiere el primer menú en casi un 0.9 de probabilidad vs un alrededor de 0.1 de preferencia del menú 2. En un PCA diríamos que esos son los “loadings”.
Las filas de W a veces se asocian con arquetipos o individuos promedio. Los individuos “blue” tienen esos pesos los dos factores latentes.
En este caso dónde tenemos color de ojos (fila) y color del pelo (columnas), vemos que en las dos distribuciones multinomiales que hay en sendas filas de h_hat
(si, eso es lo que son, dos distribuciones multinomiales), la probabilidad de tener el pelo negro es bastante pequeña (tiene que ver con la tabla de contingencia original, hay muy pocos con el pelo negro).
Pero vemos que hay un arquetipo, (el de os ojos oscuros) para el cual el peso que da al menú de preferencias dónde la probabilidad de tener el pelo negro sea mayor. Es decir, al final es una mixtura de distribuciones multinomiales.
En realidad lo que hace NMF es descubrir la estructura subyacente de los datos en un espacio de menor dimensión que el original. Bueno, pues con W y H normalizadas podemos construir una matriz diagonal D que simplemente nos genere muestras de individuos y en qué columnas caen.
Podemos utilizar como matriz diagonal la suma de las filas de a, y así obtener
(d <- diag(rowSums(a)))
## [,1] [,2] [,3] [,4]
## [1,] 718 0 0 0
## [2,] 0 1580 0 0
## [3,] 0 0 1774 0
## [4,] 0 0 0 1315
Y podemos hacer \(A \approx D \cdot W \cdot H\)
d %*% w_hat %*% h_hat
## fair red medium dark black
## [1,] 285.2419 46.38437 297.5985 86.79829 1.976900
## [2,] 644.5045 103.59331 657.2341 172.84967 1.818414
## [3,] 540.9846 99.78011 712.3866 391.30171 29.547011
## [4,] 111.6026 47.76769 487.5894 602.55729 65.483031
Que se parece bastante a w %*% h
, o podríamos usar otra D, en este caso para obtener qué matriz se obtendría para 10 casos de cada fila.
d <- diag(c(10,10, 10, 10))
d %*% w_hat %*% h_hat
## fair red medium dark black
## [1,] 3.9727284 0.6460218 4.144826 1.208890 0.02753343
## [2,] 4.0791426 0.6556538 4.159709 1.093985 0.01150895
## [3,] 3.0495184 0.5624584 4.015708 2.205759 0.16655587
## [4,] 0.8486889 0.3632524 3.707904 4.582185 0.49796982
y bueno, la verdad es que me pregunto si esto se parece o no a un análisis de correspondencias. Veamos
library(FactoMineR)
res_ca <- CA (a, ncp = 2, graph = FALSE)
factoextra::fviz_ca(res_ca)
Lo primero que hay que darse cuenta es que ambas técnicas no son del todo comparables, el correspondencias busca encontrar dimensiones que expliquen la mayor cantidad de inercia (distancia Chi-cuadrado) y es parecido al PCA en el sentido de que la primera dimensión es la que más explica, etc.. De hecho el CA, diagonaliza 2 matrices derivadas de la tabla de contingencia, una la de los perfiles filas y otra la de los perfiles columna. Y las pinta junta de acuerdo a algún teorema baricéntrico que tuve que demostrar en algún examen allá por los lejanos 90’s.
Pero en realidad si nos fijamos en las coordenadas de las filas en el CA
res_ca$row$coord
## Dim 1 Dim 2
## blue -0.40029985 0.16541100
## light -0.44070764 0.08846303
## medium 0.03361434 -0.24500190
## dark 0.70273880 0.13391383
No es más que ver las filas en un subespacio (el calculado por el CA) del espacio definido por las columnas y de forma análoga pasa con las columnas. Estas coordenadas podrían ser una forma de codificar la variable categórica. Cabe preguntarse si tienen relación con la estructura obtenida por el NMF.
coordenadas_filas <- cbind(res_ca$row$coord, w_hat)
colnames(coordenadas_filas)[3:4] <- paste0("nmf_", 1:2)
coordenadas_filas
## Dim 1 Dim 2 nmf_1 nmf_2
## blue -0.40029985 0.16541100 0.04803655 0.9519635
## light -0.44070764 0.08846303 0.02007923 0.9799208
## medium 0.03361434 -0.24500190 0.29058383 0.7094162
## dark 0.70273880 0.13391383 0.86878941 0.1312106
y
cor(coordenadas_filas)
## Dim 1 Dim 2 nmf_1 nmf_2
## Dim 1 1.00000000 -0.05155554 0.99473251 -0.99473251
## Dim 2 -0.05155554 1.00000000 0.04930306 -0.04930306
## nmf_1 0.99473251 0.04930306 1.00000000 -1.00000000
## nmf_2 -0.99473251 -0.04930306 -1.00000000 1.00000000
Resultado coherente, ¿no? . En este ejemplo de juguete una única dimensión del correspondencias explica el 86,5% de la inercia.
Cosas buenas del nmf.
- Nos da una interpretación más natural de ciertas cosas
- Las dimensiones encontradas al no estar ordenadas por importancia, no sufren del efecto tamaño de otras técnicas que buscan el mejor primer resumen y luego el segundo mejor resumen de los datos. La verdad que no estoy seguro de esto que acabo de escribir.
- Es equivalente a un LDA cuando se utiliza como función objetivo la divergencia de Kullback-Leibler.
Ah, se me olvidaba. ¿qué pasa si tengo una nueva fila/usario?, la librería NMF no permite predecir, y aunque se podría implementar, buscando un poco se encuentra la forma
library(NNLM)
res_nmf2 <- nnmf(a, k = 2, loss = "mkl")
res_nmf2$W
## [,1] [,2]
## blue 1.466091 9.609263e+00
## light 1.375861 2.230877e+01
## medium 18.693706 1.426904e+01
## dark 30.684813 1.129535e-14
res_nmf2$H
## fair red medium dark black
## [1,] 2.803009 1.481227 15.77360 20.537588 2.259650e+00
## [2,] 28.333156 4.517489 28.43426 6.896248 1.638554e-15
Nuevas filas
b <- matrix( data = c(20, 30, 40, 0,20, 10, 10, 30,10, 90), nrow=2, byrow = TRUE)
colnames(b) <- colnames(a)
rownames(b) <- c("tipo_n1", "tipo_n2")
b
## fair red medium dark black
## tipo_n1 20 30 40 0 20
## tipo_n2 10 10 30 10 90
Y tiene un método predict
predict(res_nmf2, newdata = b, which = "W")
## $coefficients
## [,1] [,2]
## tipo_n1 1.056288 0.9494225
## tipo_n2 3.500169 0.0000000
##
## $n.iteration
## [1] 32
##
## $error
## MSE MKL target.error
## 1302.88890 26.76206 26.76206
##
## $options
## $options$method
## [1] "scd"
##
## $options$loss
## [1] "mkl"
##
## $options$max.iter
## [1] 10000
##
## $options$rel.tol
## [1] 1e-12
##
##
## $call
## nnlm(x = t(object$H), y = t(newdata), method = method, loss = loss)
##
## attr(,"class")
## [1] "nnlm"
Lo dicho, una técnica muy interesante y útil.
comments powered by Disqus