Phase 02 - Lesson 03

Regressão Logística

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

A regressão logística dobra uma linha reta em uma curva S para responder perguntas de sim ou não com probabilidades.

Tipo: Construir Idiomas: Python Pré-requisitos: Fase 2, Lição 1-2 (O que é ML, regressão linear) Tempo: ~90 minutos

Objetivos de aprendizagem

  • Implementar regressão logística do zero usando a função sigmóide e perda binária de entropia cruzada
  • Calcular e interpretar precisão, recall, score F1 e matriz de confusão para classificação binária
  • Explique por que o MSE falha na classificação e por que a entropia cruzada binária produz uma superfície de custo convexa
  • Construir um modelo de regressão softmax para classificação multiclasse e avaliar compensações de ajuste de limite

O problema

Você deseja prever se um tumor é maligno ou benigno, dado o seu tamanho. Você tenta a regressão linear. Ele produz números como 0,3 ou 1,7 ou -0,5. O que isso significa? 1.7 é "muito maligno"? -0,5 é "muito benigno"? A regressão linear gera números ilimitados. A classificação precisa de probabilidades limitadas entre 0 e 1 e de uma decisão clara: sim ou não.

A regressão logística resolve isso. Ele pega a mesma combinação linear (wx + b) e a passa pela função sigmóide, que comprime qualquer número no intervalo (0, 1). A saída é uma probabilidade. Você define um limite (geralmente 0,5) e toma uma decisão.

Este é um dos algoritmos mais utilizados na prática. Apesar do nome, a regressão logística é um algoritmo de classificação, não um algoritmo de regressão. O nome vem da função logística (sigmóide) que utiliza.

O Conceito

Por que a regressão linear falha na classificação

Imagine prever aprovação/reprovação (1/0) com base nas horas de estudo. A regressão linear ajusta uma linha através dos dados:

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

Um ajuste linear pode produzir previsões como -0,2 na hora 1 e 1,3 na hora 10. Esses valores não são probabilidades. Eles vão abaixo de 0 e acima de 1. Pior ainda, um único outlier (alguém que estudou 50 horas) arrastaria a linha inteira, alterando as previsões para todos.

A classificação precisa de uma função que:

  • Produz valores entre 0 e 1 (probabilidades)
  • Cria uma transição acentuada (um limite de decisão)
  • Não é distorcido por valores discrepantes distantes do limite

A Função Sigmóide

A função sigmóide faz exatamente isso:

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

Propriedades:

  • Quando z é grande e positivo, sigmóide(z) se aproxima de 1
  • Quando z é grande e negativo, sigmóide(z) se aproxima de 0
  • Quando z = 0, sigmóide (z) = 0,5
  • A saída está sempre entre 0 e 1
  • A função é suave e diferenciável em qualquer lugar

A derivada tem uma forma conveniente: sigmóide'(z) = sigmóide(z) * (1 - sigmóide(z)). Isso torna a computação de gradiente eficiente.

Regressão Logística = Modelo Linear + Sigmóide

O modelo calcula z = wx + b (o mesmo que regressão linear) e depois aplica sigmóide:

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]

A saída p é interpretada como P(y=1 | x), a probabilidade de a entrada pertencer à classe 1. O limite de decisão é onde wx + b = 0, o que torna a saída sigmóide exatamente 0,5.

Perda de entropia cruzada binária

Você não pode usar MSE para regressão logística. MSE com sigmóide cria uma superfície de custo não convexa com muitos mínimos locais. Em vez disso, use entropia cruzada binária (perda de log):

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

Por que isso funciona:

  • Quando y=1 e p está próximo de 1: log(1) = 0, então a perda está próxima de 0 (correto, baixo custo)
  • Quando y=1 e p está próximo de 0: log(0) se aproxima do infinito negativo, então a perda é enorme (errado, custo alto)
  • Quando y=0 e p está próximo de 0: log(1) = 0, então a perda está próxima de 0 (correto, baixo custo)
  • Quando y=0 e p está próximo de 1: log(0) se aproxima do infinito negativo, então a perda é enorme (errado, custo alto)

Esta função de perda é convexa para regressão logística, garantindo um único mínimo global.

Gradiente Descendente para Regressão Logística

Os gradientes para entropia cruzada binária com sigmóide têm uma forma limpa:

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

Eles parecem idênticos aos gradientes de regressão linear. A diferença é que p = sigmóide (wx + b) em vez de p = wx + b. O sigmóide introduz a não linearidade, mas a regra de atualização do gradiente permanece a mesma.

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]

O limite da decisão

Para uma entrada 2D (dois recursos), o limite de decisão é a linha onde:

w1*x1 + w2*x2 + b = 0

Os pontos de um lado são classificados como 1, os pontos do outro lado como 0. A regressão logística sempre produz um limite de decisão linear. Se precisar de um limite curvo, adicione recursos polinomiais ou use um modelo não linear.

Classificação multiclasse com Softmax

A regressão logística binária lida com duas classes. Para k classes, use a função softmax:

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

Cada classe tem seu próprio vetor de peso. O modelo calcula uma pontuação z_i para cada classe e, em seguida, softmax converte as pontuações em probabilidades que somam 1. A classe prevista é aquela com a maior probabilidade.

A função de perda torna-se entropia cruzada categórica:

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

onde y_k é 1 para a classe verdadeira e 0 para todas as outras (codificação one-hot).

Métricas de avaliação

A precisão por si só não é suficiente. Para um conjunto de dados com 95% de negativo e 5% de positivo, um modelo que sempre prevê negativo obtém 95% de precisão, mas é inútil.

Matriz de confusão:

Previsto Positivo Negativo previsto
Na verdade positivo Verdadeiro Positivo (TP) Falso Negativo (FN)
Na verdade negativo Falso Positivo (FP) Verdadeiro Negativo (TN)

Precisão: de todos os positivos previstos, quantos são realmente positivos?

Precision = TP / (TP + FP)

Recall (Sensibilidade): De todos os aspectos positivos reais, quantos capturamos?

Recall = TP / (TP + FN)

F1 Pontuação: Média harmônica de precisão e recall. Equilibra ambas as métricas.

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

Quando priorizar:

  • Precisão: quando falsos positivos custam caro (filtro de spam, você não deseja bloquear e-mails legítimos)
  • Lembre-se: quando falsos negativos são caros (rastreamento de câncer, você não quer perder um tumor)
  • F1: quando você precisa de uma única métrica balanceada

Construa

Etapa 1: função sigmóide e geração de dados

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]}")

Etapa 2: Regressão logística do zero

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}")

Etapa 3: Matriz de confusão e métricas do zero

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()

Etapa 4: Análise do limite de decisão

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}")

Etapa 5: Multiclasse com 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)}]")

Etapa 6: ajuste de limite

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}")

Use-o

Agora a mesma coisa com 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)}")

Sua implementação do zero produz os mesmos limites de decisão e métricas. Scikit-learn adiciona opções de solucionador (liblinear, lbfgs, saga), regularização automática, estratégias multiclasse (um contra descanso, multinomial) e otimizações de estabilidade numérica.

Envie

Esta lição produz:

  • code/logistic_regression.py - regressão logística do zero com métricas

Exercícios

  1. Gere um conjunto de dados que NÃO seja linearmente separável (por exemplo, dois círculos concêntricos). Treine a regressão logística e observe seu fracasso. Em seguida, adicione recursos polinomiais (x1 ^ 2, x2 ^ 2, x1 * x2) e treine novamente. Mostre que a precisão melhora.
  2. Implementar uma matriz de confusão multiclasse para o modelo softmax de 3 classes. Calcule a precisão e o recall por classe. Qual classe é mais difícil de classificar?
  3. Construa uma curva ROC do zero. Para 100 valores limite de 0 a 1, calcule a taxa de verdadeiros positivos e a taxa de falsos positivos. Calcule AUC (área sob a curva) usando a regra trapezoidal.

Termos-chave

Prazo O que as pessoas dizem O que isso realmente significa
Regressão logística “Regressão para classificação” Um modelo linear seguido por uma função sigmóide que gera probabilidades de classe
Função sigmóide "A curva S" A função 1/(1+e^(-z)) que mapeia qualquer número real para o intervalo (0, 1)
Entropia cruzada binária "Perda de registro" A função de perda -[y*log(p) + (1-y)*log(1-p)] que penaliza severamente previsões erradas e confiantes
Limite de decisão “A linha divisória” A superfície onde a probabilidade de saída do modelo é igual a 0,5, separando as classes previstas
Softmax "Sigmóide multiclasse" Uma função que converte um vetor de pontuações em probabilidades cuja soma é 1
Precisão “Quantos selecionados são relevantes” TP / (TP + FP), a fração de previsões positivas que são realmente positivas
Lembrar “Quantos relevantes estão selecionados” TP / (TP + FN), a fração de positivos reais que o modelo identifica corretamente
F1 pontuação “Precisão equilibrada” A média harmônica de precisão e recuperação: 2PR / (P+R)
Matriz de confusão "A análise do erro" Uma tabela mostrando contagens de TP, TN, FP, FN para cada par de classes
Limite "O corte" O valor de probabilidade acima do qual o modelo prevê a classe 1 (padrão 0,5, ajustável)
Codificação one-hot "Colunas binárias para categorias" Representando a classe k como um vetor de zeros com 1 na posição k
Entropia cruzada categórica "Perda de log multiclasse" A extensão da entropia cruzada binária para k classes usando rótulos codificados one-hot
0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).