""" KRONECTOR - Prediction helpers. Inference must reuse the LabelEncoders fitted during training. Do not call prepare_model_data without the loaded encoders in production prediction paths. """ from __future__ import annotations import argparse import numpy as np import pandas as pd from ml.feature_engineering import load_encoders, prepare_model_data def load_model_and_encoders(run_id: str): """Load a logged MLflow model and its fitted categorical encoders.""" import mlflow.lightgbm from mlflow.artifacts import download_artifacts import logging logger = logging.getLogger(__name__) try: model = mlflow.lightgbm.load_model(f"runs:/{run_id}/model") except Exception as e: logger.warning(f"Could not load from runs:/ URI: {e}. Trying registered model...") try: model = mlflow.lightgbm.load_model("models:/kronector-f1-lgbm/latest") except Exception as e2: logger.error(f"Failed to load registered model: {e2}") raise encoder_path = download_artifacts( run_id=run_id, artifact_path="encoders/label_encoders.pkl" ) encoders = load_encoders(encoder_path) return model, encoders def _positive_class_shap_values(shap_values): """Normalize SHAP binary-class outputs to one 2D array.""" if isinstance(shap_values, list): return shap_values[1] if len(shap_values) > 1 else shap_values[0] if isinstance(shap_values, np.ndarray) and shap_values.ndim == 3: return shap_values[:, :, 1] return shap_values def _explain_predictions(model, X: pd.DataFrame) -> list[dict[str, float]]: import shap explainer = shap.TreeExplainer(model) shap_values = _positive_class_shap_values(explainer.shap_values(X)) return [ { feature_name: float(shap_value) for feature_name, shap_value in zip(X.columns, row_values) } for row_values in shap_values ] def predict_dataframe( df: pd.DataFrame, model, encoders: dict, explain: bool = True ) -> pd.DataFrame: """Return win probabilities and optional SHAP dictionaries.""" bundle, _ = prepare_model_data(df, encoders=encoders) if hasattr(model, "predict_proba"): probabilities = model.predict_proba(bundle.X)[:, 1] else: probabilities = model.predict(bundle.X) result = bundle.metadata.copy() result["win_probability"] = probabilities if explain: result["shap_values"] = _explain_predictions(model, bundle.X) return result def main() -> None: parser = argparse.ArgumentParser(description="Run KRONECTOR model inference") parser.add_argument("--run-id", required=True) parser.add_argument("--data-path", default="data_output/fastf1_races.parquet") args = parser.parse_args() model, encoders = load_model_and_encoders(args.run_id) df = pd.read_parquet(args.data_path) predictions = predict_dataframe(df, model, encoders) print(predictions.head().to_string(index=False)) if __name__ == "__main__": main()