Machine Learning com scikit-learn

Machine Learning com scikit-learn

O pipeline completo de Machine Learning com scikit-learn: pré-processamento com ColumnTransformer, seis classificadores comparados, regressão, validação cruzada, GridSearchCV, K-Means e joblib — e por que 97% de acurácia pode esconder um modelo que não acerta nenhum caso positivo.
Python

• • 16 min de leitura

Machine Learning é a capacidade de um sistema aprender padrões a partir de dados sem ser explicitamente programado para cada caso. scikit-learn é a biblioteca de ML mais usada em Python — oferece uma API consistente, bem documentada e que cobre a grande maioria dos algoritmos clássicos. Neste artigo percorremos o pipeline completo: preparação de dados, treinamento, avaliação e seleção de modelos.

O Pipeline de Machine Learning

Dados Brutos
     │
     ▼
Limpeza e EDA          ← pandas, NumPy
     │
     ▼
Feature Engineering    ← transformações, encoding
     │
     ▼
Divisão Train/Test     ← train_test_split
     │
     ▼
Pré-processamento      ← StandardScaler, OneHotEncoder
     │
     ▼
Treinamento            ← fit()
     │
     ▼
Avaliação              ← score(), métricas
     │
     ▼
Ajuste de Hiperparâmetros ← GridSearchCV
     │
     ▼
Modelo Final           ← predict(), deploy

Instalação

pip install scikit-learn

Tipos de Aprendizado

Supervisionado                  Não supervisionado
──────────────────              ──────────────────
Classificação                   Clustering
  - Logistic Regression           - K-Means
  - Random Forest                 - DBSCAN
  - SVM                           - Hierarchical
  - KNN
                                Redução de dimensionalidade
Regressão                         - PCA
  - Linear Regression             - t-SNE
  - Ridge / Lasso
  - Gradient Boosting

Preparando os Dados

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer


# Dataset simulado — previsão de aprovação de alunos
np.random.seed(42)
n = 500

df = pd.DataFrame({
    "horas_estudo":    np.random.uniform(1, 10, n),
    "faltas":          np.random.randint(0, 20, n),
    "nota_anterior":   np.random.uniform(3, 10, n),
    "participacao":    np.random.choice(["alta", "media", "baixa"], n),
    "turno":           np.random.choice(["manha", "tarde", "noite"], n),
})

# Variável alvo — criada com uma regra conhecida, mais ruído.
# A constante 2.5 põe a média perto de 6: sem ela, só 9 de 500 alunos
# passariam, e as métricas de classificação perderiam o sentido.
df["nota_final"] = (
    2.5 +
    0.4 * df["horas_estudo"] +
    0.4 * df["nota_anterior"] +
    -0.1 * df["faltas"] +
    np.random.normal(0, 0.8, n)
).clip(0, 10)

df["aprovado"] = (df["nota_final"] >= 6.0).astype(int)

print(df.head())
print(f"\nDistribuição: {df['aprovado'].value_counts().to_dict()}")

# Separando features e alvo
X = df.drop(columns=["nota_final", "aprovado"])
y = df["aprovado"]

# Divisão treino/teste
X_train, X_test, y_train, y_test = train_test_split(
    X, y,
    test_size=0.2,
    random_state=42,
    stratify=y    # mantém proporção das classes
)

print(f"\nTreino: {X_train.shape[0]} amostras")
print(f"Teste:  {X_test.shape[0]} amostras")

Pré-processamento com Pipeline

from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.impute import SimpleImputer


# Colunas por tipo
numericas   = ["horas_estudo", "faltas", "nota_anterior"]
categoricas = ["participacao", "turno"]

# Pipeline para numéricas: imputa nulos + normaliza
pipe_num = Pipeline([
    ("imputer", SimpleImputer(strategy="median")),
    ("scaler",  StandardScaler())
])

# Pipeline para categóricas: imputa nulos + one-hot encoding
pipe_cat = Pipeline([
    ("imputer", SimpleImputer(strategy="most_frequent")),
    ("encoder", OneHotEncoder(drop="first", sparse_output=False))
])

# Combina os dois pipelines
preprocessador = ColumnTransformer([
    ("num", pipe_num, numericas),
    ("cat", pipe_cat, categoricas)
])

# Transformando os dados
X_train_proc = preprocessador.fit_transform(X_train)
X_test_proc  = preprocessador.transform(X_test)

print(f"Shape após pré-processamento: {X_train_proc.shape}")

Classificação: Múltiplos Algoritmos

from sklearn.linear_model import LogisticRegression
from sklearn.tree import DecisionTreeClassifier
from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier
from sklearn.svm import SVC
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import (
    accuracy_score, precision_score, recall_score,
    f1_score, roc_auc_score, classification_report,
    confusion_matrix
)


def avaliar_classificador(nome, modelo, X_tr, X_te, y_tr, y_te):
    modelo.fit(X_tr, y_tr)
    y_pred = modelo.predict(X_te)
    # A AUC só precisa ordenar os alunos: serve probabilidade ou,
    # no SVC, a distância à fronteira de decisão
    if hasattr(modelo, "predict_proba"):
        y_score = modelo.predict_proba(X_te)[:, 1]
    else:
        y_score = modelo.decision_function(X_te)

    return {
        "modelo":     nome,
        "acuracia":   accuracy_score(y_te, y_pred),
        "precisao":   precision_score(y_te, y_pred, zero_division=0),
        "recall":     recall_score(y_te, y_pred, zero_division=0),
        "f1":         f1_score(y_te, y_pred, zero_division=0),
        "roc_auc":    roc_auc_score(y_te, y_score),
    }


modelos = [
    ("Regressão Logística",   LogisticRegression(max_iter=1000, random_state=42)),
    ("Árvore de Decisão",     DecisionTreeClassifier(random_state=42)),
    ("Random Forest",          RandomForestClassifier(n_estimators=100, random_state=42)),
    ("Gradient Boosting",      GradientBoostingClassifier(random_state=42)),
    ("SVM",                    SVC(random_state=42)),
    ("KNN",                    KNeighborsClassifier(n_neighbors=5)),
]

resultados = []
for nome, modelo in modelos:
    resultado = avaliar_classificador(
        nome, modelo,
        X_train_proc, X_test_proc,
        y_train, y_test
    )
    resultados.append(resultado)

df_resultados = pd.DataFrame(resultados).set_index("modelo")
print(df_resultados.round(4).to_string())

Métricas de Avaliação em Detalhe

from sklearn.metrics import (
    confusion_matrix, ConfusionMatrixDisplay,
    roc_curve, RocCurveDisplay,
    precision_recall_curve
)
import matplotlib.pyplot as plt


def avaliar_detalhado(modelo, X_test, y_test, nome_modelo="Modelo"):
    y_pred = modelo.predict(X_test)
    y_prob = modelo.predict_proba(X_test)[:, 1]

    print(f"\n{'='*50}")
    print(f"Relatório: {nome_modelo}")
    print('='*50)
    print(classification_report(y_test, y_pred,
          target_names=["Reprovado", "Aprovado"]))

    # Matriz de confusão
    cm = confusion_matrix(y_test, y_pred)
    tn, fp, fn, tp = cm.ravel()

    print(f"Verdadeiros Positivos (TP): {tp}")
    print(f"Verdadeiros Negativos (TN): {tn}")
    print(f"Falsos Positivos (FP):      {fp}  ← Reprovado previsto como Aprovado")
    print(f"Falsos Negativos (FN):      {fn}  ← Aprovado previsto como Reprovado")

    fig, axes = plt.subplots(1, 2, figsize=(12, 4))

    # Matriz de confusão visual
    ConfusionMatrixDisplay(cm, display_labels=["Reprovado", "Aprovado"]).plot(
        ax=axes[0], colorbar=False
    )
    axes[0].set_title(f"Matriz de Confusão — {nome_modelo}")

    # Curva ROC
    fpr, tpr, _ = roc_curve(y_test, y_prob)
    auc         = roc_auc_score(y_test, y_prob)
    axes[1].plot(fpr, tpr, label=f"AUC = {auc:.3f}", color="#2E75B6")
    axes[1].plot([0, 1], [0, 1], "k--", alpha=0.4)
    axes[1].set_xlabel("Taxa de Falso Positivo")
    axes[1].set_ylabel("Taxa de Verdadeiro Positivo")
    axes[1].set_title("Curva ROC")
    axes[1].legend()

    plt.tight_layout()
    plt.savefig(f"avaliacao_{nome_modelo.lower().replace(' ', '_')}.png", dpi=120)
    plt.show()


# Avaliando o melhor modelo
rf = RandomForestClassifier(n_estimators=100, random_state=42)
rf.fit(X_train_proc, y_train)
avaliar_detalhado(rf, X_test_proc, y_test, "Random Forest")

Importância de Features

def plotar_importancia_features(modelo, feature_names: list, top_n: int = 10):
    importancias = pd.Series(
        modelo.feature_importances_,
        index=feature_names
    ).sort_values(ascending=True).tail(top_n)

    fig, ax = plt.subplots(figsize=(8, 5))
    importancias.plot.barh(ax=ax, color="#2E75B6", alpha=0.8)
    ax.set_title(f"Top {top_n} Features mais Importantes")
    ax.set_xlabel("Importância")
    plt.tight_layout()
    plt.savefig("importancia_features.png", dpi=120)
    plt.show()

    return importancias


# Obtendo nomes das features após preprocessamento
nomes_num = numericas
nomes_cat = preprocessador.named_transformers_["cat"]["encoder"]\
            .get_feature_names_out(categoricas).tolist()
todos_nomes = nomes_num + nomes_cat

plotar_importancia_features(rf, todos_nomes)

Regressão

from sklearn.linear_model import LinearRegression, Ridge, Lasso
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
from sklearn.metrics import mean_squared_error, mean_absolute_error, r2_score


# Agora prevemos a nota_final (regressão)
y_reg_train = df.loc[X_train.index, "nota_final"]
y_reg_test  = df.loc[X_test.index,  "nota_final"]


def avaliar_regressor(nome, modelo, X_tr, X_te, y_tr, y_te):
    modelo.fit(X_tr, y_tr)
    y_pred = modelo.predict(X_te)
    rmse   = np.sqrt(mean_squared_error(y_te, y_pred))

    return {
        "modelo": nome,
        "R²":     r2_score(y_te, y_pred),
        "RMSE":   rmse,
        "MAE":    mean_absolute_error(y_te, y_pred),
    }


regressores = [
    ("Regressão Linear",    LinearRegression()),
    ("Ridge",               Ridge(alpha=1.0)),
    ("Lasso",               Lasso(alpha=0.1)),
    ("Random Forest",       RandomForestRegressor(n_estimators=100, random_state=42)),
    ("Gradient Boosting",   GradientBoostingRegressor(random_state=42)),
]

resultados_reg = []
for nome, modelo in regressores:
    r = avaliar_regressor(
        nome, modelo,
        X_train_proc, X_test_proc,
        y_reg_train, y_reg_test
    )
    resultados_reg.append(r)

print(pd.DataFrame(resultados_reg).set_index("modelo").round(4))

Validação Cruzada e GridSearchCV

from sklearn.model_selection import (
    cross_val_score, KFold,
    GridSearchCV, RandomizedSearchCV
)


# Validação cruzada — avaliação mais robusta
kf     = KFold(n_splits=5, shuffle=True, random_state=42)
modelo = RandomForestClassifier(random_state=42)

scores = cross_val_score(
    modelo, X_train_proc, y_train,
    cv=kf, scoring="f1"
)
print(f"F1 cross-val: {scores.mean():.4f} ± {scores.std():.4f}")


# Grid Search — busca exaustiva de hiperparâmetros
param_grid = {
    "n_estimators":      [50, 100, 200],
    "max_depth":         [None, 5, 10],
    "min_samples_split": [2, 5],
    "min_samples_leaf":  [1, 2],
}

grid_search = GridSearchCV(
    RandomForestClassifier(random_state=42),
    param_grid,
    cv=5,
    scoring="f1",
    n_jobs=-1,       # usa todos os núcleos
    verbose=1
)
grid_search.fit(X_train_proc, y_train)

print(f"\nMelhores parâmetros: {grid_search.best_params_}")
print(f"Melhor F1:           {grid_search.best_score_:.4f}")

# Randomized Search — mais eficiente para espaços grandes
from scipy.stats import randint

param_dist = {
    "n_estimators": randint(50, 300),
    "max_depth":    [None, 5, 10, 15, 20],
    "max_features": ["sqrt", "log2", None],
}

rand_search = RandomizedSearchCV(
    RandomForestClassifier(random_state=42),
    param_dist,
    n_iter=20,
    cv=5,
    scoring="f1",
    random_state=42,
    n_jobs=-1
)
rand_search.fit(X_train_proc, y_train)
print(f"\nRandomized — Melhor F1: {rand_search.best_score_:.4f}")

Pipeline Completo: Da Entrada ao Modelo

from sklearn.pipeline import Pipeline
from sklearn.ensemble import RandomForestClassifier


# Pipeline que une pré-processamento + modelo
pipeline_completo = Pipeline([
    ("preprocessador", preprocessador),
    ("classificador",  RandomForestClassifier(
        n_estimators=100,
        max_depth=10,
        random_state=42
    ))
])

# Treinamento em uma linha
pipeline_completo.fit(X_train, y_train)

# Predição diretamente nos dados brutos
y_pred = pipeline_completo.predict(X_test)
print(f"F1: {f1_score(y_test, y_pred):.4f}")
print(f"AUC: {roc_auc_score(y_test, pipeline_completo.predict_proba(X_test)[:, 1]):.4f}")

# Salvando o modelo
import joblib

joblib.dump(pipeline_completo, "modelo_aprovacao.pkl")
print("\nModelo salvo: modelo_aprovacao.pkl")

# Carregando e usando
modelo_carregado = joblib.load("modelo_aprovacao.pkl")

novo_aluno = pd.DataFrame([{
    "horas_estudo":    7.5,
    "faltas":          2,
    "nota_anterior":   8.0,
    "participacao":    "alta",
    "turno":           "manha"
}])

prob = modelo_carregado.predict_proba(novo_aluno)[0, 1]
pred = modelo_carregado.predict(novo_aluno)[0]
print(f"\nNovo aluno:")
print(f"  Probabilidade de aprovação: {prob:.1%}")
print(f"  Previsão: {'Aprovado' if pred == 1 else 'Reprovado'}")

Clustering: K-Means

from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.metrics import silhouette_score


# Agrupando alunos por perfil de desempenho
features_cluster = df[["horas_estudo", "faltas", "nota_anterior", "nota_final"]]
scaler           = StandardScaler()
X_cluster        = scaler.fit_transform(features_cluster)


# Inércia (para o gráfico do cotovelo) e silhouette para cada k
inercias    = []
silhouettes = []
k_range     = range(2, 8)

for k in k_range:
    kmeans = KMeans(n_clusters=k, random_state=42, n_init=10)
    kmeans.fit(X_cluster)
    inercias.append(kmeans.inertia_)
    silhouettes.append(silhouette_score(X_cluster, kmeans.labels_))

# Melhor k pelo silhouette
melhor_k = k_range[np.argmax(silhouettes)]
print(f"Melhor k: {melhor_k} (silhouette={max(silhouettes):.3f})")

# Modelo final
kmeans_final = KMeans(n_clusters=melhor_k, random_state=42, n_init=10)
df["cluster"] = kmeans_final.fit_predict(X_cluster)

# Perfil de cada cluster
perfil = df.groupby("cluster")[
    ["horas_estudo", "faltas", "nota_anterior", "nota_final"]
].mean().round(2)

print("\nPerfil dos clusters:")
print(perfil)

O scikit-learn deve boa parte do sucesso a uma decisão de projeto: todo estimador tem fit, todo transformador tem transform e todo modelo tem predict, e por isso seis classificadores diferentes cabem no mesmo laço e o pré-processamento cabe no mesmo Pipeline que o modelo. O caminho deste artigo — separar treino e teste com estratificação, preparar colunas numéricas e categóricas com um ColumnTransformer, comparar modelos com várias métricas, ajustar hiperparâmetros com validação cruzada e salvar o pipeline inteiro com joblib — é o mesmo para qualquer problema tabular. A preparação dos dados vem de Introdução ao Data Science com Python, e o próximo passo natural são as redes neurais, para dados que não cabem bem numa tabela.

O que decide se um modelo presta raramente é o algoritmo. É a pergunta que as métricas respondem: 97% de acurácia não quer dizer nada quando 98% dos casos são da mesma classe, e é por isso que o artigo reporta precisão, recall, F1 e AUC lado a lado. É também o que acontece fora do conjunto de teste — uma categoria que o treino nunca viu, uma versão diferente da biblioteca ao carregar o .pkl — e se o modelo simples já não resolve: quando a regra por trás dos dados é linear, a regressão linear ganha da floresta aleatória, e comparar com ela antes de escolher algo mais caro é sempre barato.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um estagiário apresenta um modelo de previsão de aprovação com 97% de acurácia no conjunto de teste e pede para colocá-lo em produção. A coordenadora olha a tabela e pergunta quantos aprovados o modelo acertou. A resposta é zero. Como as duas coisas podem ser verdade ao mesmo tempo?

Ver resposta

✓ Resposta: Aconteceu com a versão original do exemplo deste artigo. A regra que gerava a nota tinha média perto de 3, e só 9 dos 500 alunos passavam de 6: no teste estratificado, 98 reprovados e 2 aprovados. A Random Forest previu "reprovado" para quase todos e fez 97% de acurácia, com zero verdadeiros positivos, precisão 0, recall 0 e F1 0 — e o scikit-learn ainda emitiu UndefinedMetricWarning, porque a precisão fica indefinida quando o modelo não prevê a classe positiva. Um modelo que responde "reprovado" sem olhar os dados faria 98%. Acurácia só significa algo comparada à proporção da classe majoritária; com classes desbalanceadas, olhe recall e precisão da classe que interessa, a matriz de confusão e a AUC. O exemplo foi recalibrado para 299 aprovados em 500, e a mesma Random Forest passou a ter 83% de acurácia com F1 de 0,86 — números menores e muito mais honestos.

Exercício 2

Ao atualizar o scikit-learn para a 1.9, o laço de comparação de modelos começa a emitir um FutureWarning sobre SVC(probability=True). Um colega propõe simplesmente tirar o SVC da comparação, "porque sem predict_proba não dá para calcular a AUC". Ele está certo?

Ver resposta

✓ Resposta: Não. O aviso, medido na 1.9.1, diz que o parâmetro probability será removido na 1.11 e sugere CalibratedClassifierCV(SVC(), ensemble=False) para quem precisa de probabilidades. Mas a AUC não precisa delas: ela mede a chance de um positivo sorteado receber pontuação maior que um negativo sorteado, e para isso qualquer pontuação que ordene os casos serve. O SVC tem decision_function, a distância com sinal até a fronteira de decisão, e roc_auc_score aceita esse valor diretamente — é o que o artigo passou a fazer, com AUC de 0,898 para o SVM. Probabilidade calibrada só é necessária quando o número em si importa, como "73% de chance de aprovação" num painel ou um limiar de decisão ligado a custo. Havia ainda um custo escondido no probability=True: ele treina o modelo com validação cruzada interna para ajustar as probabilidades, e o predict pode discordar do predict_proba nos casos perto da fronteira.

Exercício 3

Na tabela de regressão do artigo, a regressão linear, o modelo mais simples da lista, teve R² de 0,727, e a Random Forest, com cem árvores, 0,678. Um aluno acha que houve erro no código. Explique o resultado e diga o que ele ensina sobre a escolha de modelos.

Ver resposta

✓ Resposta: Não houve erro: os dados foram gerados por uma soma ponderada de horas, nota anterior e faltas, mais ruído, e uma regressão linear consegue representar exatamente essa forma. O que ela não explica é o ruído, que nenhum modelo explica. A Random Forest aproxima a mesma relação com degraus — cada árvore divide o espaço em caixas e prevê a média de cada caixa —, e com 400 exemplos esses degraus ficam grosseiros; ela também não sabe extrapolar além das caixas vistas no treino. Por isso perde para a reta, e o Gradient Boosting, com 0,673, também. O RMSE conta a mesma história: 0,80 ponto contra 0,87. Modelos flexíveis ganham quando há interação e não linearidade que o modelo simples não captura; quando não há, pagam em variância. A regra prática é sempre treinar um modelo simples como linha de base e exigir que o complexo o supere por uma margem que justifique o custo.

Exercício 4

O código de K-Means do artigo testa k de 2 a 7 e escolhe o de maior silhouette. Deu k = 7, com silhouette 0,287, e a equipe pedagógica já está batizando os "sete perfis de aluno". O que deveria ter feito alguém desconfiar?

Ver resposta

✓ Resposta: Duas coisas. Primeiro, o vencedor foi o último valor do intervalo, o que sempre pede testar mais: estendendo até 14, o silhouette caiu depois de 7, para 0,234 em k = 14, mas o valor de k = 2 já era 0,280. De 2 a 8 grupos, a medida variou entre 0,259 e 0,287 — um platô, não um pico. Segundo, silhouette perto de 0,3 é fraco: indica grupos que se sobrepõem, não perfis distintos. O resultado faz sentido quando se lembra de como os dados foram gerados: horas, faltas e nota anterior sorteadas de distribuições uniformes, independentes, sem nenhum grupo natural. O K-Means sempre devolve k grupos, porque essa é a pergunta que ele responde; se os grupos existem é outra pergunta. Antes de dar nome aos perfis, compare com dados embaralhados, veja se os grupos se mantêm com outra semente e pergunte se a divisão serve para alguma decisão.

Exercício 5

O pipeline salvo em modelo_aprovacao.pkl vai para produção. Um mês depois, a escola abre o turno "madrugada" e o serviço de previsão começa a responder com erro 500 para esses alunos. Um desenvolvedor resolve em cinco minutos com handle_unknown="ignore" no OneHotEncoder. O erro some — e o problema continua. Qual problema?

Ver resposta

✓ Resposta: Sem a opção, o transform recusa a categoria nova: medido, ValueError: Found unknown categories ['madrugada'] in column 1 during transform. Com handle_unknown="ignore", a categoria desconhecida vira um vetor de zeros, só com um UserWarning. Acontece que o encoder do artigo usa drop="first": a primeira categoria em ordem alfabética, "manha", foi descartada, e é justamente ela que o vetor de zeros representa. Medido: "madrugada" e "manha" viram exatamente o mesmo vetor, [0, 0]. O modelo passa a tratar os alunos da madrugada como se estudassem de manhã, sem erro e sem alerta. O erro 500 era o sintoma honesto de que o modelo nunca viu aquele caso. As saídas corretas são registrar e monitorar categorias novas, retreinar quando elas aparecem e, se for para ignorá-las, não usar drop="first", para que "desconhecido" não coincida com uma categoria real.

Comentários

Mais em Python

Listas: criação, manipulação e métodos
Listas: criação, manipulação e métodos

A estrutura que aparece em praticamente todo programa Python: criação, índices…

Celery: Filas de Tarefas e Processamento Assíncrono
Celery: Filas de Tarefas e Processamento Assíncrono

Tarefas pesadas fora da requisição HTTP com Celery e Redis: filas separadas…

CI/CD com GitHub Actions
CI/CD com GitHub Actions

GitHub Actions na prática: CI com matriz e serviços, deploy com environments…