fast.ai · Practical Deep Learning for Coders · Lección 7
Collaborative filtering: recomendar sin entender el contenido
El capítulo que explica por qué Netflix te recomienda una película sin que ningún algoritmo "entienda" de qué trata — y que de paso da la definición más clara y concreta de qué es un embedding que vas a encontrar en todo el curso.
Antes de leerlección 3 §2, SGD · lección 5, modelo desde cero
🎯 Al terminar vas a poder
- Explicar con tus palabras qué es un latent factor y por qué un producto punto entre dos vectores aprendidos sirve como predicción de rating.
- Decir qué es un embedding en una frase exacta (una multiplicación por one-hot con un atajo de indexado) y reconocer esa misma capa en un LLM.
- Escribir un modelo
DotProductBiasen PyTorch, entrenarlo concollab_learnercon y sin red neuronal, y elegiry_range,n_factorsywdcon criterio. - Diagnosticar overfitting en pocas epochs y corregirlo con weight decay, y explicar por qué el cold start no tiene solución dentro del modelo.
- Interpretar lo aprendido: leer bias, medir similitud entre embeddings con coseno y proyectar con PCA.
1. El problema: recomendar sin saber nada del contenido
Collaborative filtering resuelve esto: dado un historial de usuarios calificando items (películas, productos), predecir qué le va a gustar a un usuario a partir del comportamiento de usuarios parecidos — sin ninguna información explícita sobre el contenido en sí. El dataset de ejemplo es MovieLens 100k: 100.000 ratings de 1 a 5 estrellas, de 943 usuarios sobre 1.682 películas, cada fila con usuario, película, rating y timestamp.
ratings = pd.read_csv(path/'u.data', delimiter='\t', header=None,
names=['user', 'movie', 'rating', 'timestamp'])
movies = pd.read_csv(path/'u.item', delimiter='|', encoding='latin-1',
usecols=(0, 1), names=('movie', 'title'), header=None)
ratings = ratings.merge(movies)
dls = CollabDataLoaders.from_df(ratings, item_name='title', bs=64)
Pensá la tabla como una matriz usuarios × películas donde la mayoría de las celdas está vacía (cada usuario vio una fracción minúscula del catálogo): 100.000 ratings sobre 943 × 1.682 ≈ 1,6 millones de celdas posibles, un 6 % de llenado. El trabajo del modelo es rellenar las celdas vacías.
2. Latent factors y el producto punto
La idea central: aunque nunca le preguntamos a nadie "¿cuánto te gusta la ciencia ficción?", tiene que
existir un conjunto subyacente de conceptos (género, época, tono) que explica por qué a alguien le gustan
ciertas películas. Esos son los latent factors — no se eligen a mano, se aprenden. El
libro ilustra la mecánica con un ejemplo manual antes de entrenar nada: representa una película de space
opera como vector [0.98, 0.9, -0.9] (muy sci-fi, muy acción, muy no-vieja) y a un usuario
fan del género como [0.9, 0.8, -0.6]. El rating predicho es el producto punto
de ambos vectores: (usuario * pelicula).sum().
Hacé la cuenta: 0.98·0.9 + 0.9·0.8 + (-0.9)·(-0.6) = 0.882 + 0.72 + 0.54 = 2.14, un
número alto — coinciden en los tres ejes. Para Casablanca, que el libro codifica como
[-0.99, -0.3, 0.8] (nada sci-fi, poca acción, vieja), el mismo usuario da
-0.891 - 0.24 - 0.48 = -1.61. Cada término del producto punto es un "acuerdo" en un eje:
signos iguales suman, signos opuestos restan. En la práctica no sabemos esos valores de antemano — la
solución es dejar que gradient descent los aprenda: inicializar ambas matrices al azar, calcular el
producto punto como predicción, medir el error con MSE, y ajustar ambas matrices para minimizarlo. Eso es
todo el modelo: dos tablas de números y una multiplicación.
3. Qué es un embedding, exactamente
El libro es preciso sobre algo que suele quedar como magia: "buscar por índice" (indexar
user_factors[3]) es matemáticamente idéntico a multiplicar la matriz de
factores por un vector one-hot con un 1 en la posición 3:
n_users = len(dls.classes['user']); n_factors = 5
user_factors = torch.randn(n_users, n_factors)
one_hot_3 = one_hot(3, n_users).float() # [944], todo 0 salvo la posición 3
user_factors.t() @ one_hot_3 # [5, 944] @ [944] -> [5]
user_factors[3] # el mismo vector de 5 números
Un embedding es justamente ese atajo computacional: en vez de hacer la multiplicación
matricial completa (cara e innecesaria — 944 multiplicaciones por fila para conservar una sola), la capa
Embedding indexa directamente, pero calcula su derivada como si hubiera hecho la
multiplicación por one-hot: el gradiente solo toca la fila que se usó. No es un concepto nuevo ni un
truco misterioso — es álgebra lineal estándar con un atajo de eficiencia, la misma definición que aplica
igual a embeddings de palabras, de usuarios, o de tokens en un LLM. La tabla de embeddings de un modelo
de lenguaje es exactamente Embedding(vocab_size, d_model): una fila aprendida por token, y
el id del token es el índice.
📐 Conexión conceptual: Collaborative Filtering como Atención Estática de Transformers
En el mecanismo de autoatención (Self-Attention) del Transformer (Capítulo 1 de HF), la afinidad entre el token en la posición y el token en la posición se computa mediante el producto escalar de sus proyecciones:
En Collaborative Filtering, la afinidad predicha entre el usuario y la película es:
Collaborative Filtering es estructuralmente un mecanismo de atención donde las proyecciones son estáticas: el usuario actúa como la Query y la película como la Key. La diferencia fundamental en un LLM es que las Queries y Keys son contextuales y dinámicas (generadas proyectando estados ocultos que ya incorporaron el contexto de los tokens vecinos), mientras que en Collaborative Filtering clásico son representaciones fijas por entidad.
Embedding se limita a evitar las 944 multiplicaciones inútiles. Es la definición que
vale igual para usuarios, películas, tokens de un LLM o columnas categóricas.
4. El código: DotProduct con bias
class DotProduct(Module):
def __init__(self, n_users, n_movies, n_factors):
self.user_factors = Embedding(n_users, n_factors)
self.movie_factors = Embedding(n_movies, n_factors)
def forward(self, x):
users = self.user_factors(x[:,0])
movies = self.movie_factors(x[:,1])
return (users * movies).sum(dim=1)
model = DotProduct(n_users, n_movies, 50)
learn = Learner(dls, model, loss_func=MSELossFlat())
learn.fit_one_cycle(5, 5e-3)
x llega como un batch de pares [usuario, película] (shape [64, 2]);
cada columna indexa su tabla y devuelve [64, 50]; el producto elemento a elemento y la suma
por fila dan un rating por par. Dos mejoras que el libro agrega en orden, cada una con su motivo:
Primero, acotar la salida. Un producto punto puede dar cualquier número, pero un rating
vive en 1-5; forzar el rango con sigmoid_range(res, 0, 5.5) le ahorra al modelo aprender ese
límite. El techo es 5,5 y no 5 porque una sigmoide nunca alcanza su máximo: con y_range=(0, 5)
predecir un 5 exacto sería imposible. Segundo, bias: el producto punto captura afinidad
temática, pero no distingue una película objetivamente mala de una que simplemente no calza con los
gustos de un usuario particular. Por eso se agrega un bias por usuario y por película (un escalar extra,
embedding de tamaño 1) que captura "¿le gusta a la gente en general?" — algo distinto de "¿calza con
este usuario?":
class DotProductBias(Module):
def __init__(self, n_users, n_movies, n_factors, y_range=(0,5.5)):
self.user_factors = Embedding(n_users, n_factors)
self.user_bias = Embedding(n_users, 1)
self.movie_factors = Embedding(n_movies, n_factors)
self.movie_bias = Embedding(n_movies, 1)
self.y_range = y_range
def forward(self, x):
users = self.user_factors(x[:,0])
movies = self.movie_factors(x[:,1])
res = (users * movies).sum(dim=1, keepdim=True)
res += self.user_bias(x[:,0]) + self.movie_bias(x[:,1])
return sigmoid_range(res, *self.y_range)
Fijate en keepdim=True: mantiene la forma [64, 1] para que se pueda sumar con
los bias, que también son [64, 1]. Sin eso, el broadcasting entre [64] y
[64, 1] produce silenciosamente una matriz [64, 64] — el modelo entrena sin
error y sin sentido.
📐 Formulación matemática de Factorización Matricial con Bias (Koren et al., 2009)
El modelo de factorización probabilística de matrices predice cada calificación observada descomponiéndola en la media global , un sesgo de usuario , un sesgo de item , y la interacción de factores latentes :
La función de costo regularizada sobre el conjunto de ratings observados con coeficiente es:
Definiendo el residuo como , las derivadas analíticas que ejecuta SGD son:
Notá la simetría interactiva: el gradiente que actualiza los gustos del usuario es proporcional al vector de la película ponderado por el error cometido. Si la película fue subestimada (), rota en la dirección de para aumentar su producto escalar.
5. Weight decay y cold start
Con el bias agregado, el libro entrena 5 epochs y observa algo revelador: la loss de entrenamiento baja
sin parar, pero la de validación mejora una o dos epochs y después empeora. Es overfitting, y
aparece rápido por una razón estructural: el modelo tiene (944 + 1665) × 50 ≈ 130.000
parámetros libres para explicar 80.000 ratings de entrenamiento — más parámetros que datos. Nada le
impide agrandar un par de coordenadas hasta memorizar cada rating. Y no se puede resolver con data
augmentation como en imágenes: no hay forma de "rotar" un rating.
Weight decay (regularización L2) es la respuesta: se suma a la loss el cuadrado de todos
los pesos, multiplicado por un coeficiente wd:
loss_with_wd = loss + wd * (parameters**2).sum()
Minimizar esa suma obliga a un equilibrio: el modelo solo puede agrandar un peso si eso reduce la loss
más de lo que cuesta el término de penalización. Derivar el término extra es inmediato — la derivada de
wd * p² respecto de p es 2 * wd * p — así que en la práctica no se
suma nada a la loss, sino que se agrega directamente al gradiente antes del paso de optimización:
parameters.grad += wd * 2 * parameters # equivale a haber sumado wd*(p**2).sum() a la loss
parameters -= lr * parameters.grad # el paso normal de SGD
Leé las dos líneas juntas: en cada paso, además de moverse contra el gradiente, cada peso se achica un
poquito proporcionalmente a su propio tamaño (p -= lr * 2 * wd * p) — de ahí el nombre,
"decaimiento de los pesos". Con embeddings chicos, la función que calcula el producto punto es menos
"afilada": cambiar un rating de entrenamiento requiere mover muchas coordenadas un poco, en vez de una
coordenada mucho, y eso es lo que generaliza. En el notebook, fit_one_cycle(5, 5e-3, wd=0.1)
hace que la loss de validación baje en las cinco epochs, y termine del orden de 0,82 — contra ~0,87 y
subiendo sin weight decay.
El cold start es el problema sin solución mágica: ¿qué recomendar a un usuario o item nuevo, sin historial? Su fila en la tabla de embeddings está en su valor inicial aleatorio — el modelo no tiene nada que decir. Opciones reales: asignar el promedio de embeddings existentes (riesgoso — la combinación "promedio" puede no representar a nadie real), elegir un usuario que represente un gusto típico, o —mejor— predecir el embedding inicial con un modelo tabular a partir de metadata del signup (edad, género favorito declarado) — así es como Netflix o Pandora justifican preguntar tus gustos al registrarte. Y el libro agrega una advertencia de sistema, no de modelo: un recomendador entrenado sobre el comportamiento de sus usuarios más activos sesga las recomendaciones hacia ese grupo, que a su vez atrae más usuarios parecidos — el feedback loop que se retoma en la lección de ética.
6. Con fastai: collab_learner, y la versión con red neuronal
Todo lo anterior existe empaquetado. collab_learner construye por defecto un modelo
equivalente a DotProductBias (en fastai se llama EmbeddingDotBias, con los
atributos u_weight, i_weight, u_bias, i_bias):
learn = collab_learner(dls, n_factors=50, y_range=(0, 5.5))
learn.fit_one_cycle(5, 5e-3, wd=0.1)
Los resultados son los mismos que con la clase escrita a mano — lo cual es el punto: no hay nada dentro de la función que no hayas visto ya. La segunda variante reemplaza el producto punto por una red neuronal chica: se concatenan los dos embeddings y se pasan por capas lineales con ReLU. Como ya no hace falta que ambos vectores vivan en el mismo espacio (no se multiplican entre sí), usuarios y películas pueden tener embeddings de tamaño distinto, y fastai los elige con una regla empírica:
📐 Neural Collaborative Filtering (NCF - He et al., 2017) vs. Producto Punto
El producto punto asume una interacción puramente bilineal : el factor del usuario solo interactúa con el factor del item. No puede capturar interacciones cruzadas complejas (por ejemplo: a un usuario le gusta un actor si y solo si el género es comedia ).
NCF resuelve esto concatenando los embeddings en un vector continuo y procesándolo con un Perceptrón Multicapa (MLP):
Las capas ocultas del MLP aprenden aproximaciones de funciones no lineales que relacionan cualquier dimensión latente del usuario con cualquier dimensión latente de la película, superando las limitaciones de rango del producto punto bilineal.
embs = get_emb_sz(dls)
embs # [(944, 74), (1665, 102)]: (cardinalidad, tamaño de embedding)
learn = collab_learner(dls, use_nn=True, y_range=(0, 5.5), layers=[100, 50])
learn.fit_one_cycle(5, 5e-3, wd=0.1)
learn.model # EmbeddingNN: dos Embedding, Linear(176, 100), ReLU, Linear(100, 50), ReLU, Linear(50, 1)
La regla de get_emb_sz es min(600, round(1.6 * n_cat**0.56)) — crece con la
cardinalidad pero cada vez más despacio, y se corta en 600. Es una heurística, no una ley; el libro la
presenta como "lo que funcionó bien en la práctica". Escrita a mano, la versión con red es esto:
class CollabNN(Module):
def __init__(self, user_sz, item_sz, y_range=(0,5.5), n_act=100):
self.user_factors = Embedding(*user_sz)
self.item_factors = Embedding(*item_sz)
self.layers = nn.Sequential(
nn.Linear(user_sz[1]+item_sz[1], n_act), nn.ReLU(), nn.Linear(n_act, 1))
self.y_range = y_range
def forward(self, x):
embs = self.user_factors(x[:,0]), self.item_factors(x[:,1])
x = self.layers(torch.cat(embs, dim=1))
return sigmoid_range(x, *self.y_range)
En MovieLens 100k la red no le gana claramente al producto punto con bias (el libro obtiene una loss
similar o un poco peor). Su ventaja es otra: como es una red tabular común (EmbeddingNN
hereda de TabularModel), se le pueden agregar columnas extra —fecha del rating, metadata del
usuario— sin cambiar la arquitectura. Eso es justamente lo que el producto punto no permite.
7. Interpretar embeddings: bias, distancias y PCA
Los bias son fáciles de leer directamente: ordenarlos da las películas peores/mejores de forma "intrínseca", más allá de gustos particulares — una película con bias bajo es una que la gente califica mal aunque sea del género que le gusta.
movie_bias = learn.model.movie_bias.weight.squeeze() # con collab_learner: learn.model.i_bias.weight.squeeze()
idxs = movie_bias.argsort()[:5] # las 5 de bias más bajo
[dls.classes['title'][i] for i in idxs]
idxs = movie_bias.argsort(descending=True)[:5] # las 5 más altas
dls.classes['title'] es el mapa de índice → título que CollabDataLoaders armó
al categorizar; sin él, el índice 312 no significa nada. Para películas parecidas, se
mide la distancia entre sus vectores de factores. La medida correcta es la similitud coseno
(el producto punto de los vectores normalizados a longitud 1): mide si dos vectores apuntan en la misma
dirección, sin que pese cuál es más largo — y la longitud de un embedding depende de cuántos ratings tuvo
la película, no de su contenido.
movie_factors = learn.model.i_weight.weight
idx = dls.classes['title'].o2i['Silence of the Lambs, The (1991)']
distances = nn.CosineSimilarity(dim=1)(movie_factors, movie_factors[idx][None])
idx = distances.argsort(descending=True)[1] # [0] es la propia película (similitud 1.0)
dls.classes['title'][idx]
'Dial M for Murder (1954)' (en el notebook del libro — un thriller, sin que nadie le dijera al modelo qué es un thriller)
Pero la matriz de embeddings (50 dimensiones) es imposible de inspeccionar a mano. Con PCA
se proyectan esas 50 dimensiones a 2-3 componentes principales (movie_w.pca(3) sobre las
1.000 películas más vistas), y al graficarlas aparecen agrupamientos que el modelo descubrió solo, sin
que nadie se los dijera explícitamente — un eje que separa cine de culto de cine comercial, por ejemplo,
emergente de puros ratings numéricos.
🔧 El mismo truco de interpretación que ya usaste
Proyectar embeddings de 50 dimensiones a 2-3 con PCA para ver qué "concepto" aprendió el modelo sin que
nadie se lo dijera es la misma lógica de interpretabilidad detrás del buscador semántico con FAISS del
Capítulo 5 de HF: en ambos casos, un
espacio vectorial de alta dimensión aprendido por gradiente termina codificando estructura semántica
real (similitud de películas, similitud de texto) que no fue programada a mano, solo emergió de
optimizar una tarea numérica simple. Y la similitud coseno de arriba es exactamente la que usa el índice
FAISS con vectores normalizados: IndexFlatIP sobre vectores de norma 1 es coseno.
8. Errores comunes
El modelo es diminuto; casi todo lo que falla está en los índices, el rango de salida y la regularización.
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| La loss baja lento y las predicciones salen 7,3 o -1,2 | y_range omitido: el producto punto no sabe que un rating vive en 1-5 y tiene que aprender el límite desde cero | sigmoid_range(res, 0, 5.5) — el techo un poco por encima del máximo real, porque la sigmoide nunca lo alcanza |
IndexError: index 1682 is out of range, o una tabla de embeddings gigante llena de filas nunca usadas | Los ids del dataset no son contiguos desde 0 (arrancan en 1, tienen huecos, o son strings) y los usaste como índice directo | Reindexá: CollabDataLoaders lo hace solo (dls.classes); a mano, pd.factorize o un dict id→índice, y guardá el mapa para inferencia |
| Loss de validación mejora 1-2 epochs y después sube, con training loss cayendo | Overfitting: más parámetros que ratings y ninguna regularización | wd=0.1 (o probar 0,01-0,3); menos epochs no es la solución, es el síntoma |
Con wd alto, training y validación quedan estancadas en una loss mala | Underfit: la penalización achica los embeddings hasta que no pueden representar nada | Bajá wd un orden de magnitud; el valor correcto depende del tamaño del dataset, no es universal |
| Un usuario nuevo recibe las mismas recomendaciones que "el usuario 0" o recomendaciones absurdas | Cold start ignorado: su embedding está en el valor inicial aleatorio | Decidí una política fuera del modelo: promedio, usuario tipo, o embedding predicho desde metadata de signup (§5) |
| "Las películas más parecidas" son siempre las mismas 5 blockbusters, para cualquier consulta | Comparaste con producto punto crudo: los embeddings de películas con muchos ratings son más largos y dominan | Similitud coseno (nn.CosineSimilarity) o normalizá los vectores antes de multiplicar |
| Entrena sin error pero la loss no tiene sentido (o explota la memoria con el batch) | Broadcasting silencioso: [64] + [64, 1] → [64, 64] | keepdim=True en la suma, y un assert res.shape == (bs, 1) mientras desarrollás |
9. Resumen
- Collaborative filtering recomienda a partir del comportamiento de usuarios similares, sin ninguna comprensión del contenido en sí — rellena las celdas vacías de una matriz usuarios × items.
- Latent factors son vectores aprendidos por gradiente, no elegidos a mano — el rating predicho es el producto punto usuario × película, más un bias por cada uno.
- Un embedding es matemáticamente idéntico a multiplicar por un one-hot vector — un atajo computacional, no un concepto especial. Es la misma capa que la tabla de tokens de un LLM.
- El bias por usuario y por película captura "calidad intrínseca", distinto de "afinidad de gustos" pura;
y_rangecon techo 5,5 acota la salida. - Con más parámetros que ratings, el modelo sobreajusta en dos epochs; weight decay (
grad += 2*wd*p) achica los pesos en cada paso y lo evita. Cold start no tiene solución dentro del modelo. collab_learnerempaqueta el producto punto con bias; conuse_nn=Truelo reemplaza por una red sobre embeddings concatenados de tamaños dados porget_emb_sz.- Para interpretar:
argsortsobre los bias, similitud coseno entre embeddings (nunca producto punto crudo), PCA para ver la estructura.
10. Ponete a prueba
Respondé antes de abrir cada respuesta. Si fallás una, volvé a la sección indicada.
1. ¿Por qué Embedding(n_users, 50)(x[:,0]) y user_factors.t() @ one_hot dan el mismo resultado, y cuál conviene?
Porque multiplicar una matriz por un vector one-hot con un 1 en la posición i selecciona exactamente la fila i. La capa Embedding indexa directamente (evita 944 multiplicaciones inútiles por fila) y calcula el gradiente como si hubiera multiplicado. Convienen siempre los embeddings. (§3)
2. ¿Qué captura el bias por película que el producto punto no puede capturar?
La calidad "intrínseca": si a la gente en general le gusta o no, independientemente de la afinidad temática con cada usuario. Sin bias, una película mala del género favorito de alguien se predeciría alta. (§4)
3. ¿Por qué y_range=(0, 5.5) y no (1, 5)?
La sigmoide se acerca asintóticamente a sus extremos pero nunca los alcanza; con techo 5 el modelo no podría predecir un 5 exacto. Un margen por encima del máximo real resuelve eso. (§4)
4. Sin weight decay, ¿qué ves en las curvas después de 5 epochs y por qué pasa tan rápido?
Training loss bajando, validación subiendo desde la epoch 2-3. Pasa porque el modelo tiene ~130.000 parámetros para 80.000 ratings: puede memorizarlos, y no hay data augmentation posible. (§5)
5. ¿Qué le pasa a cada peso en un paso de SGD con weight decay, en una frase?
Además de moverse contra el gradiente de la loss, se achica un poco proporcionalmente a su propio valor (p -= lr * 2 * wd * p), porque el gradiente de wd * p² es 2 * wd * p. (§5)
6. Para buscar películas parecidas, ¿por qué similitud coseno y no producto punto entre embeddings?
Porque el producto punto crece con la longitud de los vectores, y la longitud de un embedding refleja cuántos ratings tuvo la película, no su contenido; el coseno normaliza y compara solo la dirección. (§7)
11. Para practicar
✏️ Ejercicio 1 — DotProductBias en PyTorch puro, con y sin weight decay
Sin fastai ni dataset: generá ratings sintéticos a partir de factores "verdaderos" ocultos, entrená el modelo de §4 y compará la loss de validación con wd=0 y wd=0.1. Corre en CPU en menos de un minuto.
import torch, torch.nn as nn
torch.manual_seed(0)
n_users, n_items, k = 300, 400, 3
U, I = torch.randn(n_users, k), torch.randn(n_items, k)
bi = torch.randn(n_items) * 0.5
u = torch.randint(0, n_users, (12000,)); i = torch.randint(0, n_items, (12000,))
r = ((U[u]*I[i]).sum(1) + bi[i] + 3 + torch.randn(12000)*0.3).clamp(1, 5)
x = torch.stack([u, i], 1); tr, va = slice(0, 10000), slice(10000, None)
def sigmoid_range(x, lo, hi): return torch.sigmoid(x) * (hi - lo) + lo
class DotProductBias(nn.Module):
def __init__(self, nu, ni, nf=50, y_range=(0, 5.5)):
super().__init__()
self.uf, self.ub = nn.Embedding(nu, nf), nn.Embedding(nu, 1)
self.mf, self.mb = nn.Embedding(ni, nf), nn.Embedding(ni, 1)
self.y_range = y_range
def forward(self, x):
res = (self.uf(x[:,0]) * self.mf(x[:,1])).sum(1, keepdim=True)
res = res + self.ub(x[:,0]) + self.mb(x[:,1])
return sigmoid_range(res, *self.y_range).squeeze(1)
def run(wd, epochs=15, lr=5e-3, bs=64):
torch.manual_seed(1); m = DotProductBias(n_users, n_items)
opt = torch.optim.Adam(m.parameters(), lr=lr, weight_decay=wd)
for ep in range(epochs):
perm = torch.randperm(10000)
for b in perm.split(bs):
loss = ((m(x[tr][b]) - r[tr][b])**2).mean()
opt.zero_grad(); loss.backward(); opt.step()
with torch.no_grad():
print(f'wd={wd} ep{ep:2d} train {((m(x[tr])-r[tr])**2).mean():.3f} valid {((m(x[va])-r[va])**2).mean():.3f}')
run(0.0); run(0.1)
Pista / qué esperar
Con wd=0 la loss de entrenamiento se desploma y la de validación toca un mínimo temprano y después sube o se estanca alta: 50 factores para 10.000 ratings memorizan. Con wd=0.1 ambas bajan juntas y la validación termina claramente mejor. Probá wd=1.0 para ver el underfit: las dos curvas quedan estancadas cerca de la varianza total de los ratings. Nota: Adam aplica weight_decay como L2 clásico (sumado al gradiente), igual que el libro; AdamW lo desacopla — vale la pena probar ambos.
✏️ Ejercicio 2 — Vecinos por coseno vs. por producto punto
Con el modelo entrenado en el ejercicio anterior (m devuelto por run, modificá run para que haga return m), compará los 5 items más parecidos a uno dado usando coseno y usando producto punto crudo, y contá cuántas veces aparecen los mismos "populares" en el top-5 de distintas consultas.
W = m.mf.weight.detach()
def top5(idx, cos=True):
s = nn.CosineSimilarity(dim=1)(W, W[idx][None]) if cos else W @ W[idx]
return s.argsort(descending=True)[1:6].tolist()
from collections import Counter
for cos in (True, False):
c = Counter(j for q in range(50) for j in top5(q, cos))
print('coseno' if cos else 'dot ', 'items distintos en 50 top-5:', len(c), '| más repetido:', c.most_common(1))
print('normas: min', W.norm(dim=1).min().item(), 'max', W.norm(dim=1).max().item())
Pista / qué esperar
Con producto punto, unos pocos items de norma grande se cuelan en el top-5 de muchas consultas distintas (menos items distintos, un "más repetido" alto); con coseno los vecinos cambian con la consulta. Mirá la diferencia entre la norma mínima y máxima: esa dispersión es lo que el coseno cancela.
12. Fuentes y para seguir
- fastbook — 08_collab.ipynb — el capítulo completo: DotProduct, DotProductBias, weight decay desde cero, collab_learner, EmbeddingNN, bias, coseno y PCA. Todo el código de esta página sale de ahí.
- course.fast.ai — Lesson 7 — el video, con la explicación de embeddings como one-hot y la demostración en Excel del producto punto.
- fastai docs — Collaborative filtering — referencia de
CollabDataLoaders,collab_learner,EmbeddingDotBiasyEmbeddingNN. - GroupLens — MovieLens datasets — el dataset original (100k y versiones más grandes) con su documentación.
- Guo & Berkhahn, "Entity Embeddings of Categorical Variables" (2016) — los mismos embeddings de esta lección aplicados a cualquier columna categórica; el puente con la Lección 6.
- Koren, Bell & Volinsky, "Matrix Factorization Techniques for Recommender Systems" (IEEE Computer, 2009) — el artículo clásico que formaliza latent factors + bias + regularización, escrito por los ganadores del Netflix Prize.