Capítulo 12 de 21 10 secciones 7 min

Compartir

Qué es SVD, la que sostiene medio machine learning

La descomposición en valores singulares explicada desde PCA, por qué funciona con matrices que no son cuadradas y para qué se usa de verdad.

La SVD parte cualquier matriz en tres: un giro, un estirado y otro giro. A diferencia de los autovectores, funciona con matrices que no son cuadradas. Es la misma información que PCA calculada de otra forma: los valores singulares al cuadrado entre n menos uno son los autovalores de la covarianza.

El problema que arregla 🌸

Los autovalores del capítulo 10 tienen una limitación grande: solo existen para matrices cuadradas. Y tu tabla de datos casi nunca lo es. La mía tiene 1.793 filas y 5 columnas.

Por eso en el capítulo anterior no le sacamos los autovectores a la tabla: se los sacamos a su matriz de covarianza, que sí es cuadrada. Fue un rodeo.

La SVD quita el rodeo. Funciona con cualquier matriz, del tamaño que sea.

Abrimos el archivo con más columnas

Este bloque necesita una tabla más ancha. Con las tres de siempre no hay nada que descomponer, porque están casi sin relación entre ellas. Así que meto dos más: el descuento y el monto final facturado.

import pandas as pd
import numpy as np

COLS = ['unidades', 'monto', 'descuento', 'satisfaccion',
        'monto_final_facturado']

df = pd.read_csv('ventas-miss-yera.csv')
for c in COLS:
    df[c] = pd.to_numeric(df[c], errors='coerce')
M = df[COLS].dropna().to_numpy()

Z = (M - M.mean(axis=0)) / M.std(axis=0)
print('M.shape:', M.shape)
M.shape: (1793, 5)

Esa Z es la tabla escalada: a cada columna le resté su promedio y la dividí por su desviación. Después de eso todas tienen media cero y desviación uno, o sea que ninguna manda por tener números más grandes. En el capítulo 7 viste por qué eso hace falta.

Las tres piezas

U, S, Vt = np.linalg.svd(Z, full_matrices=False)
print('U ', U.shape)
print('S ', S.shape)
print('Vt', Vt.shape)
print('valores singulares:', np.round(S, 4))
U  (1793, 5)
S  (5,)
Vt (5, 5)
valores singulares: [55.9718 43.1322 42.2936 41.5137 21.4392]
Z=UΣV

cualquier matriz, sea del tamaño que sea, se parte en tres: un giro, un estirado por unos números en la diagonal, y otro giro

SímboloQué es
Ztu tabla, del tamaño que sea
Uun giro del lado de las filas
Σla sigma mayúscula: los valores singulares en la diagonal, que son el estirado
Vun giro del lado de las columnas

Girar, estirar, girar. Cualquier matriz del mundo se puede escribir así, y eso es lo que la hace tan útil.

Fíjate en que S viene como un vector de 5 números y no como una matriz. NumPy te da solo la diagonal porque el resto son ceros y sería un desperdicio guardarlos.

Que sí reconstruye

print(np.allclose(U @ np.diag(S) @ Vt, Z))
True

Nada se perdió. Es la misma tabla escrita de otra manera.

Y ahora la conexión con PCA

C = np.cov(Z, rowvar=False)
vals = np.sort(np.linalg.eigh(C)[0])[::-1]
print('S al cuadrado entre n-1:', np.round(S ** 2 / (Z.shape[0] - 1), 4))
print('autovalores de C       :', np.round(vals, 4))
S al cuadrado entre n-1: [1.7482 1.0382 0.9982 0.9617 0.2565]
autovalores de C       : [1.7482 1.0382 0.9982 0.9617 0.2565]

Los mismos números. No parecidos: los mismos.

SVD y PCA son la misma cosa calculada de dos formas. Y las librerías de verdad usan SVD, porque hace las cuentas sobre tus datos directos sin tener que construir la covarianza primero, y esa construcción es justamente donde se pierde precisión.

vals, vecs = np.linalg.eigh(C)
orden = np.argsort(vals)[::-1]
print('primera fila de Vt:', np.round(Vt[0], 4))
print('primer autovector :', np.round(vecs[:, orden][:, 0], 4))
primera fila de Vt: [ 0.     -0.6996  0.0969 -0.0631 -0.7051]
primer autovector : [-0.      0.6996 -0.0969  0.0631  0.7051]

Los mismos números con el signo cambiado. Y eso no es un error: una dirección y su contraria son la misma dirección. Si un autovector apunta al noreste, el mismo con signo cambiado apunta al suroeste, y la recta que definen es idéntica.

Te lo digo porque va a pasar: corres el mismo PCA dos veces, o en dos máquinas, y los signos salen cambiados. No te has vuelto loca 🙂

Para qué se usa: quedarse con lo grande

k = 2
Zk = U[:, :k] @ np.diag(S[:k]) @ Vt[:k]
print('rango de Z :', np.linalg.matrix_rank(Z))
print('rango de Zk:', np.linalg.matrix_rank(Zk))
print('error medio:', round(float(np.abs(Z - Zk).mean()), 4))
print('numeros de la tabla entera:', Z.size)
print('numeros que hacen falta   :', U[:, :k].size + k + Vt[:k].size)
rango de Z : 5
rango de Zk: 2
error medio: 0.4905
numeros de la tabla entera: 8965
numeros que hacen falta   : 3598

Me quedé con los dos valores singulares más grandes y tiré los tres chicos. El resultado es una tabla de rango 2 que se guarda con 3.598 números en vez de 8.965.

Eso se llama aproximación de rango bajo, y hay un teorema que dice que de todas las matrices de rango 2 que existen, esta es la más parecida a la original. No es una heurística: es lo mejor que se puede hacer.

Con mis datos el error es 0.49 y no compensa, por lo mismo de siempre: acá no había redundancia que exprimir. Pero cámbiale los datos y la historia cambia.

Dónde la vas a encontrar

  • Sistemas de recomendación. La tabla de usuarios por productos está casi vacía y es enorme. Quedarse con los k valores singulares grandes es lo que hay detrás de "a quien vio esto también le gustó aquello".
  • Compresión de imágenes. Una foto es una matriz de píxeles. Con el 10% de los valores singulares se ve casi igual.
  • Buscar temas en textos. Palabras por documentos, misma receta.
  • La pseudoinversa. Cuando no hay inversa porque la matriz es singular, como en el capítulo 6, la SVD da lo más parecido que existe.

En todos los casos la idea es la misma: casi toda la información está en unas pocas direcciones, y las demás son ruido 🌟

Lo que te llevas

  • La SVD parte cualquier matriz en giro, estirado y giro.
  • Funciona con matrices que no son cuadradas, y los autovalores no.
  • S al cuadrado entre n menos uno son los autovalores de la covarianza.
  • Las librerías calculan PCA con SVD, y por eso.
  • Los signos pueden salir cambiados y da igual.
  • Quedarse con los k más grandes es la mejor aproximación de rango k que existe.

Comprueba que se entendió

Comprueba que lo tienes

Haces la SVD de tu tabla escalada y los valores singulares son [55.97, 43.13, 42.29, 41.51, 21.44]. ¿Qué relación tienen con los autovalores de la matriz de covarianza?

  • Cada autovalor es el valor singular al cuadrado, dividido entre n menos uno
  • Son los mismos números
  • No tienen relación, son descomposiciones distintas
  • Los valores singulares son la raíz de los autovalores

Ejercicios

1. Los valores singulares vienen ordenados

Y siempre son positivos, que es lo que los hace cómodos.

U, S, Vt = np.linalg.svd(Z, full_matrices=False)
print('S                :', np.round(S, 4))
print('ya venia ordenado:', bool(np.all(np.diff(S) <= 0)))
print('todos positivos  :', bool(np.all(S > 0)))
S                : [55.9718 43.1322 42.2936 41.5137 21.4392]
ya venia ordenado: True
todos positivos  : True

Con eigh había que ordenar a mano. La SVD te los da de mayor a menor y sin signos raros. Es una de las razones por las que se usa más.

2. Cuánto pierdes con cada recorte

Ve tirando valores singulares y mira el daño.

U, S, Vt = np.linalg.svd(Z, full_matrices=False)
for k in range(1, 6):
    Zk = U[:, :k] @ np.diag(S[:k]) @ Vt[:k]
    print(k, '-> error', round(float(np.abs(Z - Zk).mean()), 4),
          '| guarda', U[:, :k].size + k + Vt[:k].size, 'numeros')
1 -> error 0.5975 | guarda 1799 numeros
2 -> error 0.4905 | guarda 3598 numeros
3 -> error 0.3578 | guarda 5397 numeros
4 -> error 0.0966 | guarda 7196 numeros
5 -> error 0.0 | guarda 8995 numeros

Con k igual a 5 guardas 8.995 números para representar una tabla de 8.965. Guardaste más de lo que tenías. Ese es el chiste de la SVD: solo compensa cuando puedes recortar de verdad, y aquí no se puede.

3. El rango, otra vez, ahora con valores singulares

Cómo se calcula el rango por dentro.

Zd = np.column_stack([Z, Z[:, 0] * 3])
_, Sd, _ = np.linalg.svd(Zd, full_matrices=False)
print('valores singulares:', np.round(Sd, 6))
print('cuantos no son cero:', int((Sd > 1e-10).sum()))
print('matrix_rank dice   :', np.linalg.matrix_rank(Zd))
valores singulares: [133.913495  55.971759  42.440309  42.136853  21.439308   0.      ]
cuantos no son cero: 5
matrix_rank dice   : 5

Ese cero del final es la columna que sobra. Y así es exactamente como matrix_rank hace su trabajo: cuenta los valores singulares que no son prácticamente cero. No calcula determinantes ni nada de eso, hace una SVD y cuenta. Por eso es fiable donde el determinante del capítulo 8 no lo era.

Practica este capítulo 📓

Todo el código de arriba en un cuaderno que corre de principio a fin, y los ejercicios con una celda vacía para que los hagas tú. Se abre en Google Colab de un clic y no hay que instalar nada. Donde veas %%revisa, escribe tu respuesta y el cuaderno te dice si te salió.

¿Prefieres trabajar en tu máquina? Bájate el cuaderno de práctica o el de soluciones. Todos están también en github.com/soymissyera/MissYeraEjercicios.

¿Le sirve a alguien que conoces?

Pásale el libro. Es gratis, está entero y no pide registro 🐣

Instagram y TikTok no dejan compartir enlaces desde la web: esos dos copian la URL para que la pegues en tu historia.

¿Tienes alguna duda o consulta?