deepreads / scripts /run_experiment.py
yash-b18
feat: evaluation metrics, model evaluator, ablation experiment, error analysis
dd33d52
Raw History Blame Contribute Delete
7.94 kB
"""
Script: run_experiment.py
Feature Ablation Experiment.
Compares LightGBM performance across four feature tiers:
1. id_only β€” encoded user/item IDs
2. id_metadata β€” + item stats, category, brand, price
3. id_metadata_text β€” + user-item title cosine similarity
4. id_metadata_text_history β€” + user history aggregates (full set)
For each tier: train LightGBM β†’ evaluate on test β†’ record NDCG@10 and Recall@10.
Outputs:
data/outputs/experiment/ablation_results.csv
data/outputs/experiment/ablation_ndcg.png
data/outputs/experiment/ablation_recall.png
Usage:
python scripts/run_experiment.py
python scripts/run_experiment.py --debug
"""
from __future__ import annotations
import argparse
import pickle
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import matplotlib.pyplot as plt
import pandas as pd
from src.config import MODELS_DIR, PROCESSED_DIR, ensure_dirs, get_config
from src.evaluation.metrics import compute_all_metrics
from src.features.builder import FEATURE_SETS, build_feature_matrix
from src.features.text_features import build_user_text_profiles
from src.models.classical import LightGBMReranker
from src.models.naive import GlobalPopularityRecommender
from src.utils.logging import get_logger
logger = get_logger(__name__)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run feature ablation experiment.")
parser.add_argument("--debug", action="store_true")
parser.add_argument("--k", type=int, default=10, help="Primary K for the experiment table.")
return parser.parse_args()
def train_and_evaluate_tier(
feature_set: str,
train_df: pd.DataFrame,
val_df: pd.DataFrame,
test_df: pd.DataFrame,
user_features: pd.DataFrame,
item_features: pd.DataFrame,
user_profiles: pd.DataFrame,
vectorizer,
naive_model: GlobalPopularityRecommender,
cfg,
candidate_pool_size: int = 100,
k: int = 10,
) -> dict[str, float]:
"""Train a LightGBM model for a given feature tier and evaluate it."""
logger.info(" Feature set: %s", feature_set)
# Build feature matrices for this tier
train_feat, feat_cols = build_feature_matrix(
train_df, user_features, item_features, user_profiles, vectorizer,
feature_set=feature_set,
)
val_feat, _ = build_feature_matrix(
val_df, user_features, item_features, user_profiles, vectorizer,
feature_set=feature_set,
)
# Train
model = LightGBMReranker(
n_estimators=200 if cfg.data.debug else 500,
learning_rate=0.05,
num_leaves=31,
random_state=cfg.data.random_seed,
early_stopping_rounds=30,
)
model.fit(train_feat, val_feat, feat_cols)
# Evaluate using candidate pool from naive model
gt_map = test_df.groupby("user_idx")["item_idx"].apply(list).to_dict()
all_recs: list[list[int]] = []
all_gts: list[list[int]] = []
for uid, gt in gt_map.items():
candidates = naive_model.recommend(uid, top_k=candidate_pool_size)
if not candidates:
all_recs.append([])
all_gts.append(gt)
continue
candidate_items = [iid for iid, _ in candidates]
rows = pd.DataFrame({"user_idx": uid, "item_idx": candidate_items, "label": 0})
feat, _ = build_feature_matrix(
rows, user_features, item_features, user_profiles, vectorizer,
feature_set=feature_set,
)
scores = model.predict_proba(feat)
ranked = sorted(zip(candidate_items, scores.tolist()), key=lambda x: x[1], reverse=True)
all_recs.append([i for i, _ in ranked[:k]])
all_gts.append(gt)
metrics = compute_all_metrics(all_recs, all_gts, k_values=[k])
logger.info(" NDCG@%d=%.4f Recall@%d=%.4f", k, metrics[f"NDCG@{k}"], k, metrics[f"Recall@{k}"])
return metrics
def plot_ablation(results_df: pd.DataFrame, metric: str, output_path: Path) -> None:
fig, ax = plt.subplots(figsize=(9, 5))
bars = ax.bar(results_df["feature_set"], results_df[metric], color="#3b82f6", edgecolor="white")
for bar, val in zip(bars, results_df[metric]):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.002,
f"{val:.4f}", ha="center", va="bottom", fontsize=10)
ax.set_xlabel("Feature Set")
ax.set_ylabel(metric)
ax.set_title(f"Feature Ablation Experiment β€” {metric}")
ax.set_ylim(0, results_df[metric].max() * 1.25)
plt.xticks(rotation=15, ha="right")
plt.tight_layout()
output_path.parent.mkdir(parents=True, exist_ok=True)
fig.savefig(output_path, dpi=150)
plt.close(fig)
logger.info("Ablation plot saved β†’ %s", output_path)
def main() -> None:
args = parse_args()
cfg = get_config(debug=args.debug)
ensure_dirs(cfg)
exp_dir = cfg.experiment.output_dir
exp_dir.mkdir(parents=True, exist_ok=True)
logger.info("Loading data…")
train_df = pd.read_parquet(cfg.data.train_path)
val_df = pd.read_parquet(cfg.data.val_path)
test_df = pd.read_parquet(cfg.data.test_path)
item_features = pd.read_parquet(cfg.data.item_features_path)
user_features = pd.read_parquet(cfg.data.user_features_path)
for split in (val_df, test_df):
if "label" not in split.columns:
split["label"] = (split["rating"] >= cfg.data.positive_rating_threshold).astype(int)
# Load feature artifacts
with open(PROCESSED_DIR / "feature_artifacts.pkl", "rb") as f:
artifacts = pickle.load(f)
vectorizer = artifacts["vectorizer"]
user_profiles = build_user_text_profiles(train_df, item_features, cfg.data.positive_rating_threshold)
# Load naive model for candidate generation
naive_model = GlobalPopularityRecommender.load(MODELS_DIR / "naive_baseline.pkl")
logger.info("=== Feature Ablation Experiment ===")
rows = []
for fset in FEATURE_SETS:
metrics = train_and_evaluate_tier(
feature_set=fset,
train_df=train_df,
val_df=val_df,
test_df=test_df,
user_features=user_features,
item_features=item_features,
user_profiles=user_profiles,
vectorizer=vectorizer,
naive_model=naive_model,
cfg=cfg,
candidate_pool_size=cfg.classical.candidate_pool_size,
k=args.k,
)
rows.append({"feature_set": fset, **metrics})
results_df = pd.DataFrame(rows)
results_path = exp_dir / "ablation_results.csv"
results_df.to_csv(results_path, index=False)
logger.info("Ablation results saved β†’ %s", results_path)
# Plots
ndcg_col = f"NDCG@{args.k}"
recall_col = f"Recall@{args.k}"
if ndcg_col in results_df.columns:
plot_ablation(results_df, ndcg_col, exp_dir / "ablation_ndcg.png")
if recall_col in results_df.columns:
plot_ablation(results_df, recall_col, exp_dir / "ablation_recall.png")
print("\n=== Ablation Results ===")
print(results_df.to_string(index=False))
# Interpretation summary
summary_path = exp_dir / "ablation_interpretation.md"
best_row = results_df.loc[results_df[ndcg_col].idxmax()] if ndcg_col in results_df else None
summary_lines = [
"# Feature Ablation Experiment β€” Interpretation\n",
f"Metric: {ndcg_col} on test set.\n",
"## Results\n",
results_df.to_markdown(index=False),
"",
"## Key Findings\n",
]
if best_row is not None:
summary_lines.append(
f"- Best feature set: **{best_row['feature_set']}** "
f"({ndcg_col} = {best_row[ndcg_col]:.4f})"
)
summary_path.write_text("\n".join(summary_lines))
logger.info("Interpretation saved β†’ %s", summary_path)
logger.info("Experiment complete.")
if __name__ == "__main__":
main()