19. Clasificación con redes neuronales

19.1 Introducción

En este tema vamos a trabajar con un problema completo y ejecutable de clasificación con redes neuronales usando PyTorch.

La idea es dejar por un momento la explicación demasiado abstracta y concentrarnos en un caso concreto que puedas leer, entender y ejecutar de principio a fin.

Veremos el problema, los datos, el modelo, el entrenamiento, la evaluación y algunas predicciones finales.

19.2 El problema que resolveremos

Supongamos que queremos predecir si un estudiante aprueba o no aprueba un examen en función de dos variables:

  • Horas de estudio.
  • Porcentaje de asistencia.

La salida será binaria:

  • 0 = no aprueba.
  • 1 = aprueba.

Este es un problema de clasificación binaria, porque solo hay dos clases posibles.

19.3 Por qué este ejemplo es útil

Este problema no intenta ser realista en términos académicos exactos, pero sí es muy útil para aprender.

Nos permite trabajar con:

  • Dos variables de entrada fáciles de interpretar.
  • Una salida binaria muy clara.
  • Un conjunto pequeño de datos que puede escribirse a mano.
  • Un modelo sencillo que se entrena en pocas líneas.

Eso hace que toda la atención pueda ponerse en entender el flujo de clasificación con PyTorch.

19.4 Los datos del problema

Usaremos un conjunto pequeño de ejemplos. Cada fila tendrá dos entradas:

  • La primera columna representa horas de estudio.
  • La segunda columna representa asistencia expresada entre 0 y 1.

La salida será una etiqueta binaria.

19.5 Los tensores de entrada y salida

Los datos del problema pueden escribirse así:

X = torch.tensor([
    [1.0, 0.30],
    [1.5, 0.35],
    [2.0, 0.40],
    [2.5, 0.45],
    [3.0, 0.50],
    [3.5, 0.55],
    [4.0, 0.60],
    [4.5, 0.65],
    [5.0, 0.70],
    [5.5, 0.75],
    [6.0, 0.80],
    [6.5, 0.85]
], dtype=torch.float32)

y = torch.tensor([
    [0.0],
    [0.0],
    [0.0],
    [0.0],
    [0.0],
    [0.0],
    [1.0],
    [1.0],
    [1.0],
    [1.0],
    [1.0],
    [1.0]
], dtype=torch.float32)

Aquí cada fila de X representa un estudiante y cada fila de y indica si aprueba o no.

19.6 Qué patrón debería aprender la red

Si miras los datos, notarás una idea intuitiva: a medida que aumentan las horas de estudio y la asistencia, la etiqueta pasa de 0 a 1.

La red no recibe esa regla escrita en palabras. Lo que hace es intentar descubrirla a partir de los ejemplos numéricos.

Eso es justamente lo que queremos que aprenda durante el entrenamiento.

19.7 La arquitectura del modelo

Usaremos una red pequeña con:

  • 2 entradas.
  • 1 capa oculta con 8 neuronas.
  • 1 salida final.

En notación resumida, sería:

2 - 8 - 1

Como es un problema binario, una sola salida es suficiente.

19.8 El modelo en PyTorch

La definición del modelo será esta:

import torch
import torch.nn as nn

class RedClasificacion(nn.Module):
    def __init__(self):
        super().__init__()
        self.capa1 = nn.Linear(2, 8)
        self.relu = nn.ReLU()
        self.capa2 = nn.Linear(8, 1)

    def forward(self, x):
        x = self.capa1(x)
        x = self.relu(x)
        x = self.capa2(x)
        return x

La última capa devuelve un solo valor por ejemplo. Ese valor todavía no es la clase final, sino un puntaje que luego interpretaremos.

19.9 La función de pérdida

Como estamos en clasificación binaria, una función de pérdida adecuada es:

criterio = nn.BCEWithLogitsLoss()

Esta función es muy conveniente porque trabaja de forma estable con una salida binaria sin obligarnos a aplicar manualmente Sigmoid dentro del modelo.

Su nombre viene de Binary Cross Entropy with Logits. "Binary Cross Entropy" indica que se usa para comparar una predicción binaria con el valor real 0 o 1, y "with Logits" significa que recibe directamente la salida cruda de la red antes de pasar por Sigmoid.

En otras palabras, durante el entrenamiento podemos dejar que la red produzca logits y usar esta pérdida directamente.

Esto es preferible a aplicar primero Sigmoid y luego usar otra pérdida, porque BCEWithLogitsLoss combina ambos pasos de una manera más estable numéricamente.

19.10 El optimizador

Para ajustar los parámetros usaremos Adam:

import torch.optim as optim
optimizador = optim.Adam(modelo.parameters(), lr=0.01)

Adam es una opción muy usada porque suele funcionar bien en muchos problemas iniciales sin demasiados ajustes finos.

19.11 El código completo y ejecutable

A continuación tienes un ejemplo completo que puede ejecutarse tal como está.

En este ejemplo separaremos los datos en estructuras distintas para adiestramiento y evaluación, tal como se hace habitualmente para medir mejor la capacidad de generalización del modelo.

import torch
import torch.nn as nn
import torch.optim as optim

torch.manual_seed(0)

X_train = torch.tensor([
    [1.0, 0.30],
    [1.5, 0.35],
    [2.0, 0.40],
    [2.5, 0.45],
    [3.0, 0.50],
    [3.5, 0.55],
    [4.0, 0.60],
    [4.5, 0.65],
    [5.0, 0.70],
    [5.5, 0.75],
    [6.0, 0.80],
    [6.5, 0.85]
], dtype=torch.float32)

y_train = torch.tensor([
    [0.0],
    [0.0],
    [0.0],
    [0.0],
    [0.0],
    [0.0],
    [1.0],
    [1.0],
    [1.0],
    [1.0],
    [1.0],
    [1.0]
], dtype=torch.float32)

X_eval = torch.tensor([
    [1.2, 0.32],
    [2.8, 0.48],
    [4.2, 0.62],
    [5.8, 0.78]
], dtype=torch.float32)

y_eval = torch.tensor([
    [0.0],
    [0.0],
    [1.0],
    [1.0]
], dtype=torch.float32)

class RedClasificacion(nn.Module):
    def __init__(self):
        super().__init__()
        self.capa1 = nn.Linear(2, 8)
        self.relu = nn.ReLU()
        self.capa2 = nn.Linear(8, 1)

    def forward(self, x):
        x = self.capa1(x)
        x = self.relu(x)
        x = self.capa2(x)
        return x

modelo = RedClasificacion()
criterio = nn.BCEWithLogitsLoss()
optimizador = optim.Adam(modelo.parameters(), lr=0.01)

for epoca in range(1000):
    modelo.train()
    salida = modelo(X_train)
    perdida = criterio(salida, y_train)
    optimizador.zero_grad()
    perdida.backward()
    optimizador.step()

    if (epoca + 1) % 100 == 0:
        print(f"Epoca {epoca+1}, perdida: {perdida.item():.4f}")

modelo.eval()
with torch.no_grad():
    logits = modelo(X_eval)
    probabilidades = torch.sigmoid(logits)
    predicciones = (probabilidades >= 0.5).float()
    accuracy = (predicciones == y_eval).float().mean()

    nuevo_alumno = torch.tensor([[4.6, 0.68]], dtype=torch.float32)
    logit_nuevo = modelo(nuevo_alumno)
    prob_nuevo = torch.sigmoid(logit_nuevo)
    pred_nuevo = (prob_nuevo >= 0.5).float()

print("Accuracy final:", accuracy.item())
print("Probabilidades:")
print(probabilidades)
print("Predicciones finales:")
print(predicciones)
print("Prediccion para un alumno nuevo:")
print(prob_nuevo)
print(pred_nuevo)

19.12 Qué hace cada parte del código

El ejemplo anterior tiene varias partes muy importantes:

  • Define datos de adiestramiento y datos de evaluación como tensores distintos.
  • Construye una red neuronal pequeña.
  • Usa una pérdida adecuada para clasificación binaria.
  • Entrena durante 1000 épocas.
  • Evalúa el modelo al final.
  • Convierte la salida en probabilidades y luego en clases.

Es decir, contiene el flujo completo del problema.

19.13 Por qué usamos torch.sigmoid al evaluar

Durante el entrenamiento usamos BCEWithLogitsLoss, que ya trabaja internamente con logits de forma estable.

Pero cuando queremos interpretar la salida, sí nos conviene convertir esos logits a probabilidades entre 0 y 1.

Para eso usamos:

probabilidades = torch.sigmoid(logits)

Así obtenemos valores más fáciles de entender.

19.14 Cómo decidimos la clase final

Una vez que tenemos probabilidades, necesitamos transformarlas en una clase.

En este problema usaremos una regla simple:

  • Si la probabilidad es mayor o igual a 0.5, predecimos 1.
  • Si es menor que 0.5, predecimos 0.

Eso se hace con esta línea:

predicciones = (probabilidades >= 0.5).float()

19.15 Cómo calculamos accuracy

Para medir qué tan bien clasifica el modelo, comparamos las predicciones con las etiquetas reales:

accuracy = (predicciones == y_eval).float().mean()

Esto calcula el porcentaje promedio de aciertos sobre el conjunto de evaluación.

Si el valor es 1.0, significa 100% de aciertos sobre ese conjunto evaluado.

19.16 Qué deberías observar al ejecutarlo

Al ejecutar el problema, deberías notar al menos tres cosas:

  • La pérdida tiende a bajar a medida que pasan las épocas.
  • La accuracy final debería ser alta también sobre el conjunto de evaluación, ya que el problema es simple.
  • Las probabilidades deberían ser bajas en ejemplos de clase 0 y altas en ejemplos de clase 1.

Eso indicará que la red logró aprender el patrón básico presente en los datos.

19.17 Probar con nuevos ejemplos

Una vez entrenado el modelo, podemos hacer predicciones sobre casos nuevos:

nuevos = torch.tensor([
    [2.2, 0.42],
    [4.8, 0.68],
    [6.2, 0.82]
], dtype=torch.float32)

modelo.eval()
with torch.no_grad():
    logits_nuevos = modelo(nuevos)
    probs_nuevos = torch.sigmoid(logits_nuevos)
    pred_nuevos = (probs_nuevos >= 0.5).float()
    print(probs_nuevos)
    print(pred_nuevos)

Esto permite comprobar si el modelo generaliza razonablemente a datos no incluidos de forma exacta en el entrenamiento. En el código principal anterior también se agregó la predicción de un alumno nuevo que no fue usado para entrenar.

19.18 Qué se puede modificar para experimentar

Este ejemplo está pensado para que puedas modificarlo fácilmente. Algunas pruebas útiles son:

  • Cambiar la cantidad de neuronas de la capa oculta.
  • Usar más o menos épocas.
  • Modificar la tasa de aprendizaje.
  • Agregar más ejemplos de entrenamiento.
  • Probar otras entradas nuevas al final.

Hacer estos cambios ayuda mucho a entender cómo afecta cada decisión al comportamiento del modelo.

19.19 Limitaciones de este problema

Aunque el ejemplo es completo y ejecutable, sigue siendo un caso didáctico pequeño.

Eso significa que no representa toda la complejidad de un problema real de clasificación. Por ejemplo:

  • El dataset es muy pequeño.
  • Las variables están muy ordenadas.
  • La separación entre adiestramiento y evaluación es correcta, pero ambos conjuntos siguen siendo demasiado simples.
  • No estamos lidiando con ruido realista.

En un problema real conviene separar los datos en conjuntos distintos, por ejemplo entrenamiento y prueba, o entrenamiento, validación y prueba.

Sin embargo, como primer ejemplo integral, es muy útil para fijar las ideas fundamentales.

19.20 Qué debes recordar de este problema

  • Es un problema de clasificación binaria.
  • Las entradas tienen dos características: horas de estudio y asistencia.
  • La salida es 0 o 1.
  • La red usada tiene arquitectura 2 - 8 - 1.
  • Se entrena con BCEWithLogitsLoss y Adam.
  • Las probabilidades se obtienen aplicando torch.sigmoid a la salida.
  • La clase final se decide con un umbral de 0.5.
  • La accuracy permite medir qué tan bien clasifica el modelo.

19.21 Conclusión

En este tema no solo vimos qué es la clasificación con redes neuronales, sino que trabajamos con un ejemplo completo que puede ejecutarse de principio a fin en PyTorch.

Eso permite ver en un solo lugar cómo se conectan los datos, la arquitectura del modelo, la función de pérdida, el optimizador, el entrenamiento y la evaluación.

En el próximo tema avanzaremos hacia otro concepto central del Deep Learning: la regularización y el overfitting.