Reglas de Asociación: ¿Cuándo Confiar en Ellas?

Interpretación Crítica y Selección de Reglas Útiles

Diego Villalba

¿Dónde estamos?

La sesión pasada: calculamos soporte, confianza y lift manualmente.
Generamos reglas con mlxtend. Todo “funcionó”.

La pregunta de hoy:

¿Cuántas de esas reglas realmente significan algo?

Advertencia

Generar reglas es fácil. Interpretarlas bien es difícil.

Configuración del Entorno

Código
import pandas as pd
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import apriori, association_rules

np.random.seed(42)

El Dataset: Muestra de Groceries

Código
# Subset representativo del dataset Groceries
transacciones_raw = [
    ["whole milk", "yogurt", "bread"],
    ["whole milk", "butter", "eggs"],
    ["whole milk", "yogurt", "vegetables"],
    ["bread", "butter", "eggs"],
    ["whole milk", "soda", "chips"],
    ["yogurt", "fruit", "bread"],
    ["whole milk", "coffee", "bread"],
    ["eggs", "butter", "cheese"],
    ["whole milk", "vegetables", "fruit"],
    ["soda", "chips", "candy"],
    ["whole milk", "bread", "eggs"],
    ["yogurt", "fruit", "granola"],
    ["whole milk", "coffee", "sugar"],
    ["bread", "eggs", "cheese"],
    ["whole milk", "yogurt", "coffee"],
    ["butter", "eggs", "bread"],
    ["chips", "soda", "beer"],
    ["whole milk", "vegetables", "butter"],
    ["yogurt", "granola", "fruit"],
    ["whole milk", "bread", "sugar"],
    ["eggs", "cheese", "vegetables"],
    ["whole milk", "soda", "bread"],
    ["coffee", "sugar", "bread"],
    ["yogurt", "whole milk", "fruit"],
    ["butter", "whole milk", "eggs"],
    ["bread", "chips", "soda"],
    ["whole milk", "granola", "yogurt"],
    ["vegetables", "fruit", "bread"],
    ["whole milk", "butter", "cheese"],
    ["eggs", "bread", "coffee"],
    ["whole milk", "yogurt", "butter"],
    ["chips", "candy", "soda"],
    ["whole milk", "coffee", "eggs"],
    ["bread", "vegetables", "fruit"],
    ["yogurt", "cheese", "eggs"],
    ["whole milk", "bread", "coffee"],
    ["soda", "beer", "chips"],
    ["whole milk", "vegetables", "eggs"],
    ["yogurt", "granola", "whole milk"],
    ["bread", "butter", "sugar"],
    ["whole milk", "fruit", "cheese"],
    ["eggs", "vegetables", "butter"],
    ["whole milk", "chips", "soda"],
    ["coffee", "bread", "butter"],
    ["yogurt", "whole milk", "bread"],
    ["eggs", "cheese", "bread"],
    ["whole milk", "sugar", "butter"],
    ["vegetables", "fruit", "yogurt"],
    ["whole milk", "bread", "butter"],
    ["granola", "yogurt", "fruit"],
]

print(f"Total de transacciones: {len(transacciones_raw)}")
print(f"Ejemplo — Transacción 1: {transacciones_raw[0]}")
Total de transacciones: 50
Ejemplo — Transacción 1: ['whole milk', 'yogurt', 'bread']

Visualización: Frecuencia de Ítems

Nota

Pregunta: ¿Qué ítem domina? ¿Podría esto distorsionar las reglas?

Visualización: Matriz de Co-ocurrencia

Nota

Pregunta: ¿Qué par de ítems co-ocurre más? ¿Es eso suficiente para confiar en una regla?

Generación de Reglas

Reglas generadas: 26
regla support confidence lift
15 soda → chips 0.14 0.875 6.250
14 chips → soda 0.14 1.000 6.250
23 granola → yogurt 0.10 1.000 3.571
22 yogurt → granola 0.10 0.357 3.571
13 eggs → cheese 0.10 0.357 2.551
12 cheese → eggs 0.10 0.714 2.551
20 fruit → yogurt 0.12 0.600 2.143
21 yogurt → fruit 0.12 0.429 2.143

El Problema Central

Alta confianza ≠ Relación fuerte

Una regla puede tener confianza = 0.90 y aun así no decirnos nada nuevo.

¿Por qué? Porque si el consecuente ocurre muy frecuentemente de todas formas…

\text{lift} = \frac{P(A \cup B)}{P(A) \cdot P(B)} = \frac{\text{confidence}}{P(B)}

Si \text{lift} \approx 1: A y B son independientes. La regla no aporta (Tan et al. 2006).

¿Qué Mide Realmente Cada Métrica?

Métrica Pregunta que responde Limitación
Support ¿Qué tan común es este patrón? No dice si es interesante
Confidence Si A, ¿qué tan probable B? Ignora la frecuencia base de B
Lift ¿Es la relación real o casual? Sensible a samples pequeños

Importante

Regla de oro: Una regla útil necesita soporte razonable Y lift > 1 (Han et al. 2011).

Scatter: El Gráfico Clave

Interpretando el Scatter

🟢 Cuadrante ideal: Alta confianza + Alto lift + Buen soporte

🔴 Zona peligrosa: Alta confianza + Lift ≤ 1 → Regla espuria

🟡 Zona gris: Alto lift + Bajo soporte → ¿Señal o ruido?

Advertencia

Pregunta para reflexionar:
¿Por qué una regla con confidence = 1.0 puede estar por debajo de la línea roja?

Disección de Reglas: Caso 1

Código
# Regla con alta confianza pero lift bajo
caso1 = reglas[reglas["confidence"] > 0.5].sort_values("lift").head(3)
caso1[["regla", "support", "confidence", "lift"]].round(3)
regla support confidence lift
10 butter → whole milk 0.14 0.538 1.036
24 yogurt → whole milk 0.16 0.571 1.099
16 coffee → whole milk 0.10 0.625 1.202

¿Qué está pasando?
El consecuente es tan frecuente que aparece “solo”. La regla no añade poder predictivo (Agrawal y Srikant 1994).

Disección de Reglas: Caso 2

Código
# Regla con lift alto
caso2 = reglas[reglas["support"] >= 0.10].sort_values("lift", ascending=False).head(3)
caso2[["regla", "support", "confidence", "lift"]].round(3)
regla support confidence lift
15 soda → chips 0.14 0.875 6.250
14 chips → soda 0.14 1.000 6.250
23 granola → yogurt 0.10 1.000 3.571

¿Qué significa lift > 1.5?
Los ítems co-ocurren 50% más de lo esperado por azar.
Esto sí es una señal estadísticamente relevante.

Selección de Reglas Útiles

Código
reglas_utiles = reglas[
    (reglas["lift"] > 1.2) &
    (reglas["confidence"] > 0.4) &
    (reglas["support"] >= 0.10)
].sort_values("lift", ascending=False)

print(f"De {len(reglas)} reglas → {len(reglas_utiles)} reglas útiles")
reglas_utiles[["regla", "support", "confidence", "lift"]].round(3)
De 26 reglas → 10 reglas útiles
regla support confidence lift
14 chips → soda 0.14 1.000 6.250
15 soda → chips 0.14 0.875 6.250
23 granola → yogurt 0.10 1.000 3.571
12 cheese → eggs 0.10 0.714 2.551
20 fruit → yogurt 0.12 0.600 2.143
21 yogurt → fruit 0.12 0.429 2.143
8 butter → eggs 0.12 0.462 1.648
9 eggs → butter 0.12 0.429 1.648
3 coffee → bread 0.10 0.625 1.562
16 coffee → whole milk 0.10 0.625 1.202

Criterios aplicados:
- Lift > 1.2: relación no trivial
- Confidence > 0.4: predictividad mínima
- Support ≥ 0.10: patrón frecuente, no ruido

Cuándo NO Usar Reglas de Asociación

❌ Dataset pequeño → el soporte es inestable estadísticamente

❌ Ítems muy frecuentes dominan → distorsión por “whole milk effect”

❌ Buscas causalidad → las reglas son correlacionales, no causales (Pearl 2009)

❌ Necesitas interpretabilidad temporal → Apriori no captura secuencias

Tip

Alternativas: Para secuencias → GSP, PrefixSpan.
Para causalidad → modelos causales.

Resumen: El Filtro Mental

Código
flowchart TD
    A["¿La regla tiene lift > 1?"] -->|NO| B["Descartar<br/>(relación espuria o independiente)"]
    A -->|SÍ| C["¿Soporte razonable (> 5–10%)?"]
    
    C -->|NO| D["Precaución<br/>(puede ser ruido)"]
    C -->|SÍ| E["¿Confidence útil para el contexto?"]
    
    E -->|SÍ| F["Regla candidata ✓"]

flowchart TD
    A["¿La regla tiene lift > 1?"] -->|NO| B["Descartar<br/>(relación espuria o independiente)"]
    A -->|SÍ| C["¿Soporte razonable (> 5–10%)?"]
    
    C -->|NO| D["Precaución<br/>(puede ser ruido)"]
    C -->|SÍ| E["¿Confidence útil para el contexto?"]
    
    E -->|SÍ| F["Regla candidata ✓"]

“Una buena regla de asociación no es la más frecuente ni la más confiable. Es la más sorprendente dado lo que ya sabemos.”

Bloque A: El Lift Tiene Límites

El Lift es Asimétrico

El lift de A \to B es igual al lift de B \to A.

Código
ej = reglas[reglas["regla"].str.contains("yogurt") &
            reglas["regla"].str.contains("whole milk")][
    ["regla", "confidence", "lift"]
].round(3)
ej
regla confidence lift
24 yogurt → whole milk 0.571 1.099
25 whole milk → yogurt 0.308 1.099

Advertencia

Problema: El lift no distingue dirección.
{yogurt} → {whole milk} y {whole milk} → {yogurt} tienen lift idéntico.
Pero la confianza puede ser muy diferente. ¿Por qué importa esto?

Asimetría de Confianza: La Dirección Sí Importa

Código
# Comparar las dos direcciones de la misma relación
par = reglas[
    (reglas["antecedents_str"].isin(["whole milk", "yogurt"])) &
    (reglas["consequents_str"].isin(["whole milk", "yogurt"]))
][["regla", "support", "confidence", "lift"]].round(3)
par
regla support confidence lift
24 yogurt → whole milk 0.16 0.571 1.099
25 whole milk → yogurt 0.16 0.308 1.099

La dirección con mayor confianza es la acción más respaldada por los datos.

Si queremos recomendar: “¿Qué le ofrezco a quien compra X?” — la dirección importa.

El Problema del Lift Máximo

El lift tiene un techo teórico que depende del soporte de los ítems:

\text{lift}_{\max}(A, B) = \frac{1}{\max(P(A), P(B))}

Código
freq_rel = df.mean()  # soporte individual de cada ítem

# Calcular lift máximo teórico para cada regla
def lift_max(row):
    pa = freq_rel.get(row["antecedents_str"], np.nan)
    pb = freq_rel.get(row["consequents_str"], np.nan)
    if pd.isna(pa) or pd.isna(pb):
        return np.nan
    return 1 / max(pa, pb)

# Solo reglas con antecedente/consecuente simples (1 ítem)
reglas_simples = reglas[
    reglas["antecedents"].apply(len) == 1
].copy()
reglas_simples["lift_max_teorico"] = reglas_simples.apply(lift_max, axis=1)
reglas_simples["lift_relativo"] = (
    reglas_simples["lift"] / reglas_simples["lift_max_teorico"]
)
reglas_simples[["regla", "lift", "lift_max_teorico", "lift_relativo"]].round(3).head(8)
regla lift lift_max_teorico lift_relativo
0 bread → butter 0.962 2.500 0.385
1 butter → bread 0.962 2.500 0.385
2 bread → coffee 1.562 2.500 0.625
3 coffee → bread 1.562 2.500 0.625
4 bread → eggs 1.071 2.500 0.429
5 eggs → bread 1.071 2.500 0.429
6 bread → whole milk 0.769 1.923 0.400
7 whole milk → bread 0.769 1.923 0.400

Lift Relativo: Una Normalización Más Justa

Código
fig = px.scatter(
    reglas_simples.dropna(subset=["lift_relativo"]),
    x="lift",
    y="lift_relativo",
    size="support",
    color="confidence",
    hover_data={"regla": True, "lift": ":.3f",
                "lift_max_teorico": ":.3f", "lift_relativo": ":.3f"},
    color_continuous_scale="Viridis",
    title="Lift Observado vs. Lift Relativo (fracción del máximo teórico)",
    labels={"lift": "Lift observado", "lift_relativo": "Lift / Lift_máx"},
    size_max=25,
)
fig.add_hline(y=0.5, line_dash="dot", line_color="orange",
              annotation_text="50% del máximo teórico")
fig.update_layout(height=430)
fig.show()

Nota

Una regla que alcanza el 60-70% del lift máximo teórico es mucho más interesante que una con lift alto en términos absolutos pero bajo en términos relativos.

Bloque B: Métricas Complementarias

Más Allá del Lift: Tres Métricas Clave

La literatura propone docenas de métricas (Tan et al. 2006). Estas tres son las más usadas en práctica:

Leverage (Apalancamiento) \text{lev}(A \to B) = P(A \cup B) - P(A) \cdot P(B)

Diferencia absoluta entre co-ocurrencia observada e independencia.
Rango: [-1, 1]. Leverage = 0 ↔︎ independencia.

Conviction \text{conv}(A \to B) = \frac{1 - P(B)}{1 - \text{confidence}(A \to B)}

¿Qué tan a menudo A ocurre sin B, relativo a lo esperado bajo independencia?
Rango: (0, \infty). Conviction → ∞ cuando confidence → 1.

Zhang’s Metric Z(A, B) = \frac{P(A \cup B) - P(A)P(B)}{\max\{P(A \cup B)(1-P(A)), P(A)P(B \setminus A)\}}

Rango: [-1, 1]. Simétrico, robusto a ítems muy frecuentes.

Calculando las Métricas Complementarias

Código
# mlxtend calcula leverage y conviction directamente
itemsets2 = apriori(df, min_support=0.10, use_colnames=True)
reglas_ext = association_rules(
    itemsets2,
    metric="lift",
    min_threshold=0.5
)

# Zhang's metric (manual — no está en mlxtend)
def zhang(row):
    pab = row["support"]
    pa  = row["antecedent support"]
    pb  = row["consequent support"]
    num = pab - pa * pb
    den = max(pab * (1 - pa), pa * (pb - pab))
    return num / den if den != 0 else 0

reglas_ext["zhang"] = reglas_ext.apply(zhang, axis=1)
reglas_ext["antecedents_str"] = reglas_ext["antecedents"].apply(
    lambda x: ", ".join(sorted(x))
)
reglas_ext["consequents_str"] = reglas_ext["consequents"].apply(
    lambda x: ", ".join(sorted(x))
)
reglas_ext["regla"] = (reglas_ext["antecedents_str"]
                       + " → " + reglas_ext["consequents_str"])

cols = ["regla", "support", "confidence", "lift", "leverage", "conviction", "zhang"]
reglas_ext[cols].sort_values("zhang", ascending=False).head(8).round(3)
regla support confidence lift leverage conviction zhang
22 yogurt → granola 0.10 0.357 3.571 0.072 1.400 1.000
15 soda → chips 0.14 0.875 6.250 0.118 6.880 1.000
14 chips → soda 0.14 1.000 6.250 0.118 inf 0.977
13 eggs → cheese 0.10 0.357 2.551 0.061 1.338 0.844
23 granola → yogurt 0.10 1.000 3.571 0.072 inf 0.800
21 yogurt → fruit 0.12 0.429 2.143 0.064 1.400 0.741
12 cheese → eggs 0.10 0.714 2.551 0.061 2.520 0.707
20 fruit → yogurt 0.12 0.600 2.143 0.064 1.800 0.667

¿Coinciden las Métricas? Correlaciones

Código
metricas = ["support", "confidence", "lift", "leverage", "conviction", "zhang"]
corr = reglas_ext[metricas].corr().round(2)

fig = px.imshow(
    corr,
    text_auto=True,
    color_continuous_scale="RdBu_r",
    zmin=-1, zmax=1,
    title="Correlación entre Métricas de Interés",
    aspect="auto",
)
fig.update_layout(height=420)
fig.show()

Importante

Pregunta clave: ¿Qué pares de métricas discrepan más?
Donde hay discrepancia, la interpretación requiere juicio humano.

Cuándo Usar Cada Métrica

Código
# Normalizar métricas para comparación visual (0-1)
from sklearn.preprocessing import MinMaxScaler

scaler = MinMaxScaler()
metricas_viz = ["support", "confidence", "lift", "leverage", "zhang"]
reglas_norm = reglas_ext.copy()
reglas_norm[metricas_viz] = scaler.fit_transform(reglas_ext[metricas_viz])

# Seleccionar top 5 reglas por lift original
top5_idx = reglas_ext["lift"].nlargest(5).index
top5 = reglas_norm.loc[top5_idx]

fig = go.Figure()
for _, row in top5.iterrows():
    fig.add_trace(go.Scatterpolar(
        r=[row[m] for m in metricas_viz],
        theta=metricas_viz,
        fill='toself',
        name=row["regla"][:35],
        opacity=0.6,
    ))

fig.update_layout(
    polar=dict(radialaxis=dict(visible=True, range=[0, 1])),
    title="Perfil Normalizado de las Top 5 Reglas por Lift",
    height=480,
    showlegend=True,
)
fig.show()

Interpretando el Radar

Cada regla tiene un “perfil” de métricas.

Perfil Interpretación
Todas las métricas altas Regla robusta — coinciden múltiples criterios
Lift alto, leverage bajo Relación relativa fuerte, pero frecuencia absoluta baja
Conviction muy alto Confidence casi 1 — casi regla determinista
Zhang cerca de 0 Relación débil aunque otras métricas lo disimulen

Tip

Si lift, leverage y zhang apuntan en la misma dirección: confiar.
Si discrepan: investigar más antes de actuar.

Bloque C: Sensibilidad a Parámetros

El Problema del Umbral Arbitrario

Cuando ejecutamos:

apriori(df, min_support=0.10)
association_rules(itemsets, metric="lift", min_threshold=1.0)

Estamos tomando decisiones que no tienen justificación estadística intrínseca.

¿Qué pasa si cambiamos min_support de 0.10 a 0.08?
¿O min_threshold de 1.0 a 1.2?

Advertencia

Hipótesis a explorar:
Muchas de las “reglas” que encontramos son artefactos del umbral elegido, no patrones reales.

Experimento: Sensibilidad al Soporte Mínimo

Código
soportes = [0.06, 0.08, 0.10, 0.12, 0.14, 0.16, 0.18, 0.20]
resultados = []

for s in soportes:
    its = apriori(df, min_support=s, use_colnames=True)
    if len(its) == 0:
        resultados.append({"min_support": s, "n_itemsets": 0, "n_reglas": 0})
        continue
    r = association_rules(its, metric="lift", min_threshold=1.0)
    resultados.append({
        "min_support": s,
        "n_itemsets": len(its),
        "n_reglas": len(r),
        "lift_mediana": r["lift"].median() if len(r) > 0 else np.nan,
        "lift_max": r["lift"].max() if len(r) > 0 else np.nan,
    })

df_sens = pd.DataFrame(resultados)
df_sens.round(3)
min_support n_itemsets n_reglas lift_mediana lift_max
0 0.06 37 36 1.896 6.250
1 0.08 28 22 1.648 6.250
2 0.10 26 20 1.605 6.250
3 0.12 18 12 1.374 6.250
4 0.14 15 6 1.099 6.250
5 0.16 11 2 1.099 1.099
6 0.18 7 0 NaN NaN
7 0.20 6 0 NaN NaN

Visualización de Sensibilidad

Código
fig = go.Figure()

fig.add_trace(go.Scatter(
    x=df_sens["min_support"],
    y=df_sens["n_reglas"],
    mode="lines+markers",
    name="N° de reglas",
    yaxis="y1",
    line=dict(color="#e63946", width=2),
    marker=dict(size=8),
))

fig.add_trace(go.Scatter(
    x=df_sens["min_support"],
    y=df_sens["lift_mediana"],
    mode="lines+markers",
    name="Lift mediana",
    yaxis="y2",
    line=dict(color="#457b9d", width=2, dash="dash"),
    marker=dict(size=8),
))

fig.update_layout(
    title="Sensibilidad al Umbral de Soporte Mínimo",
    xaxis=dict(title="min_support"),
    yaxis=dict(title="N° de reglas", side="left"),
    yaxis2=dict(title="Lift mediana", overlaying="y", side="right"),
    legend=dict(x=0.7, y=0.9),
    height=430,
)
fig.show()

Nota

Observar: A medida que sube min_support, bajan las reglas pero sube el lift mediano. ¿Por qué?

Sensibilidad al Umbral de Lift

Código
its_fijo = apriori(df, min_support=0.10, use_colnames=True)
lifts_umbral = np.arange(0.6, 2.5, 0.1)
res_lift = []

for lt in lifts_umbral:
    r = association_rules(its_fijo, metric="lift", min_threshold=float(lt))
    res_lift.append({
        "min_lift": round(lt, 1),
        "n_reglas": len(r),
        "confidence_media": r["confidence"].mean() if len(r) > 0 else np.nan,
    })

df_lift_sens = pd.DataFrame(res_lift)

fig = px.line(
    df_lift_sens,
    x="min_lift",
    y="n_reglas",
    markers=True,
    title="¿Cuántas reglas sobreviven según el umbral de lift mínimo?",
    labels={"min_lift": "Umbral mínimo de lift", "n_reglas": "Reglas que superan el umbral"},
    color_discrete_sequence=["#2a9d8f"],
)
fig.update_layout(height=380)
fig.show()

La Curva Soporte-Confianza: Un Mapa Completo

Código
grid_support = np.arange(0.06, 0.20, 0.02)
grid_conf    = np.arange(0.20, 1.00, 0.10)
heatmap_data = []

for s in grid_support:
    for c in grid_conf:
        its = apriori(df, min_support=s, use_colnames=True)
        if len(its) == 0:
            heatmap_data.append({"min_support": round(s,2),
                                  "min_confidence": round(c,2), "n_reglas": 0})
            continue
        r = association_rules(its, metric="confidence", min_threshold=float(c))
        heatmap_data.append({
            "min_support": round(s, 2),
            "min_confidence": round(c, 2),
            "n_reglas": len(r),
        })

df_heatmap = pd.DataFrame(heatmap_data)
pivot = df_heatmap.pivot(index="min_confidence", columns="min_support", values="n_reglas")

fig = px.imshow(
    pivot,
    color_continuous_scale="YlOrRd",
    title="Mapa de Calor: N° de Reglas según Soporte y Confianza Mínimos",
    labels={"x": "Soporte mínimo", "y": "Confianza mínima", "color": "N° reglas"},
    aspect="auto",
    text_auto=True,
)
fig.update_layout(height=430)
fig.show()

¿Qué Nos Dice el Mapa?

La zona de mayor densidad de reglas (esquina inferior izquierda) corresponde a los umbrales más laxos.

Pero más reglas no significa más conocimiento — significa más ruido.

Importante

Conclusión del bloque C:
El número de reglas obtenidas es una función de los parámetros elegidos, no solo del dataset.
Cambiar min_support de 0.10 a 0.08 puede duplicar las reglas sin agregar conocimiento real.
Siempre reportar los parámetros junto con los resultados (Han et al. 2011, cap. 6).

Bloque D: Grafo de Reglas y Caso de Estudio

Visualización como Grafo

Las reglas forman una red de relaciones entre ítems.
Un grafo permite ver la estructura global de un vistazo.

Código
# Seleccionar reglas útiles para el grafo
reglas_grafo = reglas_ext[
    (reglas_ext["lift"] > 1.1) &
    (reglas_ext["support"] >= 0.10) &
    (reglas_ext["antecedents"].apply(len) == 1) &
    (reglas_ext["consequents"].apply(len) == 1)
].copy()

print(f"Reglas en el grafo: {len(reglas_grafo)}")
reglas_grafo[["regla", "support", "confidence", "lift"]].round(3).head(10)
Reglas en el grafo: 14
regla support confidence lift
2 bread → coffee 0.10 0.250 1.562
3 coffee → bread 0.10 0.625 1.562
8 butter → eggs 0.12 0.462 1.648
9 eggs → butter 0.12 0.429 1.648
12 cheese → eggs 0.10 0.714 2.551
13 eggs → cheese 0.10 0.357 2.551
14 chips → soda 0.14 1.000 6.250
15 soda → chips 0.14 0.875 6.250
16 coffee → whole milk 0.10 0.625 1.202
17 whole milk → coffee 0.10 0.192 1.202

Grafo Interactivo de Reglas

Código
import math

# Nodos únicos
nodos = list(set(
    reglas_grafo["antecedents_str"].tolist() +
    reglas_grafo["consequents_str"].tolist()
))
n = len(nodos)
nodo_idx = {nd: i for i, nd in enumerate(nodos)}

# Layout circular
angulos = [2 * math.pi * i / n for i in range(n)]
xs = [math.cos(a) for a in angulos]
ys = [math.sin(a) for a in angulos]

# Aristas
edge_x, edge_y, edge_text = [], [], []
for _, row in reglas_grafo.iterrows():
    x0, y0 = xs[nodo_idx[row["antecedents_str"]]], ys[nodo_idx[row["antecedents_str"]]]
    x1, y1 = xs[nodo_idx[row["consequents_str"]]], ys[nodo_idx[row["consequents_str"]]]
    edge_x += [x0, x1, None]
    edge_y += [y0, y1, None]

# Importancia de nodo = suma de lifts de sus reglas
importancia = {}
for nd in nodos:
    r_sub = reglas_grafo[
        (reglas_grafo["antecedents_str"] == nd) |
        (reglas_grafo["consequents_str"] == nd)
    ]
    importancia[nd] = r_sub["lift"].sum()

fig = go.Figure()

fig.add_trace(go.Scatter(
    x=edge_x, y=edge_y,
    mode="lines",
    line=dict(width=1, color="#aaaaaa"),
    hoverinfo="none",
    showlegend=False,
))

fig.add_trace(go.Scatter(
    x=xs, y=ys,
    mode="markers+text",
    text=nodos,
    textposition="top center",
    marker=dict(
        size=[max(10, importancia.get(nd, 1) * 4) for nd in nodos],
        color=[importancia.get(nd, 0) for nd in nodos],
        colorscale="Plasma",
        showscale=True,
        colorbar=dict(title="Importancia<br>(suma lifts)"),
    ),
    hovertext=[
        f"{nd}<br>Importancia: {importancia.get(nd,0):.2f}"
        for nd in nodos
    ],
    hoverinfo="text",
    showlegend=False,
))

fig.update_layout(
    title="Grafo de Reglas de Asociación (solo ítems simples, lift > 1.1)",
    xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
    yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
    height=520,
    plot_bgcolor="white",
)
fig.show()

Leyendo el Grafo

Nodos grandes y brillantes: ítems que participan en muchas relaciones fuertes.

Nodos pequeños y oscuros: ítems periféricos — aparecen en pocas reglas o con lift bajo.

Ítems centrales vs. ítems puente:

  • Central (ej. whole milk): muchas conexiones, pero ¿son todas genuinas?
  • Puente (ej. granola): pocas conexiones, pero cada una puede ser muy informativa.

Advertencia

Un ítem muy central en el grafo no es necesariamente el más interesante.
Puede serlo simplemente por ser el más frecuente.

Caso de Estudio: El “Whole Milk Problem”

Código
# Todas las reglas que tienen whole milk como consecuente
wm_consecuente = reglas_ext[
    reglas_ext["consequents_str"] == "whole milk"
][["regla", "support", "confidence", "lift", "leverage", "zhang"]].round(3)

# Soporte base de whole milk
p_wm = df["whole milk"].mean()
print(f"P(whole milk) = {p_wm:.3f}  →  aparece en el {p_wm*100:.1f}% de las transacciones\n")
wm_consecuente.sort_values("lift", ascending=False)
P(whole milk) = 0.520  →  aparece en el 52.0% de las transacciones
regla support confidence lift leverage zhang
16 coffee → whole milk 0.10 0.625 1.202 0.017 0.200
24 yogurt → whole milk 0.16 0.571 1.099 0.014 0.125
10 butter → whole milk 0.14 0.538 1.036 0.005 0.046
6 bread → whole milk 0.16 0.400 0.769 -0.048 -0.333
18 eggs → whole milk 0.10 0.357 0.687 -0.046 -0.388

Advertencia

Pregunta: Si P(whole milk) ≈ 0.60, ¿qué confianza necesita una regla X → whole milk para que su lift sea mayor que 1.0?

Respuesta: El Umbral Mínimo de Confianza Útil

Código
p_wm = df["whole milk"].mean()
print(f"Para que lift > 1.0, necesitamos:")
print(f"  confidence > P(whole milk) = {p_wm:.3f}")
print()
print(f"Para que lift > 1.2, necesitamos:")
print(f"  confidence > 1.2 × {p_wm:.3f} = {1.2 * p_wm:.3f}")
print()
print(f"Para que lift > 1.5, necesitamos:")
print(f"  confidence > 1.5 × {p_wm:.3f} = {1.5 * p_wm:.3f}")
print()
print("Reglas con whole milk como consecuente y lift > 1.2:")
candidatas = reglas_ext[
    (reglas_ext["consequents_str"] == "whole milk") &
    (reglas_ext["lift"] > 1.2)
][["regla", "confidence", "lift"]].round(3)
print(candidatas.to_string(index=False) if len(candidatas) > 0
      else "  → Ninguna supera ese umbral con este dataset.")
Para que lift > 1.0, necesitamos:
  confidence > P(whole milk) = 0.520

Para que lift > 1.2, necesitamos:
  confidence > 1.2 × 0.520 = 0.624

Para que lift > 1.5, necesitamos:
  confidence > 1.5 × 0.520 = 0.780

Reglas con whole milk como consecuente y lift > 1.2:
              regla  confidence  lift
coffee → whole milk       0.625 1.202

Generalización: La Regla del Consecuente Frecuente

Para cualquier ítem B con soporte P(B), el umbral mínimo de confianza para que una regla sea no-trivial es:

\text{confidence}_{\min} > P(B)

Y para que sea “sustancialmente” no-trivial con factor k:

\text{confidence}_{\min} > k \cdot P(B)

Código
umbrales = []
for item in df.columns:
    p = df[item].mean()
    umbrales.append({
        "item": item,
        "P(item)": round(p, 3),
        "conf_min para lift>1": round(p, 3),
        "conf_min para lift>1.5": round(1.5*p, 3),
    })

pd.DataFrame(umbrales).sort_values("P(item)", ascending=False).head(8)
item P(item) conf_min para lift>1 conf_min para lift>1.5
13 whole milk 0.52 0.52 0.78
1 bread 0.40 0.40 0.60
7 eggs 0.28 0.28 0.42
14 yogurt 0.28 0.28 0.42
2 butter 0.26 0.26 0.39
8 fruit 0.20 0.20 0.30
12 vegetables 0.18 0.18 0.27
6 coffee 0.16 0.16 0.24

Diagnóstico Completo de una Regla

Tomemos la regla más prometedora del dataset y la diseccionamos completamente:

Código
mejor = reglas_ext.sort_values("zhang", ascending=False).iloc[0]

print("=" * 55)
print(f"REGLA: {mejor['regla']}")
print("=" * 55)
print(f"\n[PREVALENCIA]")
print(f"  P(antecedente)         = {mejor['antecedent support']:.3f}")
print(f"  P(consecuente)         = {mejor['consequent support']:.3f}")
print(f"  P(ambos)               = {mejor['support']:.3f}")
print(f"  P esperado (indep.)    = {mejor['antecedent support']*mejor['consequent support']:.3f}")

print(f"\n[MÉTRICAS]")
print(f"  Support    = {mejor['support']:.3f}  → aparece en {int(mejor['support']*50)}/50 transacciones")
print(f"  Confidence = {mejor['confidence']:.3f}")
print(f"  Lift       = {mejor['lift']:.3f}   → co-ocurren {mejor['lift']:.1f}x más que por azar")
print(f"  Leverage   = {mejor['leverage']:.3f}  → diff. absoluta sobre independencia")
print(f"  Conviction = {mejor['conviction']:.3f}")
print(f"  Zhang      = {mejor['zhang']:.3f}")

print(f"\n[VEREDICTO]")
if mejor['lift'] > 1.3 and mejor['zhang'] > 0.2 and mejor['support'] >= 0.10:
    print("  ✓ Regla ROBUSTA: lift, zhang y soporte coinciden.")
elif mejor['lift'] > 1.0:
    print("  ~ Regla DÉBIL: lift > 1 pero otras métricas no confirman.")
else:
    print("  ✗ Regla ESPURIA: descartar.")
=======================================================
REGLA: yogurt → granola
=======================================================

[PREVALENCIA]
  P(antecedente)         = 0.280
  P(consecuente)         = 0.100
  P(ambos)               = 0.100
  P esperado (indep.)    = 0.028

[MÉTRICAS]
  Support    = 0.100  → aparece en 5/50 transacciones
  Confidence = 0.357
  Lift       = 3.571   → co-ocurren 3.6x más que por azar
  Leverage   = 0.072  → diff. absoluta sobre independencia
  Conviction = 1.400
  Zhang      = 1.000

[VEREDICTO]
  ✓ Regla ROBUSTA: lift, zhang y soporte coinciden.

Síntesis: El Pipeline Completo de Evaluación

Código
def evaluar_regla(row, n_transacciones=50, verbose=True):
    """Evaluación sistemática de una regla de asociación."""
    veredicto = []
    puntos = 0

    # Criterio 1: lift
    if row["lift"] > 1.5:
        veredicto.append("✓ Lift fuerte (>1.5)")
        puntos += 3
    elif row["lift"] > 1.1:
        veredicto.append("~ Lift moderado (1.1–1.5)")
        puntos += 1
    else:
        veredicto.append("✗ Lift débil (≤1.1)")

    # Criterio 2: soporte absoluto
    n_obs = int(row["support"] * n_transacciones)
    if n_obs >= 10:
        veredicto.append(f"✓ Soporte sólido ({n_obs} transacciones)")
        puntos += 2
    elif n_obs >= 5:
        veredicto.append(f"~ Soporte mínimo ({n_obs} transacciones)")
        puntos += 1
    else:
        veredicto.append(f"✗ Soporte insuficiente ({n_obs} transacciones)")

    # Criterio 3: Zhang
    if row["zhang"] > 0.3:
        veredicto.append("✓ Zhang robusto (>0.3)")
        puntos += 2
    elif row["zhang"] > 0.1:
        veredicto.append("~ Zhang moderado")
        puntos += 1
    else:
        veredicto.append("✗ Zhang débil (≤0.1)")

    # Criterio 4: conviction
    if row["conviction"] > 1.5:
        veredicto.append("✓ Conviction alta (>1.5)")
        puntos += 1

    clasificacion = (
        "ROBUSTA" if puntos >= 6
        else "CANDIDATA" if puntos >= 3
        else "DESCARTAR"
    )

    if verbose:
        print(f"\nRegla: {row['regla']}")
        for v in veredicto:
            print(f"  {v}")
        print(f"  → CLASIFICACIÓN: {clasificacion} (puntos: {puntos}/8)")
    return clasificacion

# Evaluar top 5 reglas por lift
top_reglas = reglas_ext.sort_values("lift", ascending=False).head(5)
for _, row in top_reglas.iterrows():
    evaluar_regla(row)

Regla: soda → chips
  ✓ Lift fuerte (>1.5)
  ~ Soporte mínimo (7 transacciones)
  ✓ Zhang robusto (>0.3)
  ✓ Conviction alta (>1.5)
  → CLASIFICACIÓN: ROBUSTA (puntos: 7/8)

Regla: chips → soda
  ✓ Lift fuerte (>1.5)
  ~ Soporte mínimo (7 transacciones)
  ✓ Zhang robusto (>0.3)
  ✓ Conviction alta (>1.5)
  → CLASIFICACIÓN: ROBUSTA (puntos: 7/8)

Regla: granola → yogurt
  ✓ Lift fuerte (>1.5)
  ~ Soporte mínimo (5 transacciones)
  ✓ Zhang robusto (>0.3)
  ✓ Conviction alta (>1.5)
  → CLASIFICACIÓN: ROBUSTA (puntos: 7/8)

Regla: yogurt → granola
  ✓ Lift fuerte (>1.5)
  ~ Soporte mínimo (5 transacciones)
  ✓ Zhang robusto (>0.3)
  → CLASIFICACIÓN: ROBUSTA (puntos: 6/8)

Regla: eggs → cheese
  ✓ Lift fuerte (>1.5)
  ~ Soporte mínimo (5 transacciones)
  ✓ Zhang robusto (>0.3)
  → CLASIFICACIÓN: ROBUSTA (puntos: 6/8)

Conclusión: Lo que Cambia con Este Marco

Antes (enfoque mecánico):
“Genero reglas, filtro por confidence > 0.5, reporto las top 10.”

Ahora (enfoque crítico):
“Examino lift, zhang y conviction. Verifico sensibilidad al umbral. Reporto solo las reglas que coinciden en múltiples métricas.”

Dimensión Antes Ahora
Métricas usadas 1–2 4–5
Dirección importa No Sí
Sensibilidad a parámetros Ignorada Explorada
Visualización Tabla Grafo + scatter
Veredicto Automático Sistemático

Tip

El analista que usa este marco genera menos reglas y más confiables.
Eso es mejor ciencia de datos.

Lecturas Recomendadas

Para profundizar en métricas de interés y evaluación de patrones:

  • Tan et al. (2006), cap. 6: Comparación sistemática de 21 métricas de interés — el artículo de referencia para elegir métricas (Tan et al. 2006).
  • Han, Kamber & Pei (2011), cap. 6: Patrones frecuentes, asociación, correlación — tratamiento completo con pruebas de significancia.
  • Agrawal & Srikant (1994): El paper original — leerlo para entender qué limitaciones reconocían los autores desde el inicio (Agrawal y Srikant 1994).
  • Pearl (2009): Para entender por qué las reglas de asociación nunca pueden hablar de causalidad (Pearl 2009).

Referencias

Agrawal, Rakesh, y Ramakrishna Srikant. 1994. «Fast algorithms for mining association rules». Proceedings of the 20th International Conference on Very Large Data Bases (VLDB) 1215: 487-99.
Han, Jiawei, Micheline Kamber, y Jian Pei. 2011. Data Mining: Concepts and Techniques. 3rd ed. Morgan Kaufmann.
Pearl, Judea. 2009. Causality: Models, Reasoning and Inference. 2nd ed. Cambridge University Press.
Tan, Pang-Ning, Michael Steinbach, y Vipin Kumar. 2006. Introduction to Data Mining. Addison-Wesley.