Phase 02 - Lesson 03

Regresión logística

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

La regresión logística dobla una línea recta en una curva S para responder preguntas de sí o no con probabilidades.

Tipo: Construcción Idiomas: Python Requisitos previos: Fase 2, Lección 1-2 (¿Qué es ML, regresión lineal)? Tiempo: ~90 minutos

Objetivos de aprendizaje

  • Implementar regresión logística desde cero usando la función sigmoidea y pérdida de entropía cruzada binaria.
  • Calcular e interpretar la precisión, la recuperación, la score F1 y la matriz de confusión para la clasificación binaria.
  • Explicar por qué MSE falla en la clasificación y por qué la entropía cruzada binaria produce una superficie de costos convexa
  • Construir un modelo de regresión softmax para clasificación de clases múltiples y evaluar las compensaciones de ajuste de umbral

El problema

Quiere predecir si un tumor es maligno o benigno dado su tamaño. Prueba la regresión lineal. Genera números como 0,3 o 1,7 o -0,5. ¿Qué significan esos? ¿Es el 1.7 "muy maligno"? ¿Es -0,5 "muy benigno"? La regresión lineal genera números ilimitados. La clasificación necesita probabilidades acotadas entre 0 y 1, y una decisión clara: sí o no.

La regresión logística resuelve esto. Toma la misma combinación lineal (wx + b) y la pasa a través de la función sigmoidea, que aplasta cualquier número en el rango (0, 1). El resultado es una probabilidad. Usted establece un umbral (normalmente 0,5) y toma una decisión.

Este es uno de los algoritmos más utilizados en la práctica. A pesar de su nombre, la regresión logística es un algoritmo de clasificación, no un algoritmo de regresión. El nombre proviene de la función logística (sigmoidea) que utiliza.

El concepto

Por qué la regresión lineal falla en la clasificación

Imagínese predecir aprobado/reprobado (1/0) en función de las horas de estudio. La regresión lineal ajusta una línea a través de los datos:

hours:  1   2   3   4   5   6   7   8   9   10
actual: 0   0   0   0   1   1   1   1   1   1

Un ajuste lineal podría producir predicciones como -0,2 en la hora 1 y 1,3 en la hora 10. Estos valores no son probabilidades. Van por debajo de 0 y por encima de 1. Peor aún, un solo valor atípico (alguien que estudió 50 horas) arrastraría toda la línea, cambiando las predicciones para todos.

La clasificación necesita una función que:

  • Produce valores entre 0 y 1 (probabilidades)
  • Crea una transición brusca (un límite de decisión)
  • No está distorsionado por valores atípicos alejados del límite.

La función sigmoidea

La función sigmoidea hace exactamente esto:

sigmoid(z) = 1 / (1 + e^(-z))

Propiedades:

  • Cuando z es grande y positivo, sigmoide(z) tiende a 1
  • Cuando z es grande y negativo, sigmoide(z) tiende a 0
  • Cuando z = 0, sigmoide(z) = 0,5
  • La salida siempre está entre 0 y 1.
  • La función es fluida y diferenciable en todas partes.

La derivada tiene una forma conveniente: sigmoide'(z) = sigmoide(z) * (1 - sigmoide(z)). Esto hace que el cálculo del gradiente sea eficiente.

Regresión logística = Modelo lineal + Sigmoide

El modelo calcula z = wx + b (igual que la regresión lineal) y luego aplica sigmoide:

flowchart LR
    X[Input features x] --> L["Linear: z = wx + b"]
    L --> S["Sigmoid: p = 1/(1+e^-z)"]
    S --> D{"p >= 0.5?"}
    D -->|Yes| P[Predict 1]
    D -->|No| N[Predict 0]

La salida p se interpreta como P(y=1 | x), la probabilidad de que la entrada pertenezca a la clase 1. El límite de decisión es donde wx + b = 0, lo que hace que la salida sigmoide sea exactamente 0,5.

Pérdida de entropía cruzada binaria

No puede utilizar MSE para la regresión logística. MSE con una sigmoide crea una superficie de costos no convexa con muchos mínimos locales. En su lugar, utilice entropía cruzada binaria (pérdida de registro):

Loss = -(1/n) * sum(y * log(p) + (1-y) * log(1-p))

Por qué esto funciona:

  • Cuando y=1 y p está cerca de 1: log(1) = 0, por lo que la pérdida está cerca de 0 (correcto, bajo costo)
  • Cuando y=1 yp está cerca de 0: log(0) se acerca al infinito negativo, por lo que la pérdida es enorme (coste incorrecto y alto)
  • Cuando y=0 yp está cerca de 0: log(1) = 0, por lo que la pérdida está cerca de 0 (correcto, bajo costo)
  • Cuando y=0 yp está cerca de 1: log(0) se acerca al infinito negativo, por lo que la pérdida es enorme (costo incorrecto, alto)

Esta función de pérdida es convexa para la regresión logística, lo que garantiza un mínimo global único.

Descenso de gradiente para regresión logística

Los gradientes para entropía cruzada binaria con sigmoide tienen una forma limpia:

dL/dw = (1/n) * sum((p - y) * x)
dL/db = (1/n) * sum(p - y)

Estos parecen idénticos a los gradientes de regresión lineal. La diferencia es que p = sigmoide(wx + b) en lugar de p = wx + b. El sigmoide introduce la no linealidad, pero la regla de actualización del gradiente sigue siendo la misma.

flowchart TD
    A[Initialize w=0, b=0] --> B[Forward pass: z = wx+b, p = sigmoid z]
    B --> C[Compute loss: binary cross-entropy]
    C --> D["Compute gradients: dw = (1/n) * sum((p-y)*x)"]
    D --> E[Update: w = w - lr*dw, b = b - lr*db]
    E --> F{Converged?}
    F -->|No| B
    F -->|Yes| G[Model trained]

El límite de la decisión

Para una entrada 2D (dos entidades), el límite de decisión es la línea donde:

w1*x1 + w2*x2 + b = 0

Los puntos de un lado se clasifican como 1 y los puntos del otro lado como 0. La regresión logística siempre produce un límite de decisión lineal. Si necesita un límite curvo, puede agregar entidades polinómicas o utilizar un modelo no lineal.

Clasificación multiclase con Softmax

La regresión logística binaria maneja dos clases. Para k clases, use la función softmax:

softmax(z_i) = e^(z_i) / sum(e^(z_j) for all j)

Cada clase tiene su propio vector de peso. El modelo calcula una puntuación z_i para cada clase, luego softmax convierte las puntuaciones en probabilidades que suman 1. La clase predicha es la que tiene la probabilidad más alta.

La función de pérdida se convierte en entropía cruzada categórica:

Loss = -(1/n) * sum(sum(y_k * log(p_k)))

donde y_k es 1 para la clase verdadera y 0 para todas las demás (codificación one-hot).

Métricas de evaluación

La precisión por sí sola no es suficiente. Para un conjunto de datos con un 95% de negativos y un 5% de positivos, un modelo que siempre predice negativos obtiene un 95% de precisión pero es inútil.

Matriz de confusión:

Positivo previsto Negativo previsto
Realmente Positivo Verdadero Positivo (TP) Falso Negativo (FN)
Realmente Negativo Falso Positivo (FP) Verdadero Negativo (TN)

Precisión: De todos los aspectos positivos previstos, ¿cuántos son realmente positivos?

Precision = TP / (TP + FP)

Recuerdo (Sensibilidad): De todos los aspectos positivos reales, ¿cuántos detectamos?

Recall = TP / (TP + FN)

F1 Puntuación: Media armónica de precisión y recuperación. Equilibra ambas métricas.

F1 = 2 * (Precision * Recall) / (Precision + Recall)

Cuándo priorizar:

  • Precisión: cuando los falsos positivos son costosos (filtro de spam, no desea bloquear el correo electrónico legítimo)
  • Recuerde: cuando los falsos negativos son costosos (detección de cáncer, no quiere perderse ningún tumor)
  • F1: cuando necesitas una única métrica equilibrada

Constrúyelo

Paso 1: función sigmoidea y generación de datos

import random
import math

def sigmoid(z):
    z = max(-500, min(500, z))
    return 1.0 / (1.0 + math.exp(-z))


random.seed(42)
N = 200
X = []
y = []

for _ in range(N // 2):
    X.append([random.gauss(2, 1), random.gauss(2, 1)])
    y.append(0)

for _ in range(N // 2):
    X.append([random.gauss(5, 1), random.gauss(5, 1)])
    y.append(1)

combined = list(zip(X, y))
random.shuffle(combined)
X, y = zip(*combined)
X = list(X)
y = list(y)

print(f"Generated {N} samples (2 classes, 2 features)")
print(f"Class 0 center: (2, 2), Class 1 center: (5, 5)")
print(f"First 5 samples:")
for i in range(5):
    print(f"  Features: [{X[i][0]:.2f}, {X[i][1]:.2f}], Label: {y[i]}")

Paso 2: Regresión logística desde cero

class LogisticRegression:
    def __init__(self, n_features, learning_rate=0.01):
        self.weights = [0.0] * n_features
        self.bias = 0.0
        self.lr = learning_rate
        self.loss_history = []

    def predict_proba(self, x):
        z = sum(w * xi for w, xi in zip(self.weights, x)) + self.bias
        return sigmoid(z)

    def predict(self, x, threshold=0.5):
        return 1 if self.predict_proba(x) >= threshold else 0

    def compute_loss(self, X, y):
        n = len(y)
        total = 0.0
        for i in range(n):
            p = self.predict_proba(X[i])
            p = max(1e-15, min(1 - 1e-15, p))
            total += y[i] * math.log(p) + (1 - y[i]) * math.log(1 - p)
        return -total / n

    def fit(self, X, y, epochs=1000, print_every=200):
        n = len(y)
        n_features = len(X[0])
        for epoch in range(epochs):
            dw = [0.0] * n_features
            db = 0.0
            for i in range(n):
                p = self.predict_proba(X[i])
                error = p - y[i]
                for j in range(n_features):
                    dw[j] += error * X[i][j]
                db += error
            for j in range(n_features):
                self.weights[j] -= self.lr * (dw[j] / n)
            self.bias -= self.lr * (db / n)
            loss = self.compute_loss(X, y)
            self.loss_history.append(loss)
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Loss: {loss:.4f} | w: [{self.weights[0]:.3f}, {self.weights[1]:.3f}] | b: {self.bias:.3f}")
        return self

    def accuracy(self, X, y):
        correct = sum(1 for i in range(len(y)) if self.predict(X[i]) == y[i])
        return correct / len(y)


split = int(0.8 * N)
X_train, X_test = X[:split], X[split:]
y_train, y_test = y[:split], y[split:]

print("\n=== Training Logistic Regression ===")
model = LogisticRegression(n_features=2, learning_rate=0.1)
model.fit(X_train, y_train, epochs=1000, print_every=200)

print(f"\nTrain accuracy: {model.accuracy(X_train, y_train):.4f}")
print(f"Test accuracy:  {model.accuracy(X_test, y_test):.4f}")
print(f"Weights: [{model.weights[0]:.4f}, {model.weights[1]:.4f}]")
print(f"Bias: {model.bias:.4f}")

Paso 3: Matriz de confusión y métricas desde cero

class ClassificationMetrics:
    def __init__(self, y_true, y_pred):
        self.tp = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 1)
        self.tn = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 0)
        self.fp = sum(1 for t, p in zip(y_true, y_pred) if t == 0 and p == 1)
        self.fn = sum(1 for t, p in zip(y_true, y_pred) if t == 1 and p == 0)

    def accuracy(self):
        total = self.tp + self.tn + self.fp + self.fn
        return (self.tp + self.tn) / total if total > 0 else 0

    def precision(self):
        denom = self.tp + self.fp
        return self.tp / denom if denom > 0 else 0

    def recall(self):
        denom = self.tp + self.fn
        return self.tp / denom if denom > 0 else 0

    def f1(self):
        p = self.precision()
        r = self.recall()
        return 2 * p * r / (p + r) if (p + r) > 0 else 0

    def print_confusion_matrix(self):
        print(f"\n  Confusion Matrix:")
        print(f"                  Predicted")
        print(f"                  Pos   Neg")
        print(f"  Actual Pos     {self.tp:4d}  {self.fn:4d}")
        print(f"  Actual Neg     {self.fp:4d}  {self.tn:4d}")

    def print_report(self):
        self.print_confusion_matrix()
        print(f"\n  Accuracy:  {self.accuracy():.4f}")
        print(f"  Precision: {self.precision():.4f}")
        print(f"  Recall:    {self.recall():.4f}")
        print(f"  F1 Score:  {self.f1():.4f}")


y_pred_test = [model.predict(x) for x in X_test]
print("\n=== Classification Report (Test Set) ===")
metrics = ClassificationMetrics(y_test, y_pred_test)
metrics.print_report()

Paso 4: Análisis de los límites de decisión

print("\n=== Decision Boundary ===")
w1, w2 = model.weights
b = model.bias
print(f"Decision boundary: {w1:.4f}*x1 + {w2:.4f}*x2 + {b:.4f} = 0")
if abs(w2) > 1e-10:
    print(f"Solved for x2:     x2 = {-w1/w2:.4f}*x1 + {-b/w2:.4f}")

print("\nSample predictions near the boundary:")
test_points = [
    [3.0, 3.0],
    [3.5, 3.5],
    [4.0, 4.0],
    [2.5, 2.5],
    [5.0, 5.0],
]
for point in test_points:
    prob = model.predict_proba(point)
    pred = model.predict(point)
    print(f"  [{point[0]}, {point[1]}] -> prob={prob:.4f}, class={pred}")

Paso 5: multiclase con softmax

class SoftmaxRegression:
    def __init__(self, n_features, n_classes, learning_rate=0.01):
        self.n_features = n_features
        self.n_classes = n_classes
        self.lr = learning_rate
        self.weights = [[0.0] * n_features for _ in range(n_classes)]
        self.biases = [0.0] * n_classes

    def softmax(self, scores):
        max_score = max(scores)
        exp_scores = [math.exp(s - max_score) for s in scores]
        total = sum(exp_scores)
        return [e / total for e in exp_scores]

    def predict_proba(self, x):
        scores = [
            sum(self.weights[k][j] * x[j] for j in range(self.n_features)) + self.biases[k]
            for k in range(self.n_classes)
        ]
        return self.softmax(scores)

    def predict(self, x):
        probs = self.predict_proba(x)
        return probs.index(max(probs))

    def fit(self, X, y, epochs=1000, print_every=200):
        n = len(y)
        for epoch in range(epochs):
            grad_w = [[0.0] * self.n_features for _ in range(self.n_classes)]
            grad_b = [0.0] * self.n_classes
            total_loss = 0.0
            for i in range(n):
                probs = self.predict_proba(X[i])
                for k in range(self.n_classes):
                    target = 1.0 if y[i] == k else 0.0
                    error = probs[k] - target
                    for j in range(self.n_features):
                        grad_w[k][j] += error * X[i][j]
                    grad_b[k] += error
                true_prob = max(probs[y[i]], 1e-15)
                total_loss -= math.log(true_prob)
            for k in range(self.n_classes):
                for j in range(self.n_features):
                    self.weights[k][j] -= self.lr * (grad_w[k][j] / n)
                self.biases[k] -= self.lr * (grad_b[k] / n)
            if epoch % print_every == 0:
                print(f"  Epoch {epoch:4d} | Loss: {total_loss / n:.4f}")
        return self

    def accuracy(self, X, y):
        correct = sum(1 for i in range(len(y)) if self.predict(X[i]) == y[i])
        return correct / len(y)


random.seed(42)
X_3class = []
y_3class = []

centers = [(1, 1), (5, 1), (3, 5)]
for label, (cx, cy) in enumerate(centers):
    for _ in range(50):
        X_3class.append([random.gauss(cx, 0.8), random.gauss(cy, 0.8)])
        y_3class.append(label)

combined = list(zip(X_3class, y_3class))
random.shuffle(combined)
X_3class, y_3class = zip(*combined)
X_3class = list(X_3class)
y_3class = list(y_3class)

split_3 = int(0.8 * len(X_3class))
X_train_3 = X_3class[:split_3]
y_train_3 = y_3class[:split_3]
X_test_3 = X_3class[split_3:]
y_test_3 = y_3class[split_3:]

print("\n=== Multi-class Softmax Regression (3 classes) ===")
softmax_model = SoftmaxRegression(n_features=2, n_classes=3, learning_rate=0.1)
softmax_model.fit(X_train_3, y_train_3, epochs=1000, print_every=200)
print(f"\nTrain accuracy: {softmax_model.accuracy(X_train_3, y_train_3):.4f}")
print(f"Test accuracy:  {softmax_model.accuracy(X_test_3, y_test_3):.4f}")

print("\nSample predictions:")
for i in range(5):
    probs = softmax_model.predict_proba(X_test_3[i])
    pred = softmax_model.predict(X_test_3[i])
    print(f"  True: {y_test_3[i]}, Predicted: {pred}, Probs: [{', '.join(f'{p:.3f}' for p in probs)}]")

Paso 6: Ajuste del umbral

print("\n=== Threshold Tuning ===")
print("Default threshold: 0.5. Adjusting the threshold trades precision for recall.\n")

thresholds = [0.3, 0.4, 0.5, 0.6, 0.7]
print(f"{'Threshold':>10} {'Accuracy':>10} {'Precision':>10} {'Recall':>10} {'F1':>10}")
print("-" * 52)

for t in thresholds:
    y_pred_t = [1 if model.predict_proba(x) >= t else 0 for x in X_test]
    m = ClassificationMetrics(y_test, y_pred_t)
    print(f"{t:>10.1f} {m.accuracy():>10.4f} {m.precision():>10.4f} {m.recall():>10.4f} {m.f1():>10.4f}")

Úsalo

Ahora lo mismo con scikit-learn.

from sklearn.linear_model import LogisticRegression as SklearnLR
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score
from sklearn.metrics import confusion_matrix, classification_report
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
import numpy as np

np.random.seed(42)
X_0 = np.random.randn(100, 2) + [2, 2]
X_1 = np.random.randn(100, 2) + [5, 5]
X_sk = np.vstack([X_0, X_1])
y_sk = np.array([0] * 100 + [1] * 100)

X_tr, X_te, y_tr, y_te = train_test_split(X_sk, y_sk, test_size=0.2, random_state=42)

scaler = StandardScaler()
X_tr_sc = scaler.fit_transform(X_tr)
X_te_sc = scaler.transform(X_te)

lr = SklearnLR()
lr.fit(X_tr_sc, y_tr)
y_pred = lr.predict(X_te_sc)

print("=== Scikit-learn Logistic Regression ===")
print(f"Accuracy:  {accuracy_score(y_te, y_pred):.4f}")
print(f"Precision: {precision_score(y_te, y_pred):.4f}")
print(f"Recall:    {recall_score(y_te, y_pred):.4f}")
print(f"F1:        {f1_score(y_te, y_pred):.4f}")
print(f"\nConfusion Matrix:\n{confusion_matrix(y_te, y_pred)}")
print(f"\nClassification Report:\n{classification_report(y_te, y_pred)}")

Su implementación desde cero produce los mismos límites de decisión y métricas. Scikit-learn agrega opciones de resolución (liblinear, lbfgs, saga), regularización automática, estrategias multiclase (uno contra resto, multinomial) y optimizaciones de estabilidad numérica.

Envíalo

Esta lección produce:

  • code/logistic_regression.py - regresión logística desde cero con métricas

Ejercicios

  1. Genere un conjunto de datos que NO sea linealmente separable (por ejemplo, dos círculos concéntricos). Entrene la regresión logística y observe su falla. Luego agregue características polinómicas (x1^2, x2^2, x1*x2) y entrene nuevamente. Demuestre que la precisión mejora.
  2. Implementar una matriz de confusión de clases múltiples para el modelo softmax de 3 clases. Calcule la precisión y la recuperación por clase. ¿Qué clase es más difícil de clasificar?
  3. Construya una curva ROC desde cero. Para 100 valores de umbral de 0 a 1, calcule la tasa de verdaderos positivos y la tasa de falsos positivos. Calcula el AUC (área bajo la curva) usando la regla trapezoidal.

Términos clave

Término Lo que dice la gente Lo que realmente significa
Regresión logística "Regresión para clasificación" Un modelo lineal seguido de una función sigmoidea que genera probabilidades de clase
Función sigmoidea "La curva S" La función 1/(1+e^(-z)) que asigna cualquier número real al rango (0, 1)
Entropía cruzada binaria "Pérdida de registro" La función de pérdida -[y*log(p) + (1-y)*log(1-p)] que penaliza severamente las predicciones erróneas seguras
Límite de decisión "La línea divisoria" La superficie donde la probabilidad de salida del modelo es igual a 0,5, separando las clases predichas
Softmax "Sigmoide de clases múltiples" Una función que convierte un vector de puntuaciones en probabilidades que suman 1
Precisión "Cuántos seleccionados son relevantes" TP / (TP + FP), la fracción de predicciones positivas que en realidad son positivas
Recordar "Cuantos relevantes se seleccionan" TP / (TP + FN), la fracción de positivos reales que el modelo identifica correctamente
F1 puntuación "Precisión equilibrada" La media armónica de precisión y recuperación: 2PR / (P+R)
Matriz de confusión "El desglose del error" Una tabla que muestra los recuentos de TP, TN, FP y FN para cada par de clases
Umbral "El límite" El valor de probabilidad por encima del cual el modelo predice la clase 1 (predeterminado 0,5, ajustable)
Codificación one-hot "Columnas binarias para categorías" Representando la clase k como un vector de ceros con un 1 en la posición k
Entropía cruzada categórica "Pérdida de registros multiclase" La extensión de la entropía cruzada binaria a k clases utilizando etiquetas codificadas one-hot
0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).