Clasificar: vecinos cercanos y regresión logística

Tema 8 · Fase 2, Aprender de ejemplos · Toda la ruta

Cuaderno del tema. La teoría y los ejercicios con todo su código, listos para ejecutar en Colab sin instalar nada.

Los 16 bloques de código de la teoría, en el mismo orden. Si prefieres armar tu propio cuaderno, cópialos y córrelos en orden; en la ficha Teoría está la explicación de cada uno.

Clasificar es predecir una categoría

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import make_moons
from sklearn.model_selection import train_test_split

# 200 puntos sintéticos en dos clases con forma de media luna
X, y = make_moons(n_samples=200, noise=0.3, random_state=0)
# Como en el tema 7: 70 % para entrenar y 30 % para validar
X_entr, X_val, y_entr, y_val = train_test_split(
    X, y, test_size=0.3, random_state=0)

print("Forma de X:", X.shape)
print("Primeras etiquetas:", y[:10])
print("Entrenamiento:", len(y_entr), "  validación:", len(y_val))
print("Puntos de cada clase al entrenar:", np.bincount(y_entr))

def dibujar_clases(X, y, ax=None, s=30):
    """Clase 0: círculos de color C0. Clase 1: cuadrados de color C1."""
    # Si no te pasan un eje (ax), usa el del gráfico actual
    ax = ax or plt.gca()
    ax.scatter(X[y == 0, 0], X[y == 0, 1], s=s, color="C0",
               marker="o", label="Clase 0")
    ax.scatter(X[y == 1, 0], X[y == 1, 1], s=s, color="C1",
               marker="s", label="Clase 1")

dibujar_clases(X_entr, y_entr)
# "$x_1$" escribe x con el subíndice 1
plt.xlabel("$x_1$")
plt.ylabel("$x_2$")
plt.title("Dos medias lunas (datos sintéticos)")
# La leyenda va debajo del gráfico, para no tapar puntos
plt.legend(loc="upper center", bbox_to_anchor=(0.5, -0.25), ncol=2,
           frameon=False, handlelength=1.6, columnspacing=1.2)
plt.show()

Vecinos cercanos: dime con quién andas

def distancias(X_entr, x):
    """Distancia euclidiana de x a cada fila de X_entr."""
    return np.sqrt(np.sum((X_entr - x) ** 2, axis=1))

def knn_predecir(X_entr, y_entr, X_nuevos, k):
    """Para cada fila de X_nuevos, la clase que más se repite
    entre sus k vecinos más cercanos de X_entr."""
    predicciones = []
    for x in X_nuevos:
        d = distancias(X_entr, x)
        vecinos = np.argsort(d)[:k]   # índices de los k más cercanos
        votos = np.bincount(y_entr[vecinos])
        predicciones.append(np.argmax(votos))
    return np.array(predicciones)

nuevo = np.array([0.5, 0.25])
d = distancias(X_entr, nuevo)
cercanos = np.argsort(d)[:5]
print("Distancias de los 5 vecinos:", np.round(d[cercanos], 2))
print("Sus clases:", y_entr[cercanos])
print("Predicción con k = 5:",
      knn_predecir(X_entr, y_entr, [nuevo], k=5))
from sklearn.neighbors import KNeighborsClassifier

pred_val = knn_predecir(X_entr, y_entr, X_val, k=5)
print("Aciertos en validación (nuestro):", np.mean(pred_val == y_val))

knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_entr, y_entr)               # solo guarda los ejemplos
print("Aciertos en validación (scikit-learn):",
      knn.score(X_val, y_val))
print("¿Las mismas predicciones?",
      np.all(knn.predict(X_val) == pred_val))
# Una cuadrícula de 200 × 200 puntos que cubre el dibujo
A, B = np.meshgrid(np.linspace(-2, 3, 200), np.linspace(-1.5, 2, 200))
rejilla = np.column_stack([A.ravel(), B.ravel()])   # 40 000 filas

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8),
                         sharex=True, sharey=True)
for eje, k in zip(ejes.flat, [1, 5, 25, 75]):
    modelo_k = KNeighborsClassifier(n_neighbors=k).fit(X_entr, y_entr)
    zona = modelo_k.predict(rejilla).reshape(A.shape)
    # Cada región, del color de su clase y muy clara
    eje.contourf(A, B, zona, levels=[-0.5, 0.5, 1.5],
                 colors=["C0", "C1"], alpha=0.2)
    dibujar_clases(X_entr, y_entr, ax=eje, s=8)
    eje.set_title(f"k = {k}")
    print(f"k = {k:2d}:  aciertos al entrenar "
          f"{modelo_k.score(X_entr, y_entr):.2f}"
          f"   en validación {modelo_k.score(X_val, y_val):.2f}")
plt.show()
rng = np.random.default_rng(0)
for dim in [2, 10, 100, 1000]:
    puntos = rng.uniform(0, 1, size=(500, dim))
    dist = distancias(puntos[1:], puntos[0])
    print(f"{dim:4d} dimensiones:  más cercano {dist.min():6.2f}"
          f"   más lejano {dist.max():6.2f}"
          f"   cociente {dist.min() / dist.max():.2f}")

Regresión logística: una probabilidad en lugar de un voto

def sigmoide(z):
    """Aplasta cualquier número a un valor entre 0 y 1."""
    return 1 / (1 + np.exp(-z))

for z in [-6, -2, 0, 2, 6]:
    print(f"z = {z:2d}   ->   sigmoide(z) = {sigmoide(z):.3f}")

zs = np.linspace(-8, 8, 200)
plt.plot(zs, sigmoide(zs), color="C0")
plt.axhline(0.5, color="C2", linestyle="--", linewidth=1.5)
plt.scatter([0], [0.5], color="C1", s=120, zorder=3,
            label="z = 0 da 0,5")
plt.xlabel("Puntaje z")
plt.ylabel("Probabilidad de clase 1")
plt.title("La sigmoide")
plt.legend(loc="upper left")
plt.show()
z = 1.5
p = sigmoide(z)
chances = p / (1 - p)
print(f"p = {p:.3f}   chances = {chances:.3f}"
      f"   log(chances) = {np.log(chances):.3f}")

Medir el error: la entropía cruzada

p_correcta = np.array([0.99, 0.9, 0.5, 0.1, 0.01])
print("Prob. dada a la clase correcta:", p_correcta)
print("Entropía cruzada, -log(p):     ",
      np.round(-np.log(p_correcta), 2))
print("Error al cuadrado, (1 - p)²:   ",
      np.round((1 - p_correcta) ** 2, 2))

ps = np.linspace(0.005, 1, 300)
plt.plot(ps, -np.log(ps), color="C1", label="Entropía cruzada")
plt.plot(ps, (1 - ps) ** 2, color="C2", linestyle="--",
         label="Error al cuadrado")
plt.ylim(0, 5)
plt.xlabel("Probabilidad dada a la clase correcta")
plt.ylabel("Pérdida")
plt.title("Castigo según la confianza")
plt.legend()
plt.show()

Programar la logística desde cero

def entropia_cruzada(y, p):
    """Entropía cruzada binaria promedio."""
    # Un recorte mínimo evita log(0) si alguna p llega a 0 o a 1
    p = np.clip(p, 1e-12, 1 - 1e-12)
    return -np.mean(y * np.log(p) + (1 - y) * np.log(1 - p))

def descenso_logistica(X, y, tasa, pasos):
    """Regresión logística con descenso de gradiente.

    Devuelve los pesos w, el término independiente b y la pérdida
    antes de cada paso.
    """
    n, d = X.shape
    w = np.zeros(d)                   # un peso por característica
    b = 0.0
    historial = []
    for paso in range(pasos):
        p = sigmoide(X @ w + b)       # probabilidad de la clase 1
        historial.append(entropia_cruzada(y, p))
        error = p - y                 # predicho menos real
        w = w - tasa * X.T @ error / n
        b = b - tasa * np.mean(error)
    return w, b, np.array(historial)

w_log, b_log, perdidas = descenso_logistica(X_entr, y_entr,
                                            tasa=0.5, pasos=3000)
for paso in [0, 10, 100, 1000, 2999]:
    print(f"Paso {paso:4d}:   entropía cruzada = {perdidas[paso]:.4f}")
print("w =", np.round(w_log, 3), "  b =", round(b_log, 3))
from sklearn.linear_model import LogisticRegression

def predecir_clase(X, w, b, umbral=0.5):
    """Clase 1 si la probabilidad llega al umbral; si no, clase 0."""
    return (sigmoide(X @ w + b) >= umbral).astype(int)

# C=np.inf: sin regularización, para comparar con nuestro descenso
logistica = LogisticRegression(C=np.inf).fit(X_entr, y_entr)
print("scikit-learn:  w =", np.round(logistica.coef_[0], 3),
      "  b =", np.round(logistica.intercept_[0], 3))
aciertos = np.mean(predecir_clase(X_val, w_log, b_log) == y_val)
print("Aciertos en validación (nuestro):", round(aciertos, 3))
print("Aciertos en validación (scikit-learn):",
      round(logistica.score(X_val, y_val), 3))
from matplotlib.colors import LinearSegmentedColormap

blanco_tinta = LinearSegmentedColormap.from_list("bt", ["white", "C0"])
prob = sigmoide(rejilla @ w_log + b_log).reshape(A.shape)

bandas = plt.contourf(A, B, prob, levels=[0, 0.1, 0.3, 0.5, 0.7, 0.9, 1],
                      cmap=blanco_tinta, alpha=0.45)
plt.contour(A, B, prob, levels=[0.5], colors="C0", linewidths=2.6)
dibujar_clases(X_entr, y_entr, s=14)
plt.colorbar(bandas, label="Prob. de clase 1")
plt.xlabel("$x_1$")
plt.ylabel("$x_2$")
plt.title("Frontera de la regresión logística")
plt.show()

Varias clases: softmax

def softmax(z):
    """Convierte un puntaje por clase en probabilidades que suman 1."""
    # Restar el máximo no cambia el resultado y evita
    # que np.exp desborde con puntajes grandes
    e = np.exp(z - np.max(z))
    return e / e.sum()

puntajes = np.array([2.0, 1.0, 0.1])
probs = softmax(puntajes)
print("Probabilidades:", np.round(probs, 3), "  suman", probs.sum())
print("Entropía cruzada si la correcta es la primera:",
      round(-np.log(probs[0]), 3))
# Con dos clases, softmax es la sigmoide de la diferencia
print(round(softmax(np.array([1.5, 0.0]))[0], 6),
      round(sigmoide(1.5), 6))
from sklearn.datasets import load_digits

digitos = load_digits()
print("Imágenes:", digitos.images.shape)
Xd = digitos.data / 16                # 64 píxeles entre 0 y 1
yd = digitos.target                   # el dígito, de 0 a 9
Xd_entr, Xd_val, yd_entr, yd_val = train_test_split(
    Xd, yd, test_size=0.3, random_state=0)

log_digitos = LogisticRegression(max_iter=1000).fit(Xd_entr, yd_entr)
print("Aciertos en validación:",
      round(log_digitos.score(Xd_val, yd_val), 3))
proba = log_digitos.predict_proba(Xd_val[:1])
print("Probabilidades del primer dígito, de 0 a 9:")
print(np.round(proba[0], 3))
print("Es un", yd_val[0], " y el modelo dice",
      log_digitos.predict(Xd_val[:1])[0])
pred_digitos = log_digitos.predict(Xd_val)
malos = np.flatnonzero(pred_digitos != yd_val)
print("Mal clasificados:", len(malos), "de", len(yd_val))

fig, ejes = plt.subplots(2, 2, figsize=(4.8, 4.8))
for eje, i in zip(ejes.flat, malos[:4]):
    # blanco_tinta: la escala de blanco a tinta de la figura anterior
    eje.imshow(Xd_val[i].reshape(8, 8), cmap=blanco_tinta)
    p_max = log_digitos.predict_proba(Xd_val[i:i + 1]).max()
    eje.set_title(f"Es {yd_val[i]}, dice {pred_digitos[i]}\n"
                  f"(prob. {p_max:.2f})")
    eje.axis("off")
plt.show()

La probabilidad y el umbral

from sklearn.datasets import load_breast_cancer

cancer = load_breast_cancer()
Xc, yc = cancer.data, cancer.target   # 0 = maligno, 1 = benigno
print("Forma:", Xc.shape, "  clases:", cancer.target_names,
      "  cuántos:", np.bincount(yc))
Xc_entr, Xc_val, yc_entr, yc_val = train_test_split(
    Xc, yc, test_size=0.3, random_state=0)

# Estandarizar con la media y la desviación del ENTRENAMIENTO
media, desv = Xc_entr.mean(axis=0), Xc_entr.std(axis=0)
Xc_entr_e = (Xc_entr - media) / desv
Xc_val_e = (Xc_val - media) / desv

log_cancer = LogisticRegression().fit(Xc_entr_e, yc_entr)
print("Aciertos en validación:",
      round(log_cancer.score(Xc_val_e, yc_val), 3))
# Columna 0 de predict_proba: probabilidad de la clase 0, maligno
p_maligno = log_cancer.predict_proba(Xc_val_e)[:, 0]
print("P(maligno) de los 5 primeros:", np.round(p_maligno[:5], 3))
malignos = yc_val == 0
for umbral in [0.5, 0.2, 0.1]:
    alarma = p_maligno >= umbral
    no_detectados = np.sum(malignos & ~alarma)
    falsas_alarmas = np.sum(~malignos & alarma)
    print(f"Umbral {umbral}:  malignos sin detectar {no_detectados}"
          f"   falsas alarmas {falsas_alarmas}")