Clasificar: vecinos cercanos y regresión logística

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

Los 16 bloques de código de la teoría, en el mismo orden. Cópialos y córrelos en tu Python o en Colab; 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."""
    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)
plt.xlabel("$x_1$")
plt.ylabel("$x_2$")
plt.title("Dos medias lunas (datos sintéticos)")
plt.legend(loc="upper right", frameon=True)
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 sesgo 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}")