Clasificar: vecinos cercanos y regresión logística
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}")¿Terminaste este tema?
Márcalo y en la ruta verás cuánto llevas. Se guarda solo en este navegador.