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.
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 <-1682n_ratings_lt <-100000# Distribución realista: Ley de Potencia (Zipf's Law) con alfa = 1.1ranks <-1:n_items_ltpopularidad_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_ltpopularidad_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 popularidaddf_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 coloresggplot(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.
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 operarM <- R_df |>select(-User) |>as.matrix()rownames(M) <- R_df$Userprint(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).
# 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)] <-0return(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).
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)] <-0kable(S_item, digits =2, caption ="Similitud entre Películas")
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 columnaM_filled <- Mfor(i in1: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 latentessvd_result <-svd(M_filled)k <-2U_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")
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 bipartitointeracciones <-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 >= 4positiva =c(TRUE, TRUE, TRUE, TRUE, TRUE, FALSE, TRUE, FALSE,TRUE, TRUE, FALSE, TRUE)) |>filter(positiva)# Creamos el grafo bipartitog_bip <-graph_from_data_frame(interacciones[, c("usuario","item")], directed =FALSE)# CLAVE: añadir el atributo 'type' para identificar las dos particionesV(g_bip)$type <-V(g_bip)$name %in% interacciones$usuariocat("¿Es bipartito?", is_bipartite(g_bip), "\n")
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 bipartitometricas <-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ónproyecciones <-bipartite_projection(g_bip)# Proyección de USUARIOS (conectados si han visto las mismas películas)g_usuarios <- proyecciones$proj1g_items <- proyecciones$proj2aristas_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 distantepar_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:
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] <-1ppr_ana <-page_rank(g_bip, directed =FALSE,personalized = pers_vec)$vector# Ordenamos los ítems por su relevancia para Anaresultados_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 = ítemsbiadj <-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 → UsuarioP_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.5penalizacion <-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", ] >0scores_ana <- P3_beta["Ana", ]scores_ana[ana_visto] <--Inf# Excluir ítems ya conocidoscat("Top recomendaciones P3β para Ana:\n")
# Calculamos también P3 PURO (sin penalización, β=0) para compararscores_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.
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 tidygraphg_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 relevantesg_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.
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 datasetMovieLens 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 realesn_usuarios <-943n_items <-1682n_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 clavedensidad <- 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 directasalcance_2_saltos <-round(interacciones_por_usuario * (interacciones_por_usuario *0.3)) # Estimación conservadoracat("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 correctocat("╔════════════════════════════════════════════╗\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?
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.
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 }
Intersección (\(\text{Relevantes} \cap \text{Top-K}\)): Son los aciertos (hits) comunes, es decir, películas recomendadas que el usuario realmente vio:
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ículastop_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"
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:
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\)):
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.
\(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].
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).
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 transparenterecall_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 Anapeliculas_recomendadas <-c("Inception", "StarWars", "Titanic", "Amelie", "Matrix")peliculas_relevantes <-c("Inception", "StarWars") # Las que le gustaron de verdad# Añadimos Precision@K y MAPprecision_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% relevanteif (sum(es_rel) ==0) return(0) precisions <-cumsum(es_rel) /seq_along(es_rel) ap <-sum(precisions * es_rel) /length(relevante)return(ap)}K <-3cat("=== Métricas para Ana (K =", K, ") ===\n")
# 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 GNNcapas_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:
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 nodonodos_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) ===
# 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))
📌 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 vecinosset.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:
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 sistemacat("=== 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:truecode-fold:truefig-cap-location: bottomexecute:warning:falsemessage:falsecache: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:
Filtrado Colaborativo → La intuición algebraica de “usuarios similares prefieren ítems similares”.
Factorización (SVD) → Compresión de la información en factores latentes.
Grafos Bipartitos → La representación relacional que unifica todo.
PageRank Personalizado y P3β → Algoritmos de propagación local y control de sesgo de popularidad.
GNNs y LightGCN → Aprendizaje end-to-end de embeddings estructurales.
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.