| """Model loading and prediction wrapper. |
| |
| Loaded once at API startup (see api.main:lifespan) — never per-request. |
| Wraps an ONNX Runtime session (converted from the original LightGBM model via |
| scripts/export_to_onnx.py). Threshold is read from model_info.json with a |
| fallback to the value in api.settings. |
| |
| The constructor is duck-typed (predict_fn callable) so tests can inject a |
| fake without round-tripping through a real .onnx file. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
| from typing import Callable |
|
|
| import numpy as np |
| import onnxruntime as ort |
| import pandas as pd |
|
|
| from api.schemas import Decision |
|
|
| |
| |
| PredictFn = Callable[[np.ndarray], np.ndarray] |
|
|
|
|
| def resolve_threshold_and_version( |
| model_info_path: Path, default_threshold: float |
| ) -> tuple[float, str]: |
| """Parse threshold + version from model_info.json. |
| |
| Extracted so test fixtures can reuse the exact same parsing rules as the |
| production loader, instead of re-implementing the dict navigation. |
| """ |
| info = json.loads(model_info_path.read_text()) |
| threshold = float( |
| info.get("metrics", {}).get("best_threshold_mean", default_threshold) |
| ) |
| version = str(info.get("version", "unknown")) |
| return threshold, version |
|
|
|
|
| class CreditScoringPredictor: |
| """Singleton-style wrapper. Build once via load(), reuse for every request.""" |
|
|
| def __init__( |
| self, |
| predict_fn: PredictFn, |
| threshold: float, |
| model_version: str, |
| ) -> None: |
| self._predict_fn = predict_fn |
| self._threshold = threshold |
| self._model_version = model_version |
|
|
| @classmethod |
| def load( |
| cls, |
| model_path: Path, |
| model_info_path: Path, |
| default_threshold: float, |
| ) -> "CreditScoringPredictor": |
| threshold, version = resolve_threshold_and_version( |
| model_info_path, default_threshold |
| ) |
|
|
| |
| |
| |
| |
| sess_options = ort.SessionOptions() |
| sess_options.intra_op_num_threads = 1 |
| sess_options.inter_op_num_threads = 1 |
| session = ort.InferenceSession( |
| str(model_path), |
| sess_options=sess_options, |
| providers=["CPUExecutionProvider"], |
| ) |
| input_name = session.get_inputs()[0].name |
| |
| |
| proba_output_name = session.get_outputs()[1].name |
|
|
| def predict_fn(arr: np.ndarray) -> np.ndarray: |
| return session.run([proba_output_name], {input_name: arr})[0] |
|
|
| return cls( |
| predict_fn=predict_fn, |
| threshold=threshold, |
| model_version=version, |
| ) |
|
|
| @property |
| def threshold(self) -> float: |
| return self._threshold |
|
|
| @property |
| def model_version(self) -> str: |
| return self._model_version |
|
|
| def predict(self, features: pd.DataFrame) -> tuple[float, Decision]: |
| """Return (probability_of_default, decision).""" |
| |
| |
| |
| arr = features.to_numpy(dtype=np.float32) |
| proba = float(self._predict_fn(arr)[0, 1]) |
| decision: Decision = "REFUSED" if proba >= self._threshold else "GRANTED" |
| return proba, decision |
|
|