Introducción

Este documento presenta un análisis de clustering utilizando el algoritmo UMAP (Uniform Manifold Approximation and Projection) para reducción de dimensionalidad, seguido de técnicas de clustering no supervisado para identificar al menos 3 grupos distintos en los datos.

REQ01: Fuente de datos pública aprobada

Fuente seleccionada: Dataset ChocolateSales de Kaggle
https://www.kaggle.com/datasets/atharvasoundankar/chocolate-sales

REQ02: Preguntas de investigación

Pregunta 1

¿Cómo varía la preferencia de producto entre los diferentes países registrados en el dataset y qué relación existe entre el tipo de producto y el país de origen de la venta?

Variables: Producto (Nominal), País (Nominal), Canal de venta (Nominal)

Pregunta 2

¿Cómo se distribuyen los canales de venta utilizados en cada país para la comercialización de los distintos tipos de productos de chocolate?

Variables: Canal de venta (Nominal), País (Nominal), Producto (Nominal)

Pregunta 3

¿Qué relación existe entre el país de venta, el tipo de producto y el tipo de cliente registrado en el dataset?

Variables: País (Nominal), Producto (Nominal), Tipo de cliente (Nominal)

Carga de Librerías

import re
import numpy as np
import pandas as pd
import umap
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
import seaborn as sns
import matplotlib.pyplot as plt

sns.set(style="whitegrid", context="notebook")

Carga y Exploración de Datos

df = pd.read_csv("C:/UIABD/Chocolate_Sales.csv")

print(f"Dimensiones: {df.shape[0]} filas x {df.shape[1]} columnas")
## Dimensiones: 1094 filas x 6 columnas
print("\nPrimeras filas del dataset:")
## 
## Primeras filas del dataset:
print(df.head())
##      Sales Person    Country  ...    Amount Boxes Shipped
## 0  Jehu Rudeforth         UK  ...   $5,320            180
## 1     Van Tuxwell      India  ...   $7,896             94
## 2    Gigi Bohling      India  ...   $4,501             91
## 3    Jan Morforth  Australia  ...  $12,726            342
## 4  Jehu Rudeforth         UK  ...  $13,685            184
## 
## [5 rows x 6 columns]
print("\nInformación del dataset:")
## 
## Información del dataset:
print(df.info())
## <class 'pandas.core.frame.DataFrame'>
## RangeIndex: 1094 entries, 0 to 1093
## Data columns (total 6 columns):
##  #   Column         Non-Null Count  Dtype 
## ---  ------         --------------  ----- 
##  0   Sales Person   1094 non-null   object
##  1   Country        1094 non-null   object
##  2   Product        1094 non-null   object
##  3   Date           1094 non-null   object
##  4   Amount         1094 non-null   object
##  5   Boxes Shipped  1094 non-null   int64 
## dtypes: int64(1), object(5)
## memory usage: 51.4+ KB
## None
for col in df.columns:
    if df[col].dtype == "object":
        cleaned = df[col].astype(str).str.replace(r"[^0-9.\-]", "", regex=True)
        numeric = pd.to_numeric(cleaned, errors="coerce")
        if numeric.notna().mean() > 0.8:
            df[col] = numeric

num_cols = df.select_dtypes(include=[np.number]).columns.tolist()
df_num = df[num_cols].copy()

print("\nColumnas numéricas usadas para clustering:")
## 
## Columnas numéricas usadas para clustering:
print(num_cols)
## ['Amount', 'Boxes Shipped']
df_num = df_num.dropna()
print("\nFilas después de eliminar NAs:", df_num.shape[0])
## 
## Filas después de eliminar NAs: 1094

Preprocesamiento de Datos

scaler = StandardScaler()
X_scaled = scaler.fit_transform(df_num.values)

Reducción de Dimensionalidad con UMAP

umap_model = umap.UMAP(
    n_neighbors=15,
    n_components=2,
    min_dist=0.1,
    metric="euclidean",
    random_state=42
)

umap_data = umap_model.fit_transform(X_scaled)
## C:\Users\steve\AppData\Local\Programs\Python\PYTHON~2\Lib\site-packages\umap\umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
##   warn(
df_umap = pd.DataFrame(umap_data, columns=["UMAP1", "UMAP2"])
df_umap.index = df_num.index

df_umap.head()
##       UMAP1     UMAP2
## 0  4.004960  5.885582
## 1  3.418832  1.994409
## 2  8.090756  4.450562
## 3 -1.446002  5.767755
## 4 -1.242552  4.031403

Clustering con K-Means

k = 3
kmedias = KMeans(
    n_clusters=k,
    max_iter=2000,
    n_init=150,
    random_state=42
)
kmedias.fit(df_umap)
KMeans(max_iter=2000, n_clusters=3, n_init=150, random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
grupos = kmedias.predict(df_umap)
df_umap["cluster"] = grupos
df_num["cluster"] = grupos
df_umap
##          UMAP1      UMAP2  cluster
## 0     4.004960   5.885582        2
## 1     3.418832   1.994409        1
## 2     8.090756   4.450562        0
## 3    -1.446002   5.767755        2
## 4    -1.242552   4.031403        1
## ...        ...        ...      ...
## 1089  0.849156   8.682555        2
## 1090  3.637950   3.283180        1
## 1091  5.326190  10.134583        2
## 1092  0.083328   9.981619        2
## 1093  0.448169   8.586951        2
## 
## [1094 rows x 3 columns]
print("\nDistribución de clústeres:")
## 
## Distribución de clústeres:
print(df_num["cluster"].value_counts().sort_index())
## cluster
## 0    379
## 1    393
## 2    322
## Name: count, dtype: int64

Evaluación de la Calidad del Clustering

sil_prom = silhouette_score(df_umap[["UMAP1", "UMAP2"]], grupos)
print("\nCoeficiente de silueta promedio:", round(sil_prom, 4))
## 
## Coeficiente de silueta promedio: 0.4775

Visualización de Clústeres en UMAP

plt.figure(figsize=(8, 6))
sns.scatterplot(
    data=df_umap, x="UMAP1", y="UMAP2",
    hue="cluster", palette="Set2", s=40, alpha=0.8
)
plt.title(f"Clústeres con UMAP + KMeans (k = {k})")
plt.legend(title="Cluster")
plt.tight_layout()
plt.show()

Mapa de Calor de Características por Clúster

cluster_means = df_num.groupby("cluster").mean()

plt.figure(figsize=(len(cluster_means.columns) * 0.6 + 3, 4))
sns.heatmap(
    cluster_means,
    cmap="RdBu_r",
    center=0,
    annot=False,
    cbar_kws={"label": "Valor medio (escalado o numérico)"}
)
plt.title("Mapa de Calor: Características por Clúster")
plt.xlabel("Variable")
plt.ylabel("Cluster")
plt.tight_layout()
plt.show()

Gráfico de Radar (Spider Chart)

cluster_means_norm = cluster_means.copy()
for col in cluster_means_norm.columns:
    col_min = cluster_means_norm[col].min()
    col_max = cluster_means_norm[col].max()
    rango = col_max - col_min
    if rango > 0:
        cluster_means_norm[col] = (cluster_means_norm[col] - col_min) / rango * 100
    else:
        cluster_means_norm[col] = 50

max_vars = 12
vars_radar = cluster_means_norm.columns[:max_vars]

data_radar = cluster_means_norm[vars_radar].to_numpy()
labels_clusters = [f"Cluster {i}" for i in cluster_means_norm.index]

num_vars = len(vars_radar)
angles = np.linspace(0, 2 * np.pi, num_vars, endpoint=False).tolist()
angles += angles[:1]

plt.figure(figsize=(8, 6))
ax = plt.subplot(111, polar=True)

for i, (row, label) in enumerate(zip(data_radar, labels_clusters)):
    values = row.tolist()
    values += values[:1]
    ax.plot(angles, values, label=label, linewidth=2)
    ax.fill(angles, values, alpha=0.15)

ax.set_xticks(angles[:-1])
ax.set_xticklabels(vars_radar, fontsize=9)
ax.set_yticks([0, 25, 50, 75, 100])
ax.set_yticklabels(["0%", "25%", "50%", "75%", "100%"])
ax.set_title("Perfil de Características por Clúster (Radar)", fontsize=13, pad=20)
ax.legend(loc="upper right", bbox_to_anchor=(1.25, 1.1))
plt.tight_layout()
plt.show()

Resumen

print("RESUMEN DEL ANÁLISIS DE CLÚSTERES")
## RESUMEN DEL ANÁLISIS DE CLÚSTERES
print("==================================\n")
## ==================================
print("Algoritmo UMAP: Reducción de dimensionalidad")
## Algoritmo UMAP: Reducción de dimensionalidad
print("Algoritmo K-Means: Clustering")
## Algoritmo K-Means: Clustering
print(f"Número de clústeres: {k}")
## Número de clústeres: 3
print(f"Coeficiente de silueta promedio: {round(sil_prom, 4)}")
## Coeficiente de silueta promedio: 0.4775
print("\nDistribución de observaciones:")
## 
## Distribución de observaciones:
for clust in sorted(df_num["cluster"].unique()):
    n_obs = (df_num["cluster"] == clust).sum()
    porcentaje = round(n_obs / len(df_num) * 100, 2)
    print(f"  Cluster {clust}: {n_obs} observaciones ({porcentaje}%)")
##   Cluster 0: 379 observaciones (34.64%)
##   Cluster 1: 393 observaciones (35.92%)
##   Cluster 2: 322 observaciones (29.43%)