| """ |
| Inverse Propensity Scoring (IPS) for debiasing training data. |
| |
| Motivation: popular items are over-represented in observed ratings because |
| users are more likely to watch (and hence rate) popular content. Training |
| on raw ratings therefore amplifies popularity bias. IPS corrects for this |
| by down-weighting popular items (high propensity) and up-weighting rare |
| items (low propensity) so the model learns unbiased preferences. |
| |
| Propensity model: P(item exposed | user) β item_popularity^alpha |
| alpha=0 β uniform (no correction) |
| alpha=1 β full popularity-proportional propensity |
| alpha=0.5 β moderate correction (default, reduces variance vs alpha=1) |
| """ |
|
|
| import logging |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| def compute_item_popularity(ratings: pd.DataFrame) -> pd.Series: |
| """Returns raw interaction count per movie_idx.""" |
| return ratings.groupby("movie_idx")["rating"].count() |
|
|
|
|
| def estimate_propensity( |
| ratings: pd.DataFrame, |
| num_movies: int, |
| alpha: float = 0.5, |
| ) -> np.ndarray: |
| """ |
| Estimates exposure probability for each movie. |
| Returns array of shape (num_movies,) in [0, 1]. |
| """ |
| popularity = compute_item_popularity(ratings) |
|
|
| |
| counts = np.ones(num_movies, dtype=np.float64) |
| for movie_idx, cnt in popularity.items(): |
| if 0 <= movie_idx < num_movies: |
| counts[movie_idx] = float(cnt) |
|
|
| |
| propensity = counts ** alpha |
| |
| propensity = propensity / propensity.max() |
| return propensity.astype(np.float32) |
|
|
|
|
| def compute_ips_weights( |
| ratings: pd.DataFrame, |
| num_movies: int, |
| alpha: float = 0.5, |
| cap: float = 10.0, |
| ) -> np.ndarray: |
| """ |
| Computes per-sample IPS weights for the ratings DataFrame. |
| |
| IPS weight = 1 / propensity, capped at `cap` to bound variance. |
| Returns array of shape (len(ratings),). |
| """ |
| propensity = estimate_propensity(ratings, num_movies, alpha=alpha) |
|
|
| movie_idxs = ratings["movie_idx"].values |
| sample_propensity = propensity[movie_idxs] |
|
|
| |
| sample_propensity = np.clip(sample_propensity, 1e-6, None) |
| weights = 1.0 / sample_propensity |
|
|
| |
| weights = np.clip(weights, 1.0, cap) |
|
|
| |
| weights = weights / weights.mean() |
|
|
| logger.info( |
| f"IPS weights β mean: {weights.mean():.3f}, " |
| f"max: {weights.max():.3f}, " |
| f"min: {weights.min():.3f}" |
| ) |
| return weights.astype(np.float32) |
|
|
|
|
| def attach_ips_weights( |
| ratings: pd.DataFrame, |
| num_movies: int, |
| alpha: float = 0.5, |
| cap: float = 10.0, |
| ) -> pd.DataFrame: |
| """Adds an 'ips_weight' column to the ratings DataFrame in-place.""" |
| weights = compute_ips_weights(ratings, num_movies, alpha=alpha, cap=cap) |
| df = ratings.copy() |
| df["ips_weight"] = weights |
| return df |
|
|