Árvore de decisão
Introdução
A árvore de decisão é um algoritmo de aprendizado de máquina utilizado para resolver problemas de classificação e regressão. Seu funcionamento se baseia em uma estrutura hierárquica de nós, onde cada divisão (ramo) representa uma condição aplicada sobre uma variável, conduzindo a diferentes caminhos até chegar a um resultado final (folha). Essa técnica se destaca por sua simplicidade e interpretabilidade, permitindo compreender de forma clara quais fatores influenciam nas decisões do modelo. Além disso, as árvores de decisão conseguem lidar com variáveis numéricas e categóricas, sendo uma ferramenta versátil para análise e previsão em diferentes contextos.
Exploração dos Dados
O Dataset
Para esse projeto foi utilizada o Dataset Fitness Classification Dataset. Essa Base de dados posuí 2.000 linhas e 11 colunas. A variável dependente que será usada como objeto de classificação é a is_fit, ela indica se a pessoa é fit (1) ou não fit (0).
Análise dos dados
Tipo: numérica contínua
O que é: idade em anos.
Para que serve: pode relacionar-se com hábitos e condição física.
Ação necessária: nenhuma obrigatória; só checar faixas implausíveis (não observei no geral).
Tipo: numérica contínua
O que é: altura em centímetros.
Para que serve: isoladamente costuma ter pouco poder; combinada ao peso forma o BMI.
Ação necessária: checar valores muito fora do plausível. Sugestão: considerar substituir altura e peso por bmi(Índice de Massa Corporal).
Tipo: numérica contínua
O que é: peso em quilogramas.
Para que serve: junto com a altura permite calcular BMI = peso(kg) / (altura(m))², que costuma ser mais informativo para a árvore.
Ação necessária: manter como numérica ou criar bmi e remover height_cm/weight_kg das features (deixando só o bmi).
Tipo: numérica contínua
O que é: frequência cardíaca (bpm).
Para que serve: indicador de condicionamento cardiovascular; pode ajudar na separação das classes.
Ação necessária: nenhuma obrigatória; apenas conferir plausibilidade de valores extremos.
Tipo: numérica contínua
O que é: medida sintética de pressão arterial fornecida pelo dataset.
Para que serve: sinal de saúde geral que pode complementar a predição.
Ação necessária: nenhuma obrigatória; só verificar extremos muito fora do usual.
Tipo: numérica contínua
O que é: horas de sono por dia.
Para que serve: hábito de descanso; costuma ter correlação com “estar fit”.
Ação necessária: possui valores ausentes (160 valores); imputar com a mediana.
Tipo: numérica contínua (escala)
O que é: qualidade da nutrição (escala contínua, ex.: 0–10).
Para que serve: proxy de alimentação saudável; geralmente relevante.
Ação necessária: nenhuma; manter como numérica (só garantir faixa válida).
Tipo: numérica contínua (escala)
O que é: nível de atividade física (escala contínua, ex.: 0–10).
Para que serve: costuma ser uma das variáveis mais importantes para is_fit.
Ação necessária: nenhuma; manter como numérica (garantir faixa válida).
Tipo: categórica binária
O que é: status de tabagismo (sim/não).
Para que serve: fator de estilo de vida; pode ajudar a separar perfis.
Ação necessária: tipos mistos no bruto (“yes/no” e “1/0”). Padronizar para binário numérico (no→0, yes→1) e converter para int.
Tipo: categórica binária
O que é: gênero (F/M).
Para que serve: possível moderador de outros efeitos; em geral fraco sozinho.
Ação necessária: codificar para numérico (F→0, M→1) e converter para int.
Tipo: categórica binária (target)
O que é: rótulo de condição física (1 = fit, 0 = não fit).
Para que serve: variável dependente a ser prevista.
Ação necessária: checar balanceamento das classes.
Pré-processamento
Nesta etapa tratei e preparei os dados para treinar a Árvore de Decisão. Antes do tratamento, a base apresentava valores ausentes em sleep_hours, tipos mistos em smokes (valores como yes/no e 0/1 ao mesmo tempo) e variáveis categóricas em texto (gender com F/M). Abaixo, o que foi feito:
• Padronização de categóricas
-
smokes: normalizei rótulos e converti para binário numérico (no→0, yes→1, cobrindo também 0/1 em string).
-
gender: converti F→0 e M→1.
• Valores ausentes
- sleep_hours: converti para numérico e imputei a mediana.
• Tipos e consistência
- Garanti que as variáveis contínuas ficaram em formato numérico, sem strings residuais/espaços.
• Criação de nova variável
- Criei a variável BMI (peso(kg) / altura(m)²) para avaliar seu impacto. Na exploração, mantenho height_cm e weight_kg para referência; na modelagem, comparo dois cenários: (A) sem BMI (altura + peso) e (B) com apenas BMI, evitando usar os três juntos no mesmo modelo para não introduzir redundância.
| age | height_cm | weight_kg | heart_rate | blood_pressure | sleep_hours | nutrition_quality | activity_index | smokes | gender | is_fit |
|---|---|---|---|---|---|---|---|---|---|---|
| 43 | 193 | 80 | 62.4 | 109.9 | 8.9 | 4.17 | 2.08 | yes | F | 0 |
| 64 | 199 | 104 | 72.7 | 136.6 | 6.4 | 1.7 | 3.47 | 0 | F | 0 |
| 66 | 190 | 65 | 73.5 | 116.2 | 8.3 | 2.5 | 1.67 | 0 | M | 0 |
| 55 | 175 | 45 | 87.5 | 128.2 | 8.5 | 5.52 | 4.15 | no | F | 0 |
| 29 | 174 | 82 | 77.1 | 124 | 7.4 | 2.78 | 1.97 | no | M | 1 |
| 69 | 171 | 74 | 72 | 111.1 | 7.7 | 6.77 | 3.09 | yes | F | 0 |
| 42 | 167 | 103 | 85.7 | 138.2 | 9.4 | 7.25 | 4.18 | yes | F | 1 |
| 20 | 181 | 56 | 70.3 | 115.6 | 6.8 | 1.22 | 2.55 | 1 | F | 0 |
| 75 | 165 | 72 | 89.3 | 100.5 | 9.3 | 7.15 | 1.82 | yes | M | 1 |
| 64 | 184 | 53 | 71.6 | 142.1 | 7.6 | 4.11 | 3.47 | yes | M | 0 |
| 43 | 156 | 117 | 60.7 | 113.2 | 5.6 | 3.91 | 2.55 | yes | F | 0 |
| 78 | 178 | 111 | 83.2 | 122 | 7.8 | 9.29 | 2.36 | no | F | 0 |
| 68 | 197 | 59 | 75.2 | 97.9 | 7.9 | 1.46 | 2.28 | yes | M | 0 |
| 73 | 173 | 75 | 65.7 | 149.7 | 9.6 | 2.37 | 3.24 | 0 | F | 0 |
| 66 | 174 | 69 | 74.1 | 121.2 | 5.3 | 6.8 | 4.98 | no | F | 1 |
import pandas as pd
df = pd.read_csv("./src/fitness_dataset.csv")
df["sleep_hours"] = df["sleep_hours"].fillna(df["sleep_hours"].median())
df["smokes"] = (
df["smokes"].astype(str).str.strip().str.lower()
.map({"yes": 1, "no": 0, "1": 1, "0": 0})
).astype(int)
df["gender"] = df["gender"].replace({"F": 0, "M": 1}).astype(int)
h_m = pd.to_numeric(df["height_cm"], errors="coerce") / 100.0
bmi = pd.to_numeric(df["weight_kg"], errors="coerce") / (h_m**2)
df["bmi"] = bmi.replace([float("inf"), float("-inf")], pd.NA).fillna(bmi.median())
print(df.sample(n=15).to_markdown(index=False))
| age | height_cm | weight_kg | heart_rate | blood_pressure | sleep_hours | nutrition_quality | activity_index | smokes | gender | is_fit | bmi |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 24 | 169 | 49 | 63.1 | 117.9 | 10.6 | 4.31 | 2.72 | 0 | 1 | 0 | 17.1563 |
| 46 | 178 | 97 | 70.4 | 122.8 | 8.7 | 6.38 | 1.72 | 0 | 0 | 0 | 30.6148 |
| 77 | 151 | 62 | 59.7 | 157.7 | 8.1 | 1.09 | 3.84 | 0 | 1 | 0 | 27.1918 |
| 47 | 163 | 72 | 58.9 | 120.3 | 7 | 0.04 | 4.98 | 1 | 1 | 0 | 27.0993 |
| 51 | 184 | 102 | 75.5 | 114.3 | 8 | 1 | 2.62 | 1 | 1 | 0 | 30.1276 |
| 41 | 183 | 60 | 70.9 | 132.2 | 4.3 | 0.99 | 2.16 | 1 | 1 | 0 | 17.9163 |
| 20 | 196 | 75 | 56.8 | 113.1 | 9.2 | 6.83 | 4.94 | 0 | 0 | 1 | 19.5231 |
| 67 | 159 | 92 | 56.1 | 119 | 8.6 | 2.09 | 3.81 | 0 | 1 | 1 | 36.391 |
| 74 | 164 | 79 | 56.9 | 101.9 | 10.4 | 0.28 | 3.86 | 0 | 1 | 1 | 29.3724 |
| 52 | 161 | 59 | 75 | 116.1 | 7.5 | 7.93 | 3.77 | 1 | 0 | 0 | 22.7615 |
| 57 | 157 | 94 | 75.6 | 116.7 | 10.4 | 5.13 | 3.38 | 0 | 0 | 1 | 38.1354 |
| 60 | 191 | 107 | 79.3 | 98.2 | 7.1 | 6.54 | 2.7 | 1 | 0 | 0 | 29.3303 |
| 20 | 166 | 81 | 65.1 | 146.9 | 5.8 | 3.53 | 3.46 | 0 | 0 | 1 | 29.3947 |
| 66 | 186 | 53 | 68.6 | 121.8 | 6.3 | 7.63 | 3.66 | 0 | 0 | 0 | 15.3197 |
| 50 | 161 | 109 | 72.6 | 136.3 | 7.7 | 5.8 | 1.76 | 1 | 0 | 0 | 42.0508 |
Divisão dos Dados
Nesta etapa separei o conjunto em treino (70%) e teste (30%) para avaliar a árvore em dados não vistos. Usei random_state=42 para reprodutibilidade e stratify=y para manter a proporção de is_fit em treino e teste. Antes do split, finalizei o pré-processamento (imputação da mediana em sleep_hours, padronização de smokes para 0/1 e de gender para 0/1).
Nota: em um pipeline mais rígido, a imputação/codificação seria ajustada no treino e aplicada no teste para evitar vazamento; aqui mantive a simplicidade do material.
Features: age, height_cm, weight_kg, heart_rate, blood_pressure, sleep_hours, nutrition_quality, activity_index, smokes, gender.
Objetivo: servir de referência para comparar com a versão engenheirada.
Mesma configuração de split (70/30, random_state=42, stratify=y).
import matplotlib.pyplot as plt
import pandas as pd
from io import StringIO
from sklearn import tree
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score
plt.figure(figsize=(12, 10))
df = pd.read_csv("./src/fitness_dataset.csv")
label_encoder = LabelEncoder()
#Tratamento de dados nulos e categóricos
df = pd.read_csv("./src/fitness_dataset.csv")
df["sleep_hours"] = df["sleep_hours"].fillna(df["sleep_hours"].median())
df["smokes"] = (
df["smokes"].astype(str).str.strip().str.lower()
.map({"yes": 1, "no": 0, "1": 1, "0": 0})
).astype(int)
df["gender"] = df["gender"].replace({"F": 0, "M": 1}).astype(int)
# Carregar o conjunto de dados
x = df[['age', 'height_cm', 'weight_kg', 'heart_rate', 'blood_pressure',
'sleep_hours', 'nutrition_quality', 'activity_index', 'smokes',
'gender']]
y = df['is_fit']
# Dividir os dados em conjuntos de treinamento e teste
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.3, random_state=42, stratify=y)
Engenharia: criei bmi = peso(kg)/altura(m)² (cálculo linha a linha, sem vazamento). Features: age, bmi, heart_rate, blood_pressure, sleep_hours, nutrition_quality, activity_index, smokes, gender.
Objetivo: avaliar o impacto do BMI na acurácia e na simplicidade da árvore. Mantive height_cm e weight_kg na base tratada para transparência, mas retirei essas colunas apenas na seleção das features do Cenário B para evitar redundância com o bmi.
Comparação justa: ambos os cenários usam o mesmo split (70/30, random_state=42, stratify=y).
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from io import StringIO
from sklearn import tree
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score
plt.figure(figsize=(12, 10))
df = pd.read_csv("./src/fitness_dataset.csv")
label_encoder = LabelEncoder()
# Tratamento de dados nulos e categóricos
df["sleep_hours"] = pd.to_numeric(df["sleep_hours"], errors="coerce").fillna(df["sleep_hours"].median())
df["smokes"] = (
df["smokes"].astype(str).str.strip().str.lower()
.map({"yes": 1, "no": 0, "1": 1, "0": 0})
).astype(int)
df["gender"] = df["gender"].replace({"F": 0, "M": 1}).astype(int)
# BMI = peso(kg) / (altura(m))^2
h_m = pd.to_numeric(df["height_cm"], errors="coerce") / 100.0
bmi = pd.to_numeric(df["weight_kg"], errors="coerce") / (h_m**2)
df["bmi"] = bmi.replace([np.inf, -np.inf], np.nan).fillna(bmi.median())
# Features (com BMI, substitui altura e peso)
x = df[['age', 'bmi', 'heart_rate', 'blood_pressure',
'sleep_hours', 'nutrition_quality', 'activity_index', 'smokes', 'gender']]
y = df['is_fit']
x_train, x_test, y_train, y_test = train_test_split(x, y, test_size=0.3, random_state=42, stratify=y)
Primeiro Treinamento do Modelo
Acurácia da validação: 0.6883
Importância das Features:
| Feature | Importância |
|---|---|
| activity_index | 0.232233 |
| nutrition_quality | 0.175132 |
| bmi | 0.123847 |
| age | 0.115741 |
| smokes | 0.109310 |
| sleep_hours | 0.086928 |
| heart_rate | 0.082296 |
| blood_pressure | 0.064948 |
| gender | 0.009565 |
Árvore de Decisão (SVG):
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from io import StringIO
from sklearn import tree
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score
plt.figure(figsize=(12, 10))
df = pd.read_csv("./src/fitness_dataset.csv")
label_encoder = LabelEncoder()
df["sleep_hours"] = pd.to_numeric(df["sleep_hours"], errors="coerce").fillna(df["sleep_hours"].median())
df["smokes"] = (df["smokes"].astype(str).str.strip().str.lower().map({"yes":1,"no":0,"1":1,"0":0})).astype(int)
df["gender"] = df["gender"].replace({"F":0,"M":1}).astype(int)
h_m = pd.to_numeric(df["height_cm"], errors="coerce") / 100.0
bmi = pd.to_numeric(df["weight_kg"], errors="coerce") / (h_m**2)
df["bmi"] = bmi.replace([np.inf, -np.inf], np.nan).fillna(bmi.median())
x = df[['age','bmi','heart_rate','blood_pressure',
'sleep_hours','nutrition_quality','activity_index',
'smokes','gender']]
y = df['is_fit'].astype(int)
x_train, x_test, y_train, y_test = train_test_split(
x, y, test_size=0.3, random_state=42, stratify=y
)
classifier = tree.DecisionTreeClassifier(random_state=42)
classifier.fit(x_train, y_train)
acc = accuracy_score(y_test, classifier.predict(x_test))
print(f"<b>Acurácia da validação:</b> {acc:.4f}<br>")
imp = pd.Series(classifier.feature_importances_, index=x.columns)
imp_df = pd.DataFrame({"Feature": imp.index, "Importância": imp.values}).sort_values("Importância", ascending=False)
print("<br><b>Importância das Features:</b>")
print(imp_df.to_html(index=False))
tree.plot_tree(classifier, max_depth=4, fontsize=10,
feature_names=x.columns, class_names=["Não Fit","Fit"], filled=True)
buffer = StringIO()
plt.savefig(buffer, format="svg", bbox_inches="tight", transparent=True)
print("<br><b>Árvore de Decisão (SVG):</b><br>")
print(buffer.getvalue())
Avaliação do primeiro modelo
O modelo com todas as variáveis atingiu 68,83% de precisão. As que mais pesaram foram activity_index (23,2%) e nutrition_quality (17,5%), seguidas por bmi (12,4%), age (11,6%) e smokes (10,9%). Já sleep_hours (8,7%), heart_rate (8,2%), blood_pressure (6,5%) e, principalmente, gender (1,0%) tiveram impacto baixo. Em resumo: o sinal principal vem de atividade física e qualidade da alimentação, com um complemento do BMI e idade.
Segundo Treinamento do Modelo
Acurácia da validação: 0.6900
Importância das Features:
| Feature | Importância |
|---|---|
| activity_index | 0.259955 |
| nutrition_quality | 0.211840 |
| bmi | 0.196297 |
| age | 0.134762 |
| smokes | 0.109310 |
| sleep_hours | 0.087836 |
Árvore de Decisão (SVG):
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from io import StringIO
from sklearn import tree
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import accuracy_score
plt.figure(figsize=(12, 10))
df = pd.read_csv("./src/fitness_dataset.csv")
label_encoder = LabelEncoder()
df["sleep_hours"] = pd.to_numeric(df["sleep_hours"], errors="coerce").fillna(df["sleep_hours"].median())
df["smokes"] = (df["smokes"].astype(str).str.strip().str.lower().map({"yes":1,"no":0,"1":1,"0":0})).astype(int)
df["gender"] = df["gender"].replace({"F":0,"M":1}).astype(int)
h_m = pd.to_numeric(df["height_cm"], errors="coerce") / 100.0
bmi = pd.to_numeric(df["weight_kg"], errors="coerce") / (h_m**2)
df["bmi"] = bmi.replace([np.inf, -np.inf], np.nan).fillna(bmi.median())
x = df[['activity_index', 'nutrition_quality', 'bmi',
'age', 'sleep_hours', 'smokes']]
y = df['is_fit'].astype(int)
x_train, x_test, y_train, y_test = train_test_split(
x, y, test_size=0.3, random_state=42, stratify=y
)
classifier = tree.DecisionTreeClassifier(random_state=42)
classifier.fit(x_train, y_train)
acc = accuracy_score(y_test, classifier.predict(x_test))
print(f"<b>Acurácia da validação:</b> {acc:.4f}<br>")
imp = pd.Series(classifier.feature_importances_, index=x.columns)
imp_df = pd.DataFrame({"Feature": imp.index, "Importância": imp.values}).sort_values("Importância", ascending=False)
print("<br><b>Importância das Features:</b>")
print(imp_df.to_html(index=False))
tree.plot_tree(classifier, max_depth=4, fontsize=10,
feature_names=x.columns, class_names=["Não Fit","Fit"], filled=True)
buffer = StringIO()
plt.savefig(buffer, format="svg", bbox_inches="tight", transparent=True)
print("<br><b>Árvore de Decisão (SVG):</b><br>")
print(buffer.getvalue())
Avaliação do segundo modelo
Ao remover as variáveis mais fracas (gender, blood_pressure e heart_rate) e manter apenas as mais relevantes, a precisão subiu levemente para 69,00% (+0,17 p.p.). A importância ficou ainda mais concentrada em activity_index (26,0%), nutrition_quality (21,2%) e bmi (19,6%), com age (13,5%), smokes (10,9%) e sleep_hours (8,8%) completando o conjunto.
Conclusão desta comparação: tirar as variáveis com pouco sinal reduz ruído e deixa a árvore mais simples, mantendo (ou melhorando) a precisão. Para a avaliação final, faz sentido seguir com o modelo compacto com BMI.
Relatório Final
Árvores de decisão não pedem normalização, então o que realmente ajudou aqui foi tratar a base (padronizar smokes/gender, imputar sleep_hours) e criar o bmi. Comparei dois modelos e fiquei com o compacto com BMI: é mais simples e bateu ~69,00% de precisão (contra 68,83% do completo). As variáveis que mais fizeram diferença foram activity_index, nutrition_quality e bmi; idade, sono e tabagismo somaram um pouco. Mantive split 70/30 estratificado — reduzir o teste não melhora o modelo e ainda piora a avaliação. Resumo: dados limpos + bons atributos valem mais do que mexer em escala quando o modelo é uma árvore.