30  Recomendación

Recomendar significa ordenar opciones para una persona en un contexto. Predecir una calificación, recuperar productos y elegir diez elementos para una pantalla son tareas distintas: cambian los datos, la pérdida y la evaluación. Un clic registra una interacción bajo cierta exposición; no demuestra satisfacción ni rechazo de lo que no se mostró.

Construiremos un catálogo sintético con usuarios, categorías, exposición desigual y productos nuevos. Compararemos popularidad, contenido, factorización y una interacción neuronal. Después separaremos recuperación, ordenamiento y diversidad. El catálogo pequeño permite evaluar todas las opciones y detectar cambios engañosos en las métricas. No se pretende reproducir un servicio comercial.

Se usan NumPy, pandas, Matplotlib y PyTorch en CPU, sin descargas. Se conecta con Embeddings, Pipelines y Redes de grafos.

30.1 De la pregunta al protocolo

Señal Qué observamos Precaución
Explícita Calificación o preferencia declarada Solo responde parte de las personas
Implícita Clic, compra o reproducción Exposición, posición y disponibilidad afectan la señal
Impresión sin clic Se mostró y no hubo interacción Puede depender del contexto, no de desagrado
Ausencia de registro No conocemos interacción No constituye un negativo confirmado

En calificaciones suele utilizarse RMSE; para listas importan Recall@K, NDCG@K y restricciones del catálogo. Una menor RMSE no garantiza mejor ordenamiento. Aquí la unidad es usuario con al menos un positivo futuro elegible, la etiqueta es clic y la tarea es recomendar productos aún no consumidos.

Definimos ventanas globales de entrenamiento, validación y prueba. No mezclamos eventos al azar: perfiles y popularidad se construyen con el pasado disponible. La misma persona aparece en varias ventanas porque evaluamos eventos futuros de usuarios conocidos. Los usuarios nuevos se reportan aparte.

import numpy as np
import sklearn
import json, copy, platform
from pathlib import Path
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from IPython.display import display, Markdown
import torch
from torch import nn
import torch.nn.functional as F
torch.set_num_threads(1)
torch.manual_seed(42)
plt.rcParams.update({'figure.figsize':(9,4),'font.size':11,'axes.spines.top':False,'axes.spines.right':False})
def lectura(s):display(Markdown('### Cómo leer este resultado\n\n'+s))
print('Python',platform.python_version(),'| NumPy',np.__version__,'| PyTorch',torch.__version__,'| CPU')
Python 3.13.9 | NumPy 2.3.5 | PyTorch 2.14.1+cpu | CPU

30.2 Un generador con exposición y preferencia separadas

Hay 160 usuarios conocidos, 20 nuevos, 80 productos iniciales y 20 incorporados en prueba. Cuatro categorías están disponibles como metadatos. El simulador asigna una preferencia principal a cada usuario, pero los modelos no reciben esa preferencia oculta: reciben clics y categorías.

Cada ventana muestra hasta 32 productos no consumidos previamente. Algunos IDs reciben más exposición; la probabilidad de clic es 0.72 en la categoría preferida y 0.055 en las demás. Conservamos impresiones sin clic, aunque entrenaremos con positivos y negativos muestreados. Esta decisión no utiliza toda la información de exposición y limita la interpretación.

rng=np.random.default_rng(30)
NU,NI,WU,WI=180,100,160,80
category=np.arange(NI)%4;preference=rng.integers(0,4,NU)
weights=1/(1+np.arange(NI)/10);rows=[];consumed=[set() for _ in range(NU)]
for window,users,n in [('train',range(WU),WI),('validation',range(WU),WI),('test',range(NU),NI)]:
 for u in users:
  ids=np.array([i for i in range(n) if i not in consumed[u]])
  prob=weights[ids]/weights[ids].sum()
  for i in rng.choice(ids,min(32,len(ids)),replace=False,p=prob):
   click=int(rng.random() < (.72 if category[i]==preference[u] else .055))
   rows.append((window,u,int(i),int(category[i]),click))
   if click:consumed[u].add(int(i))
events=pd.DataFrame(rows,columns=['window','user','item','category','click'])
positive={w:events.query('window==@w and click==1')[['user','item']].to_numpy() for w in ['train','validation','test']}
history=[set(positive['train'][positive['train'][:,0]==u,1]) for u in range(NU)]
history_test=[history[u]|set(positive['validation'][positive['validation'][:,0]==u,1]) for u in range(NU)]
assert set(positive['train'][:,1])==set(range(WI))
assert set(positive['train'][:,0])==set(range(WU))
assert all(not history[u]&set(positive['validation'][positive['validation'][:,0]==u,1]) for u in range(NU))
summary=events.groupby('window',sort=False).agg(impressions=('click','size'),clicks=('click','sum'),users=('user','nunique'))
display(summary)
fig,ax=plt.subplots(1,2,figsize=(11,4))
counts=events.query('window=="train"').groupby('item').agg(shown=('click','size'),clicks=('click','sum')).reindex(range(WI),fill_value=0)
ax[0].plot(counts.shown,label='Exposiciones');ax[0].plot(counts.clicks,label='Clics');ax[0].legend();ax[0].set(title='Popularidad combina exposición y preferencia',xlabel='Producto',ylabel='Frecuencia')
matrix=np.zeros((WU,WI));matrix[positive['train'][:,0],positive['train'][:,1]]=1
ax[1].imshow(matrix[np.argsort(preference[:WU])][:,np.argsort(category[:WI])],aspect='auto',cmap='Blues');ax[1].set(title='Clics ordenados para inspección',xlabel='Productos por categoría',ylabel='Usuarios por preferencia oculta');plt.tight_layout();plt.show()
lectura(f'Hay {len(positive["train"])} clics de entrenamiento sobre {summary.loc["train","impressions"]} impresiones. Mayor frecuencia de clics puede proceder de más oportunidades. El orden por preferencia oculta se usa solo en la visualización: no entra en entrenamiento ni selección.')
impressions clicks users
window
train 5120 1102 160
validation 5120 905 160
test 5760 899 180

30.2.1 Cómo leer este resultado

Hay 1102 clics de entrenamiento sobre 5120 impresiones. Mayor frecuencia de clics puede proceder de más oportunidades. El orden por preferencia oculta se usa solo en la visualización: no entra en entrenamiento ni selección.

30.3 Métricas para listas completas

Sean \(R_u\) los positivos futuros y \(L_u^K\) la lista recomendada:

\[Recall@K(u)=\frac{|R_u\cap L_u^K|}{|R_u|},\qquad Precision@K(u)=\frac{|R_u\cap L_u^K|}{K}.\]

Con relevancia binaria, \(DCG@K=\sum_{r=1}^{K}rel_r/\log_2(r+1)\) y \(NDCG=DCG/IDCG\). El ranking ideal utiliza \(\min(K,|R_u|)\) relevantes. Promediamos por usuario, no por evento. Si no hay positivos elegibles, Recall queda indefinido y se excluye al usuario indicando población.

Cobertura es la fracción del catálogo que aparece en alguna lista. Diversidad es número de categorías distintas dividido entre cuatro: una definición explícita, no una medida universal de satisfacción. La cuota de productos populares utiliza el top 20 calculado en entrenamiento. Catálogo y población forman parte del protocolo.

pop=np.bincount(positive['train'][:,1],minlength=NI).astype(float);head=set(np.argsort(-pop,kind='stable')[:20])
profiles=np.zeros((NU,4))
for u in range(NU):
 for i in history[u]:profiles[u,category[i]]+=1
profiles/=np.maximum(profiles.sum(1,keepdims=True),1)
content=profiles[:,category]+.001*pop[None,:]/pop.max();popular=np.tile(pop,(NU,1))
def relevant(window,u):
 a=positive[window];return set(a[a[:,0]==u,1])
def candidates(window,u):
 h=history if window=='validation' else history_test;n=WI if window=='validation' else NI
 return np.array([i for i in range(n) if i not in h[u]],dtype=int)
def rank(scores,window,u,k=10,pool=None):
 ids=candidates(window,u) if pool is None else np.asarray(pool,dtype=int)
 # No se recomiendan IDs sin puntuación entrenada.
 ids=ids[np.isfinite(scores[u,ids])]
 return ids[np.argsort(-scores[u,ids],kind='stable')[:k]]
def list_metrics(lists,window,users,n):
 rows=[];shown=[]
 for u in users:
  rel=relevant(window,u)
  if not rel:continue
  listing=np.asarray(lists[u]);hits=np.isin(listing,list(rel)).astype(float)
  dcg=np.sum(hits/np.log2(np.arange(len(hits))+2));ideal=np.sum(1/np.log2(np.arange(min(10,len(rel)))+2))
  cold=rel&set(range(WI,NI))
  rows.append({'user':u,'recall':hits.sum()/len(rel),'precision':hits.sum()/10,'ndcg':dcg/ideal,'diversity':len(set(category[listing]))/4,'head_share':np.mean([i in head for i in listing]),'cold_recall':len(set(listing)&cold)/len(cold) if cold else np.nan})
  shown.extend(listing)
 detail=pd.DataFrame(rows);out=detail.drop(columns='user').mean().to_dict()
 out.update(users=len(detail),cold_users=int(detail.cold_recall.notna().sum()),coverage=len(set(shown))/n)
 return out,detail
def evaluate(scores,window,users=range(WU)):
 return list_metrics({u:rank(scores,window,u) for u in users},window,users,WI if window=='validation' else NI)
assert np.isclose(1/(1+1/np.log2(3)),.6131471927654584)
display(pd.DataFrame([{'model':name,**evaluate(s,'validation')[0]} for name,s in [('Popularidad',popular),('Contenido',content)]]).round(3))
model recall precision ndcg diversity head_share cold_recall users cold_users coverage
0 Popularidad 0.210 0.119 0.158 0.959 1.00 NaN 160 0 0.200
1 Contenido 0.604 0.336 0.554 0.250 0.27 NaN 160 0 0.938

30.4 Factorización: embeddings y BPR

Una tabla aprende \(p_u\) por usuario y \(q_i\) por producto; factorización usa \(s(u,i)=p_u^Tq_i+b_i\). Una categoría es un atributo observado y un embedding de ID es un parámetro aprendido. Un ID nuevo no dispone de entrenamiento.

BPR compara un positivo observado \(i\) con un producto \(j\) no observado hasta el corte:

\[\mathcal L_{BPR}=-\log\sigma(s(u,i)-s(u,j)).\]

El negativo no significa rechazo. Para muestrearlo consultamos solo historia y catálogo de entrenamiento; excluir futuros positivos usando prueba introduciría información futura. Se usa un negativo uniforme por positivo y época y weight decay 0.0001.

30.5 Interacción neuronal

El segundo modelo concatena embeddings y los combina mediante MLP. Utiliza cuatro negativos por positivo y BCE con logits. El ratio artificial impide interpretar la sigmoide como probabilidad de compra. Ambos tienen embeddings de dimensión 16, Adam 0.01 y 60 épocas; seleccionamos cada checkpoint por NDCG@10 de validación.

La comparación incluye arquitectura, objetivo y muestreo. No aísla el efecto de cambiar únicamente producto interno por MLP. Las pérdidas tienen escalas distintas y no sirven para elegir entre modelos.

Código
class Recommender(nn.Module):
 def __init__(self,kind):
  super().__init__();self.kind=kind;self.users=nn.Embedding(WU,16);self.items=nn.Embedding(WI,16);self.bias=nn.Embedding(WI,1)
  nn.init.normal_(self.users.weight,std=.1);nn.init.normal_(self.items.weight,std=.1);nn.init.zeros_(self.bias.weight)
  if kind=='MLP':self.mlp=nn.Sequential(nn.Linear(32,32),nn.ReLU(),nn.Linear(32,1))
 def forward(self,u,i):
  p,q=self.users(u),self.items(i)
  return (p*q).sum(-1)+self.bias(i).squeeze(-1) if self.kind=='BPR' else self.mlp(torch.cat([p,q],-1)).squeeze(-1)
def model_scores(model):
 model.eval();scores=np.full((NU,NI),-np.inf)
 with torch.no_grad():
  u=torch.arange(WU).repeat_interleave(WI);i=torch.arange(WI).repeat(WU)
  scores[:WU,:WI]=model(u,i).numpy().reshape(WU,WI)
 scores[WU:,:WI]=pop[:WI] # Fallback para usuario desconocido.
 return scores
negative_pool=[np.array([i for i in range(WI) if i not in history[u]]) for u in range(WU)]
models={};training=[];selection=[]
for kind in ['BPR','MLP']:
 torch.manual_seed(302 if kind=='BPR' else 303);gen=np.random.default_rng(304 if kind=='BPR' else 305)
 model=Recommender(kind);opt=torch.optim.Adam(model.parameters(),lr=.01,weight_decay=.0001);best=-np.inf
 for epoch in range(1,61):
  model.train();losses=[];pairs=positive['train'][gen.permutation(len(positive['train']))]
  for start in range(0,len(pairs),512):
   batch=pairs[start:start+512];u=torch.tensor(batch[:,0]);i=torch.tensor(batch[:,1])
   if kind=='BPR':
    j=torch.tensor([gen.choice(negative_pool[int(x)]) for x in batch[:,0]])
    loss=F.softplus(-(model(u,i)-model(u,j))).mean()
   else:
    neg=np.array([[gen.choice(negative_pool[int(x)]) for _ in range(4)] for x in batch[:,0]])
    uu=torch.cat([u,u.repeat_interleave(4)]);ii=torch.cat([i,torch.tensor(neg.reshape(-1))]);y=torch.cat([torch.ones(len(u)),torch.zeros(4*len(u))])
    loss=F.binary_cross_entropy_with_logits(model(uu,ii),y)
   opt.zero_grad();loss.backward();opt.step();losses.append(loss.item())
  val=evaluate(model_scores(model),'validation')[0]
  training.append({'model':kind,'epoch':epoch,'loss':np.mean(losses),'val_ndcg':val['ndcg']})
  if val['ndcg']>best:best=val['ndcg'];state=copy.deepcopy(model.state_dict());best_epoch=epoch
 model.load_state_dict(state);models[kind]=model;selection.append({'model':kind,'epoch':best_epoch,'val_ndcg':best,'parameters':sum(x.numel() for x in model.parameters())})
curves=pd.DataFrame(training);selected=pd.DataFrame(selection);display(selected.round(4))
fig,ax=plt.subplots(1,2,figsize=(11,4))
for kind,g in curves.groupby('model'):
 ax[0].plot(g.epoch,g.loss,label=kind);ax[1].plot(g.epoch,g.val_ndcg,label=kind)
ax[0].set(title='Objetivos diferentes: alturas no comparables',xlabel='Época',ylabel='Pérdida');ax[1].set(title='Selección con el mismo protocolo',xlabel='Época',ylabel='NDCG@10 validación')
for a in ax:a.legend()
plt.tight_layout();plt.show()
lectura('; '.join(f'{r.model}: checkpoint {r.epoch}, NDCG de validación {r.val_ndcg:.3f}' for r in selected.itertuples())+'. Cada checkpoint se eligió sin consultar prueba. Una menor BCE frente a BPR no indica mejor ranking: sus pérdidas representan problemas distintos.')
model epoch val_ndcg parameters
0 BPR 43 0.4376 3920
1 MLP 60 0.4910 5009

30.5.1 Cómo leer este resultado

BPR: checkpoint 43, NDCG de validación 0.438; MLP: checkpoint 60, NDCG de validación 0.491. Cada checkpoint se eligió sin consultar prueba. Una menor BCE frente a BPR no indica mejor ranking: sus pérdidas representan problemas distintos.

30.6 Ver los embeddings y auditar una lista

Un embedding no viene con un significado fijo por coordenada. En factorización, aplicar la misma rotación ortogonal a vectores de usuario y producto conserva sus productos internos. Por eso interpretamos relaciones y scores, no nombres inventados para cada dimensión.

Proyectamos embeddings BPR de productos a dos componentes mediante SVD centrada. El color usa categorías conocidas del catálogo, no etiquetas de prueba. La fracción de energía retenida ayuda a recordar cuánto se pierde al mirar dos ejes. La cercanía en este plano no sustituye el score completo ni prueba causalidad.

También inspeccionamos usuario 0, prefijado, en validación. Mostramos tres listas y marcamos clics reservados solo para leer el resultado. No añadimos esas etiquetas al historial del perfil.

vectors=models['BPR'].items.weight.detach().numpy();centered=vectors-vectors.mean(0)
_,singular,basis=np.linalg.svd(centered,full_matrices=False);xy=centered@basis[:2].T;energy=np.sum(singular[:2]**2)/np.sum(singular**2)
fig,ax=plt.subplots(figsize=(7,5))
for cat in range(4):
 ids=np.flatnonzero(category[:WI]==cat);ax.scatter(xy[ids,0],xy[ids,1],label=f'Categoría {cat}',alpha=.8)
ax.set(title=f'Embeddings BPR: dos ejes retienen {energy:.1%} de energía',xlabel='Componente 1',ylabel='Componente 2');ax.legend();plt.show()
user=0;example_rows=[]
for name,s in [('Popularidad',popular),('Contenido',content),('BPR',model_scores(models['BPR']))]:
 for position,i in enumerate(rank(s,'validation',user),1):
  example_rows.append({'model':name,'rank':position,'item':int(i),'category':int(category[i]),'future_click':int(i in relevant('validation',user))})
example=pd.DataFrame(example_rows);display(example)
hist_cat=np.bincount(category[list(history[user])],minlength=4)
lectura(f'La proyección conserva {energy:.1%} de la energía centrada. El historial del usuario 0 contiene clics por categoría {hist_cat.tolist()}; contenido aprovecha este patrón directamente. La tabla permite identificar aciertos, repetición de categorías y productos ignorados. Una lista individual ilustra mecanismos, pero no sustituye el promedio ni la incertidumbre por usuario.')

model rank item category future_click
0 Popularidad 1 1 1 0
1 Popularidad 2 17 1 0
2 Popularidad 3 2 2 0
3 Popularidad 4 6 2 0
4 Popularidad 5 7 3 0
5 Popularidad 6 9 1 1
6 Popularidad 7 4 0 0
7 Popularidad 8 5 1 0
8 Popularidad 9 13 1 0
9 Popularidad 10 3 3 0
10 Contenido 1 4 0 0
11 Contenido 2 16 0 0
12 Contenido 3 8 0 1
13 Contenido 4 24 0 1
14 Contenido 5 40 0 0
15 Contenido 6 32 0 1
16 Contenido 7 36 0 0
17 Contenido 8 56 0 0
18 Contenido 9 28 0 0
19 Contenido 10 44 0 0
20 BPR 1 7 3 0
21 BPR 2 27 3 0
22 BPR 3 8 0 1
23 BPR 4 19 3 0
24 BPR 5 40 0 0
25 BPR 6 16 0 0
26 BPR 7 4 0 0
27 BPR 8 31 3 0
28 BPR 9 58 2 0
29 BPR 10 32 0 1

30.6.1 Cómo leer este resultado

La proyección conserva 35.6% de la energía centrada. El historial del usuario 0 contiene clics por categoría [4, 0, 0, 2]; contenido aprovecha este patrón directamente. La tabla permite identificar aciertos, repetición de categorías y productos ignorados. Una lista individual ilustra mecanismos, pero no sustituye el promedio ni la incertidumbre por usuario.

30.7 Prueba: una lista, varias consecuencias

En prueba ya están disponibles los 100 productos. Filtramos clics de entrenamiento y validación. Pesos y perfiles permanecen estimados solo con entrenamiento; no se reentrena con validación. Esta política de actualización explícita difiere de un servicio que aprende tras cada clic.

BPR y MLP no puntúan productos nuevos: los excluyen. Contenido puede hacerlo porque las categorías están disponibles al lanzamiento. La comparación evalúa este requisito del sistema, no solamente un catálogo de IDs conocidos.

scores={'Popularidad':popular,'Contenido':content,'BPR':model_scores(models['BPR']),'MLP':model_scores(models['MLP'])}
records=[];details={}
for name,s in scores.items():
 met,detail=evaluate(s,'test');records.append({'model':name,**met});details[name]=detail
results=pd.DataFrame(records).set_index('model');display(results.round(3))
fig,ax=plt.subplots(1,3,figsize=(13,4));results[['recall','ndcg']].plot.bar(ax=ax[0],rot=30);results[['coverage','diversity']].plot.bar(ax=ax[1],rot=30);results.cold_recall.plot.bar(ax=ax[2],rot=30,color='#c87939')
for a,title in zip(ax,['Relevancia futura','Catálogo y categorías','Recall de productos nuevos']):a.set(title=title,ylim=(0,1))
plt.tight_layout();plt.show()
winner=results.ndcg.idxmax()
lectura(f'Mayor NDCG@10: {winner}, {results.loc[winner,"ndcg"]:.3f}. BPR obtiene Recall {results.loc["BPR","recall"]:.3f} frente a popularidad {results.loc["Popularidad","recall"]:.3f}. Contenido recupera {results.loc["Contenido","cold_recall"]:.3f} de positivos de productos nuevos; BPR {results.loc["BPR","cold_recall"]:.3f}. Elegir solo por el promedio ocultaría la limitación de IDs. Estas métricas describen clics del simulador, no satisfacción causal.')
recall precision ndcg diversity head_share cold_recall users cold_users coverage
model
Popularidad 0.143 0.074 0.109 0.892 1.000 0.000 158 98 0.19
Contenido 0.567 0.270 0.484 0.253 0.134 0.129 158 98 0.98
BPR 0.404 0.189 0.339 0.693 0.294 0.000 158 98 0.80
MLP 0.438 0.206 0.374 0.502 0.373 0.000 158 98 0.72

30.7.1 Cómo leer este resultado

Mayor NDCG@10: Contenido, 0.484. BPR obtiene Recall 0.404 frente a popularidad 0.143. Contenido recupera 0.129 de positivos de productos nuevos; BPR 0.000. Elegir solo por el promedio ocultaría la limitación de IDs. Estas métricas describen clics del simulador, no satisfacción causal.

30.7.2 Por qué contenido es una referencia fuerte aquí

El generador asigna afinidad principal a una categoría disponible como metadato. Un perfil de conteos tiene, por diseño, información especialmente útil para recuperar esa regla. Esto explica su ventaja sin declarar que contenido domina siempre a filtrado colaborativo. Las redes de IDs necesitan estimar afinidades desde una matriz dispersa y además no puntúan productos nuevos.

La decisión para este escenario sería empezar por contenido y comprobar requisitos adicionales antes de añadir una red. Para estudiar una ventaja colaborativa habría que introducir preferencias no explicadas por categorías, historial suficiente y un protocolo nuevo fijado antes de consultar resultados. Cambiar el simulador después de ver prueba para hacer ganar a la red invalidaría la comparación.

30.7.3 Incertidumbre por usuario

Remuestreamos usuarios completos conservando la pareja BPR–contenido. El bootstrap refleja variación de esta población simulada condicionada a los modelos entrenados; no incluye otras semillas, otros generadores ni sesgo de exposición. Los productos de una lista no se tratan como observaciones independientes.

paired=details['BPR'][['user','ndcg']].merge(details['Contenido'][['user','ndcg']],on='user',suffixes=('_bpr','_content'))
delta=(paired.ndcg_bpr-paired.ndcg_content).to_numpy();gen=np.random.default_rng(306)
boot=delta[gen.integers(0,len(delta),(2000,len(delta)))].mean(1);ci=np.quantile(boot,[.025,.975])
fig,ax=plt.subplots();ax.hist(boot,bins=35);ax.axvline(0,color='black',ls='--');ax.axvspan(*ci,color='orange',alpha=.2);ax.set(title='Bootstrap pareado por usuario',xlabel='NDCG BPR menos contenido',ylabel='Remuestras');plt.show()
lectura(f'Diferencia media {delta.mean():.3f}; intervalo percentil del 95% [{ci[0]:.3f}, {ci[1]:.3f}], {len(delta)} usuarios. '+('El intervalo cruza cero: no sostiene una ventaja clara en esta población.' if ci[0]<=0<=ci[1] else 'El intervalo no cruza cero bajo este protocolo; no demuestra superioridad fuera del simulador.')+' Este análisis no se utiliza para volver a seleccionar hiperparámetros sobre prueba.')

30.7.4 Cómo leer este resultado

Diferencia media -0.145; intervalo percentil del 95% [-0.173, -0.117], 158 usuarios. El intervalo no cruza cero bajo este protocolo; no demuestra superioridad fuera del simulador. Este análisis no se utiliza para volver a seleccionar hiperparámetros sobre prueba.

30.8 Muestrear candidatos cambia la dificultad

Algunas evaluaciones agregan todos los positivos y unos pocos negativos aleatorios. Así eliminan competidores y pueden aumentar métricas sin mejorar el modelo. Construimos cinco paneles con los positivos futuros y 20 negativos por usuario y mantenemos K=10. Conocer etiquetas para formar el panel es válido en esta demostración de evaluación; no sería un generador de candidatos desplegable.

No se mezclan estos resultados con métricas de catálogo completo. La variación entre paneles refleja muestreo de candidatos, no incertidumbre de entrenamiento.

sample_rows=[]
for seed in range(5):
 gen=np.random.default_rng(400+seed);lists={}
 for u in range(WU):
  rel=relevant('test',u);non=np.array([i for i in candidates('test',u) if i not in rel]);pool=sorted(rel)+list(gen.choice(non,min(20,len(non)),replace=False))
  lists[u]=rank(scores['BPR'],'test',u,pool=pool)
 sample_rows.append({'seed':seed,**list_metrics(lists,'test',range(WU),NI)[0]})
sampled=pd.DataFrame(sample_rows)
fig,ax=plt.subplots();ax.bar(['Catálogo completo','Positivos + 20 negativos'],[results.loc['BPR','ndcg'],sampled.ndcg.mean()]);ax.errorbar([1],[sampled.ndcg.mean()],yerr=[sampled.ndcg.std(ddof=1)],fmt='none',color='black',capsize=4);ax.set(ylim=(0,1),ylabel='NDCG@10',title='Mismo modelo, otro conjunto de competidores');plt.show()
lectura(f'NDCG completo {results.loc["BPR","ndcg"]:.3f}; panel muestreado {sampled.ndcg.mean():.3f}, desviación entre paneles {sampled.ndcg.std(ddof=1):.3f}. Ningún parámetro cambió. Informar una mejora sin declarar candidatos sería engañoso; productos nuevos siguen sin puntuación en BPR incluso si el panel los incluye.')

30.8.1 Cómo leer este resultado

NDCG completo 0.339; panel muestreado 0.581, desviación entre paneles 0.006. Ningún parámetro cambió. Informar una mejora sin declarar candidatos sería engañoso; productos nuevos siguen sin puntuación en BPR incluso si el panel los incluye.

30.9 Recuperación y ordenamiento

Un sistema grande puede recuperar cientos de candidatos con embeddings y ordenar ese subconjunto mediante un modelo más costoso. Si el relevante no llega al pool, el reranker no puede recuperarlo. Aquí BPR recupera 20 candidatos y MLP ordena diez.

flowchart LR
 A[Historial hasta el corte] --> B[Recuperar candidatos]
 B --> C[Filtrar vistos y no disponibles]
 C --> D[Ordenar por puntuación]
 D --> E[Ajustar diversidad]
 E --> F[Mostrar y registrar exposición]

Calculamos también el techo con ordenamiento ideal dentro del pool; usa etiquetas solo como diagnóstico. El catálogo pequeño se puntúa exhaustivamente: no se implementa ANN ni se mide latencia. Al escalar habría que evaluar recall de búsqueda, memoria, actualización y tiempo de respuesta.

retrieval=[];reranked={};oracle_lists={}
for u in range(WU):
 pool=rank(scores['BPR'],'test',u,k=20);rel=relevant('test',u)
 if rel:retrieval.append(len(set(pool)&rel)/len(rel))
 reranked[u]=rank(scores['MLP'],'test',u,pool=pool)
 oracle_lists[u]=np.array(sorted(pool,key=lambda i:(i not in rel,int(i)))[:10])
two=list_metrics(reranked,'test',range(WU),NI)[0];ceiling=list_metrics(oracle_lists,'test',range(WU),NI)[0]
fig,ax=plt.subplots();ax.bar(['BPR directo','BPR → MLP','Techo dentro de 20'],[results.loc['BPR','ndcg'],two['ndcg'],ceiling['ndcg']]);ax.set(ylim=(0,1),ylabel='NDCG@10',title='Recuperación y ranking son etapas distintas');plt.show()
lectura(f'Recall de candidatos@20 {np.mean(retrieval):.3f}; NDCG reranking {two["ndcg"]:.3f}; BPR directo {results.loc["BPR","ndcg"]:.3f}; techo del pool {ceiling["ndcg"]:.3f}. '+('El reranker empeora el ranking: agregar una etapa no justifica implementarla.' if two['ndcg']<results.loc['BPR','ndcg'] else 'La mejora observada requiere valorar también el coste de la etapa adicional.')+' El techo con etiquetas no es un modelo entrenado ni un resultado desplegable.')

30.9.1 Cómo leer este resultado

Recall de candidatos@20 0.542; NDCG reranking 0.377; BPR directo 0.339; techo del pool 0.649. La mejora observada requiere valorar también el coste de la etapa adicional. El techo con etiquetas no es un modelo entrenado ni un resultado desplegable.

30.10 Diversidad: una restricción explícita

Normalizamos scores finitos por usuario y aplicamos una regla voraz: score menos λ por cada producto ya seleccionado de la misma categoría. Probamos λ=0, 0.3, 0.7 y 1.2 en validación. El criterio prefijado es maximizar NDCG entre reglas con diversidad media ≥0.60; si ninguna cumple, escoger mayor diversidad y declarar incumplimiento.

Ese umbral es una decisión didáctica de producto, no una constante estadística. El modelo de IDs sigue sin recuperar productos nuevos. Mayor variedad no demuestra mayor satisfacción; el intercambio con relevancia debe mostrarse.

def diverse_lists(s,window,lam):
 out={}
 for u in range(WU):
  ids=candidates(window,u);ids=ids[np.isfinite(s[u,ids])];z=(s[u,ids]-s[u,ids].mean())/max(s[u,ids].std(),1e-6)
  selected=[];counts=np.zeros(4)
  for _ in range(min(10,len(ids))):
   j=int(np.argmax(z-lam*counts[category[ids]]));i=int(ids[j]);selected.append(i);counts[category[i]]+=1;ids=np.delete(ids,j);z=np.delete(z,j)
  out[u]=np.array(selected)
 return out
trade=pd.DataFrame([{'lambda':lam,**list_metrics(diverse_lists(scores['BPR'],'validation',lam),'validation',range(WU),WI)[0]} for lam in [0,.3,.7,1.2]])
feasible=trade[trade.diversity>=.60];chosen=feasible.sort_values('ndcg',ascending=False).iloc[0] if len(feasible) else trade.sort_values('diversity',ascending=False).iloc[0]
lam=float(chosen['lambda']);diverse_test=list_metrics(diverse_lists(scores['BPR'],'test',lam),'test',range(WU),NI)[0]
fig,ax=plt.subplots();ax.plot(trade.diversity,trade.ndcg,'o-')
for _,r in trade.iterrows():ax.annotate(f'λ={r["lambda"]}',(r.diversity,r.ndcg),xytext=(4,5),textcoords='offset points')
ax.axvline(.60,color='gray',ls='--');ax.set(xlabel='Diversidad en validación',ylabel='NDCG@10 validación',title='Medir el coste de diversificar antes de prueba');plt.show()
display(trade[['lambda','ndcg','diversity','coverage']].round(3))
lectura(f'λ elegido {lam:.1f}, diversidad de validación {chosen.diversity:.3f}. En prueba diversidad pasa de {results.loc["BPR","diversity"]:.3f} a {diverse_test["diversity"]:.3f}, NDCG de {results.loc["BPR","ndcg"]:.3f} a {diverse_test["ndcg"]:.3f}. '+('El umbral se cumplió en validación.' if len(feasible) else 'Ninguna regla cumplió el umbral; hace falta revisar el objetivo antes de implementar.')+' La decisión requiere aceptar explícitamente este intercambio.')

lambda ndcg diversity coverage
0 0.0 0.438 0.558 1.000
1 0.3 0.343 0.941 0.988
2 0.7 0.302 1.000 0.962
3 1.2 0.279 1.000 0.975

30.10.1 Cómo leer este resultado

λ elegido 0.3, diversidad de validación 0.941. En prueba diversidad pasa de 0.693 a 0.949, NDCG de 0.339 a 0.289. El umbral se cumplió en validación. La decisión requiere aceptar explícitamente este intercambio.

30.11 Arranque en frío: personas y productos

Una persona nueva necesita popularidad, preferencias declaradas con consentimiento o contexto observable. Un producto nuevo puede usar categoría, texto o imagen mediante un encoder de contenido. Aquí contenido conoce categorías al lanzamiento, pero la persona nueva no tiene perfil y recibe popularidad como fallback.

Evaluamos los 20 usuarios incorporados en prueba aparte. No añadimos sus clics de prueba al perfil antes de recomendarlos. Simular actualización tras el primer clic requeriría otro corte y otra definición de tarea.

cold_results=pd.DataFrame([{'model':name,**evaluate(s,'test',range(WU,NU))[0]} for name,s in scores.items()]).set_index('model');display(cold_results.round(3))
fig,ax=plt.subplots();pd.DataFrame({'Conocidos':results.recall,'Nuevos':cold_results.recall}).plot.bar(ax=ax,rot=30);ax.set(ylim=(0,1),ylabel='Recall@10',title='Sin historia no hay personalización por IDs');plt.tight_layout();plt.show()
lectura(f'BPR conocido: Recall {results.loc["BPR","recall"]:.3f}; fallback para personas nuevas {cold_results.loc["BPR","recall"]:.3f}. Aumentar dimensiones no crea historia para un ID desconocido. Popularidad y contenido sin perfil comparten un orden semejante; la decisión relevante es diseñar onboarding, contexto o una política de entrada medible.')
recall precision ndcg diversity head_share cold_recall users cold_users coverage
model
Popularidad 0.212 0.14 0.204 1.0 1.0 0.0 20 10 0.1
Contenido 0.212 0.14 0.204 1.0 1.0 0.0 20 10 0.1
BPR 0.212 0.14 0.204 1.0 1.0 0.0 20 10 0.1
MLP 0.212 0.14 0.204 1.0 1.0 0.0 20 10 0.1

30.11.1 Cómo leer este resultado

BPR conocido: Recall 0.404; fallback para personas nuevas 0.212. Aumentar dimensiones no crea historia para un ID desconocido. Popularidad y contenido sin perfil comparten un orden semejante; la decisión relevante es diseñar onboarding, contexto o una política de entrada medible.

30.12 Sesgo, causalidad y decisiones

El log depende de la política que mostró opciones. Imitar clics puede reforzar popularidad y reducir oportunidades de productos menos expuestos. Nuestro generador no modela posición, precio, satisfacción, abandono ni efectos de largo plazo. Evaluación offline no estima automáticamente el efecto de cambiar política.

Hallazgo Decisión Comprobación adicional
Contenido recupera nuevos productos Mantener ruta de contenido o híbrida Calidad de metadatos al lanzamiento
Mejora ranking pero baja cobertura Revisar restricciones Segmentos y distribución de exposición
Reranker no mejora Conservar sistema simple Coste y varias semillas
Métrica sube al muestrear candidatos Separar protocolos Candidatos, filtrado y K
Personas nuevas fallan Diseñar fallback Consentimiento y actualización

Una prueba A/B debe medir un objetivo de producto y guardrails como abandono o concentración. Evaluación off-policy exige supuestos y propensiones identificables; dividir por popularidad no resuelve el problema. Modelos secuenciales incorporan orden del historial; optimizar decisiones con efectos futuros conecta con Aprendizaje por refuerzo.

30.13 Registro reproducible

Guardamos impresiones, resultados por usuario, curvas, selección y pesos. Los archivos sirven para inspección e inferencia didáctica. El esquema de IDs, perfiles y filtrado también forma parte del sistema: los pesos solos no reproducen una recomendación.

example.to_csv('ejemplo-listas.csv',index=False)
events.to_csv('interacciones.csv',index=False);results.to_csv('resultados.csv');cold_results.to_csv('usuarios-nuevos.csv')
curves.to_csv('curvas-entrenamiento.csv',index=False);selected.to_csv('seleccion.csv',index=False);trade.to_csv('diversidad-validacion.csv',index=False);sampled.to_csv('evaluacion-muestreada.csv',index=False)
pd.concat([d.assign(model=name) for name,d in details.items()]).to_csv('resultados-usuarios.csv',index=False)
for name,model in models.items():
 torch.save(model.state_dict(),f'modelo-{name.lower()}.pt');clone=Recommender(name);clone.load_state_dict(torch.load(f'modelo-{name.lower()}.pt',weights_only=True))
 assert np.allclose(model_scores(clone)[:WU,:WI],scores[name][:WU,:WI])
protocol={'seed_generator':30,'users_known':WU,'users_new':NU-WU,'items_initial':WI,'items_new':NI-WI,'selection':'validation NDCG@10; first tie','k':10,'candidates':'all available unseen products; untrained ID items excluded in BPR/MLP','profiles':'train only; no refit','negative_sampling':'uniform train catalog minus train positives; never future labels','BPR_negatives':1,'MLP_negatives':4,'epochs':60,'embedding_dim':16,'diversity_lambda':lam,'diversity_threshold':.6,'bootstrap_users':2000,'torch':torch.__version__}
Path('protocolo.json').write_text(json.dumps(protocol,ensure_ascii=False,indent=2),encoding='utf-8')
print('Registros guardados; carga de ambos modelos verificada.')
Registros guardados; carga de ambos modelos verificada.

30.14 Contrato común y persistencia

En recomendación, el catálogo disponible, el filtrado de productos vistos y el corte temporal forman parte de la métrica. Se conservan los modelos BPR/MLP, sus negativos y el tratamiento de usuarios y productos nuevos. La recarga no sustituye el esquema de IDs ni los perfiles de entrenamiento necesarios para reproducir un ranking.

Esta ejecución conserva el desarrollo didáctico y las recetas del capítulo. El cierre registra entorno, tablas y pesos en artefactos_pytorch/. Los estados serializados se recargan en una copia de la arquitectura y se comprueba identidad de parámetros y buffers; esa comprobación de persistencia no sustituye una evaluación de inferencia ni demuestra calidad predictiva. Los pesos guardados no incluyen todos los estados necesarios para reanudar exactamente el experimento.

from pathlib import Path
import json,hashlib,copy,shutil,platform
import torch
artefactos_cierre=Path('artefactos_pytorch');artefactos_cierre.mkdir(exist_ok=True)
modulos_cierre=[];vistos_cierre=set()
for nombre_cierre,objeto_cierre in list(globals().items()):
    if isinstance(objeto_cierre,torch.nn.Module) and id(objeto_cierre) not in vistos_cierre:
        vistos_cierre.add(id(objeto_cierre));estado_cierre={k:v.detach().cpu().clone() for k,v in objeto_cierre.state_dict().items()}
        assert all(torch.isfinite(v).all() for v in estado_cierre.values() if v.is_floating_point()),nombre_cierre
        ruta_cierre=artefactos_cierre/f'{nombre_cierre}-estado.pt';torch.save({'state_dict':estado_cierre},ruta_cierre)
        copia_cierre=copy.deepcopy(objeto_cierre).cpu();copia_cierre.load_state_dict(torch.load(ruta_cierre,map_location='cpu',weights_only=True)['state_dict'])
        assert all(torch.equal(v.cpu(),estado_cierre[k]) for k,v in copia_cierre.state_dict().items())
        modulos_cierre.append({'variable':nombre_cierre,'clase':type(objeto_cierre).__name__,'parametros':sum(p.numel() for p in objeto_cierre.parameters()),'recarga_estado':True})
for nombre_cierre,objeto_cierre in list(globals().items()):
    if isinstance(objeto_cierre,pd.DataFrame) and len(objeto_cierre)<100000:objeto_cierre.to_csv(artefactos_cierre/f'tabla-{nombre_cierre}.csv',index=False)
for ruta_cierre in list(Path('.').iterdir()):
    if ruta_cierre.is_file() and ruta_cierre.suffix in ['.csv','.json','.joblib','.pt','.wav','.npz']:shutil.copy2(ruta_cierre,artefactos_cierre/ruta_cierre.name)
registro_cierre={'python':platform.python_version(),'torch':str(torch.__version__),'numpy':np.__version__,'sklearn':sklearn.__version__,'modulos':modulos_cierre,'alcance':'identidad de parámetros/buffers al recargar; no reanudación completa','archivos':{p.name:hashlib.sha256(p.read_bytes()).hexdigest() for p in artefactos_cierre.iterdir() if p.is_file()}}
(artefactos_cierre/'cierre-armonizacion.json').write_text(json.dumps(registro_cierre,ensure_ascii=False,indent=2),encoding='utf-8')
print('Estados recargados y verificados:',len(modulos_cierre),'| archivos registrados:',len(registro_cierre['archivos']))
Estados recargados y verificados: 2 | archivos registrados: 28

30.14.1 Lectura de la ejecución registrada

Ejecución CPU del 15 de octubre de 2026.

Se conservaron los cortes temporales, el catálogo disponible, los comparadores de contenido/popularidad, BPR, MLP y el ajuste de diversidad elegido en validación. Los controles distinguen catálogo completo, candidatos muestreados y usuarios nuevos sin historia; las métricas offline no estiman por sí solas un efecto causal del servicio. Se conservaron las comprobaciones de recarga originales de ambos recomendadores y se archivaron también sus pesos y resultados por usuario.

La armonización verificó identidad de parámetros y buffers tras recarga de 2 módulos presentes al cierre; archivó 28 archivos con SHA-256. Esta prueba no incluye reanudación exacta del optimizador ni sustituye las evaluaciones predictivas del capítulo.

30.15 Ejercicios para tomar decisiones

  1. Exposición uniforme. Reconstruir con pesos constantes. ¿Cuánto de la popularidad procede de exposición? Repetir todos los cortes, no modificar solo prueba.
  2. Preferencias mixtas. Asignar dos afinidades por usuario sin entregarlas al modelo. Comparar diversidad cuando variedad forma parte del generador.
  3. Negativos. Comparar muestreo uniforme con proporcional a popularidad. No excluir futuros positivos; mantener catálogo y K.
  4. Contenido inductivo. Construir un encoder de categorías y reservar productos completos. Separar generalización a IDs de generalización a atributos.
  5. Calificaciones. Añadir señal explícita, entrenar MSE y estudiar si RMSE y NDCG seleccionan el mismo checkpoint.
  6. Semillas. Reentrenar cinco veces; separar variabilidad de optimización del bootstrap por usuario. No elegir la mejor semilla en prueba.
  7. Servicio. Simular productos retirados, bloqueos e IDs desconocidos. Informar errores de catálogo y latencia además de relevancia.

El informe debe declarar tarea, cortes, población, candidatos, filtrado, K, negativos, selección y límites. La pregunta es qué decisión sostiene la evidencia.

30.16 Referencias