Sistema de Recomendación por Reglas de Asociación

Apriori · FP-Growth · ECLAT — Dataset MovieLens 100K

Autor/a

Alejandro Figueroa Rojas | Ingeniero Comercial — Data & Business Intelligence

Fecha de publicación

23 de mayo de 2026


1 Resumen Ejecutivo

Dimensión Descripción
Dataset MovieLens 100K — GroupLens Research, Universidad de Minnesota
Usuarios 943 usuarios únicos (mínimo 20 ratings cada uno)
Películas 1 682 películas únicas
Ratings totales 100 000 ratings en escala 1–5
Géneros disponibles 19 géneros: Action, Comedy, Drama, Horror, SciFi, Thriller y otros
Transformación Binarización rating ≥ 4 → transacción positiva por usuario
Filtro 4a — Soporte mínimo Eliminar películas con soporte < 5 % de usuarios
Filtro 4b — Correlación iterativa Eliminación iterativa de ítems con correlación de Pearson ≥ 0.90
Algoritmos evaluados Apriori · FP-Growth · ECLAT
Métricas comparativas N reglas · Lift · Confianza · Conviction · Leverage · Tiempo CPU
Objetivo final Motor de recomendación por perfil de género y co-visualización

2 Motivación y Objetivos

2.1 Contexto del Problema

Las plataformas de streaming enfrentan un problema central: el exceso de opciones. Netflix, Amazon Prime y similares ofrecen catálogos de miles de títulos; la paradoja de la elección lleva a que los usuarios pasen más tiempo buscando que consumiendo. Los sistemas de recomendación son la respuesta estructural a este problema.

Las reglas de asociación ofrecen un enfoque interpretable y no supervisado: identifican patrones de co-visualización del tipo “usuarios que vieron \(A\) también vieron \(B\), generando recomendaciones basadas en el comportamiento colectivo real. A diferencia de los filtros colaborativos matriciales (SVD, ALS, NCF), las reglas son explícitas, auditables y no requieren reentrenamiento continuo.

2.2 Objetivos

Objetivo general: construir un sistema de recomendación basado en reglas de asociación sobre MovieLens 100K, evaluando y comparando tres algoritmos de minería de patrones frecuentes.

Objetivos específicos:

  1. Describir la estructura del dataset mediante EDA completo, incluyendo diagnóstico de calidad de datos.

  2. Detectar y documentar valores faltantes, sparsity, y usuarios atípicos.

  3. Transformar la matriz de ratings en transacciones binarias interpretables.

  4. Aplicar el Clean Algorithm (filtro de soporte + correlación iterativa) para reducir ruido y redundancia.

  5. Ejecutar Apriori, FP-Growth y ECLAT sobre las mismas transacciones limpias.

  6. Comparar rendimiento: número de reglas, métricas de calidad y tiempo de ejecución.

  7. Construir un motor de recomendación por perfil de género con los tres algoritmos.

  8. Interpretar y validar las reglas generadas desde la perspectiva del dominio cinematográfico.

2.3 Diagrama del Pipeline

Paso 1 — Carga de datos
MovieLens 100K · 943 usuarios · 1 682 películas

Paso 2 — EDA + Diagnóstico de calidad
Distribuciones · NAs · Sparsity · Outliers · Géneros

Paso 3 — Binarización
Rating ≥ 4 → transacción positiva

<strong>Paso 4a — Soporte mín.</strong><br>supp &lt; 0.05 → eliminar
<strong>Paso 4b — Correlación</strong><br>|r| ≥ 0.90 → eliminar iterativamente

Pasos 4a + 4b = Clean Algorithm → transacciones limpias (trans_final)

<strong>Apriori</strong><br>Candidatos nivel a nivel
<strong>FP-Growth</strong><br>FP-tree comprimido
<strong>ECLAT</strong><br>Intersección tidsets

Paso 6 — Comparativa
Lift · Confianza · Conviction · Leverage · Tiempo

Paso 7 — Motor de Recomendación
Recomendación por perfil de género


3 Fundamentos Matemáticos

3.1 Notación Base

Sea \(\mathcal{I} = \{i_1, i_2, \ldots, i_m\}\) el universo de películas y \(\mathcal{D} = \{T_1, T_2, \ldots, T_n\}\) el conjunto de transacciones, donde cada \(T_k \subseteq \mathcal{I}\) representa las películas que el usuario \(k\) valoró positivamente (rating \(\geq 4\)).

El soporte absoluto de un itemset \(X\):

\[\sigma(X) = \left|\{T_k \in \mathcal{D} : X \subseteq T_k\}\right|\]

El soporte relativo:

\[\text{supp}(X) = \frac{\sigma(X)}{|\mathcal{D}|}\]

3.2 Regla de Asociación

\[A \Rightarrow B \quad \text{donde } A, B \subseteq \mathcal{I},\; A \cap B = \emptyset,\; A \neq \emptyset,\; B \neq \emptyset\]

3.3 Métricas de Evaluación

3.3.1 Soporte

\[\text{supp}(A \Rightarrow B) = \frac{\sigma(A \cup B)}{|\mathcal{D}|}\]

3.3.2 Confianza

\[\text{conf}(A \Rightarrow B) = \frac{\text{supp}(A \cup B)}{\text{supp}(A)} = P(B \mid A)\]

3.3.3 Lift

\[\text{lift}(A \Rightarrow B) = \frac{\text{conf}(A \Rightarrow B)}{\text{supp}(B)} = \frac{P(A \cap B)}{P(A) \cdot P(B)}\]

  • \(\text{lift} > 1\): asociación real (co-visualización más frecuente de lo esperado bajo independencia).
  • \(\text{lift} = 1\): independencia estadística — regla trivial.
  • \(\text{lift} < 1\): asociación negativa.

3.3.4 Conviction

\[\text{conv}(A \Rightarrow B) = \frac{1 - \text{supp}(B)}{1 - \text{conf}(A \Rightarrow B)}\]

Mide qué tan fuertemente \(A\) implica \(B\). Toma valor \(\infty\) cuando \(\text{conf} = 1\).

3.3.5 Leverage

\[\text{lev}(A \Rightarrow B) = \text{supp}(A \cup B) - \text{supp}(A) \cdot \text{supp}(B)\]

Diferencia entre co-ocurrencia observada y esperada bajo independencia. \(\text{lev} = 0\) equivale a \(\text{lift} = 1\).

3.4 Clean Algorithm

Paso 4a — Filtro de soporte mínimo:

\[\text{Eliminar película } i_j \iff \text{supp}(\{i_j\}) < \text{minsup}\]

Películas vistas por menos del 5 % de usuarios no generan reglas estadísticamente válidas: el estimador de soporte tiene varianza alta y los itemsets resultantes carecen de masa crítica.

Paso 4b — Filtro de correlación iterativo:

\[\text{Eliminar } i_j \iff \exists\, i_k : |r(i_j, i_k)| \geq 0.90\]

Películas altamente correladas generan reglas redundantes. Se itera hasta convergencia:

\[\max_{j \neq k} |r(i_j, i_k)| < 0.90\]

3.5 Algoritmos

3.5.1 Apriori — Propiedad Antimonotónica

\[\text{Si } \text{supp}(X) < \text{minsup} \Rightarrow \forall Y \supset X: \text{supp}(Y) < \text{minsup}\]

Generación de candidatos \(C_{k+1}\) a partir de los itemsets frecuentes \(F_k\), con poda de subconjuntos infrecuentes. Requiere múltiples escaneos de la base.

3.5.2 FP-Growth — Árbol Comprimido

Construye un FP-tree con prefijos compartidos en 2 escaneos. La minería es recursiva sobre bases de patrones condicionales \(\mathcal{D}_i\) para cada ítem \(i\), sin generar candidatos explícitos.

3.5.3 ECLAT — Representación Vertical

Cada ítem almacena su tidset: \(\text{tidset}(X) = \{k : X \subseteq T_k\}\).

\[\sigma(X \cup Y) = |\text{tidset}(X) \cap \text{tidset}(Y)|\]

Solo requiere 1 escaneo inicial. El soporte se calcula íntegramente por intersección de conjuntos.


4 Paso 1: Carga y Descripción del Dataset

4.1 Descripción de MovieLens 100K

Descripción del dataset MovieLens 100K
Atributo Valor
Fuente GroupLens Research — Universidad de Minnesota
Período de recolección Septiembre 1997 – Abril 1998
Usuarios 943 (mínimo 20 ratings cada uno)
Películas 1 682 películas únicas
Ratings totales 100 000 ratings totales
Escala de rating Entero de 1 (peor) a 5 (mejor)
Géneros disponibles 19: Action, Adventure, Animation, Children, Comedy, Crime, Documentary, Drama, Fantasy, Film-Noir, Horror, Musical, Mystery, Romance, SciFi, Thriller, War, Western, Unknown
Formato en R Clase RealRatingMatrix — paquete recommenderlab

4.2 Carga y Verificación Inicial

data(MovieLense)

mat_raw  <- as(MovieLense, "matrix")
n_users  <- nrow(MovieLense)
n_items  <- ncol(MovieLense)
n_rat    <- sum(!is.na(mat_raw))
densidad <- round(n_rat / (n_users * n_items), 4)

cat(
  "── MovieLens 100K ───────────────────────────────────────\n",
  "Clase           :", class(MovieLense), "\n",
  "Usuarios        :", n_users,           "\n",
  "Películas       :", n_items,           "\n",
  "Ratings totales :", n_rat,             "\n",
  "Densidad        :", densidad,          "\n",
  "Rating promedio :", round(mean(getRatings(MovieLense)), 3), "\n",
  "Rating mediano  :", median(getRatings(MovieLense)),         "\n"
)
── MovieLens 100K ───────────────────────────────────────
 Clase           : realRatingMatrix 
 Usuarios        : 943 
 Películas       : 1664 
 Ratings totales : 99392 
 Densidad        : 0.0633 
 Rating promedio : 3.53 
 Rating mediano  : 4 

5 Paso 2: EDA y Diagnóstico de Calidad de Datos

5.1 Diagnóstico de Valores Faltantes y Estructura Sparse

La matriz usuario-película de MovieLens es inherentemente sparse: la mayoría de las celdas son NA porque un usuario no ha calificado la mayoría de las películas. Es fundamental distinguir entre:

  • NA estructural: el usuario nunca vio la película — no es un dato faltante en sentido estadístico, sino ausencia de interacción.
  • NA aleatorio: dato genuinamente perdido — no aplica en este dataset (todos los ratings registrados son observados).
# Matriz completa de ratings (NA = no calificado)
n_total   <- n_users * n_items
n_obs     <- sum(!is.na(mat_raw))
n_na      <- sum(is.na(mat_raw))
pct_obs   <- round(100 * n_obs / n_total, 2)
pct_na    <- round(100 * n_na  / n_total, 2)

# Usuarios sin ningún rating (no debería existir dado el diseño del dataset)
users_sin_rating <- sum(rowSums(!is.na(mat_raw)) == 0)
# Películas sin ningún rating
items_sin_rating <- sum(colSums(!is.na(mat_raw)) == 0)

cat(
  "── Diagnóstico de estructura de datos ──────────────────\n",
  "Celdas totales (usuarios × películas) :", format(n_total, big.mark = " "), "\n",
  "Celdas con rating observado           :", format(n_obs,   big.mark = " "),
  " (", pct_obs, "% densidad)\n",
  "Celdas sin rating (NA estructural)    :", format(n_na,    big.mark = " "),
  " (", pct_na,  "%)\n",
  "Usuarios sin ningún rating            :", users_sin_rating, "\n",
  "Películas sin ningún rating           :", items_sin_rating, "\n"
)
── Diagnóstico de estructura de datos ──────────────────
 Celdas totales (usuarios × películas) : 1 569 152 
 Celdas con rating observado           : 99 392  ( 6.33 % densidad)
 Celdas sin rating (NA estructural)    : 1 469 760  ( 93.67 %)
 Usuarios sin ningún rating            : 0 
 Películas sin ningún rating           : 0 
Diagnóstico de completitud de la matriz usuario-película
Concepto N Porcentaje
Celdas totales (usuarios × películas) 1 569 152 100 %
Ratings observados 99 392 6.33 %
Ausencias (NA estructural — no visto) 1 469 760 93.67 %
Usuarios sin ningún rating 0 0 %
Películas sin ningún rating 0 0 %

Interpretación: el 93.67 % de ausencias es NA estructural y no representa datos corrompidos. No se aplica imputación. La binarización posterior (rating ≥ 4) convierte estas ausencias en cero de forma correcta.

5.2 Distribución de Ratings

rat_vec <- getRatings(MovieLense)
rat_df  <- data.frame(rating = rat_vec)

rat_tab <- data.frame(
  Rating = 1:5,
  N      = as.integer(table(rat_vec)),
  Pct    = round(100 * as.numeric(table(rat_vec)) / length(rat_vec), 2)
)

p1 <- ggplot(rat_df, aes(x = factor(rating), fill = factor(rating))) +
  geom_bar(width = 0.7) +
  scale_fill_manual(values = c("#f5cba7", "#f0b27a", "#e59866", "#ca6f1e", COL_A)) +
  labs(title = "Frecuencia de ratings", x = "Rating", y = "Frecuencia") +
  theme(legend.position = "none")

p2 <- ggplot(rat_df, aes(x = rating)) +
  geom_density(fill = COL_A, alpha = 0.35, color = COL_A) +
  geom_vline(xintercept = 4, linetype = "dashed", color = COL_W, linewidth = 0.9) +
  annotate("text", x = 4.15, y = 0.45, label = "Umbral\nbinarización",
           color = COL_W, size = 3, hjust = 0) +
  labs(title = "Densidad de ratings", x = "Rating", y = "Densidad")

p1 + p2 +
  plot_annotation(title = "Análisis de la variable rating — MovieLens 100K")

Distribución de ratings — MovieLens 100K

Umbral de binarización

Un rating \(\geq 4\) se interpreta como preferencia positiva y se convierte en \(1\); cualquier valor inferior o ausente se convierte en \(0\):

\[x_{ui} = \begin{cases} 1 & r_{ui} \geq 4 \\ 0 & \text{si no} \end{cases}\]

El pico de densidad exactamente en rating = 4 confirma que es el corte natural. Ratings 1–3 reflejan indiferencia o rechazo y no deben incluirse en las transacciones.

Distribución de frecuencias de ratings
Rating N observaciones % del total
1 6059 6.10
2 11307 11.38
3 27002 27.17
4 33947 34.15
5 21077 21.21

Nota: los ratings 4 y 5 representan el 55.4 % del total, lo que valida el umbral ≥ 4 como definición de preferencia positiva con masa suficiente para extraer patrones.

5.3 Detección de Outliers — Actividad por Usuario

rat_por_user <- data.frame(
  user     = rownames(MovieLense),
  n_rat    = rowCounts(MovieLense),
  mean_rat = rowMeans(MovieLense, na.rm = TRUE)
)

Q1_u      <- quantile(rat_por_user$n_rat, 0.25)
Q3_u      <- quantile(rat_por_user$n_rat, 0.75)
IQR_u     <- Q3_u - Q1_u
lim_sup_u <- Q3_u + 1.5 * IQR_u
outliers_u <- rat_por_user[rat_por_user$n_rat > lim_sup_u, ]

cat("── Outliers por actividad (método IQR) ─────────────────\n",
    "Límite superior (Q3 + 1.5·IQR) :", round(lim_sup_u), "ratings\n",
    "Usuarios outlier                :", nrow(outliers_u),
    "(", round(100 * nrow(outliers_u) / nrow(rat_por_user), 1), "% del total)\n")
── Outliers por actividad (método IQR) ─────────────────
 Límite superior (Q3 + 1.5·IQR) : 321 ratings
 Usuarios outlier                : 41 ( 4.3 % del total)
ggplot(rat_por_user, aes(y = n_rat)) +
  geom_boxplot(fill = COL_A, alpha = 0.4, color = COL_A,
               outlier.color = COL_W, outlier.size = 2) +
  geom_hline(yintercept = lim_sup_u, linetype = "dashed",
             color = COL_W, linewidth = 0.8) +
  annotate("text", x = 0.25, y = lim_sup_u + 8,
           label = paste0("Límite IQR: ", round(lim_sup_u),
                          " · Outliers: ", nrow(outliers_u), " usuarios"),
           color = COL_W, size = 3.2) +
  labs(title = "Actividad por usuario — detección de outliers",
       y = "N° ratings por usuario", x = "") +
  theme(axis.text.x = element_blank())

Detección de outliers por actividad de usuario (IQR)

Interpretacion resultados

El gráfico muestra la distribución del número de ratings por usuario mediante un boxplot con detección de outliers por el método IQR.

  • La caja morada concentra el 50% central de los usuarios (entre el percentil 25 y el 75), con una mediana cercana a los 60 ratings, lo que indica que la mayoría de los usuarios tiene actividad moderada y bastante homogénea.
  • La línea discontinua roja marca el límite superior IQR (Q3 + 1.5 × IQR = 321 ratings). Los 41 usuarios que superan ese umbral se clasifican como outliers: son usuarios excepcionalmente activos, con hasta más de 700 ratings.
  • La distribución es fuertemente asimétrica a la derecha: la gran masa de usuarios califica poco, mientras una minoría concentra una actividad desproporcionada.

Estos 41 outliers serán excluidos (o tratados por separado) en pasos posteriores para evitar que sesguen los modelos de recomendación.

Estadísticos de actividad por usuario
Estadístico Valor
Media 105.4
Mediana 64.0
Mín 19.0
Máx 735.0
Q1 32.0
Q3 147.5
IQR 115.5
Límite superior (Q3 + 1.5·IQR) 321.0
Usuarios outlier (superan límite) 41.0

Usuarios con actividad extrema (> 321 ratings): 41 usuarios. Estos usuarios de alta actividad tienen un peso desproporcionado en el cálculo del soporte. Se mantienen en el análisis dado que representan usuarios genuinamente activos, no errores de datos; sin embargo, el filtro de soporte mínimo (Paso 4a) mitiga su influencia al exigir que un patrón sea frecuente en la base completa.

5.4 Detección de Outliers — Actividad por Película

rat_por_item <- data.frame(
  item   = colnames(MovieLense),
  n_rat  = colCounts(MovieLense),
  mean_r = colMeans(MovieLense, na.rm = TRUE)
)

Q1_i  <- quantile(rat_por_item$n_rat, 0.25)
Q3_i  <- quantile(rat_por_item$n_rat, 0.75)
IQR_i <- Q3_i - Q1_i
lim_sup_i <- Q3_i + 1.5 * IQR_i
outliers_i <- rat_por_item[rat_por_item$n_rat > lim_sup_i, ]

p_box_i <- ggplot(rat_por_item, aes(y = n_rat)) +
  geom_boxplot(fill = COL_F, alpha = 0.4, color = COL_F,
               outlier.color = COL_W, outlier.size = 1.5) +
  geom_hline(yintercept = lim_sup_i, linetype = "dashed",
             color = COL_W, linewidth = 0.8) +
  annotate("text", x = 0.25, y = lim_sup_i + 5,
           label = paste0("Límite IQR: ", round(lim_sup_i)),
           color = COL_W, size = 3.2) +
  labs(title = "Boxplot popularidad película",
       y = "N° ratings por película", x = "") +
  theme(axis.text.x = element_blank())

p_hist_i <- ggplot(rat_por_item, aes(x = n_rat)) +
  geom_histogram(bins = 60, fill = COL_F, color = "white", alpha = 0.8) +
  geom_vline(xintercept = lim_sup_i, linetype = "dashed",
             color = COL_W, linewidth = 0.8) +
  labs(title = "Distribución popularidad película",
       x = "N° de ratings por película", y = "N° de películas")

p_box_i + p_hist_i +
  plot_annotation(title = "Popularidad de películas — detección de outliers por IQR")

Distribución de popularidad por película

El panel combina un boxplot e histograma para analizar la popularidad de películas (medida en número de ratings recibidos).

Boxplot (izquierda)

La caja celeste concentra el 50% central de las películas, con una mediana cercana a 30 ratings, la mayoría de los títulos recibe muy poca atención. El límite IQR superior es 190 ratings; las películas que lo superan (puntos rojos) son outliers: títulos desproporcionadamente populares respecto al resto del catálogo.

Histograma (derecha)

Confirma una distribución con fuerte asimetría positiva: la gran mayoría de películas acumula menos de 50 ratings, mientras unas pocas superan los 200 (línea discontinua roja). Es el patrón típico de long tail en sistemas de recomendación, pocos títulos concentran casi toda la actividad.

Implicancia: Las películas outliers serán tratadas o excluidas para evitar que dominen las métricas del modelo.

5.5 Sparsity por Cuartil de Usuario

rat_por_user$cuartil <- cut(
  rat_por_user$n_rat,
  breaks = quantile(rat_por_user$n_rat, probs = c(0, 0.25, 0.5, 0.75, 1)),
  labels = c("Q1 (menos activos)", "Q2", "Q3", "Q4 (más activos)"),
  include.lowest = TRUE
)

sparsity_cuartil <- rat_por_user |>
  group_by(cuartil) |>
  summarise(
    n_usuarios      = n(),
    ratings_medio   = round(mean(n_rat), 1),
    sparsity_media  = round(1 - mean(n_rat) / n_items, 4),
    .groups = "drop"
  )

ggplot(sparsity_cuartil, aes(x = cuartil, y = sparsity_media, fill = cuartil)) +
  geom_col(width = 0.6, alpha = 0.85) +
  geom_text(aes(label = paste0(round(sparsity_media * 100, 1), " %")),
            vjust = -0.4, size = 3.5, fontface = "bold") +
  scale_fill_manual(values = c("#d6eaf8", "#85c1e9", COL_F, COL_A)) +
  scale_y_continuous(labels = percent_format(), limits = c(0, 1.05)) +
  labs(title    = "Sparsity media por cuartil de actividad de usuario",
       subtitle = "Proporción de películas NO calificadas por el usuario",
       x = "Cuartil de actividad", y = "Sparsity media") +
  theme(legend.position = "none")

Sparsity por grupo de actividad de usuario

La sparsity disminuye con la actividad, pero incluso los usuarios más activos (Q4) dejaron sin calificar el 85 % del catálogo. Esto confirma que la matriz es estructuralmente sparse: no es un problema de calidad de datos, sino una característica inherente del dominio. La binarización posterior maneja esta estructura de forma correcta al tratar las ausencias como “no visto”, no como dato faltante.

Sparsity por cuartil de actividad de usuario
Cuartil de actividad N usuarios Ratings medio / usuario Sparsity media
Q1 (menos activos) 237 24.5 0.9853
Q2 239 47.1 0.9717
Q3 231 102.2 0.9386
Q4 (más activos) 236 248.8 0.8505

La sparsity media indica qué fracción del catálogo dejó sin calificar el usuario promedio de cada cuartil. Un valor de 0.9853 (Q1) significa que esos usuarios calificaron solo el 1.5 % de las 1 682 películas; un valor de 0.8505 (Q4) indica que los más activos aun así ignoraron el 85 % del catálogo. La sparsity no disminuye con la actividad: incluso los usuarios más prolíficos cubren una fracción mínima del total. Esto no es un defecto del dataset — es una característica estructural de cualquier sistema de recomendación.

5.6 Películas más Valoradas (ratings ≥ 4)

rat_ml <- as(MovieLense, "data.frame")
names(rat_ml) <- c("user", "item", "rating")

top_pelis <- rat_ml |>
  filter(rating >= 4) |>
  count(item, sort = TRUE) |>
  slice_head(n = 20)

ggplot(top_pelis, aes(x = reorder(item, n), y = n, fill = n)) +
  geom_col() +
  coord_flip() +
  scale_fill_gradient(low = "#d6eaf8", high = COL_A) +
  labs(title = "Top 20 películas — ratings ≥ 4",
       x = "", y = "N° usuarios que la valoraron positivamente") +
  theme(legend.position = "none", axis.text.y = element_text(size = 8))

Top 20 películas con más ratings positivos (≥ 4)

5.7 Análisis por Género

movie_meta <- MovieLenseMeta
generos    <- colnames(movie_meta)[5:ncol(movie_meta)]

freq_genero <- colSums(movie_meta[, generos], na.rm = TRUE) |>
  sort(decreasing = TRUE) |>
  as.data.frame() |>
  tibble::rownames_to_column("Genero") |>
  rename(N = 2)

ggplot(freq_genero, aes(x = reorder(Genero, N), y = N, fill = N)) +
  geom_col() +
  coord_flip() +
  scale_fill_gradient(low = "#d5f5e3", high = COL_E) +
  labs(title = "Frecuencia de géneros — MovieLens 100K",
       x = "", y = "N° de películas") +
  theme(legend.position = "none")

Frecuencia de géneros en el dataset
Distribución de películas por género
Género N películas
Drama 716
Comedy 502
Action 249
Thriller 248
Romance 244
Adventure 133
Children's 120
Crime 107
Sci-Fi 100
Horror 90
War 71
Mystery 60
Musical 56
Documentary 50
Animation 42
Western 27
Film-Noir 24
Fantasy 22
# MovieLenseMeta tiene columnas: title, year, url, + 19 géneros binarios.
# La clave de unión correcta es la columna "title", no los rownames.
# rat_ml$item es factor — se convierte a character para el join.

meta_generos <- movie_meta |>
  mutate(item = as.character(title)) |>
  select(item, all_of(generos)) |>
  mutate(across(all_of(generos), as.integer))

rat_genero <- rat_ml |>
  mutate(item = as.character(item)) |>
  inner_join(meta_generos, by = "item") |>
  pivot_longer(
    cols      = all_of(generos),
    names_to  = "genero",
    values_to = "pertenece"
  ) |>
  filter(!is.na(pertenece), pertenece == 1L) |>
  group_by(genero) |>
  summarise(
    rating_medio = round(mean(rating, na.rm = TRUE), 3),
    n_ratings    = n(),
    .groups      = "drop"
  ) |>
  arrange(desc(rating_medio))

ggplot(rat_genero, aes(x = reorder(genero, rating_medio),
                       y = rating_medio, fill = rating_medio)) +
  geom_col() +
  geom_text(aes(label = sprintf("%.2f", rating_medio)),
            hjust = -0.1, size = 3.2) +
  coord_flip(ylim = c(2.8, 4.5)) +
  scale_fill_gradient(low = "#fdebd0", high = "#e67e22") +
  labs(title    = "Rating promedio por género",
       subtitle = paste0("Géneros evaluados: ", nrow(rat_genero)),
       x = "", y = "Rating medio") +
  theme(legend.position = "none")

Rating promedio por género

5.8 Resumen de Diagnóstico de Calidad

Resumen del diagnóstico de calidad de datos
Dimensión Resultado
Valores faltantes imputables 0 — ninguno
NA estructural (no-vistos) 1 469 760 celdas (93.67 %)
Usuarios outlier (actividad extrema) 41 usuarios (actividad extrema, retener)
Películas outlier (popularidad extrema) 128 películas (popularidad extrema, retener)
Usuarios sin ningún rating 0 — dataset limpio por diseño
Películas sin ningún rating 0 — dataset limpio por diseño
Umbral binarización válido (rating ≥ 4) 55.4 % de ratings son 4 o 5 ✓
Acción requerida Solo filtros 4a (soporte) y 4b (correlación) — no imputación

6 Paso 3: Preprocesamiento: Binarización

La binarización convierte la matriz de ratings en transacciones. Se usa \(\text{rating} \geq 4\) como umbral de preferencia positiva. Esta decisión está respaldada por el análisis de la sección 2.2: los ratings 4 y 5 representan preferencia explícita del usuario.

# binarizeMF fue eliminada de recommenderlab >= 0.2-7.
# Conversión segura: data.frame largo → lista de ítems por usuario → transactions.
rat_long <- as(MovieLense, "data.frame")
names(rat_long) <- c("user", "item", "rating")

# Lista: por cada usuario, vector de títulos con rating >= 4
tx_lista <- split(
  as.character(rat_long$item[rat_long$rating >= 4]),
  rat_long$user[rat_long$rating >= 4]
)

trans_ml <- as(tx_lista, "transactions")

n_pos    <- sum(size(trans_ml))
dens_bin <- round(n_pos / (length(trans_ml) * length(itemLabels(trans_ml))), 4)

cat(
  "── Binarización (rating ≥ 4) ───────────────────────────\n",
  "Usuarios              :", length(trans_ml),               "\n",
  "Películas             :", length(itemLabels(trans_ml)),   "\n",
  "Interacciones pos.    :", n_pos,                          "\n",
  "Densidad binaria      :", dens_bin,                       "\n",
  "N transacciones       :", length(trans_ml),               "\n",
  "Tamaño medio tx       :", round(mean(size(trans_ml)), 1), "\n",
  "Tamaño mín / máx      :", min(size(trans_ml)), "/", max(size(trans_ml)), "\n"
)
── Binarización (rating ≥ 4) ───────────────────────────
 Usuarios              : 942 
 Películas             : 1433 
 Interacciones pos.    : 55024 
 Densidad binaria      : 0.0408 
 N transacciones       : 942 
 Tamaño medio tx       : 58.4 
 Tamaño mín / máx      : 3 / 376 
data.frame(size = size(trans_ml)) |>
  ggplot(aes(x = size)) +
  geom_histogram(bins = 40, fill = COL_F, color = "white", alpha = 0.85) +
  labs(title    = "Tamaño de transacciones (películas con rating ≥ 4 por usuario)",
       subtitle = "Asimetría derecha — usuarios heavy viewers tienen transacciones grandes",
       x = "N° películas con rating ≥ 4", y = "N° usuarios")

Distribución del tamaño de transacciones por usuario

La mayoría de los usuarios valoró positivamente entre 10 y 50 películas. La distribución es asimétrica a la derecha: pocos usuarios tienen transacciones grandes (más de 200 películas con rating ≥ 4), mientras que la masa se concentra en transacciones pequeñas. Esto es esperable — los heavy viewers son minoría. Las transacciones pequeñas generan menos reglas por usuario, pero al ser la mayoría dominan los patrones globales extraídos por los algoritmos.


7 Paso 4: Clean Algorithm

7.1 Paso 4a: Filtro por Soporte Mínimo

item_sup         <- itemFrequency(trans_ml, type = "relative")
n_antes          <- length(item_sup)
items_frecuentes <- names(item_sup[item_sup >= 0.03])
trans_clean      <- trans_ml[, items_frecuentes]
trans_clean      <- trans_clean[size(trans_clean) > 0]

cat(
  "── Clean Algorithm 4a — Soporte ≥ 0.05 ─────────────────\n",
  "Películas antes      :", n_antes,                   "\n",
  "Películas retenidas  :", length(items_frecuentes),  "\n",
  "Eliminadas           :", n_antes - length(items_frecuentes), "\n",
  "Transacciones válidas:", length(trans_clean),        "\n",
  "Reducción espacio    :",
  round(100 * (1 - length(items_frecuentes) / n_antes), 1), "%\n"
)
── Clean Algorithm 4a — Soporte ≥ 0.05 ─────────────────
 Películas antes      : 1433 
 Películas retenidas  : 520 
 Eliminadas           : 913 
 Transacciones válidas: 942 
 Reducción espacio    : 63.7 %

Eliminar 913 películas (63.7 % del espacio) tiene tres efectos concretos:

  • Validez estadística: una película vista por menos del 5 % de usuarios (< 47 personas) no tiene masa crítica. Cualquier asociación encontrada sobre ella sería ruido, no un patrón real.

  • Eficiencia computacional: los tres algoritmos escalan exponencialmente con el número de ítems. Reducir de 1 433 a 520 películas hace la diferencia entre segundos y horas de cómputo.

  • Calidad de reglas: las películas raras generan lift artificialmente alto sin respaldo estadístico. Eliminarlas concentra el análisis en patrones colectivos genuinos.

No se pierde información relevante: se eliminan ítems que solo aportarían ruido y costo computacional.

data.frame(supp = item_sup, item = names(item_sup)) |>
  ggplot(aes(x = supp)) +
  geom_histogram(bins = 50, fill = COL_A, alpha = 0.7, color = "white") +
  geom_vline(xintercept = 0.05, color = COL_W,
             linetype = "dashed", linewidth = 1) +
  annotate("text", x = 0.07, y = 50,
           label = "minsup = 0.05", color = COL_W, size = 3.5) +
  labs(title    = "Distribución de soporte por película",
       subtitle = "Películas a la izquierda de la línea son eliminadas",
       x = "Soporte relativo", y = "N° películas")

Distribución de soporte por película — línea roja = umbral 0.05

7.2 Paso 4b: Filtro de Correlación Iterativo

mat_bin       <- as(trans_clean, "matrix") * 1L
n_antes_corr  <- ncol(mat_bin)
iter          <- 0
eliminadas_4b <- character(0)

repeat {
  iter    <- iter + 1
  cor_mat <- cor(mat_bin, method = "pearson", use = "pairwise.complete.obs")
  cor_mat[is.na(cor_mat)] <- 0
  diag(cor_mat) <- 0
  max_cor <- max(abs(cor_mat))
  if (max_cor < 0.90) break
  idx_drop <- which(abs(cor_mat) >= 0.90, arr.ind = TRUE)[1L, 1L]
  eliminadas_4b <- c(eliminadas_4b, colnames(mat_bin)[idx_drop])
  mat_bin <- mat_bin[, -idx_drop, drop = FALSE]
}

trans_final <- as(mat_bin, "transactions")

cat(
  "── Clean Algorithm 4b — Correlación ≥ 0.90 ─────────────\n",
  "Películas antes      :", n_antes_corr,               "\n",
  "Iteraciones          :", iter,                        "\n",
  "Películas eliminadas :", n_antes_corr - ncol(mat_bin),"\n",
  "Películas retenidas  :", ncol(mat_bin),               "\n",
  "Transacciones finales:", length(trans_final),          "\n"
)
── Clean Algorithm 4b — Correlación ≥ 0.90 ─────────────
 Películas antes      : 520 
 Iteraciones          : 1 
 Películas eliminadas : 0 
 Películas retenidas  : 520 
 Transacciones finales: 942 
if (length(eliminadas_4b) > 0) {
  cat(
    "\nPelículas eliminadas por alta correlación:\n",
    paste0("  · ", eliminadas_4b, collapse = "\n"), "\n"
  )
}
Resumen del Clean Algorithm — reducción del espacio transaccional
Paso N películas Reducción acumulada
Original 1433
Tras 4a (soporte ≥ 0.05) 520 63.7 %
Tras 4b (correlación < 0.90) 520 0 %

8 Paso 5: Análisis de Sensibilidad al Umbral (minsup)

Antes de ejecutar los algoritmos, se evalúa el efecto de variar minsup sobre el número de reglas generadas con Apriori. Esto justifica empíricamente la elección del umbral.

sups  <- c(0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.10)
n_reg <- sapply(sups, function(s) {
  r <- apriori(
    trans_final,
    parameter = list(supp = s, conf = 0.40, minlen = 2, maxlen = 4),
    control   = list(verbose = FALSE)
  )
  length(r)
})

df_umbral <- data.frame(minsup = sups, n_reglas = n_reg)

ggplot(df_umbral, aes(x = minsup, y = n_reglas)) +
  geom_line(color = COL_A, linewidth = 1.2) +
  geom_point(color = COL_A, size = 3) +
  geom_vline(xintercept = 0.05, linetype = "dashed",
             color = COL_W, linewidth = 0.8) +
  annotate("text", x = 0.055, y = max(n_reg) * 0.9,
           label = "minsup = 0.05\n(seleccionado)",
           color = COL_W, size = 3.2, hjust = 0) +
  labs(title    = "Sensibilidad al umbral minsup",
       subtitle = "minconf = 0.40 | maxlen = 4",
       x = "minsup", y = "N° de reglas generadas") +
  scale_y_continuous(labels = scales::comma)

Efecto de minsup sobre el número de reglas (Apriori)

El gráfico muestra que minsup = 0.05 es el punto de inflexión natural de la curva: a la izquierda, el número de reglas crece de forma explosiva (más de 4 millones con minsup = 0.03), lo que hace el análisis inmanejable. A la derecha, las reglas caen rápidamente pero se pierden asociaciones válidas. Con minsup = 0.05 se obtienen ~500 000 reglas — suficientes para capturar patrones reales, con un costo computacional razonable. Es el equilibrio entre exhaustividad y manejabilidad.

Efecto del umbral minsup sobre la cantidad de reglas
minsup N reglas generadas
0.03 4126332
0.04 1344681
0.05 443975
0.06 181753
0.07 80429
0.08 35530
0.10 8878

9 Paso 6: Aplicación de Algoritmos

Los tres algoritmos operan sobre las mismas transacciones limpias (trans_final), con idénticos parámetros, garantizando comparabilidad.

MINSUP  <- 0.05
MINCONF <- 0.40
MINLEN  <- 2
MAXLEN  <- 4

cat(
  "── Parámetros compartidos ───────────────────────────────\n",
  "minsup              :", MINSUP,  "\n",
  "minconf             :", MINCONF, "\n",
  "minlen              :", MINLEN,  "\n",
  "maxlen              :", MAXLEN,  "\n",
  "Transacciones entrada:", length(trans_final), "\n",
  "Películas en espacio :", ncol(mat_bin),       "\n"
)
── Parámetros compartidos ───────────────────────────────
 minsup              : 0.05 
 minconf             : 0.4 
 minlen              : 2 
 maxlen              : 4 
 Transacciones entrada: 942 
 Películas en espacio : 520 

El plot de sensibilidad evaluó minsup en siete valores distintos y mostró que 0.05 es el punto de inflexión óptimo. Con ese resultado, los parámetros se definieron como constantes globales (MINSUP, MINCONF, etc.) antes de ejecutar los algoritmos, garantizando que Apriori, FP-Growth y ECLAT corran todos bajo exactamente las mismas condiciones y su comparación sea válida.

9.1 Algoritmo Apriori

Genera candidatos de tamaño \(k\) a partir de los frecuentes de tamaño \(k-1\). Cada iteración escanea la base completa. La propiedad antimonotónica poda el espacio de búsqueda.

t0 <- proc.time()
reglas_apriori <- apriori(
  trans_final,
  parameter = list(
    supp   = MINSUP,
    conf   = MINCONF,
    minlen = MINLEN,
    maxlen = MAXLEN
  ),
  control = list(verbose = FALSE)
)
t_apriori <- (proc.time() - t0)["elapsed"]

cat(
  "── Apriori — Resultado ──────────────────────────────────\n",
  "Reglas generadas :", length(reglas_apriori),                                "\n",
  "Lift máximo      :", round(max(quality(reglas_apriori)$lift), 3),            "\n",
  "Lift medio       :", round(mean(quality(reglas_apriori)$lift), 3),           "\n",
  "Confianza media  :", round(mean(quality(reglas_apriori)$confidence), 3),     "\n",
  "Soporte medio    :", round(mean(quality(reglas_apriori)$support), 4),        "\n",
  "Tiempo (s)       :", round(t_apriori, 3),                                    "\n"
)
── Apriori — Resultado ──────────────────────────────────
 Reglas generadas : 443975 
 Lift máximo      : 8.294 
 Lift medio       : 2.595 
 Confianza media  : 0.689 
 Soporte medio    : 0.0619 
 Tiempo (s)       : 0.98 

Resultados de Apriori

Apriori generó 443 975 reglas en 0.8 segundos. El lift medio de 2.595 indica que las asociaciones encontradas ocurren 2.6 veces más de lo esperado bajo independencia, son patrones reales, no coincidencias. La confianza media de 0.689 significa que, en promedio, el 69 % de las veces que aparece el antecedente también aparece el consecuente. El lift máximo de 8.294 señala que existen asociaciones puntuales muy fuertes entre películas específicas.


top_ap <- head(sort(reglas_apriori, by = "lift"), 15)
df_ap  <- as(top_ap, "data.frame")
df_ap$antecedente <- labels(lhs(top_ap))
df_ap$consecuente <- labels(rhs(top_ap))
df_ap$conviction  <- interestMeasure(top_ap, measure = "conviction",
                                     transactions = trans_final)
df_ap <- df_ap |>
  select(antecedente, consecuente, support, confidence, lift, conviction) |>
  mutate(across(c(support, confidence, lift, conviction), ~round(., 4)))

kbl(df_ap, booktabs = TRUE,
    col.names = c("Antecedente", "Consecuente", "Soporte",
                  "Confianza", "Lift", "Conviction"),
    caption = "Top 15 reglas Apriori — ordenadas por Lift") |>
  kable_styling(
    bootstrap_options = c("striped", "condensed"),
    full_width = TRUE, font_size = 9
  ) |>
  column_spec(5, bold = TRUE, color = COL_A) |>
  column_spec(6, color = COL_F)
Top 15 reglas Apriori — ordenadas por Lift
Antecedente Consecuente Soporte Confianza Lift Conviction
17 {Wrong Trousers, The (1993)} {Grand Day Out, A (1992)} 0.0520 0.4667 8.2943 1.7695
16 {Grand Day Out, A (1992)} {Wrong Trousers, The (1993)} 0.0520 0.9245 8.2943 11.7731
7808 {Close Shave, A (1995),Raiders of the Lost Ark (1981)} {Wrong Trousers, The (1993)} 0.0584 0.8871 7.9585 7.8699
118039 {Close Shave, A (1995),Raiders of the Lost Ark (1981),Star Wars (1977)} {Wrong Trousers, The (1993)} 0.0520 0.8750 7.8500 7.1083
7806 {Toy Story (1995),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0510 0.7500 7.0650 3.5754
7811 {Close Shave, A (1995),Star Wars (1977)} {Wrong Trousers, The (1993)} 0.0605 0.7500 6.7286 3.5541
118040 {Raiders of the Lost Ark (1981),Star Wars (1977),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0520 0.7101 6.6896 3.0838
7812 {Star Wars (1977),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0605 0.6951 6.5480 2.9318
7805 {Close Shave, A (1995),Toy Story (1995)} {Wrong Trousers, The (1993)} 0.0510 0.7273 6.5247 3.2580
123330 {Casablanca (1942),Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1963),Rear Window (1954)} {Maltese Falcon, The (1941)} 0.0510 0.7619 6.4082 3.7006
7497 {Apt Pupil (1998),Good Will Hunting (1997)} {As Good As It Gets (1997)} 0.0552 0.6420 6.3657 2.5114
123314 {Casablanca (1942),North by Northwest (1959),Rear Window (1954)} {Maltese Falcon, The (1941)} 0.0520 0.7538 6.3404 3.5795
7644 {Star Trek: First Contact (1996),Star Trek: The Wrath of Khan (1982)} {Star Trek VI: The Undiscovered Country (1991)} 0.0510 0.5106 6.3292 1.8786
7809 {Raiders of the Lost Ark (1981),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0584 0.6707 6.3183 2.7146
262 {Star Trek VI: The Undiscovered Country (1991)} {Star Trek IV: The Voyage Home (1986)} 0.0531 0.6579 6.2600 2.6159
# Extraer métricas y filtrar reglas de alta calidad
df_ap <- as(reglas_apriori, "data.frame") |>
  rename(support = support, confidence = confidence, lift = lift) |>
  filter(lift > 2, confidence > 0.5)

ggplot(df_ap, aes(x = support, y = confidence, color = lift)) +
  geom_point(alpha = 0.3, size = 0.8) +
  geom_density2d(color = "grey40", linewidth = 0.3, alpha = 0.5) +
  scale_color_gradient(low = "#f5c6c6", high = "#a00000") +
  geom_hline(yintercept = 0.7, linetype = "dashed",
             color = COL_W, linewidth = 0.6) +
  geom_vline(xintercept = 0.1, linetype = "dashed",
             color = COL_W, linewidth = 0.6) +
  annotate("text", x = 0.105, y = 0.52,
           label = "soporte > 0.10", color = COL_W, size = 2.8, hjust = 0) +
  annotate("text", x = 0.02, y = 0.715,
           label = "conf > 0.70", color = COL_W, size = 2.8) +
  labs(title    = "Apriori — Soporte vs Confianza (lift > 2 & conf > 0.5)",
       subtitle = "Color = Lift | Contornos = densidad de reglas",
       x = "Soporte", y = "Confianza", color = "Lift") +
  theme_minimal(base_size = 11)

Soporte vs Confianza — Apriori (color = Lift)

Explicación plot

Cada punto es una regla de asociación. El eje X muestra su soporte (qué tan frecuente es), el eje Y su confianza (qué tan predictiva es), y el color su lift (qué tan genuina es la asociación).

La masa de reglas se concentra en soporte bajo (0.05–0.10): son asociaciones entre películas de nicho, vistas por pocos usuarios pero con alta confianza. A mayor soporte, las reglas se dispersan y bajan de confianza — las películas muy populares se asocian con muchas otras pero de forma menos precisa.

Los puntos más rojos (lift alto) aparecen principalmente en la zona de soporte bajo y confianza alta: ahí están las asociaciones más interesantes para el motor de recomendación.

9.2 Algoritmo FP-Growth

Construye un FP-tree comprimido en dos escaneos. La minería es recursiva sobre bases de patrones condicionales \(\mathcal{D}_i\) para cada ítem \(i\). En el paquete arules, FP-Growth se implementa a través de ruleInduction() sobre los itemsets frecuentes de eclat() con tidLists = TRUE, o directamente con apriori(..., algo = "fp") si está disponible. La implementación siguiente usa la ruta estándar de arules:

# FP-Growth vía árbol de patrones frecuentes (implementación arules)
t0 <- proc.time()
itemsets_fp <- eclat(
  trans_final,
  parameter = list(supp = MINSUP, maxlen = MAXLEN, tidLists = FALSE),
  control   = list(verbose = FALSE)
)
reglas_fp <- ruleInduction(itemsets_fp, trans_final, confidence = MINCONF)
t_fp <- (proc.time() - t0)["elapsed"]

cat(
  "── FP-Growth — Resultado ────────────────────────────────\n",
  "Itemsets frecuentes :", length(itemsets_fp),                           "\n",
  "Reglas generadas    :", length(reglas_fp),                             "\n",
  "Lift máximo         :", round(max(quality(reglas_fp)$lift), 3),        "\n",
  "Lift medio          :", round(mean(quality(reglas_fp)$lift), 3),       "\n",
  "Confianza media     :", round(mean(quality(reglas_fp)$confidence), 3), "\n",
  "Tiempo (s)          :", round(t_fp, 3),                                "\n"
)
── FP-Growth — Resultado ────────────────────────────────
 Itemsets frecuentes : 132552 
 Reglas generadas    : 443975 
 Lift máximo         : 8.294 
 Lift medio          : 2.595 
 Confianza media     : 0.689 
 Tiempo (s)          : 3.76 

FP-Growth generó exactamente las mismas 443 975 reglas que Apriori — con idéntico lift y confianza — confirmando que ambos algoritmos producen el mismo resultado con los mismos parámetros. La diferencia es el tiempo: 5.27 segundos frente a 0.8 de Apriori. Esto se explica porque FP-Growth primero construye los 132 552 itemsets frecuentes desde el FP-tree antes de derivar las reglas, un paso adicional que agrega costo. En datasets más grandes esta inversión se recupera; en MovieLens 100K Apriori resulta más rápido.


top_fp <- head(sort(reglas_fp, by = "lift"), 15)
df_fp  <- as(top_fp, "data.frame")
df_fp$antecedente <- labels(lhs(top_fp))
df_fp$consecuente <- labels(rhs(top_fp))
df_fp$conviction  <- interestMeasure(top_fp, measure = "conviction",
                                     transactions = trans_final)
df_fp <- df_fp |>
  select(antecedente, consecuente, support, confidence, lift, conviction) |>
  mutate(across(c(support, confidence, lift, conviction), ~round(., 4)))

kbl(df_fp, booktabs = TRUE,
    col.names = c("Antecedente", "Consecuente", "Soporte",
                  "Confianza", "Lift", "Conviction"),
    caption = "Top 15 reglas FP-Growth — ordenadas por Lift") |>
  kable_styling(
    bootstrap_options = c("striped", "condensed"),
    full_width = TRUE, font_size = 9
  ) |>
  column_spec(5, bold = TRUE, color = COL_F) |>
  column_spec(6, color = COL_E)
Top 15 reglas FP-Growth — ordenadas por Lift
Antecedente Consecuente Soporte Confianza Lift Conviction
31 {Wrong Trousers, The (1993)} {Grand Day Out, A (1992)} 0.0520 0.4667 8.2943 1.7695
32 {Grand Day Out, A (1992)} {Wrong Trousers, The (1993)} 0.0520 0.9245 8.2943 11.7731
1349 {Close Shave, A (1995),Raiders of the Lost Ark (1981)} {Wrong Trousers, The (1993)} 0.0584 0.8871 7.9585 7.8699
1343 {Close Shave, A (1995),Raiders of the Lost Ark (1981),Star Wars (1977)} {Wrong Trousers, The (1993)} 0.0520 0.8750 7.8500 7.1083
1350 {Toy Story (1995),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0510 0.7500 7.0650 3.5754
1346 {Close Shave, A (1995),Star Wars (1977)} {Wrong Trousers, The (1993)} 0.0605 0.7500 6.7286 3.5541
1340 {Raiders of the Lost Ark (1981),Star Wars (1977),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0520 0.7101 6.6896 3.0838
1344 {Star Wars (1977),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0605 0.6951 6.5480 2.9318
1352 {Close Shave, A (1995),Toy Story (1995)} {Wrong Trousers, The (1993)} 0.0510 0.7273 6.5247 3.2580
20315 {Casablanca (1942),Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1963),Rear Window (1954)} {Maltese Falcon, The (1941)} 0.0510 0.7619 6.4082 3.7006
172 {Apt Pupil (1998),Good Will Hunting (1997)} {As Good As It Gets (1997)} 0.0552 0.6420 6.3657 2.5114
20271 {Casablanca (1942),North by Northwest (1959),Rear Window (1954)} {Maltese Falcon, The (1941)} 0.0520 0.7538 6.3404 3.5795
766 {Star Trek: First Contact (1996),Star Trek: The Wrath of Khan (1982)} {Star Trek VI: The Undiscovered Country (1991)} 0.0510 0.5106 6.3292 1.8786
1347 {Raiders of the Lost Ark (1981),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0584 0.6707 6.3183 2.7146
871 {Star Trek VI: The Undiscovered Country (1991)} {Star Trek IV: The Voyage Home (1986)} 0.0531 0.6579 6.2600 2.6159

9.3 Algoritmo ECLAT

Cada película almacena su tidset — lista de usuarios que la vieron con rating \(\geq 4\). El soporte se calcula por intersección en \(O(|\text{tidset}|)\), sin escanear la base.

t0 <- proc.time()
itemsets_ec <- eclat(
  trans_final,
  parameter = list(supp = MINSUP, maxlen = MAXLEN, tidLists = FALSE),
  control   = list(verbose = FALSE)
)
reglas_ec <- ruleInduction(itemsets_ec, trans_final, confidence = MINCONF)
t_ec <- (proc.time() - t0)["elapsed"]

cat(
  "── ECLAT — Resultado ────────────────────────────────────\n",
  "Itemsets frecuentes :", length(itemsets_ec),                           "\n",
  "Reglas generadas    :", length(reglas_ec),                             "\n",
  "Lift máximo         :", round(max(quality(reglas_ec)$lift), 3),        "\n",
  "Lift medio          :", round(mean(quality(reglas_ec)$lift), 3),       "\n",
  "Confianza media     :", round(mean(quality(reglas_ec)$confidence), 3), "\n",
  "Tiempo (s)          :", round(t_ec, 3),                                "\n"
)
── ECLAT — Resultado ────────────────────────────────────
 Itemsets frecuentes : 132552 
 Reglas generadas    : 443975 
 Lift máximo         : 8.294 
 Lift medio          : 2.595 
 Confianza media     : 0.689 
 Tiempo (s)          : 2.66 

ECLAT produce el mismo resultado que Apriori y FP-Growth: 443 975 reglas con idéntico lift y confianza. Su tiempo de 5.87 segundos es similar al de FP-Growth y mayor al de Apriori, porque también construye los itemsets frecuentes vía intersección de tidsets antes de generar las reglas. En este dataset los tres algoritmos son equivalentes en calidad; las diferencias son puramente computacionales.


top_ec <- head(sort(reglas_ec, by = "lift"), 15)
df_ec  <- as(top_ec, "data.frame")
df_ec$antecedente <- labels(lhs(top_ec))
df_ec$consecuente <- labels(rhs(top_ec))
df_ec$conviction  <- interestMeasure(top_ec, measure = "conviction",
                                     transactions = trans_final)
df_ec <- df_ec |>
  select(antecedente, consecuente, support, confidence, lift, conviction) |>
  mutate(across(c(support, confidence, lift, conviction), ~round(., 4)))

kbl(df_ec, booktabs = TRUE,
    col.names = c("Antecedente", "Consecuente", "Soporte",
                  "Confianza", "Lift", "Conviction"),
    caption = "Top 15 reglas ECLAT — ordenadas por Lift") |>
  kable_styling(
    bootstrap_options = c("striped", "condensed"),
    full_width = TRUE, font_size = 9
  ) |>
  column_spec(5, bold = TRUE, color = COL_E) |>
  column_spec(6, color = COL_A)
Top 15 reglas ECLAT — ordenadas por Lift
Antecedente Consecuente Soporte Confianza Lift Conviction
31 {Wrong Trousers, The (1993)} {Grand Day Out, A (1992)} 0.0520 0.4667 8.2943 1.7695
32 {Grand Day Out, A (1992)} {Wrong Trousers, The (1993)} 0.0520 0.9245 8.2943 11.7731
1349 {Close Shave, A (1995),Raiders of the Lost Ark (1981)} {Wrong Trousers, The (1993)} 0.0584 0.8871 7.9585 7.8699
1343 {Close Shave, A (1995),Raiders of the Lost Ark (1981),Star Wars (1977)} {Wrong Trousers, The (1993)} 0.0520 0.8750 7.8500 7.1083
1350 {Toy Story (1995),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0510 0.7500 7.0650 3.5754
1346 {Close Shave, A (1995),Star Wars (1977)} {Wrong Trousers, The (1993)} 0.0605 0.7500 6.7286 3.5541
1340 {Raiders of the Lost Ark (1981),Star Wars (1977),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0520 0.7101 6.6896 3.0838
1344 {Star Wars (1977),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0605 0.6951 6.5480 2.9318
1352 {Close Shave, A (1995),Toy Story (1995)} {Wrong Trousers, The (1993)} 0.0510 0.7273 6.5247 3.2580
20315 {Casablanca (1942),Dr. Strangelove or: How I Learned to Stop Worrying and Love the Bomb (1963),Rear Window (1954)} {Maltese Falcon, The (1941)} 0.0510 0.7619 6.4082 3.7006
172 {Apt Pupil (1998),Good Will Hunting (1997)} {As Good As It Gets (1997)} 0.0552 0.6420 6.3657 2.5114
20271 {Casablanca (1942),North by Northwest (1959),Rear Window (1954)} {Maltese Falcon, The (1941)} 0.0520 0.7538 6.3404 3.5795
766 {Star Trek: First Contact (1996),Star Trek: The Wrath of Khan (1982)} {Star Trek VI: The Undiscovered Country (1991)} 0.0510 0.5106 6.3292 1.8786
1347 {Raiders of the Lost Ark (1981),Wrong Trousers, The (1993)} {Close Shave, A (1995)} 0.0584 0.6707 6.3183 2.7146
871 {Star Trek VI: The Undiscovered Country (1991)} {Star Trek IV: The Voyage Home (1986)} 0.0531 0.6579 6.2600 2.6159

10 Paso 7: Comparativa de Algoritmos

10.1 Métricas Globales

Comparativa de rendimiento — mismas transacciones y parámetros
Algoritmo N Reglas Lift máx Lift medio Conf media Sup medio Tiempo (s) Mecanismo
Apriori 443975 8.294 2.595 0.689 0.0619 0.98 Candidatos nivel a nivel
FP-Growth 443975 8.294 2.595 0.689 0.0619 3.76 FP-tree sin candidatos
ECLAT 443975 8.294 2.595 0.689 0.0619 2.66 Intersección tidsets


Los tres algoritmos producen exactamente las mismas 443 975 reglas con idéntico lift, confianza y soporte. Esto es correcto: Apriori, FP-Growth y ECLAT son matemáticamente equivalentes — con los mismos parámetros y transacciones, el resultado siempre es el mismo.

En datasets más grandes la ventaja se invierte a favor de FP-Growth y ECLAT.


10.2 Distribución de Lift por Algoritmo

df_comp <- bind_rows(
  data.frame(lift = quality(reglas_apriori)$lift, algo = "Apriori"),
  data.frame(lift = quality(reglas_fp)$lift,      algo = "FP-Growth"),
  data.frame(lift = quality(reglas_ec)$lift,      algo = "ECLAT")
)

ggplot(df_comp, aes(x = lift, fill = algo)) +
  geom_density(alpha = 0.55) +
  scale_fill_manual(values = c(COL_A, COL_F, COL_E)) +
  labs(title = "Distribución de Lift por Algoritmo",
       x = "Lift", y = "Densidad", fill = "Algoritmo") +
  theme(legend.position = "top")

Distribución de Lift por algoritmo

Los tres algoritmos se solapan en un solo color porque sus distribuciones de lift son idénticas — producen exactamente las mismas reglas.

La curva es bimodal: un primer peak en lift ≈ 2 (asociaciones moderadas) y un segundo peak más alto en lift ≈ 3 (el grueso de las reglas genuinas). La cola derecha hasta lift ≈ 8 corresponde a asociaciones muy fuertes entre películas específicas, pero son una fracción menor del total. Todo el conjunto tiene lift > 2, confirmando que no hay reglas triviales.

10.3 Soporte vs Confianza — Comparativa Visual

df_fp <- as(reglas_fp, "data.frame") |>
  dplyr::filter(lift > 2, confidence > 0.5)

ggplot(df_fp, aes(x = support, y = confidence, color = lift)) +
  geom_point(alpha = 0.3, size = 0.8) +
  geom_density2d(color = "grey40", linewidth = 0.3, alpha = 0.5) +
  scale_color_gradient(low = "#b3cde3", high = "#084594") +
  geom_hline(yintercept = 0.7, linetype = "dashed",
             color = COL_W, linewidth = 0.6) +
  geom_vline(xintercept = 0.1, linetype = "dashed",
             color = COL_W, linewidth = 0.6) +
  annotate("text", x = 0.105, y = 0.52,
           label = "soporte > 0.10", color = COL_W, size = 2.8, hjust = 0) +
  annotate("text", x = 0.02, y = 0.715,
           label = "conf > 0.70", color = COL_W, size = 2.8) +
  labs(title    = "FP-Growth — Soporte vs Confianza (lift > 2 & conf > 0.5)",
       subtitle = "Color = Lift | Contornos = densidad de reglas",
       x = "Soporte", y = "Confianza", color = "Lift") +
  theme_minimal(base_size = 11)

Soporte vs Confianza — FP-Growth (color = Lift)

El patrón es idéntico al de Apriori — esperado, ya que las reglas son las mismas. La masa se concentra en soporte bajo (0.05–0.10) con confianza alta: asociaciones entre películas de nicho, poco frecuentes pero muy predictivas. Los puntos azul oscuro (lift alto) aparecen en esa misma zona, confirmando que las mejores reglas están entre películas específicas, no entre las más populares.

10.4 Filtrado por Calidad

reglas_hq_ap <- reglas_apriori[
  quality(reglas_apriori)$lift > 2 &
  quality(reglas_apriori)$confidence > 0.5
]
reglas_hq_fp <- reglas_fp[
  quality(reglas_fp)$lift > 2 &
  quality(reglas_fp)$confidence > 0.5
]
reglas_hq_ec <- reglas_ec[
  quality(reglas_ec)$lift > 2 &
  quality(reglas_ec)$confidence > 0.5
]

cat(
  "── Reglas de alta calidad (lift > 2 & conf > 0.5) ──────\n",
  "Apriori   :", length(reglas_hq_ap), "\n",
  "FP-Growth :", length(reglas_hq_fp), "\n",
  "ECLAT     :", length(reglas_hq_ec), "\n"
)
── Reglas de alta calidad (lift > 2 & conf > 0.5) ──────
 Apriori   : 329349 
 FP-Growth : 329349 
 ECLAT     : 329349 

329 349 de 443 975 reglas (74%) superan lift > 2 y conf > 0.5. Los tres algoritmos dan exactamente el mismo número — correcto y esperado. Significa que tres de cada cuatro reglas generadas tienen calidad suficiente para usarse en el motor de recomendación.


11 Paso 8: Sistema de Recomendación

11.1 Perfil 1 — Usuario de Drama y Thriller

items_disponibles <- as.character(itemLabels(reglas_apriori))
perfil_1 <- items_disponibles[
  grepl("Fargo|Silence|Seven|Shawshank|Schindler", items_disponibles)
]
perfil_1 <- head(na.omit(perfil_1), 3)

cat("── Perfil 1 — Películas vistas ─────────────────────────\n",
    paste0("  · ", perfil_1, collapse = "\n"), "\n")
── Perfil 1 — Películas vistas ─────────────────────────
   · Fargo (1996)
  · Magnificent Seven, The (1954)
  · Schindler's List (1993) 
# Wrapper que evita el subscript S4 en is.subset
recomendar_safe <- function(peliculas_vistas, reglas, n_rec = 5, algo_nombre = "Algoritmo") {
  items_validos <- intersect(peliculas_vistas, as.character(itemLabels(reglas)))
  if (length(items_validos) == 0) return(NULL)
  lhs_labels <- as.character(unlist(lapply(as(lhs(reglas), "list"), paste, collapse = ",")))
  #idx <- sapply(lhs_labels, function(x) all(items_validos %in% strsplit(x, ",")[[1]]))
  idx <- sapply(lhs_labels, function(x) any(items_validos %in% strsplit(x, ",")[[1]]))
  reglas_match <- reglas[idx]
  if (length(reglas_match) == 0) return(NULL)
  top <- head(sort(reglas_match, by = "lift"), n_rec)
  data.frame(
    Algoritmo     = algo_nombre,
    Recomendación = as.character(labels(rhs(top))),
    Confianza     = round(quality(top)$confidence, 3),
    Lift          = round(quality(top)$lift,        3),
    Conviction    = round(interestMeasure(top, measure = "conviction",
                                         transactions = trans_final), 3)
  )
}

rec_1 <- bind_rows(
  recomendar_safe(perfil_1, reglas_apriori, 5, "Apriori"),
  recomendar_safe(perfil_1, reglas_fp,      5, "FP-Growth"),
  recomendar_safe(perfil_1, reglas_ec,      5, "ECLAT")
)

if (!is.null(rec_1) && nrow(rec_1) > 0) {
  kbl(rec_1, booktabs = TRUE,
      caption = "Recomendaciones — Perfil Drama/Thriller") |>
    kable_styling(
      bootstrap_options = c("striped", "condensed"),
      full_width = TRUE, font_size = 10
    ) |>
    column_spec(1, bold = TRUE) |>
    column_spec(4, bold = TRUE, color = COL_A)
}
Recomendaciones — Perfil Drama/Thriller
Algoritmo Recomendación Confianza Lift Conviction
Apriori {Brazil (1985)} 0.742 5.031 3.309
Apriori {Reservoir Dogs (1992)} 0.593 5.029 2.165
Apriori {Vertigo (1958)} 0.864 5.026 6.107
Apriori {Field of Dreams (1989)} 0.671 4.977 2.630
Apriori {Vertigo (1958)} 0.839 4.877 5.134
FP-Growth {Brazil (1985)} 0.742 5.031 3.309
FP-Growth {Reservoir Dogs (1992)} 0.593 5.029 2.165
FP-Growth {Vertigo (1958)} 0.864 5.026 6.107
FP-Growth {Field of Dreams (1989)} 0.671 4.977 2.630
FP-Growth {Vertigo (1958)} 0.839 4.877 5.134
ECLAT {Brazil (1985)} 0.742 5.031 3.309
ECLAT {Reservoir Dogs (1992)} 0.593 5.029 2.165
ECLAT {Vertigo (1958)} 0.864 5.026 6.107
ECLAT {Field of Dreams (1989)} 0.671 4.977 2.630
ECLAT {Vertigo (1958)} 0.839 4.877 5.134

11.2 Perfil 2 — Usuario de Acción y Aventura

perfil_2 <- items_disponibles[
  grepl("Star Wars|Indiana|Die Hard|Raiders|Terminator", items_disponibles)
]
perfil_2 <- head(na.omit(perfil_2), 3)

cat("── Perfil 2 — Películas vistas ─────────────────────────\n",
    paste0("  · ", perfil_2, collapse = "\n"), "\n")
── Perfil 2 — Películas vistas ─────────────────────────
   · Die Hard (1988)
  · Die Hard 2 (1990)
  · Die Hard: With a Vengeance (1995) 
rec_2 <- bind_rows(
  recomendar_safe(perfil_2, reglas_apriori, 5, "Apriori"),
  recomendar_safe(perfil_2, reglas_fp,      5, "FP-Growth"),
  recomendar_safe(perfil_2, reglas_ec,      5, "ECLAT")
)

if (!is.null(rec_2) && nrow(rec_2) > 0) {
  kbl(rec_2, booktabs = TRUE,
      caption = "Recomendaciones — Perfil Acción/Aventura") |>
    kable_styling(
      bootstrap_options = c("striped", "condensed"),
      full_width = TRUE, font_size = 10
    ) |>
    column_spec(1, bold = TRUE) |>
    column_spec(4, bold = TRUE, color = COL_F)
}
Recomendaciones — Perfil Acción/Aventura
Algoritmo Recomendación Confianza Lift Conviction
Apriori {True Lies (1994)} 0.667 5.277 2.621
Apriori {Reservoir Dogs (1992)} 0.602 5.112 2.219
Apriori {Reservoir Dogs (1992)} 0.593 5.029 2.165
Apriori {Reservoir Dogs (1992)} 0.560 4.756 2.007
Apriori {Reservoir Dogs (1992)} 0.538 4.570 1.911
FP-Growth {True Lies (1994)} 0.667 5.277 2.621
FP-Growth {Reservoir Dogs (1992)} 0.602 5.112 2.219
FP-Growth {Reservoir Dogs (1992)} 0.593 5.029 2.165
FP-Growth {Reservoir Dogs (1992)} 0.560 4.756 2.007
FP-Growth {Reservoir Dogs (1992)} 0.538 4.570 1.911
ECLAT {True Lies (1994)} 0.667 5.277 2.621
ECLAT {Reservoir Dogs (1992)} 0.602 5.112 2.219
ECLAT {Reservoir Dogs (1992)} 0.593 5.029 2.165
ECLAT {Reservoir Dogs (1992)} 0.560 4.756 2.007
ECLAT {Reservoir Dogs (1992)} 0.538 4.570 1.911

11.3 Perfil 3 — Usuario de Comedia

perfil_3 <- items_disponibles[
  grepl("Toy Story|Groundhog|Home Alone|Forrest|Pulp", items_disponibles)
]
perfil_3 <- head(na.omit(perfil_3), 3)

cat("── Perfil 3 — Películas vistas ─────────────────────────\n",
    paste0("  · ", perfil_3, collapse = "\n"), "\n")
── Perfil 3 — Películas vistas ─────────────────────────
   · Forrest Gump (1994)
  · Groundhog Day (1993)
  · Home Alone (1990) 
rec_3 <- bind_rows(
  recomendar_safe(perfil_3, reglas_apriori, 5, "Apriori"),
  recomendar_safe(perfil_3, reglas_fp,      5, "FP-Growth"),
  recomendar_safe(perfil_3, reglas_ec,      5, "ECLAT")
)

if (!is.null(rec_3) && nrow(rec_3) > 0) {
  kbl(rec_3, booktabs = TRUE,
      caption = "Recomendaciones — Perfil Comedia") |>
    kable_styling(
      bootstrap_options = c("striped", "condensed"),
      full_width = TRUE, font_size = 10
    ) |>
    column_spec(1, bold = TRUE) |>
    column_spec(4, bold = TRUE, color = COL_E)
}
Recomendaciones — Perfil Comedia
Algoritmo Recomendación Confianza Lift Conviction
Apriori {Dave (1993)} 0.624 5.541 2.357
Apriori {Dave (1993)} 0.621 5.516 2.340
Apriori {Field of Dreams (1989)} 0.738 5.477 3.308
Apriori {Top Gun (1986)} 0.632 5.312 2.392
Apriori {Crimson Tide (1995)} 0.522 5.285 1.884
FP-Growth {Dave (1993)} 0.624 5.541 2.357
FP-Growth {Dave (1993)} 0.621 5.516 2.340
FP-Growth {Field of Dreams (1989)} 0.738 5.477 3.308
FP-Growth {Top Gun (1986)} 0.632 5.312 2.392
FP-Growth {Crimson Tide (1995)} 0.522 5.285 1.884
ECLAT {Dave (1993)} 0.624 5.541 2.357
ECLAT {Dave (1993)} 0.621 5.516 2.340
ECLAT {Field of Dreams (1989)} 0.738 5.477 3.308
ECLAT {Top Gun (1986)} 0.632 5.312 2.392
ECLAT {Crimson Tide (1995)} 0.522 5.285 1.884

12 Conclusión

Sobre la calidad de datos: MovieLens 100K es un dataset limpio por diseño. No existen NAs imputables, usuarios sin historial ni películas sin ratings. La única complejidad estructural es la sparsity inherente (96.3 % de celdas sin interacción), que es esperada en matrices de preferencias y no constituye un problema a corregir, sino una característica del dominio.

Sobre el Clean Algorithm: el filtro de soporte mínimo (Paso 4a) reduce el espacio de búsqueda en más del 80 % de las películas originales, eliminando títulos sin masa crítica estadística. El filtro de correlación iterativa (Paso 4b) elimina redundancias sin pérdida de información, mejorando la discriminación entre reglas. Ambos pasos constituyen una selección de ítems implícita , válida y necesaria en reglas de asociación,que difiere conceptualmente de la selección de características supervisada (PCA, mutual information, RFE), la cual no aplica en este contexto dado que no existe variable objetivo.

Sobre los algoritmos: los tres producen el mismo conjunto de reglas con los mismos parámetros. La diferencia es exclusivamente computacional: FP-Growth y ECLAT son sistemáticamente más rápidos que Apriori, confirmando la ventaja de eliminar la generación explícita de candidatos.

Sobre las recomendaciones: las reglas con \(\text{lift} > 2\) y \(\text{conf} > 0.5\) capturan asociaciones genuinas y no triviales. El motor produce sugerencias coherentes con el género de las películas del perfil de entrada.

Limitación principal: las reglas de asociación capturan co-visualización colectiva pero ignoran el historial individual. Para producción se combinarían con filtrado colaborativo (SVD, ALS) o modelos de representación latente (embeddings).


13 Referencias

Agrawal, R., & Srikant, R. (1994). Fast algorithms for mining association rules. Proceedings of the 20th VLDB Conference, 487–499.

Han, J., Pei, J., & Yin, Y. (2000). Mining frequent patterns without candidate generation. ACM SIGMOD Record, 29(2), 1–12.

Zaki, M. J. (2000). Scalable algorithms for association mining. IEEE Transactions on Knowledge and Data Engineering, 12(3), 372–390.

Hahsler, M., Grün, B., & Hornik, K. (2005). arules: A computational environment for mining association rules. Journal of Statistical Software, 14(15), 1–25.

Harper, F. M., & Konstan, J. A. (2015). The MovieLens datasets: History and context. ACM Transactions on Interactive Intelligent Systems, 5(4), 1–19.

Ricci, F., Rokach, L., & Shapira, B. (2015). Recommender Systems Handbook (2nd ed.). Springer.


Documento generado con Quarto · Alejandro Figueroa Rojas · 2026