Clasificar: vecinos cercanos y regresión logística
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}")