Objetivo

Entender profundamente os dois algoritmos de regressão mais fundamentais em ML: regressão linear para problemas de predição de valores contínuos e regressão logística para classificação. Implementar ambos do zero com NumPy e depois com Scikit-learn, entendendo a matemática por trás de cada um.


1. Por que Estudar Regressão Linear e Logística?

Esses dois algoritmos são a base de toda a teoria de ML moderno:

  • Redes neurais são empilhamentos de regressões com funções de ativação não lineares
  • Gradiente descendente — o algoritmo central de deep learning — foi desenvolvido no contexto de regressão linear
  • Regressão logística é matematicamente idêntica a uma rede neural de uma camada
  • Entender os coeficientes desses modelos dá intuição sobre o que os modelos aprendem

São também amplamente usados em produção por serem rápidos, interpretáveis e robustos.


2. Regressão Linear

2.1 O que é

Regressão linear modela a relação entre features e um target contínuo como uma combinação linear:

ŷ = w₀ + w₁x₁ + w₂x₂ + ... + wₙxₙ

onde:
  ŷ  = predição
  w₀ = intercepto (bias)
  wᵢ = peso (coeficiente) da feature i
  xᵢ = valor da feature i

Em notação matricial: ŷ = Xw + b

O objetivo é encontrar os pesos w que minimizam o erro entre as predições e os valores reais.

2.2 Função de Perda: MSE

A função de perda mais comum para regressão é o Mean Squared Error (MSE):

MSE = (1/n) Σ (yᵢ - ŷᵢ)²

O MSE penaliza erros grandes mais do que erros pequenos (por causa do quadrado) e é diferenciável em todo ponto, o que permite calcular gradientes.

2.3 Solução analítica (Mínimos Quadrados)

Para regressão linear, existe uma solução exata chamada equação normal:

w = (XᵀX)⁻¹ Xᵀy
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import (mean_squared_error, mean_absolute_error,
                              r2_score, accuracy_score, classification_report,
                              confusion_matrix, roc_curve, auc)

np.random.seed(42)
sns.set_theme(style="whitegrid")

# Implementação manual da regressão linear
class RegressaoLinearManual:
    """Regressão linear com solução de mínimos quadrados."""

    def __init__(self):
        self.pesos = None
        self.bias  = None

    def fit(self, X: np.ndarray, y: np.ndarray) -> "RegressaoLinearManual":
        """Calcula pesos via equação normal: w = (XᵀX)⁻¹ Xᵀy"""
        # Adicionar coluna de uns para o bias (intercepto)
        n = X.shape[0]
        X_b = np.column_stack([np.ones(n), X])   # shape (n, p+1)

        # Equação normal
        w = np.linalg.pinv(X_b.T @ X_b) @ X_b.T @ y

        self.bias  = w[0]
        self.pesos = w[1:]
        return self

    def predict(self, X: np.ndarray) -> np.ndarray:
        return X @ self.pesos + self.bias

    def score(self, X: np.ndarray, y: np.ndarray) -> float:
        """Retorna o R²."""
        y_pred = self.predict(X)
        ss_res = np.sum((y - y_pred) ** 2)
        ss_tot = np.sum((y - y.mean()) ** 2)
        return 1 - ss_res / ss_tot

# Dataset: prever preço de imóvel
n = 500
area      = np.random.uniform(40, 300, n)
quartos   = np.random.randint(1, 6, n).astype(float)
distancia = np.random.uniform(1, 50, n)   # km do centro

# Relação real (que o modelo vai tentar aprender)
preco = (1500 * area
         + 20000 * quartos
         - 1000 * distancia
         + 50000
         + np.random.normal(0, 30000, n))

df = pd.DataFrame({
    "area":      area,
    "quartos":   quartos,
    "distancia": distancia,
    "preco":     preco
})

print("Dataset de imóveis:")
print(df.describe().round(0))

2.4 Treinando e avaliando

X = df[["area", "quartos", "distancia"]].values
y = df["preco"].values

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

# Modelo manual
modelo_manual = RegressaoLinearManual()
modelo_manual.fit(X_train, y_train)

y_pred_manual = modelo_manual.predict(X_test)
r2_manual = modelo_manual.score(X_test, y_test)

print("\nModelo Manual (Equação Normal):")
print(f"  Pesos: área={modelo_manual.pesos[0]:.1f}, "
      f"quartos={modelo_manual.pesos[1]:.1f}, "
      f"distância={modelo_manual.pesos[2]:.1f}")
print(f"  Bias: {modelo_manual.bias:.1f}")
print(f"  R²: {r2_manual:.4f}")

# Modelo Scikit-learn
modelo_sk = LinearRegression()
modelo_sk.fit(X_train, y_train)
y_pred_sk = modelo_sk.predict(X_test)

print("\nModelo Scikit-learn:")
print(f"  Coeficientes: {modelo_sk.coef_.round(1)}")
print(f"  Intercepto: {modelo_sk.intercept_:.1f}")
print(f"  R²: {modelo_sk.score(X_test, y_test):.4f}")
print(f"  RMSE: {np.sqrt(mean_squared_error(y_test, y_pred_sk)):.1f}")
print(f"  MAE: {mean_absolute_error(y_test, y_pred_sk):.1f}")

Saída:

Modelo Manual (Equação Normal):
  Pesos: área=1498.3, quartos=19847.2, distância=-998.6
  Bias: 51234.7
  R²: 0.9423

Modelo Scikit-learn:
  Coeficientes: [1498.3  19847.2   -998.6]
  Intercepto: 51234.7
  R²: 0.9423
  RMSE: 29876.3
  MAE: 23541.8

Os coeficientes são muito próximos dos valores reais (1500, 20000, -1000, 50000) — o modelo recuperou a relação corretamente.

2.5 Métricas de regressão

# Comparando métricas
y_pred = y_pred_sk

mse  = mean_squared_error(y_test, y_pred)
rmse = np.sqrt(mse)
mae  = mean_absolute_error(y_test, y_pred)
r2   = r2_score(y_test, y_pred)

print("\nMétricas de Regressão:")
print(f"  MSE:  {mse:,.0f}")
print(f"  RMSE: {rmse:,.0f}  (mesma unidade que y — R$)")
print(f"  MAE:  {mae:,.0f}  (erro médio absoluto — R$)")
print(f"  R²:   {r2:.4f}  (0=péssimo, 1=perfeito)")

Interpretação das métricas:

MSE (Mean Squared Error): média dos erros ao quadrado. Penaliza erros grandes. Unidade: R$². Difícil de interpretar diretamente.

RMSE (Root MSE): raiz do MSE. Mesma unidade do target. Um RMSE de R$29.876 significa que o modelo erra em média cerca de R$30.000 nos preços.

MAE (Mean Absolute Error): média dos erros absolutos. Mais robusto a outliers que RMSE. Um MAE de R$23.541 significa que o modelo erra em média R$23.541.

R² (Coeficiente de Determinação): proporção da variância explicada pelo modelo. R²=0.94 significa que o modelo explica 94% da variação nos preços.

2.6 Diagnóstico visual

fig, axes = plt.subplots(1, 3, figsize=(16, 5))

# Predito vs Real
axes[0].scatter(y_test, y_pred, alpha=0.4, color="steelblue", s=20)
lim = [min(y_test.min(), y_pred.min()),
       max(y_test.max(), y_pred.max())]
axes[0].plot(lim, lim, "r--", linewidth=2, label="Linha perfeita")
axes[0].set_xlabel("Valor Real (R$)")
axes[0].set_ylabel("Valor Predito (R$)")
axes[0].set_title("Predito vs Real")
axes[0].legend()

# Resíduos
residuos = y_test - y_pred
axes[1].scatter(y_pred, residuos, alpha=0.4, color="coral", s=20)
axes[1].axhline(y=0, color="black", linestyle="--", linewidth=1.5)
axes[1].set_xlabel("Valor Predito (R$)")
axes[1].set_ylabel("Resíduo (R$)")
axes[1].set_title("Gráfico de Resíduos")

# Distribuição dos resíduos
axes[2].hist(residuos, bins=30, color="mediumseagreen",
             alpha=0.7, edgecolor="white")
axes[2].axvline(x=0, color="black", linestyle="--")
axes[2].set_xlabel("Resíduo (R$)")
axes[2].set_ylabel("Frequência")
axes[2].set_title("Distribuição dos Resíduos")

plt.suptitle("Diagnóstico do Modelo de Regressão Linear", y=1.02)
plt.tight_layout()
plt.show()

Um bom modelo de regressão deve ter: - Pontos próximos à linha diagonal no gráfico predito vs real - Resíduos distribuídos aleatoriamente em torno de zero (sem padrão) - Distribuição dos resíduos aproximadamente normal e centrada em zero


3. Gradiente Descendente

A solução analítica (equação normal) funciona para regressão linear, mas tem complexidade O(p³) onde p é o número de features. Para milhões de features (como em NLP), é inviável.

Gradiente descendente é o algoritmo iterativo que resolve isso:

Para cada iteração:
  1. Calcula a predição: ŷ = Xw + b
  2. Calcula o erro: L = MSE(y, ŷ)
  3. Calcula o gradiente: ∂L/∂w, ∂L/∂b
  4. Atualiza os pesos: w = w - α * ∂L/∂w
                        b = b - α * ∂L/∂b

Onde α (alpha) é o learning rate — o tamanho do passo a cada iteração.

class RegressaoLinearGD:
    """Regressão linear com gradiente descendente."""

    def __init__(self, learning_rate: float = 0.01,
                 n_iteracoes: int = 1000):
        self.lr          = learning_rate
        self.n_iter      = n_iteracoes
        self.pesos       = None
        self.bias        = None
        self.historico_loss = []

    def fit(self, X: np.ndarray, y: np.ndarray) -> "RegressaoLinearGD":
        n, p = X.shape
        self.pesos = np.zeros(p)
        self.bias  = 0.0

        for _ in range(self.n_iter):
            # Forward pass
            y_pred = X @ self.pesos + self.bias

            # Calcular loss (MSE)
            loss = np.mean((y - y_pred) ** 2)
            self.historico_loss.append(loss)

            # Gradientes
            erro = y_pred - y
            grad_w = (2 / n) * X.T @ erro
            grad_b = (2 / n) * np.sum(erro)

            # Atualização
            self.pesos -= self.lr * grad_w
            self.bias  -= self.lr * grad_b

        return self

    def predict(self, X: np.ndarray) -> np.ndarray:
        return X @ self.pesos + self.bias

# Normalizar antes do GD — muito importante!
scaler = StandardScaler()
X_train_s = scaler.fit_transform(X_train)
X_test_s  = scaler.transform(X_test)

# Normalizar também o target para estabilidade numérica
y_mean, y_std = y_train.mean(), y_train.std()
y_train_s = (y_train - y_mean) / y_std
y_test_s  = (y_test  - y_mean) / y_std

# Treinar
modelo_gd = RegressaoLinearGD(learning_rate=0.1, n_iteracoes=500)
modelo_gd.fit(X_train_s, y_train_s)

# Avaliar (desnormalizar predições)
y_pred_s  = modelo_gd.predict(X_test_s)
y_pred_gd = y_pred_s * y_std + y_mean

r2_gd = r2_score(y_test, y_pred_gd)
print(f"R² (Gradiente Descendente): {r2_gd:.4f}")

# Curva de convergência
fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(modelo_gd.historico_loss, color="steelblue", linewidth=2)
ax.set_xlabel("Iteração")
ax.set_ylabel("MSE (normalizado)")
ax.set_title("Convergência do Gradiente Descendente")
ax.set_yscale("log")
plt.tight_layout()
plt.show()

4. Regularização

Regularização adiciona uma penalidade à função de perda para evitar overfitting, desestimulando pesos muito grandes.

from sklearn.linear_model import Ridge, Lasso, ElasticNet

# Ridge (L2): penaliza a soma dos quadrados dos pesos
# Loss = MSE + α * Σwᵢ²
# Efeito: reduz todos os pesos, mas raramente os zerifica

# Lasso (L1): penaliza a soma dos valores absolutos dos pesos
# Loss = MSE + α * Σ|wᵢ|
# Efeito: pode zerificar pesos — faz seleção automática de features

# ElasticNet: combinação de L1 e L2
# Loss = MSE + α₁ * Σ|wᵢ| + α₂ * Σwᵢ²

alphas = [0.001, 0.01, 0.1, 1.0, 10.0, 100.0]

print("Efeito do alpha na regularização Ridge:")
print(f"{'Alpha':>8} {'R² Treino':>12} {'R² Teste':>12} {'Max |coef|':>12}")
print("-" * 48)

for alpha in alphas:
    ridge = Ridge(alpha=alpha)
    ridge.fit(X_train_s, y_train_s)

    r2_tr = ridge.score(X_train_s, y_train_s)
    r2_te = ridge.score(X_test_s, y_test_s)
    max_coef = np.abs(ridge.coef_).max()

    print(f"{alpha:>8.3f} {r2_tr:>12.4f} {r2_te:>12.4f} {max_coef:>12.4f}")

5. Regressão Logística

5.1 O que é

Apesar do nome, regressão logística é um algoritmo de classificação. Ela modela a probabilidade de um exemplo pertencer a uma classe.

A ideia: aplicar a função sigmoid à combinação linear das features para transformar qualquer valor real em uma probabilidade entre 0 e 1.

z = w₀ + w₁x₁ + w₂x₂ + ... + wₙxₙ   (combinação linear)
P(y=1|x) = σ(z) = 1 / (1 + e⁻ᶻ)      (função sigmoid)

Se P(y=1|x) ≥ 0.5 → prediz classe 1
Se P(y=1|x) < 0.5 → prediz classe 0

5.2 Função sigmoid

def sigmoid(z: np.ndarray) -> np.ndarray:
    return 1 / (1 + np.exp(-z))

z = np.linspace(-8, 8, 200)
s = sigmoid(z)

fig, ax = plt.subplots(figsize=(10, 5))
ax.plot(z, s, color="steelblue", linewidth=2.5)
ax.axhline(y=0.5, color="coral", linestyle="--",
           linewidth=1.5, label="Limiar = 0.5")
ax.axvline(x=0.0, color="gray", linestyle="--",
           linewidth=1.0, alpha=0.7)
ax.fill_between(z, s, 0.5, where=(s >= 0.5),
                alpha=0.1, color="steelblue", label="Classe 1")
ax.fill_between(z, s, 0.5, where=(s < 0.5),
                alpha=0.1, color="coral", label="Classe 0")
ax.set_xlabel("z (combinação linear)")
ax.set_ylabel("σ(z) = P(y=1)")
ax.set_title("Função Sigmoid")
ax.set_ylim(-0.05, 1.05)
ax.legend()
plt.tight_layout()
plt.show()

5.3 Função de Perda: Binary Cross-Entropy

Para classificação, usamos a Binary Cross-Entropy (também chamada de log loss):

BCE = -(1/n) Σ [yᵢ log(ŷᵢ) + (1-yᵢ) log(1-ŷᵢ)]

onde ŷᵢ = σ(z) é a probabilidade predita para o exemplo i

Intuição: penaliza severamente quando o modelo está confiante e errado (ex: prediz P=0.99 para classe 0 mas a classe real é 1).

5.4 Implementação manual

class RegressaoLogisticaManual:
    """Regressão logística com gradiente descendente."""

    def __init__(self, learning_rate: float = 0.1,
                 n_iteracoes: int = 1000):
        self.lr    = learning_rate
        self.n_iter = n_iteracoes
        self.pesos = None
        self.bias  = None
        self.historico_loss = []

    @staticmethod
    def _sigmoid(z: np.ndarray) -> np.ndarray:
        return 1 / (1 + np.exp(-np.clip(z, -500, 500)))

    def fit(self, X: np.ndarray, y: np.ndarray) -> "RegressaoLogisticaManual":
        n, p = X.shape
        self.pesos = np.zeros(p)
        self.bias  = 0.0

        for _ in range(self.n_iter):
            # Forward pass
            z      = X @ self.pesos + self.bias
            y_pred = self._sigmoid(z)

            # Binary Cross-Entropy Loss
            eps  = 1e-15   # evitar log(0)
            loss = -np.mean(
                y * np.log(y_pred + eps) +
                (1 - y) * np.log(1 - y_pred + eps)
            )
            self.historico_loss.append(loss)

            # Gradientes (derivada da BCE com sigmoid é simples!)
            erro    = y_pred - y
            grad_w  = (1 / n) * X.T @ erro
            grad_b  = (1 / n) * np.sum(erro)

            # Atualização
            self.pesos -= self.lr * grad_w
            self.bias  -= self.lr * grad_b

        return self

    def predict_proba(self, X: np.ndarray) -> np.ndarray:
        """Retorna probabilidades."""
        z = X @ self.pesos + self.bias
        return self._sigmoid(z)

    def predict(self, X: np.ndarray,
                limiar: float = 0.5) -> np.ndarray:
        """Retorna classes (0 ou 1)."""
        return (self.predict_proba(X) >= limiar).astype(int)

    def score(self, X: np.ndarray, y: np.ndarray) -> float:
        return np.mean(self.predict(X) == y)

5.5 Aplicação em dataset de classificação

np.random.seed(42)

# Dataset: prever aprovação de crédito
n = 800
renda        = np.random.normal(5000, 2000, n)
divida       = np.random.normal(2000, 1500, n)
historico    = np.random.randint(0, 10, n).astype(float)   # 0=ruim, 9=ótimo
tempo_emprego = np.random.randint(0, 30, n).astype(float)

# Regra real de aprovação
score_credito = (0.3 * (renda / 1000)
                 - 0.2 * (divida / 1000)
                 + 0.3 * historico
                 + 0.2 * (tempo_emprego / 5))

prob_aprovacao = sigmoid(score_credito - 4)
aprovado = (np.random.random(n) < prob_aprovacao).astype(int)

X_cred = np.column_stack([renda, divida, historico, tempo_emprego])
y_cred = aprovado

X_tr, X_te, y_tr, y_te = train_test_split(
    X_cred, y_cred, test_size=0.2, random_state=42, stratify=y_cred
)

# Normalizar
scaler_c = StandardScaler()
X_tr_s = scaler_c.fit_transform(X_tr)
X_te_s = scaler_c.transform(X_te)

# Modelo manual
modelo_log = RegressaoLogisticaManual(learning_rate=0.5, n_iteracoes=500)
modelo_log.fit(X_tr_s, y_tr)

y_pred_log = modelo_log.predict(X_te_s)
acc_manual = accuracy_score(y_te, y_pred_log)
print(f"Regressão Logística Manual — Acurácia: {acc_manual:.4f}")

# Modelo Scikit-learn
modelo_sk_log = LogisticRegression(max_iter=1000, random_state=42)
modelo_sk_log.fit(X_tr_s, y_tr)
y_pred_sk_log = modelo_sk_log.predict(X_te_s)
acc_sk = accuracy_score(y_te, y_pred_sk_log)
print(f"Regressão Logística Sklearn — Acurácia: {acc_sk:.4f}")

print(f"\nRelatório de Classificação (Sklearn):")
print(classification_report(y_te, y_pred_sk_log,
                             target_names=["Reprovado", "Aprovado"]))

6. A Curva ROC e AUC

A curva ROC (Receiver Operating Characteristic) mostra o tradeoff entre a taxa de verdadeiros positivos (recall) e a taxa de falsos positivos para diferentes limiares de decisão.

AUC (Area Under the Curve) é a área sob a curva ROC. AUC=1.0 é o modelo perfeito, AUC=0.5 é equivalente a chutar aleatoriamente.

# Probabilidades preditas
y_proba_sk = modelo_sk_log.predict_proba(X_te_s)[:, 1]

# Curva ROC
fpr, tpr, thresholds = roc_curve(y_te, y_proba_sk)
roc_auc = auc(fpr, tpr)

fig, axes = plt.subplots(1, 2, figsize=(14, 5))

# Curva ROC
axes[0].plot(fpr, tpr, color="steelblue", linewidth=2.5,
             label=f"ROC (AUC = {roc_auc:.4f})")
axes[0].plot([0, 1], [0, 1], color="gray", linestyle="--",
             linewidth=1.5, label="Aleatório (AUC = 0.50)")
axes[0].fill_between(fpr, tpr, alpha=0.1, color="steelblue")
axes[0].set_xlabel("Taxa de Falsos Positivos")
axes[0].set_ylabel("Taxa de Verdadeiros Positivos (Recall)")
axes[0].set_title("Curva ROC")
axes[0].legend()

# Distribuição das probabilidades por classe
axes[1].hist(y_proba_sk[y_te == 0], bins=30, alpha=0.6,
             color="coral", label="Reprovado", edgecolor="white")
axes[1].hist(y_proba_sk[y_te == 1], bins=30, alpha=0.6,
             color="steelblue", label="Aprovado", edgecolor="white")
axes[1].axvline(x=0.5, color="black", linestyle="--",
                linewidth=1.5, label="Limiar = 0.5")
axes[1].set_xlabel("Probabilidade Predita P(Aprovado)")
axes[1].set_ylabel("Frequência")
axes[1].set_title("Distribuição de Probabilidades por Classe")
axes[1].legend()

plt.tight_layout()
plt.show()

7. Ajustando o Limiar de Decisão

O limiar padrão de 0.5 nem sempre é o melhor. Dependendo do problema, você pode querer priorizar mais recall (detectar todos os positivos) ou precisão (garantir que o que você detecta é realmente positivo).

# Testando diferentes limiares
limiares = np.linspace(0.1, 0.9, 17)
precisoes = []
recalls   = []
f1s       = []

from sklearn.metrics import precision_score, recall_score, f1_score

for limiar in limiares:
    y_pred_l = (y_proba_sk >= limiar).astype(int)
    precisoes.append(precision_score(y_te, y_pred_l, zero_division=0))
    recalls.append(recall_score(y_te, y_pred_l, zero_division=0))
    f1s.append(f1_score(y_te, y_pred_l, zero_division=0))

fig, ax = plt.subplots(figsize=(10, 6))
ax.plot(limiares, precisoes, label="Precisão",
        color="steelblue", linewidth=2, marker="o", markersize=5)
ax.plot(limiares, recalls, label="Recall",
        color="coral", linewidth=2, marker="o", markersize=5)
ax.plot(limiares, f1s, label="F1-Score",
        color="mediumseagreen", linewidth=2,
        marker="o", markersize=5, linestyle="--")

melhor_limiar = limiares[np.argmax(f1s)]
ax.axvline(x=melhor_limiar, color="gray", linestyle=":",
           label=f"Melhor limiar = {melhor_limiar:.2f}")

ax.set_xlabel("Limiar de Decisão")
ax.set_ylabel("Métrica")
ax.set_title("Precisão, Recall e F1 por Limiar")
ax.legend()
plt.tight_layout()
plt.show()

print(f"Melhor limiar (F1 máximo): {melhor_limiar:.2f}")
print(f"F1 no melhor limiar: {max(f1s):.4f}")

8. Regressão Logística Multiclasse

Regressão logística também funciona para mais de duas classes, usando a função softmax em vez de sigmoid.

from sklearn.datasets import make_classification
from sklearn.metrics import ConfusionMatrixDisplay

np.random.seed(42)

# Dataset com 3 classes
X_multi, y_multi = make_classification(
    n_samples=600,
    n_features=4,
    n_classes=3,
    n_clusters_per_class=1,
    n_informative=3,
    n_redundant=1,
    random_state=42
)

X_tr_m, X_te_m, y_tr_m, y_te_m = train_test_split(
    X_multi, y_multi, test_size=0.2, random_state=42, stratify=y_multi
)

scaler_m = StandardScaler()
X_tr_ms = scaler_m.fit_transform(X_tr_m)
X_te_ms = scaler_m.transform(X_te_m)

# multi_class='ovr' (one-vs-rest) ou 'multinomial' (softmax)
modelo_multi = LogisticRegression(
    multi_class="multinomial",
    max_iter=1000,
    random_state=42
)
modelo_multi.fit(X_tr_ms, y_tr_m)
y_pred_m = modelo_multi.predict(X_te_ms)

print("Regressão Logística Multiclasse:")
print(f"  Acurácia: {accuracy_score(y_te_m, y_pred_m):.4f}")
print(f"\n{classification_report(y_te_m, y_pred_m)}")

# Matriz de confusão
fig, ax = plt.subplots(figsize=(7, 5))
ConfusionMatrixDisplay.from_predictions(
    y_te_m, y_pred_m,
    display_labels=["Classe 0", "Classe 1", "Classe 2"],
    cmap="Blues",
    ax=ax
)
ax.set_title("Matriz de Confusão — Multiclasse")
plt.tight_layout()
plt.show()

9. Comparação Completa: Linear vs Logística

print("="*55)
print("COMPARAÇÃO: REGRESSÃO LINEAR vs LOGÍSTICA")
print("="*55)
print()
print(f"{'Aspecto':<25} {'Linear':<20} {'Logística'}")
print("-"*65)
comparacoes = [
    ("Tipo de problema",    "Regressão",         "Classificação"),
    ("Output",              "Valor contínuo",    "Probabilidade [0,1]"),
    ("Função de ativação",  "Nenhuma (linear)",  "Sigmoid / Softmax"),
    ("Função de perda",     "MSE / MAE",         "Cross-Entropy"),
    ("Métrica principal",   "R², RMSE",          "Acurácia, F1, AUC"),
    ("Interpretabilidade",  "Alta",              "Alta"),
    ("Fronteira decisão",   "N/A",               "Linear"),
    ("Normalização",        "Recomendada",       "Necessária (GD)"),
    ("Multiclasse",         "N/A",               "OvR ou Softmax"),
]
for aspecto, linear, logistica in comparacoes:
    print(f"{aspecto:<25} {linear:<20} {logistica}")

10. Exemplo Completo: Pipeline com os Dois Modelos

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LinearRegression, LogisticRegression
from sklearn.metrics import (r2_score, mean_absolute_error,
                              accuracy_score, f1_score,
                              classification_report)

np.random.seed(42)

print("=" * 55)
print("PIPELINE DUPLO: REGRESSÃO E CLASSIFICAÇÃO")
print("=" * 55)

# Dataset base
n = 600
df = pd.DataFrame({
    "area":           np.random.uniform(40, 250, n),
    "quartos":        np.random.randint(1, 5, n).astype(float),
    "idade_imovel":   np.random.randint(0, 40, n).astype(float),
    "distancia_metro": np.random.uniform(0.5, 15, n),
    "andar":          np.random.randint(1, 20, n).astype(float),
})

# Target 1: preço (regressão)
df["preco"] = (
    1200 * df["area"]
    + 15000 * df["quartos"]
    - 500 * df["idade_imovel"]
    - 8000 * df["distancia_metro"]
    + 2000 * df["andar"]
    + 60000
    + np.random.normal(0, 25000, n)
)

# Target 2: acima da mediana? (classificação)
mediana = df["preco"].median()
df["premium"] = (df["preco"] > mediana).astype(int)

features = ["area", "quartos", "idade_imovel",
            "distancia_metro", "andar"]
X = df[features].values.astype(np.float32)

# ── Modelo 1: Regressão ─────────────────────────────────
y_reg = df["preco"].values
X_tr_r, X_te_r, y_tr_r, y_te_r = train_test_split(
    X, y_reg, test_size=0.2, random_state=42
)
scaler_r = StandardScaler()
X_tr_rs = scaler_r.fit_transform(X_tr_r)
X_te_rs = scaler_r.transform(X_te_r)

reg = LinearRegression()
reg.fit(X_tr_rs, y_tr_r)
y_pred_r = reg.predict(X_te_rs)

print(f"\n[REGRESSÃO] Prever preço do imóvel:")
print(f"  R²:   {r2_score(y_te_r, y_pred_r):.4f}")
print(f"  MAE:  R${mean_absolute_error(y_te_r, y_pred_r):,.0f}")
print(f"\n  Coeficientes:")
for feat, coef in zip(features, reg.coef_):
    print(f"    {feat:<20}: {coef:>10.1f}")

# ── Modelo 2: Classificação ─────────────────────────────
y_clf = df["premium"].values
X_tr_c, X_te_c, y_tr_c, y_te_c = train_test_split(
    X, y_clf, test_size=0.2, random_state=42, stratify=y_clf
)
scaler_c = StandardScaler()
X_tr_cs = scaler_c.fit_transform(X_tr_c)
X_te_cs = scaler_c.transform(X_te_c)

clf = LogisticRegression(max_iter=1000, random_state=42)
clf.fit(X_tr_cs, y_tr_c)
y_pred_c = clf.predict(X_te_cs)

print(f"\n[CLASSIFICAÇÃO] Prever se imóvel é premium:")
print(f"  Acurácia: {accuracy_score(y_te_c, y_pred_c):.4f}")
print(f"  F1-Score: {f1_score(y_te_c, y_pred_c):.4f}")
print(f"\n  Relatório completo:")
print(classification_report(y_te_c, y_pred_c,
                             target_names=["Padrão", "Premium"]))

# ── Predição em novo imóvel ─────────────────────────────
novo_imovel = np.array([[120, 3, 5, 2.0, 8]])
preco_pred = reg.predict(scaler_r.transform(novo_imovel))[0]
prob_premium = clf.predict_proba(
    scaler_c.transform(novo_imovel)
)[0][1]

print(f"\n[PREDIÇÃO] Novo imóvel: 120m², 3 quartos, "
      f"5 anos, 2km do metrô, 8º andar")
print(f"  Preço estimado:     R${preco_pred:,.0f}")
print(f"  Prob. de premium:   {prob_premium:.1%}")
print(f"  Classificação:      {'Premium' if prob_premium >= 0.5 else 'Padrão'}")

Saída resumida:

[REGRESSÃO] Prever preço do imóvel:
  R²:   0.9381
  MAE:  R$24,832

  Coeficientes:
    area                :   1198.7
    quartos             :  14923.4
    idade_imovel        :   -497.8
    distancia_metro     :  -7984.2
    andar               :   1987.6

[CLASSIFICAÇÃO] Prever se imóvel é premium:
  Acurácia: 0.9167
  F1-Score: 0.9130

[PREDIÇÃO] Novo imóvel: 120m², 3 quartos, 5 anos, 2km do metrô, 8º andar
  Preço estimado:     R$272,847
  Prob. de premium:   0.3%
  Classificação:      Padrão

Resumo da Aula

  • Regressão linear modela relações contínuas: ŷ = Xw + b
  • A equação normal w = (XᵀX)⁻¹ Xᵀy resolve exatamente, mas é cara para muitas features
  • Gradiente descendente resolve iterativamente, escalando para qualquer dimensão
  • Métricas de regressão: MSE, RMSE (mesma unidade do target), MAE (robusto a outliers), R²
  • Regularização Ridge (L2) reduz pesos, Lasso (L1) pode zerá-los (seleção de features)
  • Regressão logística aplica sigmoid à combinação linear para obter probabilidades
  • Função de perda da regressão logística é Binary Cross-Entropy
  • Limiar de 0.5 pode ser ajustado dependendo do custo relativo de FP vs FN
  • Curva ROC e AUC medem a capacidade discriminativa independente do limiar
  • Para multiclasse: One-vs-Rest ou Softmax (multinomial)
  • Os coeficientes de ambos os modelos são interpretáveis: magnitude indica importância, sinal indica direção do efeito

Exercícios

  1. Explique intuitivamente o que o R² mede. O que significa um R²=0.85? É sempre bom ter R² alto? Existe situação onde R² alto pode ser enganoso?

    ✓ Resposta:

    R² (coeficiente de determinação) mede a proporção da variância do target que é explicada pelo modelo. Matematicamente, compara o erro do modelo com o erro de simplesmente prever a média sempre.

    R²=0.85 significa que o modelo explica 85% da variação nos dados. Os outros 15% são variação que o modelo não consegue capturar, seja por ruído intrínseco, features ausentes ou relações não lineares.

    R² alto não é sempre bom. Existem duas situações principais onde pode ser enganoso:

    Overfitting: um modelo muito complexo pode atingir R²=0.99 no treino mas ter R²=0.3 no teste — memorizou o ruído. Por isso é essencial reportar o R² no conjunto de teste, nunca apenas no treino.

    Extrapolação perigosa: um modelo com R²=0.95 pode ser excelente dentro da faixa de dados de treino mas péssimo para prever valores fora dessa faixa. Por exemplo, um modelo de preço de casas treinado com imóveis de 50 a 200m² pode ter R² alto nessa faixa mas errar gravemente para imóveis de 500m².

  2. Qual a diferença matemática entre Ridge (L2) e Lasso (L1)? Por que Lasso pode zerar coeficientes mas Ridge raramente faz isso? Em qual situação você escolheria cada um?

    ✓ Resposta:

    Matematicamente, a diferença está na penalidade adicionada à função de perda:

    Ridge (L2): penalidade = α × Σwᵢ². A penalidade cresce quadraticamente com o peso. Para zerar um peso de valor 0.001, o gradiente da penalidade seria 2α × 0.001 — muito pequeno. O custo de reduzir um peso quase zero é mínimo, então Ridge os mantém pequenos mas raramente os zera.

    Lasso (L1): penalidade = α × Σ|wᵢ|. O gradiente da penalidade é constante (±α), independente do valor do peso. Isso cria uma força constante empurrando cada peso para zero. Quando o peso se torna suficientemente pequeno, essa força constante é maior que o gradiente dos dados, e o peso vai exatamente a zero.

    Escolha Ridge quando: você acredita que todas as features contribuem e quer apenas controlar a magnitude dos pesos. Ridge é melhor quando há muitas features levemente correlacionadas com o target.

    Escolha Lasso quando: você suspeita que apenas algumas features são realmente importantes e quer seleção automática. Lasso é melhor para datasets com muitas features irrelevantes, pois automaticamente as zera, produzindo um modelo mais interpretável.

  3. Explique por que regressão logística se chama "regressão" se é usada para classificação. O que ela está "regredindo" e como isso se transforma em uma classificação?

    ✓ Resposta:

    O nome "regressão" vem do que o modelo faz internamente: ele regride (modela) o log-odds (logaritmo da razão de chances) como uma função linear das features.

    log(P/(1-P)) = w₀ + w₁x₁ + ... + wₙxₙ
    

    Isso é literalmente uma regressão — uma combinação linear. A função sigmoid é simplesmente a transformação matemática que converte esse valor linear (que pode ser qualquer número real) para uma probabilidade entre 0 e 1. A classificação final (0 ou 1) é apenas a decisão tomada a partir dessa probabilidade usando um limiar.

    Então o modelo está "regredindo" o log-odds de pertencer à classe positiva. A saída intermediária é contínua e pode ser qualquer valor real. O que transforma isso em classificação é a aplicação do limiar no final.

  4. Você está construindo um modelo de detecção de fraude bancária onde apenas 0.1% das transações são fraudes. Descreva: qual métrica NÃO deve ser usada como principal, qual deve ser usada, e como você ajustaria o limiar de decisão. Justifique cada escolha.

    ✓ Resposta:

    Com 0.1% de fraudes, acurácia não deve ser usada como métrica principal. Um modelo que simplesmente diz "não é fraude" para todas as transações atingiria 99.9% de acurácia sem detectar nenhuma fraude real — completamente inútil.

    A métrica principal deve ser Recall (sensibilidade) para a classe fraude: dos casos reais de fraude, quantos o modelo detectou? Perder uma fraude real (falso negativo) causa dano direto ao banco e ao cliente. Um falso positivo (bloquear transação legítima) é inconveniente mas reversível.

    Complementarmente, monitore Precision para não gerar alarmes demais, e F1 como balanço. A área sob a curva ROC (AUC) é útil para comparar modelos independente do limiar.

    Para o limiar de decisão: reduza abaixo de 0.5, talvez para 0.1 ou 0.05. Isso aumenta o recall (detecta mais fraudes) ao custo de mais falsos positivos (mais transações legítimas bloqueadas). O limiar ideal depende do custo relativo: quanto custa uma fraude não detectada vs o custo operacional de revisar manualmente um alarme falso. Esse é um cálculo de negócio, não puramente técnico.

  5. O que é a curva ROC e o que o AUC representa? Um modelo com AUC=0.75 e outro com AUC=0.90: qual é melhor e em que sentido? Existe situação em que o modelo com AUC menor seria preferível?

    ✓ Resposta:

    A curva ROC plota a taxa de verdadeiros positivos (recall) no eixo Y contra a taxa de falsos positivos no eixo X para todos os possíveis limiares de decisão. Cada ponto na curva representa um limiar diferente.

    O AUC é a área sob essa curva. AUC=0.5 é equivalente a escolha aleatória (a curva é uma diagonal). AUC=1.0 é um classificador perfeito que separa completamente as classes.

    O modelo com AUC=0.90 é melhor em sentido geral: para qualquer limiar escolhido, tende a ter melhor tradeoff entre recall e falsos positivos.

    Situação onde o modelo com AUC menor seria preferível: se o custo computacional do modelo com AUC=0.90 for proibitivo e o com AUC=0.75 rodar 100x mais rápido para milhões de transações em tempo real. Ou se o modelo com AUC=0.75 for muito mais interpretável e o contexto exige explicabilidade (ex: decisões de crédito reguladas por lei que exigem justificativa de cada decisão). Performance não é o único critério em sistemas reais.

  6. Explique gradiente descendente usando uma analogia de montanhismo. Quais os problemas que podem ocorrer com learning rate muito alto e muito baixo? O que é o mínimo local vs mínimo global e por que isso importa?

    ✓ Resposta:

    Analogia: imagine que você está em uma montanha com neblina densa e quer chegar ao vale mais baixo (mínimo da função de perda). Você não consegue ver o vale diretamente, então usa uma estratégia: sinta a inclinação do chão sob seus pés (o gradiente) e dê um passo na direção de maior descida. Repita isso até não conseguir mais descer.

    O learning rate é o tamanho do passo:

    Learning rate muito alto: você dá passos enormes. Pode "pular" por cima do vale e ficar oscilando de um lado para o outro nas encostas sem nunca chegar ao fundo. Nos gráficos de treinamento, a loss sobe e desce erraticamente ou diverge para infinito.

    Learning rate muito baixo: você dá passos minúsculos. Vai chegar ao vale, mas vai demorar enormemente — talvez precisando de milhões de iterações para percorrer o caminho que poderia ser feito em mil iterações com um passo adequado.

    Mínimo local vs global: nas montanhas, pode haver vales menores no caminho antes de chegar ao vale principal. Se você der passos pequenos e cair em um vale raso (mínimo local), ficará preso ali e nunca encontrará o vale mais profundo (mínimo global).

    Em regressão linear, a função de perda (MSE) é convexa — tem apenas um vale (mínimo global), então gradiente descendente sempre converge para a solução ótima. Em redes neurais profundas, a função de perda tem muitos mínimos locais, mas na prática eles tendem a ser "bons o suficiente" e o problema do mínimo global é menos crítico do que parecia teoricamente.

Referências