library(tidyverse)
library(tidymodels)
library(mlbench)
library(themis)
library(ggcorrplot)
library(skimr)
library(vip)
library(probably)
# Backends
library(ranger)
library(xgboost)
library(kknn)
set.seed(42)
theme_set(theme_bw(base_size = 13))Aprendizado de Máquina aplicado à Saúde
Problema de Classificação
1 Introdução
Neste documento vamos trabalhar com problemas de classificação — quando queremos prever uma categoria e não um número contínuo como no módulo anterior.
1.1 O que é um problema de classificação?
Um problema de classificação ocorre quando a variável resposta é categórica. Na área da saúde, exemplos abundam:
- Predizer se um paciente tem ou não diabetes ✓
- Classificar um tumor como benigno ou maligno
- Identificar se um paciente terá readmissão hospitalar
- Prever o diagnóstico entre múltiplas doenças
1.2 Dataset utilizado: Pima Indians Diabetes (UCI)
Usaremos o conjunto de dados Pima Indians Diabetes, do repositório UCI Machine Learning Repository, disponível no pacote mlbench. Ele contém dados de 768 mulheres da etnia Pima (nativos americanos do Arizona) com as seguintes variáveis:
| Variável | Tipo | Descrição |
|---|---|---|
pregnant |
Numérica | Número de gestações |
glucose |
Numérica | Concentração de glicose plasmática (mg/dL) |
pressure |
Numérica | Pressão arterial diastólica (mmHg) |
triceps |
Numérica | Espessura da dobra cutânea do tríceps (mm) |
insulin |
Numérica | Insulina sérica 2h pós-ingestão (mu U/ml) |
mass |
Numérica | Índice de Massa Corporal (kg/m²) |
pedigree |
Numérica | Função do histórico familiar de diabetes |
age |
Numérica | Idade (anos) |
diabetes |
Categórica | Diagnóstico de diabetes — variável resposta (pos/neg) |
Por que usamos
PimaIndiansDiabetes2e nãoPimaIndiansDiabetes? A versão2do pacotemlbenchsubstitui os zeros biologicamente impossíveis (ex: glicose = 0, IMC = 0) porNA. Esses zeros são erros de coleta, não medidas reais — tratar como zero distorceria os modelos.
Por que
mlbenchpara este dataset? Ao contrário do arquivo bruto do UCI, que não possui cabeçalho e mantém os zeros impossíveis, a versãoPimaIndiansDiabetes2já entrega os dados limpos e nomeados corretamente. Para um uso didático isso é preferível à reconstrução manual da limpeza.
1.3 Classificação binária vs. multiclasse
| Tipo | Exemplo | Variável resposta |
|---|---|---|
| Binária | Tem/não tem diabetes | 2 categorias |
| Multiclasse | Tipo de câncer | 3+ categorias |
Neste módulo tratamos a classificação binária, que é o caso mais comum na prática clínica.
2 Configuração do Ambiente
2.1 Instalação dos pacotes
# Execute apenas se necessário (uma única vez)
install.packages(c(
"tidyverse", # manipulação e visualização
"tidymodels", # framework de ML
"mlbench", # dataset Pima Indians
"themis", # balanceamento de classes (SMOTE)
"vip", # importância de variáveis
"ggcorrplot", # matriz de correlação
"skimr", # sumário estatístico
"ranger", # Random Forest (backend)
"xgboost", # XGBoost (backend)
"kknn", # KNN (backend)
"glmnet", # backend opcional para modelos regularizados
"probably". # threshold ótimo em modelos de classificação
))2.2 Carregamento dos pacotes
3 Carregamento dos Dados
data("PimaIndiansDiabetes2", package = "mlbench")
diabetes_df <- as_tibble(PimaIndiansDiabetes2)
glimpse(diabetes_df)Rows: 768
Columns: 9
$ pregnant <dbl> 6, 1, 8, 1, 0, 5, 3, 10, 2, 8, 4, 10, 10, 1, 5, 7, 0, 7, 1, 1…
$ glucose <dbl> 148, 85, 183, 89, 137, 116, 78, 115, 197, 125, 110, 168, 139,…
$ pressure <dbl> 72, 66, 64, 66, 40, 74, 50, NA, 70, 96, 92, 74, 80, 60, 72, N…
$ triceps <dbl> 35, 29, NA, 23, 35, NA, 32, NA, 45, NA, NA, NA, NA, 23, 19, N…
$ insulin <dbl> NA, NA, NA, 94, 168, NA, 88, NA, 543, NA, NA, NA, NA, 846, 17…
$ mass <dbl> 33.6, 26.6, 23.3, 28.1, 43.1, 25.6, 31.0, 35.3, 30.5, NA, 37.…
$ pedigree <dbl> 0.627, 0.351, 0.672, 0.167, 2.288, 0.201, 0.248, 0.134, 0.158…
$ age <dbl> 50, 31, 32, 21, 33, 30, 26, 29, 53, 54, 30, 34, 57, 59, 51, 3…
$ diabetes <fct> pos, neg, pos, neg, pos, neg, pos, neg, pos, pos, neg, pos, n…
4 Verificação da Qualidade dos Dados
4.1 Resumo estatístico completo
skim(diabetes_df)| Name | diabetes_df |
| Number of rows | 768 |
| Number of columns | 9 |
| _______________________ | |
| Column type frequency: | |
| factor | 1 |
| numeric | 8 |
| ________________________ | |
| Group variables | None |
Variable type: factor
| skim_variable | n_missing | complete_rate | ordered | n_unique | top_counts |
|---|---|---|---|---|---|
| diabetes | 0 | 1 | FALSE | 2 | neg: 500, pos: 268 |
Variable type: numeric
| skim_variable | n_missing | complete_rate | mean | sd | p0 | p25 | p50 | p75 | p100 | hist |
|---|---|---|---|---|---|---|---|---|---|---|
| pregnant | 0 | 1.00 | 3.85 | 3.37 | 0.00 | 1.00 | 3.00 | 6.00 | 17.00 | ▇▃▂▁▁ |
| glucose | 5 | 0.99 | 121.69 | 30.54 | 44.00 | 99.00 | 117.00 | 141.00 | 199.00 | ▁▇▇▃▂ |
| pressure | 35 | 0.95 | 72.41 | 12.38 | 24.00 | 64.00 | 72.00 | 80.00 | 122.00 | ▁▃▇▂▁ |
| triceps | 227 | 0.70 | 29.15 | 10.48 | 7.00 | 22.00 | 29.00 | 36.00 | 99.00 | ▆▇▁▁▁ |
| insulin | 374 | 0.51 | 155.55 | 118.78 | 14.00 | 76.25 | 125.00 | 190.00 | 846.00 | ▇▂▁▁▁ |
| mass | 11 | 0.99 | 32.46 | 6.92 | 18.20 | 27.50 | 32.30 | 36.60 | 67.10 | ▅▇▃▁▁ |
| pedigree | 0 | 1.00 | 0.47 | 0.33 | 0.08 | 0.24 | 0.37 | 0.63 | 2.42 | ▇▃▁▁▁ |
| age | 0 | 1.00 | 33.24 | 11.76 | 21.00 | 24.00 | 29.00 | 41.00 | 81.00 | ▇▃▁▁▁ |
4.2 Verificação de valores ausentes
diabetes_df |>
summarise(across(everything(), ~ sum(is.na(.)))) |>
pivot_longer(everything(), names_to = "variavel", values_to = "n_missing") |>
arrange(desc(n_missing))diabetes_df |>
summarise(across(everything(), ~ mean(is.na(.)) * 100)) |>
pivot_longer(everything(), names_to = "variavel", values_to = "pct") |>
ggplot(aes(x = reorder(variavel, pct), y = pct, fill = pct > 0)) +
geom_col() +
coord_flip() +
scale_fill_manual(values = c("FALSE" = "steelblue", "TRUE" = "tomato"),
guide = "none") +
labs(
title = "Percentual de Valores Ausentes por Variável",
subtitle = "Vermelho = variáveis com valores faltantes",
x = NULL,
y = "% de Valores Ausentes"
)Interpretação clínica: Os NAs em
glucose,pressure,triceps,insulinemassrepresentam medidas não coletadas, não zeros reais. Imputar zero seria um erro grave — por isso usaremos imputação por KNN no pré-processamento.
4.3 Verificação de duplicatas
tibble(
descricao = "Linhas duplicadas",
valor = sum(duplicated(diabetes_df))
)4.4 Distribuição da variável resposta — desbalanceamento de classes
contagem_classes <- diabetes_df |>
count(diabetes) |>
mutate(pct = round(n / sum(n) * 100, 1))
contagem_classesggplot(contagem_classes, aes(x = diabetes, y = n, fill = diabetes)) +
geom_col(show.legend = FALSE) +
geom_text(aes(label = paste0(n, "\n(", pct, "%)")),
vjust = -0.3, size = 4.5) +
scale_fill_manual(values = c("neg" = "steelblue", "pos" = "tomato")) +
labs(
title = "Distribuição das Classes",
subtitle = "neg = sem diabetes | pos = com diabetes",
x = "Diagnóstico",
y = "Contagem"
)Desbalanceamento de classes: É muito comum em saúde (doenças raras, eventos adversos). Um modelo que sempre prevê “negativo” teria ~65% de acurácia aqui — sem aprender nada. Vamos lidar com isso usando SMOTE no pré-processamento.
5 Análise Descritiva (EDA)
5.1 Distribuição das variáveis por classe
diabetes_df |>
pivot_longer(-diabetes, names_to = "variavel", values_to = "valor") |>
ggplot(aes(x = valor, fill = diabetes)) +
geom_density(alpha = 0.5) +
facet_wrap(~variavel, scales = "free") +
scale_fill_manual(values = c("neg" = "steelblue", "pos" = "tomato")) +
labs(
title = "Distribuição das Variáveis por Classe de Diabetes",
subtitle = "Sobreposição maior = menor poder discriminativo",
x = NULL,
y = "Densidade",
fill = "Diabetes"
)Insight: Variáveis com maior separação entre as curvas (como
glucose,masseage) tendem a ser mais preditivas. Variáveis com muita sobreposição têm menor poder discriminativo.
5.2 Boxplots comparativos
diabetes_df |>
pivot_longer(-diabetes, names_to = "variavel", values_to = "valor") |>
ggplot(aes(x = diabetes, y = valor, fill = diabetes)) +
geom_boxplot(alpha = 0.7, outlier.alpha = 0.3) +
facet_wrap(~variavel, scales = "free_y") +
scale_fill_manual(values = c("neg" = "steelblue", "pos" = "tomato")) +
labs(
title = "Comparação de Variáveis por Classe",
x = "Diagnóstico de Diabetes",
y = "Valor",
fill = "Diabetes"
)5.3 Testes de Wilcoxon por variável
diabetes_df |>
pivot_longer(-diabetes, names_to = "variavel", values_to = "valor") |>
group_by(variavel) |>
summarise(
p_valor = wilcox.test(valor ~ diabetes)$p.value,
.groups = "drop"
) |>
mutate(
significativo = if_else(p_valor < 0.05, "Sim ✓", "Não"),
p_valor = format.pval(p_valor, digits = 3)
) |>
arrange(p_valor)5.4 Matriz de correlação
diabetes_df |>
select(-diabetes) |>
cor(use = "pairwise.complete.obs") |>
ggcorrplot(
method = "circle",
type = "lower",
lab = TRUE,
lab_size = 3,
colors = c("#d73027", "white", "#1a9850"),
title = "Matriz de Correlação entre Variáveis Preditoras"
)6 Particionamento: Treino e Teste
split <- initial_split(diabetes_df, prop = 0.80, strata = diabetes)
treino <- training(split)
teste <- testing(split)
tibble(
conjunto = c("Treino", "Teste", "Total"),
observacoes = c(nrow(treino), nrow(teste), nrow(diabetes_df))
)# Verifica se a estratificação manteve a proporção de classes
bind_rows(
treino |> count(diabetes) |> mutate(conjunto = "Treino", pct = round(n / sum(n) * 100, 1)),
teste |> count(diabetes) |> mutate(conjunto = "Teste", pct = round(n / sum(n) * 100, 1))
) |>
select(conjunto, diabetes, n, pct)7 Pré-Processamento com recipes
A ordem dos passos é deliberada e importante:
| Passo | Por quê aqui |
|---|---|
step_impute_knn |
Primeiro: os passos seguintes não toleram NA |
step_corr |
Opera nas numéricas originais, antes de qualquer transformação de escala |
step_normalize |
Normaliza as numéricas antes do SMOTE — o SMOTE interpola, então a escala deve estar padronizada |
step_smote |
Cria exemplos sintéticos da classe minoritária no espaço normalizado |
step_zv |
Por último: remove qualquer preditor com variância zero após todas as transformações |
receita <- recipe(diabetes ~ ., data = treino) |>
# 1. Imputa NAs com KNN (usa as k observações mais similares)
step_impute_knn(all_numeric_predictors(), neighbors = 5) |>
# 2. Remove variáveis numéricas altamente correlacionadas (r > 0.90)
step_corr(all_numeric_predictors(), threshold = 0.90) |>
# 3. Normaliza as numéricas (média 0, dp 1) — antes do SMOTE
step_normalize(all_numeric_predictors()) |>
# 4. SMOTE: cria exemplos sintéticos da classe minoritária
step_smote(diabetes, over_ratio = 0.8) |>
# 5. Remove preditores com variância zero
step_zv(all_predictors())
receitaO que é SMOTE? Em vez de simplesmente duplicar observações da classe minoritária, o SMOTE cria exemplos sintéticos interpolando entre observações existentes. O
tidymodelsgarante que o SMOTE seja aplicado apenas no treino, nunca no teste.Por que normalizar antes do SMOTE? O SMOTE calcula distâncias entre observações para interpolar. Se as variáveis estiverem em escalas muito diferentes, variáveis com maior amplitude dominarão as distâncias. Normalizar primeiro garante que todas contribuam igualmente.
7.1 Verificando a receita aplicada
receita_prep <- prep(receita, training = treino)
treino_processado <- bake(receita_prep, new_data = NULL)
glimpse(treino_processado)Rows: 720
Columns: 9
$ pregnant <dbl> -0.83653374, -0.83653374, 0.32159865, 1.76926414, 0.03206555,…
$ glucose <dbl> -1.19371206, -1.06184815, -0.17176671, -0.20473269, -0.369562…
$ pressure <dbl> -0.53269284, -0.53269284, 0.11181852, 0.07959295, 1.56196908,…
$ triceps <dbl> 0.004231826, -0.662009439, -0.706425523, 0.181896163, 0.35956…
$ insulin <dbl> -0.96354258, -0.62645306, -0.55526645, 0.71562387, 0.20056782…
$ mass <dbl> -0.87295395, -0.65547816, -1.01793781, 0.38840562, 0.72186849…
$ pedigree <dbl> -0.36941236, -0.92033681, -0.81853555, -1.01914391, -0.848477…
$ age <dbl> -0.18841202, -1.03221201, -0.27279202, -0.35717202, -0.272792…
$ diabetes <fct> neg, neg, neg, neg, neg, neg, neg, neg, neg, neg, neg, neg, n…
# Verifica o balanceamento após SMOTE
treino_processado |>
count(diabetes) |>
mutate(pct = round(n / sum(n) * 100, 1))8 Definição dos Modelos
8.1 Regressão Logística Clássica
A regressão logística é a extensão natural da regressão linear para classificação binária. Ela estima a probabilidade de pertencer à classe positiva usando a função logística. É interpretável e serve como baseline natural para problemas de classificação.
modelo_logistico <- logistic_reg() |>
set_engine("glm") |>
set_mode("classification")Sem hiperparâmetros: assim como a regressão linear clássica, a regressão logística não requer
tune_grid(). Usaremosfit_resamples()para obter as métricas de CV e compará-la com os outros modelos em igualdade de condições.
8.2 K-Nearest Neighbors (KNN)
Para classificação, o KNN encontra os k vizinhos mais próximos e realiza votação majoritária — a classe mais frequente entre os vizinhos vence.
modelo_knn <- nearest_neighbor(
neighbors = tune()
) |>
set_engine("kknn") |>
set_mode("classification")8.3 Árvore de Decisão
modelo_arvore <- decision_tree(
cost_complexity = tune(),
tree_depth = tune(),
min_n = tune()
) |>
set_engine("rpart") |>
set_mode("classification")8.4 Random Forest
modelo_rf <- rand_forest(
mtry = tune(),
trees = 500,
min_n = tune()
) |>
set_engine("ranger", importance = "impurity") |>
set_mode("classification")8.5 XGBoost
modelo_xgb <- boost_tree(
trees = 500,
tree_depth = tune(),
learn_rate = tune(),
loss_reduction = tune(),
min_n = tune()
) |>
set_engine("xgboost") |>
set_mode("classification")9 Workflows
wf_logistico <- workflow() |> add_recipe(receita) |> add_model(modelo_logistico)
wf_knn <- workflow() |> add_recipe(receita) |> add_model(modelo_knn)
wf_arvore <- workflow() |> add_recipe(receita) |> add_model(modelo_arvore)
wf_rf <- workflow() |> add_recipe(receita) |> add_model(modelo_rf)
wf_xgb <- workflow() |> add_recipe(receita) |> add_model(modelo_xgb)10 Métricas de Avaliação para Classificação
As métricas de classificação são muito diferentes das de regressão. Vamos entender as principais:
10.1 A Matriz de Confusão
| Predito: Positivo | Predito: Negativo | |
|---|---|---|
| Real: Positivo | Verdadeiro Positivo (VP) | Falso Negativo (FN) |
| Real: Negativo | Falso Positivo (FP) | Verdadeiro Negativo (VN) |
10.2 Métricas derivadas
| Métrica | Fórmula | Pergunta respondida |
|---|---|---|
| Acurácia | (VP + VN) / Total | “Acertei no geral?” |
| Sensibilidade | VP / (VP + FN) | “Dos doentes, quantos identifiquei?” |
| Especificidade | VN / (VN + FP) | “Dos saudáveis, quantos identifiquei?” |
| Precisão | VP / (VP + FP) | “Dos que previ como doentes, quantos são?” |
| F1-Score | 2 × (Prec × Sens) / (Prec + Sens) | Equilíbrio precisão-sensibilidade |
| AUC-ROC | Área sob a curva ROC | Poder discriminativo geral |
Na saúde pública: Geralmente priorizamos sensibilidade — não deixar passar casos verdadeiros. Um exame de triagem deve ter alta sensibilidade, mesmo que gere mais falsos positivos que depois serão confirmados com outros exames.
metricas_classificacao <- metric_set(
roc_auc, # principal métrica para classificação binária
accuracy, # acurácia geral
sens, # sensibilidade
spec, # especificidade
f_meas # F1-Score
)11 Validação Cruzada e Otimização de Hiperparâmetros
11.1 Configuração dos folds
cv_folds <- vfold_cv(
treino,
v = 10,
repeats = 3,
strata = diabetes
)
cv_folds11.2 Grades de hiperparâmetros
grid_knn <- grid_regular(
neighbors(range = c(3, 30)),
levels = 15
)
grid_arvore <- grid_regular(
cost_complexity(range = c(-4, -1)),
tree_depth(range = c(2, 8)),
min_n(range = c(5, 30)),
levels = 4
)
grid_rf <- grid_regular(
mtry(range = c(2, 7)),
min_n(range = c(2, 20)),
levels = 5
)
grid_xgb <- grid_latin_hypercube(
tree_depth(range = c(2, 8)),
learn_rate(range = c(-3, -1)),
loss_reduction(range = c(-5, 0)),
min_n(range = c(5, 30)),
size = 30
)11.3 Executando avaliação e tuning
# Regressão Logística: sem hiperparâmetros
rs_logistico <- fit_resamples(
wf_logistico,
resamples = cv_folds,
metrics = metricas_classificacao
)
tune_knn <- tune_grid(
wf_knn,
resamples = cv_folds,
grid = grid_knn,
metrics = metricas_classificacao
)
tune_arvore <- tune_grid(
wf_arvore,
resamples = cv_folds,
grid = grid_arvore,
metrics = metricas_classificacao
)
tune_rf <- tune_grid(
wf_rf,
resamples = cv_folds,
grid = grid_rf,
metrics = metricas_classificacao
)
tune_xgb <- tune_grid(
wf_xgb,
resamples = cv_folds,
grid = grid_xgb,
metrics = metricas_classificacao
)12 Análise dos Resultados do Tuning
12.1 Curvas de tuning
Para a regressão logística não há curvas de tuning (sem hiperparâmetros). Para os demais:
autoplot(tune_knn) +
labs(title = "Tuning do KNN",
subtitle = "Efeito do número de vizinhos nas métricas de CV")autoplot(tune_arvore) +
labs(title = "Tuning da Árvore de Decisão")autoplot(tune_rf) +
labs(title = "Tuning do Random Forest")autoplot(tune_xgb) +
labs(title = "Tuning do XGBoost")12.2 Melhores hiperparâmetros (por AUC-ROC)
best_knn <- select_best(tune_knn, metric = "roc_auc")
best_arvore <- select_best(tune_arvore, metric = "roc_auc")
best_rf <- select_best(tune_rf, metric = "roc_auc")
best_xgb <- select_best(tune_xgb, metric = "roc_auc")Melhores hiperparâmetros do KNN:
best_knnMelhores hiperparâmetros da Árvore de Decisão:
best_arvoreMelhores hiperparâmetros do Random Forest:
best_rfMelhores hiperparâmetros do XGBoost:
best_xgb12.3 Comparação na Cross-Validation
resumo_cv <- bind_rows(
collect_metrics(rs_logistico) |>
filter(.metric == "roc_auc") |>
mutate(modelo = "Reg. Logística"),
collect_metrics(tune_knn) |>
filter(.metric == "roc_auc") |>
slice_max(mean, n = 1) |>
mutate(modelo = "KNN"),
collect_metrics(tune_arvore) |>
filter(.metric == "roc_auc") |>
slice_max(mean, n = 1) |>
mutate(modelo = "Árvore de Decisão"),
collect_metrics(tune_rf) |>
filter(.metric == "roc_auc") |>
slice_max(mean, n = 1) |>
mutate(modelo = "Random Forest"),
collect_metrics(tune_xgb) |>
filter(.metric == "roc_auc") |>
slice_max(mean, n = 1) |>
mutate(modelo = "XGBoost")
) |>
select(modelo, auc_cv = mean, std_err) |>
arrange(desc(auc_cv))
resumo_cvggplot(resumo_cv, aes(x = reorder(modelo, auc_cv), y = auc_cv)) +
geom_col(fill = "steelblue") +
geom_errorbar(aes(ymin = auc_cv - std_err, ymax = auc_cv + std_err),
width = 0.3, color = "darkred") +
geom_text(aes(label = round(auc_cv, 3)), hjust = -0.2, size = 4) +
coord_flip() +
scale_y_continuous(limits = c(0, 1)) +
labs(
title = "Comparação de Modelos — AUC-ROC na Cross-Validation",
subtitle = "Barras de erro = ± 1 erro padrão | Maior é melhor",
x = "Modelo",
y = "AUC-ROC"
)13 Finalização e Avaliação no Conjunto de Teste
13.1 Finalizando os workflows
A regressão logística não precisa de finalização. Para os demais:
wf_final_knn <- finalize_workflow(wf_knn, best_knn)
wf_final_arvore <- finalize_workflow(wf_arvore, best_arvore)
wf_final_rf <- finalize_workflow(wf_rf, best_rf)
wf_final_xgb <- finalize_workflow(wf_xgb, best_xgb)13.2 Avaliação final no conjunto de teste
Regra de ouro: o conjunto de teste só deve ser usado uma única vez, no final. Qualquer decisão tomada com base no teste invalida a estimativa de desempenho real.
resultado_logistico <- last_fit(wf_logistico, split, metrics = metricas_classificacao)
resultado_knn <- last_fit(wf_final_knn, split, metrics = metricas_classificacao)
resultado_arvore <- last_fit(wf_final_arvore, split, metrics = metricas_classificacao)
resultado_rf <- last_fit(wf_final_rf, split, metrics = metricas_classificacao)
resultado_xgb <- last_fit(wf_final_xgb, split, metrics = metricas_classificacao)13.3 Tabela comparativa de todas as métricas
metricas_teste <- bind_rows(
collect_metrics(resultado_logistico) |> mutate(modelo = "Reg. Logística"),
collect_metrics(resultado_knn) |> mutate(modelo = "KNN"),
collect_metrics(resultado_arvore) |> mutate(modelo = "Árvore de Decisão"),
collect_metrics(resultado_rf) |> mutate(modelo = "Random Forest"),
collect_metrics(resultado_xgb) |> mutate(modelo = "XGBoost")
) |>
select(modelo, metrica = .metric, valor = .estimate) |>
pivot_wider(names_from = metrica, values_from = valor) |>
arrange(desc(roc_auc))
metricas_testemetricas_teste |>
pivot_longer(-modelo, names_to = "metrica", values_to = "valor") |>
mutate(metrica = factor(metrica,
levels = c("roc_auc", "accuracy", "sens", "spec", "f_meas"),
labels = c("AUC-ROC", "Acurácia", "Sensibilidade", "Especificidade", "F1")
)) |>
ggplot(aes(x = metrica, y = reorder(modelo, valor), fill = valor)) +
geom_tile(color = "white", linewidth = 0.5) +
geom_text(aes(label = round(valor, 3)), size = 3.5, fontface = "bold") +
scale_fill_gradient2(
low = "#d73027",
mid = "white",
high = "#1a9850",
midpoint = 0.7,
limits = c(0, 1)
) +
labs(
title = "Heatmap de Desempenho — Conjunto de Teste",
x = "Métrica",
y = "Modelo",
fill = "Valor"
)14 Análise Aprofundada do Melhor Modelo
melhor_nome <- metricas_teste |> slice_max(roc_auc, n = 1) |> pull(modelo)
melhor_resultado <- switch(melhor_nome,
"Reg. Logística" = resultado_logistico,
"KNN" = resultado_knn,
"Árvore de Decisão" = resultado_arvore,
"Random Forest" = resultado_rf,
"XGBoost" = resultado_xgb
)O melhor modelo no conjunto de teste foi: Reg. Logística.
14.1 Curvas ROC de todos os modelos
roc_data <- bind_rows(
collect_predictions(resultado_logistico) |> mutate(modelo = "Reg. Logística"),
collect_predictions(resultado_knn) |> mutate(modelo = "KNN"),
collect_predictions(resultado_arvore) |> mutate(modelo = "Árvore de Decisão"),
collect_predictions(resultado_rf) |> mutate(modelo = "Random Forest"),
collect_predictions(resultado_xgb) |> mutate(modelo = "XGBoost")
)
roc_data |>
group_by(modelo) |>
roc_curve(truth = diabetes, .pred_pos, event_level = "second") |>
ggplot(aes(x = 1 - specificity, y = sensitivity, color = modelo)) +
geom_line(linewidth = 1.1) +
geom_abline(linetype = "dashed", color = "gray50") +
scale_color_brewer(palette = "Set1") +
labs(
title = "Curvas ROC — Conjunto de Teste",
subtitle = "Linha diagonal = classificador aleatório | Canto superior esquerdo = melhor",
x = "1 − Especificidade (Taxa de Falsos Positivos)",
y = "Sensibilidade (Taxa de Verdadeiros Positivos)",
color = "Modelo"
) +
coord_equal()Como ler a curva ROC: O eixo X representa o custo (falsos positivos) e o Y o benefício (verdadeiros positivos). Uma curva próxima do canto superior esquerdo indica alto benefício com baixo custo. A AUC resume esse comportamento em um único número entre 0 e 1.
14.2 Curvas de Precisão-Recall
Quando as classes são desbalanceadas, a curva Precisão-Recall é mais informativa que a ROC:
roc_data |>
group_by(modelo) |>
pr_curve(truth = diabetes, .pred_pos, event_level = "second") |>
ggplot(aes(x = recall, y = precision, color = modelo)) +
geom_line(linewidth = 1.1) +
scale_color_brewer(palette = "Set1") +
labs(
title = "Curvas Precisão-Recall — Conjunto de Teste",
subtitle = "Mais informativa quando há desbalanceamento de classes",
x = "Recall (Sensibilidade)",
y = "Precisão",
color = "Modelo"
)14.3 Matriz de Confusão do Melhor Modelo
conf_mat_resultado <- collect_predictions(melhor_resultado) |>
conf_mat(truth = diabetes, estimate = .pred_class)
autoplot(conf_mat_resultado, type = "heatmap") +
scale_fill_gradient(low = "white", high = "steelblue") +
labs(title = paste("Matriz de Confusão —", melhor_nome))summary(conf_mat_resultado) |>
select(.metric, .estimate) |>
mutate(
.metric = case_when(
.metric == "accuracy" ~ "Acurácia",
.metric == "sens" ~ "Sensibilidade",
.metric == "spec" ~ "Especificidade",
.metric == "ppv" ~ "Valor Preditivo Positivo",
.metric == "npv" ~ "Valor Preditivo Negativo",
.metric == "f_meas" ~ "F1-Score",
.metric == "kap" ~ "Kappa",
TRUE ~ .metric
),
.estimate = round(.estimate, 4)
) |>
filter(.metric %in% c("Acurácia", "Sensibilidade", "Especificidade",
"Valor Preditivo Positivo", "Valor Preditivo Negativo",
"F1-Score", "Kappa"))Interpretação clínica: - Sensibilidade: dos pacientes diabéticos, quantos o modelo identificou corretamente? Alta sensibilidade é crítica para triagem. - Especificidade: dos não-diabéticos, quantos foram corretamente classificados? Importante para evitar tratamentos desnecessários. - VPP: quando o modelo prevê positivo, com que frequência está certo? - VPN: quando o modelo prevê negativo, com que frequência está certo?
14.4 Importância das variáveis — Random Forest
modelo_rf_final <- extract_fit_parsnip(resultado_rf)
vip(modelo_rf_final,
num_features = 8,
aesthetics = list(fill = "steelblue")) +
labs(
title = "Importância das Variáveis — Random Forest",
subtitle = "Baseada na redução de impureza (Gini)"
)14.5 Coeficientes da Regressão Logística
A regressão logística tem a vantagem de ser completamente interpretável via odds ratios:
extract_fit_parsnip(resultado_logistico) |>
tidy() |>
filter(term != "(Intercept)") |>
mutate(
odds_ratio = exp(estimate),
significativo = if_else(p.value < 0.05, "Sim ✓", "Não")
) |>
select(term, estimate, odds_ratio, p.value, significativo) |>
arrange(p.value)extract_fit_parsnip(resultado_logistico) |>
tidy() |>
filter(term != "(Intercept)") |>
ggplot(aes(x = reorder(term, estimate),
y = estimate,
fill = estimate > 0)) +
geom_col(show.legend = FALSE) +
geom_errorbar(aes(ymin = estimate - std.error,
ymax = estimate + std.error),
width = 0.3) +
coord_flip() +
scale_fill_manual(values = c("FALSE" = "tomato", "TRUE" = "steelblue")) +
labs(
title = "Coeficientes da Regressão Logística",
subtitle = "Azul = aumenta chance de diabetes | Vermelho = reduz",
x = "Preditor",
y = "Coeficiente (escala log-odds)"
)14.6 Análise do ponto de corte
Por padrão usamos 0.5 como ponto de corte. Mas na prática clínica podemos ajustá-lo para priorizar sensibilidade ou especificidade:
threshold_data <- collect_predictions(melhor_resultado) |>
threshold_perf(
truth = diabetes,
estimate = .pred_pos,
thresholds = seq(0.1, 0.9, by = 0.05)
)
threshold_data |>
filter(.metric %in% c("sens", "spec", "j_index")) |>
mutate(.metric = case_when(
.metric == "sens" ~ "Sensibilidade",
.metric == "spec" ~ "Especificidade",
.metric == "j_index" ~ "Índice de Youden"
)) |>
ggplot(aes(x = .threshold, y = .estimate, color = .metric)) +
geom_line(linewidth = 1.1) +
geom_vline(xintercept = 0.5, linetype = "dashed", color = "gray50") +
scale_color_manual(values = c(
"Sensibilidade" = "tomato",
"Especificidade" = "steelblue",
"Índice de Youden" = "darkgreen"
)) +
labs(
title = paste("Sensibilidade vs. Especificidade —", melhor_nome),
subtitle = "Youden máximo = ponto de corte ótimo | Linha tracejada = ponto de corte padrão (0.5)",
x = "Ponto de corte",
y = "Valor da métrica",
color = NULL
)ponto_corte_otimo <- threshold_data |>
filter(.metric == "j_index") |>
slice_max(.estimate, n = 1) |>
select(.threshold, j_index = .estimate)
ponto_corte_otimoAjuste do ponto de corte na prática: Se for mais grave perder um caso positivo (falso negativo) do que classificar um saudável como doente (falso positivo), devemos abaixar o ponto de corte. Isso aumenta a sensibilidade à custa da especificidade.
15 Conclusões
15.1 Resumo dos resultados
metricas_teste |>
mutate(across(where(is.numeric), ~ round(., 3))) |>
arrange(desc(roc_auc))O melhor modelo foi Reg. Logística, com AUC-ROC de 0.812.
15.2 Comparação entre os dois problemas: Regressão X Classificação
| Aspecto | Regressão | Classificação |
|---|---|---|
| Tipo de resposta | Numérica contínua | Categórica |
| Modelo baseline | Regressão Linear | Regressão Logística |
| Métrica principal | RMSE, R² | AUC-ROC, Sensibilidade |
| Avaliação visual | Predito vs. Real, Resíduos | Curva ROC, Matriz de Confusão |
| Desafio específico | Multicolinearidade | Desbalanceamento de classes |
| Interpretação | Coeficientes lineares | Coeficientes em log-odds / OR |
15.3 Lições aprendidas
Acurácia é enganosa em datasets desbalanceados. Use sempre AUC-ROC, sensibilidade e especificidade juntos.
Contexto clínico guia o ponto de corte: a escolha entre falsos positivos e falsos negativos depende das consequências clínicas de cada erro.
Ordem dos steps importa: normalizar antes do SMOTE garante que a interpolação ocorra no espaço correto.
SMOTE apenas no treino: o
tidymodelsgarante isso automaticamente dentro do workflow.Regressão logística como baseline: é interpretável via odds ratios e serve como referência para avaliar se a complexidade dos modelos mais sofisticados se justifica.
Conjunto de teste: use uma única vez. Qualquer decisão tomada com base no teste invalida a estimativa de desempenho real.
16 Referências
- Kuhn, M. & Silge, J. (2022). Tidy Modeling with R. O’Reilly. Disponível em: https://www.tmwr.org/
- Smith, J.W. et al. (1988). Using the ADAP learning algorithm to forecast the onset of diabetes mellitus. Proceedings of the Annual Symposium on Computer Application in Medical Care, 261–265.
- Chawla, N.V. et al. (2002). SMOTE: Synthetic Minority Over-sampling Technique. Journal of Artificial Intelligence Research, 16, 321–357.
- Fawcett, T. (2006). An introduction to ROC analysis. Pattern Recognition Letters, 27(8), 861–874.
- UCI Machine Learning Repository — Pima Indians Diabetes: https://archive.ics.uci.edu/dataset/34/diabetes