---
title: "FP-Growth y minería de patrones frecuentes"
pagetitle: "FP-Growth y minería de patrones frecuentes"
open-graph:
description: "Explica el algoritmo FP-Growth, la construcción del árbol FP-tree y la extracción de patrones frecuentes, comparándolo con Apriori en Python."
twitter-card:
description: "Explica el algoritmo FP-Growth, la construcción del árbol FP-tree y la extracción de patrones frecuentes, comparándolo con Apriori en Python."
description: "Explica el algoritmo FP-Growth, la construcción del árbol FP-tree y la extracción de patrones frecuentes, comparándolo con Apriori en Python."
author: "Diego Villalba"
date: today
lang: es
format:
html:
toc: true
toc-depth: 3
toc-title: "Contenido"
number-sections: true
code-fold: true
code-tools: true
code-summary: "Mostrar código"
fig-align: center
theme: cosmo
highlight-style: github
smooth-scroll: true
pdf:
toc: true
toc-depth: 3
number-sections: true
documentclass: scrbook
papersize: letter
fontsize: 11pt
geometry: margin=2.5cm
keep-tex: false
execute:
echo: true
warning: false
message: false
cache: false
bibliography: [referencias_fp_growth.bib, ../../references.bib]
crossref:
fig-title: "Figura"
tbl-title: "Tabla"
eq-prefix: "Ec."
jupyter: python3
---
{{< include ../preprocesamiento/_labkit.qmd >}}
## FP-Growth y minería de patrones frecuentes
### Introducción
La minería de reglas de asociación busca descubrir regularidades en bases de datos transaccionales: productos que suelen comprarse juntos, páginas que suelen visitarse en una misma sesión, palabras que coaparecen en documentos o eventos que tienden a registrarse en conjunto. En este contexto, una transacción representa una observación compuesta por varios ítems; por ejemplo, una compra de supermercado puede contener `{Pan, Leche, Pañales}`. El objetivo no es predecir una etiqueta, sino identificar estructuras de coocurrencia que permitan extraer conocimiento interpretable a partir de datos discretos [@agrawal1994fast; @han2012data].
El algoritmo **FP-Growth** (*Frequent Pattern Growth*) fue propuesto como una alternativa eficiente a Apriori para encontrar conjuntos frecuentes de ítems. Su idea central es evitar la generación explícita de candidatos y comprimir la base de transacciones en una estructura llamada **FP-tree** (*Frequent Pattern Tree*). A partir de ese árbol, el algoritmo explora patrones frecuentes mediante bases condicionales y árboles condicionales [@han2000mining; @borgelt2005implementation].
Este capítulo desarrolla FP-Growth de manera progresiva. Primero se revisan las nociones de transacción, itemset, soporte y frecuencia. Después se muestra cómo construir un FP-tree usando un ejemplo pequeño de cesta de compra. Finalmente, se explica cómo extraer patrones frecuentes mediante caminos prefijo condicionales, se compara FP-Growth con Apriori y se implementa un flujo práctico en Python.
### Conceptos básicos
En minería de asociaciones, trabajamos con una colección de transacciones. Cada transacción contiene un subconjunto de ítems tomado de un universo finito de productos, términos o eventos posibles. Esta representación es natural en problemas donde los datos son conjuntos: una compra contiene productos, un documento contiene palabras, una sesión web contiene páginas visitadas.
::: {.callout-note appearance="minimal"}
Definición. Sea $I = \{i_1, i_2, \ldots, i_m\}$ un conjunto finito de ítems. Una transacción $T$ es un subconjunto de $I$, es decir:
$$
T \subseteq I.
$$
Una base transaccional es una colección $\mathcal{D} = \{T_1,T_2,\ldots,T_n\}$ de transacciones.
:::
::: {.callout-note appearance="minimal"}
Definición. Un itemset es cualquier subconjunto $X \subseteq I$. Decimos que una transacción $T_i$ contiene a $X$ si todos los ítems de $X$ aparecen en $T_i$:
$$
X \subseteq T_i.
$$
:::
El concepto clave para decidir si un patrón es relevante es el **soporte**. Existen dos formas comunes de reportarlo: como conteo absoluto o como proporción. El conteo absoluto indica cuántas transacciones contienen el patrón; el soporte relativo divide ese conteo entre el número total de transacciones.
::: {.callout-note appearance="minimal"}
Definición. El soporte absoluto de un itemset $X$ en una base transaccional $\mathcal{D}$ es el número de transacciones que contienen a $X$:
$$
\operatorname{supp}_{\#}(X)=\left|\{T_i \in \mathcal{D}: X \subseteq T_i\}\right|.
$$
El soporte relativo se define como:
$$
\operatorname{supp}(X)=\frac{\operatorname{supp}_{\#}(X)}{|\mathcal{D}|}.
$$ {#eq-support}
:::
::: {.callout-note appearance="minimal"}
Definición. Dado un umbral mínimo de soporte $\text{min\_sup}$, un itemset $X$ es frecuente si su soporte absoluto cumple:
$$
\operatorname{supp}_{\#}(X) \geq \text{min\_sup}.
$$ {#eq-frequent}
:::
El umbral $\text{min\_sup}$ controla la granularidad del análisis. Un valor demasiado bajo puede producir demasiados patrones, muchos de ellos triviales o difíciles de interpretar. Un valor demasiado alto puede eliminar patrones interesantes pero menos comunes. Por ello, en aplicaciones reales suele elegirse mediante exploración, conocimiento del dominio y restricciones computacionales.
### De Apriori a FP-Growth
El algoritmo Apriori explota una propiedad fundamental: si un conjunto de ítems es frecuente, entonces todos sus subconjuntos también son frecuentes. De manera equivalente, si un itemset no es frecuente, ningún superconjunto suyo puede ser frecuente [@agrawal1994fast]. Esta propiedad permite podar el espacio de búsqueda, pero Apriori todavía necesita generar muchos candidatos y escanear la base varias veces.
FP-Growth sigue otra estrategia. En lugar de construir y evaluar candidatos explícitamente, realiza dos recorridos principales sobre la base de datos: uno para calcular los soportes de los ítems individuales y otro para construir un árbol compacto. Después extrae patrones frecuentes desde la estructura comprimida [@han2000mining].
La ventaja aparece especialmente cuando muchas transacciones comparten prefijos. Si los ítems se ordenan globalmente por frecuencia decreciente, transacciones parecidas tienden a compartir las primeras ramas del árbol. Así, el FP-tree almacena información agregada en lugar de repetir cada transacción completa.
### Ejemplo base: transacciones de cesta de compra
Considérese la siguiente base de transacciones y un soporte mínimo absoluto $\text{min\_sup}=3$:
| Transacción | Ítems |
|---|---|
| $T_1$ | `{Pan, Leche}` |
| $T_2$ | `{Pan, Leche, Pañales, Cerveza}` |
| $T_3$ | `{Leche, Pañales, Cerveza}` |
| $T_4$ | `{Pan, Leche, Pañales, Cerveza}` |
| $T_5$ | `{Pan, Leche, Pañales}` |
Los soportes absolutos de los ítems individuales son:
| Ítem | Transacciones donde aparece | Soporte absoluto |
|---|---|---:|
| Pan | $T_1,T_2,T_4,T_5$ | 4 |
| Leche | $T_1,T_2,T_3,T_4,T_5$ | 5 |
| Pañales | $T_2,T_3,T_4,T_5$ | 4 |
| Cerveza | $T_2,T_3,T_4$ | 3 |
Como todos los ítems tienen soporte absoluto mayor o igual que $3$, ninguno se elimina en esta etapa. Si hubiera ítems con soporte menor que $\text{min\_sup}$, se descartarían antes de construir el árbol.
```{python}
# Ejemplo base de transacciones
transactions = [
{"Pan", "Leche"},
{"Pan", "Leche", "Pañales", "Cerveza"},
{"Leche", "Pañales", "Cerveza"},
{"Pan", "Leche", "Pañales", "Cerveza"},
{"Pan", "Leche", "Pañales"},
]
min_sup = 3
from collections import Counter
item_counts = Counter()
for transaction in transactions:
item_counts.update(transaction)
item_counts
```
### Ordenamiento por frecuencia
El FP-tree depende de un orden global de ítems. La regla usual consiste en ordenar los ítems de mayor a menor frecuencia. En caso de empates, conviene fijar un criterio determinista, por ejemplo orden alfabético. Este ordenamiento hace que transacciones con ítems frecuentes compartan prefijos y que el árbol sea más compacto.
En el ejemplo, el orden global puede tomarse como:
$$
\text{Leche} \succ \text{Pan} \succ \text{Pañales} \succ \text{Cerveza}.
$$
Con ese orden, las transacciones se transforman en:
| Transacción original | Transacción ordenada |
|---|---|
| `{Pan, Leche}` | `[Leche, Pan]` |
| `{Pan, Leche, Pañales, Cerveza}` | `[Leche, Pan, Pañales, Cerveza]` |
| `{Leche, Pañales, Cerveza}` | `[Leche, Pañales, Cerveza]` |
| `{Pan, Leche, Pañales, Cerveza}` | `[Leche, Pan, Pañales, Cerveza]` |
| `{Pan, Leche, Pañales}` | `[Leche, Pan, Pañales]` |
```{python}
# Orden global por frecuencia descendente y desempate alfabético
frequent_items = {item for item, count in item_counts.items() if count >= min_sup}
order = sorted(frequent_items, key=lambda item: (-item_counts[item], item))
rank = {item: position for position, item in enumerate(order)}
ordered_transactions = []
for transaction in transactions:
filtered = [item for item in transaction if item in frequent_items]
ordered = sorted(filtered, key=lambda item: rank[item])
ordered_transactions.append(ordered)
order, ordered_transactions
```
### Construcción del FP-tree
Un FP-tree es un árbol con raíz nula. Cada nodo almacena un ítem, un contador y enlaces hacia sus hijos. Para insertar una transacción ordenada, se recorre la transacción de izquierda a derecha. Si el siguiente ítem ya existe como hijo del nodo actual, se incrementa su contador. Si no existe, se crea un nuevo nodo con contador inicial igual a uno.
::: {.callout-note appearance="minimal"}
Definición. Un FP-tree es una estructura arbórea $\mathcal{T}$ donde cada nodo no raíz representa un ítem y almacena un contador. El contador de un nodo indica cuántas transacciones comparten el prefijo correspondiente al camino desde la raíz hasta ese nodo:
$$
\operatorname{count}(v)=\left|\{T_i \in \mathcal{D}: \operatorname{path}(v) \subseteq T_i\}\right|.
$$
:::
El procedimiento de construcción puede resumirse así:
1. Crear una raíz `null`.
2. Ordenar cada transacción de acuerdo con la frecuencia global.
3. Insertar cada transacción en el árbol.
4. Incrementar contadores cuando una rama ya existe.
5. Crear nodos nuevos cuando el prefijo todavía no aparece.
```{python}
class FPNode:
def __init__(self, item=None, count=0, parent=None):
self.item = item
self.count = count
self.parent = parent
self.children = {}
def add_child(self, item):
child = FPNode(item=item, count=0, parent=self)
self.children[item] = child
return child
def insert_transaction(root, transaction):
current = root
for item in transaction:
if item not in current.children:
current.add_child(item)
current = current.children[item]
current.count += 1
def build_fp_tree(ordered_transactions):
root = FPNode(item="null", count=0)
for transaction in ordered_transactions:
insert_transaction(root, transaction)
return root
def print_tree(node, indent=0):
label = f"{node.item}: {node.count}" if node.item != "null" else "null"
print(" " * indent + label)
for child in node.children.values():
print_tree(child, indent + 1)
root = build_fp_tree(ordered_transactions)
print_tree(root)
```
En este ejemplo, la rama más importante inicia con `Leche`, porque aparece en todas las transacciones. Desde `Leche`, varias transacciones continúan con `Pan`; otras continúan directamente con `Pañales`. La compresión proviene de sumar contadores en prefijos compartidos en lugar de almacenar cada transacción de forma independiente.
### Caminos prefijo y base condicional
Una vez construido el FP-tree, FP-Growth extrae patrones frecuentes considerando los ítems desde los menos frecuentes hacia los más frecuentes. Para cada ítem objetivo, se buscan todos los caminos que terminan en ese ítem. Al quitar el ítem objetivo de esos caminos se obtiene su **base de patrones condicional**.
::: {.callout-note appearance="minimal"}
Definición. Dado un ítem $a$, la base de patrones condicional de $a$, denotada $\mathcal{B}_a$, es el multiconjunto de caminos prefijo que aparecen antes de $a$ en el FP-tree, ponderados por el contador del nodo asociado a $a$:
$$
\mathcal{B}_a = \{(P_j,c_j): P_j \text{ es un camino prefijo de } a \text{ y } c_j \text{ es su conteo}\}.
$$
:::
Para el ítem `Cerveza`, los caminos relevantes son:
| Camino en el árbol | Conteo |
|---|---:|
| `[Leche, Pan, Pañales, Cerveza]` | 2 |
| `[Leche, Pañales, Cerveza]` | 1 |
Al eliminar `Cerveza`, se obtiene:
| Camino prefijo | Conteo |
|---|---:|
| `[Leche, Pan, Pañales]` | 2 |
| `[Leche, Pañales]` | 1 |
En esta base condicional, los conteos acumulados son:
| Ítem | Conteo condicional |
|---|---:|
| Leche | 3 |
| Pan | 2 |
| Pañales | 3 |
Con $\text{min\_sup}=3$, `Pan` se elimina de la base condicional de `Cerveza`, mientras que `Leche` y `Pañales` permanecen. Por tanto, los patrones frecuentes que incluyen `Cerveza` son:
$$
\{\text{Cerveza}\}, \quad
\{\text{Leche},\text{Cerveza}\}, \quad
\{\text{Pañales},\text{Cerveza}\}, \quad
\{\text{Leche},\text{Pañales},\text{Cerveza}\}.
$$
```{python}
def collect_prefix_paths(node, target_item):
"""Devuelve caminos prefijo hacia target_item dentro de un FP-tree simple."""
paths = []
def traverse(current):
for child in current.children.values():
if child.item == target_item:
path = []
parent = child.parent
while parent is not None and parent.item != "null":
path.append(parent.item)
parent = parent.parent
path.reverse()
if path:
paths.append((path, child.count))
traverse(child)
traverse(node)
return paths
prefix_paths_cerveza = collect_prefix_paths(root, "Cerveza")
prefix_paths_cerveza
```
```{python}
def conditional_counts(prefix_paths):
counts = Counter()
for path, weight in prefix_paths:
for item in path:
counts[item] += weight
return counts
conditional_counts(prefix_paths_cerveza)
```
### Árbol condicional
Después de obtener la base condicional de un ítem, se eliminan los ítems que no alcanzan el soporte mínimo y se construye un nuevo FP-tree restringido a esa base. Ese árbol se llama **árbol condicional**. En el caso de `Cerveza`, el árbol condicional conserva la estructura:
$$
\text{Leche}:3 \rightarrow \text{Pañales}:3.
$$
Esto significa que, entre las transacciones que contienen `Cerveza`, existen tres ocurrencias donde `Leche` aparece junto con `Cerveza` y tres ocurrencias donde `Pañales` aparece junto con `Cerveza`. Además, el camino conjunto indica que `{Leche, Pañales, Cerveza}` también es frecuente.
::: {.callout-note appearance="minimal"}
Definición. El árbol condicional de un ítem $a$ es el FP-tree construido a partir de la base condicional $\mathcal{B}_a$ después de eliminar los ítems cuyo conteo condicional es menor que $\text{min\_sup}$:
$$
\mathcal{T}_a = \operatorname{FPtree}\left(\{P_j \in \mathcal{B}_a: \operatorname{count}_{\mathcal{B}_a}(i) \geq \text{min\_sup}\}\right).
$$
:::
```{python}
def expand_weighted_paths(prefix_paths):
expanded = []
for path, weight in prefix_paths:
expanded.extend([path] * weight)
return expanded
conditional_item_counts = conditional_counts(prefix_paths_cerveza)
conditional_frequent = {
item for item, count in conditional_item_counts.items()
if count >= min_sup
}
conditional_order = sorted(
conditional_frequent,
key=lambda item: (-conditional_item_counts[item], item)
)
conditional_rank = {item: pos for pos, item in enumerate(conditional_order)}
conditional_transactions = []
for path in expand_weighted_paths(prefix_paths_cerveza):
filtered_path = [item for item in path if item in conditional_frequent]
ordered_path = sorted(filtered_path, key=lambda item: conditional_rank[item])
if ordered_path:
conditional_transactions.append(ordered_path)
conditional_root = build_fp_tree(conditional_transactions)
print_tree(conditional_root)
```
### Laboratorio: construir y minar el FP-tree
El laboratorio inserta las transacciones de una en una. En cada paso verás qué prefijo se comparte (los contadores suben) y qué nodos nuevos se crean. Después elige un ítem para obtener su base de patrones condicional y los patrones frecuentes que terminan en él. Las líneas discontinuas son los **enlaces de nodo** de la tabla de encabezados: conectan todas las apariciones de un mismo ítem en el árbol.
::: {.content-visible when-format="html"}
```{=html}
<div class="plab" id="fp-lab">
<p class="plab-eyebrow">Laboratorio · FP-Growth</p>
<h3 class="plab-title">¿Cómo comprime el FP-tree las transacciones y cómo se minan sus patrones?</h3>
<p class="plab-sub">Mueve el número de transacciones insertadas, cambia el soporte mínimo y elige el ítem cuya base condicional quieres ver.</p>
<div class="plab-controls">
<label>Transacciones insertadas: <b id="fp-ins-v"></b><input type="range" id="fp-ins" min="0" max="5" step="1" value="5"></label>
<label>Soporte mínimo (conteo): <b id="fp-min-v"></b><input type="range" id="fp-min" min="1" max="5" step="1" value="3"></label>
<div class="plab-field" style="flex:2 1 260px">Ítem a minar
<div class="plab-seg" id="fp-item"></div>
</div>
</div>
<div class="plab-panel">
<h4 id="fp-tree-title">FP-tree</h4>
<svg id="fp-svg" class="wide" role="img" aria-label="Árbol FP con los contadores de cada nodo"></svg>
<div class="plab-legend">
<span><i class="dot" style="background:var(--lab-s1)"></i>nodo del árbol</span>
<span><i class="dot" style="background:var(--lab-s2)"></i>camino de la última transacción insertada</span>
<span><i style="background:var(--lab-s4)"></i>enlaces de nodo del ítem elegido</span>
</div>
</div>
<div class="plab-stats">
<div class="plab-stat"><span>Ítems en las transacciones</span><strong id="fp-raw"></strong></div>
<div class="plab-stat"><span>Nodos en el árbol</span><strong id="fp-nodes"></strong></div>
<div class="plab-stat"><span>Compresión</span><strong id="fp-ratio"></strong></div>
</div>
<div class="plab-grid">
<div class="plab-panel"><h4>Transacciones ordenadas</h4><div id="fp-tx" class="plab-scroll"></div></div>
<div class="plab-panel"><h4 id="fp-cond-title">Base condicional</h4><div id="fp-cond" class="plab-scroll"></div></div>
</div>
<p class="plab-note" id="fp-note" aria-live="polite"></p>
</div>
<script>
(() => {
const K = window.LabKit, root = document.getElementById('fp-lab');
const $ = s => root.querySelector(s);
const TX = [['Pan','Leche'], ['Pan','Leche','Pañales','Cerveza'], ['Leche','Pañales','Cerveza'], ['Pan','Leche','Pañales','Cerveza'], ['Pan','Leche','Pañales']];
const cmp = (a, b) => a < b ? -1 : a > b ? 1 : 0;
const COUNT = {}; TX.flat().forEach(i => { COUNT[i] = (COUNT[i] || 0) + 1; });
let item = 'Cerveza';
// Minado recursivo sobre una base condicional ponderada: [[prefijo ordenado], peso].
function mine(base, suffix, minc, order, out) {
const cnt = {}; base.forEach(([p, w]) => p.forEach(i => { cnt[i] = (cnt[i] || 0) + w; }));
order.filter(i => (cnt[i] || 0) >= minc).reverse().forEach(i => {
const pat = [i, ...suffix]; out.push([pat, cnt[i]]);
const sub = base.filter(([p]) => p.includes(i)).map(([p, w]) => [p.slice(0, p.indexOf(i)).filter(x => (cnt[x] || 0) >= minc), w]).filter(([p]) => p.length);
mine(sub, pat, minc, order, out);
});
}
function render() {
const ins = +$('#fp-ins').value, minc = +$('#fp-min').value;
$('#fp-ins-v').textContent = ins; $('#fp-min-v').textContent = minc;
const order = Object.keys(COUNT).filter(i => COUNT[i] >= minc).sort((a, b) => (COUNT[b] - COUNT[a]) || cmp(a, b));
const ordered = TX.map(t => order.filter(i => t.includes(i)));
// Selector de ítem (solo ítems frecuentes).
if (!order.includes(item)) item = order[order.length - 1];
$('#fp-item').innerHTML = order.map(i => `<button type="button" data-value="${i}" aria-pressed="${i === item}">${i}</button>`).join('');
// Construcción del árbol con las primeras `ins` transacciones.
let uid = 0;
const rootN = { item: null, count: 0, children: [], parent: null, id: uid++, depth: 0 };
const links = {}; let lastPath = [], shared = 0, created = 0;
ordered.slice(0, ins).forEach((t, j) => {
let cur = rootN; const path = []; let sh = 0, cr = 0;
t.forEach(i => {
let ch = cur.children.find(c => c.item === i);
if (ch) sh++; else { ch = { item: i, count: 0, children: [], parent: cur, id: uid++, depth: cur.depth + 1 }; cur.children.push(ch); (links[i] = links[i] || []).push(ch); cr++; }
ch.count++; path.push(ch.id); cur = ch;
});
if (j === ins - 1) { lastPath = path; shared = sh; created = cr; }
});
const nodes = []; (function walk(n) { if (n.item) nodes.push(n); n.children.forEach(walk); })(rootN);
// Distribución: hojas en orden DFS, padres al centro de sus hijos.
let leaf = 0;
(function place(n) { if (!n.children.length) { n.x = leaf++; return; } n.children.forEach(place); n.x = K.mean(n.children.map(c => c.x)); })(rootN);
const maxD = Math.max(1, ...nodes.map(n => n.depth)), W = 640, H = 50 + maxD * 70;
const svg = $('#fp-svg'); svg.setAttribute('viewBox', `0 0 ${W} ${H}`); svg.classList.add('plab-chart'); svg.replaceChildren();
const X = x => leaf <= 1 ? W / 2 : 70 + x * (W - 140) / (leaf - 1), Yp = d => 24 + d * 70;
const hot = new Set(lastPath);
nodes.forEach(n => K.el('line', { x1: X(n.parent.x), y1: Yp(n.parent.depth) + 14, x2: X(n.x), y2: Yp(n.depth) - 14,
class: hot.has(n.id) ? 's2 line' : 'muted line thin' }, svg));
(links[item] || []).forEach((n, k, arr) => { if (k) K.el('path', { d: `M${X(arr[k - 1].x)},${Yp(arr[k - 1].depth)}Q${(X(arr[k - 1].x) + X(n.x)) / 2},${Math.max(Yp(arr[k - 1].depth), Yp(n.depth)) + 40} ${X(n.x)},${Yp(n.depth)}`, class: 's4 line thin dash' }, svg); });
K.el('circle', { cx: X(rootN.x), cy: Yp(0), r: 12, class: 'muted soft' }, svg);
K.el('text', { x: X(rootN.x), y: Yp(0) + 4, 'text-anchor': 'middle' }, svg).textContent = 'null';
nodes.forEach(n => {
const cls = n.item === item ? 's4' : hot.has(n.id) ? 's2' : 's1';
const r = K.el('rect', { x: X(n.x) - 44, y: Yp(n.depth) - 14, width: 88, height: 28, rx: 8, class: cls }, svg);
r.style.fillOpacity = 0.18;
K.el('text', { x: X(n.x), y: Yp(n.depth) + 4, 'text-anchor': 'middle', class: 'strong', 'font-size': 12 }, svg).textContent = `${n.item}: ${n.count}`;
});
if (!ins) K.el('text', { x: W / 2, y: Yp(1), 'text-anchor': 'middle' }, svg).textContent = 'Árbol vacío: mueve el control para insertar transacciones.';
const raw = ordered.slice(0, ins).reduce((s, t) => s + t.length, 0);
$('#fp-raw').textContent = raw; $('#fp-nodes').textContent = nodes.length;
$('#fp-ratio').textContent = raw ? `${K.fmt(nodes.length / raw * 100, 0)} %` : '—';
$('#fp-tree-title').textContent = `FP-tree con orden global ${order.join(' ≻ ')}`;
$('#fp-tx').innerHTML = '<table class="plab-table"><thead><tr><th>T</th><th style="text-align:left">Original</th><th style="text-align:left">Ordenada y filtrada</th></tr></thead><tbody>' +
TX.map((t, j) => `<tr${j === ins - 1 ? ' class="hl"' : ''} style="${j >= ins ? 'opacity:.45' : ''}"><td>T${j + 1}</td><td style="text-align:left">${t.join(', ')}</td><td style="text-align:left">[${ordered[j].join(', ')}]</td></tr>`).join('') + '</tbody></table>';
// Base condicional del ítem elegido (en el árbol actual).
const base = (links[item] || []).map(n => { const p = []; let q = n.parent; while (q.item) { p.unshift(q.item); q = q.parent; } return [p, n.count]; });
const supItem = (links[item] || []).reduce((s, n) => s + n.count, 0);
const pats = supItem >= minc ? [[[item], supItem]] : [];
if (supItem >= minc) mine(base.filter(([p]) => p.length), [item], minc, order, pats);
const ccount = {}; base.forEach(([p, w]) => p.forEach(i => { ccount[i] = (ccount[i] || 0) + w; }));
$('#fp-cond-title').textContent = `Base condicional de ${item}`;
$('#fp-cond').innerHTML = (base.length ? '<table class="plab-table"><thead><tr><th style="text-align:left">Camino prefijo</th><th>Conteo</th></tr></thead><tbody>' +
base.map(([p, w]) => `<tr><td style="text-align:left">[${p.join(', ') || '∅'}]</td><td>${w}</td></tr>`).join('') + '</tbody></table>' : '<p class="plab-sub">El ítem aún no aparece en el árbol.</p>') +
(Object.keys(ccount).length ? '<div class="plab-math">conteos condicionales: ' + order.filter(i => ccount[i]).map(i => `${i}=${ccount[i]}${ccount[i] < minc ? ' ✗' : ''}`).join(', ') + '</div>' : '') +
(pats.length ? '<div class="plab-math">patrones frecuentes:\n' + pats.map(([p, c]) => `{${p.join(', ')}}: ${c}`).join('\n') + '</div>' : '');
const note = $('#fp-note');
note.className = 'plab-note';
note.textContent = !ins ? 'Primer recorrido: se cuentan los ítems y se fija el orden global. El segundo recorrido inserta cada transacción ordenada.'
: `T${ins} ordenada = [${ordered[ins - 1].join(', ')}]: comparte un prefijo de ${shared} nodo(s), cuyos contadores suben en 1, y crea ${created} nodo(s) nuevo(s). ` +
`El árbol guarda ${nodes.length} nodos para ${raw} apariciones de ítems. ` +
(pats.length ? `Con soporte mínimo ${minc}, ${item} participa en ${pats.length} patrón(es) frecuente(s).` : `${item} no alcanza el soporte mínimo ${minc} con las transacciones insertadas.`);
}
$('#fp-item').addEventListener('click', e => { const b = e.target.closest('button'); if (b) { item = b.dataset.value; render(); } });
['#fp-ins', '#fp-min'].forEach(id => $(id).addEventListener('input', render));
render();
})();
</script>
```
:::
::: {.content-visible when-format="pdf"}
En la versión web, este laboratorio construye el FP-tree insertando las transacciones de una en una y calcula la base condicional del ítem elegido. Valores de referencia con soporte mínimo 3 y las cinco transacciones: 6 nodos para 16 apariciones de ítems (38 %); la base condicional de Cerveza es [Leche, Pan, Pañales]: 2 y [Leche, Pañales]: 1, con conteos condicionales Leche = 3, Pan = 2, Pañales = 3, y sus patrones frecuentes son {Cerveza}, {Pañales, Cerveza}, {Leche, Pañales, Cerveza} y {Leche, Cerveza}, todos con conteo 3.
:::
**Guía del laboratorio.**
1. Lleva el control a 0 y avanza de una en una. Predice antes de cada paso cuántos nodos nuevos creará la transacción; comprueba con la nota. ¿Cuál es la única transacción que abre una rama nueva bajo Leche?
2. Con las cinco transacciones, elige Cerveza y compara la base condicional con la tabla de la sección anterior.
3. Elige Pañales. ¿Por qué tiene dos caminos prefijo? ¿Qué patrones frecuentes salen?
4. Baja el soporte mínimo a 2. ¿Qué patrones nuevos aparecen para Cerveza y por qué todos incluyen a Pan? Súbelo a 4: ¿qué ítem desaparece del árbol y cuántos nodos quedan?
### Patrones frecuentes del ejemplo
Repitiendo el procedimiento para los ítems restantes en orden ascendente de frecuencia, se obtienen los conjuntos frecuentes:
| Ítem base | Patrones frecuentes asociados |
|---|---|
| Cerveza | `{Cerveza}`, `{Leche, Cerveza}`, `{Pañales, Cerveza}`, `{Leche, Pañales, Cerveza}` |
| Pañales | `{Pañales}`, `{Leche, Pañales}`, `{Pan, Pañales}`, `{Leche, Pan, Pañales}` |
| Pan | `{Pan}`, `{Leche, Pan}` |
| Leche | `{Leche}` |
Agrupando y eliminando duplicados, los itemsets frecuentes con $\text{min\_sup}=3$ son:
$$
\begin{aligned}
&\{\text{Leche}\},\{\text{Pan}\},\{\text{Pañales}\},\{\text{Cerveza}\},\\
&\{\text{Leche},\text{Pan}\},\{\text{Leche},\text{Pañales}\},\{\text{Leche},\text{Cerveza}\},\\
&\{\text{Pan},\text{Pañales}\},\{\text{Pañales},\text{Cerveza}\},\\
&\{\text{Leche},\text{Pan},\text{Pañales}\},\{\text{Leche},\text{Pañales},\text{Cerveza}\}.
\end{aligned}
$$
Es importante notar que FP-Growth no produce directamente reglas de asociación; primero produce itemsets frecuentes. Las reglas se construyen después a partir de esos itemsets, evaluando métricas como confianza, lift o convicción [@tan2019introduction; @han2012data].
### Implementación práctica con `mlxtend`
En Python, una forma sencilla de aplicar FP-Growth es utilizar `mlxtend`, que implementa tanto Apriori como FP-Growth para matrices transaccionales codificadas en formato one-hot [@raschka2018mlxtend].
```{python}
# Si es necesario, instalar previamente:
# pip install mlxtend pandas
import pandas as pd
from mlxtend.preprocessing import TransactionEncoder
from mlxtend.frequent_patterns import fpgrowth, association_rules
transactions_list = [list(t) for t in transactions]
encoder = TransactionEncoder()
one_hot = encoder.fit(transactions_list).transform(transactions_list)
df = pd.DataFrame(one_hot, columns=encoder.columns_)
df
```
Como `mlxtend` usa soporte relativo, el umbral absoluto $\text{min\_sup}=3$ debe transformarse dividiendo entre el número de transacciones:
$$
\text{min\_sup}_{rel}=\frac{3}{5}=0.6.
$$
```{python}
min_support_relative = min_sup / len(transactions)
frequent_itemsets = fpgrowth(
df,
min_support=min_support_relative,
use_colnames=True
)
frequent_itemsets.sort_values(
by=["support", "itemsets"],
ascending=[False, True]
)
```
A partir de los itemsets frecuentes se pueden generar reglas. Por ejemplo, una regla $X \Rightarrow Y$ se interpreta como: “cuando aparece $X$, también tiende a aparecer $Y$”.
::: {.callout-note appearance="minimal"}
Definición. La confianza de una regla de asociación $X \Rightarrow Y$, con $X \cap Y = \varnothing$, mide la proporción de transacciones que contienen $X$ y también contienen $Y$:
$$
\operatorname{conf}(X \Rightarrow Y)=\frac{\operatorname{supp}(X \cup Y)}{\operatorname{supp}(X)}.
$$ {#eq-confidence}
:::
::: {.callout-note appearance="minimal"}
Definición. El lift de una regla $X \Rightarrow Y$ compara la coocurrencia observada de $X$ y $Y$ con la coocurrencia esperada bajo independencia estadística:
$$
\operatorname{lift}(X \Rightarrow Y)=\frac{\operatorname{supp}(X \cup Y)}{\operatorname{supp}(X)\operatorname{supp}(Y)}.
$$ {#eq-lift}
:::
```{python}
rules = association_rules(
frequent_itemsets,
metric="confidence",
min_threshold=0.7
)
selected_columns = [
"antecedents", "consequents", "support", "confidence", "lift"
]
rules[selected_columns].sort_values(
by=["lift", "confidence"],
ascending=False
)
```
La confianza puede ser alta aun cuando el consecuente sea muy frecuente. Por ello, conviene interpretarla junto con el lift. Un lift mayor que uno sugiere asociación positiva; un lift cercano a uno sugiere que la regla no mejora sustancialmente lo esperado por independencia; un lift menor que uno sugiere asociación negativa.
### Ejemplo práctico: FP-Growth sobre 50 canastas
Volvamos a la muestra de 50 canastas del capítulo de Apriori. Construimos el FP-tree con las funciones de este capítulo para medir cuánto comprime y comprobamos que `fpgrowth` y `apriori` devuelven exactamente los mismos itemsets frecuentes.
```{python}
#| label: fp-practico
#| fig-cap: "Crecimiento del FP-tree al insertar las 50 canastas (soporte mínimo de 5 canastas). La línea azul cuenta las apariciones de ítems frecuentes acumuladas en las transacciones; la naranja, los nodos del árbol. La distancia entre ambas es la compresión."
import plotly.graph_objects as go
from mlxtend.frequent_patterns import apriori
canastas = [
["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"],
]
min_conteo_g = 5
conteos_g = Counter(i for t in canastas for i in t)
orden_g = sorted([i for i, c in conteos_g.items() if c >= min_conteo_g], key=lambda i: (-conteos_g[i], i))
rango_g = {i: r for r, i in enumerate(orden_g)}
def contar_nodos(nodo):
return sum(1 + contar_nodos(h) for h in nodo.children.values())
raiz_g, apariciones, nodos = FPNode(item="null"), [], []
for t in canastas:
t_ord = sorted([i for i in t if i in rango_g], key=rango_g.get)
insert_transaction(raiz_g, t_ord)
apariciones.append((apariciones[-1] if apariciones else 0) + len(t_ord))
nodos.append(contar_nodos(raiz_g))
fig = go.Figure()
fig.add_scatter(y=apariciones, mode="lines", name="apariciones de ítems", line=dict(color="#2563eb", width=2))
fig.add_scatter(y=nodos, mode="lines", name="nodos del FP-tree", line=dict(color="#ea580c", width=2))
fig.update_layout(xaxis_title="Canastas insertadas", yaxis_title="Total acumulado", height=360)
fig.show()
print(f"{apariciones[-1]} apariciones de ítems -> {nodos[-1]} nodos ({nodos[-1] / apariciones[-1]:.0%})")
```
```{python}
#| label: fp-vs-apriori
te_g = TransactionEncoder()
df_g = pd.DataFrame(te_g.fit_transform(canastas), columns=te_g.columns_)
fp_items = fpgrowth(df_g, min_support=0.10, use_colnames=True)
ap_items = apriori(df_g, min_support=0.10, use_colnames=True)
mismos = set(map(frozenset, fp_items["itemsets"])) == set(map(frozenset, ap_items["itemsets"]))
print(f"FP-Growth: {len(fp_items)} itemsets | Apriori: {len(ap_items)} itemsets | ¿idénticos? {mismos}")
```
Con canastas tan cortas y heterogéneas la compresión es moderada: muchas canastas no comparten prefijo. El árbol comprime más cuando las transacciones se parecen entre sí, que es justamente el caso de datos densos donde Apriori genera más candidatos. Ambos algoritmos son **exactos**: cambian el camino, no la respuesta.
### Comparación entre FP-Growth y Apriori
| Característica | FP-Growth | Apriori |
|---|---|---|
| Generación de candidatos | No requerida | Necesaria |
| Recorridos de los datos | Usualmente dos recorridos principales | Varios recorridos |
| Velocidad | Eficiente en bases grandes y densas | Puede ser lento por generación repetida de candidatos |
| Uso de memoria | Puede ser alto por el FP-tree | Generalmente menor, basado en listas y conteos |
| Implementación | Más compleja | Más sencilla de explicar e implementar |
FP-Growth suele ser preferible cuando la base es grande y contiene muchos patrones frecuentes. Apriori puede ser útil con fines didácticos o cuando se desea una implementación directa basada en candidatos. En la práctica, la elección también depende de la densidad de las transacciones, el umbral de soporte, la memoria disponible y la necesidad de interpretar pasos intermedios.
### Aplicaciones
FP-Growth es aplicable a distintos dominios donde los datos pueden representarse como conjuntos de eventos o ítems:
- **Análisis de cesta de compra:** identificar productos que se compran juntos con frecuencia.
- **Sistemas de recomendación:** sugerir productos, contenidos o servicios a partir de patrones de coocurrencia.
- **Análisis de comportamiento del cliente:** estudiar hábitos de compra en comercio minorista o electrónico.
- **Minería de uso web:** detectar rutas de navegación o secuencias de clics comunes.
- **Minería de texto:** encontrar palabras, términos o frases que aparecen juntas en documentos.
En todos estos casos, los patrones frecuentes deben interpretarse con cuidado. La coocurrencia no implica causalidad. Una regla puede ser estadísticamente fuerte y, aun así, carecer de utilidad práctica si representa un patrón obvio, redundante o inducido por sesgos de captura de datos.
### Ventajas y limitaciones
Entre las principales ventajas de FP-Growth se encuentran:
- Evita la generación explícita de candidatos.
- Reduce el número de escaneos completos de la base de datos.
- Comprime transacciones mediante prefijos compartidos.
- Puede extraer patrones frecuentes complejos de manera eficiente.
- Funciona bien en bases densas donde Apriori produce demasiados candidatos.
Sus limitaciones principales son:
- El FP-tree puede consumir mucha memoria si la base es grande y poco compresible.
- La implementación es más compleja que Apriori.
- La minería condicional puede ser costosa cuando existen muchos patrones frecuentes.
- No es naturalmente incremental: cuando cambian los datos, puede requerir reconstruir el árbol.
Estas limitaciones no invalidan el método, pero sí obligan a seleccionar adecuadamente el umbral de soporte, revisar la calidad de los datos y evaluar la interpretabilidad de los patrones resultantes.
### Buenas prácticas de análisis
Al aplicar FP-Growth en un proyecto real, conviene seguir una estrategia reproducible:
1. Definir qué representa una transacción.
2. Limpiar y normalizar los ítems.
3. Explorar frecuencias individuales antes de minar patrones.
4. Probar varios valores de soporte mínimo.
5. Generar reglas solo después de revisar los itemsets frecuentes.
6. Evaluar confianza, lift y soporte de manera conjunta.
7. Validar los patrones con conocimiento del dominio.
8. Documentar decisiones, umbrales y supuestos.
La etapa más importante no es ejecutar el algoritmo, sino construir una representación transaccional coherente. Por ejemplo, en minería de texto, decidir si los ítems serán palabras, lemas, n-gramas o entidades cambia completamente el significado de los patrones. En comercio electrónico, decidir si una transacción corresponde a una compra, una sesión o un usuario también modifica la interpretación del soporte.
### Conclusiones
FP-Growth es un algoritmo fundamental para la minería de patrones frecuentes. Su principal contribución es reemplazar la generación explícita de candidatos por una estructura compacta, el FP-tree, que permite almacenar prefijos compartidos y extraer patrones mediante bases condicionales. Esta estrategia reduce recorridos sobre la base de datos y suele mejorar el rendimiento frente a Apriori en escenarios grandes o densos.
Desde el punto de vista conceptual, FP-Growth muestra una idea central en ciencia de datos: la eficiencia no depende solo del algoritmo, sino también de la representación. Al transformar transacciones en un árbol comprimido, el problema se vuelve más manejable sin perder la información necesaria para recuperar los itemsets frecuentes.
En aplicaciones reales, el algoritmo debe acompañarse de una interpretación cuidadosa. Los patrones frecuentes son puntos de partida para formular hipótesis, diseñar recomendaciones o comprender comportamientos, pero no constituyen por sí solos evidencia causal. Por ello, la minería de asociaciones debe integrarse con análisis exploratorio, conocimiento del dominio y criterios claros de utilidad práctica.