Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
46 changes: 36 additions & 10 deletions src/kz_scoring_api/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
39 changes: 25 additions & 14 deletions tests/test_endpoints.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -175,19 +180,25 @@ 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}]
)
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
33 changes: 20 additions & 13 deletions tests/test_lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]
63 changes: 51 additions & 12 deletions tests/test_scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
BoosterScorer,
ScoringService,
_to_float,
pd_to_score,
)


Expand Down Expand Up @@ -86,31 +87,67 @@ 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()
assert not svc.enabled
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):
Expand All @@ -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="")
Expand Down Expand Up @@ -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)
Loading