Aprendizado de Máquina aplicado à Saúde

Problema de Classificação

Autor

Ana H. A. Silva, Cristina K. T. T. Mendes, Marcelo R. P. Ferreira

Data de Publicação

21 de maio de 2026

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 PimaIndiansDiabetes2 e não PimaIndiansDiabetes? A versão 2 do pacote mlbench substitui os zeros biologicamente impossíveis (ex: glicose = 0, IMC = 0) por NA. Esses zeros são erros de coleta, não medidas reais — tratar como zero distorceria os modelos.

Por que mlbench para 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ão PimaIndiansDiabetes2 já 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

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))

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)
Data summary
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, insulin e mass representam 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_classes
ggplot(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, mass e age) 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())

receita

O 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 tidymodels garante 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(). Usaremos fit_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_folds

11.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_knn

Melhores hiperparâmetros da Árvore de Decisão:

best_arvore

Melhores hiperparâmetros do Random Forest:

best_rf

Melhores hiperparâmetros do XGBoost:

best_xgb

12.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_cv
ggplot(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_teste
metricas_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_otimo

Ajuste 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

  1. Acurácia é enganosa em datasets desbalanceados. Use sempre AUC-ROC, sensibilidade e especificidade juntos.

  2. 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.

  3. Ordem dos steps importa: normalizar antes do SMOTE garante que a interpolação ocorra no espaço correto.

  4. SMOTE apenas no treino: o tidymodels garante isso automaticamente dentro do workflow.

  5. 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.

  6. 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