Ejemplo K-Means acciones#

import yfinance as yf
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

Descargar precios de acciones con diferentes características.

acciones = [
    # 🟢 Acciones de bajo Beta (< 0.8) — defensivas
    'JNJ',   # Johnson & Johnson
    'PG',    # Procter & Gamble
    'KO',    # Coca-Cola
    'PEP',   # PepsiCo
    'WMT',   # Walmart

    # 🟡 Acciones de Beta cercano a 1 (≈ 0.9 - 1.1) — mercado promedio
    'AAPL',  # Apple
    'MSFT',  # Microsoft
    'V',     # Visa
    'MA',    # Mastercard
    'UNH',   # UnitedHealth

    # 🔴 Acciones de Beta alto (> 1.2) — más volátiles que el mercado
    'TSLA',  # Tesla
    'NVDA',  # Nvidia
    'META',  # Meta (Facebook)
    'AMZN',  # Amazon
    'NFLX',  # Netflix

    # 🔁 Adicionales mixtas para aumentar diversidad
    'GOOGL', # Alphabet (Google)
    'AMD',   # Advanced Micro Devices
    'CRM',   # Salesforce
    'BA',    # Boeing
    'NKE'    # Nike
]

indice = '^GSPC'  # S&P500

datos = yf.download(acciones + [indice], start='2020-07-01', end='2025-07-31', interval='1mo')['Close']
datos.dropna(inplace=True)

datos.describe()
/tmp/ipython-input-4028798923.py:33: FutureWarning: YF.download() has changed argument auto_adjust default to True
  datos = yf.download(acciones + [indice], start='2020-07-01', end='2025-07-31', interval='1mo')['Close']
[*******************100%*********************]  21 of 21 completed
Ticker AAPL AMD AMZN BA CRM GOOGL JNJ KO MA META ... NFLX NKE NVDA PEP PG TSLA UNH V WMT ^GSPC
count 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 ... 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000 61.000000
mean 168.308614 111.604426 157.696810 192.557214 234.247295 130.157316 149.466887 56.029760 398.977756 351.281434 ... 555.438689 106.611314 53.068235 149.308493 141.179574 246.167060 444.539697 241.869443 56.554208 4591.564901
std 37.929745 32.599924 35.406763 31.330118 48.640246 32.428266 10.006475 8.197990 82.631454 165.833284 ... 256.862147 26.270566 47.606693 17.191153 16.928580 67.619500 87.096894 50.515682 18.592934 813.091153
min 103.174973 60.060001 84.000000 121.080002 131.438263 72.843140 119.756889 40.592747 279.303223 92.651718 ... 174.869995 56.027664 10.579877 112.904648 110.428230 95.384003 249.559998 173.635742 38.925205 3269.959961
25% 138.524857 85.519997 133.089996 171.820007 201.018570 103.111603 144.014481 50.373859 346.991211 252.285934 ... 394.519989 88.638329 16.206350 134.108490 127.865433 201.880005 386.012909 206.614456 44.427788 4076.600098
50% 167.575699 102.900002 160.309998 194.190002 236.031769 131.928787 151.222519 56.363880 364.622528 316.861694 ... 517.570007 105.146385 27.729065 153.950897 140.274231 240.080002 472.730499 225.511017 47.599205 4395.259766
75% 191.829437 137.179993 176.759995 212.009995 267.514771 154.275345 155.431351 59.936256 447.335144 473.209686 ... 641.619995 125.442963 90.315811 161.462921 155.857986 282.160004 498.170715 269.519684 59.151489 5254.350098
max 249.534180 192.529999 237.679993 260.660004 340.623810 203.538910 164.740005 72.037811 584.808716 773.440002 ... 1339.130005 160.342438 177.869995 177.486206 175.867111 404.600006 601.015320 363.944122 98.481895 6339.390137

8 rows × 21 columns

datos.info()
<class 'pandas.core.frame.DataFrame'>
DatetimeIndex: 61 entries, 2020-07-01 to 2025-07-01
Data columns (total 21 columns):
 #   Column  Non-Null Count  Dtype
---  ------  --------------  -----
 0   AAPL    61 non-null     float64
 1   AMD     61 non-null     float64
 2   AMZN    61 non-null     float64
 3   BA      61 non-null     float64
 4   CRM     61 non-null     float64
 5   GOOGL   61 non-null     float64
 6   JNJ     61 non-null     float64
 7   KO      61 non-null     float64
 8   MA      61 non-null     float64
 9   META    61 non-null     float64
 10  MSFT    61 non-null     float64
 11  NFLX    61 non-null     float64
 12  NKE     61 non-null     float64
 13  NVDA    61 non-null     float64
 14  PEP     61 non-null     float64
 15  PG      61 non-null     float64
 16  TSLA    61 non-null     float64
 17  UNH     61 non-null     float64
 18  V       61 non-null     float64
 19  WMT     61 non-null     float64
 20  ^GSPC   61 non-null     float64
dtypes: float64(21)
memory usage: 10.5 KB

Variables:#

Se usaran indicadores financieros para agrupar a las acciones:

  • Rendimiento medio mensual.

  • Volatilidad mensual.

  • Asimetría (Skewness).

  • Curtosis.

  • Coeficiente Beta: mide la sensibilidad del rendimiento de una acción frente a los movimientos del mercado, indicando cuánto tiende a variar la acción en relación con el índice de referencia.

def calcular_indicadores(serie_accion, serie_indice):
    retornos = serie_accion.pct_change().dropna()
    beta = np.cov(retornos, serie_indice.pct_change().dropna())[0, 1] / np.var(serie_indice.pct_change().dropna())
    return {
        'Retorno': retornos.mean(),
        'Volatilidad': retornos.std(),
        'Skewness': retornos.skew(),
        'Kurtosis': retornos.kurt(),
        'Beta': beta
    }

caracteristicas = []
for accion in acciones:
    caracteristicas.append(calcular_indicadores(datos[accion], datos[indice]))

df_indicadores = pd.DataFrame(caracteristicas, index=acciones)

df_indicadores.describe()
Retorno Volatilidad Skewness Kurtosis Beta
count 20.000000 20.000000 20.000000 20.000000 20.000000
mean 0.016279 0.092425 0.051634 0.542927 1.153155
std 0.013544 0.041562 0.562000 1.361423 0.589540
min 0.001234 0.045147 -1.143828 -0.827875 0.392903
25% 0.008797 0.062848 -0.288277 -0.358965 0.615435
50% 0.012891 0.079984 0.123930 -0.230199 1.125789
75% 0.020063 0.119030 0.306697 1.171872 1.420758
max 0.058650 0.203409 1.035205 4.428854 2.369970

Matriz de correlación:

# Matriz de correlación entre las variables:
import seaborn as sns

plt.figure(figsize=(8, 6))
sns.heatmap(df_indicadores.corr(), annot=True, cmap='coolwarm', fmt=".2f", linewidths=.5)
plt.title('Mapa de Calor de Correlaciones de Indicadores')
plt.show()
../../../_images/output_9_02.png

Pair plot:

sns.pairplot(df_indicadores, diag_kind='kde')
plt.suptitle('Indicadores', y=1.02)
plt.show()
../../../_images/output_11_02.png
# Visualización 3D de los indicadores usando px.scatter_3d:
import plotly.express as px

fig = px.scatter_3d(
    df_indicadores,
    x='Retorno',
    y='Volatilidad',
    z='Skewness',
    opacity=0.7,
    title='Indicadores Financieros 3D'
)

fig.update_layout(
    scene=dict(
        xaxis_title='Retorno',
        yaxis_title='Volatilidad',
        zaxis_title='Skewness'
    )
)

fig.show()
fig = px.scatter_3d(
    df_indicadores,
    x='Retorno',
    y='Volatilidad',
    z='Skewness',
    color='Beta', # Para clasificar por Beta
    opacity=0.7,
    title='Indicadores Financieros 3D'
)

fig.update_layout(
    scene=dict(
        xaxis_title='Retorno',
        yaxis_title='Volatilidad',
        zaxis_title='Skewness'
    )
)

fig.show()

K-Means:#

from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans, DBSCAN
from sklearn.metrics import silhouette_score, pairwise_distances_argmin_min
from scipy.cluster.hierarchy import dendrogram, linkage, fcluster
scaler = StandardScaler()
X_scaled = scaler.fit_transform(df_indicadores)
# Calcular WCSS para diferentes valores de K:
wcss = []
K = range(1, 10)
for k in K:
    kmeans = KMeans(n_clusters=k, random_state=34)
    kmeans.fit(X_scaled)
    wcss.append(kmeans.inertia_)

# Visualizar el método del codo
plt.figure(figsize=(8, 4))
plt.plot(K, wcss, "bo-")
plt.xlabel("Número de clústeres (K)")
plt.ylabel("WCSS")
plt.title("Método del Codo para determinar el número óptimo de clústeres")
plt.show()
../../../_images/output_17_01.png
!pip install kneed
Requirement already satisfied: kneed in /usr/local/lib/python3.11/dist-packages (0.8.5)
Requirement already satisfied: numpy>=1.14.2 in /usr/local/lib/python3.11/dist-packages (from kneed) (2.0.2)
Requirement already satisfied: scipy>=1.0.0 in /usr/local/lib/python3.11/dist-packages (from kneed) (1.16.1)
# Seleccion "automatica" del punto de codo:

# !pip install kneed

from kneed import KneeLocator

kl = KneeLocator(
    range(1,10),
    wcss,
    curve="convex",
    direction="decreasing"
)

print(kl.elbow)
5
# Calcular la puntuación de la silueta para diferentes valores de K:
from sklearn.metrics import silhouette_score

silhouette_scores = []
K = range(2, 11)
for k in K:
    kmeans = KMeans(n_clusters=k, random_state=34)
    kmeans.fit(X_scaled)
    labels = kmeans.labels_
    score = silhouette_score(X_scaled, labels)
    silhouette_scores.append(score)

# Visualizar la puntuación de la silueta
plt.figure(figsize=(8, 4))
plt.plot(K, silhouette_scores, "bo-")
plt.xlabel("Número de clústeres (K)")
plt.ylabel("Puntuación de la Silueta")
plt.title("Método de la Silueta para determinar el número óptimo de clústeres")
plt.show()
../../../_images/output_20_0.png

Clusters = 3

k_base = 3

kmeans = KMeans(n_clusters=k_base, random_state=34)
df_indicadores['Cluster_KMeans'] = kmeans.fit_predict(X_scaled)

# Valores de Inercia y Silueta:
inercia = kmeans.inertia_
silhouette = silhouette_score(X_scaled, df_indicadores['Cluster_KMeans'])

print(f"Clusters: {k_base}")
print(f"Inercia: {inercia}")
print(f"Puntuación de la Silueta: {silhouette}")
Clusters: 3
Inercia: 43.04524640982964
Puntuación de la Silueta: 0.38199664893165897
sns.pairplot(df_indicadores, hue='Cluster_KMeans', diag_kind='kde', palette='Set1')
plt.suptitle('Clustering K-Means', y=1.02)
plt.show()
../../../_images/output_23_01.png
# Visualización 3D de los clusters usando px.scatter_3d:

fig = px.scatter_3d(
    df_indicadores,
    x='Retorno',
    y='Volatilidad',
    z='Beta',
    color='Cluster_KMeans',
    opacity=0.7,
    title='Clustering K-Means 3D'
)

fig.update_layout(
    scene=dict(
        xaxis_title='Retorno',
        yaxis_title='Volatilidad',
        zaxis_title='Beta'
    )
)

fig.show()
from itertools import combinations
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from plotly.express.colors import qualitative as qcolors

def plot_3d_combinations(df_indicadores,
                         indicadores=('Retorno','Volatilidad','Skewness','Kurtosis','Beta'),
                         cluster_col='Cluster_KMeans',
                         rows=2,
                         marker_size=4,
                         opacity=0.8):
    # Combinaciones 3D
    combos = list(combinations(indicadores, 3))
    cols = int(np.ceil(len(combos)/rows))

    # Paleta discreta para clusters
    clusters = df_indicadores[cluster_col].astype(str)
    cluster_vals = clusters.unique()
    color_map = {cl: qcolors.Plotly[i % len(qcolors.Plotly)] for i, cl in enumerate(sorted(cluster_vals))}

    # Lienzo con subplots 3D
    fig = make_subplots(
        rows=rows, cols=cols,
        specs=[[{'type': 'scene'} for _ in range(cols)] for _ in range(rows)],
        subplot_titles=[f"{x} vs {y} vs {z}" for (x,y,z) in combos]
    )

    # Añadir trazas por subplot y por cluster (para leyenda discreta)
    for i, (x, y, z) in enumerate(combos, start=1):
        r = (i-1)//cols + 1
        c = (i-1)%cols + 1

        for cl in sorted(cluster_vals):
            mask = clusters == cl
            fig.add_trace(
                go.Scatter3d(
                    x=df_indicadores.loc[mask, x],
                    y=df_indicadores.loc[mask, y],
                    z=df_indicadores.loc[mask, z],
                    mode='markers',
                    name=f"Cluster {cl}",
                    legendgroup=f"Cluster {cl}",
                    showlegend=(i == 1),  # leyenda solo en el primer subplot
                    marker=dict(size=marker_size, opacity=opacity, color=color_map[cl]),
                    hovertemplate=f"{x}: %{{x}}<br>{y}: %{{y}}<br>{z}: %{{z}}<br>Cluster: {cl}<extra></extra>"
                ),
                row=r, col=c
            )

        # Títulos de ejes para cada escena
        scene_id = "scene" if i == 1 else f"scene{i}"
        fig.layout[scene_id].xaxis.title = x
        fig.layout[scene_id].yaxis.title = y
        fig.layout[scene_id].zaxis.title = z

    fig.update_layout(
        height=800, width=1700,
        title_text="Clustering K-Means 3D — Todas las combinaciones de indicadores",
        margin=dict(l=0, r=0, t=50, b=0)
    )
    fig.show()

plot_3d_combinations(df_indicadores)
def graficar_clusters(df, metodo, var_x='Volatilidad', var_y='Retorno'):
    plt.figure(figsize=(10, 6))
    sns.scatterplot(
        data=df,
        x=var_x,
        y=var_y,
        hue=f'Cluster_{metodo}',
        palette='Set1',
        s=120
    )
    for i in range(len(df)):
        plt.text(df[var_x].iloc[i] + 0.002, df[var_y].iloc[i], df.index[i], fontsize=9)

    plt.title(f'Clustering por {metodo}: {var_y} vs {var_x}')
    plt.xlabel(var_x)
    plt.ylabel(var_y)
    plt.legend(title='Cluster')
    plt.grid(True)
    plt.show()

# Graficar cada método
graficar_clusters(df_indicadores, 'KMeans')
../../../_images/output_26_0.png
# Clustering y variables en escala estandarizada:
labels = kmeans.labels_
X_scaled_df = pd.DataFrame(X_scaled, columns=df_indicadores.iloc[:,:-1].columns)
X_scaled_df['Cluster_KMeans'] = labels
import plotly.graph_objects as go

# Columnas a usar
cols = ['Retorno','Volatilidad','Skewness','Kurtosis','Beta']

# Preparar datos y calcular promedio por cluster
tmp = X_scaled_df.copy()
tmp[cols] = tmp[cols].apply(pd.to_numeric, errors='coerce')
agg = tmp.groupby('Cluster_KMeans')[cols].mean().sort_index()

# Construir radar combinado
cats = cols + [cols[0]]
fig = go.Figure()

for cl, row in agg.iterrows():
    vals = row.tolist()
    fig.add_trace(go.Scatterpolar(
        r=vals + [vals[0]],
        theta=cats,
        name=f'Cluster {cl}',
        fill='toself',
        opacity=0.30
    ))

fig.update_layout(
    title='Radar combinado por cluster',
    template='plotly_white',
    polar=dict(radialaxis=dict(showline=False, gridcolor='lightgray'))
)

fig.show()
import math

def boxplots_por_cluster(df):
    """
    Genera boxplots para cada variable numérica en df agrupando por Cluster_KMeans,
    mostrando 3 gráficos por fila.

    Parámetros:
    -----------
    df : pandas.DataFrame
        DataFrame con las columnas numéricas y la columna 'Cluster_KMeans'.
    """

    # Variables numéricas (todas menos Cluster_KMeans)
    variables_numericas = [col for col in df.columns if col != 'Cluster_KMeans']

    # Número de filas necesarias (3 gráficos por fila)
    n_vars = len(variables_numericas)
    n_cols = 3
    n_rows = math.ceil(n_vars / n_cols)

    # Crear figura
    fig, axes = plt.subplots(n_rows, n_cols, figsize=(n_cols * 5, n_rows * 4))
    axes = axes.flatten()

    for i, col in enumerate(variables_numericas):
        sns.boxplot(
            data=df,
            x='Cluster_KMeans',
            y=col,
            hue='Cluster_KMeans',   # Para evitar el FutureWarning
            palette='Set2',
            legend=False,
            ax=axes[i]
        )
        axes[i].set_title(f'Boxplot de {col} por Cluster', fontsize=12)
        axes[i].set_xlabel('Cluster')
        axes[i].set_ylabel(col)

    # Ocultar ejes vacíos si sobran
    for j in range(i+1, len(axes)):
        axes[j].axis('off')

    plt.tight_layout()
    plt.show()
boxplots_por_cluster(df_indicadores)
../../../_images/output_30_0.png

¿Cómo cambian los resultados con 3 clusters?

¿Cómo cambian los resultados con solo las variables Skewness y Kurtosis?

Analizar CMR con BA

¿Qué tiene de característico NFLX?

¿Es posible agrupar en un mismo clusters las acciones de baja volatilidad y rendimientos con las acciones más agresivas?