Curso / fast.ai / Lección 7
fast.ai · 7 de 14

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 DotProductBias en PyTorch, entrenarlo con collab_learner con y sin red neuronal, y elegir y_range, n_factors y wd con 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.

Latent factors: un rating predicho es el producto punto entre el vector del usuario y el de la película Un usuario representado por tres factores: 0,9, 0,8 y menos 0,6. Una space opera: 0,98, 0,9 y menos 0,9; el producto punto da 2,14, alto, porque coinciden en los tres ejes. Casablanca: menos 0,99, menos 0,3 y 0,8; el producto punto da menos 1,61, porque los signos se oponen. En la práctica los factores no se escriben a mano: se aprenden por gradiente para que los productos coincidan con los ratings conocidos. ejes latentes: sci-fi · acción · qué tan «no vieja» · en la práctica 50, y nadie les pone nombre usuario +0,90 +0,80 -0,60 space opera +0,98 +0,90 -0,90 Casablanca -0,99 -0,30 +0,80 producto término a término: usuario × película +0,88 +0,72 +0,54 = +2,14 -0,89 -0,24 -0,48 = -1,61 alto: coinciden en los tres ejes bajo: signos opuestos restan 0 la matriz usuarios × películas está 94 % vacía (100.000 ratings sobre 943 × 1.682 celdas): el modelo la rellena con estos productos los factores no se eligen a mano: gradient descent los ajusta para que los productos coincidan con los ratings conocidos; después se suma un bias por usuario y por película
El ejemplo manual del libro con las cuentas hechas. Cada término del producto punto es un «acuerdo» en un eje: signos iguales suman, opuestos restan. Lo que collaborative filtering aprende son estos vectores, sin que nadie le diga qué significa cada eje.

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 t y el token en la posición s se computa mediante el producto escalar de sus proyecciones:

Score(t,s)=qt⊤ks=(xtWQ)(xsWK)⊤

En Collaborative Filtering, la afinidad predicha entre el usuario u y la película i es:

r^u,i=pu⊤qi

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.

Un embedding es multiplicar por un one-hot, con un atajo de indexado Un vector one-hot de 944 posiciones con un 1 en la posición 3, multiplicado por la matriz de factores de 944 por 5, devuelve exactamente la fila 3. La capa Embedding hace lo mismo indexando la fila directamente, sin las 944 multiplicaciones, y calcula el gradiente como si hubiera multiplicado: solo toca la fila usada. one_hot(3) · [944] 0 0 0 1 0 … 0 (944 en total) × user_factors · [944, 5] fila 3 ⋮ 944 filas = resultado · [5] la fila 3, tal cual 944 multiplicaciones por columna para conservar una sola fila Embedding(944, 5)(tensor([3])) == user_factors[3] indexa directo; el gradiente solo toca la fila 3, como si hubiera multiplicado la misma capa que la tabla de tokens de un LLM: un id entero entra, un vector aprendido sale y la misma que Categorify + embeddings usa en tabular para una columna categórica
Nada mágico: buscar por índice y multiplicar por un one-hot son la misma operación, y la capa 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 ru,i descomponiéndola en la media global μ, un sesgo de usuario bu, un sesgo de item bi, y la interacción de factores latentes pu,qi∈RK:

r^u,i=μ+bu+bi+pu⊤qi=μ+bu+bi+∑k=1Kpu,kqi,k

La función de costo regularizada sobre el conjunto de ratings observados R con coeficiente λ es:

L=1|R|∑(u,i)∈R(ru,i−r^u,i)2+λ(∑u∥pu∥22+∑i∥qi∥22+∑ubu2+∑ibi2)

Definiendo el residuo como eu,i=ru,i−r^u,i, las derivadas analíticas que ejecuta SGD son:

∂L∂pu=−2eu,iqi+2λpu⟹pu←pu+η(2eu,iqi−2λpu)
∂L∂qi=−2eu,ipu+2λqi⟹qi←qi+η(2eu,ipu−2λqi)

Notá la simetría interactiva: el gradiente que actualiza los gustos del usuario pu es proporcional al vector de la película qi ponderado por el error cometido. Si la película fue subestimada (eu,i>0), pu rota en la dirección de qi 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 ∑pkqk: el factor k del usuario solo interactúa con el factor k del item. No puede capturar interacciones cruzadas complejas (por ejemplo: a un usuario le gusta un actor p1 si y solo si el género es comedia q2).

NCF resuelve esto concatenando los embeddings en un vector continuo z0=[pu∥qi]∈Rdu+di y procesándolo con un Perceptrón Multicapa (MLP):

z1=ReLU(W1z0+b1),z2=ReLU(W2z1+b2),r^u,i=σrange(W3z2+b3)

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íntomaCausa probableQué hacer
La loss baja lento y las predicciones salen 7,3 o -1,2y_range omitido: el producto punto no sabe que un rating vive en 1-5 y tiene que aprender el límite desde cerosigmoid_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 usadasLos ids del dataset no son contiguos desde 0 (arrancan en 1, tienen huecos, o son strings) y los usaste como índice directoReindexá: 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 cayendoOverfitting: más parámetros que ratings y ninguna regularizaciónwd=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 malaUnderfit: la penalización achica los embeddings hasta que no pueden representar nadaBajá 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 absurdasCold start ignorado: su embedding está en el valor inicial aleatorioDecidí 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 consultaComparaste con producto punto crudo: los embeddings de películas con muchos ratings son más largos y dominanSimilitud 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

  1. 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.
  2. 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.
  3. 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.
  4. El bias por usuario y por película captura "calidad intrínseca", distinto de "afinidad de gustos" pura; y_range con techo 5,5 acota la salida.
  5. 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.
  6. collab_learner empaqueta el producto punto con bias; con use_nn=True lo reemplaza por una red sobre embeddings concatenados de tamaños dados por get_emb_sz.
  7. Para interpretar: argsort sobre 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