<jupyter_start><jupyter_text>Unit 6: Model-based Collaborative Filtering for **Ranking** Prediction However, we still do Collaborative Filtering and Matrix Factorization in this unit, we do something fundamentally different: we change from rating prediction to **ranking prediction**.We achieve this by changing the optimization criterion. Instead of minimizing the deviation between true and predicted ratings we push positive and negative user-item combinationa as much as possible apart. We transform explicit user feedback into implicit feedback. Implicit feedback refers to user interaction without the purpose to reflect preference or disregard and is much more common in pactice. Ranking prediction algorithms tackle to learn from implicit feedback data.In addition, ranking-based algorithms yield a much more intuitive prediction result. Our goal is to present to the user a very limited amount of items in the correct ordering. Therefore, ordering is much more important than rating prediction. Ranking-based algorithms like BPR work pair-wise, i.e. for a user and two items they yield the correct order of both items for the user. Generalizing from this, we can impose an ordering on our item corpus and pick the top-$N$ to present to the user.<jupyter_code>from collections import OrderedDict
import itertools
from typing import Dict, List, Tuple

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from recsys_training.data import Dataset
from recsys_training.evaluation import get_relevant_items
ml100k_ratings_filepath = '../../data/raw/ml-100k/u.data'<jupyter_output><empty_output><jupyter_text>Load Data Different to previous units, we work with implicit feedback data now. However, MovieLens is an explicit feedback dataset, we can argue that everything above the user mean ratings is positive and everything below is negative. Bayesian Personalized Ranking learns from implicit positive feedback data and randomly samples negative feedback data during training. Thus, we keep all ratings above a threhold of $4.0$ and remove all other ratings.<jupyter_code>data = Dataset(ml100k_ratings_filepath)
data.filter(min_rating=4.0)
data.rating_split(seed=42)<jupyter_output><empty_output><jupyter_text>As we want to learn the user/item latent factors from rating data, we first randomly initialize them<jupyter_code>seed = 42
m = data.n_users
n = data.n_items
d = 10
# Latent Factor initialization
random_state = np.random.RandomState(seed)
user_factors = (random_state.rand(m, d) - 0.5) / d
item_factors = (random_state.rand(n, d) - 0.5) / d
        
ratings = data.train_ratings.sample(frac=1, random_state=seed)
# positive implicit feedback items
user_pos_items = {}
# corpus of all remaining items for every user
# Ask me about the "Non missing at random hypothesis" ;)
user_neg_items = {}
grouped = ratings[['user', 'item']].groupby('user')
groups = grouped.groups.keys()
for user in data.users:
    pos_items = []
    if user in groups:
        pos_items = grouped.get_group(user).item.values
    neg_items = np.setdiff1d(data.items, pos_items)
    user_pos_items[user] = pos_items
    user_neg_items[user] = neg_items<jupyter_output><empty_output><jupyter_text>Training Yes, there is some math involved:\begin{equation*}\hat{x}_{uij} = \hat{x}_{ui} - \hat{x}_{uj} \\x_{ui} = \sum_{f=1}^{d} w_{uf} \cdot h_{if}, i \in I_u^+ \\x_{uj} = \sum_{f=1}^{d} w_{uf} \cdot h_{jf}, j \in I_u^- \\\end{equation*}\begin{equation*}\text{BPR-Opt} := \sum_{(u,i,j) \in D_S} \ln\sigma(\hat{x}_{uijj}) - \lambda_{\Theta} \cdot ||\Theta||^2\end{equation*}\begin{equation*}\frac{\partial \text{BPR-Opt}}{\partial \Theta} = \frac{-e^{-\hat{x}_{uij}}}{1+e^{-\hat{x}_{uij}}} \cdot \frac{\partial \hat{x}_{uij}}{\partial \Theta} - \lambda_{\Theta} \cdot \Theta\end{equation*}\begin{equation*}\frac{\partial x_{uij}}{\partial \Theta} =\begin{cases}(h_{if}-h_{jf}) & \text{for } \Theta = w_{uf} \\w_{uf} & \text{for } \Theta = h_{if} \\-w_{uf} & \text{for } \Theta = h_{jf}\end{cases}\end{equation*} Let's talk about regularization!<jupyter_code>def sigmoid(x):
    return 1/(1+np.exp(-x))
def negative_sampling(user: int, user_neg_items: Dict[int, np.array]) -> int:
    """
    Return the item ids for negative samples
    """
    negative_item = np.random.choice(user_neg_items[user])
    
    return negative_item<jupyter_output><empty_output><jupyter_text>![](../Parrot.png)**Task:** Adapt the `compute_gradients` method from the unit before to realize stochastic gradient descent (SGD) for Bayesian Personalized Ranking.<jupyter_code>def compute_gradients(user_embed: np.array,
                      pos_item_embed: np.array,
                      neg_item_embed: np.array,
                      l2_decay: Dict[str, float]) -> Tuple[np.array, np.array, np.array]:
    
    pos_pred = np.sum(user_embed * pos_item_embed)
    neg_pred = np.sum(user_embed * neg_item_embed)
    pred = pos_pred - neg_pred

    generic_grad = (-np.exp(-pred) * sigmoid(pred))

    user_grad = generic_grad * (pos_item_embed - neg_item_embed)
    pos_item_grad = generic_grad * user_embed
    neg_item_grad = generic_grad * (-user_embed)

    user_grad += user_embed * l2_decay['user']
    pos_item_grad += pos_item_embed * l2_decay['pos']
    neg_item_grad += neg_item_embed * l2_decay['neg']

    return user_grad, pos_item_grad, neg_item_grad
def print_update(epoch: int, samples: np.array) -> float:
    # take the 1000 most recent ratings and compute the mean ranking loss
    users = samples[:, 0]
    pos_items = samples[:, 1]
    neg_items = np.array([negative_sampling(user, user_neg_items)
                          for user in users])

    user_embeds = user_factors[users - 1]
    pos_item_embeds = item_factors[pos_items - 1]
    neg_item_embeds = item_factors[neg_items - 1]

    pos_preds = np.sum(user_embeds * pos_item_embeds, axis=1)
    neg_preds = np.sum(user_embeds * neg_item_embeds, axis=1)
    preds = pos_preds - neg_preds

    loss = -np.log(sigmoid(preds)).mean()
    
    return loss<jupyter_output><empty_output><jupyter_text>Instead of minibatch gradient descent we do **stochastic gradient descent** (SGD) here. It just shrinks the batch size down to 1 instance.<jupyter_code>epochs = 30
learning_rate = 0.05
l2_decay = {'user': 0.002, 'pos': 0.0, 'neg': 0.002}
verbose = True
train_ratings_arr = ratings[['user', 'item']].values
test_ratings_arr = data.test_ratings[['user', 'item']].values
n_ratings = len(train_ratings_arr)
train_loss_trace = []
test_loss_trace = []

for epoch in range(epochs):

    for _ in range(len(ratings)):
        random_index = np.random.randint(n_ratings)
        user, pos_item = tuple(train_ratings_arr[random_index])
        neg_item = negative_sampling(user, user_neg_items)

        # Deduct 1 as user ids are 1-indexed, but array is 0-indexed
        user_embed = user_factors[user - 1]
        pos_item_embed = item_factors[pos_item - 1]
        neg_item_embed = item_factors[neg_item - 1]

        user_grad, pos_item_grad, neg_item_grad = \
            compute_gradients(user_embed,
                              pos_item_embed,
                              neg_item_embed,
                              l2_decay)

        user_factors[user - 1] -= learning_rate * user_grad
        item_factors[pos_item - 1] -= learning_rate * pos_item_grad
        item_factors[neg_item - 1] -= learning_rate * neg_item_grad

    if verbose:
        train_samples = train_ratings_arr[-1000:]
        test_samples = test_ratings_arr[-1000:]
        train_loss = print_update(epoch, train_samples)
        test_loss = print_update(epoch, test_samples)
        train_loss_trace.append(train_loss)
        test_loss_trace.append(test_loss)
        print(f"Epoch {epoch+1:02d}: Mean Ranking Loss - Train: {train_loss:.4f}, Mean Ranking Loss - Test: {test_loss:.4f}")
plt.figure(figsize=(12,8))
plt.plot(range(epochs), train_loss_trace, 'b--', label='Train')
plt.plot(range(epochs), test_loss_trace, 'g--', label='Test')
plt.grid(True)
plt.legend()
plt.show()<jupyter_output><empty_output><jupyter_text>Using the model for Recommendations We have now created a model to describe users and items in terms of latent vectors. But this time we fitted them to get the rankings correctly. So for obtaining recommendations we simply multiply user-item latent vectors we are interested in and achieve an estimate that can be used to order items for a given user. This time it is not a rating prediction, but still a prediction.For that, we can reuse the `get_prediction` method from previous units.Thus, before writing the `get_recommendations` again we first implement `get_prediction`.<jupyter_code>def get_prediction(user: int, items: np.array = None, remove_known_pos: bool = True) -> Dict[int, Dict[str, float]]:
    if items is None:
        if remove_known_pos:
            # Predict from unobserved items
            # We simplified this compared to the unit before
            items = user_neg_items[user]
        else:
            items = np.array(data.items)
    if type(items) == np.int64:
        items = np.array([items])
    
    user_embed = user_factors[user - 1].reshape(1, -1)
    item_embeds = item_factors[items - 1].reshape(len(items), -1)

    # use array-broadcasting
    preds = np.sum(user_embed * item_embeds, axis=1)
    sorting = np.argsort(preds)[::-1]
    preds = {item: {'pred': pred} for item, pred in
             zip(items[sorting], preds[sorting])}

    return preds
item_predictions = get_prediction(1)
list(item_predictions.items())[:20]
def get_recommendations(user: int, N: int, remove_known_pos: bool = False) -> List[Tuple[int, Dict[str, float]]]:
    predictions = get_prediction(user, remove_known_pos=remove_known_pos)
    recommendations = []
    for item, pred in predictions.items():
        add_item = (item, pred)
        recommendations.append(add_item)
        if len(recommendations) == N:
            break

    return recommendations
recommendations = get_recommendations(1, 10)
recommendations<jupyter_output><empty_output><jupyter_text>Evaluation<jupyter_code>N = 10
relevant_items = get_relevant_items(data.test_ratings)
users = relevant_items.keys()
prec_at_N = dict.fromkeys(data.users)

for user in users:
    recommendations = get_recommendations(user, N, remove_known_pos=True)
    recommendations = [val[0] for val in recommendations]
    hits = np.intersect1d(recommendations,
                          relevant_items[user])
    prec_at_N[user] = len(hits)/N
recommendations
np.mean([val for val in prec_at_N.values() if val is not None])<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.10765301295998486, 'fraction_numerical': 0.010311228833601362, 'mean_word_length': 3.991501416430595, 'pattern_counts': {'":': 0, '<': 33, '<?xml version=': 0, '>': 38, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '8', 'dir': 'jupyter-structured-clean-dedup', 'id': '185443', 'license': 'MIT', 'n_tokens_mistral': 3463, 'n_tokens_neox': 3315, 'n_words': 942, 'path': 'notebooks/solutions/6_s_cf_mf_ranking_pred.ipynb', 'repo_name': 'mkurovski/recsys_training', 'size': '10571'}