deepreads / src /models /classical.py
yash-b18
feat: all three models — naive popularity, LightGBM reranker, Two-Tower neural
fa98c11
Raw History Blame Contribute Delete
6.2 kB
"""
Classical ML model: LightGBM-based recommendation reranker.
Formulates recommendation as binary classification:
label = 1 if the user interacted positively with the item, else 0.
At inference, for each user we score a candidate pool of items (retrieved
by the naive popularity model) and return the top-K by predicted probability.
Explainability is provided via:
- Global feature importance (gain-based)
- Per-prediction SHAP values (computed by the explainability module)
"""
from __future__ import annotations
import pickle
from pathlib import Path
import lightgbm as lgb
import numpy as np
import pandas as pd
from src.utils.logging import get_logger
logger = get_logger(__name__)
class LightGBMReranker:
"""
LightGBM binary classifier used as a recommendation reranker.
Args:
n_estimators: Number of boosting rounds.
learning_rate: Gradient boosting learning rate.
num_leaves: Maximum number of leaves per tree.
max_depth: Maximum tree depth (-1 = unlimited).
min_child_samples: Minimum samples per leaf.
subsample: Row sub-sampling ratio.
colsample_bytree: Column sub-sampling ratio.
reg_alpha: L1 regularisation.
reg_lambda: L2 regularisation.
n_jobs: Parallel threads.
random_state: Random seed.
early_stopping_rounds: Early stopping patience.
"""
def __init__(
self,
n_estimators: int = 500,
learning_rate: float = 0.05,
num_leaves: int = 63,
max_depth: int = -1,
min_child_samples: int = 20,
subsample: float = 0.8,
colsample_bytree: float = 0.8,
reg_alpha: float = 0.1,
reg_lambda: float = 0.1,
n_jobs: int = -1,
random_state: int = 42,
early_stopping_rounds: int = 50,
) -> None:
self.params = dict(
n_estimators=n_estimators,
learning_rate=learning_rate,
num_leaves=num_leaves,
max_depth=max_depth,
min_child_samples=min_child_samples,
subsample=subsample,
colsample_bytree=colsample_bytree,
reg_alpha=reg_alpha,
reg_lambda=reg_lambda,
n_jobs=n_jobs,
random_state=random_state,
objective="binary",
metric="binary_logloss",
verbose=-1,
)
self.early_stopping_rounds = early_stopping_rounds
self.model: lgb.LGBMClassifier | None = None
self.feature_names: list[str] = []
def fit(
self,
train_df: pd.DataFrame,
val_df: pd.DataFrame,
feature_cols: list[str],
) -> "LightGBMReranker":
"""
Train the LightGBM classifier.
Args:
train_df: Training feature matrix with a 'label' column.
val_df: Validation feature matrix for early stopping.
feature_cols: Column names to use as features.
Returns:
self
"""
self.feature_names = feature_cols
X_train = train_df[feature_cols].values.astype(np.float32)
y_train = train_df["label"].values
X_val = val_df[feature_cols].values.astype(np.float32)
y_val = val_df["label"].values
self.model = lgb.LGBMClassifier(**self.params)
callbacks = [lgb.early_stopping(self.early_stopping_rounds, verbose=False),
lgb.log_evaluation(100)]
self.model.fit(
X_train, y_train,
eval_set=[(X_val, y_val)],
callbacks=callbacks,
feature_name=feature_cols,
)
logger.info(
"LightGBM trained for %d rounds (best: %d)",
self.params["n_estimators"],
self.model.best_iteration_,
)
return self
def predict_proba(self, df: pd.DataFrame) -> np.ndarray:
"""
Return positive-class probabilities for rows in df.
Args:
df: Feature DataFrame with columns matching feature_names.
Returns:
1-D numpy array of probabilities.
"""
if self.model is None:
raise RuntimeError("Model not fitted.")
X = df[self.feature_names].values.astype(np.float32)
return self.model.predict_proba(X)[:, 1]
def recommend(
self,
user_idx: int,
candidate_item_idxs: list[int],
feature_df: pd.DataFrame,
top_k: int = 10,
) -> list[tuple[int, float]]:
"""
Score a set of candidate items for a user and return the top-K.
Args:
user_idx: Encoded user index.
candidate_item_idxs: Pool of item indices to score.
feature_df: Pre-built feature rows for (user_idx, item_idx) pairs.
Must contain all feature_names columns.
top_k: Number of recommendations.
Returns:
List of (item_idx, score) sorted descending.
"""
scores = self.predict_proba(feature_df)
ranked = sorted(
zip(candidate_item_idxs, scores.tolist()),
key=lambda x: x[1],
reverse=True,
)
return ranked[:top_k]
def feature_importance(self, importance_type: str = "gain") -> pd.DataFrame:
"""
Return a DataFrame of feature importances sorted descending.
Args:
importance_type: 'gain' or 'split'.
Returns:
DataFrame with columns [feature, importance].
"""
if self.model is None:
raise RuntimeError("Model not fitted.")
importances = self.model.feature_importances_
return (
pd.DataFrame({"feature": self.feature_names, "importance": importances})
.sort_values("importance", ascending=False)
.reset_index(drop=True)
)
def save(self, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "wb") as f:
pickle.dump(self, f)
logger.info("Saved LightGBMReranker → %s", path)
@classmethod
def load(cls, path: Path) -> "LightGBMReranker":
with open(path, "rb") as f:
return pickle.load(f)