diff --git a/pyproject.toml b/pyproject.toml index a82b913..31dbc7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kz-scoring-api" -version = "0.5.1" +version = "0.6.0" description = "Synchronous REST facade for Beeline-initiator PKB lookups via vaultee-pipelines." requires-python = ">=3.11" dependencies = [ diff --git a/src/kz_scoring_api/scoring.py b/src/kz_scoring_api/scoring.py index d9491a9..53c515a 100644 --- a/src/kz_scoring_api/scoring.py +++ b/src/kz_scoring_api/scoring.py @@ -71,21 +71,43 @@ def score(self, row: dict[str, Any]) -> float: return float(np.asarray(preds).ravel()[0]) +def pd_to_score(pd: float) -> int | None: + """Convert a probability of default into a PKB scoring band. + + Formula per PKB: ``int(round(500 + 50 * log2((1 - pd) / pd)))``. Clips + the input to (eps, 1 - eps) so 0/1 don't blow up log2. Returns None if + the input is NaN or otherwise unusable. + """ + if pd is None: + return None + try: + p = float(pd) + except (TypeError, ValueError): + return None + if math.isnan(p): + return None + eps = 1e-6 + p = min(max(p, eps), 1.0 - eps) + return int(round(500 + 50 * math.log2((1.0 - p) / p))) + + class ScoringService: - """Attaches HH_score / CG_score to each feature-row. + """Attaches HH_pd/HH_score and CG_pd/CG_score to each feature-row. The two LightGBM boosters are loaded once at process start (see - :meth:`from_paths`). Scoring is a plain in-place mutation of the row - dicts so it composes with the existing TSV → JSON pipeline without - changing the response schema shape. + :meth:`from_paths`). Each booster's ``predict`` returns a probability of + default (``*_pd``); this is converted into an integer PKB score band + (``*_score``) via :func:`pd_to_score`. Both fields are written per row. Either model can be omitted by leaving its path empty; the corresponding - ``*_score`` field is then omitted from the response (the service falls - back to a pass-through). + pair of fields is then omitted from the response (the service falls back + to a pass-through for that model). """ - HH_KEY = "HH_score" - CG_KEY = "CG_score" + HH_PD_KEY = "HH_pd" + CG_PD_KEY = "CG_pd" + HH_SCORE_KEY = "HH_score" + CG_SCORE_KEY = "CG_score" def __init__( self, @@ -149,7 +171,11 @@ def score_rows(self, rows: list[dict[str, Any]]) -> list[dict[str, Any]]: return rows for row in rows: if self._hh is not None: - row[self.HH_KEY] = self._hh.score(row) + pd = self._hh.score(row) + row[self.HH_PD_KEY] = pd + row[self.HH_SCORE_KEY] = pd_to_score(pd) if self._cg is not None: - row[self.CG_KEY] = self._cg.score(row) + pd = self._cg.score(row) + row[self.CG_PD_KEY] = pd + row[self.CG_SCORE_KEY] = pd_to_score(pd) return rows diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index a679a94..1e685ba 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1,3 +1,5 @@ +import pytest + from kz_scoring_api.hashing import compute_row_id_full, compute_row_id_iin from kz_scoring_api.pipeline_client import ( PipelineTimeoutError, @@ -148,22 +150,25 @@ def test_multi_invalid_item_422(test_client): assert r.status_code == 422 -def test_single_with_scoring_returns_scores( +def test_single_with_scoring_returns_pd_and_score( test_client_with_scoring, settings, fake_pipelines, fake_secrets ): row_id = compute_row_id_iin( fake_secrets.salt_bytes, "801217301434", settings.iin_salt ) - fake_pipelines.set(row_id, "a\tb\tc\n1\t2\t10\n") + # HH pd = 0.1+0.4 = 0.5 → score 500; CG pd = 0.9 → score 341 + fake_pipelines.set(row_id, "a\tb\tc\n0.1\t0.4\t0.9\n") r = test_client_with_scoring.get("/single", params={"iin": "801217301434"}) assert r.status_code == 200 body = r.json() assert len(body) == 1 row = body[0] - assert row["a"] == "1" - assert row["HH_score"] == 3.0 - assert row["CG_score"] == 10.0 + assert row["a"] == "0.1" + assert row["HH_pd"] == pytest.approx(0.5) + assert row["HH_score"] == 500 + assert row["CG_pd"] == pytest.approx(0.9) + assert row["CG_score"] == 342 def test_multi_with_scoring_applies_per_row( @@ -175,8 +180,10 @@ def test_multi_with_scoring_applies_per_row( a_row = compute_row_id_iin(salt, iin_a, settings.iin_salt) b_row = compute_row_id_iin(salt, iin_b, settings.iin_salt) - fake_pipelines.set(a_row, "a\tb\tc\n1\t2\t10\n1\t3\t20\n") - fake_pipelines.set(b_row, "a\tb\tc\n5\t5\t0\n") + # a_row row0: HH_pd=0.5→500, CG_pd=0.9→341; row1: HH_pd=0.4→558, CG_pd=0.8→400 + fake_pipelines.set(a_row, "a\tb\tc\n0.1\t0.4\t0.9\n0.1\t0.3\t0.8\n") + # b_row row0: HH_pd=0.5→500, CG_pd=0.001 clipped→ ~998 + fake_pipelines.set(b_row, "a\tb\tc\n0.2\t0.3\t0.001\n") r = test_client_with_scoring.post( "/multi", json=[{"iin": iin_a}, {"iin": iin_b}] @@ -184,10 +191,14 @@ def test_multi_with_scoring_applies_per_row( assert r.status_code == 200 payload = r.json() # First IIN — two rows - assert payload[0][0]["HH_score"] == 3.0 - assert payload[0][0]["CG_score"] == 10.0 - assert payload[0][1]["HH_score"] == 4.0 - assert payload[0][1]["CG_score"] == 20.0 - # Second IIN — one row - assert payload[1][0]["HH_score"] == 10.0 - assert payload[1][0]["CG_score"] == 0.0 + assert payload[0][0]["HH_pd"] == pytest.approx(0.5) + assert payload[0][0]["HH_score"] == 500 + assert payload[0][0]["CG_pd"] == pytest.approx(0.9) + assert payload[0][0]["CG_score"] == 342 + assert payload[0][1]["HH_pd"] == pytest.approx(0.4) + assert payload[0][1]["CG_pd"] == pytest.approx(0.8) + # Second IIN — one row; CG_pd very low → very high CG_score + assert payload[1][0]["HH_pd"] == pytest.approx(0.5) + assert payload[1][0]["HH_score"] == 500 + assert payload[1][0]["CG_pd"] == pytest.approx(0.001) + assert payload[1][0]["CG_score"] >= 900 diff --git a/tests/test_lookup.py b/tests/test_lookup.py index 39be30d..433b0a3 100644 --- a/tests/test_lookup.py +++ b/tests/test_lookup.py @@ -95,23 +95,26 @@ async def test_lookup_many_collects_per_item_errors( @pytest.mark.asyncio -async def test_lookup_with_scoring_adds_hh_cg_scores( +async def test_lookup_with_scoring_adds_hh_cg_pd_and_scores( settings, fake_pipelines, fake_secrets, lookup_service_with_scoring ): salt_pkb = fake_secrets.salt_bytes row_id = compute_row_id_iin(salt_pkb, "801217301434", settings.iin_salt) - # HH scorer sums a+b (=3), CG scorer takes c (=10) - fake_pipelines.set(row_id, "a\tb\tc\n1\t2\t10\n") + # HH scorer sums a+b (=0.5 → pd_to_score=500); + # CG scorer takes c (=0.9 → pd_to_score=341) + fake_pipelines.set(row_id, "a\tb\tc\n0.1\t0.4\t0.9\n") result = await lookup_service_with_scoring.lookup("801217301434", None) assert len(result) == 1 row = result[0] - assert row["a"] == "1" - assert row["b"] == "2" - assert row["c"] == "10" - assert row["HH_score"] == 3.0 - assert row["CG_score"] == 10.0 + assert row["a"] == "0.1" + assert row["b"] == "0.4" + assert row["c"] == "0.9" + assert row["HH_pd"] == pytest.approx(0.5) + assert row["HH_score"] == 500 + assert row["CG_pd"] == pytest.approx(0.9) + assert row["CG_score"] == 342 @pytest.mark.asyncio @@ -120,14 +123,16 @@ async def test_lookup_with_scoring_handles_missing_features( ): salt_pkb = fake_secrets.salt_bytes row_id = compute_row_id_iin(salt_pkb, "801217301434", settings.iin_salt) - # 'b' absent from the row → falls back to NaN in the feature vector; - # FakeBooster sums non-nan cells so HH_score = 1.0, CG_score = 5.0 - fake_pipelines.set(row_id, "a\tc\n1\t5\n") + # 'b' absent from row → NaN in feature vector; FakeBooster sums non-nan cells, + # so HH_pd = 0.1 (→ 659), CG_pd = 0.5 (→ 500). + fake_pipelines.set(row_id, "a\tc\n0.1\t0.5\n") result = await lookup_service_with_scoring.lookup("801217301434", None) - assert result[0]["HH_score"] == 1.0 - assert result[0]["CG_score"] == 5.0 + assert result[0]["HH_pd"] == pytest.approx(0.1) + assert result[0]["HH_score"] == 658 + assert result[0]["CG_pd"] == pytest.approx(0.5) + assert result[0]["CG_score"] == 500 @pytest.mark.asyncio @@ -141,5 +146,7 @@ async def test_lookup_without_scoring_leaves_rows_untouched( result = await lookup_service.lookup("801217301434", None) assert result == [{"a": "1", "b": "2"}] + assert "HH_pd" not in result[0] assert "HH_score" not in result[0] + assert "CG_pd" not in result[0] assert "CG_score" not in result[0] diff --git a/tests/test_scoring.py b/tests/test_scoring.py index f673747..45f37dc 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -8,6 +8,7 @@ BoosterScorer, ScoringService, _to_float, + pd_to_score, ) @@ -86,15 +87,47 @@ def test_feature_names_exposed(self): assert scorer.name == "hh" +class TestPdToScore: + def test_pd_half_is_500(self): + assert pd_to_score(0.5) == 500 + + def test_pd_low_gives_high_score(self): + # pd = 0.1 → log2(9) ≈ 3.1699 → 500 + 158.5 → 659 + assert pd_to_score(0.1) == 658 + + def test_pd_high_gives_low_score(self): + # pd = 0.9 → log2(1/9) ≈ -3.1699 → 500 - 158.5 → 341 + assert pd_to_score(0.9) == 342 + + def test_pd_zero_clipped_not_inf(self): + s = pd_to_score(0.0) + assert isinstance(s, int) + assert s >= 1000 + + def test_pd_one_clipped_not_neg_inf(self): + s = pd_to_score(1.0) + assert isinstance(s, int) + assert s <= 0 + + def test_nan_returns_none(self): + assert pd_to_score(float("nan")) is None + + def test_none_returns_none(self): + assert pd_to_score(None) is None + + class TestScoringService: - def test_score_rows_adds_both_scores(self): + def test_score_rows_adds_pd_and_score(self): + # FakeBooster returns sum(non-nan features), clamped to (eps, 1-eps) inside pd_to_score hh = BoosterScorer(FakeBooster(["f1", "f2"]), "hh") cg = BoosterScorer(FakeBooster(["f3"]), "cg") svc = ScoringService(hh_scorer=hh, cg_scorer=cg) - rows = [{"f1": "1", "f2": "2", "f3": "10"}] + rows = [{"f1": "0.1", "f2": "0.4", "f3": "0.9"}] svc.score_rows(rows) - assert rows[0]["HH_score"] == 3.0 - assert rows[0]["CG_score"] == 10.0 + assert rows[0]["HH_pd"] == pytest.approx(0.5) + assert rows[0]["HH_score"] == 500 + assert rows[0]["CG_pd"] == pytest.approx(0.9) + assert rows[0]["CG_score"] == 342 def test_pass_through_when_disabled(self): svc = ScoringService() @@ -102,15 +135,19 @@ def test_pass_through_when_disabled(self): rows = [{"a": "1"}] out = svc.score_rows(rows) assert out is rows + assert "HH_pd" not in rows[0] assert "HH_score" not in rows[0] + assert "CG_pd" not in rows[0] assert "CG_score" not in rows[0] def test_partial_enabled_only_hh(self): hh = BoosterScorer(FakeBooster(["a"]), "hh") svc = ScoringService(hh_scorer=hh, cg_scorer=None) - rows = [{"a": "7"}] + rows = [{"a": "0.5"}] svc.score_rows(rows) - assert rows[0]["HH_score"] == 7.0 + assert rows[0]["HH_pd"] == pytest.approx(0.5) + assert rows[0]["HH_score"] == 500 + assert "CG_pd" not in rows[0] assert "CG_score" not in rows[0] def test_empty_rows_short_circuits(self): @@ -121,10 +158,10 @@ def test_empty_rows_short_circuits(self): def test_missing_features_produce_nan_input(self): hh = BoosterScorer(FakeBooster(["a", "b", "c"]), "hh") svc = ScoringService(hh_scorer=hh) - rows = [{"a": "1"}] + rows = [{"a": "0.3"}] svc.score_rows(rows) - # FakeBooster sums non-nan; 'b' and 'c' missing → sum = 1.0 - assert rows[0]["HH_score"] == 1.0 + # FakeBooster sums non-nan; 'b' and 'c' missing → pd = 0.3 + assert rows[0]["HH_pd"] == pytest.approx(0.3) def test_from_paths_empty_disables(self, tmp_path): svc = ScoringService.from_paths(hh_model_path="", cg_model_path="") @@ -176,7 +213,9 @@ def test_loads_and_scores(self, booster_file): rows = [{"alpha": "1.0", "beta": "0.5", "gamma": "0.1"}] svc.score_rows(rows) + assert "HH_pd" in rows[0] + pd = rows[0]["HH_pd"] + assert isinstance(pd, float) + assert 0.0 <= pd <= 1.0 assert "HH_score" in rows[0] - score = rows[0]["HH_score"] - assert isinstance(score, float) - assert 0.0 <= score <= 1.0 + assert isinstance(rows[0]["HH_score"], int)