aaa / training /train_recommender.py
work-sejal
Deploy AI service with FastAPI
70ea7be
Raw
History Blame Contribute Delete
12.9 kB
"""Recommender training pipeline.
Trains two GradientBoostingClassifier models for recommendation outcome prediction.
Targets: clicked (binary), is_completed (binary) — trained as two separate models.
Features: priority (encoded), ai_confidence, recommendation_type (encoded),
grade, subject (encoded).
Primary metric: ROC-AUC for clicked.
"""
import logging
from datetime import datetime, timezone
import numpy as np
import pandas as pd
from sklearn.ensemble import GradientBoostingClassifier
from sklearn.metrics import roc_auc_score
from sklearn.preprocessing import OrdinalEncoder
from app.core.config import settings
from app.core.exceptions import TrainingError
from training.base_trainer import BaseTrainer, TrainingResult
logger = logging.getLogger(__name__)
FEATURE_COLUMNS = ["priority", "ai_confidence", "recommendation_type", "grade", "subject"]
CATEGORICAL_COLUMNS = ["priority", "recommendation_type", "subject"]
NUMERIC_COLUMNS = ["ai_confidence", "grade"]
TARGET_COLUMNS = ["clicked", "is_completed"]
class RecommenderTrainer(BaseTrainer):
"""GradientBoostingClassifier for recommendation outcome prediction.
Targets: clicked (binary), is_completed (binary) — trained as two separate models
Features: priority (encoded), ai_confidence, recommendation_type (encoded),
grade, subject (encoded)
Primary metric: ROC-AUC for clicked
"""
@property
def model_name(self) -> str:
return "recommender"
@property
def model_version(self) -> str:
return "recommender_v2_baseline_001"
@property
def table_name(self) -> str:
return "training_recommendation_outcomes"
def _build_features(
self, df: pd.DataFrame, encoder: OrdinalEncoder, fit: bool = False
) -> np.ndarray:
"""Build feature matrix from DataFrame using OrdinalEncoder.
Args:
df: DataFrame with feature columns.
encoder: OrdinalEncoder instance.
fit: If True, fit the encoder on the data first.
Returns:
Feature matrix as numpy array.
"""
if fit:
encoder.fit(df[CATEGORICAL_COLUMNS])
X_cat = encoder.transform(df[CATEGORICAL_COLUMNS])
X_num = df[NUMERIC_COLUMNS].values
return np.hstack([X_num, X_cat])
def train(self, train_df: pd.DataFrame, val_df: pd.DataFrame) -> dict:
"""Train two GradientBoostingClassifier models for clicked and is_completed.
Algorithm:
1. Encode categorical columns (priority, recommendation_type, subject)
with OrdinalEncoder
2. Train GBC for 'clicked': GradientBoostingClassifier(
n_estimators=100, max_depth=4, random_state=seed)
3. Train GBC for 'is_completed': same hyperparameters
4. Return {"model": {"clicked": gbc_clicked, "is_completed": gbc_completed},
"encoder": ordinal_enc,
"feature_columns.json": FEATURE_COLUMNS}
"""
# Fit OrdinalEncoder on categorical columns
ordinal_enc = OrdinalEncoder(
handle_unknown="use_encoded_value",
unknown_value=-1,
)
# Build feature matrix
X_train = self._build_features(train_df, ordinal_enc, fit=True)
# Train GBC for 'clicked'
y_clicked = train_df["clicked"].values.astype(int)
gbc_clicked = GradientBoostingClassifier(
n_estimators=100,
max_depth=4,
random_state=self._seed,
)
gbc_clicked.fit(X_train, y_clicked)
# Train GBC for 'is_completed'
y_completed = train_df["is_completed"].values.astype(int)
gbc_completed = GradientBoostingClassifier(
n_estimators=100,
max_depth=4,
random_state=self._seed,
)
gbc_completed.fit(X_train, y_completed)
logger.info(
"Recommender trained — %d samples, %d features, "
"clicked positive rate: %.2f%%, is_completed positive rate: %.2f%%",
X_train.shape[0],
X_train.shape[1],
100.0 * y_clicked.sum() / len(y_clicked),
100.0 * y_completed.sum() / len(y_completed),
)
return {
"model": {"clicked": gbc_clicked, "is_completed": gbc_completed},
"encoder": ordinal_enc,
"feature_columns.json": FEATURE_COLUMNS,
}
def _compute_lift_at_10(
self, y_true: np.ndarray, y_proba: np.ndarray
) -> float:
"""Compute lift@10: ratio of positive rate in top-10% predicted vs overall.
Lift@10 = (positive rate in top 10% by predicted probability) /
(overall positive rate)
"""
n = len(y_true)
if n == 0:
return 0.0
overall_positive_rate = y_true.sum() / n
if overall_positive_rate == 0.0:
return 0.0
# Top 10% by predicted probability
top_k = max(1, int(np.ceil(n * 0.10)))
top_indices = np.argsort(y_proba)[::-1][:top_k]
top_positive_rate = y_true[top_indices].sum() / top_k
lift = top_positive_rate / overall_positive_rate
return round(lift, 4)
def evaluate(self, artifacts: dict, df: pd.DataFrame, split_name: str) -> dict:
"""Evaluate model on a split.
Computes: ROC-AUC for clicked, ROC-AUC for is_completed,
lift@10 for each target.
"""
models = artifacts["model"]
encoder = artifacts["encoder"]
# Build feature matrix
X = self._build_features(df, encoder)
metrics = {}
for target in TARGET_COLUMNS:
model = models[target]
y_true = df[target].values.astype(int)
y_proba = model.predict_proba(X)[:, 1]
# ROC-AUC
try:
roc_auc = roc_auc_score(y_true, y_proba)
except ValueError:
# Only one class present in y_true
roc_auc = 0.0
# Lift@10
lift_10 = self._compute_lift_at_10(y_true, y_proba)
metrics[f"roc_auc_{target}"] = round(roc_auc, 4)
metrics[f"lift_at_10_{target}"] = lift_10
logger.info(
"%s metrics — ROC-AUC clicked: %.4f, ROC-AUC is_completed: %.4f, "
"lift@10 clicked: %.4f, lift@10 is_completed: %.4f",
split_name,
metrics["roc_auc_clicked"],
metrics["roc_auc_is_completed"],
metrics["lift_at_10_clicked"],
metrics["lift_at_10_is_completed"],
)
return metrics
def _check_baseline(self, metrics: dict) -> None:
"""Verify ROC-AUC for clicked > 0.50 (above random).
Raises TrainingError if not met.
"""
test_metrics = metrics.get("metrics", {}).get("test", {})
roc_auc_clicked = test_metrics.get("roc_auc_clicked")
# Fallback to validation metrics if test not available
if roc_auc_clicked is None:
val_metrics = metrics.get("metrics", {}).get("validation", {})
roc_auc_clicked = val_metrics.get("roc_auc_clicked")
if roc_auc_clicked is None:
raise TrainingError(
"Cannot compute baseline: roc_auc_clicked not found in metrics.",
model_name=self.model_name,
)
if roc_auc_clicked <= 0.50:
raise TrainingError(
f"ROC-AUC for clicked ({roc_auc_clicked:.4f}) does not exceed "
f"baseline (0.50). Model fails to predict click engagement.",
model_name=self.model_name,
)
logger.info(
"Baseline check passed — ROC-AUC clicked %.4f > 0.50", roc_auc_clicked
)
def _build_metrics(
self,
val_metrics: dict,
test_metrics: dict,
train_df: pd.DataFrame,
val_df: pd.DataFrame,
test_df: pd.DataFrame,
) -> dict:
"""Assemble full metrics.json content."""
return {
"model_name": self.model_name,
"model_version": self.model_version,
"dataset_version": settings.ai_service_version,
"trained_at": datetime.now(timezone.utc).isoformat(),
"seed": self._seed,
"split_counts": {
"train": len(train_df),
"validation": len(val_df),
"test": len(test_df),
},
"metrics": {
"validation": val_metrics,
"test": test_metrics,
},
"limitations": [
"Trained on synthetic data only.",
"Two separate GBC models — no joint optimization of clicked + is_completed.",
"OrdinalEncoder assumes an ordering for priority/recommendation_type/subject.",
"Lift@10 depends on the distribution of positive labels in the dataset.",
"No user-level features (e.g., engagement history) included in baseline.",
],
}
def _build_training_config(
self,
train_df: pd.DataFrame,
val_df: pd.DataFrame,
test_df: pd.DataFrame,
) -> dict:
"""Build training_config.json with hyperparameters."""
return {
"model_name": self.model_name,
"model_version": self.model_version,
"dataset_version": settings.ai_service_version,
"seed": self._seed,
"split_counts": {
"train": len(train_df),
"validation": len(val_df),
"test": len(test_df),
},
"hyperparameters": {
"n_estimators": 100,
"max_depth": 4,
"random_state": self._seed,
"algorithm": "GradientBoostingClassifier",
"encoder": "OrdinalEncoder",
},
"feature_columns": FEATURE_COLUMNS,
"categorical_columns": CATEGORICAL_COLUMNS,
"numeric_columns": NUMERIC_COLUMNS,
"target_columns": TARGET_COLUMNS,
"algorithm": "GradientBoostingClassifier",
}
def _build_model_card(self, metrics: dict) -> str:
"""Generate model_card.md content."""
val_metrics = metrics.get("metrics", {}).get("validation", {})
test_metrics = metrics.get("metrics", {}).get("test", {})
card = f"""# Model Card: Recommender
## Model Details
- **Model Name:** {self.model_name}
- **Model Version:** {self.model_version}
- **Algorithm:** GradientBoostingClassifier (two models: clicked, is_completed)
- **Framework:** scikit-learn
- **Trained At:** {metrics.get("trained_at", "N/A")}
- **Seed:** {self._seed}
## Intended Use
Predict whether a student will click on a recommendation and whether they will
complete the recommended content. Used in the recommendation engine to rank
content by predicted engagement. Two separate binary classifiers are trained:
one for `clicked` and one for `is_completed`.
## Training Data
- **Source:** training_recommendation_outcomes.csv (synthetic dataset v2)
- **Split Counts:** train={metrics.get("split_counts", {}).get("train", "N/A")}, \
validation={metrics.get("split_counts", {}).get("validation", "N/A")}, \
test={metrics.get("split_counts", {}).get("test", "N/A")}
- **Features:** priority (OrdinalEncoded), ai_confidence (numeric), \
recommendation_type (OrdinalEncoded), grade (numeric), subject (OrdinalEncoded)
- **Targets:** clicked (binary), is_completed (binary)
## Metrics
### Validation Set
- ROC-AUC (clicked): {val_metrics.get("roc_auc_clicked", "N/A")}
- ROC-AUC (is_completed): {val_metrics.get("roc_auc_is_completed", "N/A")}
- Lift@10 (clicked): {val_metrics.get("lift_at_10_clicked", "N/A")}
- Lift@10 (is_completed): {val_metrics.get("lift_at_10_is_completed", "N/A")}
### Test Set
- ROC-AUC (clicked): {test_metrics.get("roc_auc_clicked", "N/A")}
- ROC-AUC (is_completed): {test_metrics.get("roc_auc_is_completed", "N/A")}
- Lift@10 (clicked): {test_metrics.get("lift_at_10_clicked", "N/A")}
- Lift@10 (is_completed): {test_metrics.get("lift_at_10_is_completed", "N/A")}
## Known Limitations
- Trained on synthetic data only — performance on real recommendation data is unknown.
- Two separate GBC models — no joint optimization of clicked + is_completed.
- OrdinalEncoder assumes an ordering for priority/recommendation_type/subject.
- Lift@10 depends on the distribution of positive labels in the dataset.
- No user-level features (e.g., engagement history) included in baseline.
- Limited feature set (5 features); adding student history could improve performance.
## Fallback Behavior
When the model is not loaded or confidence is below threshold, the system
falls back to knowledge-graph weakest-prerequisite + content_catalog filtered
by LO + difficulty, ranked by estimated_mastery_gain.
"""
return card