cote.log
001

Clasificando galaxias: lo que aprendí saliéndome de MNIST

Entrené una CNN sobre 17.736 galaxias reales y el hallazgo no fue el accuracy, sino descubrir de dónde salían mis etiquetas.

Casi todos los cursos de deep learning parten con MNIST. Sirve para entender la base —yo mismo reforcé harto ahí— pero no es un problema que te obligue a razonar: los datos vienen limpios, balanceados, con las etiquetas perfectas, y tu única pega es elegir una arquitectura.

Quería lo contrario. Un dataset que me obligara a entender el problema real, a meterme en un área ajena a la IA, y a toparme con conceptos que no había visto. Terminé clasificando galaxias, y el hallazgo más interesante no fue ninguna métrica.

Fui siguiendo en paralelo el capítulo de clasificación de imágenes de Deep Learning with Python, de François Chollet.

El dataset

Galaxy10 DECaLS: 17.736 imágenes de galaxias reales, 256×256 píxeles, en color, en diez clases morfológicas. Todo en un solo archivo .h5 de 2,54 GB.

Grilla con un ejemplo de cada una de las diez clases de Galaxy10
Fig. 1Un ejemplo de cada clase, a resolución original (256×256).

Lo primero que salta es el desbalance: casi 8 veces entre la clase más grande y la más chica.

Clase Imágenes
Round Smooth 2.645
Unbarred Loose Spiral 2.628
Barred Spiral 2.043
In-between Round Smooth 2.027
Edge-on con bulbo 1.873
Merging 1.853
Unbarred Tight Spiral 1.829
Edge-on sin bulbo 1.423
Disturbed 1.081
Cigar Shaped Smooth 334

Las etiquetas no son lo que parecen

Acá está la parte que no esperaba.

Las etiquetas vienen de Galaxy Zoo, un proyecto de ciencia ciudadana donde voluntarios clasifican galaxias por internet. Pero a ningún voluntario le mostraron nunca estas diez clases. A la gente le hacen preguntas encadenadas sobre una imagen, y después alguien agregó esos votos y armó las categorías aplicando umbrales.

Eso significa dos cosas que me acompañaron todo el proyecto.

Primero, la “verdad” de cada galaxia no es una etiqueta: es una distribución de votos, y el umbral la aplasta a un entero. Cuando mi modelo se equivoca, puede que se haya equivocado de verdad, o puede que la galaxia fuera ambigua y alguien decidió por ella.

Segundo, las galaxias donde los voluntarios no se pusieron de acuerdo fueron filtradas: de unas 441 mil disponibles quedaron ~18 mil. Este dataset es un subconjunto de alto acuerdo, no una muestra representativa del cielo. Cualquier accuracy que yo reporte es optimista respecto a imágenes sin filtrar.

Así que me fui a clasificar galaxias yo mismo

Antes de entrenar nada me metí a Galaxy Zoo a clasificar a mano. Quería entender de dónde salía mi variable objetivo: no leerlo, vivirlo.

Captura del formulario de clasificación de Galaxy Zoo
Fig. 2El formulario de Galaxy Zoo: preguntas encadenadas, no un menú de diez opciones.
Segunda captura del formulario de clasificación de Galaxy Zoo
Fig. 3Otra de las preguntas del árbol: la respuesta anterior decide cuál te toca.

El flujo es así: te muestran una imagen y te preguntan si la galaxia es lisa o tiene rasgos. Según lo que respondes, te hacen la siguiente pregunta. Si dijiste “lisa”, nunca te van a preguntar por la barra. Si dijiste “tiene disco”, te preguntan si se ve de canto.

Dudé harto. Hubo galaxias donde estuve diez segundos entre dos opciones.

Preparando los datos

El archivo .h5

El archivo pesa 2,54 GB comprimido; descomprimido en uint8 son ~3,5 GB, y al pasarlo a float32 para entrenar se va a ~14 GB. En Colab, con ~12 GB de RAM, no cabe.

El primer intento fue una red densa sobre las imágenes crudas. Aplanar 256×256×3 hacia una Dense(128) da 25.167.242 parámetros solo en esa capa, y el kernel se cayó.

def build_denselyconn(input_shape=(256, 256, 3), n_classes=10):
    inputs = keras.Input(shape=input_shape)
    x = layers.Rescaling(1./255)(inputs)
    x = layers.Flatten()(x)
    x = layers.Dense(128)(x)
    outputs = layers.Dense(n_classes, activation="softmax")(x)
    return keras.Model(inputs, outputs)

La solución fue dejar el array en uint8 y mover todo el preprocesamiento adentro del modelo, como capas: Resizing para bajar resolución y Rescaling(1./255) para pasar a float. Así la conversión ocurre por batch en la GPU, lo que vive en memoria ocupa cuatro veces menos, y el .keras guardado acepta la imagen de 256×256 tal cual viene.

Grilla de clases recortadas y reducidas a 64 píxeles
Fig. 4Las mismas clases con recorte central a 128 y resize a 64. La morfología sigue siendo reconocible a ojo.

El split, y por qué macro-F1

Galaxy10 no trae partición, así que la armé yo: estratificada, seed 42, guardando los índices. Train 12.060 / val 2.128 / test 3.548. El test no se toca hasta el final.

Para la métrica me comprometí antes de ver resultados. El baseline trivial lo explica mejor que cualquier argumento:

Predecir siempre “Round Smooth” Accuracy Macro-F1
0.149 0.026

Es el mismo modelo estúpido medido de dos formas. En accuracy se ve como “casi 15%, no tan mal”. En macro-F1 se ve como lo que es: inútil, porque nueve de las diez clases tienen F1 cero. Con 8× de desbalance, accuracy te deja mentirte solo.

Lo que predije antes de entrenar

Me impuse escribir qué esperaba que pasara antes de ver un solo resultado. Si escribes las conclusiones después de ver la matriz de confusión, te acomodas a lo que viste y juras que eso era lo que pensabas.

Mirando las imágenes, las diez clases se agrupan en tres familias donde la silueta es la misma y solo cambia una cosa:

Familia Clases Qué las separa
Smooth Round · In-between · Cigar la elongación: un continuo cortado en tres
Espirales Barred · Unbarred Tight · Unbarred Loose una barra central, y qué tan enrollados los brazos
Edge-on sin bulbo · con bulbo un abultamiento chico en el centro

Disturbed y Merging no caen en ninguna familia.

Predicción 1. Cuando el modelo se equivoque, se va a equivocar dentro de una familia. No una espiral confundida con una lisa. Se falsifica si las celdas más grandes fuera de la diagonal caen fuera de las familias — que cubren solo 14 de 90 celdas.

Predicción 2. En el par Edge-on con bulbo / sin bulbo hay dos mecanismos que predicen direcciones opuestas: el prior dice que los errores van hacia “con bulbo” (la clase más frecuente), y la evidencia perdible dice lo contrario (un bulbo tenue se te pasa, pero nunca se alucina). La dirección que salga me dice cuál manda.

Los modelos

Partí de lo más simple —una red densa— y fui subiendo: CNN de una convolución, CNN con varios bloques. No a lo bruto: mirando en cada caso qué le pasaba a las curvas.

Lo primero que aprendí es que “hay overfitting” es una frase demasiado vaga. Los tres modelos tenían problemas distintos: la red densa subentrenaba (train y val pegadas y las dos bajas), la CNN de una capa sobreajustaba de libro, y la CNN profunda era la sana. Si les hubiera aplicado la misma receta a los tres, se la habría puesto justo al que necesitaba lo contrario.

Tabla 1

macro-F1ma·cro·efe·u·no(s. m.)Promedio del F1 de cada clase, sin ponderar por tamaño: todas pesan igual.

ModeloParamsVal accMacro-F1
Baseline trivial00.1490.026
Densa1.574.2820.5410.475
CNN 1 conv7.875.7220.5660.507
CNN profunda261.4500.6840.628
CNN profunda + augment261.45030× menos parámetros0.7890.751
  1. Todos sobre validación, entrada 64×64 (recorte 128 → resize 64).
  2. Las primeras cuatro filas sin augmentation; la última con espejos y rotaciones.

Hallazgo 1: menos parámetros, mejor resultado

La CNN de una convolución tiene 7.875.722 parámetros y saca 0.507 de macro-F1. La CNN profunda tiene 261.450 y saca 0.628. Treinta veces menos, doce puntos más.

La explicación está en dónde gasta los pesos cada una. La chica hace Flatten y después una Dense(128): ese aplanamiento conecta cada píxel del mapa de features con cada neurona, así que la capa aprende la posición exacta de cada píxel. Ahí se le va casi todo, y es capacidad gastada en memorizar.

La profunda mantiene la información pasando por convoluciones —que comparten pesos y no dependen de la posición absoluta— y cierra con un GlobalAveragePooling que la obliga a resumir cada mapa en un número.

Hallazgo 2: el augmentation depende del dominio

Buscando documentación me di cuenta de que había entendido mal para qué sirve.1 La pregunta no es “¿aplico augmentation o no?”, es qué transformaciones puedo aplicar sin cambiar la identidad del dato.

Acá tengo una ventaja concreta: no hay orientación privilegiada en el cielo. Una galaxia rotada 90 grados sigue siendo exactamente la misma clase. La transformación preserva la etiqueta de forma perfecta, no aproximada.

En MNIST no es que el augmentation sea inútil — es que hay que elegir con cuidado. Rotar un 6 lo suficiente lo convierte en un 9, y ahí no estás generando un ejemplo nuevo: estás enseñándole al modelo una etiqueta falsa. La misma operación, en un dominio distinto, pasa de ser correcta a ser dañina.

Lo que descarté, que es la parte que me obligó a pensar:

  • Brillo y contraste agresivos: la barra y el bulbo se detectan por contraste.
  • Crops agresivos: pueden cortar brazos espirales, o la compañera de una fusión.
  • Shear o estiramiento no uniforme: el peor. Tres de mis clases se definen por su elongación. Un estiramiento no degrada la señal, fabrica etiquetas falsas.

Algo que me confundió al principio: el augmentation no agrega datos. El array sigue teniendo las mismas imágenes y cada época cuesta lo mismo. Lo que cambia es que la imagen #47 le llega distinta cada vez. El efecto no es “más datos”, es que memorizar deja de funcionar.

Resultado: +12 puntos de macro-F1 (0.628 → 0.751).

Curvas de loss y accuracy, train vs validación
Fig. 5Curvas de entrenamiento y validación del modelo a resolución completa (128×128).

Dónde falla

La matriz de abajo es la del modelo a 128×128 (el que salió peor en macro-F1). La uso porque es la corrida más reciente y porque los patrones de error son los mismos que en el modelo bueno, solo que un poco más marcados.

Matriz de confusión de diez clases, normalizada por fila
Fig. 6Matriz de confusión normalizada por fila: la diagonal es el recall de cada clase.

Ordené las celdas fuera de la diagonal de mayor a menor, quedándome con las que tuvieran al menos 10 casos para quitar ruido.

Real → Predicho n Tasa ¿Misma familia?
Unbarred Tight → Unbarred Loose 109 0.495 Sí
Disturbed → Unbarred Loose 45 0.346 No
Barred Spiral → Unbarred Loose 41 0.167 Sí
Disturbed → Round Smooth 11 0.085 No
Disturbed → In-between 10 0.077 No
Disturbed → Barred Spiral 10 0.077 No
Unbarred Loose → Barred Spiral 24 0.076 Sí

La predicción 1, a medias

De las cinco celdas más grandes, dos son de la misma familia y tres caen fuera. Y las tres de afuera son la misma fila: Disturbed.

Las familias que predije se comportan como dije: la celda más grande de toda la matriz es Unbarred Tight → Unbarred Loose, y las que siguen dentro de la familia espiral son todas barra / sin barra. Ninguna espiral se va a una smooth, ninguna smooth a una edge-on.

Lo que rompió la predicción no es que estuviera equivocada, es que estaba incompleta: no tenía ninguna historia para Disturbed.

La predicción 2 quedó sin resolver

Las dos clases Edge-on terminaron con recall 0.89 y 0.90, de las mejores de la matriz. Sin bulbo → con bulbo da 0.04 y al revés 0.02: con tan pocos errores ya no puedo decir hacia dónde fluyen.

Igual sirvió escribirla antes. Si no la hubiera dejado por escrito habría mirado la matriz y dicho “claro, obvio”, con cualquier resultado que hubiera salido.

Clase Disturbed

Recall de 0.28, el peor de la matriz. Y no está confundida con una clase: el 35% se va a Unbarred Loose y el resto se reparte entre Round Smooth, In-between y Barred Spiral. Tres familias distintas.

Esa firma es distinta a cualquier otra confusión. Unbarred Tight → Unbarred Loose es “dos cosas parecidas”. Esto es otra cosa: “perturbada” no es una forma, es una anotación sobre cualquier forma. Una galaxia perturbada muchas veces es una espiral, con asimetría.

Fui a buscar si le pasaba a alguien más y encontré un paper que clasifica este mismo dataset con ResNet101, a 224×224, sin recorte y con las clases balanceadas. Reportan que su mayor confusión de Disturbed también es con Unbarred Loose Spiral.2

No saco de ahí una conclusión definitiva — es un solo paper y no repliqué su setup. Pero me deja una sospecha razonable: que un modelo bastante más potente que el mío se confunda en el mismo lugar sugiere que subir capacidad quizás no arregle esta clase en particular. Por ahora lo dejo anotado y no me meto más ahí, que es un agujero en el que se puede perder mucho tiempo.

Qué aprendí

  1. La capacidad de un modelo no es su número de parámetros, es dónde están puestos. 261k bien distribuidos le ganan a 7,9M gastados en memorizar posiciones.
  2. El augmentation depende del dominio. No es que en unos datasets sirva y en otros no: es que hay que saber qué transformaciones no alteran la identidad del dato. La misma rotación que acá es gratis, en otro dominio te fabrica etiquetas falsas.
  3. Antes de gastar épocas en una clase, hay que preguntarse qué significa esa clase. Mi peor clase no es una forma, es una anotación sobre cualquier forma — y eso no se arregla entrenando más.

Lo que dejo pendiente

Este proyecto ya me dio lo que venía a buscar, así que lo dejo acá y anoto lo que queda por probar, para no quedarme dando vueltas en el mismo dataset:

  • Unbarred Tight → Unbarred Loose, la celda más grande de la matriz. Es donde más hay que ganar.
  • Separar el efecto del recorte del de la arquitectura con una ablación limpia: un cambio a la vez, como debí haber hecho.
  • Class weights, para ver si mueven las clases chicas.
  • Abrir el test, cuando cierre de verdad.

Transfer learning también está pendiente, pero ese lo quiero abordar aparte: es un tema suficientemente grande como para merecer su propio proyecto en vez de ser el apéndice de este.

Siguiente registro: cuánto de lo que me falta es resolución y cuánto es el dato.


El notebook completo, con todas las corridas: TODO: pegar la URL de Google Colab.

Footnotes

  1. Lo que leí para entenderlo: Data augmentation is not necessarily about more data · Data augmentation — TensorFlow · Data Augmentation (video) ↩

  2. Classifying galaxies in the Galaxy10 DECals dataset using Inception and Residual CNNs. arXiv:2606.08826 ↩

← volver al log