Antes de empezar
Varios ejercicios usan lo que se definió en la teoría: sombra, pca, vecino_igual, los dígitos (Xd, yd) y el mapa t-SNE Z_tsne. Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código (recuerda instalar umap-learn primero, si tu Colab no lo trae) y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.
Ejercicios
1. La sombra a mano
Tres puntos ya centrados: (2, 1), (−1, 0) y (−1, −1).
a) Proyéctalos sobre el eje horizontal, la recta de 0°. ¿Cuánto vale la varianza de la sombra? ¿Y el error de reconstrucción? Repite para la recta de 90°.
b) Calcula a mano la matriz de covarianza (recuerda: es Xc.T @ Xc / n).
c) Sin calcular nada más: ¿cuánto suman los dos valores propios? Compruébalo con np.linalg.eigh y busca el ángulo de la primera componente.
Respuesta
a) Sobre la recta de 0°, la sombra de cada punto es su coordenada x: 2, −1 y −1. La varianza de la sombra es (4 + 1 + 1) / 3 = 2. Lo que se pierde es la coordenada y (1, 0 y −1), así que el error es (1 + 0 + 1) / 3 = 2/3 ≈ 0,67. Sobre la recta de 90° se intercambian: varianza 2/3 y error 2. En los dos casos la suma es 8/3 ≈ 2,67, la varianza total.
b) Los elementos de la diagonal son las varianzas de cada columna, 2 y 2/3. Fuera de la diagonal va el promedio de los productos x·y: (2·1 + (−1)·0 + (−1)·(−1)) / 3 = 3/3 = 1.
c) Suman lo mismo que la diagonal, 8/3: la varianza total no cambia al girar los ejes, solo se reparte distinto.
P = np.array([[2, 1], [-1, 0], [-1, -1]])
print("0°: varianza y error", np.round(sombra(P, 0), 2))
print("90°: varianza y error", np.round(sombra(P, 90), 2))
C3 = P.T @ P / 3
valores3, vectores3 = np.linalg.eigh(C3)
print("Valores propios:", valores3.round(3),
" suma:", valores3.sum().round(3))
v = vectores3[:, 1]
print(f"Ángulo de la primera componente: "
f"{np.rad2deg(np.arctan2(v[1], v[0])) % 180:.1f}°")
0°: varianza y error [2. 0.67]
90°: varianza y error [0.67 2. ]
Valores propios: [0.131 2.535] suma: 2.667
Ángulo de la primera componente: 28.2°
La primera componente se lleva casi toda la varianza: es una recta inclinada unos 28°, entre el eje horizontal (que ya era bueno) y la diagonal.
2. PCA con SVD
Hay otra forma de calcular las componentes sin armar la matriz de covarianza: la descomposición en valores singulares (SVD), que NumPy trae como np.linalg.svd. Escribe Xc = U · S · Vt; las filas de Vt son las componentes principales y cada valor singular al cuadrado, dividido entre n, es la varianza de su componente. Comprueba con los dígitos que da lo mismo que tu función pca.
Respuesta
Xd_c = Xd - Xd.mean(axis=0)
U, S, Vt = np.linalg.svd(Xd_c, full_matrices=False)
varianzas_svd = S ** 2 / len(Xd)
print("Proporción con SVD: ",
(varianzas_svd / varianzas_svd.sum())[:5].round(3))
Z2, comps2, proporcion2 = pca(Xd, 2)
print("Proporción con eigh: ", proporcion2[:5].round(3))
print("Mismas componentes:", np.allclose(np.abs(Vt[:2]), np.abs(comps2)))
Proporción con SVD: [0.149 0.136 0.118 0.084 0.058]
Proporción con eigh: [0.149 0.136 0.118 0.084 0.058]
Mismas componentes: True
Dan lo mismo, salvo el signo de cada componente, que es arbitrario. scikit-learn usa SVD por dentro porque es más estable numéricamente y no necesita formar la matriz de covarianza, que con muchas columnas es enorme.
3. ¿Escalar antes de PCA?
El dataset load_wine de scikit-learn tiene 13 mediciones químicas de 178 vinos. Haz PCA con las columnas tal como vienen y mira qué proporción de la varianza se lleva la primera componente y qué columna pesa más en ella. Después estandariza las columnas (tema 10) y repite. ¿Qué pasó?
Respuesta
from sklearn.datasets import load_wine
from sklearn.preprocessing import StandardScaler
vinos = load_wine()
nombres = np.array(vinos.feature_names)
desviaciones = vinos.data.std(axis=0)
for j in [0, 4, 12]:
print(f"Desviación de {nombres[j]}: {desviaciones[j]:.1f}")
for titulo, datos in [("Sin escalar", vinos.data),
("Estandarizado",
StandardScaler().fit_transform(vinos.data))]:
p = PCA().fit(datos)
principal = nombres[np.argmax(np.abs(p.components_[0]))]
print(f"{titulo}: la 1.ª componente explica "
f"{p.explained_variance_ratio_[0]:.1%}; pesa más {principal}")
Desviación de alcohol: 0.8
Desviación de magnesium: 14.2
Desviación de proline: 314.0
Sin escalar: la 1.ª componente explica 99.8%; pesa más proline
Estandarizado: la 1.ª componente explica 36.2%; pesa más flavanoids
Sin escalar, la columna proline tiene una desviación de unos 300, mientras la mayoría de las demás andan entre 0,1 y 15. La varianza es desviación al cuadrado, así que proline se lleva casi toda la varianza y la «primera componente» es básicamente esa columna sola: PCA no descubrió nada, solo midió unidades. Estandarizado, cada columna aporta lo mismo, la primera componente explica alrededor de un tercio y mezcla varias mediciones químicas. Regla: si las columnas están en unidades distintas, estandariza antes de PCA (en los dígitos no hizo falta porque todos los píxeles van de 0 a 16).
4. PCA antes de k-NN
Usa PCA como preprocesamiento de un clasificador k-NN (tema 8) para los dígitos. Separa un 25 % para validar (train_test_split con random_state=0 y stratify=yd), arma un make_pipeline(PCA(k), KNeighborsClassifier(5)) y mide la exactitud para k = 1, 2, 3, 5, 10, 20, 40 y 64. ¿Con cuántas componentes ya no vale la pena seguir subiendo?
Respuesta
from sklearn.model_selection import train_test_split
from sklearn.pipeline import make_pipeline
from sklearn.neighbors import KNeighborsClassifier
X_entr, X_val, y_entr, y_val = train_test_split(
Xd, yd, test_size=0.25, random_state=0, stratify=yd)
for k in [1, 2, 3, 5, 10, 20, 40, 64]:
tubo = make_pipeline(PCA(n_components=k, random_state=0),
KNeighborsClassifier(n_neighbors=5))
tubo.fit(X_entr, y_entr)
print(f"{k:2d} componentes: exactitud {tubo.score(X_val, y_val):.3f}")
1 componentes: exactitud 0.356
2 componentes: exactitud 0.618
3 componentes: exactitud 0.749
5 componentes: exactitud 0.913
10 componentes: exactitud 0.967
20 componentes: exactitud 0.976
40 componentes: exactitud 0.980
64 componentes: exactitud 0.980
Con 1 o 2 componentes el k-NN se confunde mucho (es lo que viste en el panel de PCA: los dígitos encimados). Con 10 ya pasa del 96 % y desde unas 20 se queda prácticamente igual que con los 64 píxeles. Es decir: con un tercio de los números se clasifica igual de bien, y el k-NN, que compara distancias con todos los ejemplos, trabaja tres veces menos. El PCA va dentro del pipeline para que se ajuste solo con los datos de entrenamiento (tema 10): ajustarlo con todos sería una pequeña fuga de datos.
5. Leer un mapa de t-SNE
Mira el panel «t-SNE» de la figura de los dígitos en la teoría. Tres de estas afirmaciones son conclusiones que no se pueden sacar del mapa y una sí. ¿Cuál es cuál y por qué?
- El grupo del 0 queda más cerca del 5 que de cualquier otro dígito, así que los ceros se parecen más a los cincos que a los demás dígitos.
- Los puntos de un mismo grupito del mapa suelen ser vecinos también en las 64 dimensiones.
- El eje horizontal mide alguna propiedad de los dígitos, como el grosor del trazo.
- Con perplejidad 5 los grupos se ven más sueltos y a otras distancias, así que uno de los dos mapas está mal.
Respuesta
La 2 es la que sí vale: t-SNE se esfuerza justamente en que los vecinos cercanos sigan cerca. Lo comprobaste en la teoría: el vecino más cercano en el mapa es del mismo dígito en el 98,8 % de los casos.
Las otras tres son falsas:
- Las distancias entre grupos no significan mucho. t-SNE cuida los vecindarios, no la distancia entre grupos lejanos. Mídelo en las 64 dimensiones, con el dígito promedio de cada grupo:
centros = np.array([Xd[yd == d].mean(axis=0) for d in range(10)])
dist_64 = np.linalg.norm(centros - centros[0], axis=1)
centros_mapa = np.array([np.median(Z_tsne[yd == d], axis=0)
for d in range(10)])
dist_mapa = np.linalg.norm(centros_mapa - centros_mapa[0], axis=1)
print("Del 0 al más cercano, en 64 dimensiones:", np.argsort(dist_64)[1:4])
print("Del 0 al más cercano, en el mapa t-SNE: ",
np.argsort(dist_mapa)[1:4])
Del 0 al más cercano, en 64 dimensiones: [9 8 5]
Del 0 al más cercano, en el mapa t-SNE: [5 9 6]
En los píxeles, el dígito más parecido al 0 promedio es el 9, no el 5.
-
Los ejes no tienen nombre. Si giras o reflejas el mapa entero, es igual de bueno: t-SNE solo mira qué puntos quedan cerca de cuáles. En PCA, en cambio, cada eje sí es una dirección concreta de los datos originales.
-
Los dos mapas son vistas válidas. La perplejidad dice cuántos vecinos mira cada punto: con 5, cada punto solo se preocupa por sus vecinos más inmediatos y los grupos se desarman en hilos y grumitos. Por eso se prueban varios valores y solo se confía en lo que se repite (como que el 1 se parte en dos grupos y que unos cuantos unos quedan cerca del 2 o sueltos).
Mini-proyecto: un compresor de imágenes con PCA
Vas a comprimir una foto en escala de grises con PCA y dibujar la curva calidad–tamaño.
- Carga la foto
china.jpgque trae scikit-learn (from sklearn.datasets import load_sample_image), pásala a gris con un promedio de los tres canales y divide entre 255. Te queda una matriz de 427 × 640. - Trata cada fila de la imagen como un ejemplo de 640 números. Ajusta
PCA(n_components=k)con las 427 filas y reconstruye la imagen coninverse_transform(transform(...)). - Cuenta cuántos números hay que guardar: k coordenadas por fila, k componentes de 640 números y la fila promedio. Divídelo entre los 427 · 640 números de la imagen original: es el tamaño relativo.
- Mide la calidad con el error cuadrático medio entre la imagen original y la reconstruida.
- Repite para k = 5, 10, 20, 50 y 100. Dibuja el error frente al tamaño y muestra las imágenes reconstruidas con el mapa de color
grisde la teoría. - Escribe tres conclusiones: ¿desde qué k ya no notas la diferencia a simple vista? ¿Qué partes de la foto se arreglan primero y cuáles al final? ¿Le ganarías a un JPG?
Resultados de referencia, para comprobar los tuyos
Este código hace los pasos 1 a 4 sin gráficos; tus números deberían coincidir.
from sklearn.datasets import load_sample_image
foto = load_sample_image("china.jpg").mean(axis=2) / 255
alto, ancho = foto.shape
print("Forma:", foto.shape)
for k in [5, 10, 20, 50, 100]:
p = PCA(n_components=k, random_state=0).fit(foto)
reconstruida = p.inverse_transform(p.transform(foto))
guardados = k * alto + k * ancho + ancho
error = np.mean((foto - reconstruida) ** 2)
print(f"k = {k:3d}: tamaño {guardados / foto.size:5.1%}"
f" error {error:.4f}")
Forma: (427, 640)
k = 5: tamaño 2.2% error 0.0140
k = 10: tamaño 4.1% error 0.0107
k = 20: tamaño 8.0% error 0.0078
k = 50: tamaño 19.8% error 0.0045
k = 100: tamaño 39.3% error 0.0023
- La curva baja rápido al principio y después se aplana: las primeras componentes se llevan lo grueso (las zonas grandes de luz y sombra) y las siguientes van agregando detalles cada vez más finos, como bordes y texturas.
- Con k = 50, guardando una quinta parte de los números, la foto ya se reconoce sin problema; con 100, la diferencia cuesta verla, pero el tamaño ya va por el 40 %.
- Un JPG suele comprimir más con la misma calidad, porque trabaja con bloques pequeños y aprovecha cómo ve el ojo. PCA no fue diseñado para fotos; lo interesante es que aprende las «piezas» de la imagen a partir de los propios datos.
Errores comunes
- No centrar los datos. PCA busca direcciones que pasan por el centro de la nube.
PCAde scikit-learn centra solo; si lo programas tú, resta la media antes. - No escalar columnas con unidades distintas. La columna con números más grandes se lleva la primera componente aunque no sea la más importante (ejercicio 3).
- Ajustar PCA con todos los datos antes de dividir. Igual que el escalador, el PCA se ajusta solo con el entrenamiento; ponlo dentro del pipeline.
- Preocuparse por el signo. Una componente y su opuesta son la misma recta. Que tu resultado tenga signos distintos que el de scikit-learn no es un error.
- Leer distancias y tamaños en un mapa t-SNE o UMAP. Lo que se conserva son los vecindarios. Que un grupo sea más grande o esté más lejos de otro no dice casi nada de los datos originales.
- Sacar conclusiones de una sola corrida. t-SNE y UMAP dependen de la semilla y de sus parámetros (perplejidad,
n_neighbors,min_dist). Prueba varios valores y confía solo en lo que se repite. - Usar t-SNE como preprocesamiento. t-SNE no sabe proyectar un dato nuevo: sirve para mirar, no para alimentar un modelo. Para eso está PCA (y UMAP, con cuidado, porque sí tiene
transform). - Confundir varianza con importancia para predecir. La dirección de más varianza no siempre es la que mejor separa las clases; PCA no mira las etiquetas.
¿Lo lograste?
Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.
- Explicar PCA como la búsqueda de las direcciones de máxima varianza, y por qué eso es lo mismo que el mínimo error de reconstrucción (actividad y ejercicio 1).
- Implementar PCA con NumPy (covarianza y
eigh, o SVD) y compararlo con scikit-learn (ejercicios 1 y 2). - Elegir el número de componentes con la varianza explicada y con la exactitud de un modelo (ejercicio 4 y mini-proyecto).
- Usar t-SNE y UMAP para visualizar y explicar qué no se debe concluir de ellos (ejercicio 5).