| """Unit tests for CreditScoringPredictor. |
| |
| The predictor is duck-typed on a `predict_fn` callable, so these tests inject |
| fake functions directly rather than round-tripping through a real .onnx file. |
| The full load() path (ONNX session + model_info.json) is exercised by the |
| integration tests with a real model. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| from pathlib import Path |
|
|
| import numpy as np |
| import pandas as pd |
| import pytest |
|
|
| from api.predictor import CreditScoringPredictor |
|
|
|
|
| def _fixed_predict_fn(proba: float): |
| """Build a predict_fn that always returns the given positive-class proba.""" |
|
|
| def fn(arr: np.ndarray) -> np.ndarray: |
| return np.array([[1 - proba, proba]]) |
|
|
| return fn |
|
|
|
|
| class _FakeOnnxSession: |
| """Stand-in for ort.InferenceSession used to test the load() contract. |
| |
| Exposes the same get_inputs/get_outputs/run surface as a real session |
| without requiring a real .onnx file. Used by tests that exercise |
| CreditScoringPredictor.load — keeps the session-wiring detail |
| out of every test body. |
| """ |
|
|
| def get_inputs(self): |
| return [type("X", (), {"name": "input"})()] |
|
|
| def get_outputs(self): |
| return [ |
| type("L", (), {"name": "label"})(), |
| type("P", (), {"name": "probabilities"})(), |
| ] |
|
|
| def run(self, _outputs, _feed): |
| return [np.array([[0.6, 0.4]])] |
|
|
|
|
| @pytest.fixture |
| def patch_onnx_session(monkeypatch): |
| """Replace ort.InferenceSession with the fake so load() can be unit-tested.""" |
| monkeypatch.setattr( |
| "api.predictor.ort.InferenceSession", lambda *_a, **_kw: _FakeOnnxSession() |
| ) |
|
|
|
|
| def _build(proba: float, threshold: float) -> CreditScoringPredictor: |
| return CreditScoringPredictor( |
| predict_fn=_fixed_predict_fn(proba), |
| threshold=threshold, |
| model_version="test", |
| ) |
|
|
|
|
| def _make_info(path: Path, threshold: float | None = 0.33) -> Path: |
| metrics = {"best_threshold_mean": threshold} if threshold is not None else {} |
| path.write_text(json.dumps({"version": "test-1", "metrics": metrics})) |
| return path |
|
|
|
|
| def test_threshold_property_round_trip(): |
| assert _build(proba=0.1, threshold=0.42).threshold == 0.42 |
|
|
|
|
| def test_model_version_property_round_trip(): |
| pred = CreditScoringPredictor( |
| predict_fn=_fixed_predict_fn(0.0), |
| threshold=0.5, |
| model_version="abc-123", |
| ) |
| assert pred.model_version == "abc-123" |
|
|
|
|
| @pytest.mark.parametrize( |
| "proba, expected", |
| [ |
| (0.10, "GRANTED"), |
| (0.50, "REFUSED"), |
| (0.33, "REFUSED"), |
| ], |
| ) |
| def test_decision_logic(proba, expected): |
| p, decision = _build(proba=proba, threshold=0.33).predict(pd.DataFrame([{"x": 1.0}])) |
| assert p == pytest.approx(proba) |
| assert decision == expected |
|
|
|
|
| def test_proba_is_continuous_not_label(): |
| """Regression: predict() must surface the proba, not a class label.""" |
| proba, _ = _build(proba=0.27, threshold=0.5).predict(pd.DataFrame([{"x": 1.0}])) |
| assert proba == pytest.approx(0.27) |
|
|
|
|
| def test_features_are_converted_to_float32(): |
| """The predict_fn must receive a float32 numpy array (ONNX requirement).""" |
| captured: dict[str, np.ndarray] = {} |
|
|
| def capturing_fn(arr: np.ndarray) -> np.ndarray: |
| captured["arr"] = arr |
| return np.array([[0.7, 0.3]]) |
|
|
| pred = CreditScoringPredictor( |
| predict_fn=capturing_fn, threshold=0.5, model_version="v1" |
| ) |
| pred.predict(pd.DataFrame([{"a": 1.0, "b": 2.0}])) |
|
|
| assert captured["arr"].dtype == np.float32 |
| assert captured["arr"].shape == (1, 2) |
|
|
|
|
| def test_load_reads_threshold_from_model_info(tmp_path, patch_onnx_session): |
| """load() must resolve threshold from model_info.json without touching ONNX.""" |
| info_path = _make_info(tmp_path / "info.json", threshold=0.42) |
|
|
| pred = CreditScoringPredictor.load( |
| model_path=tmp_path / "dummy.onnx", |
| model_info_path=info_path, |
| default_threshold=0.5, |
| ) |
| assert pred.threshold == 0.42 |
| assert pred.model_version == "test-1" |
|
|
|
|
| def test_load_falls_back_to_default_threshold(tmp_path, patch_onnx_session): |
| info_path = _make_info(tmp_path / "info.json", threshold=None) |
|
|
| pred = CreditScoringPredictor.load( |
| model_path=tmp_path / "dummy.onnx", |
| model_info_path=info_path, |
| default_threshold=0.7, |
| ) |
| assert pred.threshold == 0.7 |
|
|