45  El Algoritmo Apriori: Paso a Paso y Ejemplo Práctico

Autor/a

Diego Villalba

En el capítulo introductorio vimos que el número de itemsets posibles crece como \(2^m\) y que la propiedad antimonótona permite descartar regiones enteras de la retícula. En el capítulo de reglas definimos soporte, confianza y lift. Apriori (Agrawal y Srikant 1994) une ambas ideas en un procedimiento concreto: encuentra todos los itemsets frecuentes recorriendo la retícula nivel por nivel y, a partir de ellos, genera las reglas que superan una confianza mínima. Este capítulo lo desarrolla a mano, lo implementa desde cero y lo aplica a un conjunto de canastas de supermercado.

45.1 Intuición: buscar por niveles

Supón que quieres saber qué pares de productos son frecuentes. La estrategia ingenua es contar todos los pares posibles. Apriori razona antes de contar:

Un par solo puede ser frecuente si cada uno de sus productos es frecuente. Un trío solo puede serlo si cada uno de sus tres pares lo es.

Así que primero cuenta productos sueltos, después solo los pares formados con productos frecuentes, después solo los tríos cuyos pares son todos frecuentes, y así sucesivamente. Cada nivel usa el resultado del anterior para decidir qué vale la pena contar. El algoritmo se detiene cuando un nivel no produce ningún itemset frecuente.

45.2 Desarrollo manual paso a paso

Usamos los cinco tickets de siempre, con conteo mínimo 2 (soporte mínimo \(0.40\)) y confianza mínima 0.60. Para que la unión sea reproducible, los ítems dentro de cada itemset se escriben en orden alfabético.

Ticket Productos
\(T_1\) leche, mantequilla, pan
\(T_2\) leche, pan
\(T_3\) leche, mantequilla
\(T_4\) mantequilla, pan
\(T_5\) leche, pan, queso
Tabla 45.1: Base de transacciones del ejemplo, con los ítems en orden alfabético.

45.2.1 Nivel 1: contar ítems sueltos

Primer recorrido de los datos. Los candidatos \(C_1\) son todos los ítems:

Candidato Conteo ¿Frecuente?
{leche} 4 sí
{mantequilla} 3 sí
{pan} 4 sí
{queso} 1 no

\(L_1 = \{\{\text{leche}\}, \{\text{mantequilla}\}, \{\text{pan}\}\}\). A partir de aquí queso desaparece del análisis: ningún itemset que lo contenga puede ser frecuente.

45.2.2 Nivel 2: unión, poda y conteo

Unión. Se combinan pares de itemsets de \(L_1\). Con tres ítems frecuentes salen \(\binom{3}{2} = 3\) candidatos: {leche, mantequilla}, {leche, pan} y {mantequilla, pan}. Los tres pares con queso nunca se generan.

Poda. Cada candidato se conserva solo si todos sus subconjuntos de tamaño 1 están en \(L_1\). Aquí todos pasan.

Conteo. Segundo recorrido de los datos:

Candidato Tickets Conteo ¿Frecuente?
{leche, mantequilla} \(T_1, T_3\) 2 sí
{leche, pan} \(T_1, T_2, T_5\) 3 sí
{mantequilla, pan} \(T_1, T_4\) 2 sí

\(L_2\) contiene los tres pares.

45.2.3 Nivel 3: la unión exige un prefijo común

Para formar candidatos de tamaño 3 se unen dos itemsets de \(L_2\) que comparten su primer ítem1: {leche, mantequilla} y {leche, pan} comparten «leche» y producen {leche, mantequilla, pan}. El par {mantequilla, pan} no tiene con quién combinarse.

1 Unir solo itemsets con el mismo prefijo de \(k-2\) ítems evita generar el mismo candidato varias veces. Si se unieran todos los pares de \(L_{k-1}\), {leche, mantequilla, pan} saldría de tres combinaciones distintas.

Poda. Los tres subconjuntos de tamaño 2 de {leche, mantequilla, pan} son {leche, mantequilla}, {leche, pan} y {mantequilla, pan}; los tres están en \(L_2\), así que el candidato sobrevive.

Conteo. Tercer recorrido: solo \(T_1\) contiene los tres productos, conteo 1 < 2. \(L_3 = \varnothing\) y el algoritmo termina.

En total se contaron \(4 + 3 + 1 = 8\) candidatos en tres recorridos de los datos, en lugar de los 15 itemsets posibles. Hay 6 itemsets frecuentes.

45.2.4 Generación de reglas

Cada itemset frecuente \(Z\) con al menos dos ítems produce reglas \(A \Rightarrow Z \setminus A\). Su confianza no requiere volver a los datos: todos los soportes necesarios ya se contaron. \[\text{conf}(A \Rightarrow Z \setminus A) = \frac{\sigma(Z)}{\sigma(A)}.\]

Itemset Regla Confianza ¿≥ 0.60? Lift
{leche, mantequilla} leche ⇒ mantequilla 2/4 = 0.50 no 0.833
{leche, mantequilla} mantequilla ⇒ leche 2/3 = 0.67 sí 0.833
{leche, pan} leche ⇒ pan 3/4 = 0.75 sí 0.938
{leche, pan} pan ⇒ leche 3/4 = 0.75 sí 0.938
{mantequilla, pan} mantequilla ⇒ pan 2/3 = 0.67 sí 0.833
{mantequilla, pan} pan ⇒ mantequilla 2/4 = 0.50 no 0.833
Tabla 45.2: Reglas candidatas del ejemplo. El lift se calcula como confianza entre el soporte del consecuente.

Cuatro reglas superan la confianza mínima. Pero observa la última columna: todos los lifts son menores que 1. En este ejemplo diminuto, cada par de productos aparece junto un poco menos de lo que esperaríamos por azar. Es el recordatorio del capítulo anterior: Apriori encuentra lo frecuente y lo confiable, no necesariamente lo interesante.

45.3 Formalización

Algoritmo Apriori (Agrawal y Srikant 1994).

Entrada: transacciones \(\mathcal{D}\), conteo mínimo \(s_{\min}\).

  1. \(L_1 \leftarrow \{\{i\} : \sigma(\{i\}) \ge s_{\min}\}\) (primer recorrido).
  2. Para \(k = 2, 3, \ldots\) mientras \(L_{k-1} \neq \varnothing\):
    1. Unión: \(C_k \leftarrow \{\, A \cup B : A, B \in L_{k-1},\ A \text{ y } B \text{ coinciden en sus primeros } k-2 \text{ ítems} \,\}\).
    2. Poda: eliminar de \(C_k\) todo candidato con algún \((k-1)\)-subconjunto fuera de \(L_{k-1}\).
    3. Conteo: un recorrido de \(\mathcal{D}\) para obtener \(\sigma(c)\) de cada \(c \in C_k\).
    4. \(L_k \leftarrow \{c \in C_k : \sigma(c) \ge s_{\min}\}\).
  3. Devolver \(\bigcup_k L_k\).

Corrección. Todo \(k\)-itemset frecuente \(Z\) se genera en la unión: si \(Z = \{z_1 < \cdots < z_k\}\), sus subconjuntos \(Z \setminus \{z_k\}\) y \(Z \setminus \{z_{k-1}\}\) son frecuentes por antimonotonía, comparten los primeros \(k-2\) ítems y su unión es \(Z\). La poda solo descarta candidatos que la antimonotonía garantiza infrecuentes. Por tanto, Apriori es completo: no pierde ningún itemset frecuente.

Costo. Apriori hace un recorrido de los datos por nivel, es decir, \(k_{\max} + 1\) recorridos si el itemset frecuente más largo tiene \(k_{\max}\) ítems. El cuello de botella suele ser el nivel 2: con \(|L_1|\) ítems frecuentes hay \(\binom{|L_1|}{2}\) candidatos, y con miles de productos eso son millones de pares. FP-Growth, que se estudia más adelante, ataca precisamente este costo.

Reglas. Para un itemset frecuente \(Z\), mover ítems del antecedente al consecuente solo puede bajar la confianza: si \(A' \subset A\), entonces \(\sigma(A') \ge \sigma(A)\) y \[\text{conf}(A' \Rightarrow Z \setminus A') = \frac{\sigma(Z)}{\sigma(A')} \le \frac{\sigma(Z)}{\sigma(A)} = \text{conf}(A \Rightarrow Z \setminus A).\] Por eso las reglas también se generan por niveles: si \(\{a, b\} \Rightarrow \{c\}\) no alcanza la confianza mínima, tampoco la alcanzará \(\{a\} \Rightarrow \{b, c\}\).

45.4 Laboratorio: Apriori nivel por nivel

El laboratorio ejecuta Apriori sobre la Tabla 45.1. Recorre los pasos con los botones: en cada nivel verás qué candidatos produjo la unión, cuáles eliminó la poda y cuáles resultaron frecuentes al contar. El último paso genera las reglas.

Laboratorio · Algoritmo Apriori

¿Qué candidatos se generan, se podan y se cuentan en cada nivel?

Elige un paso, ajusta el conteo mínimo y la confianza mínima y compara con el desarrollo manual.

Candidatos por nivel

podados contados e infrecuentes frecuentes
Recorridos de los datos
Candidatos contados
Itemsets frecuentes
Reglas aceptadas

Guía del laboratorio.

  1. Comprueba el estado inicial contra el desarrollo manual: en el nivel 2 deben aparecer tres candidatos frecuentes; en el nivel 3, un solo candidato con conteo 1.
  2. Predice antes de mover: con conteo mínimo 1, ¿cuántos candidatos de tamaño 3 producirá la unión? ¿Alguno se podará? Comprueba en el nivel 3 cuál es el subconjunto que provoca la poda.
  3. Con conteo mínimo 3, ¿cuántos recorridos de los datos se necesitan y cuántas reglas quedan?
  4. En el paso «Reglas», sube la confianza mínima a 0.70. ¿Qué reglas se pierden? Explica por qué leche ⇒ pan y pan ⇒ leche tienen la misma confianza aquí.

45.5 Implementación desde cero en Python

La función apriori_gen implementa la unión con prefijo común y la poda; apriori_desde_cero alterna generación y conteo y guarda estadísticas por nivel.

Mostrar código
from itertools import combinations
import pandas as pd

def apriori_gen(L_prev, k):
    """Candidatos de tamaño k a partir de los (k-1)-itemsets frecuentes (tuplas ordenadas)."""
    L_prev = sorted(L_prev)
    frecuentes = set(L_prev)
    candidatos, podados = [], []
    for i in range(len(L_prev)):
        for j in range(i + 1, len(L_prev)):
            a, b = L_prev[i], L_prev[j]
            if a[:k - 2] != b[:k - 2]:            # unión: mismo prefijo de k-2 ítems
                continue
            c = tuple(sorted(set(a) | set(b)))
            if all(s in frecuentes for s in combinations(c, k - 1)):   # poda
                candidatos.append(c)
            else:
                podados.append(c)
    return candidatos, podados

def apriori_desde_cero(transacciones, min_conteo):
    T = [frozenset(t) for t in transacciones]
    C = [(i,) for i in sorted({i for t in T for i in t})]
    soporte, niveles, k = {}, [], 1
    podados = []
    while C:
        conteos = {c: sum(set(c) <= t for t in T) for c in C}     # un recorrido de los datos
        L = [c for c in C if conteos[c] >= min_conteo]
        soporte.update({c: conteos[c] for c in L})
        niveles.append({"k": k, "podados": len(podados), "contados": len(C), "frecuentes": len(L)})
        k += 1
        C, podados = apriori_gen(L, k)
        if not L:
            break
    return soporte, pd.DataFrame(niveles)

tickets = [["leche", "mantequilla", "pan"], ["leche", "pan"], ["leche", "mantequilla"],
           ["mantequilla", "pan"], ["leche", "pan", "queso"]]
soporte, niveles = apriori_desde_cero(tickets, min_conteo=2)
print({", ".join(s): c for s, c in soporte.items()})
niveles
{'leche': 4, 'mantequilla': 3, 'pan': 4, 'leche, mantequilla': 2, 'leche, pan': 3, 'mantequilla, pan': 2}
k podados contados frecuentes
0 1 0 4 3
1 2 0 3 3
2 3 0 1 0

La generación de reglas usa solo los conteos ya calculados:

Mostrar código
def generar_reglas(soporte, n, min_conf):
    filas = []
    for Z, cZ in soporte.items():
        for r in range(1, len(Z)):
            for A in combinations(Z, r):
                B = tuple(i for i in Z if i not in A)
                conf = cZ / soporte[A]
                filas.append({"regla": f"{set(A)} ⇒ {set(B)}", "soporte": cZ / n,
                              "confianza": round(conf, 3),
                              "lift": round(conf / (soporte[B] / n), 3),
                              "aceptada": conf >= min_conf})
    return pd.DataFrame(filas)

generar_reglas(soporte, n=len(tickets), min_conf=0.6)
regla soporte confianza lift aceptada
0 {'leche'} ⇒ {'mantequilla'} 0.4 0.500 0.833 False
1 {'mantequilla'} ⇒ {'leche'} 0.4 0.667 0.833 True
2 {'leche'} ⇒ {'pan'} 0.6 0.750 0.938 True
3 {'pan'} ⇒ {'leche'} 0.6 0.750 0.938 True
4 {'mantequilla'} ⇒ {'pan'} 0.4 0.667 0.833 True
5 {'pan'} ⇒ {'mantequilla'} 0.4 0.500 0.833 False

Los resultados coinciden con la Tabla 45.2 y con el laboratorio. En la práctica se usa una implementación optimizada como la de mlxtend (Raschka 2018), que trabaja sobre la matriz binaria:

Mostrar código
from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import apriori, association_rules

te = TransactionEncoder()
df_mini = pd.DataFrame(te.fit_transform(tickets), columns=te.columns_)
frecuentes_mini = apriori(df_mini, min_support=2 / 5, use_colnames=True)
frecuentes_mini
support itemsets
0 0.8 (leche)
1 0.6 (mantequilla)
2 0.8 (pan)
3 0.4 (leche, mantequilla)
4 0.6 (pan, leche)
5 0.4 (pan, mantequilla)

45.6 Ejemplo práctico: canastas de supermercado

Pasamos a una muestra de 50 canastas con 15 productos, inspirada en el conjunto Groceries (la misma que usan los capítulos de reglas y de métricas). El objetivo de negocio es típico: encontrar combinaciones de productos para promociones cruzadas.

Mostrar código
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"],
]
n = len(transacciones_raw)
productos = sorted({i for t in transacciones_raw for i in t})
print(f"{n} canastas, {len(productos)} productos, {2**len(productos) - 1:,} itemsets posibles")
50 canastas, 15 productos, 32,767 itemsets posibles

45.6.1 Paso 1: elegir el soporte mínimo en unidades de canastas

Un soporte mínimo de \(0.10\) significa al menos 5 canastas de 50. Es el mínimo razonable para que un patrón no sea una coincidencia aislada; con datos reales de miles de tickets se puede bajar el soporte relativo manteniendo un conteo absoluto suficiente.

45.6.2 Paso 2: ejecutar Apriori y mirar el trabajo por nivel

Mostrar código
import plotly.graph_objects as go

soporte_g, niveles_g = apriori_desde_cero(transacciones_raw, min_conteo=5)
niveles_g["infrecuentes"] = niveles_g["contados"] - niveles_g["frecuentes"]

fig = go.Figure()
fig.add_bar(x=niveles_g["k"], y=niveles_g["frecuentes"], name="frecuentes", marker_color="#16a34a")
fig.add_bar(x=niveles_g["k"], y=niveles_g["infrecuentes"], name="contados e infrecuentes", marker_color="#dc2626")
fig.add_bar(x=niveles_g["k"], y=niveles_g["podados"], name="podados sin contar", marker_color="#8c959f")
fig.update_layout(barmode="stack", height=360, xaxis=dict(title="Nivel k", dtick=1),
                  yaxis_title="Candidatos")
fig.show()
print(f"Candidatos contados: {niveles_g['contados'].sum()} de {2**len(productos) - 1:,} posibles")
Figura 45.1: Trabajo de Apriori por nivel con soporte mínimo 0.10 (5 canastas). En el nivel 2 se cuentan 78 pares, pero solo 13 resultan frecuentes; en el nivel 3, la poda reduce los candidatos a 5 y ninguno alcanza el umbral.
Candidatos contados: 98 de 32,767 posibles

Apriori resolvió el problema contando menos de 100 itemsets de más de 32 000 posibles. Con mlxtend el resultado es el mismo:

Mostrar código
te = TransactionEncoder()
df = pd.DataFrame(te.fit_transform(transacciones_raw), columns=te.columns_)

itemsets = apriori(df, min_support=0.10, use_colnames=True)
itemsets["k"] = itemsets["itemsets"].apply(len)
print(itemsets["k"].value_counts().sort_index().to_string())
assert len(itemsets) == len(soporte_g)          # misma respuesta que la versión desde cero
itemsets.sort_values("support", ascending=False).head(8)
k
1    13
2    13
support itemsets k
11 0.52 (whole milk) 1
0 0.40 (bread) 1
12 0.28 (yogurt) 1
5 0.28 (eggs) 1
1 0.26 (butter) 1
6 0.20 (fruit) 1
10 0.18 (vegetables) 1
16 0.16 (bread, whole milk) 2

45.6.3 Paso 3: generar reglas y leerlas con lift

Mostrar código
reglas = association_rules(itemsets, metric="confidence", min_threshold=0.40)
reglas["regla"] = (reglas["antecedents"].apply(lambda s: ", ".join(sorted(s))) + " ⇒ " +
                   reglas["consequents"].apply(lambda s: ", ".join(sorted(s))))
print(f"Reglas con confianza ≥ 0.40: {len(reglas)}")
reglas.sort_values("lift", ascending=False)[["regla", "support", "confidence", "lift"]].round(3)
Reglas con confianza ≥ 0.40: 13
regla support confidence lift
6 chips ⇒ soda 0.14 1.000 6.250
7 soda ⇒ chips 0.14 0.875 6.250
11 granola ⇒ yogurt 0.10 1.000 3.571
5 cheese ⇒ eggs 0.10 0.714 2.551
10 fruit ⇒ yogurt 0.12 0.600 2.143
9 yogurt ⇒ fruit 0.12 0.429 2.143
2 butter ⇒ eggs 0.12 0.462 1.648
3 eggs ⇒ butter 0.12 0.429 1.648
0 coffee ⇒ bread 0.10 0.625 1.562
8 coffee ⇒ whole milk 0.10 0.625 1.202
12 yogurt ⇒ whole milk 0.16 0.571 1.099
1 eggs ⇒ bread 0.12 0.429 1.071
4 butter ⇒ whole milk 0.14 0.538 1.036
Mostrar código
import plotly.express as px

fig = px.scatter(reglas, x="confidence", y="lift", size="support", color="lift",
                 color_continuous_scale="RdBu", color_continuous_midpoint=1.0,
                 hover_name="regla", size_max=22,
                 labels=dict(confidence="Confianza", lift="Lift", support="Soporte"))
fig.add_hline(y=1, line_dash="dash", line_color="gray")
fig.update_layout(height=430)
fig.show()
Figura 45.2: Reglas con confianza ≥ 0.40. El eje horizontal es la confianza; el vertical, el lift. La línea discontinua marca lift = 1. Las reglas hacia whole milk tienen confianza respetable pero lift cercano o inferior a 1: la leche está en más de la mitad de las canastas.

La lectura de negocio sale del cuadrante superior: reglas como chips ⇒ soda o granola ⇒ yogurt combinan confianza alta con lift muy superior a 1. Son candidatas naturales para una promoción cruzada. Las reglas hacia whole milk son el caso contrario: frecuentes, aparentemente confiables y casi sin información.

45.6.4 Paso 4: la sensibilidad a los umbrales

Los umbrales de Apriori son decisiones del analista. Antes de reportar reglas conviene ver cuánto cambia el resultado al moverlos:

Mostrar código
import numpy as np

resultados_sens = []
for s in [0.06, 0.08, 0.10, 0.12, 0.14, 0.16, 0.18]:
    its = apriori(df, min_support=s, use_colnames=True)
    r = association_rules(its, metric="confidence", min_threshold=0.3) if len(its) else pd.DataFrame()
    resultados_sens.append({"min_support": s, "n_reglas": len(r),
                            "lift_med": r["lift"].median() if len(r) else np.nan})
df_sens = pd.DataFrame(resultados_sens)

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", line=dict(color="#dc2626", width=2)))
fig.add_trace(go.Scatter(x=df_sens["min_support"], y=df_sens["lift_med"], mode="lines+markers",
                         name="Lift mediano", yaxis="y2", line=dict(color="#2563eb", width=2, dash="dash")))
fig.update_layout(xaxis_title="Soporte mínimo", yaxis=dict(title="N.º de reglas"),
                  yaxis2=dict(title="Lift mediano", overlaying="y", side="right"),
                  legend=dict(x=0.6, y=0.95), height=400)
fig.show()

Número de reglas generadas (confianza mínima 0.3) y lift mediano en función del soporte mínimo. Al subir el soporte quedan menos reglas, pero las que sobreviven tienden a tener mayor lift.

Reportar reglas sin reportar los umbrales con los que se obtuvieron es una práctica deficiente: el conjunto de reglas es una función de los datos y de los parámetros. El capítulo de métricas avanzadas retoma este análisis con más detalle.

45.7 Ventajas y limitaciones

Aspecto Apriori
Idea central Búsqueda por niveles con poda por antimonotonía
Recorridos de los datos Uno por nivel (\(k_{\max} + 1\))
Cuello de botella Número de candidatos del nivel 2 con muchos ítems frecuentes
Memoria Baja: solo candidatos y conteos del nivel actual
Fortaleza Fácil de entender, implementar y explicar; completo y exacto
Debilidad Lento en datos densos o con soporte mínimo bajo
Tabla 45.3: Resumen de Apriori.

Estas debilidades motivan FP-Growth, que comprime los datos en un árbol y evita generar candidatos. Antes de llegar a él, el siguiente capítulo profundiza en cómo evaluar las reglas que Apriori produce.

Referencias