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

Design Patterns em Python
Design Patterns em Python

Singleton, Factory, Builder, Adapter, Facade, Strategy, Observer, Command…

Estruturas de Controle: if, elif e else
Estruturas de Controle: if, elif e else

Decidir é o que separa um script de um programa. O if, o elif e o else, a…

Logging, Monitoramento e Observabilidade
Logging, Monitoramento e Observabilidade

Logging estruturado em JSON, ContextVar, métricas com Prometheus, Sentry e…