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
- scikit-learn — documentação oficial — https://scikit-learn.org/stable/
- scikit-learn — guia do usuário — https://scikit-learn.org/stable/user_guide.html
- Escolhendo o estimador certo — https://scikit-learn.org/stable/tutorial/machine_learning_map/
- joblib — serialização — https://joblib.readthedocs.io/en/stable/
- GÉRON, Aurélien. Hands-On Machine Learning with Scikit-Learn, Keras and TensorFlow. 3. ed. O'Reilly Media, 2022. — a referência mais completa de ML prático com Python.
- MÜLLER, Andreas C.; GUIDO, Sarah. Introduction to Machine Learning with Python. O'Reilly Media, 2016. — introdução acessível focada em scikit-learn.
- RASCHKA, Sebastian; MIRJALILI, Vahid. Python Machine Learning. 3. ed. Packt, 2019. — cobertura ampla de algoritmos e técnicas de avaliação.
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.