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
- 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.
- 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?
- 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 |