Projeto 1
Grupo 3 / Os Goats do SI
- José Longo Neto
- Pedro Almeida Maricate
- Martim Ponzio
- Pablo Dimitrof
- Enzo Malagoli
- Eduardo Gul
Introdução
O objetivo deste projeto é aplicar três algoritmos de Machine Learning — Árvore de Decisão, K-Nearest Neighbors (KNN) e K-Means (Clustering) — sobre a base de dados do kagle.
A base contém aproximadamente 130 mil registros de vinhos, com informações como país de origem, pontuação atribuída por especialistas, preço, tipo de uva, região produtora e descrição sensorial.
A proposta é explorar o uso de algoritmos de aprendizado supervisionado e não supervisionado para compreender padrões entre as variáveis e identificar possíveis relações entre características como origem, variedade e pontuação dos vinhos.
O roteiro segue a mesma estrutura aplicada em outros projetos da disciplina, dividindo o processo em etapas sequenciais e documentadas:
- Exploração dos Dados (EDA): análise geral das variáveis, distribuição de valores e identificação de possíveis inconsistências;
- Pré-processamento: limpeza, padronização e codificação de dados categóricos;
- Divisão dos Dados: separação em conjuntos de treino e teste, quando houver variável-alvo definida;
- Treinamento e Avaliação: aplicação e comparação dos modelos supervisionados (Árvore de Decisão e KNN);
- Modelagem Não Supervisionada: uso do K-Means para identificar agrupamentos entre os vinhos sem utilizar o rótulo de pontuação;
- Relatório Final e Comparação: discussão sobre os resultados obtidos e limitações de cada abordagem.
Observação:
O dataset não possui uma coluna explicitamente binária de qualidade, mas contém a variávelpoints, que representa a nota do vinho atribuída por avaliadores.
Para fins de classificação, esta variável será transformada em uma variável-alvo derivada, categorizando os vinhos conforme sua pontuação (por exemplo, alta pontuação ≥ 90).
Assim, os modelos supervisionados trabalharão com essa classificação, enquanto o K-Means será utilizado para detectar padrões de agrupamento sem rótulos.
Exploração dos Dados
A etapa de Exploração dos Dados (EDA) tem como objetivo compreender a estrutura, o conteúdo e o significado das variáveis presentes na base wine.csv.
Essa análise inicial permite identificar padrões, outliers, distribuições e possíveis problemas de qualidade dos dados, como valores ausentes ou inconsistências.
As visualizações e descrições abaixo ajudam a construir uma visão geral do conjunto e a orientar as etapas seguintes de pré-processamento e modelagem.
A coluna country indica o país de origem do vinho. Essa variável é importante para observar a distribuição geográfica dos registros e entender a representatividade de cada país no conjunto.
A coluna designation informa a denominação ou nome específico do vinho, dentro da vinícola. É uma variável categórica de alta cardinalidade (muitos valores únicos) e pode indicar edições especiais ou lotes de produção.
A coluna points representa a pontuação do vinho atribuída por avaliadores especializados, geralmente variando entre 80 e 100 pontos. Essa variável é central no projeto, pois será usada para derivar a variável-alvo de qualidade que alimentará os modelos supervisionados.
A coluna price indica o preço do vinho em dólares. Ela é uma variável numérica contínua que pode apresentar assimetria devido à presença de vinhos muito caros (outliers). A relação entre preço e pontuação será uma das análises mais relevantes desta etapa.
A coluna province indica a província ou região produtora do vinho dentro de seu país. É uma variável categórica útil para observar a diversidade geográfica da produção.
A coluna region_1 representa uma sub-região produtora (como “Napa Valley” ou “Bordeaux”). Pode ser usada para análises mais detalhadas de terroir e diferenciação regional.
A coluna region_2 fornece informações complementares sobre uma segunda subdivisão geográfica, quando disponível. Nem todos os registros possuem este campo preenchido, portanto ele pode apresentar alta taxa de valores ausentes.
A coluna taster_name contém o nome do avaliador responsável pela nota e descrição do vinho. Ela permite explorar a distribuição de avaliações entre diferentes especialistas e identificar potenciais vieses.
A coluna variety indica o tipo de uva utilizada na produção (por exemplo: Pinot Noir, Chardonnay, Riesling). É uma das variáveis mais importantes do conjunto, pois reflete o perfil sensorial e o tipo do vinho.
A coluna winery identifica a vinícola responsável pela produção. É uma variável categórica de alta cardinalidade e pode ser explorada futuramente em análises de desempenho médio por produtor.
Pré-processamento
Após a exploração inicial, aplicamos um conjunto de procedimentos para preparar a base wine.csv para modelagem:
- Remoção de colunas irrelevantes: descartamos campos sem utilidade direta para o modelo ou que serão usados apenas como metadados na documentação (ex.:
Unnamed: 0,title,description,taster_twitter_handle). - Conversão e limpeza de tipos: garantimos que
priceepointsestejam em formato numérico; tratamos valores ausentes com imputações simples (mediana para numéricas; rótulo"Unknown"para categóricas). - Criação do alvo (quando aplicável): a partir de
points, derivamos a variávelquality_high(1 sepoints ≥ 90; 0 caso contrário). Para evitar vazamento de informação,pointsnão será usada como feature nos modelos supervisionados. - Codificação de categóricas: para esta versão “base preparada” usada na documentação, aplicamos Label Encoding coluna a coluna (adequado para árvore; nas seções de treino do KNN/K-Means faremos scaling e codificações apropriadas nos pipelines).
- Entrega de uma visão pronta para modelagem: apresentamos uma amostra da base já limpa e codificada, com
quality_highdisponível para as etapas supervisionadas e os demais campos prontos para uso em pipelines.
Dimensão (após pré-processamento): 129971 linhas x 10 colunas
| country | designation | price | province | region_1 | region_2 | taster_name | variety | winery | quality_high |
|---|---|---|---|---|---|---|---|---|---|
| 2 | 35112 | 5 | 24 | 1027 | 15 | 8 | 125 | 952 | 0 |
| 15 | 29390 | 12 | 309 | 377 | 15 | 15 | 492 | 2718 | 0 |
| 37 | 12139 | 9 | 262 | 909 | 15 | 12 | 656 | 7019 | 0 |
| 40 | 23298 | 29 | 51 | 799 | 1 | 18 | 80 | 11074 | 0 |
| 40 | 37256 | 40 | 51 | 36 | 15 | 18 | 440 | 8338 | 0 |
| 40 | 35112 | 22 | 51 | 1013 | 0 | 7 | 125 | 10956 | 1 |
| 40 | 13155 | 33 | 268 | 1218 | 17 | 14 | 440 | 7730 | 1 |
| 22 | 30032 | 35 | 197 | 448 | 15 | 9 | 125 | 9739 | 0 |
| 40 | 35112 | 10 | 51 | 185 | 1 | 18 | 80 | 5345 | 0 |
| 15 | 7395 | 21 | 299 | 366 | 15 | 15 | 492 | 6439 | 1 |
| 40 | 37134 | 38 | 51 | 964 | 1 | 11 | 560 | 5574 | 0 |
| 15 | 29438 | 14 | 195 | 977 | 15 | 15 | 137 | 2639 | 0 |
| 15 | 7424 | 18 | 195 | 1210 | 15 | 15 | 137 | 3338 | 1 |
| 40 | 35112 | 20 | 51 | 911 | 12 | 18 | 703 | 11688 | 0 |
| 31 | 29965 | 19 | 7 | 1094 | 15 | 15 | 450 | 2411 | 1 |
| 22 | 5095 | 25 | 341 | 161 | 15 | 9 | 2 | 11693 | 0 |
| 40 | 9886 | 20 | 51 | 157 | 0 | 18 | 691 | 6891 | 0 |
| 17 | 10175 | 18 | 232 | 1094 | 15 | 1 | 479 | 1277 | 0 |
| 22 | 36799 | 20 | 341 | 855 | 15 | 18 | 459 | 586 | 0 |
| 40 | 8376 | 48 | 412 | 872 | 3 | 16 | 362 | 10668 | 1 |
import pandas as pd
import numpy as np
from sklearn.preprocessing import LabelEncoder
# ===== 1) Ler base =====
df = pd.read_csv("./src/wine.csv")
# ===== 2) Remover colunas irrelevantes (se existirem) =====
drop_cols = ["Unnamed: 0", "title", "description", "taster_twitter_handle"]
df = df.drop(columns=[c for c in drop_cols if c in df.columns], errors="ignore")
# ===== 3) Garantir tipos numéricos e tratar nulos =====
if "points" in df.columns:
df["points"] = pd.to_numeric(df["points"], errors="coerce")
if "price" in df.columns:
df["price"] = pd.to_numeric(df["price"], errors="coerce")
num_cols = df.select_dtypes(include=["number"]).columns.tolist()
cat_cols = df.select_dtypes(exclude=["number"]).columns.tolist()
for c in num_cols:
med = df[c].median() if df[c].notna().any() else 0
df[c] = df[c].fillna(med)
for c in cat_cols:
df[c] = df[c].fillna("Unknown")
# ===== 4) Criar alvo supervisionado (se houver points) =====
if "points" in df.columns:
df["quality_high"] = (df["points"] >= 90).astype(int)
# ===== 5) Codificação simples de categóricas (rastreável) =====
df_ready = df.copy()
for c in df_ready.select_dtypes(exclude=["number"]).columns:
le = LabelEncoder()
df_ready[c] = le.fit_transform(df_ready[c].astype(str))
# ===== 6) Evitar vazamento: retirar 'points' das features "base" =====
if "points" in df_ready.columns:
df_ready = df_ready.drop(columns=["points"])
# ===== 7) Mostrar SOMENTE algumas linhas para não poluir o markdown =====
n_show = min(20, len(df_ready))
print(f"Dimensão (após pré-processamento): {df_ready.shape[0]} linhas x {df_ready.shape[1]} colunas\n")
print(df_ready.sample(n=n_show, random_state=42).to_markdown(index=False))
Dimensão (base original): 129971 linhas x 11 colunas
| Unnamed: 0 | country | designation | points | price | province | region_1 | region_2 | taster_name | variety | winery |
|---|---|---|---|---|---|---|---|---|---|---|
| 77718 | Australia | nan | 83 | 5 | Australia Other | South Eastern Australia | nan | Joe Czerwinski | Chardonnay | Banrock Station |
| 67681 | France | Réserve | 85 | 12 | Rhône Valley | Côtes du Rhône | nan | Roger Voss | Rosé | Cellier des Dauphins |
| 69877 | Spain | Estate Grown & Bottled | 86 | 9 | Northern Spain | Rueda | nan | Michael Schachner | Verdejo-Viura | Esperanza |
| 46544 | US | Nebula | 87 | 29 | California | Paso Robles | Central Coast | nan | Cabernet Sauvignon | Midnight |
| 186 | US | Wiley Vineyard | 88 | 40 | California | Anderson Valley | nan | nan | Pinot Noir | Harrington |
| 73126 | US | nan | 90 | 22 | California | Sonoma County-Monterey County-Santa Barbara County | California Other | Jim Gordon | Chardonnay | Meiomi |
| 26800 | US | Five Faces | 90 | 33 | Oregon | Willamette Valley | Willamette Valley | Paul Gregutt | Pinot Noir | Fullerton |
| 80832 | Italy | Saten | 89 | 35 | Lombardy | Franciacorta | nan | Kerin O’Keefe | Chardonnay | Lantieri de Paratico |
| 86297 | US | nan | 84 | 10 | California | Central Coast | Central Coast | nan | Cabernet Sauvignon | Cupcake |
| 56015 | France | Clos du Château | 92 | 21 | Provence | Côtes de Provence | nan | Roger Voss | Rosé | Domaine du Clos Gautier |
| 78536 | US | White Hawk Vineyard | 89 | 38 | California | Santa Barbara County | Central Coast | Matt Kettmann | Syrah | Deep Sea |
| 41016 | France | Réserve des Vignerons | 86 | 14 | Loire Valley | Saumur | nan | Roger Voss | Chenin Blanc | Cave de Saumur |
| 81019 | France | Clos le Vigneau | 90 | 18 | Loire Valley | Vouvray | nan | Roger Voss | Chenin Blanc | Château Gaudrelle |
| 129762 | US | nan | 87 | 20 | California | Russian River Valley | Sonoma | nan | Zinfandel | Novy |
| 34634 | Portugal | Santos da Casa Reserva | 91 | 19 | Alentejano | nan | nan | Roger Voss | Portuguese Red | Casca Wines |
| 20284 | Italy | Caleno Oro | 88 | nan | Southern Italy | Campania | nan | Kerin O’Keefe | Aglianico | Nugnes |
| 111397 | US | Dessert Wine | 86 | 20 | California | California | California Other | nan | White Blend | Elkhorn Peak |
| 26153 | Germany | Dom Off-Dry | 89 | 18 | Mosel | nan | nan | Anna Lee C. Iijima | Riesling | Bischöfliche Weingüter Trier |
| 110148 | Italy | Vriccio | 86 | 20 | Southern Italy | Puglia | nan | nan | Primitivo | Antica Enotria |
| 125453 | US | Crazy Mary | 93 | 48 | Washington | Red Mountain | Columbia Valley | Sean P. Sullivan | Mourvèdre | Mark Ryan |
Divisão dos Dados
Com a base pré-processada, realizou-se a divisão entre conjuntos de treinamento e teste.
O objetivo dessa etapa é garantir que o modelo seja avaliado em dados que ele nunca viu durante o treinamento, permitindo uma medida mais confiável de sua capacidade de generalização.
Foi utilizada a função train_test_split da biblioteca scikit-learn, com os seguintes critérios:
- 70% dos dados destinados ao treinamento, para que o modelo aprenda os padrões da base;
- 30% dos dados destinados ao teste, para avaliar o desempenho em novos exemplos;
- Estratificação pelo alvo (quality_high), garantindo que a proporção entre classes seja mantida em ambos os conjuntos;
- Random State fixado, assegurando reprodutibilidade na divisão.
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
# ===== 1) Ler base =====
df = pd.read_csv("./src/wine.csv")
# ===== 2) Remover colunas irrelevantes (se existirem) =====
drop_cols = ["Unnamed: 0", "title", "description", "taster_twitter_handle"]
df = df.drop(columns=[c for c in drop_cols if c in df.columns], errors="ignore")
# ===== 3) Conversões de tipo essenciais =====
if "points" in df.columns:
df["points"] = pd.to_numeric(df["points"], errors="coerce")
if "price" in df.columns:
df["price"] = pd.to_numeric(df["price"], errors="coerce")
# ===== 4) Criar alvo supervisionado a partir de 'points' =====
# quality_high = 1 (>= 90), 0 (< 90)
# Linhas sem 'points' não ajudam na classificação — removemos
df = df[df["points"].notna()].copy()
df["quality_high"] = (df["points"] >= 90).astype(int)
# ===== 5) Codificar variáveis categóricas (LabelEncoder simples) =====
cat_cols = [
col for col in ["country", "province", "region_1", "region_2",
"taster_name", "designation", "variety", "winery"]
if col in df.columns
]
for c in cat_cols:
le = LabelEncoder()
df[c] = df[c].fillna("Unknown").astype(str)
df[c] = le.fit_transform(df[c])
# ===== 6) Definir X (features) e y (alvo), evitando vazamento de 'points' =====
feature_cols = []
feature_cols += [c for c in cat_cols] # categóricas codificadas
if "price" in df.columns:
feature_cols.append("price") # numérica útil
# NÃO usar 'points' como feature (origem do alvo)
X = df[feature_cols].copy()
y = df["quality_high"].copy()
# ===== 7) Divisão treino/teste com estratificação =====
X_train, X_test, y_train, y_test = train_test_split(
X, y,
test_size=0.30,
random_state=27,
stratify=y
)
Treinamento do Modelo — Árvore de Decisão
Nesta etapa treinamos um DecisionTreeClassifier utilizando a base pré-processada.
Mantemos a mesma preparação usada na divisão (remoção de colunas irrelevantes, criação de quality_high a partir de points, codificação das categóricas) e realizamos o ajuste do modelo com 70/30 de treino/teste e random_state=27.
Precisão da Validação: 0.75
Importância das Features:
| Feature | Importância |
|---|---|
| price | 0.343989 |
| winery | 0.205696 |
| designation | 0.169186 |
| variety | 0.091451 |
| region_1 | 0.082287 |
| taster_name | 0.034639 |
| province | 0.034284 |
| country | 0.020755 |
| region_2 | 0.017713 |
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn import tree
from sklearn.metrics import accuracy_score
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from io import StringIO
from sklearn.preprocessing import LabelEncoder
import numpy as np
# ===================== Ler base =====================
df = pd.read_csv("./src/wine.csv")
# ===================== Excluir colunas não desejadas =====================
# (mesma ideia do seu script antigo: tirar campos irrelevantes/verbosos)
drop_cols = ["Unnamed: 0", "title", "description", "taster_twitter_handle"]
df = df.drop(columns=[c for c in drop_cols if c in df.columns], errors="ignore")
# ===================== Conversões básicas =====================
# 'points' será usada para criar o alvo; por isso NÃO entra em X
df["points"] = pd.to_numeric(df["points"], errors="coerce")
# price pode ter nulos; converte e imputa mediana
if "price" in df.columns:
df["price"] = pd.to_numeric(df["price"], errors="coerce")
if df["price"].isna().any():
df["price"] = df["price"].fillna(df["price"].median())
# manter somente linhas com 'points' (necessário para o alvo)
df = df[df["points"].notna()].copy()
# ===================== Criar alvo supervisionado =====================
# quality_high = 1 (>= 90), 0 (< 90)
df["quality_high"] = (df["points"] >= 90).astype(int)
# ===================== Label encoding das categóricas =====================
# (seguindo o espírito do seu antigo: LabelEncoder direto nas colunas de texto)
cat_cols = [c for c in ["country", "province", "region_1", "region_2",
"taster_name", "designation", "variety", "winery"]
if c in df.columns]
for c in cat_cols:
le = LabelEncoder()
df[c] = df[c].fillna("Unknown").astype(str)
df[c] = le.fit_transform(df[c])
# ===================== Definição de features (X) e alvo (y) =====================
# Evitar vazamento: NUNCA usar 'points' em X, pois dela deriva o alvo
feature_cols = []
feature_cols += cat_cols
if "price" in df.columns:
feature_cols.append("price")
x = df[feature_cols].copy()
y = df["quality_high"].copy()
# ===================== Divisão treino/teste =====================
x_train, x_test, y_train, y_test = train_test_split(
x, y, test_size=0.3, random_state=27, stratify=y
)
# ===================== Criar e treinar o modelo de árvore de decisão =====================
classifier = tree.DecisionTreeClassifier(random_state=27)
classifier.fit(x_train, y_train)
# ===================== Avaliar o modelo =====================
y_pred = classifier.predict(x_test)
accuracy = accuracy_score(y_test, y_pred)
print(f"Precisão da Validação: {accuracy:.2f}")
# Importância das features
feature_importance = pd.DataFrame({
'Feature': classifier.feature_names_in_,
'Importância': classifier.feature_importances_
}).sort_values(by='Importância', ascending=False)
print("<br>Importância das Features:")
print(feature_importance.to_html(index=False))
# ===================== Plot da árvore (até profundidade 5, como no seu padrão) =====================
plt.figure(figsize=(20, 10))
try:
tree.plot_tree(classifier, max_depth=5, fontsize=10, feature_names=classifier.feature_names_in_)
except Exception:
tree.plot_tree(classifier, max_depth=5, fontsize=10)
# Para imprimir na página HTML (MkDocs)
buffer = StringIO()
plt.savefig(buffer, format="svg", transparent=True)
print(buffer.getvalue())
Avaliação do Modelo — Árvore de Decisão
Após o treinamento, a árvore foi avaliada no conjunto de teste (30%), garantindo que as métricas reflitam a capacidade de generalização do modelo. As saídas principais consideradas são:
- Acurácia (teste): proporção de acertos sobre o total de amostras de teste;
- Matriz de Confusão: distribuição de acertos/erros por classe (0 = qualidade comum, 1 = alta qualidade), útil para inspecionar erros assimétricos;
- Classification Report: métricas por classe (precision, recall e F1), evidenciando se o modelo está sacrificando uma classe em detrimento da outra;
- Importância das Features: ranking de variáveis mais influentes na decisão (ex.:
variety,province/country,price), auxiliando na interpretação.
Observações e interpretação
- Desbalanceamento: é comum a classe “alta qualidade” (≥ 90 pontos) ser minoritária, o que pode reduzir o recall dessa classe mesmo com acurácia global razoável.
- Overfitting: árvores muito profundas tendem a memorizar o treino. Se a diferença entre desempenho de treino e teste for alta, recomenda-se restringir
max_depth,min_samples_leafe/oumin_samples_split. - Variáveis proxy: atributos geográficos (
country,province,region_*) evarietypodem capturar padrões de estilo/qualidade;pricecostuma aparecer relevante, mas atenção a correlações espúrias e viés de seleção. - Sem vazamento: a variável
pointsfoi usada apenas para gerar o alvo (quality_high) e não entrou como feature nas previsões.
Treinamento do Modelo - KNN
Nesta seção foi implementado o algoritmo KNN de forma manual, a partir do zero, para consolidar o entendimento do funcionamento do método.
A implementação considera a distância euclidiana entre os pontos, identifica os vizinhos mais próximos e atribui a classe com maior frequência.
Esse exercício é importante para compreender a lógica por trás do KNN antes de utilizar bibliotecas prontas.
Acurácia (KNN manual, k=5): 0.716 Tamanho treino/teste: 3500 / 1500
Acurácia (KNN manual, k=5): 0.716 Tamanho treino/teste: 3500 / 1500
Usando Scikit-Learn
Aqui repetimos a preparação e treinamos o KNeighborsClassifier do scikit-learn. Para fins de visualização, usamos PCA (2D) apenas para projetar os dados e exibir a fronteira de decisão do KNN no plano, junto de um gráfico de dispersão dos pontos de treino.
Acurácia (KNN sklearn c/ PCA 2D, k=5): 0.709
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg") # backend headless para rodar no mkdocs
import matplotlib.pyplot as plt
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
# 1) Carregar base
df = pd.read_csv("./src/wine.csv")
# 2) Limpeza básica e alvo
drop_cols = ["Unnamed: 0", "title", "description", "taster_twitter_handle"]
df = df.drop(columns=[c for c in drop_cols if c in df.columns], errors="ignore")
df["points"] = pd.to_numeric(df.get("points"), errors="coerce")
if "price" in df.columns:
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df = df[df["points"].notna()].copy()
df["quality_high"] = (df["points"] >= 90).astype(int)
# 3) Amostragem estratificada cedo (antes de dummies) para visualização
MAX_VIS = 4000
if len(df) > MAX_VIS:
frac = MAX_VIS / len(df)
df = (
df.groupby("quality_high", group_keys=False)
.apply(lambda g: g.sample(frac=frac, random_state=42))
.reset_index(drop=True)
)
y = df["quality_high"].values
# 4) Features (evitar cardinalidade absurda)
num_cols = [c for c in ["price"] if c in df.columns]
cat_cols = [c for c in ["country", "province", "region_1", "variety"] if c in df.columns]
for c in num_cols:
df[c] = df[c].fillna(df[c].median())
for c in cat_cols:
df[c] = df[c].fillna("Unknown").astype(str).str.strip()
X_cat = pd.get_dummies(df[cat_cols], drop_first=False, dtype=int) if cat_cols else pd.DataFrame(index=df.index)
X_num = df[num_cols].copy() if num_cols else pd.DataFrame(index=df.index)
if not X_num.empty:
scaler = StandardScaler()
X_num[num_cols] = scaler.fit_transform(X_num[num_cols])
X = pd.concat([X_num, X_cat], axis=1).values
# 5) Split (70/30), manter consistência
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.30, random_state=27, stratify=y
)
# 6) PCA (2D) apenas para visualização/fronteira
pca = PCA(n_components=2, random_state=27)
X_train_2d = pca.fit_transform(X_train)
X_test_2d = pca.transform(X_test)
# 7) KNN no espaço 2D projetado
knn = KNeighborsClassifier(n_neighbors=5)
knn.fit(X_train_2d, y_train)
pred = knn.predict(X_test_2d)
acc = accuracy_score(y_test, pred)
print(f"Acurácia (KNN sklearn c/ PCA 2D, k=5): {acc:.3f}")
# 8) Fronteira de decisão em 2D (grid menos denso pra não travar)
plt.figure(figsize=(8, 6))
h = 0.25
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, alpha=0.3)
plt.scatter(X_train_2d[:, 0], X_train_2d[:, 1], c=y_train, s=16, edgecolors="k", alpha=0.8)
plt.xlabel("PCA 1")
plt.ylabel("PCA 2")
plt.title("KNN — Fronteira de decisão (PCA 2D)")
plt.tight_layout()
buf = StringIO()
plt.savefig(buf, format="svg", transparent=True)
print(buf.getvalue())
plt.close()
Avaliação do Modelo - KNN
Após o treinamento, avaliamos a acurácia em teste nas duas abordagens (manual e com scikit-learn). Como o alvo é quality_high (derivado de points), o desempenho pode ser afetado por desbalanceamento (vinhos com nota ≥ 90 tendem a ser minoria). Além disso, o KNN é sensível à escala e à escolha de k, o que pode gerar variação de resultados. A visualização em PCA 2D tende a mostrar sobreposição entre classes, reforçando a dificuldade de separação perfeita nesse domínio.
Treinamento do Modelo - K-Means
O modelo K-Means foi treinado com 3 clusters como referência pedagógica para segmentar perfis de vinhos (ex.: baixa/média/alta qualidade percebida).
Após o pré-processamento (padronização de variáveis numéricas e one-hot para categóricas selecionadas), aplicamos o K-Means e projetamos os dados em PCA (2D) para visualização dos grupos e de seus centróides.
import base64
from io import BytesIO
import numpy as np
import pandas as pd
import matplotlib
matplotlib.use("Agg") # backend headless p/ rodar no mkdocs
import matplotlib.pyplot as plt
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
# 1) Carregar base
df = pd.read_csv("./src/wine.csv")
# 2) Remover campos verbosos/irrelevantes (se existirem)
drop_cols = ["Unnamed: 0", "title", "description", "taster_twitter_handle"]
df = df.drop(columns=[c for c in drop_cols if c in df.columns], errors="ignore")
# 3) Conversões e imputações mínimas
df["points"] = pd.to_numeric(df.get("points"), errors="coerce")
if "price" in df.columns:
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df["price"] = df["price"].fillna(df["price"].median())
# 4) AMOSTRAGEM cedo (para não pesar no build)
MAX_ROWS = 5000
if len(df) > MAX_ROWS:
df = df.sample(MAX_ROWS, random_state=27).reset_index(drop=True)
# 5) Seleção de features (evitar cardinalidade extrema)
num_cols = [c for c in ["price"] if c in df.columns]
cat_cols = [c for c in ["country", "province", "region_1", "variety"] if c in df.columns] # sem taster_name
for c in cat_cols:
df[c] = df[c].fillna("Unknown").astype(str).str.strip()
X_cat = pd.get_dummies(df[cat_cols], drop_first=False, dtype=int) if cat_cols else pd.DataFrame(index=df.index)
X_num = df[num_cols].copy() if num_cols else pd.DataFrame(index=df.index)
if not X_num.empty:
scaler = StandardScaler()
X_num[num_cols] = scaler.fit_transform(X_num[num_cols])
X = pd.concat([X_num, X_cat], axis=1).values
# 6) PCA 2D para visualização
pca = PCA(n_components=2, random_state=27)
X_pca = pca.fit_transform(X)
# 7) K-Means no espaço 2D (k=3)
kmeans = KMeans(n_clusters=3, init="k-means++", max_iter=300, n_init=10, random_state=27)
labels = kmeans.fit_predict(X_pca)
# 8) Plot (matplotlib puro) e saída em base64 (HTML <img>)
plt.figure(figsize=(8, 6))
plt.scatter(X_pca[:, 0], X_pca[:, 1], c=labels, s=18, alpha=0.85)
plt.scatter(
kmeans.cluster_centers_[:, 0], kmeans.cluster_centers_[:, 1],
c="red", marker="*", s=200, label="Centróides"
)
plt.title("K-Means (Wine) — PCA 2D, k=3")
plt.xlabel("PCA 1")
plt.ylabel("PCA 2")
plt.legend(loc="best")
buf = BytesIO()
plt.savefig(buf, format="png", transparent=True, bbox_inches="tight")
plt.close()
buf.seek(0)
img_b64 = base64.b64encode(buf.read()).decode("utf-8")
print(f'<img src="data:image/png;base64,{img_b64}" alt="KMeans clustering (Wine)"/>')
Avaliação do Modelo
Para avaliar o clustering, mapeamos os clusters para o rótulo derivado quality_high (1 se points ≥ 90, 0 caso contrário) por voto majoritário em cada grupo. Assim, obtemos métricas de classificação mesmo em um cenário originalmente não supervisionado, incluindo acurácia e matriz de confusão.
Acurácia (mapeamento por voto, treino): 62.90%
Matriz de Confusão (treino):
| Classe Pred 0 | Classe Pred 1 | |
|---|---|---|
| Classe Real 0 | 2614 | 1 |
| Classe Real 1 | 1557 | 28 |
import numpy as np
import pandas as pd
from sklearn.metrics import accuracy_score, confusion_matrix
from sklearn.model_selection import train_test_split
from sklearn.cluster import KMeans
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
# 1) Carregar base
df = pd.read_csv("./src/wine.csv")
# 2) Limpeza e alvo derivado
drop_cols = ["Unnamed: 0", "title", "description", "taster_twitter_handle"]
df = df.drop(columns=[c for c in drop_cols if c in df.columns], errors="ignore")
df["points"] = pd.to_numeric(df.get("points"), errors="coerce")
df = df[df["points"].notna()].copy() # necessário para quality_high
if "price" in df.columns:
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df["price"] = df["price"].fillna(df["price"].median())
df["quality_high"] = (df["points"] >= 90).astype(int)
y = df["quality_high"].values
# 3) AMOSTRAGEM estratificada cedo (para não pesar no build)
MAX_ROWS = 6000
if len(df) > MAX_ROWS:
frac = MAX_ROWS / len(df)
df = (
df.groupby("quality_high", group_keys=False)
.apply(lambda g: g.sample(frac=frac, random_state=27))
.reset_index(drop=True)
)
y = df["quality_high"].values
# 4) Features (evitar cardinalidade extrema)
num_cols = [c for c in ["price"] if c in df.columns]
cat_cols = [c for c in ["country", "province", "region_1", "variety"] if c in df.columns] # sem taster_name
for c in cat_cols:
df[c] = df[c].fillna("Unknown").astype(str).str.strip()
X_cat = pd.get_dummies(df[cat_cols], drop_first=False, dtype=int) if cat_cols else pd.DataFrame(index=df.index)
X_num = df[num_cols].copy() if num_cols else pd.DataFrame(index=df.index)
if not X_num.empty:
scaler = StandardScaler()
X_num[num_cols] = scaler.fit_transform(X_num[num_cols])
X = pd.concat([X_num, X_cat], axis=1).values
# 5) Split (70/30) + PCA no treino
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.30, random_state=27, stratify=y
)
pca = PCA(n_components=2, random_state=27)
X_train_pca = pca.fit_transform(X_train)
# 6) K-Means em treino (k=3) e mapeamento cluster->classe por voto majoritário
kmeans = KMeans(n_clusters=3, init="k-means++", max_iter=300, n_init=10, random_state=27)
labels_train = kmeans.fit_predict(X_train_pca)
cluster_map = {}
classes = np.unique(y_train)
for c in np.unique(labels_train):
mask = labels_train == c
if mask.sum() == 0:
cluster_map[c] = classes[0]
continue
counts = np.bincount(y_train[mask], minlength=classes.max() + 1)
cluster_map[c] = counts.argmax()
# 7) Acurácia e matriz de confusão (TREINO), como no seu exemplo
y_pred_train = np.array([cluster_map[c] for c in labels_train])
acc = accuracy_score(y_train, y_pred_train)
cm = confusion_matrix(y_train, y_pred_train, labels=np.sort(classes))
cm_df = pd.DataFrame(
cm,
index=[f"Classe Real {cls}" for cls in np.sort(classes)],
columns=[f"Classe Pred {cls}" for cls in np.sort(classes)]
)
print(f"Acurácia (mapeamento por voto, treino): {acc*100:.2f}%")
print("<br>Matriz de Confusão (treino):")
print(cm_df.to_html(index=True))
Conclusão Geral do Projeto
O projeto teve como objetivo aplicar e comparar três abordagens clássicas de Machine Learning — Árvore de Decisão, KNN (K-Nearest Neighbors) e K-Means — sobre o dataset kagle de vinhos, que contém informações como país, região, variedade, provador, preço e pontuação (variável points), entre outras.
A proposta foi compreender como diferentes técnicas de aprendizado supervisionado e não supervisionado lidam com o problema de prever ou agrupar vinhos de alta qualidade (definidos como aqueles com pontuação ≥ 90).
Sobre a Base de Dados
A base apresenta alta cardinalidade categórica (muitos valores distintos em colunas como variety e region_1) e distribuição desigual entre países e faixas de pontuação.
Por esse motivo, foi necessário aplicar pré-processamentos cuidadosos, como: - normalização das variáveis numéricas (price); - one-hot encoding para variáveis categóricas; - e amostragem estratificada para reduzir o custo computacional mantendo representatividade.
Essa estrutura favoreceu a análise, mas também revelou as limitações naturais do conjunto — com poucos atributos diretamente relacionados à qualidade sensorial do vinho, as previsões tendem a capturar mais o perfil geral do mercado do que a avaliação crítica de especialistas.
Árvore de Decisão
O modelo de Árvore de Decisão apresentou uma acurácia de 75%, sendo o melhor desempenho entre os métodos supervisionados testados.
A análise das importâncias das variáveis mostrou que: - o preço é o principal preditor (forte correlação com a pontuação),
- seguido por winery e designation, que representam o produtor e o rótulo do vinho.
O modelo conseguiu estruturar regras compreensíveis — como faixas de preço associadas à qualidade —, reforçando o caráter interpretável das árvores.
Ainda assim, parte do resultado pode refletir overfitting leve, já que a árvore aprende padrões muito específicos de produtores e regiões.
KNN (K-Nearest Neighbors)
O KNN manual alcançou ≈71,6% de acurácia, enquanto a versão com Scikit-Learn e projeção via PCA (2D) obteve cerca de 70,8%.
O gráfico de fronteira de decisão mostrou uma sobreposição significativa entre classes, especialmente nas regiões de média qualidade.
Isso reforça o fato de que os atributos disponíveis não separam de forma nítida os vinhos de alta e baixa pontuação.
Apesar disso, o KNN apresentou comportamento consistente e intuitivo: - bons resultados para amostras com padrões similares de região e variedade; - sensibilidade a escala e densidade de vizinhança, exigindo scaling e escolha adequada de k.
K-Means (Clustering)
O K-Means, aplicado com 3 clusters, buscou agrupar vinhos de maneira não supervisionada, representando diferentes perfis de qualidade.
Os grupos formados mostraram tendência de separação, mas com fronteiras difusas, o que é esperado dado que os dados não possuem rótulos explícitos de classes distintas.
O mapeamento por voto majoritário atingiu ≈62,9% de acurácia, com confusão entre vinhos medianos e de alta pontuação.
O resultado indica que, embora o K-Means consiga identificar padrões estruturais (como faixas de preço e origem), ele não captura com precisão o conceito subjetivo de “qualidade” — que depende de fatores sensoriais não representados na base.
Considerações Finais
Comparando os modelos:
| Modelo | Tipo de Aprendizado | Acurácia | Observações |
|---|---|---|---|
| Árvore de Decisão | Supervisionado | 0.75 | Melhor desempenho; interpretável; sensível a overfitting |
| KNN | Supervisionado | ~0.71 | Bom em padrões locais; alta sobreposição entre classes |
| K-Means | Não supervisionado | ~0.63 | Agrupamento coerente, mas difuso; útil para segmentação exploratória |
Em síntese, o projeto mostrou que: - modelos supervisionados (Árvore e KNN) se beneficiam do conhecimento prévio das classes e alcançam resultados mais robustos;
- o K-Means, embora menos preciso, é útil para análises exploratórias e descoberta de padrões; - e que a qualidade do dado é tão ou mais determinante do que o algoritmo escolhido — atributos objetivos como preço e origem são insuficientes para explicar completamente uma variável subjetiva como “pontuação de degustação”.
Assim, o estudo reforça a importância do pré-processamento, seleção de variáveis e análise crítica dos resultados em qualquer projeto de Machine Learning, especialmente em domínios complexos como o enológico, onde os dados quantitativos capturam apenas parte da realidade avaliada por especialistas.
Reflexão Final
O desenvolvimento deste projeto proporcionou uma visão prática do ciclo completo de Machine Learning: desde a limpeza e transformação de dados até a avaliação e comparação de modelos.
Ficou evidente que compreender o contexto do problema e a natureza dos dados é essencial para interpretar resultados e extrair conclusões relevantes.
Mais do que alcançar a maior acurácia possível, o aprendizado principal foi entender como cada modelo oferece uma lente diferente sobre os mesmos dados, revelando tanto suas potencialidades quanto suas limitações.