Clase 1 · Redes neuronales en la práctica
Cómo estudiar esta cápsula
Esta página enseña sola: no hay cátedra. Avanza tema por tema, en orden. Cada tema parte con una pregunta guía, explica la idea con un ejemplo de negocio y luego te deja jugar:
- 🧠 Simuladores: mueve los deslizadores y presiona los botones. No hay forma de romperlos; la idea es que predigas qué va a pasar antes de tocar y luego compruebes.
- 🧪 Python en el navegador: cada bloque tiene un botón ▶ Ejecutar en el navegador. La primera vez tarda unos 15 segundos en cargar Python; después es inmediato. Puedes editar el código y cada bloque trae un 🎯 Reto.
- ✍️ Practica: preguntas de selección múltiple. Si te equivocas, la alternativa te explica por qué no es.
- 🧑💻 Notebook de Colab: al final está el notebook que aplica todo a los datos completos de tu caso, con una red neuronal en Keras. Es la base de tu Sprint 1.
Para la tutoría del jueves 29 (kickoff): llega con tu equipo armado (2–3 personas) o con la intención de armarlo ahí, y con una idea de qué caso prefieren: FinTrust (reclamos de un banco), MercaSur (reseñas de un retail) o un caso propio. Si alcanzas a leer el Tema 5 antes, mejor: es lo primero que harás con los datos.
🎯 Objetivos
Al terminar esta cápsula podrás:
- Explicar qué hace una neurona (suma ponderada + activación) y por qué la regresión logística del curso I ya era una red neuronal de una sola neurona.
- Explicar por qué apilar capas ocultas permite fronteras de decisión no lineales.
- Describir cómo aprende una red: función de pérdida (entropía cruzada), descenso de gradiente, tasa de aprendizaje, épocas y batches.
- Reconocer el sobreajuste en las curvas de entrenamiento y validación y controlarlo con early stopping y dropout.
- Convertir texto en números con bolsa de palabras y TF-IDF, después de limpiar los datos.
- Comparar una regresión logística con una red neuronal usando F1 macro y leer la matriz de confusión con ojos de negocio.
🗺️ Ruta de la cápsula
Haz clic en un tema para ir directo a él.
- 20 min1De la regresión logística a la neurona🧠 simulador · 🧪 Python · ✍️ 2 preguntas
- 25 min2Redes multicapa: fronteras no lineales🧠 simulador · 🧪 Python · ✍️ 2 preguntas
- 25 min3Cómo aprende una red🧠 simulador · 🧪 Python · ✍️ 3 preguntas
- 20 min4Sobreajuste y cómo evitarlo🧠 simulador · 🧪 Python · ✍️ 2 preguntas
- ☕Pausa: estírate antes de abrir los datos
- 40 min5El texto también es un dato🧪 4 bloques de Python · ✍️ 3 preguntas
- 15 min6La matriz de confusión con ojos de negocio🧠 simulador · 🧪 Python · ✍️ 2 preguntas
- ✅Notebook de Colab, conexión con el Sprint 1 y resumen
Tema 1 · De la regresión logística a la neurona
Pregunta guía: si ya sabes regresión logística, ¿cuánto te falta para entender una red neuronal?
Menos de lo que crees. Piensa en el equipo de servicio al cliente de FinTrust, que recibe cientos de reclamos al día. Una ejecutiva con experiencia decide en segundos si un reclamo es urgente: mira si trae palabras de alarma ("no reconozco", "fraude", "urgente") y cuánta plata está en juego. Sin darse cuenta, está ponderando señales: a algunas les da mucha importancia, a otras poca, y si la suma pasa cierto nivel, lo marca como urgente.
Eso es exactamente una neurona artificial. Hace dos cosas:
- Suma ponderada: multiplica cada entrada por un peso (cuánto importa) y suma un sesgo (qué tan exigente es la neurona): $$ z = w_1 x_1 + w_2 x_2 + b $$
- Activación: pasa \( z \) por una función \( f \) que decide qué tan fuerte "se enciende": $$ a = f(z) $$
Si la activación es la sigmoide \( \sigma(z) = \frac{1}{1+e^{-z}} \), la salida queda entre 0 y 1 y se lee como probabilidad. ¿Te suena? Es la regresión logística del curso I, con otro nombre: los "coeficientes" ahora se llaman pesos y el "intercepto" se llama sesgo.
| Activación | Fórmula | Rango | Dónde se usa |
|---|---|---|---|
| Sigmoide | \( \frac{1}{1+e^{-z}} \) | 0 a 1 | Salida de un clasificador binario (probabilidad) |
| tanh | \( \tanh(z) \) | −1 a 1 | Capas ocultas (centrada en cero) |
| ReLU | \( \max(0, z) \) | 0 a ∞ | Capas ocultas: la más usada hoy, simple y rápida |
| Softmax | \( \frac{e^{z_k}}{\sum_j e^{z_j}} \) | 0 a 1, suman 1 | Salida de un clasificador con varias clases (p. ej. 6 categorías de reclamo) |
💼 En el negocio
Los pesos son interpretables en una sola neurona: un peso positivo y grande para "palabras de alarma" dice que esa señal empuja fuerte hacia "urgente". Esa transparencia se pierde cuando apilamos muchas neuronas, y por eso en banca (regulada) la regresión logística sigue viva: a veces explicar la decisión vale más que ganar dos puntos de F1.
🧪 Practica en Python · Una neurona a mano
Tres reclamos descritos con dos números. La neurona calcula \( z \), aplica la sigmoide y decide.
import numpy as np
# Cada reclamo se describe con dos números:
# x1 = cantidad de palabras de alarma ("urgente", "fraude", "no reconozco"...)
# x2 = monto reclamado, en millones de pesos
reclamos = {
"Me cobraron dos veces la cuota": [1, 0.08],
"NO RECONOZCO compras, urgente, fraude": [3, 0.85],
"¿Cómo cambio la clave de la app?": [0, 0.00],
}
w = np.array([1.2, 2.0]) # pesos: cuánto importa cada entrada
b = -3.0 # sesgo: qué tan exigente es la neurona
def sigmoide(z):
return 1 / (1 + np.exp(-z))
for texto, x in reclamos.items():
z = np.dot(w, x) + b # 1) suma ponderada
p = sigmoide(z) # 2) activación
etiqueta = "URGENTE" if p >= 0.5 else "normal"
print(f"{texto:40s} z = {z:5.2f} -> p = {p:.2f} ({etiqueta})")
🎯 Reto: cambia el sesgo a b = -1.0. ¿Qué reclamo cambia de etiqueta? ¿Te parece razonable para el negocio
marcar un cobro duplicado como urgente?
✍️ Practica · Tema 1
Una neurona con activación sigmoide y una sola capa, entrenada para predecir si un reclamo es urgente, es equivalente a…
✅ Suma ponderada + sigmoide = regresión logística. Los pesos son los coeficientes y el sesgo es el intercepto. Las redes “de verdad” aparecen al apilar muchas de estas neuronas.
Tienes que clasificar reclamos en 6 categorías (Fraude, Cobro indebido, …). ¿Qué activación usas en la capa de salida?
✅ Softmax convierte 6 puntajes en 6 probabilidades que suman 1, y la red elige la categoría con mayor probabilidad. Es lo que usarás en Keras en el notebook.
Tema 2 · Redes multicapa: fronteras no lineales
Pregunta guía: si una neurona ya es una regresión logística, ¿para qué queremos muchas?
Una neurona sola traza una frontera recta: a un lado "sí", al otro "no". El problema es que muchos patrones de negocio no se separan con una recta:
- Clientes que se van de MercaSur: si los ubicas según cada cuánto compran y cuánto gastan, los que se van están en los extremos (los que casi no compran y no enganchan, y los revendedores que compran muchísimo y se cambian apenas encuentran mejor precio). Los cercanos al promedio se quedan. Eso es un anillo alrededor del centro, no una recta.
- Compras sospechosas en FinTrust: una compra en el extranjero con el cliente en Chile, o una compra en Chile con el cliente de viaje. Lo sospechoso es que no coincidan: es un "o exclusivo" (XOR) y ninguna recta lo separa.
La solución es poner capas ocultas entre la entrada y la salida. Cada neurona oculta traza su propia recta; la neurona de salida combina esas rectas y puede formar curvas, bandas o regiones cerradas. Eso es una red multicapa o MLP (multilayer perceptron).
La activación es lo que hace la magia
Si las capas ocultas no tuvieran activación (o esta fuera lineal), apilar capas no serviría de nada: una combinación lineal de combinaciones lineales sigue siendo lineal, y la red entera volvería a ser una recta. La no linealidad (ReLU, tanh) es lo que permite "doblar" la frontera.
🧪 Practica en Python · Logística vs. red con capa oculta
Clientes "promedio" en el centro y clientes "extremos" en un anillo. Entrenamos los dos modelos y dibujamos sus fronteras.
import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_circles
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
# 400 clientes: anillo = se van (y = 0), centro = se quedan (y = 1)
X, y = make_circles(n_samples=400, noise=0.12, factor=0.45, random_state=1)
modelos = {
"Regresión logística (1 neurona)": LogisticRegression(),
"Red con 8 neuronas ocultas": MLPClassifier(hidden_layer_sizes=(8,), activation="tanh",
solver="lbfgs", max_iter=2000, random_state=1),
}
# Grilla para pintar la frontera de decisión
xx, yy = np.meshgrid(np.linspace(-1.6, 1.6, 150), np.linspace(-1.6, 1.6, 150))
grilla = np.c_[xx.ravel(), yy.ravel()]
fig, axes = plt.subplots(1, 2, figsize=(9, 4))
for ax, (nombre, modelo) in zip(axes, modelos.items()):
modelo.fit(X, y)
exactitud = modelo.score(X, y)
prob = modelo.predict_proba(grilla)[:, 1].reshape(xx.shape)
ax.contourf(xx, yy, prob, levels=20, cmap="coolwarm", alpha=0.6)
ax.scatter(X[:, 0], X[:, 1], c=y, cmap="coolwarm", edgecolor="k", s=15)
ax.set_title(f"{nombre}\nexactitud = {exactitud:.0%}", fontsize=10)
ax.set_xticks([]); ax.set_yticks([])
print(f"{nombre:35s} exactitud = {exactitud:.1%}")
plt.tight_layout()
🎯 Reto: cambia hidden_layer_sizes=(8,) por (1,), luego por (2,) y por (3,). ¿Con cuántas neuronas ocultas
la red logra "encerrar" el centro? ¿Por qué con una sola neurona oculta se parece tanto a la logística?
⚠️ Error común
Pensar que "más capas siempre es mejor". Una red más grande puede dibujar fronteras más complejas, pero también puede dibujar fronteras absurdas que se acomodan al ruido de los datos (lo verás en el Tema 4). Con texto representado como TF-IDF, a menudo una regresión logística ya funciona muy bien: la red tiene que ganarse su lugar en la comparación del Sprint 1.
✍️ Practica · Tema 2
En el simulador, con 0 neuronas ocultas y los datos en anillo, la exactitud se queda cerca del 50–60 % aunque entrenes muchas épocas. ¿Por qué?
✅ Es un problema de capacidad del modelo, no de entrenamiento. Con capa oculta, cada neurona aporta una recta y la salida las combina en una región cerrada.
¿Qué pasaría si quitaras la función de activación de las capas ocultas (dejándolas lineales)?
✅ Lineal de lineal es lineal: \( W_2(W_1x) = (W_2W_1)x \). La no linealidad es la que permite doblar la frontera.
Tema 3 · Cómo aprende una red
Pregunta guía: nadie le dice a la red cuáles son los pesos correctos. ¿Cómo los encuentra?
Una red parte con pesos al azar y los va corrigiendo, como quien aprende a lanzar al aro: lanza, mira qué tan lejos quedó, y ajusta. Para eso necesita tres piezas:
1. Una función de pérdida: ¿qué tan mal lo hice? En clasificación se usa la entropía cruzada. La intuición es simple: mira la probabilidad que la red le dio a la respuesta correcta y castígala si es baja.
| La red dijo que era "Fraude" con probabilidad… | …y era Fraude. Pérdida |
|---|---|
| 0,90 (segura y acertó) | \( -\log 0{,}90 = 0{,}11 \) 🙂 |
| 0,50 (dudosa) | \( -\log 0{,}50 = 0{,}69 \) 😐 |
| 0,05 (segura y se equivocó) | \( -\log 0{,}05 = 3{,}00 \) 😱 |
Equivocarse con seguridad cuesta muchísimo más que dudar. Eso empuja a la red a ser honesta con sus probabilidades.
2. El descenso de gradiente: ¿hacia dónde corrijo? Imagina que estás en un cerro con niebla y quieres llegar al valle. No ves el valle, pero sí sientes la pendiente bajo tus pies. Das un paso cuesta abajo, vuelves a sentir la pendiente, otro paso… El gradiente es esa pendiente: dice cómo cambia la pérdida si mueves cada peso un poquito.
3. La tasa de aprendizaje \( \eta \): ¿qué tan largo es el paso? Es el hiperparámetro más importante del entrenamiento. Pruébalo:
Épocas y batches. Calcular el gradiente con todos los reclamos a la vez es caro. En la práctica la red mira los datos de a batches (lotes, p. ej. 32 reclamos), da un paso con cada lote, y cuando ya vio todos los datos una vez, completó una época.
| Concepto | Qué es | Ejemplo con 6.000 reclamos |
|---|---|---|
| Batch | Grupo de ejemplos con que se calcula un paso | 32 reclamos |
| Iteración | Un paso de descenso de gradiente | 6.000 / 32 ≈ 188 pasos por época |
| Época | Una pasada completa por los datos de entrenamiento | 20 épocas = 3.760 pasos |
El algoritmo que calcula el gradiente de todos los pesos de una red, capa por capa desde la salida hacia atrás, es la retropropagación (backpropagation), que viste en el curso I. Hoy no lo programas a mano: Keras y scikit-learn lo hacen por ti. Lo que sí decides tú es la tasa, el tamaño del batch y cuántas épocas entrenar.
🧪 Practica en Python · Descenso de gradiente a mano
Entrenamos una neurona (regresión logística) con tres tasas de aprendizaje y comparamos cómo baja la pérdida.
import numpy as np
import matplotlib.pyplot as plt
# Intuición de la entropía cruzada: castiga equivocarse con seguridad
for p in [0.9, 0.5, 0.05]:
print(f"Prob. asignada a la clase correcta = {p:.2f} -> pérdida = {-np.log(p):.2f}")
# 200 reclamos simulados: x = palabras de alarma (estandarizado), y = 1 si es urgente
rng = np.random.default_rng(0)
x = rng.normal(size=200)
y = (rng.random(200) < 1 / (1 + np.exp(-(2.5 * x - 0.5)))).astype(float)
def entrenar(lr, epocas=30):
w, b = 0.0, 0.0 # pesos iniciales
historia = []
for _ in range(epocas):
p = 1 / (1 + np.exp(-(w * x + b))) # predicción
perdida = -np.mean(y * np.log(p + 1e-9) + (1 - y) * np.log(1 - p + 1e-9))
historia.append(perdida)
grad_w = np.mean((p - y) * x) # pendiente de la pérdida respecto de w
grad_b = np.mean(p - y) # pendiente respecto de b
w -= lr * grad_w # paso cuesta abajo
b -= lr * grad_b
return historia
plt.figure(figsize=(7, 3.5))
for lr in [0.1, 2, 30]:
h = entrenar(lr)
plt.plot(h, marker="o", ms=3, label=f"tasa = {lr}")
print(f"tasa {lr:>4}: pérdida inicial {h[0]:.3f} -> final {h[-1]:.3f}")
plt.xlabel("época"); plt.ylabel("entropía cruzada"); plt.legend()
plt.title("¿Qué tasa de aprendizaje elegirías?")
plt.tight_layout()
🎯 Reto: prueba con lr = 0.5 y lr = 8. Busca la tasa más grande que todavía baja de forma suave. ¿Qué pasa
con la curva de 30, y por qué se parece a la bola del simulador cuando la tasa es demasiado grande?
⚠️ Error común
Entrenar con una tasa demasiado alta y concluir que "la red no funciona". Si la pérdida sube o salta de una época a otra, el primer sospechoso es la tasa de aprendizaje. Si baja lentísimo, también. En Keras el optimizador Adam (el que usaremos) ajusta el tamaño del paso de cada peso de forma automática, y su valor por defecto (0,001) suele ser un buen punto de partida.
✍️ Practica · Tema 3
Un reclamo era “Cobro indebido”. El modelo A le asignó 0,40 a esa clase; el modelo B, 0,02. ¿Cuál tiene mayor entropía cruzada en ese reclamo?
✅ −log(0,02) ≈ 3,9 frente a −log(0,40) ≈ 0,92. La pérdida castiga mucho más la seguridad equivocada que la duda. Ojo: con 6 clases, 0,40 puede ser la probabilidad más alta, así que el modelo A quizás hasta acertó la categoría.
Durante el entrenamiento, la pérdida salta así: 0,69 → 0,52 → 2,10 → 0,61 → 3,40… ¿Qué cambias primero?
✅ Pérdida que rebota o crece = pasos demasiado largos que sobrepasan el mínimo, como la bola del simulador con tasa > 2.
Tienes 4.800 reclamos de entrenamiento y usas batch_size=64. ¿Cuántos pasos de descenso de gradiente da la red en una época?
✅ 4.800 / 64 = 75 pasos (iteraciones) por época. En Keras verás “75/75” en la barra de progreso de cada época.
Tema 4 · Sobreajuste y cómo evitarlo
Pregunta guía: si la pérdida de entrenamiento sigue bajando, ¿por qué no entrenar para siempre?
Porque la red puede memorizar en vez de aprender. Es como el alumno que se aprende de memoria las respuestas de la prueba del año pasado: le va perfecto en esa prueba, pero mal en la de este año. Una red con muchos pesos puede aprenderse de memoria hasta el ruido de los reclamos de entrenamiento (incluidas las etiquetas mal puestas, que en FinTrust y MercaSur existen a propósito).
Para detectarlo separamos un grupo de validación: datos que la red no usa para aprender, solo para controlar cómo le va. Luego comparamos las dos curvas:
- Las dos bajan juntas → la red está aprendiendo patrones generales. 👍
- Entrenamiento sigue bajando, validación sube → sobreajuste (overfitting). 🚩
Las dos herramientas que usarás en Keras:
- Early stopping: vigila la pérdida de validación; si no mejora en N épocas seguidas (la paciencia), detiene
el entrenamiento y, con
restore_best_weights=True, recupera los pesos de la mejor época. - Dropout: en cada paso de entrenamiento apaga al azar un porcentaje de neuronas (p. ej. 30 %). La red no puede depender de una sola neurona "estrella" y se ve obligada a repartir lo aprendido. Es como entrenar a un equipo rotando a los jugadores: nadie se vuelve imprescindible.
🧪 Practica en Python · Ver el sobreajuste en vivo
Una red demasiado grande para pocos datos ruidosos. Registramos la pérdida en entrenamiento y validación época a época.
import numpy as np
import warnings
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import log_loss
warnings.filterwarnings("ignore")
# Pocos datos y bastante ruido: terreno fértil para memorizar
X, y = make_moons(n_samples=160, noise=0.35, random_state=3)
X_tr, X_val, y_tr, y_val = train_test_split(X, y, test_size=0.5, random_state=3)
# Red grande (2 capas de 64 neuronas) y sin regularización (alpha = 0)
red = MLPClassifier(hidden_layer_sizes=(64, 64), alpha=0.0, learning_rate_init=0.01, random_state=0)
perdida_tr, perdida_val = [], []
for epoca in range(250):
red.partial_fit(X_tr, y_tr, classes=[0, 1]) # una época de entrenamiento
perdida_tr.append(log_loss(y_tr, red.predict_proba(X_tr)))
perdida_val.append(log_loss(y_val, red.predict_proba(X_val)))
mejor = int(np.argmin(perdida_val))
print(f"Mejor época según validación: {mejor + 1} (pérdida val = {perdida_val[mejor]:.3f})")
print(f"Época 250: pérdida train = {perdida_tr[-1]:.3f}, pérdida val = {perdida_val[-1]:.3f}")
epocas = range(1, 251)
plt.figure(figsize=(7, 3.5))
plt.plot(epocas, perdida_tr, label="entrenamiento", color="#e5b300")
plt.plot(epocas, perdida_val, label="validación", color="#005f96")
plt.axvline(mejor + 1, ls="--", color="#b30828", label="aquí convenía parar")
plt.xlabel("época"); plt.ylabel("entropía cruzada"); plt.ylim(0, 1.2); plt.legend()
plt.tight_layout()
🎯 Reto: scikit-learn no trae dropout, pero sí otra forma de frenar la memorización: la penalización alpha
(como el Ridge del curso I). Prueba alpha=0.05 y luego hidden_layer_sizes=(8,). ¿Qué pasa con la brecha entre
las curvas y con la mejor época?
⚠️ Error común
Elegir el modelo (o la época, o la cantidad de neuronas) mirando el test. El test es la "prueba final":
se mira una sola vez, al final, para reportar. Para decidir hiperparámetros se usa validación
(en Keras, validation_split=0.15). Si ajustas mirando el test, tu F1 reportado será optimista y en producción
te llevarás una sorpresa.
✍️ Practica · Tema 4
En la época 30, la pérdida de entrenamiento es 0,05 y la de validación es 0,95 (y viene subiendo desde la época 8). ¿Qué diagnosticas?
✅ Brecha grande y validación subiendo = sobreajuste. Early stopping con restore_best_weights=True se habría quedado con los pesos de la época 8.
¿Cuál de estas afirmaciones sobre el dropout es correcta?
✅ Por eso con dropout la pérdida de entrenamiento suele quedar más alta que sin él, pero la de validación mejora: la red aprende patrones más robustos.
Tema 5 · El texto también es un dato
Pregunta guía: una red neuronal solo entiende números. ¿Cómo le pasamos un reclamo escrito por un cliente?
5.1 Antes de modelar: auditar y limpiar
Antes de cualquier modelo, mira los datos. Los casos FinTrust y MercaSur vienen sucios a propósito, como los datos reales: reclamos duplicados, textos vacíos o basura ("reclamo", "ok"), categorías escritas de varias formas distintas ("Fraude", "FRAUDE", "fraude", "Fraude ", …), montos negativos o días de resolución imposibles. Si entrenas con "Fraude" y "FRAUDE" como clases distintas, el modelo cree que hay 20 categorías en vez de 6 (lo verás en la auditoría) y tus métricas no significan nada.
🧪 Practica en Python · Auditoría de calidad de FinTrust
Cuántos problemas hay, y de qué tipo. La herramienta estrella es value_counts().
import pandas as pd
df = pd.read_csv(DATA + "reclamos_fintrust_muestra.csv")
print("Filas:", len(df))
print("Duplicados exactos:", df.duplicated().sum())
texto = df["texto"].fillna("").str.strip()
print("Textos vacíos o de menos de 3 palabras:", (texto.str.split().str.len() < 3).sum())
print("Montos negativos:", (df["monto_reclamado"] < 0).sum())
print("Días de resolución imposibles (< 0 o > 365):",
((df["dias_resolucion"] < 0) | (df["dias_resolucion"] > 365)).sum())
print("Urgencia nula:", df["urgencia"].isna().sum())
print("Formas distintas de escribir la categoría:", df["categoria"].nunique(), "(deberían ser 6)\n")
# ¿Cómo está escrita la categoría? Debería haber 6 valores...
df["categoria"].value_counts()
🎯 Reto: haz lo mismo con la columna canal (df["canal"].value_counts()). ¿Cuántos canales distintos hay
de verdad? Si eliges MercaSur, cambia el archivo por resenas_mercasur_muestra.csv y adapta la auditoría a sus columnas: aspecto en vez de
categoria, y rating en vez de montos, días y urgencia (que no existen en MercaSur).
La limpieza mínima para el Sprint 1 cabe en una función reutilizable: quitar duplicados, sacar textos basura y normalizar las categorías (minúsculas, sin tildes, sin espacios extra, y un diccionario para los sinónimos).
🧪 Practica en Python · Una función de limpieza
Normalizamos la categoría para que "FRAUDE", "fraude " y "Fraude" sean la misma clase.
import pandas as pd
import unicodedata
def sin_tildes(s):
return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
def clave(s):
"""' Crédito y TASAS ' -> 'credito y tasas'"""
return " ".join(sin_tildes(str(s)).lower().split())
CATEGORIAS = ["Fraude", "Cobro indebido", "App y canales digitales",
"Crédito y tasas", "Atención al cliente", "Información y trámites"]
MAPA = {clave(c): c for c in CATEGORIAS}
MAPA["cobros indebidos"] = "Cobro indebido" # sinónimos detectados en la auditoría
def limpiar(df, col_texto="texto", col_clase="categoria"):
n0 = len(df)
df = df.drop_duplicates().copy()
df[col_texto] = df[col_texto].fillna("").str.strip()
df = df[df[col_texto].str.split().str.len() >= 3] # fuera textos basura
df[col_clase] = df[col_clase].map(lambda s: MAPA.get(clave(s))) # normaliza categorías
df = df.dropna(subset=[col_clase])
print(f"Filas: {n0} -> {len(df)} (eliminadas: {n0 - len(df)})")
return df
df = limpiar(pd.read_csv(DATA + "reclamos_fintrust_muestra.csv"))
df["categoria"].value_counts()
🎯 Reto: agrega una línea a limpiar que elimine los montos negativos (convirtiéndolos en NaN con
df.loc[df["monto_reclamado"] < 0, "monto_reclamado"] = None). ¿Por qué es mejor convertirlos en nulos que borrar la fila completa?
5.2 Bolsa de palabras y TF-IDF
La idea más simple para convertir texto en números es la bolsa de palabras: hacemos una columna por cada palabra del vocabulario y contamos cuántas veces aparece en cada reclamo. Se pierde el orden ("me cobraron y no pagué" y "no me cobraron y pagué" quedan idénticos), pero funciona sorprendentemente bien para clasificar temas.
Algunas columnas del vocabulario (en la práctica hay miles):
| Reclamo | cobraron | dos | veces | comisión | reconozco | compra | fraude | app | cae | la | no |
|---|---|---|---|---|---|---|---|---|---|---|---|
| R1 · "me cobraron dos veces la comisión" | 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
| R2 · "no reconozco una compra, creo que es fraude" | 0 | 0 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | 0 | 1 |
| R3 · "la app no me deja entrar, la app se cae" | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 2 | 1 | 2 | 1 |
Problema: palabras como "la", "no" o "me" aparecen en casi todos los reclamos y no ayudan a distinguir la categoría. TF-IDF corrige esto multiplicando dos cosas:
- TF (term frequency): cuántas veces aparece la palabra en este reclamo → "app" pesa más en R3 porque aparece dos veces.
- IDF (inverse document frequency): qué tan rara es la palabra en todos los reclamos → "fraude" aparece en pocos reclamos y pesa mucho; "la" aparece en casi todos y pesa poco.
donde \( N \) es el total de reclamos. (scikit-learn usa una versión suavizada de esta fórmula y además normaliza cada fila para que reclamos largos y cortos sean comparables; por eso sus números no calzan exactos con la cuenta a mano, pero la idea es la misma.)
El resultado: cada reclamo se convierte en un vector (una fila) con miles de columnas, casi todas en cero, donde las palabras frecuentes en ese reclamo pero raras en general tienen los valores más altos. Esa matriz es la entrada de la regresión logística y de la red neuronal.
🧪 Practica en Python · TF-IDF con tres reclamos
Compara los conteos con los pesos TF-IDF: mira qué pasa con "la" y con "fraude".
import pandas as pd
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
reclamos = ["me cobraron dos veces la comisión",
"no reconozco una compra, creo que es fraude",
"la app no me deja entrar, la app se cae"]
bolsa = CountVectorizer()
conteos = bolsa.fit_transform(reclamos)
print("Vocabulario:", len(bolsa.get_feature_names_out()), "palabras\n")
tfidf = TfidfVectorizer()
pesos = tfidf.fit_transform(reclamos)
# Palabras en filas, reclamos en columnas (más fácil de leer)
tabla = pd.DataFrame(pesos.toarray().T, index=tfidf.get_feature_names_out(),
columns=["R1", "R2", "R3"])
tabla["conteo total"] = conteos.toarray().sum(axis=0)
tabla.round(2).sort_values("R3", ascending=False)
🎯 Reto: agrega un cuarto reclamo: "la app me cobró una comisión que no reconozco" (y "R4" en columns). ¿Qué le pasa al peso de
"app" en R3 ahora que la palabra aparece en dos reclamos?
5.3 Logística vs. red neuronal sobre TF-IDF
Ahora sí, el flujo completo del Sprint 1 en miniatura: limpiar → separar entrenamiento y test → TF-IDF → dos modelos → comparar con F1 macro.
¿Por qué F1 macro y no exactitud? Porque las categorías no tienen el mismo tamaño. F1 macro calcula el F1 de cada categoría y los promedia sin ponderar: una categoría más chica pero crítica (como Fraude) pesa lo mismo que la más grande. Un modelo que ignora Fraude no puede esconderse detrás de un buen promedio.
🧪 Practica en Python · Primer duelo: logística vs. MLP
Con la muestra de 1.500 reclamos. Tarda unos segundos: la red se entrena en tu navegador.
import pandas as pd
import unicodedata, warnings
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.neural_network import MLPClassifier
from sklearn.metrics import f1_score, ConfusionMatrixDisplay
from sklearn.preprocessing import LabelEncoder
warnings.filterwarnings("ignore")
# --- 1. Limpiar (misma función de antes, compacta)
def clave(s):
s = "".join(c for c in unicodedata.normalize("NFD", str(s)) if unicodedata.category(c) != "Mn")
return " ".join(s.lower().split())
CATS = ["Fraude", "Cobro indebido", "App y canales digitales", "Crédito y tasas",
"Atención al cliente", "Información y trámites"]
MAPA = {clave(c): c for c in CATS} | {"cobros indebidos": "Cobro indebido"}
df = pd.read_csv(DATA + "reclamos_fintrust_muestra.csv").drop_duplicates()
df["texto"] = df["texto"].fillna("").str.strip()
df = df[df["texto"].str.split().str.len() >= 3]
df["categoria"] = df["categoria"].map(lambda s: MAPA.get(clave(s)))
df = df.dropna(subset=["categoria"])
# Las redes trabajan con números: cada categoría pasa a ser 0, 1, ..., 5
codif = LabelEncoder()
y = codif.fit_transform(df["categoria"])
# --- 2. Separar (estratificado: misma proporción de categorías en train y test)
X_tr, X_te, y_tr, y_te = train_test_split(df["texto"], y, test_size=0.2,
stratify=y, random_state=42)
# --- 3. TF-IDF: se ajusta SOLO con entrenamiento
vec = TfidfVectorizer(max_features=3000, ngram_range=(1, 2), min_df=2, sublinear_tf=True)
Xtr = vec.fit_transform(X_tr)
Xte = vec.transform(X_te)
print("Matriz TF-IDF de entrenamiento:", Xtr.shape)
# --- 4. Dos modelos
modelos = {
"Regresión logística": LogisticRegression(max_iter=1000, C=5),
"Red neuronal (MLP 64)": MLPClassifier(hidden_layer_sizes=(64,), early_stopping=True,
max_iter=60, random_state=42),
}
for nombre, m in modelos.items():
m.fit(Xtr, y_tr)
pred = m.predict(Xte)
print(f"{nombre:25s} F1 macro en test = {f1_score(y_te, pred, average='macro'):.3f}")
# --- 5. Matriz de confusión del modelo logístico
fig, ax = plt.subplots(figsize=(7, 6))
ConfusionMatrixDisplay.from_predictions(y_te, modelos["Regresión logística"].predict(Xte),
labels=range(len(codif.classes_)),
display_labels=codif.classes_, ax=ax,
xticks_rotation=45, cmap="Blues", colorbar=False)
ax.set_title("Regresión logística · matriz de confusión (test)")
ax.set_xlabel("Categoría predicha"); ax.set_ylabel("Categoría real")
plt.tight_layout()
🎯 Reto: comenta la línea que normaliza categoria (ponle # adelante) y vuelve a ejecutar. ¿Cuántas
"categorías" ve ahora el modelo y qué le pasa al F1 macro? Esa es la razón de auditar primero.
💼 En el negocio
Que la red neuronal no le gane a la logística sobre TF-IDF no es un fracaso: es un hallazgo. Con vocabularios claros ("no reconozco", "cobraron dos veces", "la app se cae"), un modelo lineal ya separa bien, es más rápido, más barato y más fácil de explicar a un gerente. En las próximas clases verás cuándo sí ganan los modelos más sofisticados (embeddings y LLM): con pocos datos etiquetados, sinónimos y tareas de extracción.
⚠️ Error común
Ajustar el TfidfVectorizer con todos los datos (fit antes de separar). El IDF "espía" las palabras del test
y el resultado sale optimista. La regla: fit_transform en entrenamiento, transform en test. Lo mismo vale para
cualquier limpieza que "aprenda" de los datos.
✍️ Practica · Tema 5
En la auditoría, value_counts() de la categoría muestra 20 valores distintos en vez de 6. ¿Qué haces?
✅ Normalizar recupera esas filas y deja exactamente 6 clases. Reporta en tu Sprint 1 cuántas filas corregiste: es parte de la auditoría.
En TF-IDF, ¿qué palabra tendrá mayor peso en el reclamo “no reconozco un cargo, creo que es fraude, no fui yo”?
✅ TF-IDF premia lo frecuente aquí y raro en general: justo las palabras que distinguen la categoría.
Un modelo tiene 85 % de exactitud, pero su F1 macro es 0,60. ¿Qué es lo más probable?
✅ La exactitud la dominan las clases grandes; el F1 macro da el mismo peso a todas. Revisa el F1 por clase en el classification_report para encontrar la categoría débil.
Tema 6 · La matriz de confusión con ojos de negocio
Pregunta guía: si el modelo se equivoca en 20 reclamos, ¿da lo mismo cuáles 20?
No da lo mismo. En FinTrust no cuesta lo mismo:
- Un fraude clasificado como "Información y trámites": el reclamo va a la cola lenta, el cliente sigue perdiendo plata, el banco puede tener que devolver el dinero con intereses, aparece en redes sociales y quizás en la CMF.
- Una consulta de información clasificada como "Fraude": el equipo antifraude pierde 10 minutos revisándola y la reenvía. Molesta, pero barato.
La matriz de confusión te dice cuántos errores de cada tipo hay; el negocio te dice cuánto cuesta cada uno. Multiplicando ambas cosas obtienes el costo esperado del modelo, que es lo que le importa al gerente. Y hay una palanca que no requiere reentrenar: el umbral de decisión. Si un fraude no detectado es muy caro, conviene marcar como fraude todo reclamo con probabilidad sobre 0,3 (no 0,5), aceptando más falsas alarmas.
🧪 Practica en Python · ¿Cuánto cuestan los errores del modelo?
Usamos la probabilidad de "Fraude" de la regresión logística y buscamos el umbral que minimiza el costo.
import numpy as np
import pandas as pd
import unicodedata
from sklearn.model_selection import train_test_split
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
def clave(s):
s = "".join(c for c in unicodedata.normalize("NFD", str(s)) if unicodedata.category(c) != "Mn")
return " ".join(s.lower().split())
CATS = ["Fraude", "Cobro indebido", "App y canales digitales", "Crédito y tasas",
"Atención al cliente", "Información y trámites"]
MAPA = {clave(c): c for c in CATS} | {"cobros indebidos": "Cobro indebido"}
df = pd.read_csv(DATA + "reclamos_fintrust_muestra.csv").drop_duplicates()
df["texto"] = df["texto"].fillna("").str.strip()
df = df[df["texto"].str.split().str.len() >= 3]
df["categoria"] = df["categoria"].map(lambda s: MAPA.get(clave(s)))
df = df.dropna(subset=["categoria"])
X_tr, X_te, y_tr, y_te = train_test_split(df["texto"], df["categoria"], test_size=0.3,
stratify=df["categoria"], random_state=7)
vec = TfidfVectorizer(max_features=3000, ngram_range=(1, 2), min_df=2, sublinear_tf=True)
modelo = LogisticRegression(max_iter=1000, C=5).fit(vec.fit_transform(X_tr), y_tr)
p_fraude = modelo.predict_proba(vec.transform(X_te))[:, list(modelo.classes_).index("Fraude")]
es_fraude = (y_te == "Fraude").to_numpy()
COSTO_FN = 400_000 # fraude que se nos escapa (devolución, multa, reputación)
COSTO_FP = 20_000 # falsa alarma: revisión manual del equipo antifraude
filas = []
for umbral in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7]:
marcado = p_fraude >= umbral
fn = int((es_fraude & ~marcado).sum())
fp = int((~es_fraude & marcado).sum())
filas.append({"umbral": umbral, "fraudes no detectados": fn, "falsas alarmas": fp,
"costo total ($)": fn * COSTO_FN + fp * COSTO_FP})
tabla = pd.DataFrame(filas)
print("Umbral de menor costo:", tabla.loc[tabla["costo total ($)"].idxmin(), "umbral"])
print("Reclamos con probabilidad de fraude 'dudosa' (entre 0,2 y 0,6):",
int(((p_fraude > 0.2) & (p_fraude < 0.6)).sum()), "de", len(p_fraude))
# Los "fraudes" que el modelo no ve ni con umbral 0,1: ¿son fraude de verdad?
print("\nReclamos etiquetados como Fraude con probabilidad < 0,1:")
for t in X_te[es_fraude & (p_fraude < 0.1)]:
print(" ·", t)
tabla
¿Te sorprende que el umbral de menor costo quede en 0,5 y no más abajo? En esta muestra el modelo separa el fraude tan bien que casi ningún reclamo cae en la zona dudosa: los fraudes reales tienen probabilidad alta, así que bajar el umbral solo suma falsas alarmas. En el simulador, en cambio, los puntajes de fraudes y no fraudes se traslapan, y ahí mover el umbral sí paga.
¿Y los "fraudes" que el modelo no detecta ni con umbral 0,1? Léelos: ¿de verdad son fraudes? Son ruido de etiqueta (reclamos mal etiquetados por una persona). Ningún umbral ni ninguna red los arregla: el techo de tu F1 lo pone la calidad de las etiquetas.
🎯 Reto: cambia COSTO_FP a 150_000 (por ejemplo, porque la falsa alarma bloquea la tarjeta del cliente).
¿Se mueve el umbral óptimo? Haz lo mismo en el simulador (falsa alarma = $150 mil) y explica en una frase, como se lo
dirías a la gerenta de riesgo, por qué en un caso se mueve y en el otro no.
💼 En el negocio
Presenta los errores en pesos y en clientes, no solo en F1. "El modelo deja pasar 3 de cada 100 fraudes, lo que estimamos en $X al mes; bajando el umbral a 0,3 dejamos pasar 1 de cada 100 a cambio de 40 revisiones manuales más por semana" es una frase que un comité puede decidir. Esa traducción es lo que se evalúa en la presentación final.
✍️ Practica · Tema 6
Si un fraude no detectado cuesta 20 veces más que una falsa alarma, ¿hacia dónde mueves el umbral para marcar “Fraude”?
✅ Cuando el falso negativo es mucho más caro, conviene sacrificar precisión para ganar recall en Fraude. Compruébalo en el simulador con el botón 🎯.
En la matriz de confusión de tu modelo, 6 reclamos de “Fraude” quedaron como “Información y trámites” y 6 de “Información y trámites” quedaron como “Fraude”. ¿Qué le dices al gerente?
✅ Misma cantidad, costo muy distinto. Leer la matriz con ojos de negocio es separar el error molesto del error caro y proponer una acción.
🧑💻 Notebook de Colab
🧑💻 Notebook de la clase: carga el caso completo (FinTrust o MercaSur, ~6.000 textos), audita y limpia los datos, hace el EDA de texto, entrena el baseline TF-IDF + regresión logística y una red neuronal en Keras con dropout y early stopping, y compara ambos en test con F1 macro y matriz de confusión. Toma ~45 minutos leyéndolo con calma (el entrenamiento corre en menos de 2 minutos). Es el esqueleto de tu Sprint 1.
Antes de abrirlo
En Colab, usa Archivo → Guardar una copia en Drive para no perder tus cambios. En la primera celda de código
elige tu caso: CASO = "fintrust" o CASO = "mercasur". No necesitas GPU ni clave de API para esta clase.
🔗 Conexión con tu proyecto (Sprint 1)
📬 Sprint 1 · Datos y primeros modelos domingo 8 de noviembre, 23:59 10 %
Antes, el domingo 1 de noviembre se inscribe el equipo y el caso por buzón (sin nota). Para el Sprint 1, tu notebook debe incluir:
- Pregunta de negocio en 2–3 líneas: qué decisión apoya el clasificador y quién lo usaría (p. ej. "derivar automáticamente cada reclamo al equipo correcto en menos de un minuto").
- Auditoría de calidad con números: duplicados, nulos, textos vacíos o basura, categorías mal escritas y valores imposibles (montos negativos, días o ratings fuera de rango). Una tabla resumen "problema → decisión → filas afectadas".
- Función de limpieza reutilizable y el
value_counts()de las clases antes y después. - EDA de texto: distribución de clases, largo de los textos y palabras más frecuentes por clase, con una conclusión breve de cada gráfico.
- Baseline: TF-IDF + regresión logística, con el vectorizador ajustado solo en entrenamiento.
- Red neuronal MLP en Keras sobre TF-IDF, con dropout, early stopping y sus curvas de entrenamiento (pérdida en entrenamiento y validación).
- Comparación en test: F1 macro,
classification_reporty matriz de confusión de ambos modelos. - Lectura de negocio (3–5 líneas): qué error es el más caro en tu caso y qué modelo recomiendas por ahora.
Cómo se entrega: descarga el notebook (Archivo → Descargar → Descargar .ipynb), nómbralo
S1_FinTrust_Apellido1_Apellido2_Apellido3.ipynb (o S1_MercaSur_…) y súbelo al
buzón del Sprint 1. Basta con que un integrante lo entregue. El buzón acepta dos días más
con −1,0 punto por día de atraso. Detalles y rúbrica en Sprints y buzón.
Para la tutoría del jueves 5 de noviembre
Lleva el notebook con la auditoría y el baseline funcionando, y una pregunta concreta (p. ej. "¿cómo tratamos las reseñas con sarcasmo?" o "¿nuestra red sobreajusta?"). Recibirás un semáforo 🟢🟡🔴 de avance.
🧠 Resumen
| Concepto | La idea en una línea |
|---|---|
| Neurona | Suma ponderada de las entradas + sesgo, pasada por una activación. Con sigmoide = regresión logística. |
| Activación | La no linealidad (ReLU, tanh) que permite fronteras curvas; softmax en la salida multiclase. |
| Red multicapa (MLP) | Capas ocultas que combinan muchas "rectas" en fronteras de cualquier forma. |
| Entropía cruzada | −log(probabilidad de la clase correcta): castiga mucho equivocarse con seguridad. |
| Descenso de gradiente | Corregir los pesos dando pasos cuesta abajo en la pérdida. |
| Tasa de aprendizaje | El largo del paso: muy chica = lento; muy grande = rebota o diverge. |
| Época / batch | Una pasada completa por los datos / el grupo de ejemplos con que se da cada paso. |
| Sobreajuste | Validación empeora mientras entrenamiento mejora: la red memoriza. |
| Early stopping / dropout | Parar en la mejor época / apagar neuronas al azar al entrenar. |
| Auditoría y limpieza | Duplicados, textos basura y categorías mal escritas se corrigen antes de modelar. |
| TF-IDF | Peso alto a palabras frecuentes en el texto y raras en el resto. |
| F1 macro | Promedio simple del F1 de cada clase: las clases chicas importan igual. |
| Matriz de confusión con ojos de negocio | No todos los errores cuestan lo mismo; el umbral es una palanca de negocio. |
📚 Para profundizar
- 3Blue1Brown · ¿Qué es una red neuronal? (video, con subtítulos en español): la mejor intuición visual de neuronas, capas y pesos. youtube.com/watch?v=aircAruvnKk
- TensorFlow Playground: el simulador del Tema 2 en versión extendida; prueba capas, activaciones, tasa de aprendizaje y regularización. playground.tensorflow.org
- Google · Machine Learning Crash Course (módulos de redes neuronales y clasificación): developers.google.com/machine-learning/crash-course
- scikit-learn · Extracción de características de texto (bolsa de palabras y TF-IDF, con ejemplos): scikit-learn.org/stable/modules/feature_extraction.html