11  Sistemas de Recomendación

En la era del streaming y el comercio electrónico, la oferta supera infinitamente nuestra capacidad de consumo. Nadie puede evaluar de forma manual los más de 100 millones de canciones en Spotify o los más de 2.600 millones de productos en Amazon (según estimaciones de 2026). Aquí entran los Sistemas de Recomendación: algoritmos diseñados para filtrar la sobrecarga de información y predecir la preferencia de un usuario por un ítem que nunca ha visto.

Lo que hace especialmente interesantes a los sistemas de recomendación es que, vistos desde la perspectiva adecuada, son exactamente el mismo problema que ya hemos estudiado en el Análisis de Redes Sociales: predicción de enlaces en un grafo.

La pregunta “¿Qué película le gustaría a Ana?” y la pregunta “¿Qué nodo se conectará con Ana en el futuro?” son matemáticamente equivalentes.


11.1 1. El Concepto del Long Tail

La mayoría de los mercados físicos están limitados por el espacio (estanterías). Esto obliga a vender solo los Best Sellers. En el mundo digital, el coste de almacenamiento tiende a cero, lo que permite ofrecer millones de productos de nicho.

La teoría del Long Tail [@anderson2006] postula que la suma de ventas de estos productos de nicho puede igualar o superar a los superventas. El problema es: ¿cómo encuentran los usuarios esos nichos? La respuesta son los motores de recomendación (ya sean colaborativos, basados en contenido o híbridos), y la herramienta natural para modelar y unificar todas estas relaciones de forma flexible es la teoría de grafos.

NotaDel mercado físico al digital

Un videoclub solo podía tener las 500 películas más populares (espacio limitado). Netflix tiene más de 15.000 títulos. El reto ya no es la oferta, sino la selección: hacer que cada usuario encuentre su película entre miles de opciones de nicho que le resultarán mucho más satisfactorias que el éxito de turno.

¿Videoclub?: Historia de Blockbuster, los videoclubes y el alquiler de VHS. “Espacio físico” en las estanterías de los años 90 versus al coste marginal cero del almacenamiento digital.

Simulamos este fenómeno con datos reales de MovieLens. ¿Cuánto peso tiene realmente la “cabeza” del catálogo?

# Estadísticas de MovieLens 100K (simuladas con sus parámetros reales)
set.seed(2026)
n_items_lt    <- 1682
n_ratings_lt  <- 100000

# Distribución realista: Ley de Potencia (Zipf's Law) con alfa = 1.1
ranks <- 1:n_items_lt
popularidad_lt <- 1 / (ranks^1.1)
popularidad_lt <- sort(popularidad_lt, decreasing = TRUE)
popularidad_lt <- round(popularidad_lt / sum(popularidad_lt) * n_ratings_lt)

# Ajuste por redondeo para que la suma sea exactamente n_ratings_lt
popularidad_lt[1] <- popularidad_lt[1] + (n_ratings_lt - sum(popularidad_lt))

# ¿Qué % de interacciones acapara el top 10% de películas?
n_top10    <- round(n_items_lt * 0.10)
rating_top <- sum(head(popularidad_lt, n_top10))
rating_tot <- sum(popularidad_lt)

cat("=== Concentración de popularidad en MovieLens ===\n")
=== Concentración de popularidad en MovieLens ===
cat("Top 10% del catálogo (", n_top10, "películas) acapara:",
    round(rating_top / rating_tot * 100, 1), "% de todas las interacciones\n")
Top 10% del catálogo ( 168 películas) acapara: 78.9 % de todas las interacciones
cat("El 90% restante (", n_items_lt - n_top10, "películas) solo recibe:",
    round((1 - rating_top/rating_tot) * 100, 1), "% de las interacciones\n")
El 90% restante ( 1514 películas) solo recibe: 21.1 % de las interacciones
cat("Películas con 5 o menos valoraciones (invisibles sin recomendador):",
    sum(popularidad_lt <= 5), "de", n_items_lt)
Películas con 5 o menos valoraciones (invisibles sin recomendador): 181 de 1682
# Creamos el dataframe con el ranking de popularidad
df_lt <- data.frame(
  Ranking = 1:n_items_lt,
  Ratings = popularidad_lt
) %>%
  mutate(
    Segmento = ifelse(Ranking <= n_top10, "Cabeza (Top 10%)", "Larga Cola (Resto 90%)")
  )

# Graficamos la curva pintando la Cabeza y la Cola de diferentes colores
ggplot(df_lt, aes(x = Ranking, y = Ratings)) +
  geom_area(aes(fill = Segmento), alpha = 0.85) +
  scale_fill_manual(
    values = c("Cabeza (Top 10%)" = "#E63946", "Larga Cola (Resto 90%)" = "#457B9D")
  ) +
  annotate("text", x = 110, y = max(popularidad_lt) * 0.6, 
           label = "Cabeza\n(78.9% de los ratings)", color = "#FFFFFF", fontface = "bold", size = 3.5) +
  annotate("text", x = 900, y = max(popularidad_lt) * 0.15, 
           label = "Larga Cola (Nicho)\n(21.1% de los ratings)", color = "#1D3557", fontface = "bold", size = 3.5) +
  labs(
    title = "Curva de Popularidad del Catálogo: The Long Tail",
    subtitle = "Simulación basada en la Ley de Potencia (Zipf) de MovieLens 100K",
    x = "Productos (Ordenados de Más a Menos Populares)",
    y = "Número de Valoraciones (Ratings)",
    fill = "Segmento del Catálogo"
  ) +
  theme_minimal(base_size = 11) +
  theme(
    legend.position = "bottom",
    plot.title = element_text(face = "bold", size = 13, color = "#1D3557"),
    plot.subtitle = element_text(color = "#457B9D", size = 10),
    panel.grid.minor = element_blank(),
    axis.text = element_text(color = "#333333"),
    axis.title = element_text(face = "bold", color = "#1D3557")
  )
Figura 11.1: Distribución de popularidad del catálogo (Long Tail) en MovieLens

📌 Insight: El 10% del catálogo acapara prácticamente el 80% de todas las valoraciones. Un sistema que solo recomienda la cabeza es útil para el negocio a corto plazo, pero ignora a cientos de creadores de contenido de nicho y empobrece la experiencia del usuario. El desafío de los sistemas de recomendación es hacer visible esa larga cola sin sacrificar relevancia.


11.2 2. Filtrado Colaborativo (Collaborative Filtering)

La intuición detrás de este enfoque es tan natural como el propio refranero popular: “Dime con quién andas y te diré quién eres”. Trasladado al mundo de la recomendación, esto significa que si descubrimos con qué otros usuarios compartes gustos (“con quién andas”), podremos predecir con gran precisión qué cosas te van a gustar en el futuro (“quién eres”).

Si Juan y Ana han coincidido valorando positivamente Star Wars y Matrix, y Ana acaba de ver Dune y le ha encantado, es muy probable que a Juan también le guste Dune. Matemáticamente, trabajamos con una matriz de utilidad \(R\) donde \(r_{ui}\) es la valoración del usuario \(u\) por el ítem \(i\) [@herlocker2004]. Esta matriz es extremadamente dispersa (a menudo más del 99% de ceros o NAs).

11.2.1 Implementación Manual: User-Based CF

Vamos a construir un recomendador desde cero en R, usando álgebra lineal pura.

Paso 1: La Matriz de Valoraciones

# Matriz de usuarios (filas) x Películas (columnas)
# Escala de 1 a 5. NA indica "no visto".
R_df <- tribble(
  ~User, ~Matrix, ~StarWars, ~Titanic, ~Amelie, ~Inception,
  "Ana",      5,        5,       1,      NA,        5,
  "Beto",     5,        4,       1,       2,        5,
  "Carla",    1,        1,       5,       4,        1,
  "David",   NA,        5,       2,      NA,        4, # Usuario nuevo similar a Ana/Beto
  "Elena",    1,        1,       5,       5,       NA  # Usuario similar a Carla
)

# Convertimos a matriz numérica para operar
M <- R_df |> select(-User) |> as.matrix()
rownames(M) <- R_df$User
print(M)
      Matrix StarWars Titanic Amelie Inception
Ana        5        5       1     NA         5
Beto       5        4       1      2         5
Carla      1        1       5      4         1
David     NA        5       2     NA         4
Elena      1        1       5      5        NA

Paso 2: Medir la Similitud

Para encontrar “vecinos”, necesitamos una métrica de distancia. La Correlación de Pearson es estándar porque maneja bien las diferencias de escala (usuarios que siempre votan alto vs críticos duros).

\[Sim(u, v) = \frac{\sum (r_{ui} - \bar{r}_u)(r_{vi} - \bar{r}_v)}{\sqrt{\sum (r_{ui} - \bar{r}_u)^2} \sqrt{\sum (r_{vi} - \bar{r}_v)^2}}\]

# La Correlación de Pearson maneja NAs con "pairwise.complete.obs"
# Transponemos porque cor() calcula correlación entre COLUMNAS (ítems)
# y nosotros queremos correlación entre FILAS (usuarios)
similitud_usuarios <- function(M) {
  sim <- cor(t(M), use = "pairwise.complete.obs")
  sim[is.na(sim)] <- 0
  return(sim)
}

S_user <- similitud_usuarios(M)
kable(S_user, digits = 2, caption = "Matriz de Similitud entre Usuarios")
Matriz de Similitud entre Usuarios
Ana Beto Carla David Elena
Ana 1.00 0.97 -1.00 0.94 -1.00
Beto 0.97 1.00 -0.97 0.84 -0.95
Carla -1.00 -0.97 1.00 -0.94 0.98
David 0.94 0.84 -0.94 1.00 -1.00
Elena -1.00 -0.95 0.98 -1.00 1.00

Observemos la matriz: * Ana y Beto tienen similitud casi perfecta (\(\approx 0.97\)): gustos perfectamente alineados. * Ana y Carla tienen similitud negativa (\(\approx -1.00\)): son opuestas en todo.

Paso 3: Predicción de Votos

Para predecir cómo valoraría Ana la película Amelie (que no ha visto), hacemos una media ponderada de las desviaciones respecto a las medias de los otros usuarios, usando la similitud como peso. Esto corrige el sesgo individual (usuarios que votan sistemáticamente alto o bajo).

\[\hat{r}_{ui} = \bar{r}_u + \frac{\sum_{v \in Vecinos} Sim(u, v) \cdot (r_{vi} - \bar{r}_v)}{\sum_{v \in Vecinos} |Sim(u, v)|}\]

predecir_voto <- function(M, S, usuario, item) {
  idx_u <- which(rownames(M) == usuario)
  idx_i <- which(colnames(M) == item)
  
  if (!is.na(M[idx_u, idx_i])) return(M[idx_u, idx_i])
  
  # Medias de votación de cada usuario (ignorando NAs)
  medias   <- rowMeans(M, na.rm = TRUE)
  media_u  <- medias[idx_u]
  
  otros_votos  <- M[, idx_i]
  similitudes  <- S[idx_u, ]
  validos      <- !is.na(otros_votos) & (rownames(M) != usuario)
  
  if (sum(validos) == 0) return(NA)
  
  # Centramos los votos de los vecinos respecto a sus propias medias
  votos_centrados <- otros_votos[validos] - medias[validos]
  
  numerador   <- sum(similitudes[validos] * votos_centrados)
  denominador <- sum(abs(similitudes[validos]))
  
  if (denominador == 0) return(media_u)
  
  # Predicción final centrada en la media del usuario objetivo
  prediccion <- media_u + (numerador / denominador)
  
  # Acotamos los resultados al rango real de valoraciones [1, 5]
  return(max(1, min(5, prediccion)))
}

pred_ana_amelie      <- predecir_voto(M, S_user, "Ana", "Amelie")
pred_elena_inception <- predecir_voto(M, S_user, "Elena", "Inception")

cat("Predicción para Ana → Amelie:     ", round(pred_ana_amelie, 2), "\n")
Predicción para Ana → Amelie:      2.33 
cat("Predicción para Elena → Inception:", round(pred_elena_inception, 2), "\n")
Predicción para Elena → Inception: 1.93 

El sistema predice un 2.33 para Ana y Amelie.

  • Su vecino más cercano (Beto) la valoró con un 2 (por debajo de su media personal de 3.40), lo que resta puntos a la predicción de Ana.

  • Además, sus “anti-vecinos” (Carla, Elena) la valoran muy alto (4 y 5, muy por encima de sus medias), pero al tener similitudes opuestas (negativas), el sistema invierte esa preferencia convirtiéndola en una penalización.

  • Así, infiere correctamente que a Ana no le gustará el cine romántico francés si prefiere la ciencia ficción.

Por otro lado, para Elena y Inception, el sistema predice un 1.67.

  • Su vecina más cercana, Carla (similitud de 0.98), valoró Inception con un 1 (muy por debajo de su media de 2.40), lo que arrastra con fuerza la predicción de Elena hacia abajo.

  • Además, sus “anti-vecinos” (Ana y Beto, con similitudes de -1.00 y -0.99) valoraron la película con un 5 (máxima nota, muy por encima de sus medias), pero al ser opuestos, esa opinión positiva se invierte penalizando aún más a Elena.

  • El resultado final (1.67 frente a su media de 3.00) es totalmente coherente con el perfil de Elena, que detesta los blockbusters de ciencia ficción (habiendo puntuado con un 1 a Matrix y Star Wars).

11.2.2 Implementación Manual: Item-Based CF

A menudo, la base de usuarios cambia rápidamente, pero el catálogo de productos es estable. Amazon patentó el Item-Based Filtering (“Usuarios que compraron esto también compraron…”). La lógica es la misma, pero transpuesta: calculamos la similitud entre ítems (columnas), no entre usuarios.

S_item <- cor(M, use = "pairwise.complete.obs")
S_item[is.na(S_item)] <- 0
kable(S_item, digits = 2, caption = "Similitud entre Películas")
Similitud entre Películas
Matrix StarWars Titanic Amelie Inception
Matrix 1.00 0.98 -1.00 -0.94 1.00
StarWars 0.98 1.00 -0.94 -0.94 0.91
Titanic -1.00 -0.94 1.00 0.94 -1.00
Amelie -0.94 -0.94 0.94 1.00 -1.00
Inception 1.00 0.91 -1.00 -1.00 1.00

Vemos claramente dos clústeres:

  1. Blockbusters/Acción: Matrix, StarWars, Inception (altamente correlacionadas).
  2. Drama/Romance: Titanic, Amelie.

Si a un usuario le gusta Matrix, el sistema le recomendará Inception (\(r \approx 1.0\)) antes que Titanic (\(r \approx -1.0\)).


11.3 3. Factorización de Matrices (SVD)

Los métodos anteriores son “basados en memoria” (usan toda la matriz en cada predicción). Pero existe un enfoque más potente y escalable: los Modelos de Factores Latentes.

La idea es que no necesitamos almacenar todas las interacciones. Podemos comprimir la información en características ocultas (latentes). Quizás a los usuarios les importan fundamentalmente dos dimensiones: “Nivel de Adrenalina” y “Profundidad Emocional”. Matemáticamente, aproximamos \(R \approx U \Sigma V^T\).

# 1. Imputación básica: Rellenamos NAs con la media de la columna
M_filled <- M
for(i in 1:ncol(M)) {
  M_filled[is.na(M_filled[,i]), i] <- mean(M_filled[,i], na.rm = TRUE)
}

# 2. Descomposición SVD con k=2 factores latentes
svd_result <- svd(M_filled)
k    <- 2
U_k  <- svd_result$u[, 1:k]
D_k  <- diag(svd_result$d[1:k])
V_k  <- svd_result$v[, 1:k]

# 3. Reconstrucción de la Matriz (Predicción)
M_pred <- U_k %*% D_k %*% t(V_k)
rownames(M_pred) <- rownames(M)
colnames(M_pred) <- colnames(M)

cat("Matriz Reconstruida (SVD k=2):\n")
Matriz Reconstruida (SVD k=2):
print(round(M_pred, 2))
      Matrix StarWars Titanic Amelie Inception
Ana     4.87     5.08    1.25   3.28      5.22
Beto    4.47     4.63    0.61   2.56      4.62
Carla   0.44     0.69    4.67   4.12      1.83
David   3.76     3.98    2.09   3.48      4.37
Elena   1.29     1.59    5.24   4.99      2.85
cat("\nSVD Predicción Ana → Amelie:", round(M_pred["Ana", "Amelie"], 2), "\n")

SVD Predicción Ana → Amelie: 3.28 

La SVD ha capturado la “esencia” de los gustos. Predice 1.81 para Ana y Amelie, ¡casi idéntico a nuestro cálculo manual de vecinos (1.83)! Esta técnica fue la base del ganador del famoso Netflix Prize [@koren2009] y es la raíz de los sistemas modernos.


11.4 4. La Perspectiva de Grafos: Un Cambio de Paradigma

Hasta ahora hemos tratado la recomendación como un problema de álgebra lineal sobre matrices. Pero hay una representación alternativa y más rica: los grafos. Este cambio de perspectiva no es solo estético; abre la puerta a algoritmos radicalmente más potentes.

La transición desde los métodos matriciales hacia los modelos basados en grafos representa un cambio de paradigma. Un grafo de recomendación no es simplemente un almacén de datos, sino una estructura dinámica que codifica preferencias latentes a través de su topología. Mientras que la factorización de matrices se enfoca en los valores de las interacciones, la recomendación en grafos se centra en la estructura relacional del sistema completo.

Enfoque Modelo de datos Pregunta que responde
User-Based CF Matriz \(U \times I\) ¿Qué usuarios son similares?
SVD Factores latentes \(U, \Sigma, V\) ¿Cuáles son las dimensiones ocultas?
Grafos Red de relaciones ¿Qué caminos conectan un usuario con un ítem?

11.4.1 El Grafo Bipartito como Piedra Angular

El grafo bipartito es la estructura fundamental de la recomendación en grafos. Se define como un grafo \(G = (V, E)\) donde el conjunto de vértices \(V\) se particiona en dos subconjuntos disjuntos \(U\) (usuarios) e \(I\) (ítems), de modo que cada arista \(e \in E\) conecta un nodo de \(U\) con un nodo de \(I\).

Esta estructura es equivalente a la matriz de biadyacencia \(A \in \{0,1\}^{|U| \times |I|}\), donde \(A_{ui} = 1\) si existe una interacción observada y \(A_{ui} = 0\) en caso contrario.

library(igraph)

# La misma información de la sección anterior, ahora como grafo bipartito
interacciones <- data.frame(
  usuario = c("Ana",   "Ana",   "Beto",   "Beto",   "Beto",  "Carla", "Carla",  "Carla",
              "David", "David", "Elena",  "Elena"),
  item    = c("Matrix","StarWars","Matrix","StarWars","Inception","Titanic","Amelie","Matrix",
              "StarWars","Inception","Titanic","Amelie"),
  # Interacción positiva = rating >= 4
  positiva = c(TRUE, TRUE, TRUE, TRUE, TRUE, FALSE, TRUE, FALSE,
                TRUE, TRUE, FALSE, TRUE)
) |> filter(positiva)

# Creamos el grafo bipartito
g_bip <- graph_from_data_frame(interacciones[, c("usuario","item")], directed = FALSE)

# CLAVE: añadir el atributo 'type' para identificar las dos particiones
V(g_bip)$type <- V(g_bip)$name %in% interacciones$usuario

cat("¿Es bipartito?", is_bipartite(g_bip), "\n")
¿Es bipartito? TRUE 
cat("Usuarios:", sum(!V(g_bip)$type), " | Ítems:", sum(V(g_bip)$type), "\n")
Usuarios: 4  | Ítems: 5 
library(ggraph)
library(tidygraph)

g_bip_tidy <- as_tbl_graph(g_bip) |>
  activate(nodes) |>
  mutate(
    tipo  = ifelse(type, "Ítem", "Usuario"),
    grado = centrality_degree()
  )

set.seed(42)
ggraph(g_bip_tidy, layout = "bipartite") +
  geom_edge_link(alpha = 0.4, color = "grey60", width = 0.8) +
  geom_node_point(aes(color = tipo, shape = tipo, size = grado)) +
  geom_node_text(aes(label = name), repel = TRUE, size = 3.5, fontface = "bold") +
  scale_color_manual(values = c("Usuario" = "#3B82F6", "Ítem" = "#F97316")) +
  scale_shape_manual(values = c("Usuario" = 15, "Ítem" = 16)) +
  scale_size_continuous(range = c(4, 10)) +
  theme_graph(base_family = "sans") +
  labs(title = "Grafo Bipartito de Recomendación",
       subtitle = "La estructura relacional que subyace a toda recomendación colaborativa",
       color = "Tipo de nodo", shape = "Tipo de nodo")
Figura 11.2: Grafo bipartito usuario-ítem. Los nodos cuadrados (azul) son usuarios y los nodos circulares (naranja) son películas. Cada arista representa una interacción positiva (rating ≥ 4).

¿Qué nos dice la topología de este grafo sobre los datos? Antes de pasar a los algoritmos, merece la pena leer la estructura en sí misma:

# Extraemos métricas estructurales básicas del grafo bipartito
metricas <- data.frame(
  nodo        = V(g_bip)$name,
  tipo        = ifelse(V(g_bip)$type, "Ítem", "Usuario"),
  grado       = degree(g_bip),
  es_hub      = degree(g_bip) >= 3
) |> arrange(tipo, desc(grado))

kable(metricas, caption = "Grado de cada nodo en el grafo bipartito")
Grado de cada nodo en el grafo bipartito
nodo tipo grado es_hub
StarWars StarWars Usuario 3 TRUE
Matrix Matrix Usuario 2 FALSE
Inception Inception Usuario 2 FALSE
Amelie Amelie Usuario 2 FALSE
Beto Beto Ítem 3 TRUE
Ana Ana Ítem 2 FALSE
David David Ítem 2 FALSE
Carla Carla Ítem 1 FALSE
Elena Elena Ítem 1 FALSE

📌 Lectura de resultados: Los ítems con mayor grado (más conexiones) son los hubs de la red: películas que han visto muchos tipos de usuarios diferentes. En nuestra red, Matrix y StarWars son los hubs principales. Esto tiene una consecuencia directa: un usuario nuevo que solo conoce StarWars está, a 2 saltos de distancia, de casi toda la red. Los hubs son las puertas de entrada naturales al catálogo. El ítem con menor grado, en cambio, solo es visible desde un único camino — el sistema de recomendación es su única esperanza de ser descubierto.

11.4.2 La Propiedad de Transitividad: Por qué los Grafos son Superiores

Uno de los desafíos persistentes en los modelos predictivos es la dispersión (sparsity). En conjuntos de datos masivos, la mayoría de los usuarios solo interactúan con una fracción minúscula del catálogo disponible, generando matrices con densidades a menudo inferiores al 1%.

Los sistemas basados en grafos mitigan este problema aprovechando la transitividad. Si el usuario \(u_1\) está conectado al ítem \(i_1\), y el ítem \(i_1\) ha sido consumido también por el usuario \(u_2\), quien a su vez consumió el ítem \(i_2\), el grafo permite inferir una conexión indirecta entre \(u_1\) e \(i_2\) a través de un camino de longitud tres.

Tipo de Relación Descripción Aplicación en Recomendación
Directa (Orden 1) Interacción explícita (clic, compra) Historial de consumo del usuario
Indirecta (Orden 2) Ítems similares por co-ocurrencia “Usuarios que compraron esto también compraron…”
Transmitida (Orden 3) Caminos Usuario→Ítem→Usuario→Ítem Descubrimiento de nuevos intereses latentes
Semántica (KG) Relaciones basadas en atributos Recomendación basada en contenido estructurado

Esta capacidad de explorar relaciones de “vecindad extendida” permite generar predicciones incluso cuando no existen similitudes directas obvias.

11.4.3 Proyecciones Unimodales

A partir del grafo bipartito podemos derivar dos grafos de proyección unimodales donde los nodos de un mismo tipo se conectan si comparten un vecino en la otra partición.

# bipartite_projection() genera UN grafo por cada partición
proyecciones <- bipartite_projection(g_bip)

# Proyección de USUARIOS (conectados si han visto las mismas películas)
g_usuarios <- proyecciones$proj1
g_items    <- proyecciones$proj2

aristas_usr <- as_data_frame(g_usuarios, what = "edges") |>
  arrange(desc(weight))

kable(aristas_usr, caption = "Proyección de usuarios: similitud colaborativa (peso = películas en común)")
Proyección de usuarios: similitud colaborativa (peso = películas en común)
from to weight
Matrix StarWars 2
StarWars Inception 2
Matrix Inception 1

📌 Lectura de resultados: El peso de cada arista indica cuántas películas han visto en común esos dos usuarios. El par con mayor peso tiene la similitud colaborativa más alta: si a uno le gusta algo nuevo, el otro es el primer candidato para recibirlo. Esta proyección es exactamente la misma similitud que calculamos a mano con Pearson en §2, pero ahora representada como estructura de red — lo que abre la puerta a todos los algoritmos de grafos que vienen a continuación.

# Identificamos el par más similar y el más distante
par_mas_similar  <- aristas_usr[1, ]
par_mas_distante <- aristas_usr[nrow(aristas_usr), ]

cat("Par con MAYOR similitud:",
    par_mas_similar$from, "y", par_mas_similar$to,
    "→", par_mas_similar$weight, "películas en común\n")
Par con MAYOR similitud: Matrix y StarWars → 2 películas en común
cat("Par con MENOR similitud:",
    par_mas_distante$from, "y", par_mas_distante$to,
    "→", par_mas_distante$weight, "película(s) en común\n")
Par con MENOR similitud: Matrix y Inception → 1 película(s) en común
g_usr_tidy <- as_tbl_graph(g_usuarios) |>
  activate(nodes) |>
  mutate(grado = centrality_degree())

set.seed(7)
ggraph(g_usr_tidy, layout = "fr") +
  geom_edge_link(aes(edge_width = weight, edge_alpha = weight), color = "#3B82F6") +
  geom_node_point(aes(size = grado), color = "#1E40AF") +
  geom_node_label(aes(label = name), repel = TRUE, size = 4, fontface = "bold",
                   fill = "white", color = "#1E40AF") +
  scale_edge_width_continuous(range = c(0.5, 3)) +
  theme_graph(base_family = "sans") +
  labs(title = "Proyección de Usuarios",
       subtitle = "Similitud colaborativa basada en co-consumo de ítems")
Figura 11.3: Proyección de usuarios: dos usuarios están conectados si han consumido al menos un ítem en común. El grosor de la arista es proporcional al número de ítems compartidos.

El peso de las aristas en estas proyecciones es un indicador directo de la similitud colaborativa y sirve como base para algoritmos de filtrado basados en vecindad.


11.5 5. Mecánicas Algorítmicas: Del PageRank al Paseo Aleatorio

El núcleo de la predicción en grafos reside en algoritmos que simulan el flujo de información o la navegación de un usuario a través de la red. Estos métodos transforman la estructura estática del grafo en una clasificación dinámica de relevancia.

11.5.1 PageRank Personalizado (PPR)

El algoritmo PageRank original mide la importancia de un nodo basándose en la cantidad y calidad de sus enlaces entrantes, asumiendo un modelo de “navegante aleatorio” que salta entre nodos con una probabilidad de amortiguamiento \(\alpha\) (generalmente 0.85). Para la recomendación, este modelo se transforma en el PageRank Personalizado (PPR).

En el PPR, cuando el navegante decide realizar un “salto aleatorio” (teletransporte), no va hacia cualquier nodo del grafo, sino que regresa obligatoriamente al nodo del usuario objetivo. La formulación matricial es:

\[\mathbf{r} = (1 - \alpha) \cdot \mathbf{M} \cdot \mathbf{r} + \alpha \cdot \mathbf{e}_u\]

Donde \(\mathbf{r}\) es el vector de puntuaciones de relevancia, \(\mathbf{M}\) es la matriz de transición de probabilidad y \(\mathbf{e}_u\) es el vector de personalización (un 1 en la posición del usuario objetivo, cero en el resto).

# Calculamos PageRank convencional (importancia global)
pr_global <- page_rank(g_bip, directed = FALSE)$vector

# Para PPR, simulamos el vector de personalización para "Ana"
# (solo regresamos al nodo "Ana")
idx_ana <- which(V(g_bip)$name == "Ana")
pers_vec <- rep(0, vcount(g_bip))
pers_vec[idx_ana] <- 1

ppr_ana <- page_rank(g_bip, directed = FALSE,
                     personalized = pers_vec)$vector

# Ordenamos los ítems por su relevancia para Ana
resultados_ppr <- data.frame(
  nodo      = V(g_bip)$name,
  tipo      = ifelse(V(g_bip)$type, "Ítem", "Usuario"),
  ppr_score = ppr_ana,
  pr_global = pr_global
) |>
  filter(tipo == "Ítem") |>
  arrange(desc(ppr_score))

kable(resultados_ppr, digits = 4,
      caption = "Relevancia PPR de cada película para el usuario Ana")
Relevancia PPR de cada película para el usuario Ana
nodo tipo ppr_score pr_global
Ana Ana Ítem 0.2788 0.0973
Beto Beto Ítem 0.1661 0.1387
David David Ítem 0.0957 0.0973
Carla Carla Ítem 0.0000 0.0856
Elena Elena Ítem 0.0000 0.0856

📌 Lectura de resultados: Fíjate en los datos de la tabla:

  • La película con mayor PPR para Ana debería ser alguna de las que ya vio (Matrix, StarWars, Inception) — el modelo confirma que las conoce bien y son relevantes en su entorno.
  • Entre los ítems no vistos, la puntuación PPR más baja corresponde a Titanic o Amelie, coherente con su perfil de amante de la ciencia ficción.
  • Compara la columna ppr_score con pr_global: el ranking global y el personalizado difieren significativamente. Ítems que son globalmente populares no necesariamente son relevantes para Ana, y viceversa.
# ¿Cuánto difiere el ranking PPR del global?
items_ppr <- resultados_ppr |>
  mutate(
    ranking_ppr    = rank(-ppr_score),
    ranking_global = rank(-pr_global),
    diferencia     = abs(ranking_ppr - ranking_global)
  ) |>
  arrange(ranking_ppr)

kable(items_ppr |> select(nodo, ppr_score, pr_global, ranking_ppr, ranking_global, diferencia),
      digits = 4,
      caption = "Comparativa: ¿cambia mucho el ranking personalizado vs el global para Ana?")
Comparativa: ¿cambia mucho el ranking personalizado vs el global para Ana?
nodo ppr_score pr_global ranking_ppr ranking_global diferencia
Ana Ana 0.2788 0.0973 1.0 2.5 1.5
Beto Beto 0.1661 0.1387 2.0 1.0 1.0
David David 0.0957 0.0973 3.0 2.5 0.5
Carla Carla 0.0000 0.0856 4.5 4.5 0.0
Elena Elena 0.0000 0.0856 4.5 4.5 0.0
Tip¿Por qué PPR es superior al PageRank global?

El PageRank global nos dice qué películas son populares en general (las que más usuarios han vieron). El PPR nos dice qué películas son relevantes para Ana en particular. La columna diferencia en la tabla anterior muestra numéricamente cuántas posiciones sube o baja cada ítem cuando la recomendación se personaliza. Cuanto mayor la diferencia, más valor añade la personalización.

11.5.2 Los Algoritmos SimRank Basados en Caminos

En la búsqueda de modelos que equilibren precisión con eficiencia computacional, han surgido algoritmos basados en caminos de longitud fija, específicamente de tres saltos (Usuario→Ítem→Usuario→Ítem).

El algoritmo \(P^3\) calcula la similitud entre un usuario \(u\) y un ítem \(i\) basándose en la probabilidad de llegar a \(i\) tras tres pasos aleatorios desde \(u\). Su refinamiento, denominado \(P^3\beta\), aborda un problema crítico: el sesgo hacia la popularidad. Los ítems populares tienden a atraer la mayor parte del “flujo” en un paseo aleatorio, resultando en recomendaciones monótonas de los mismos productos masivos.

\(P^3\beta\) introduce una penalización dividiendo las puntuaciones por el grado del ítem elevado a una potencia \(\beta\):

\[\text{score}_{P^3\beta}(u, i) = \frac{\sum_{\text{caminos}} \text{probabilidad del camino}}{d(i)^\beta}\]

Esto permite “enfriar” los nodos excesivamente conectados y dar visibilidad a la larga cola del catálogo, mejorando la diversidad y la serendipia de las sugerencias.

# Implementamos una versión simplificada de P3β sobre la proyección de usuarios
# El concepto: similitud entre usuario y película = Σ (1/d_u × 1/d_i × 1/d_u') 
# a lo largo de caminos U-I-U'-I' ajustados por el grado del ítem destino (^β)

# Extraemos la matriz de biadyacencia
# as_biadjacency_matrix() pone type=FALSE (ítems) en filas y type=TRUE (usuarios) en columnas,
# así que transponemos para que filas = usuarios y columnas = ítems
biadj <- t(as_biadjacency_matrix(g_bip, sparse = FALSE))

usuarios_g <- rownames(biadj)
items_g    <- colnames(biadj)

# Normalización por grado (fila = usuarios, columna = ítems)
d_u <- rowSums(biadj)
d_i <- colSums(biadj)

# Matriz de transición Usuario → Ítem  (dividir cada fila por su grado)
P_ui <- sweep(biadj, 1, d_u, "/")

# Matriz de transición Ítem → Usuario
P_iu <- t(sweep(biadj, 2, d_i, "/"))

# Camino de 3 pasos: U → I → U → I   (P3)
P3 <- P_ui %*% P_iu %*% P_ui

# P3β: penalizamos los ítems populares (β = 0.5)
beta <- 0.5
penalizacion <- matrix(d_i^beta, nrow = nrow(P3), ncol = ncol(P3), byrow = TRUE)
P3_beta <- P3 / penalizacion

# Recomendaciones para "Ana" (excluimos lo que ya vio)
ana_visto    <- biadj["Ana", ] > 0
scores_ana   <- P3_beta["Ana", ]
scores_ana[ana_visto] <- -Inf   # Excluir ítems ya conocidos

cat("Top recomendaciones P3β para Ana:\n")
Top recomendaciones P3β para Ana:
sort(scores_ana, decreasing = TRUE) |> head(5) |> print()
Inception    Amelie    Matrix  StarWars 
0.1571348 0.0000000      -Inf      -Inf 
# Calculamos también P3 PURO (sin penalización, β=0) para comparar
scores_p3_puro <- P3["Ana", ]
scores_p3_puro[ana_visto] <- -Inf

# Tabla comparativa: ¿qué cambia al introducir β?
comparacion_beta <- data.frame(
  item        = names(scores_p3_puro),
  popularidad = d_i[names(scores_p3_puro)],
  P3_puro     = round(scores_p3_puro, 5),
  P3_beta05   = round(scores_ana, 5)
) |>
  filter(!is.infinite(P3_puro)) |>
  arrange(desc(P3_puro)) |>
  mutate(
    rank_P3   = rank(-P3_puro),
    rank_beta = rank(-P3_beta05),
    cambio_posicion = rank_P3 - rank_beta
  )

kable(comparacion_beta,
      caption = "P3 puro vs P3β (β=0.5): efecto de la penalización de popularidad")
P3 puro vs P3β (β=0.5): efecto de la penalización de popularidad
item popularidad P3_puro P3_beta05 rank_P3 rank_beta cambio_posicion
Inception Inception 2 0.22222 0.15713 1 1 0
Amelie Amelie 2 0.00000 0.00000 2 2 0

📌 Insight clave: Observa la columna cambio_posicion. Los ítems populares (mayor popularidad) tienen cambio_posicion negativo: P3β los baja en el ranking. Los ítems de nicho (menor grado) suben posiciones. Este es exactamente el efecto serendipity que buscamos: con P3β, el sistema deja de recomendar siempre lo mismo a todo el mundo y empieza a dar visibilidad a la larga cola del catálogo.

11.5.3 Propagación de Influencia en Grafos Heterogéneos

Los grafos hasta ahora han sido homogéneos: todos los nodos son del mismo tipo (usuarios o ítems) o solo hay un tipo de relación. En la práctica, sin embargo, los sistemas de recomendación reales operan sobre grafos heterogéneos donde coexisten múltiples tipos de nodos y relaciones.

Imagina un grafo de recomendación de música que integra simultáneamente: - Nodos: Usuarios, Canciones, Artistas, Géneros, Playlists. - Relaciones: escucha, interpreta, pertenece_a, colabora_con, guardada_en.

En este entorno, la propagación de influencia no puede tratarse de forma uniforme. Una estrategia clave es el Meta-Path: una secuencia de tipos de nodos y relaciones que define un camino semánticamente significativo. Por ejemplo:

Meta-Path Semántica
Usuario → Canción → Artista → Canción Recomienda canciones del mismo artista que ya escuchas
Usuario → Canción → Género → Canción Recomienda canciones del mismo género
Usuario → Canción → Usuario → Canción Filtrado colaborativo clásico a través del grafo

El algoritmo HAN (Heterogeneous Attention Network) aprende automáticamente qué meta-paths son más relevantes para cada usuario, asignando pesos de atención diferencial a cada tipo de relación. Esto permite que el mismo modelo recomiende por similitud de género para un usuario explorador y por similitud de artista para un usuario fiel.

# Construimos un mini grafo heterogéneo ilustrativo
library(igraph)
library(ggraph)
library(tidygraph)

nodos_het <- data.frame(
  name = c("Ana", "Beto", "Song_A", "Song_B", "Song_C",
            "Artista_X", "Pop"),
  tipo = c("Usuario", "Usuario", "Canción", "Canción", "Canción",
            "Artista", "Género")
)

aristas_het <- data.frame(
  from = c("Ana",      "Beto",     "Song_A",    "Song_B",    "Song_C",    "Song_A", "Song_B"),
  to   = c("Song_A",  "Song_B",   "Artista_X", "Artista_X", "Artista_X", "Pop",    "Pop"),
  relacion = c("escucha", "escucha", "interpreta", "interpreta", "interpreta", "pertenece", "pertenece")
)

g_het <- graph_from_data_frame(aristas_het, directed = TRUE, vertices = nodos_het)

paleta_tipos <- c(
  "Usuario"  = "#3B82F6",
  "Canción"  = "#F97316",
  "Artista"  = "#10B981",
  "Género"   = "#8B5CF6"
)

set.seed(99)
as_tbl_graph(g_het) |>
  activate(nodes) |>
  mutate(color_tipo = paleta_tipos[tipo]) |>
  ggraph(layout = "fr") +
  geom_edge_link(aes(label = relacion, color = relacion),
                 arrow = arrow(length = unit(0.3, "cm"), type = "closed"),
                 angle_calc = "along", label_dodge = unit(3, "mm"),
                 end_cap = circle(6, "mm"), alpha = 0.8) +
  geom_node_point(aes(color = tipo), size = 10) +
  geom_node_text(aes(label = name), size = 3, fontface = "bold", color = "white") +
  scale_color_manual(values = paleta_tipos) +
  scale_edge_color_brewer(palette = "Set2") +
  theme_graph(base_family = "sans") +
  labs(title = "Grafo Heterogéneo de Recomendación Musical",
       subtitle = "Múltiples tipos de nodos y relaciones → señales semánticas más ricas",
       color = "Tipo de nodo", edge_color = "Relación")
Figura 11.4: Esquema de un grafo heterogéneo de recomendación musical. Diferentes tipos de nodos (colores) y diferentes tipos de relaciones permiten capturar señales semánticas que un grafo homogéneo usuario-ítem ignoraría.
Tip¿Cuándo usar grafos heterogéneos?

Los grafos heterogéneos son especialmente valiosos cuando disponemos de metadatos ricos sobre los ítems (géneros, etiquetas, relaciones entre entidades) y queremos superar las limitaciones del filtrado colaborativo puro [@shi2016hetero]. Son la base técnica de sistemas como el de Spotify (grafos de canciones, artistas, oyentes) o el motor de recomendación de LinkedIn (grafos de personas, empresas, habilidades, conexiones).


11.6 6. Implementación con tidygraph

Para proyectos integrados en Quarto, el paquete tidygraph ofrece una API que permite aplicar verbos de dplyr directamente sobre los componentes del grafo. Esto facilita enormemente la fase de ingeniería de características, permitiendo, por ejemplo, calcular el grado de cada nodo (popularidad) y filtrar nodos ruidosos en una sola cadena de comandos.

Función tidygraph Propósito Equivalente en igraph
as_tbl_graph() Convierte dataframes en grafos tidy graph_from_data_frame()
activate(nodes) Cambia el contexto a los vértices V(g)
activate(edges) Cambia el contexto a las aristas E(g)
centrality_pagerank() Calcula puntuaciones de relevancia page_rank()
group_louvain() Detecta comunidades/clústeres cluster_louvain()
library(tidygraph)

# Pipeline completo de análisis del grafo bipartito con tidygraph
g_analizado <- as_tbl_graph(g_bip) |>
  activate(nodes) |>
  mutate(
    tipo        = ifelse(type, "Ítem", "Usuario"),
    popularidad = centrality_degree(),
    relevancia  = centrality_pagerank()
  ) |>
  activate(edges) |>
  mutate(
    peso = 1  # En un caso real, aquí iría el rating
  )

# Resumen de nodos más relevantes
g_analizado |>
  activate(nodes) |>
  as_tibble() |>
  arrange(desc(relevancia)) |>
  select(name, tipo, popularidad, relevancia) |>
  kable(digits = 4, caption = "Nodos ordenados por relevancia (PageRank)")
Nodos ordenados por relevancia (PageRank)
name tipo popularidad relevancia
Amelie Usuario 2 0.1622
Beto Ítem 3 0.1387
StarWars Usuario 3 0.1387
Ana Ítem 2 0.0973
David Ítem 2 0.0973
Matrix Usuario 2 0.0973
Inception Usuario 2 0.0973
Carla Ítem 1 0.0856
Elena Ítem 1 0.0856
# Extraemos el ítem más relevante y el usuario más "influyente"
top_item_tg <- g_analizado |> activate(nodes) |> as_tibble() |>
  filter(tipo == "Ítem") |> slice_max(relevancia, n = 1)

top_usuario_tg <- g_analizado |> activate(nodes) |> as_tibble() |>
  filter(tipo == "Usuario") |> slice_max(relevancia, n = 1)

cat("🎥 Ítem más relevante en la red:", top_item_tg$name,
    "| PageRank =", round(top_item_tg$relevancia, 4),
    "| Visto por", top_item_tg$popularidad, "usuarios\n")
🎥 Ítem más relevante en la red: Beto | PageRank = 0.1387 | Visto por 3 usuarios
cat("👤 Usuario más 'influyente' (conectado con más ítems):", top_usuario_tg$name,
    "| PageRank =", round(top_usuario_tg$relevancia, 4),
    "| Ha visto", top_usuario_tg$popularidad, "películas\n")
👤 Usuario más 'influyente' (conectado con más ítems): Amelie | PageRank = 0.1622 | Ha visto 2 películas

📌 Insight: El ítem con mayor PageRank es el mejor candidato para recomendar a usuarios nuevos (cold start de usuarios): es el ítem más “bien conectado” en la red, no necesariamente el más visto. El usuario más influyente es, en cambio, un “beta-tester” natural: si le ofrecemos algo nuevo y le gusta, los algoritmos de difusión lo propagarán rápidamente por toda la red.

set.seed(123)
ggraph(g_analizado, layout = "stress") +
  geom_edge_link(alpha = 0.3, color = "grey70", width = 1) +
  geom_node_point(aes(color = tipo, size = relevancia * 500)) +
  geom_node_text(aes(label = name, size = sqrt(popularidad) * 2 + 2),
                 repel = TRUE, fontface = "bold") +
  scale_color_manual(values = c("Usuario" = "#3B82F6", "Ítem" = "#F97316")) +
  scale_size_identity() +
  theme_graph(base_family = "sans") +
  labs(title = "Red de Recomendación: Estructura Bipartita Completa",
       subtitle = "Tamaño ∝ PageRank | Azul = Usuarios | Naranja = Ítems",
       color = "Tipo")
Figura 11.5: Grafo bipartito de recomendación. El tamaño de los nodos refleja su relevancia (PageRank). Los ítems naranjas más grandes son los más populares en la red.

11.7 7. Caso de Estudio: El Dataset MovieLens 100K

Para validar estos modelos a mayor escala, el dataset MovieLens 100K [@harper2015movielens] es el estándar de oro. Contiene 100.000 calificaciones de 943 usuarios sobre 1.682 películas.

El análisis preliminar del dataset revela una dispersión crítica: solo el 6.3% de las posibles interacciones están presentes en la matriz. Esto justifica el uso de métodos basados en grafos sobre métodos estadísticos simples.

# Simulamos las estadísticas de MovieLens 100K para ilustrar el concepto
# (en un proyecto real, se descargaría de https://grouplens.org/datasets/movielens/)
set.seed(2026)

# Parametros de MovieLens 100K reales
n_usuarios <- 943
n_items    <- 1682
n_ratings  <- 100000

# Simulamos una distribución de ley de potencia en el número de ratings por ítem
# (Long Tail: pocos ítems con muchas interacciones, muchos con pocas)
popularidad_items <- sort(rpois(n_items, lambda = 5) + 1, decreasing = TRUE)

# Estadísticas clave
densidad <- n_ratings / (n_usuarios * n_items)

cat("=== Estadísticas MovieLens 100K ===\n")
=== Estadísticas MovieLens 100K ===
cat("Usuarios:", n_usuarios, "\n")
Usuarios: 943 
cat("Películas:", n_items, "\n")
Películas: 1682 
cat("Interacciones:", n_ratings, "\n")
Interacciones: 1e+05 
cat("Densidad de la matriz:", round(densidad * 100, 2), "%\n")
Densidad de la matriz: 6.3 %
cat("Vacíos en la matriz:", round((1 - densidad) * 100, 2), "%\n")
Vacíos en la matriz: 93.7 %

📌 ¿Por qué la dispersión hace necesarios los grafos? Con una densidad del 6.3%, los métodos matriciales clásicos sufren: la mayoría de las comparaciones entre usuarios o ítems se basan en muy pocos puntos en común. Pero a través del grafo, la situación cambia radicalmente:

# Con el 6.3% de interacciones directas...
# ¿A cuántos ítems puede llegar un usuario a través de 2 saltos?
interacciones_por_usuario <- n_ratings / n_usuarios  # Media directa

# Un usuario tipo ve ~106 películas. Cada una es vista también por ~6% de 943 usuarios.
# Cada uno de esos usuarios vio a su vez ~106 películas -> acceso indirecto a:
alcance_1_salto <- round(interacciones_por_usuario)           # Películas directas
alcance_2_saltos <- round(interacciones_por_usuario * (interacciones_por_usuario * 0.3)) # Estimación conservadora

cat("Media de películas vistas por usuario:", alcance_1_salto, "\n")
Media de películas vistas por usuario: 106 
cat("Estimación de acceso indirecto (2 saltos en grafo):", alcance_2_saltos, "películas adicionales\n")
Estimación de acceso indirecto (2 saltos en grafo): 3374 películas adicionales
cat("Porcentaje del catálogo accesible a través del grafo:",
    round((alcance_1_salto + alcance_2_saltos) / n_items * 100, 1), "%")
Porcentaje del catálogo accesible a través del grafo: 206.9 %

📌 Insight estructural: Un usuario con 106 valoraciones directas (6.3% del catálogo) tiene acceso indirecto a cientos de ítems adicionales a través de la red. Los grafos multiplican exponencialmente el alcance informativo, permitiendo hacer recomendaciones de calidad incluso en zonas del catálogo nunca visitadas directamente por el usuario.

data.frame(
  pelicula = seq_along(popularidad_items),
  n_ratings = popularidad_items
) |>
  ggplot(aes(x = pelicula, y = n_ratings)) +
  geom_area(fill = "#3B82F6", alpha = 0.3) +
  geom_line(color = "#1E40AF", linewidth = 0.8) +
  annotate("rect", xmin = 1, xmax = 50, ymin = 0, ymax = max(popularidad_items),
           fill = "#F97316", alpha = 0.1) +
  annotate("text", x = 25, y = max(popularidad_items) * 0.85,
           label = "Cabeza\n(Best Sellers)", color = "#F97316", fontface = "bold", size = 3.5) +
  annotate("text", x = n_items * 0.7, y = max(popularidad_items) * 0.3,
           label = "Long Tail\n(Nichos)", color = "#3B82F6", fontface = "bold", size = 3.5) +
  scale_x_continuous(labels = scales::comma) +
  scale_y_continuous(labels = scales::comma) +
  labs(title = "Distribución Long Tail de Popularidad en MovieLens",
       x = "Películas (ordenadas por popularidad)",
       y = "Número de valoraciones") +
  theme_minimal(base_size = 13) +
  theme(plot.title = element_text(face = "bold"))
Figura 11.6: Distribución Long Tail en MovieLens: un pequeño número de películas concentra la mayoría de las interacciones, mientras que miles de títulos de nicho son raramente vistos. Un buen sistema de recomendación debe brillar en ambas zonas.

11.7.1 Protocolo de Evaluación Temporal

Para evitar el data leakage (fuga de datos al futuro), las recomendaciones deben evaluarse mediante una división temporal. El modelo se entrena con los datos hasta una fecha \(t\) y se evalúa su capacidad para predecir las interacciones que ocurrieron después de \(t\).

# Esquema de evaluación temporal correcto
cat("╔════════════════════════════════════════════╗\n")
╔════════════════════════════════════════════╗
cat("║         PROTOCOLO DE EVALUACIÓN           ║\n")
║         PROTOCOLO DE EVALUACIÓN           ║
cat("╠════════════════════════════════════════════╣\n")
╠════════════════════════════════════════════╣
cat("║  ENTRENAMIENTO │ VALIDACIÓN │  TEST        ║\n")
║  ENTRENAMIENTO │ VALIDACIÓN │  TEST        ║
cat("║  (70% del      │ (10%)      │  (20%)       ║\n")
║  (70% del      │ (10%)      │  (20%)       ║
cat("║   tiempo)      │            │              ║\n")
║   tiempo)      │            │              ║
cat("╠════════════════════════════════════════════╣\n")
╠════════════════════════════════════════════╣
cat("║  t₀ ────────── t₁ ─────── t₂ ───────── t₃ ║\n")
║  t₀ ────────── t₁ ─────── t₂ ───────── t₃ ║
cat("╚════════════════════════════════════════════╝\n")
╚════════════════════════════════════════════╝
cat("\nClave: El modelo NUNCA ve interacciones futuras durante el entrenamiento\n")

Clave: El modelo NUNCA ve interacciones futuras durante el entrenamiento

11.8 8. Evaluación del Rendimiento: Métricas de Ranking

La validación de un modelo predictivo en grafos difiere de la regresión tradicional. En lugar de medir el error cuadrático medio (aunque se usa RMSE en algunos contextos), la prioridad es la calidad del ranking superior (Top-N).

11.8.1 Métricas de Clasificación de Top-N

Precision@K: Mide qué fracción de los \(K\) ítems recomendados son realmente relevantes para el usuario. Responde a la pregunta: ¿cuánto “desperdicia” el sistema en sugerencias inútiles?

\[\text{Precision@K} = \frac{|\text{Relevantes} \cap \text{Top-K}|}{K}\]

Recall@K: Mide qué porcentaje de los ítems que el usuario realmente consumió en el futuro fueron “adivinados” por el sistema dentro de los primeros \(K\) resultados sugeridos.

\[\text{Recall@K} = \frac{|\text{Relevantes} \cap \text{Top-K}|}{|\text{Relevantes}|}\]

TipEntendiendo la Intersección, Precision y Recall con un ejemplo práctico

Imagina que queremos evaluar las recomendaciones que Netflix le hizo a un usuario el fin de semana pasado:

  1. Top-K (Recomendadas por el sistema): El algoritmo de Netflix le recomendó un Top-10 (\(K = 10\)) de películas el viernes:
    • Top-10 = { Matrix, Star Wars, Inception, Dune, Interstellar, Amelie, Titanic, Avatar, Gladiator, Alien }
  2. Relevantes (Consumidas por el usuario): En la realidad, el usuario vio y disfrutó de 5 películas durante el fin de semana:
    • Relevantes = { Dune, Interstellar, Alien, Toy Story, Shrek }
  3. Intersección (\(\text{Relevantes} \cap \text{Top-K}\)): Son los aciertos (hits) comunes, es decir, películas recomendadas que el usuario realmente vio:
    • Relevantes ∩ Top-10 = { Dune, Interstellar, Alien } \(\implies\) 3 aciertos

11.8.1.1 Cálculo de las métricas:

  • Precision@10: ¿Qué porcentaje de lo recomendado fue un acierto? \[\text{Precision@10} = \frac{3 \text{ (aciertos)}}{10 \text{ (recomendadas)}} = 0.30 \implies \mathbf{30\%}\]
  • Recall@10: ¿Qué porcentaje de lo que el usuario realmente vio fue capturado por las recomendaciones? \[\text{Recall@10} = \frac{3 \text{ (aciertos)}}{5 \text{ (vistas en total)}} = 0.60 \implies \mathbf{60\%}\]

11.8.1.2 Simulación en R:

Puedes simular esta intersección en la consola ejecutando el siguiente código:

# 1. Definimos los conjuntos de películas
top_10     <- c("Matrix", "Star Wars", "Inception", "Dune", "Interstellar", "Amelie", "Titanic", "Avatar", "Gladiator", "Alien")
relevantes <- c("Dune", "Interstellar", "Alien", "Toy Story", "Shrek")

# 2. R realiza la intersección (busca elementos comunes)
comunes <- intersect(top_10, relevantes)
print(comunes) # Resultado: "Dune" "Interstellar" "Alien"
[1] "Dune"         "Interstellar" "Alien"       
# 3. Calculamos las métricas usando length()
aciertos     <- length(comunes)     # 3
total_reales <- length(relevantes)  # 5

precision_10 <- aciertos / length(top_10)  # 3 / 10 = 0.30
recall_10    <- aciertos / total_reales    # 3 / 5  = 0.60

cat("Precision@10:", precision_10, "| Recall@10:", recall_10, "\n")
Precision@10: 0.3 | Recall@10: 0.6 
NotaPrecision@K vs Recall@K: el dilema clásico

Estas dos métricas están en tensión permanente. Si el sistema recomienda muchos ítems (\(K\) grande), el Recall sube (encuentra más relevantes) pero la Precision cae (hay más ruido). El punto de equilibrio óptimo depende del caso de uso: en una aplicación de streaming basta con que una de las sugerencias enganche al usuario (Recall importa más); en una plataforma de noticias médicas, cada recomendación debe ser relevante (Precision importa más).

MAP (Mean Average Precision): a diferencia de Precision@K (que solo cuenta cuántos aciertos totales hay sin importar su orden), MAP premia que los aciertos estén lo más arriba posible de la lista. Mide la calidad del ranking evaluando la precisión local justo en el momento en que se encuentra cada película relevante, y luego promedia esos valores para todos los usuarios:

\[\text{MAP} = \frac{1}{|U|} \sum_{u \in U} \frac{1}{|\text{Relevantes}_u|} \sum_{k=1}^{K} \text{Precision}_u(k) \cdot rel_u(k)\]

Donde \(rel_u(k) = 1\) si el ítem en la posición \(k\) es relevante para el usuario \(u\), y 0 en caso contrario.

TipEjemplo práctico y traducción de la fórmula del MAP

Supongamos que Ana tiene 2 películas relevantes (las que de verdad vio y le gustaron): Inception y Star Wars (\(|\text{Relevantes}_{Ana}| = 2\)). Comparemos cómo evalúa el MAP a un recomendador de menor calidad para un Top-5 (\(K=5\)):

  • Algoritmo Y: Recomienda [1. Inception, 2. Titanic, 3. Amelie, 4. Matrix, 5. Star Wars].
    • Los aciertos están en las posiciones 1 y 5.

11.8.1.3 1. Traduciendo la pieza interna: \(\text{Precision}_u(k) \cdot rel_u(k)\)

El término \(rel_u(k)\) funciona como un interruptor (vale 1 si es un acierto, y 0 si es ruido), anulando todo lo que no sea útil: * Pos 1 (\(k=1\)): Inception es acierto \(\to \text{Precision}(1) \cdot rel(1) = (1/1) \cdot 1 = \mathbf{1.00}\) * Pos 2 (\(k=2\)): Titanic es ruido \(\to \text{Precision}(2) \cdot rel(2) = (1/2) \cdot 0 = \mathbf{0}\) * Pos 3 (\(k=3\)): Amelie es ruido \(\to \text{Precision}(3) \cdot rel(3) = (1/3) \cdot 0 = \mathbf{0}\) * Pos 4 (\(k=4\)): Matrix es ruido \(\to \text{Precision}(4) \cdot rel(4) = (1/4) \cdot 0 = \mathbf{0}\) * Pos 5 (\(k=5\)): Star Wars es acierto \(\to \text{Precision}(5) \cdot rel(5) = (2/5) \cdot 1 = \mathbf{0.40}\)

Sumando estas precisiones locales de los aciertos obtenemos: \(1.00 + 0 + 0 + 0 + 0.40 = \mathbf{1.40}\).

11.8.1.4 2. Dividiendo entre el total: \(\frac{1}{|\text{Relevantes}_u|}\)

Dividimos el resultado anterior entre el total de películas que a Ana le gustaban de verdad (2) para obtener su precisión media personal: \[\text{Average Precision (AP)}_{Ana} = \frac{1.40}{2 \text{ (relevantes)}} = \mathbf{0.70}\]

Nota: Si el recomendador hubiera sido perfecto poniendo los aciertos al principio (posiciones 1 y 2), el AP de Ana habría sido de 1.00.

11.8.1.5 3. El promedio final: \(\frac{1}{|U|} \sum_{u \in U}\)

El MAP es simplemente el promedio de estos valores de AP calculado sobre todos los usuarios de la plataforma: \[\text{MAP} = \frac{\text{AP}_{Ana} + \text{AP}_{Beto} + \text{AP}_{Carla} + \dots}{|U| \text{ (total usuarios)}}\]

NDCG (Normalized Discounted Cumulative Gain): Métrica fundamental porque tiene en cuenta la posición. Si el sistema recomienda un ítem relevante en la posición 1, obtiene una puntuación mucho mayor que si lo recomienda en la posición 10.

\[\text{NDCG@K} = \frac{DCG@K}{IDCG@K}, \quad DCG@K = \sum_{k=1}^{K} \frac{rel_k}{\log_2(k+1)}\]

Donde el numerador y el denominador representan:

  • \(DCG@K\) (Ganancia Acumulada Descontada): Mide la utilidad real del ranking sumando los aciertos, pero aplicando un descuento logarítmico según su posición (cuanto más abajo esté la recomendación, menos suma al total).

  • \(IDCG@K\) (Ideal DCG - El Denominador): Es el caso perfecto posible. Mide la puntuación ideal que se obtendría si el recomendador pusiera todas las películas de verdad consumidas por el usuario en las primeras posiciones de la lista.

¿Por qué dividimos por el \(IDCG\) en el denominador? Para normalizar. El valor absoluto de \(DCG\) depende del perfil de cada usuario: un usuario muy activo que ve muchas películas tendrá una puntuación ideal mucho más alta que un usuario ocasional. Si no dividiéramos, no podríamos comparar los resultados de distintos usuarios. Al dividir el \(DCG\) real entre su propio ideal (\(IDCG\)), logramos que la puntuación de cualquier usuario esté acotada exactamente en el rango \([0, 1]\) (donde 1 representa el orden perfecto). Esto permite promediar el rendimiento del recomendador para toda la base de datos de manera justa.

TipEjemplo práctico de NDCG (Normalized Discounted Cumulative Gain)

Usando el mismo caso de Ana (películas reales que vio: Inception y Star Wars), veamos cómo penaliza la posición el NDCG en un Top-5:

  • Paseo ideal (IDCG): Las 2 películas relevantes ordenadas perfectamente al principio: [Inception, Star Wars, ...]. \[\text{IDCG@5} = \frac{1}{\log_2(1+1)} + \frac{1}{\log_2(2+1)} = 1 + 0.63 = \mathbf{1.63}\]

  • Si el recomendador acierta en las posiciones 1 y 5: [Inception, Titanic, Amelie, Matrix, Star Wars].

    • DCG@5: \[\text{DCG@5} = \frac{1}{\log_2(1+1)} \text{ (Pos 1)} + \frac{1}{\log_2(5+1)} \text{ (Pos 5)} = 1 + 0.39 = \mathbf{1.39}\]
    • NDCG@5 (Resultado Final): \[\text{NDCG@5} = \frac{\text{DCG@5}}{\text{IDCG@5}} = \frac{1.39}{1.63} = \mathbf{0.85}\]

Un NDCG de 0.85 es una puntuación excelente: indica que el recomendador ha alcanzado el 85% de la calidad de ordenación perfecta para Ana. No llega al 1.00 (el 100% ideal) debido a que el segundo acierto se desvió hasta la última posición (la 5), aplicando un castigo logarítmico a la puntuación. Como el NDCG está acotado estrictamente entre [0, 1], nos permite interpretar de un solo vistazo lo cerca que se encuentra el algoritmo del orden de visualización perfecto (donde 0 sería no acertar nada y 1 sería la perfección absoluta).

Diversidad del Catálogo: Mide el porcentaje total de ítems distintos que el sistema es capaz de recomendar a través de toda la base de usuarios. Un sistema con baja diversidad tiende a recomendar los mismos 20 productos a todo el mundo.

TipEjemplo práctico de Diversidad de Catálogo

Esta métrica no evalúa a un solo usuario, sino la salud general del catálogo de la plataforma:

  • Imagina que tu plataforma de streaming tiene un catálogo con 100 películas en total.
  • El sistema genera recomendaciones para 50 usuarios diferentes.
  • Si sumamos todas las recomendaciones que hizo el sistema y contamos cuántas películas únicas sugirió, resulta que solo recomendó 30 películas distintas (las otras sugerencias eran copias de los mismos 5 blockbusters superventas de siempre).
  • Diversidad / Cobertura del Catálogo: \[\text{Cobertura} = \frac{30 \text{ (películas únicas recomendadas)}}{100 \text{ (catálogo total)}} = 0.30 \implies \mathbf{30\%}\]

Una cobertura baja (\(30\%\)) delata que tu recomendador sufre de un grave sesgo de popularidad, ignorando por completo el Long Tail del catálogo y empobreciendo la diversidad.

# Implementamos las métricas clave de forma transparente
recall_at_k <- function(recomendado, relevante, K) {
  top_k     <- head(recomendado, K)
  relevantes_encontrados <- sum(top_k %in% relevante)
  return(relevantes_encontrados / length(relevante))
}

ndcg_at_k <- function(recomendado, relevante, K) {
  top_k <- head(recomendado, K)
  # DCG: ganancia descontada por posición
  gains  <- ifelse(top_k %in% relevante, 1, 0)
  dcg    <- sum(gains / log2(seq_along(gains) + 1))
  # IDCG: DCG ideal (todos los relevantes al principio)
  ideal  <- head(c(rep(1, length(relevante)), rep(0, K)), K)
  idcg   <- sum(ideal / log2(seq_along(ideal) + 1))
  return(ifelse(idcg == 0, 0, dcg / idcg))
}

# Ejemplo concreto para Ana
peliculas_recomendadas <- c("Inception", "StarWars", "Titanic", "Amelie", "Matrix")
peliculas_relevantes   <- c("Inception", "StarWars")  # Las que le gustaron de verdad

# Añadimos Precision@K y MAP
precision_at_k <- function(recomendado, relevante, K) {
  top_k <- head(recomendado, K)
  return(sum(top_k %in% relevante) / K)
}

map_at_k <- function(recomendado, relevante, K) {
  top_k    <- head(recomendado, K)
  es_rel   <- top_k %in% relevante
  if (sum(es_rel) == 0) return(0)
  precisions <- cumsum(es_rel) / seq_along(es_rel)
  ap <- sum(precisions * es_rel) / length(relevante)
  return(ap)
}

K <- 3
cat("=== Métricas para Ana (K =", K, ") ===\n")
=== Métricas para Ana (K = 3 ) ===
cat("Precision@3:", round(precision_at_k(peliculas_recomendadas, peliculas_relevantes, K), 3), "\n")
Precision@3: 0.667 
cat("Recall@3:   ", round(recall_at_k(peliculas_recomendadas, peliculas_relevantes, K), 3), "\n")
Recall@3:    1 
cat("NDCG@3:     ", round(ndcg_at_k(peliculas_recomendadas, peliculas_relevantes, K), 3), "\n")
NDCG@3:      1 
cat("MAP@3:      ", round(map_at_k(peliculas_recomendadas, peliculas_relevantes, K), 3), "\n")
MAP@3:       1 
# Comparativa de algoritmos simulada (ahora con todas las métricas)
comparativa <- data.frame(
  Algoritmo   = c("User-Based CF", "Item-Based CF", "SVD (k=50)", "PPR Grafos", "P3β Grafos"),
  Precision_10 = c(0.21, 0.24, 0.33, 0.36, 0.38),
  Recall_10   = c(0.38, 0.41, 0.55, 0.58, 0.61),
  NDCG_10     = c(0.29, 0.32, 0.48, 0.52, 0.56),
  MAP         = c(0.18, 0.21, 0.39, 0.43, 0.48),
  Cobertura   = c(0.45, 0.52, 0.71, 0.78, 0.89)
)
kable(comparativa, caption = "Comparativa de algoritmos en MovieLens 100K — todas las métricas (valores ilustrativos)")
Comparativa de algoritmos en MovieLens 100K — todas las métricas (valores ilustrativos)
Algoritmo Precision_10 Recall_10 NDCG_10 MAP Cobertura
User-Based CF 0.21 0.38 0.29 0.18 0.45
Item-Based CF 0.24 0.41 0.32 0.21 0.52
SVD (k=50) 0.33 0.55 0.48 0.39 0.71
PPR Grafos 0.36 0.58 0.52 0.43 0.78
P3β Grafos 0.38 0.61 0.56 0.48 0.89
ImportanteInterpretación de la Comparativa

Observa que los métodos basados en grafos (PPR, P3β) superan consistentemente a los métodos matriciales clásicos, especialmente en Cobertura: son capaces de recomendar mayor diversidad de ítems. El algoritmo P3β destaca especialmente gracias a su penalización de popularidad, que da visibilidad a la larga cola del catálogo.

Visualizamos el trade-off Precision-Recall para distintos valores de \(K\) en nuestro ejemplo de Ana:

library(purrr)

resultados_pr <- map_dfr(1:5, function(k) {
  tibble(
    K         = k,
    Precision = precision_at_k(peliculas_recomendadas, peliculas_relevantes, k),
    Recall    = recall_at_k(peliculas_recomendadas, peliculas_relevantes, k),
    NDCG      = ndcg_at_k(peliculas_recomendadas, peliculas_relevantes, k)
  )
})

resultados_pr |>
  pivot_longer(cols = c(Precision, Recall, NDCG), names_to = "Metrica", values_to = "Valor") |>
  ggplot(aes(x = K, y = Valor, color = Metrica, group = Metrica)) +
  geom_line(linewidth = 1.2) +
  geom_point(size = 3) +
  geom_label(aes(label = round(Valor, 2)), vjust = -0.5, size = 2.8, show.legend = FALSE) +
  scale_color_manual(values = c("Precision" = "#EF4444", "Recall" = "#3B82F6", "NDCG" = "#10B981")) +
  scale_x_continuous(breaks = 1:5) +
  scale_y_continuous(limits = c(0, 1.15)) +
  labs(title = "Evolución de las Métricas con K",
       subtitle = "Precision (rojo) cae • Recall (azul) sube • NDCG (verde) pondera ambas",
       x = "K (número de recomendaciones)", y = "Valor de la métrica") +
  theme_minimal(base_size = 12) +
  theme(plot.title = element_text(face = "bold"),
        legend.position = "bottom")
Figura 11.7: Curva Precision-Recall para K=1..10. A medida que aumentamos el número de recomendaciones, el Recall crece (encontramos más películas relevantes) pero la Precision cae (hay más ruido). El punto óptimo depende del contexto de uso.

📌 Leyendo la curva: Con K=1, el sistema acierta 100% de las veces (Precision=1.0) pero solo encuentra el 50% de los relevantes (Recall=0.5). Con K=3, cae a Precision=0.67 pero sube a Recall=1.0 (encuentra todo). El NDCG actúa de árbitro: premia que los relevantes aparezcan pronto y penaliza que aparezcan tarde. En nuestro ejemplo, K=2 ofrece el mejor equilibrio: buena Precision con buen NDCG.


11.9 9. Aprendizaje Profundo sobre Grafos: GNN y LightGCN

La frontera actual de los sistemas de recomendación se encuentra en la intersección de la teoría de grafos y el aprendizaje profundo. Las Redes Neuronales sobre Grafos (GNN) permiten aprender representaciones vectoriales (embeddings) de usuarios e ítems de manera end-to-end, optimizando directamente una función de pérdida relacionada con la precisión de la recomendación.

11.9.1 La Revolución de los Embeddings Estructurales

En los modelos de factorización de matrices tradicionales, los embeddings se aprenden como variables independientes. En cambio, en una GNN, el embedding de un nodo es el resultado de un proceso de agregación de la información de sus vecinos. Este proceso de “paso de mensajes” permite que la representación de un usuario capture no solo sus acciones directas, sino también las características de los ítems que prefiere y los perfiles de otros usuarios similares en la red.

# Ilustración conceptual del paso de mensajes
# Mostramos cómo fluye la información en 2 capas de GNN
capas_datos <- rbind(
  data.frame(nodo = "Ana", x = 0, y = 0, capa = "Capa 0 (input)", tipo = "Usuario"),
  data.frame(nodo = "Matrix", x = 1.5, y = 1, capa = "Capa 0 (input)", tipo = "Ítem"),
  data.frame(nodo = "StarWars", x = 1.5, y = -1, capa = "Capa 0 (input)", tipo = "Ítem"),
  data.frame(nodo = "Beto", x = 3, y = 0.5, capa = "Capa 0 (input)", tipo = "Usuario"),
  data.frame(nodo = "Ana'", x = 0, y = 0, capa = "Capa 1 (output)", tipo = "Usuario"),
  data.frame(nodo = "Matrix'", x = 1.5, y = 1, capa = "Capa 1 (output)", tipo = "Ítem"),
  data.frame(nodo = "StarWars'", x = 1.5, y = -1, capa = "Capa 1 (output)", tipo = "Ítem"),
  data.frame(nodo = "Beto'", x = 3, y = 0.5, capa = "Capa 1 (output)", tipo = "Usuario")
)

aristas_datos <- rbind(
  data.frame(x = 0, y = 0, xend = 1.5, yend = 1,  capa = "Capa 0 (input)"),
  data.frame(x = 0, y = 0, xend = 1.5, yend = -1, capa = "Capa 0 (input)"),
  data.frame(x = 1.5, y = 1, xend = 3, yend = 0.5, capa = "Capa 0 (input)"),
  data.frame(x = 0, y = 0, xend = 1.5, yend = 1,  capa = "Capa 1 (output)"),
  data.frame(x = 0, y = 0, xend = 1.5, yend = -1, capa = "Capa 1 (output)"),
  data.frame(x = 1.5, y = 1, xend = 3, yend = 0.5, capa = "Capa 1 (output)")
)

ggplot() +
  geom_segment(data = aristas_datos,
               aes(x = x, y = y, xend = xend, yend = yend),
               color = "grey70", linewidth = 0.8,
               arrow = arrow(length = unit(0.2, "cm"), type = "closed")) +
  geom_point(data = capas_datos,
             aes(x = x, y = y, color = tipo), size = 8) +
  geom_text(data = capas_datos,
            aes(x = x, y = y, label = nodo), size = 3, fontface = "bold", color = "white") +
  facet_wrap(~capa) +
  scale_color_manual(values = c("Usuario" = "#3B82F6", "Ítem" = "#F97316")) +
  coord_fixed() +
  theme_minimal(base_size = 12) +
  theme(
    axis.text = element_blank(),
    axis.title = element_blank(),
    panel.grid = element_blank(),
    strip.text = element_text(face = "bold", size = 11),
    legend.position = "bottom"
  ) +
  labs(title = "Paso de Mensajes en una GNN de Recomendación",
       subtitle = "Los embeddings de la capa 1 incorporan información de los vecinos",
       color = "Tipo de nodo")
Figura 11.8: Ilustración del proceso de paso de mensajes en una GNN. El embedding del usuario Ana (capa l+1) se computa agregando los embeddings de sus vecinos (ítems vistos) en la capa l. Esto permite capturar señales de vecindad de múltiples órdenes.

11.9.2 LightGCN: Simplicidad sin Perder Potencia

Un modelo destacado es LightGCN [@he2020lightgcn], que ha simplificado las arquitecturas GNN eliminando las transformaciones de características no lineales y las activaciones (como ReLU) que se demostraron superfluas o incluso perjudiciales para la recomendación colaborativa. LightGCN se enfoca exclusivamente en la propagación lineal de embeddings a través de múltiples capas de la matriz de adyacencia normalizada:

\[\mathbf{E}^{(l+1)} = \tilde{\mathbf{A}} \cdot \mathbf{E}^{(l)}\]

Donde \(\tilde{\mathbf{A}}\) es la matriz de adyacencia normalizada simétricamente. El embedding final de cada nodo es la combinación ponderada de todos los embeddings intermedios de las distintas capas, capturando señales de vecindad de múltiples órdenes.

Esta capacidad de capturar señales de fidelidad alta en grafos dispersos convierte a LightGCN en el estado del arte para escenarios realistas de baja densidad de interacciones.

Verificamos esta intuición con un ejemplo numérico pequeño. Partimos de embeddings aleatorios y aplicamos las capas de propagación lineal de LightGCN:

set.seed(42)
# Embeddings iniciales (capa 0): vectores aleatorios 2D para cada nodo
nodos_lgcn <- c("Ana", "Beto", "Matrix", "StarWars", "Inception")
E0 <- matrix(rnorm(length(nodos_lgcn) * 2), nrow = length(nodos_lgcn),
             dimnames = list(nodos_lgcn, c("dim1", "dim2")))

cat("=== Embeddings ANTES de la propagación (Capa 0) ===\n")
=== Embeddings ANTES de la propagación (Capa 0) ===
print(round(E0, 3))
            dim1   dim2
Ana        1.371 -0.106
Beto      -0.565  1.512
Matrix     0.363 -0.095
StarWars   0.633  2.018
Inception  0.404 -0.063
# Construimos la matriz de adyacencia normalizada simétricamente
A <- as_adjacency_matrix(g_bip, sparse = FALSE)
idx <- match(nodos_lgcn, V(g_bip)$name)
A_sub <- A[idx, idx]

# Normalización simétrica: D^(-1/2) A D^(-1/2)
d_vec <- rowSums(A_sub)
d_vec[d_vec == 0] <- 1
D_inv_sqrt <- diag(1 / sqrt(d_vec))
A_norm <- D_inv_sqrt %*% A_sub %*% D_inv_sqrt

# Capas de propagación lineal
E1 <- A_norm %*% E0
E2 <- A_norm %*% E1

# Embedding final = media de todas las capas
E_final <- (E0 + E1 + E2) / 3

cat("\n=== Embeddings DESPUÉS de 2 capas de propagación ===\n")

=== Embeddings DESPUÉS de 2 capas de propagación ===
print(round(E_final, 3))
           dim1  dim2
Ana       0.775 0.473
Beto      0.086 1.075
Matrix    0.443 0.419
StarWars  0.533 1.123
Inception 0.149 0.414
# Similitud coseno entre Ana y Beto (antes vs después)
cosine_sim <- function(a, b) sum(a*b) / (sqrt(sum(a^2)) * sqrt(sum(b^2)))

cat("\nSimilitud Ana-Beto ANTES:", round(cosine_sim(E0["Ana",], E0["Beto",]), 3))

Similitud Ana-Beto ANTES: -0.421
cat("\nSimilitud Ana-Beto DESPUÉS:", round(cosine_sim(E_final["Ana",], E_final["Beto",]), 3))

Similitud Ana-Beto DESPUÉS: 0.588

📌 Insight numérico: Fíjate en la similitud coseno entre Ana y Beto antes y después de la propagación. Antes de propagar, sus embeddings son aleatorios e independientes. Después de 2 capas, sus vectores se han ‘alineado’ porque el grafo ha forzado que incorporen información de sus vecinos compartidos (Matrix, StarWars). Este acercamiento en el espacio vectorial es exactamente lo que permite al sistema detectar que son usuarios similares, sin haberlo programado explícitamente.

11.9.3 PinSage: Escalabilidad Industrial con Paseos Aleatorios

Para aplicaciones que manejan miles de millones de aristas —como el grafo de Pinterest, con más de 3.000 millones de pines y 1.000 millones de tableros—, las arquitecturas estándar de GNN resultan inviables: agregar los vecinos de un nodo hub extremadamente popular requeriría procesar decenas de millones de nodos en una sola pasada.

PinSage [@ying2018pinsage] resuelve este problema con una estrategia ingeniosamente simple: en lugar de usar toda la vecindad de un nodo, usa paseos aleatorios cortos para muestrear dinámicamente qué vecinos son más relevantes para cada cómputo. El resultado es un conjunto de vecinos de tamaño fijo (por ejemplo, 500 nodos) seleccionados según su frecuencia de visita en los paseos, lo que prioriza automáticamente los vecinos con mayor afinidad estructural.

# Ilustración conceptual: nodo hub con muchos vecinos
set.seed(42)
n_vecinos_reales <- 30  # Simplificado (en Pinterest serían miles)
g_hub <- make_star(n_vecinos_reales + 1, mode = "undirected")
g_hub_tidy <- as_tbl_graph(g_hub) |>
  activate(nodes) |>
  mutate(
    es_hub    = (row_number() == 1),
    muestreado = c(TRUE, sample(c(TRUE, FALSE), n_vecinos_reales,
                                replace = TRUE, prob = c(0.4, 0.6))),
    nombre    = c("Hub", paste0("v", 1:n_vecinos_reales))
  )

ggraph(g_hub_tidy, layout = "star") +
  geom_edge_link(aes(color = .N()$muestreado[to]), alpha = 0.6, width = 0.8) +
  geom_node_point(aes(
    color = case_when(es_hub ~ "Hub", muestreado ~ "Seleccionado", TRUE ~ "Ignorado"),
    size  = ifelse(es_hub, 8, 4)
  )) +
  geom_node_text(aes(label = ifelse(es_hub, "Hub", "")),
                  fontface = "bold", color = "white", size = 3.5) +
  scale_color_manual(
    values = c("Hub" = "#1E40AF", "Seleccionado" = "#F97316", "Ignorado" = "#D1D5DB"),
    name = "Estado del nodo"
  ) +
  scale_edge_color_manual(values = c("TRUE" = "#F97316", "FALSE" = "#D1D5DB"),
                           name = "Arista activa", guide = "none") +
  scale_size_identity() +
  theme_graph(base_family = "sans") +
  labs(title = "PinSage: Muestreo de Vecindad por Paseos Aleatorios",
       subtitle = "Solo los vecinos naranjas participan en la agregación → escala a miles de millones de nodos")
Figura 11.9: Diferencia entre la agregación estándar (todos los vecinos) y la agregación por muestreo con paseos aleatorios de PinSage (subconjunto representativo). En nodos hub con miles de vecinos, el muestreo es imprescindible para la escalabilidad.

Otra ventaja clave de PinSage es que es inductivo: puede generar embeddings para ítems completamente nuevos, incluso sin interacciones previas, simplemente agregando los embeddings de los vecinos iniciales del nuevo nodo en el grafo de conocimiento. Esto resuelve elegantemente el problema del cold start que afecta a los modelos transductivos como LightGCN.

Característica LightGCN PinSage
Tipo Transductivo Inductivo
Vecindad Completa (toda la red) Muestreada (paseos aleatorios)
Escalabilidad Grafos medianos (≤ 10M aristas) Grafos masivos (miles de millones)
Cold Start ❌ No ✅ Sí
Complejidad Baja (propagación lineal) Media (muestreo + agregación)

11.9.4 Enriquecimiento con Grafos de Conocimiento (Knowledge Graphs)

La recomendación puramente colaborativa sufre ante el problema del “arranque en frío” (cold start): no puede recomendar ítems que no tienen interacciones previas. El uso de Grafos de Conocimiento (KG) soluciona esto integrando información sobre los atributos de los ítems.

En un KG [@wang2019kgat], los nodos representan entidades (actores, géneros, marcas) y las aristas representan relaciones semánticas. Al conectar el grafo de interacción usuario-ítem con un KG, el sistema puede razonar: “El Usuario A compró el Producto X. Producto X pertenece a la Categoría C y es de la Marca M. Por lo tanto, el Usuario A podría estar interesado en el Producto Y, que también es de la Marca M”.

Veamos un ejemplo concreto con un mini KG de películas:

# Mini KG: títulos + entidades semánticas
kg_nodos <- data.frame(
  name = c("Ana", "Inception", "Interstellar", "Nolan", "SciFi", "StarWars", "Lucas"),
  tipo = c("Usuario", "Película", "Película", "Director", "Género", "Película", "Director")
)

kg_aristas <- data.frame(
  from = c("Ana",       "Ana",       "Inception",    "Inception",  "Interstellar",  "Interstellar",  "StarWars",  "StarWars"),
  to   = c("Inception", "StarWars",  "Nolan",        "SciFi",      "Nolan",         "SciFi",         "Lucas",     "SciFi"),
  rel  = c("vio",       "vio",       "dirigida_por",  "género",     "dirigida_por",  "género",        "dirigida_por", "género")
)

g_kg <- graph_from_data_frame(kg_aristas, directed = TRUE, vertices = kg_nodos)

paleta_kg <- c("Usuario" = "#3B82F6", "Película" = "#F97316",
                "Director" = "#10B981", "Género" = "#8B5CF6")

set.seed(77)
as_tbl_graph(g_kg) |>
  ggraph(layout = "fr") +
  geom_edge_link(aes(color = rel, label = rel),
                 arrow = arrow(length = unit(0.25, "cm"), type = "closed"),
                 angle_calc = "along", label_dodge = unit(3, "mm"),
                 end_cap = circle(7, "mm"), alpha = 0.85) +
  geom_node_point(aes(color = tipo), size = 11) +
  geom_node_text(aes(label = name), size = 3, fontface = "bold", color = "white") +
  scale_color_manual(values = paleta_kg) +
  scale_edge_color_brewer(palette = "Dark2") +
  theme_graph(base_family = "sans") +
  labs(title = "Knowledge Graph de Películas",
       subtitle = "Camino de razonamiento: Ana → Inception → Nolan → Interstellar",
       color = "Tipo de nodo")
Figura 11.10: Mini Knowledge Graph de películas: conecta el grafo de interacciones usuario-ítem (azul/naranja) con entidades semánticas (verde=director, púrpura=género). El camino Ana→Inception→Nolan→Interstellar es la explicación de la recomendación.
# Simulamos el razonamiento explícito del sistema
cat("=== Explicación de la recomendación generada por el KG ===\n")
=== Explicación de la recomendación generada por el KG ===
cat("\n RECOMENDACIÓN: Interstellar para Ana\n")

 RECOMENDACIÓN: Interstellar para Ana
cat("\n CAMINO EN EL GRAFO:\n")

 CAMINO EN EL GRAFO:
cat("  Ana\n")
  Ana
cat("  └─[vio]─► Inception\n")
  └─[vio]─► Inception
cat("       └─[dirigida_por]─► Nolan\n")
       └─[dirigida_por]─► Nolan
cat("                          └─[dirige también]─► Interstellar\n")
                          └─[dirige también]─► Interstellar
cat("\n POR QUÉ funciona: Ana vió Inception (de Nolan). Nolan también dirigió Interstellar.\n")

 POR QUÉ funciona: Ana vió Inception (de Nolan). Nolan también dirigió Interstellar.
cat(" EXPLICACIÓN AL USUARIO: 'Te recomendamos Interstellar porque disfrutaste\n")
 EXPLICACIÓN AL USUARIO: 'Te recomendamos Interstellar porque disfrutaste
cat(" Inception, otra obra de Christopher Nolan del mismo género.'")
 Inception, otra obra de Christopher Nolan del mismo género.'

📌 Insight clave: El camino Ana → Inception → Nolan → Interstellar es la explicación de la recomendación. Este es el superpoder único de los sistemas basados en grafos frente a los de caja negra: puedes mostrar al usuario exactamente por qué recibió esa sugerencia. Las plataformas que implementan esta transparencia registran tasas de conversión hasta un 35% superiores a las que solo muestran el resultado.

Tipo de Sistema Fuente de Datos Ventaja Principal Desafío Clave
Colaborativo (Grafos) Interacciones (clics, compras) Captura modas y tendencias latentes Arranque en frío
Basado en Contenido (KG) Atributos y metadatos Recomienda ítems nuevos Sobreespecialización
Híbrido Ambas fuentes combinadas Robustez y alta precisión Complejidad de integración
NotaExplicabilidad como Ventaja Competitiva

Los sistemas que combinan señales colaborativas con KGs ofrecen una ventaja no solo en precisión, sino en transparencia. A diferencia de los modelos de “caja negra”, las recomendaciones basadas en grafos pueden explicarse mediante el camino seguido en la red: “Te recomendamos esta película porque te gustan los trabajos de Christopher Nolan”. Esta capacidad de proporcionar justificaciones aumenta la confianza del usuario y mejora la tasa de conversión.


11.10 10. Generación de Reportes Reproducibles con Quarto

Quarto es la herramienta definitiva para documentar investigaciones sobre sistemas de recomendación, permitiendo combinar el rigor del código fuente con la claridad del discurso académico.

11.10.1 Estructura del Documento .qmd

Un reporte de alta fidelidad sobre sistemas de recomendación en Quarto debe incluir:

  • Encabezado YAML: Configuración de estilos, numeración de figuras y referencias cruzadas.
  • Bloques con opciones de ejecución: Uso de #| echo: false para mostrar solo resultados o #| cache: true para procesos pesados como el entrenamiento de GNNs.
  • Interactividad con visNetwork: Los grafos de recomendación son complejos y la capacidad de hacer zoom o filtrar nodos dinámicamente en el reporte final ayuda al lector a comprender la estructura.
if (requireNamespace("visNetwork", quietly = TRUE)) {
  library(visNetwork)
  
  # Preparamos los nodos
  nodos_vis <- data.frame(
    id    = V(g_bip)$name,
    label = V(g_bip)$name,
    group = ifelse(V(g_bip)$type, "Ítem", "Usuario"),
    value = degree(g_bip),
    title = paste0("<b>", V(g_bip)$name, "</b><br>Tipo: ",
                   ifelse(V(g_bip)$type, "Película", "Usuario"),
                   "<br>Conexiones: ", degree(g_bip))
  )
  
  # Preparamos las aristas
  el        <- as_data_frame(g_bip, what = "edges")
  aristas_vis <- data.frame(from = el$from, to = el$to, width = 1.5, color = "grey70")
  
  visNetwork(nodos_vis, aristas_vis,
             main = "Red de Recomendación Interactiva",
             submain = "Mueve los nodos, haz zoom y explora la estructura") |>
    visGroups(groupname = "Usuario", color = "#3B82F6", shape = "square",
              font = list(color = "white")) |>
    visGroups(groupname = "Ítem",    color = "#F97316", shape = "dot") |>
    visLegend() |>
    visPhysics(stabilization = TRUE) |>
    visOptions(highlightNearest = list(enabled = TRUE, degree = 1),
               nodesIdSelection = TRUE)
} else {
  cat("Instala visNetwork: install.packages('visNetwork')")
}

Visualización interactiva del grafo bipartito de recomendación con visNetwork. Puedes mover los nodos, hacer zoom y pasar el cursor sobre ellos para ver sus atributos.

11.10.2 Encabezado YAML Recomendado

Para un capítulo de este tipo dentro de un proyecto Quarto, el encabezado YAML recomendado es:

---
title: "Análisis de Recomendación Basada en Grafos"
format: 
  html:
    toc: true
    code-fold: true
    fig-cap-location: bottom
execute:
  warning: false
  message: false
  cache: true
---

11.11 Recapitulación

El estudio de los modelos de recomendación basados en grafos cierra el círculo entre el Análisis de Redes Sociales y la predicción computacional. Al tratar la recomendación no como una tarea de coincidencia de patrones aislados, sino como un proceso de difusión en una red dinámica, se logra una comprensión mucho más profunda de la economía de la atención y el comportamiento de los usuarios en entornos digitales.

El camino recorrido en este capítulo sigue una progresión natural:

  1. Filtrado Colaborativo → La intuición algebraica de “usuarios similares prefieren ítems similares”.
  2. Factorización (SVD) → Compresión de la información en factores latentes.
  3. Grafos Bipartitos → La representación relacional que unifica todo.
  4. PageRank Personalizado y P3β → Algoritmos de propagación local y control de sesgo de popularidad.
  5. GNNs y LightGCN → Aprendizaje end-to-end de embeddings estructurales.
  6. Knowledge Graphs → Integración de conocimiento semántico para resolver el arranque en frío.

La tendencia futura apunta hacia los sistemas de recomendación de tiempo real basados en grafos de flujo, donde la estructura de la red se actualiza con cada clic, y hacia el uso de grafos heterogéneos que integren señales de múltiples contextos (ubicación, hora, dispositivo) en una sola representación topológica. Para el profesional de la ciencia de datos, el dominio de estas herramientas en R y su correcta comunicación mediante Quarto constituyen competencias esenciales para liderar el desarrollo de la próxima generación de inteligencia artificial centrada en las relaciones.