Antes de empezar
Varios ejercicios usan las variables y funciones de la teoría: X_entr, X_val, y_entr, y_val (las dos lunas), distancias, knn_predecir, sigmoide, entropia_cruzada, softmax, y los datos de los tumores (Xc_entr, Xc_val, yc_entr, yc_val y sus versiones estandarizadas Xc_entr_e y Xc_val_e). Abre un cuaderno nuevo en Colab, pega y ejecuta en orden los bloques de la ficha Código y después trabaja debajo. Intenta cada ejercicio antes de abrir la respuesta.
Ejercicios
1. ¿Clasificación o regresión?
Para cada situación, di si es un problema de regresión (predecir un número) o de clasificación, y en ese caso si es binaria o multiclase:
- Predecir cuántos milímetros va a llover mañana.
- Decidir si un mensaje es basura o no.
- Reconocer qué letra del alfabeto está escrita en una imagen.
- Predecir si un estudiante va a aprobar un examen.
- Predecir la nota que va a sacar ese estudiante.
Respuesta
- Regresión: la respuesta es una cantidad.
- Clasificación binaria: basura o no.
- Clasificación multiclase: una clase por letra.
- Clasificación binaria: aprueba o no.
- Regresión, como en el tema 6. Fíjate en que 4 y 5 hablan de lo mismo: muchas veces se puede plantear un problema de las dos formas, según qué respuesta sirve más.
2. Vecinos a mano
Seis ejemplos de entrenamiento con dos características:
| Punto | x₁ | x₂ | Clase |
|---|---|---|---|
| A | 1 | 1 | 1 |
| B | 2 | 1 | 0 |
| C | 4 | 3 | 1 |
| D | 5 | 4 | 1 |
| E | 3 | 2,5 | 1 |
| F | 0,5 | 3 | 0 |
a) Calcula sin computadora la distancia euclidiana de cada uno al punto nuevo (2,5; 1,5).
b) ¿Qué clase predice k-NN con k = 1, con k = 3 y con k = 5?
c) Compruébalo con distancias y knn_predecir de la teoría.
Respuesta
a) Por ejemplo, para B: √((2 − 2,5)² + (1 − 1,5)²) = √(0,25 + 0,25) = √0,5 ≈ 0,71. Ordenadas de menor a mayor: B 0,71; E 1,12; A 1,58; C 2,12; F 2,50; D 3,54.
b) Con k = 1, el más cercano es B: clase 0. Con k = 3 votan B, E y A: un voto para la clase 0 y dos para la 1, así que clase 1. Con k = 5 se suman C (1) y F (0): tres contra dos, clase 1. El mismo punto cambia de clase según cuántos vecinos se miran.
c)
P = np.array([[1, 1], [2, 1], [4, 3], [5, 4], [3, 2.5], [0.5, 3]])
clases = np.array([1, 0, 1, 1, 1, 0])
nuevo = np.array([2.5, 1.5])
print("Distancias:", np.round(distancias(P, nuevo), 2))
for k in [1, 3, 5]:
print(f"k = {k}:", knn_predecir(P, clases, [nuevo], k))
Distancias: [1.58 0.71 2.12 3.54 1.12 2.5 ]
k = 1: [0]
k = 3: [1]
k = 5: [1]
3. El gradiente de la entropía cruzada (guiado)
Vamos a deducir, para un solo ejemplo, el gradiente que usa descenso_logistica. La pérdida de un ejemplo es L = −[y log p + (1 − y) log(1 − p)], con p = σ(z) y z = w·x + b.
a) La derivada de la sigmoide. Escribe σ(z) = (1 + e−z)−1 y usa la regla de la cadena para mostrar que σ′(z) = σ(z) · (1 − σ(z)). Pista: al final, escribe e−z / (1 + e−z) como 1 − σ(z).
b) La derivada de L respecto de p. Recuerda que la derivada de log p es 1/p.
c) Junta a) y b) con la regla de la cadena para obtener ∂L/∂z. Simplifica todo lo que puedas.
d) Como z = w·x + b, ¿cuánto valen ∂L/∂w y ∂L/∂b?
e) Comprueba la fórmula con la derivada numérica del tema 5, sobre las lunas y en w = (0,5; −1), b = 0,2.
Respuesta
a) La derivada de u−1 es −u−2, y la de u = 1 + e−z es −e−z. Entonces
σ′(z) = −(1 + e−z)−2 · (−e−z) = [1 / (1 + e−z)] · [e−z / (1 + e−z)] = σ(z) · (1 − σ(z)),
porque e−z / (1 + e−z) = 1 − 1 / (1 + e−z).
b) ∂L/∂p = −y/p + (1 − y)/(1 − p). (El segundo signo es + porque la derivada de log(1 − p) es −1/(1 − p).)
c) ∂L/∂z = ∂L/∂p · σ′(z) = [−y/p + (1 − y)/(1 − p)] · p(1 − p) = −y(1 − p) + (1 − y)p = p − y. Todo se cancela y queda «predicho menos real».
d) ∂z/∂w = x y ∂z/∂b = 1, así que ∂L/∂w = (p − y) · x y ∂L/∂b = p − y. Con n ejemplos se promedian: es justo lo que hace descenso_logistica.
e)
def perdida_en(w, b):
"""Entropía cruzada de la logística (w, b) en las lunas."""
return entropia_cruzada(y_entr, sigmoide(X_entr @ w + b))
w, b = np.array([0.5, -1.0]), 0.2
error = sigmoide(X_entr @ w + b) - y_entr
formula = np.append(X_entr.T @ error / len(y_entr), np.mean(error))
h = 1e-6
numerica = []
for j in range(2):
paso = np.zeros(2)
paso[j] = h
numerica.append((perdida_en(w + paso, b)
- perdida_en(w - paso, b)) / (2 * h))
numerica.append((perdida_en(w, b + h) - perdida_en(w, b - h)) / (2 * h))
print("Con la fórmula: ", np.round(formula, 5))
print("Derivada numérica:", np.round(numerica, 5))
Con la fórmula: [-0.09438 0.09365 0.02684]
Derivada numérica: [-0.09438 0.09365 0.02684]
4. k-NN sin escalar y escalado
Con los datos de los tumores de la teoría:
a) Entrena KNeighborsClassifier(n_neighbors=5) con las medidas sin estandarizar (Xc_entr) y mide los aciertos en validación. Repite con las estandarizadas (Xc_entr_e, Xc_val_e).
b) Mira la desviación estándar de cada columna en el entrenamiento. ¿Cuál es la más grande y cuál la más chica? ¿Qué explica eso?
Respuesta
for nombre, A_entr, A_val in [("sin escalar", Xc_entr, Xc_val),
("escalado ", Xc_entr_e, Xc_val_e)]:
modelo = KNeighborsClassifier(n_neighbors=5).fit(A_entr, yc_entr)
print(f"{nombre}: aciertos en validación "
f"{modelo.score(A_val, yc_val):.3f}")
desv_col = Xc_entr.std(axis=0)
nombres = cancer.feature_names
print(f"Más grande: {nombres[desv_col.argmax()]}"
f" ({desv_col.max():.1f})")
print(f"Más chica: {nombres[desv_col.argmin()]}"
f" ({desv_col.min():.4f})")
sin escalar: aciertos en validación 0.947
escalado : aciertos en validación 0.959
Más grande: worst area (597.1)
Más chica: fractal dimension error (0.0026)
Escalar sube los aciertos de 94,7 % a 95,9 %. Sin escalar, las columnas de área (en unidades de cientos o miles) dominan la distancia: una diferencia de 100 en el área pesa millones de veces más que cualquier diferencia en fractal dimension error, que se mueve en milésimas. k-NN termina mirando casi solo el tamaño del tumor y desperdicia las otras 27 medidas. Estandarizadas, todas pesan lo mismo al medir el parecido.
5. Leer la sigmoide
Una logística que predice si un estudiante aprueba según sus horas de estudio tiene w = 1,2 y b = −4,8.
a) ¿Qué probabilidad de aprobar da para 2, 4 y 6 horas?
b) ¿Con cuántas horas la probabilidad es exactamente 0,5? Resuélvelo sin computadora.
c) ¿Con cuántas horas llega a 0,9? Pista: usa el log-odds.
Respuesta
b) La probabilidad es 0,5 cuando z = 0: 1,2 · h − 4,8 = 0, así que h = 4 horas. Esa es la frontera de decisión: con una sola característica, la «recta» es un punto.
c) Con p = 0,9 las chances son 0,9 / 0,1 = 9, así que z = log 9 ≈ 2,197. Despejando, h = (2,197 + 4,8) / 1,2 ≈ 5,83 horas.
w, b = 1.2, -4.8
for h in [2, 4, 6]:
print(f"{h} horas: probabilidad {sigmoide(w * h + b):.3f}")
h_90 = (np.log(0.9 / 0.1) - b) / w
print(f"Para 0.9 hacen falta {h_90:.2f} horas;"
f" comprobación: {sigmoide(w * h_90 + b):.3f}")
2 horas: probabilidad 0.083
4 horas: probabilidad 0.500
6 horas: probabilidad 0.917
Para 0.9 hacen falta 5.83 horas; comprobación: 0.900
a) 8,3 %, 50 % y 91,7 %. Cada hora más suma 1,2 al log-odds: las chances se multiplican por e1,2 ≈ 3,3.
6. ¿Por qué no el MSE?
Imagina un solo ejemplo de la clase 1 (y = 1) y un modelo cuyo puntaje z va desde muy equivocado (−6) hasta acertado (2). Con el MSE, la pérdida sería (p − y)² con p = σ(z), y su derivada respecto de z es 2(p − y) · p(1 − p). Con la entropía cruzada, es p − y (ejercicio 3). Calcula las dos para z = −6, −4, −2, 0 y 2. ¿Cuál empuja más fuerte al modelo cuando más se equivoca?
Respuesta
for z in [-6, -4, -2, 0, 2]:
p = sigmoide(z)
grad_mse = 2 * (p - 1) * p * (1 - p)
grad_ec = p - 1
print(f"z = {z:2d} p = {p:.3f} MSE: {grad_mse:7.4f}"
f" entropía cruzada: {grad_ec:7.4f}")
z = -6 p = 0.002 MSE: -0.0049 entropía cruzada: -0.9975
z = -4 p = 0.018 MSE: -0.0347 entropía cruzada: -0.9820
z = -2 p = 0.119 MSE: -0.1850 entropía cruzada: -0.8808
z = 0 p = 0.500 MSE: -0.2500 entropía cruzada: -0.5000
z = 2 p = 0.881 MSE: -0.0250 entropía cruzada: -0.1192
Con z = −6 el modelo le da 0,2 % a la clase correcta: está muy equivocado. La entropía cruzada lo empuja con fuerza casi −1, la mayor posible. El MSE, en cambio, lo empuja con −0,005, doscientas veces menos: la sigmoide está en su parte plana y el factor p(1 − p) es casi cero. Con el MSE, un modelo muy equivocado aprende muy despacio; con la entropía cruzada, cuanto más se equivoca, más fuerte corrige.
7. Jugar con softmax
Con los puntajes [3; 1; 0,2], calcula las probabilidades de softmax. Después suma 10 a los tres puntajes, multiplícalos por 2 y multiplícalos por 0,5. ¿Qué cambia y qué no?
Respuesta
z = np.array([3.0, 1.0, 0.2])
for nombre, puntajes in [("originales", z), ("más 10 ", z + 10),
("por 2 ", z * 2), ("por 0.5 ", z * 0.5)]:
print(nombre, np.round(softmax(puntajes), 3))
originales [0.836 0.113 0.051]
más 10 [0.836 0.113 0.051]
por 2 [0.978 0.018 0.004]
por 0.5 [0.619 0.228 0.153]
- Sumar lo mismo a todos no cambia nada: softmax solo mira las diferencias entre puntajes. Por eso la función de la teoría puede restar el máximo sin cambiar el resultado.
- Multiplicar por 2 agranda las diferencias y el modelo queda más seguro de la primera clase (97,8 %).
- Multiplicar por 0,5 las achica y las probabilidades se acercan entre sí: el modelo duda más. El orden de las clases nunca cambia.
8. Elegir k con validación
Con las dos lunas, prueba todos los k impares de 1 a 59 y quédate con el que da más aciertos en validación. ¿Cuál es? ¿Es honesto reportar esos aciertos como lo que el modelo logrará con datos nuevos?
Respuesta
ks = list(range(1, 60, 2))
aciertos_val = []
for k in ks:
modelo = KNeighborsClassifier(n_neighbors=k).fit(X_entr, y_entr)
aciertos_val.append(modelo.score(X_val, y_val))
mejor = int(np.argmax(aciertos_val))
print("Mejor k:", ks[mejor], " aciertos en validación:",
round(aciertos_val[mejor], 3))
print("Cuántos k empatan con el mejor:",
sum(a == aciertos_val[mejor] for a in aciertos_val))
Mejor k: 7 aciertos en validación: 0.95
Cuántos k empatan con el mejor: 5
El mejor es k = 7, con el 95 %, pero otros cuatro valores empatan: con solo 60 puntos de validación, la diferencia entre muchos k es de uno o dos puntos acertados más o menos. Y ese 95 % es optimista: elegimos k justamente porque le fue bien en esos 60 puntos. Como viste en el tema 7, para tener una estimación honesta hace falta un conjunto de prueba que no se usó para elegir nada (o validación cruzada).
Mini-proyecto: un clasificador de dígitos
Vas a construir dos clasificadores de dígitos escritos a mano, uno con regresión logística y otro con k-NN, y a compararlos en aciertos y en tiempo.
Los datos. load_digits de scikit-learn (sin descarga): 1797 imágenes de 8 × 8 píxeles, del 0 al 9. Usa la misma división que la teoría: Xd_entr, Xd_val, yd_entr, yd_val, con los píxeles divididos por 16.
Pasos.
- Mira los datos. Dibuja diez imágenes, una de cada dígito, con
plt.imshow(..., cmap=blanco_tinta). Cuenta cuántas imágenes hay de cada dígito connp.bincount. - Logística. Entrena
LogisticRegression(max_iter=1000)y mide los aciertos en validación. Usapredict_probapara encontrar el dígito de validación en el que el modelo está menos seguro de su respuesta y dibújalo. - k-NN. Entrena
KNeighborsClassifiercon k = 1, 3, 5 y 7 y mide los aciertos en validación. Elige un k. - Tiempos. Mide por separado el tiempo de entrenar y el de predecir todo el conjunto de validación, para los dos modelos. En Colab existe
%timeit, pero aquí usatime.perf_counter(): guarda la hora antes y después y resta. - Errores. Para el mejor de los dos modelos, cuenta qué pares (dígito real, dígito predicho) se confunden más.
- Concluye. Escribe tres conclusiones de una o dos frases. Por ejemplo: ¿cuál acierta más? ¿Cuál entrena más rápido y cuál predice más rápido, y por qué? ¿Cuál usarías en un teléfono que tiene que reconocer miles de dígitos por segundo?
Resultados de referencia, para comprobar los tuyos
Este código hace los pasos 2 a 5 sin gráficos. Los aciertos deberían coincidir con los tuyos; los tiempos dependen de la computadora, así que solo se imprime cuál es más rápido.
import time
from collections import Counter
print("Imágenes de cada dígito:", np.bincount(yd))
# Paso 2: logística
t0 = time.perf_counter()
log_d = LogisticRegression(max_iter=1000).fit(Xd_entr, yd_entr)
t1 = time.perf_counter()
pred_log = log_d.predict(Xd_val)
t2 = time.perf_counter()
print("Logística, aciertos:", round(np.mean(pred_log == yd_val), 3))
duda = log_d.predict_proba(Xd_val).max(axis=1)
i = duda.argmin()
print(f"Menos segura: es {yd_val[i]}, dice {pred_log[i]},"
f" con probabilidad {duda[i]:.2f}")
# Paso 3: k-NN con varios k
for k in [1, 3, 5, 7]:
modelo = KNeighborsClassifier(n_neighbors=k).fit(Xd_entr, yd_entr)
print(f"k-NN con k = {k}: aciertos "
f"{modelo.score(Xd_val, yd_val):.3f}")
# Paso 4: tiempos con k = 3
t3 = time.perf_counter()
knn_d = KNeighborsClassifier(n_neighbors=3).fit(Xd_entr, yd_entr)
t4 = time.perf_counter()
pred_knn = knn_d.predict(Xd_val)
t5 = time.perf_counter()
print("¿k-NN entrena más rápido?", (t4 - t3) < (t1 - t0))
print("¿La logística predice más rápido?", (t2 - t1) < (t5 - t4))
# Paso 5: las confusiones más frecuentes de k-NN
fallos = pred_knn != yd_val
# tolist() convierte a enteros comunes de Python, que se imprimen limpios
pares = Counter(zip(yd_val[fallos].tolist(), pred_knn[fallos].tolist()))
print("Errores de k-NN:", fallos.sum(),
" (real, predicho) más comunes:", pares.most_common(2))
Imágenes de cada dígito: [178 182 177 183 181 182 181 179 174 180]
Logística, aciertos: 0.961
Menos segura: es 1, dice 8, con probabilidad 0.26
k-NN con k = 1: aciertos 0.989
k-NN con k = 3: aciertos 0.987
k-NN con k = 5: aciertos 0.981
k-NN con k = 7: aciertos 0.980
¿k-NN entrena más rápido? True
¿La logística predice más rápido? True
Errores de k-NN: 7 (real, predicho) más comunes: [((8, 3), 2), ((5, 9), 1)]
- Aciertos. k-NN acierta más que la logística en estos dígitos: 98,7 % con k = 3 (98,9 % con k = 1) contra 96,1 %. Las imágenes del mismo dígito se parecen mucho píxel a píxel, que es justo lo que k-NN aprovecha; la logística solo puede trazar fronteras lineales entre las clases.
- Tiempos. k-NN «entrena» casi instantáneamente porque solo guarda los datos, pero al predecir mide la distancia a las 1257 imágenes de entrenamiento. La logística tarda más en entrenar y luego predice con una sola multiplicación de matrices. Para miles de predicciones por segundo en un teléfono, conviene la logística (o un modelo con pesos), aunque acierte un poco menos.
- Errores. k-NN se equivoca en 7 de 540 imágenes; la confusión más repetida es un 8 que confunde con un 3, que comparten las curvas de la derecha. La imagen en la que la logística está menos segura es un 1 que el modelo llama 8 con apenas 26 % de probabilidad.
Errores comunes
- No escalar antes de k-NN. La característica con números más grandes manda en la distancia. Estandariza con la media y la desviación del entrenamiento y aplica esas mismas a la validación.
- Elegir k (o cualquier hiperparámetro) mirando los aciertos al entrenar. Con k = 1 siempre da 100 %. Se elige con validación.
- Usar un k par con dos clases. La votación puede empatar.
- Leer la regresión logística como una regresión. A pesar del nombre, predice una clase (y su probabilidad), no un número cualquiera.
- Entrenar la logística con el MSE. Con la sigmoide, el MSE aprende muy despacio cuando el modelo está muy equivocado. Usa la entropía cruzada.
- Calcular
np.log(0). Si una probabilidad llega a 0 o a 1, el logaritmo da-infy la pérdida,nan. Por esoentropia_cruzadarecorta las probabilidades connp.clip. - Olvidar que
LogisticRegressionregulariza por defecto. Sus pesos salen más chicos que los de un descenso sin regularización. Para comparar, usaC=np.inf. - Confundir las columnas de
predict_proba. Van en el orden demodelo.classes_. En los tumores, la columna 0 es «maligno» porque esa clase es el 0. - Tomar el umbral 0,5 como obligatorio. Si los dos errores no cuestan lo mismo, se elige otro umbral.
- Esperar que una recta separe cualquier cosa. Si las clases forman lunas, anillos o islas, la frontera lineal de la logística no alcanza.
¿Lo lograste?
Al terminar este tema deberías poder hacer lo siguiente. Al lado, dónde lo practicaste.
- Explicar k-NN y el efecto de k (ejercicios 2 y 8, paso 3 del mini-proyecto).
- Explicar la función sigmoide y la frontera de decisión de la regresión logística (ejercicio 5).
- Programar desde cero la regresión logística con entropía cruzada y descenso de gradiente (ejercicios 3 y 6).
- Extenderla a varias clases con softmax (ejercicio 7 y paso 2 del mini-proyecto).
- Usar
KNeighborsClassifieryLogisticRegression(ejercicio 4 y pasos 2 a 4 del mini-proyecto).