KNN
Introdução ao KNN
O algoritmo K-Nearest Neighbors (KNN) foi utilizado como alternativa para realizar tarefas de classificação. Esse método classifica uma nova observação com base nos exemplos mais próximos do conjunto de treino, considerando a similaridade entre seus atributos. Por sua simplicidade e flexibilidade, o KNN não exige pressupostos sobre a distribuição dos dados e oferece uma análise fundamentada na proximidade entre instâncias, funcionando como complemento às previsões obtidas com a árvore de decisão.
Descrição sobre o banco
Para mais informações, cheque a página sobre Árvore de decisão, aqui tem toda a explicação necessária para compreender as variáveis e as outras coisas.
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 |
|---|---|---|---|---|---|---|---|---|---|---|
| 52 | 168 | 95 | 60.2 | 128.4 | 7.3 | 3.87 | 1.34 | no | M | 0 |
| 37 | 182 | 73 | 59.4 | 108.5 | 8.8 | 4.11 | 1.87 | 0 | M | 1 |
| 22 | 150 | 95 | 67 | 121.6 | 6.2 | 2.54 | 1.58 | yes | F | 0 |
| 58 | 191 | 62 | 59 | 136.6 | 8.8 | 5.74 | 1.94 | no | F | 0 |
| 39 | 190 | 65 | 68.3 | 107.5 | 7.3 | 8.3 | 1.52 | yes | F | 0 |
| 79 | 199 | 107 | 82.5 | 117.5 | 9.1 | 1.42 | 3.67 | 0 | F | 1 |
| 65 | 162 | 99 | 69.1 | 105.5 | 6.3 | 6.87 | 4.18 | yes | F | 0 |
| 45 | 153 | 98 | 75.9 | 125.2 | 8.8 | 3.72 | 1.5 | 0 | M | 0 |
| 42 | 158 | 84 | 62.6 | 129.3 | 6.1 | 3.82 | 2.95 | yes | F | 0 |
| 19 | 180 | 96 | 98.8 | 95.9 | 7.4 | 2.39 | 3.56 | no | F | 0 |
| 48 | 193 | 101 | 96.6 | 121.2 | 7.5 | 6.38 | 3.6 | 0 | M | 1 |
| 29 | 187 | 71 | 68.4 | 117.1 | 6.0 | 9.95 | 3.61 | 0 | F | 1 |
| 31 | 166 | 81 | 68 | 128.8 | 8.3 | 0.88 | 2.88 | no | M | 0 |
| 62 | 152 | 91 | 59 | 105.7 | 9.0 | 4.16 | 4 | 0 | M | 1 |
| 43 | 181 | 91 | 96.1 | 121.4 | null | 3.99 | 4.52 | 1 | M | 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 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 77 | 171 | 90 | 66.5 | 104.8 | 4.6 | 9.2 | 1.98 | 0 | 0 | 0 | 30.7787 |
| 79 | 184 | 91 | 73.2 | 115.4 | 5.6 | 1.96 | 4.46 | 0 | 0 | 0 | 26.8785 |
| 31 | 198 | 100 | 82.9 | 108.4 | 9.1 | 8.02 | 2.9 | 0 | 1 | 1 | 25.5076 |
| 40 | 183 | 87 | 72.2 | 124.2 | 8.9 | 6.67 | 3.59 | 0 | 1 | 1 | 25.9787 |
| 75 | 191 | 109 | 66.4 | 128.8 | 11.4 | 2.98 | 3.87 | 0 | 0 | 0 | 29.8786 |
| 78 | 178 | 113 | 85.4 | 134.3 | 6.6 | 9.2 | 2.75 | 1 | 1 | 0 | 35.6647 |
| 40 | 161 | 108 | 53.1 | 125.3 | 6.7 | 1.58 | 3.31 | 1 | 1 | 0 | 41.6651 |
| 21 | 164 | 110 | 60.7 | 131.2 | 7.3 | 0.38 | 4.06 | 0 | 1 | 1 | 40.8983 |
| 38 | 188 | 57 | 81.2 | 110.6 | 6.6 | 8.47 | 4.96 | 0 | 1 | 1 | 16.1272 |
| 66 | 174 | 82 | 75.9 | 128.8 | 8.1 | 7.23 | 3.47 | 0 | 0 | 1 | 27.0842 |
| 52 | 171 | 54 | 53.8 | 109.5 | 6.8 | 10 | 1.3 | 1 | 1 | 0 | 18.4672 |
| 75 | 152 | 118 | 67.9 | 110.9 | 7.6 | 4.56 | 2.67 | 0 | 0 | 0 | 51.0734 |
| 43 | 154 | 69 | 73.8 | 133.2 | 6.6 | 2.66 | 2.05 | 0 | 1 | 0 | 29.0943 |
| 35 | 158 | 104 | 68.1 | 118.1 | 5.6 | 6.92 | 1.45 | 0 | 0 | 0 | 41.66 |
| 56 | 164 | 108 | 70.1 | 140.9 | 9.1 | 4.15 | 2.06 | 0 | 0 | 0 | 40.1547 |
Divisão dos Dados
Para o modelo KNN, optei por separar o conjunto em treino (80%) e teste (20%), de forma a avaliar o desempenho do classificador em dados não vistos. O parâmetro random_state=42 foi utilizado para garantir reprodutibilidade, e stratify=y assegurou que a proporção entre as classes (is_fit = 0 e is_fit = 1) fosse preservada em ambas as partições.
Antes da divisão, o pré-processamento já havia sido realizado: imputação da mediana em sleep_hours, padronização de smokes (0/1), codificação de gender (0/1) e criação da variável bmi em substituição às variáveis originais height_cm e weight_kg. Como o KNN é sensível a diferenças de escala, também foi aplicada a padronização dos atributos numéricos após a definição das features finais.
Essa estratégia garante que o modelo seja treinado em um subconjunto representativo e avaliado de maneira justa, evitando que a acurácia reflita apenas o aprendizado sobre o conjunto total de dados.
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 pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
# Carregar base
df = pd.read_csv("./src/fitness_dataset.csv")
# Tratamento
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)
# Criar BMI e substituir height/weight
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())
# Features e alvo
num_cols = ["age", "heart_rate", "blood_pressure", "sleep_hours",
"nutrition_quality", "activity_index", "bmi"]
cat_cols = ["smokes", "gender"]
target = "is_fit"
# Garantir numéricos válidos
for c in num_cols:
df[c] = pd.to_numeric(df[c], errors="coerce").fillna(df[c].median())
X_num = df[num_cols]
X_cat = df[cat_cols]
# Padronizar atributos numéricos
scaler = StandardScaler()
X_num_scaled = pd.DataFrame(scaler.fit_transform(X_num), columns=num_cols)
# Concatenar
X = pd.concat([X_num_scaled.reset_index(drop=True),
X_cat.reset_index(drop=True)], axis=1).values
y = df[target].astype(int).values
# Divisão treino/teste (80/20)
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.2,
random_state=42,
stratify=y
)
print("Tamanho treino:", X_train.shape[0])
print("Tamanho teste:", X_test.shape[0])
print("Proporção classes treino:", y_train.mean())
print("Proporção classes teste:", y_test.mean())
Treinamento do Modelo
Acurácia (KNN k=5): 0.71
import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score
from sklearn.preprocessing import StandardScaler
df = pd.read_csv("./src/fitness_dataset.csv")
# Tratamento dos dados
df["sleep_hours"] = pd.to_numeric(df["sleep_hours"], errors="coerce")
df["sleep_hours"] = df["sleep_hours"].fillna(df["sleep_hours"].median())
df["smokes"] = df["smokes"].replace({"yes": 1, "no": 0, "1": 1, "0": 0}).astype(int)
df["gender"] = df["gender"].replace({"F": 0, "M": 1}).astype(int)
# bmi = weight_kg / (height_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([float("inf"), float("-inf")], pd.NA)
.fillna(bmi.median())
)
# Features e target
num_cols = [
"age", "heart_rate", "blood_pressure",
"sleep_hours", "nutrition_quality", "activity_index", "bmi"
]
cat_cols = ["smokes", "gender"]
target = "is_fit"
# Garantir tipos numéricos
for c in num_cols:
df[c] = pd.to_numeric(df[c], errors="coerce")
df[c] = df[c].fillna(df[c].median())
X_num = df[num_cols]
X_cat = df[cat_cols]
# Padronização dos dados numéricos
scaler = StandardScaler()
X_num = pd.DataFrame(scaler.fit_transform(X_num), columns=num_cols)
# Junta tudo
X = pd.concat([X_num, X_cat.reset_index(drop=True)], axis=1).values
y = df[target].astype(int).values
# Split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.20, random_state=42, stratify=y
)
class KNNClassifier:
def __init__(self, k=5):
self.k = k
def fit(self, X, y):
self.X_train = X
self.y_train = np.array(y)
def predict(self, X):
return np.array([self._predict(x) for x in X])
def _predict(self, x):
distances = np.sqrt(((self.X_train - x) ** 2).sum(axis=1))
k_idx = np.argsort(distances)[:self.k]
k_labels = self.y_train[k_idx]
vals, counts = np.unique(k_labels, return_counts=True)
return vals[np.argmax(counts)]
# Treinar e avaliar
knn = KNNClassifier(k=5)
knn.fit(X_train, y_train)
y_pred = knn.predict(X_test)
acc = accuracy_score(y_test, y_pred)
print(f"Acurácia (KNN k={knn.k}): {acc:.2f}")
Usando o Scikit-Learn
Acurácia: 0.6250
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from io import StringIO
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.neighbors import KNeighborsClassifier
from sklearn.metrics import accuracy_score, balanced_accuracy_score
# === Carregar e tratar como na base_tratada ===
df = pd.read_csv("./src/fitness_dataset.csv")
# sleep_hours -> mediana
df["sleep_hours"] = pd.to_numeric(df["sleep_hours"], errors="coerce")
df["sleep_hours"] = df["sleep_hours"].fillna(df["sleep_hours"].median())
# smokes -> normaliza rótulos e converte p/ 0/1
df["smokes"] = (
df["smokes"].astype(str).str.strip().str.lower()
.map({"yes": 1, "no": 0, "1": 1, "0": 0})
).astype(int)
# gender -> F=0, M=1
df["gender"] = df["gender"].replace({"F": 0, "M": 1}).astype(int)
# bmi = weight_kg / (height_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([float("inf"), float("-inf")], pd.NA)
.fillna(bmi.median())
)
# === Features e alvo ===
# Usa BMI e remove height_cm/weight_kg para evitar redundância
num_cols = [
"age", "heart_rate", "blood_pressure", "sleep_hours",
"nutrition_quality", "activity_index", "bmi"
]
cat_cols = ["smokes", "gender"]
target = "is_fit"
# Garantir numéricos válidos nas contínuas
for c in num_cols:
df[c] = pd.to_numeric(df[c], errors="coerce")
df[c] = df[c].fillna(df[c].median())
X_num = df[num_cols]
X_cat = df[cat_cols]
# Padronização (KNN é sensível à escala)
scaler = StandardScaler()
X_num_scaled = pd.DataFrame(scaler.fit_transform(X_num), columns=num_cols)
# Matriz final
X = pd.concat([X_num_scaled.reset_index(drop=True),
X_cat.reset_index(drop=True)], axis=1).values
y = df[target].astype(int).values
# Split
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42, stratify=y
)
# PCA 2D só para visualização/decisão do gráfico
pca = PCA(n_components=2, random_state=42)
X_train_2d = pca.fit_transform(X_train)
X_test_2d = pca.transform(X_test)
# KNN
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train_2d, y_train)
pred = knn.predict(X_test_2d)
# Métricas
acc = accuracy_score(y_test, pred)
bacc = balanced_accuracy_score(y_test, pred)
print(f"Acurácia: {acc:.4f}")
# Plot
plt.figure(figsize=(12, 10))
h = 0.05
x_min, x_max = X_train_2d[:, 0].min() - 1, X_train_2d[:, 0].max() + 1
y_min, y_max = X_train_2d[:, 1].min() - 1, X_train_2d[:, 1].max() + 1
xx, yy = np.meshgrid(np.arange(x_min, x_max, h),
np.arange(y_min, y_max, h))
Z = knn.predict(np.c_[xx.ravel(), yy.ravel()]).reshape(xx.shape)
plt.contourf(xx, yy, Z, cmap=plt.cm.RdYlBu, alpha=0.3)
sns.scatterplot(
x=X_train_2d[:, 0], y=X_train_2d[:, 1], hue=y_train,
palette="deep", s=100, edgecolor="k", alpha=0.8, legend=True
)
plt.xlabel("PCA 1")
plt.ylabel("PCA 2")
plt.title("KNN Decision Boundary (Fitness Dataset, com BMI)")
plt.tight_layout()
# Renderiza 1 SVG no site e fecha a figura
buf = StringIO()
plt.savefig(buf, format="svg", bbox_inches="tight", transparent=True)
print(buf.getvalue())
plt.close()
Avaliação do Modelo
O modelo KNN, configurado com k = 5, apresentou uma acurácia de aproximadamente 62% no conjunto de teste, com balanced accuracy em torno de 59%. Isso significa que o desempenho ficou apenas um pouco acima do acaso (50%), refletindo a dificuldade do algoritmo em distinguir corretamente entre indivíduos classificados como “fit” e “não fit”.
A visualização da fronteira de decisão confirma esse resultado: as classes aparecem bastante sobrepostas no espaço de duas dimensões (após PCA), e a divisão entre elas não é nítida. Esse comportamento indica que as variáveis utilizadas (idade, indicadores de saúde, hábitos de sono, nutrição, atividade física, tabagismo, gênero e BMI) possuem alguma relevância, mas não oferecem separação clara o suficiente para que o KNN construa regiões bem definidas para cada classe. Além disso, a irregularidade da fronteira de decisão ressalta a sensibilidade do método a ruídos e à distribuição dos dados, algo esperado nesse tipo de problema.
Conclusão
O uso do KNN para prever a condição física (fit vs. não fit) demonstrou resultados modestos, mas ainda válidos como exercício de classificação e comparação com outros algoritmos, como a árvore de decisão. Apesar da simplicidade e da interpretação intuitiva do método, a análise gráfica mostrou que as classes são altamente sobrepostas, o que limita a capacidade do modelo de generalizar.
De maneira geral, os resultados indicam que o KNN pode servir como uma abordagem inicial para explorar o dataset, mas melhorias dependem de ajustes nos hiperparâmetros (como o valor de k), da criação de novas features ou da aplicação de modelos mais robustos, capazes de lidar melhor com a complexidade e o ruído dos dados.