Pieter182 commited on
Commit
2fc9309
·
verified ·
1 Parent(s): 529ac13

Deploy MzansiScore API Space

Browse files
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ ENV MZANSISCORE_MODEL_SOURCE=hub
6
+ ENV MZANSISCORE_MODEL_REPO=Pieter182/mzansiscore-credit-risk
7
+
8
+ COPY requirements.txt requirements.txt
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY . .
12
+
13
+ EXPOSE 7860
14
+
15
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,54 @@
1
- ---
2
- title: Mzansiscore Api
3
- emoji: 👁
4
- colorFrom: purple
5
- colorTo: blue
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: MzansiScore Credit Risk API
3
+ emoji: 📉
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ # MzansiScore Credit Risk API
11
+
12
+ FastAPI backend for scoring South African credit applications using the trained MzansiScore model artifacts.
13
+
14
+ ## Endpoints
15
+
16
+ - `GET /v1/health`
17
+ - `GET /v1/model/info`
18
+ - `POST /v1/score`
19
+
20
+ ## Local run
21
+
22
+ ```powershell
23
+ pip install -r api/requirements.txt
24
+ uvicorn api.main:app --reload --port 7860
25
+ ```
26
+
27
+ If you are inside the `api/` folder itself, run:
28
+
29
+ ```powershell
30
+ pip install -r requirements.txt
31
+ uvicorn main:app --reload --port 7860
32
+ ```
33
+
34
+ ## Model artifact source
35
+
36
+ The API supports both local artifacts and HuggingFace Hub downloads.
37
+
38
+ - Default HF model repo: `Pieter182/mzansiscore-credit-risk`
39
+ - Optional env var: `MZANSISCORE_MODEL_REPO`
40
+ - Optional env var: `MZANSISCORE_MODEL_SOURCE=hub` to force Hub downloads
41
+ - Optional env var: `HF_TOKEN` for private repos or authenticated access
42
+
43
+ The Docker image is configured to prefer Hub artifacts by default:
44
+
45
+ - `MZANSISCORE_MODEL_SOURCE=hub`
46
+ - `MZANSISCORE_MODEL_REPO=Pieter182/mzansiscore-credit-risk`
47
+
48
+ ## HuggingFace Docker Space
49
+
50
+ This folder is now self-contained for deployment as its own HuggingFace Docker Space repo.
51
+
52
+ - push the contents of `api/` as the Space repository root
53
+ - `Dockerfile` now builds from that root directly
54
+ - runtime feature engineering is bundled via `features_runtime.py`
__init__.py ADDED
File without changes
__pycache__/__init__.cpython-313.pyc ADDED
Binary file (140 Bytes). View file
 
__pycache__/features_runtime.cpython-313.pyc ADDED
Binary file (6.02 kB). View file
 
__pycache__/main.cpython-313.pyc ADDED
Binary file (3.48 kB). View file
 
__pycache__/models.cpython-313.pyc ADDED
Binary file (3.92 kB). View file
 
__pycache__/scorer.cpython-313.pyc ADDED
Binary file (15.5 kB). View file
 
features_runtime.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+
5
+ def build_features(df):
6
+ df = df.copy()
7
+
8
+ df['living_to_income'] = df['living_expenses'] / df['net_income'].replace(0, np.nan)
9
+ df['discretionary_ratio'] = df['discretionary_income'] / df['net_income'].replace(0, np.nan)
10
+ df['gross_to_net_ratio'] = df['net_income'] / df['gross_income'].replace(0, np.nan)
11
+
12
+ df['score_norm'] = df['credit_score'] / 850
13
+ df['score_risk'] = 1 - df['score_norm']
14
+ df['combined_risk'] = df['score_risk'] + df['debt_service_ratio']
15
+
16
+ df['food_ratio'] = df['food_expense'] / df['net_income'].replace(0, np.nan)
17
+ df['housing_ratio'] = df['accommodation_expense'] / df['net_income'].replace(0, np.nan)
18
+ df['transport_ratio'] = df['transport_expense'] / df['net_income'].replace(0, np.nan)
19
+
20
+ if 'credit_bureau_monthly_debt' in df.columns:
21
+ df['bureau_debt_to_income'] = df['credit_bureau_monthly_debt'] / df['net_income'].replace(0, np.nan)
22
+
23
+ if 'affordability_surplus' in df.columns:
24
+ df['affordability_surplus_ratio'] = df['affordability_surplus'] / df['net_income'].replace(0, np.nan)
25
+
26
+ if 'affordability_expense_basis' in df.columns:
27
+ df['affordability_basis_ratio'] = df['affordability_expense_basis'] / df['net_income'].replace(0, np.nan)
28
+
29
+ if {'affordability_expense_basis', 'living_expenses'}.issubset(df.columns):
30
+ df['expense_gap_ratio'] = (
31
+ (df['affordability_expense_basis'] - df['living_expenses']) /
32
+ df['net_income'].replace(0, np.nan)
33
+ )
34
+
35
+ if {'minimum_living_expense', 'living_expenses'}.issubset(df.columns):
36
+ df['norm_to_declared_ratio'] = df['minimum_living_expense'] / df['living_expenses'].replace(0, np.nan)
37
+
38
+ if 'predicted_monthly_income' in df.columns:
39
+ df['income_prediction_gap'] = (
40
+ (df['predicted_monthly_income'] - df['gross_income']) /
41
+ df['gross_income'].replace(0, np.nan)
42
+ )
43
+
44
+ if 'household_gross_income' in df.columns:
45
+ df['household_to_income_ratio'] = df['household_gross_income'] / df['gross_income'].replace(0, np.nan)
46
+
47
+ if {'cpa_commitments', 'net_income'}.issubset(df.columns):
48
+ df['cpa_to_income_ratio'] = df['cpa_commitments'] / df['net_income'].replace(0, np.nan)
49
+
50
+ if {'nlr_commitments', 'net_income'}.issubset(df.columns):
51
+ df['nlr_to_income_ratio'] = df['nlr_commitments'] / df['net_income'].replace(0, np.nan)
52
+
53
+ if {'contactability_index', 'bureau_utilisation'}.issubset(df.columns):
54
+ df['contactability_risk_gap'] = (100 - df['contactability_index']) / 100 + df['bureau_utilisation']
55
+
56
+ if {'monthly_instalment', 'net_income'}.issubset(df.columns):
57
+ df['instalment_to_income'] = df['monthly_instalment'] / df['net_income'].replace(0, np.nan)
58
+
59
+ if {'total_debt_outstanding_zar', 'gross_income'}.issubset(df.columns):
60
+ df['debt_to_income_multiple'] = df['total_debt_outstanding_zar'] / df['gross_income'].replace(0, np.nan)
61
+
62
+ if {'asset_total_zar', 'gross_income'}.issubset(df.columns):
63
+ df['assets_to_income_multiple'] = df['asset_total_zar'] / df['gross_income'].replace(0, np.nan)
64
+
65
+ if {'avg_bank_balance_30d_zar', 'net_income'}.issubset(df.columns):
66
+ df['avg_balance_to_income'] = df['avg_bank_balance_30d_zar'] / df['net_income'].replace(0, np.nan)
67
+
68
+ if {'min_bank_balance_30d_zar', 'net_income'}.issubset(df.columns):
69
+ df['min_balance_to_income'] = df['min_bank_balance_30d_zar'] / df['net_income'].replace(0, np.nan)
70
+
71
+ if {'credit_history_months'}.issubset(df.columns):
72
+ df['credit_history_years'] = df['credit_history_months'] / 12
73
+
74
+ df['income_band'] = pd.cut(
75
+ df['gross_income'],
76
+ bins=[0, 8000, 15000, 25000, 45000, 80000, np.inf],
77
+ labels=['very_low', 'low', 'lower_mid', 'middle', 'upper_mid', 'high'],
78
+ )
79
+
80
+ return df
main.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+
3
+ from fastapi import FastAPI, HTTPException
4
+ from fastapi.middleware.cors import CORSMiddleware
5
+
6
+ try:
7
+ from api.models import HealthResponse, ModelInfoResponse, ScoreRequest, ScoreResponse
8
+ from api.scorer import ModelScorer
9
+ except ImportError:
10
+ from models import HealthResponse, ModelInfoResponse, ScoreRequest, ScoreResponse
11
+ from scorer import ModelScorer
12
+
13
+ scorer: ModelScorer | None = None
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(app: FastAPI):
18
+ global scorer
19
+ scorer = ModelScorer()
20
+ yield
21
+
22
+
23
+ app = FastAPI(
24
+ title='MzansiScore Credit Risk API',
25
+ version='0.1.0',
26
+ description='FastAPI scorer for the MzansiScore SA credit risk model.',
27
+ lifespan=lifespan,
28
+ )
29
+
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=['*'],
33
+ allow_credentials=True,
34
+ allow_methods=['*'],
35
+ allow_headers=['*'],
36
+ )
37
+
38
+
39
+ @app.get('/')
40
+ def root() -> dict[str, str | bool | None]:
41
+ return {
42
+ 'service': 'MzansiScore Credit Risk API',
43
+ 'status': 'ok',
44
+ 'model_loaded': scorer is not None,
45
+ 'best_model': None if scorer is None else scorer.meta.get('best_model'),
46
+ 'artifact_source': None if scorer is None else scorer.artifact_source,
47
+ 'model_repo': None if scorer is None else scorer.repo_id,
48
+ 'docs': '/docs',
49
+ 'health': '/v1/health',
50
+ }
51
+
52
+
53
+ @app.get('/v1/health', response_model=HealthResponse)
54
+ def health() -> HealthResponse:
55
+ return HealthResponse(
56
+ status='ok',
57
+ model_loaded=scorer is not None,
58
+ best_model=None if scorer is None else scorer.meta.get('best_model'),
59
+ artifact_source=None if scorer is None else scorer.artifact_source,
60
+ model_repo=None if scorer is None else scorer.repo_id,
61
+ )
62
+
63
+
64
+ @app.get('/v1/model/info', response_model=ModelInfoResponse)
65
+ def model_info() -> ModelInfoResponse:
66
+ if scorer is None:
67
+ raise HTTPException(status_code=503, detail='Model not loaded')
68
+ return ModelInfoResponse(
69
+ **scorer.model_info(),
70
+ artifact_source=scorer.artifact_source,
71
+ model_repo=scorer.repo_id,
72
+ )
73
+
74
+
75
+ @app.post('/v1/score', response_model=ScoreResponse)
76
+ def score_application(request: ScoreRequest) -> ScoreResponse:
77
+ if scorer is None:
78
+ raise HTTPException(status_code=503, detail='Model not loaded')
79
+ result = scorer.score(request.to_payload())
80
+ return ScoreResponse(**result)
models.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class ScoreRequest(BaseModel):
7
+ model_config = ConfigDict(extra='allow')
8
+
9
+ gross_income: float = Field(..., gt=0)
10
+ net_income: float = Field(..., gt=0)
11
+ living_expenses: float = Field(..., ge=0)
12
+ credit_score: int = Field(..., ge=250, le=850)
13
+ age: int = Field(..., ge=18, le=100)
14
+
15
+ discretionary_income: float | None = None
16
+ debt_service_ratio: float | None = Field(default=None, ge=0)
17
+ credit_bureau_monthly_debt: float | None = Field(default=None, ge=0)
18
+ monthly_instalment: float | None = Field(default=None, ge=0)
19
+ loan_amount_requested: float | None = Field(default=None, ge=0)
20
+ loan_term_months: int | None = Field(default=None, ge=1)
21
+
22
+ province: str | None = None
23
+ gender: str | None = None
24
+ marital_status: str | None = None
25
+ employment_type: str | None = None
26
+ education_level: str | None = None
27
+ dependants: int | None = Field(default=None, ge=0)
28
+ residence_type: str | None = None
29
+ loan_product_type: str | None = None
30
+ salary_frequency: str | None = None
31
+
32
+ affordability_expense_basis: float | None = None
33
+ affordability_surplus: float | None = None
34
+ affordability_margin: float | None = None
35
+ nca_affordability_pass: int | None = Field(default=None, ge=0, le=1)
36
+
37
+ def to_payload(self) -> dict[str, Any]:
38
+ return self.model_dump(exclude_none=True)
39
+
40
+
41
+ class ScoreResponse(BaseModel):
42
+ decision_id: str
43
+ risk_score: int
44
+ risk_band: str
45
+ recommendation: str
46
+ default_probability: float
47
+ nca_pass: int
48
+ policy_reason: str
49
+ shap_top3: list[dict[str, float | str]]
50
+ processing_time_ms: float
51
+
52
+
53
+ class HealthResponse(BaseModel):
54
+ status: str
55
+ model_loaded: bool
56
+ best_model: str | None = None
57
+ artifact_source: str | None = None
58
+ model_repo: str | None = None
59
+
60
+
61
+ class ModelInfoResponse(BaseModel):
62
+ best_model: str
63
+ holdout_auc: float
64
+ holdout_brier: float
65
+ runtime_human: str | None = None
66
+ top_features: list[dict[str, float | str]]
67
+ artifact_source: str | None = None
68
+ model_repo: str | None = None
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115
2
+ uvicorn[standard]>=0.30
3
+ pydantic>=2.8
4
+ pandas>=2.2
5
+ numpy>=1.26
6
+ joblib>=1.4
7
+ scikit-learn>=1.5
8
+ xgboost>=2.0
9
+ lightgbm>=4.3
10
+ shap>=0.46
11
+ huggingface_hub>=0.30
12
+ pytest>=8.3
13
+ httpx>=0.27
scorer.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import sys
4
+ import time
5
+ import uuid
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import joblib
10
+ import numpy as np
11
+ import pandas as pd
12
+ import shap
13
+ from huggingface_hub import hf_hub_download
14
+
15
+ ROOT_DIR = Path(__file__).resolve().parents[1]
16
+ ML_SRC_DIR = ROOT_DIR / 'ml' / 'src'
17
+ ML_DATA_DIR = ROOT_DIR / 'ml' / 'data'
18
+ HF_MODEL_REPO = os.getenv('MZANSISCORE_MODEL_REPO', 'Pieter182/mzansiscore-credit-risk')
19
+ HF_TOKEN = os.getenv('HF_TOKEN') or os.getenv('HUGGINGFACE_HUB_TOKEN')
20
+ ARTIFACT_FILES = [
21
+ 'best_tree_model.joblib',
22
+ 'feature_cols.joblib',
23
+ 'label_encoders.joblib',
24
+ 'model_meta.json',
25
+ ]
26
+ STATIC_DEFAULTS: dict[str, Any] = {
27
+ 'province': 'Gauteng',
28
+ 'gender': 'Male',
29
+ 'marital_status': 'Single',
30
+ 'employment_type': 'permanent',
31
+ 'education_level': 'matric',
32
+ 'residence_type': 'rented_formal',
33
+ 'loan_product_type': 'personal_loan',
34
+ 'salary_frequency': 'Monthly',
35
+ 'dependants': 0,
36
+ 'gross_income': 25000.0,
37
+ 'net_income': 19000.0,
38
+ 'living_expenses': 7000.0,
39
+ 'discretionary_income': 6500.0,
40
+ 'debt_service_ratio': 0.28,
41
+ 'credit_bureau_monthly_debt': 2500.0,
42
+ 'monthly_instalment': 2800.0,
43
+ 'loan_amount_requested': 50000.0,
44
+ 'loan_term_months': 24,
45
+ 'age': 34,
46
+ 'credit_score': 640,
47
+ 'food_expense': 1800.0,
48
+ 'transport_expense': 1200.0,
49
+ 'accommodation_expense': 2800.0,
50
+ 'utilities_expense': 900.0,
51
+ 'medical_expense': 500.0,
52
+ 'minimum_living_expense': 7200.0,
53
+ 'affordability_expense_basis': 7200.0,
54
+ 'affordability_surplus': 6500.0,
55
+ 'affordability_margin': 0.34,
56
+ 'predicted_monthly_income': 25500.0,
57
+ 'household_gross_income': 32000.0,
58
+ 'cpa_commitments': 700.0,
59
+ 'nlr_commitments': 600.0,
60
+ 'contactability_index': 82.0,
61
+ 'bureau_utilisation': 0.33,
62
+ 'total_debt_outstanding_zar': 42000.0,
63
+ 'asset_total_zar': 110000.0,
64
+ 'avg_bank_balance_30d_zar': 8400.0,
65
+ 'min_bank_balance_30d_zar': 2200.0,
66
+ 'credit_history_months': 48,
67
+ 'nca_affordability_pass': 1,
68
+ }
69
+
70
+ if str(ML_SRC_DIR) not in sys.path:
71
+ sys.path.append(str(ML_SRC_DIR))
72
+
73
+ try:
74
+ from api.features_runtime import build_features # type: ignore
75
+ except ImportError:
76
+ try:
77
+ from features_runtime import build_features # type: ignore
78
+ except ImportError:
79
+ from features import build_features # type: ignore # noqa: E402
80
+
81
+
82
+ class ModelScorer:
83
+ def __init__(self, repo_id: str | None = None, token: str | None = None) -> None:
84
+ self.repo_id = repo_id or HF_MODEL_REPO
85
+ self.token = token or HF_TOKEN
86
+ self.artifact_source = 'unknown'
87
+ self.artifact_dir = self._resolve_artifact_dir()
88
+
89
+ self.best_model = joblib.load(self.artifact_dir / 'best_tree_model.joblib')
90
+ self.feature_cols = joblib.load(self.artifact_dir / 'feature_cols.joblib')
91
+ self.label_encoders = joblib.load(self.artifact_dir / 'label_encoders.joblib')
92
+ with open(self.artifact_dir / 'model_meta.json', 'r', encoding='utf-8') as handle:
93
+ self.meta = json.load(handle)
94
+
95
+ self.defaults = self._build_defaults()
96
+ self.explainer = shap.TreeExplainer(self.best_model)
97
+
98
+ def _resolve_artifact_dir(self) -> Path:
99
+ local_ready = all((ML_DATA_DIR / name).exists() for name in ARTIFACT_FILES)
100
+ prefer_hub = os.getenv('MZANSISCORE_MODEL_SOURCE', '').lower() == 'hub' or not local_ready
101
+
102
+ if prefer_hub and self.repo_id:
103
+ try:
104
+ self.artifact_source = 'hub'
105
+ return self._download_artifacts_from_hub()
106
+ except Exception:
107
+ if not local_ready:
108
+ raise
109
+
110
+ if local_ready:
111
+ self.artifact_source = 'local'
112
+ return ML_DATA_DIR
113
+
114
+ if self.repo_id:
115
+ self.artifact_source = 'hub'
116
+ return self._download_artifacts_from_hub()
117
+
118
+ raise FileNotFoundError('No local model artifacts found and no HuggingFace repo configured.')
119
+
120
+ def _download_artifacts_from_hub(self) -> Path:
121
+ downloaded_paths = [
122
+ Path(
123
+ hf_hub_download(
124
+ repo_id=self.repo_id,
125
+ filename=filename,
126
+ repo_type='model',
127
+ token=self.token,
128
+ ),
129
+ )
130
+ for filename in ARTIFACT_FILES
131
+ ]
132
+ return downloaded_paths[0].parent
133
+
134
+ def _build_defaults(self) -> dict[str, Any]:
135
+ dataset_path = ML_DATA_DIR / 'sa_credit_dataset.csv'
136
+ if not dataset_path.exists():
137
+ return dict(STATIC_DEFAULTS)
138
+
139
+ df = pd.read_csv(dataset_path)
140
+ defaults: dict[str, Any] = dict(STATIC_DEFAULTS)
141
+
142
+ for col in df.columns:
143
+ series = df[col]
144
+ if pd.api.types.is_numeric_dtype(series):
145
+ defaults[col] = float(series.median()) if not pd.isna(series.median()) else 0.0
146
+ else:
147
+ mode = series.mode(dropna=True)
148
+ defaults[col] = str(mode.iloc[0]) if not mode.empty else ''
149
+
150
+ defaults.setdefault('province', 'Gauteng')
151
+ defaults.setdefault('gender', 'Male')
152
+ defaults.setdefault('marital_status', 'Single')
153
+ defaults.setdefault('employment_type', 'permanent')
154
+ defaults.setdefault('education_level', 'matric')
155
+ defaults.setdefault('residence_type', 'rented_formal')
156
+ defaults.setdefault('loan_product_type', 'personal_loan')
157
+ defaults.setdefault('salary_frequency', 'Monthly')
158
+ defaults.setdefault('dependants', 0)
159
+ return defaults
160
+
161
+ def _merge_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
162
+ row = dict(self.defaults)
163
+ row.update(payload)
164
+
165
+ if row.get('discretionary_income') is None:
166
+ row['discretionary_income'] = (
167
+ float(row.get('net_income', 0))
168
+ - float(row.get('living_expenses', 0))
169
+ - float(row.get('credit_bureau_monthly_debt', 0))
170
+ - float(row.get('monthly_instalment', 0))
171
+ )
172
+
173
+ if row.get('affordability_expense_basis') is None:
174
+ row['affordability_expense_basis'] = max(
175
+ float(row.get('living_expenses', 0)),
176
+ float(row.get('minimum_living_expense', row.get('living_expenses', 0))),
177
+ )
178
+
179
+ if row.get('affordability_surplus') is None:
180
+ row['affordability_surplus'] = (
181
+ float(row.get('net_income', 0))
182
+ - float(row.get('affordability_expense_basis', 0))
183
+ - float(row.get('credit_bureau_monthly_debt', 0))
184
+ - float(row.get('monthly_instalment', 0))
185
+ )
186
+
187
+ if row.get('affordability_margin') is None:
188
+ row['affordability_margin'] = float(row['affordability_surplus']) / max(float(row.get('net_income', 1)), 1.0)
189
+
190
+ if row.get('debt_service_ratio') is None:
191
+ row['debt_service_ratio'] = (
192
+ float(row.get('credit_bureau_monthly_debt', 0)) + float(row.get('monthly_instalment', 0))
193
+ ) / max(float(row.get('net_income', 1)), 1.0)
194
+
195
+ if row.get('nca_affordability_pass') is None:
196
+ row['nca_affordability_pass'] = int(
197
+ float(row.get('affordability_surplus', 0)) > 800
198
+ and float(row.get('affordability_margin', 0)) > 0.02
199
+ )
200
+
201
+ return row
202
+
203
+ def _encode_for_tree(self, df: pd.DataFrame) -> pd.DataFrame:
204
+ model_df = df.copy()
205
+ for col in self.feature_cols:
206
+ if col not in model_df.columns:
207
+ model_df[col] = np.nan
208
+
209
+ model_df = model_df[self.feature_cols].copy()
210
+
211
+ for col, encoder in self.label_encoders.items():
212
+ if col not in model_df.columns:
213
+ continue
214
+ values = model_df[col].astype(str).fillna('MISSING')
215
+ known = set(encoder.classes_)
216
+ values = values.where(values.isin(known), 'UNKNOWN')
217
+ model_df[col] = encoder.transform(values)
218
+
219
+ return model_df.fillna(-999)
220
+
221
+ def _build_shap_top3(self, encoded_df: pd.DataFrame) -> list[dict[str, Any]]:
222
+ shap_values = self.explainer.shap_values(encoded_df)
223
+ if isinstance(shap_values, list):
224
+ shap_values = shap_values[0]
225
+ row_vals = np.asarray(shap_values)[0]
226
+ top_idx = np.argsort(np.abs(row_vals))[-3:][::-1]
227
+ return [
228
+ {
229
+ 'feature': self.feature_cols[int(idx)],
230
+ 'shap_value': round(float(row_vals[int(idx)]), 4),
231
+ }
232
+ for idx in top_idx
233
+ ]
234
+
235
+ def score(self, payload: dict[str, Any]) -> dict[str, Any]:
236
+ start = time.perf_counter()
237
+ merged = self._merge_payload(payload)
238
+ raw_df = pd.DataFrame([merged])
239
+ feature_df = build_features(raw_df)
240
+ encoded_df = self._encode_for_tree(feature_df)
241
+
242
+ default_probability = float(self.best_model.predict_proba(encoded_df)[0, 1])
243
+ nca_pass = int(merged['nca_affordability_pass'])
244
+
245
+ if not nca_pass:
246
+ risk_band = 'DECLINE'
247
+ recommendation = 'DECLINE'
248
+ policy_reason = 'Affordability rule failed'
249
+ elif default_probability < 0.10:
250
+ risk_band = 'LOW'
251
+ recommendation = 'APPROVE'
252
+ policy_reason = 'Affordability pass'
253
+ elif default_probability < 0.25:
254
+ risk_band = 'MEDIUM'
255
+ recommendation = 'REVIEW'
256
+ policy_reason = 'Affordability pass'
257
+ elif default_probability < 0.50:
258
+ risk_band = 'HIGH'
259
+ recommendation = 'REVIEW'
260
+ policy_reason = 'Affordability pass'
261
+ else:
262
+ risk_band = 'DECLINE'
263
+ recommendation = 'DECLINE'
264
+ policy_reason = 'High predicted default probability'
265
+
266
+ elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
267
+ return {
268
+ 'decision_id': f"MZS-{uuid.uuid4().hex[:8].upper()}",
269
+ 'risk_score': int(round((1 - default_probability) * 999)),
270
+ 'risk_band': risk_band,
271
+ 'recommendation': recommendation,
272
+ 'default_probability': round(default_probability, 4),
273
+ 'nca_pass': nca_pass,
274
+ 'policy_reason': policy_reason,
275
+ 'shap_top3': self._build_shap_top3(encoded_df),
276
+ 'processing_time_ms': elapsed_ms,
277
+ }
278
+
279
+ def model_info(self) -> dict[str, Any]:
280
+ calibrated = self.meta['results'].get('XGBoost_cal', self.meta['results']['XGBoost'])
281
+ return {
282
+ 'best_model': self.meta['best_model'],
283
+ 'holdout_auc': calibrated['holdout_auc'],
284
+ 'holdout_brier': calibrated['holdout_brier'],
285
+ 'runtime_human': self.meta.get('runtime_human'),
286
+ 'top_features': self.meta.get('top_features', [])[:5],
287
+ }
tests/__pycache__/test_main.cpython-313-pytest-9.0.2.pyc ADDED
Binary file (10.9 kB). View file
 
tests/__pycache__/test_scorer.cpython-313-pytest-9.0.2.pyc ADDED
Binary file (7.24 kB). View file
 
tests/test_main.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi.testclient import TestClient
2
+
3
+ from api.main import app
4
+
5
+
6
+ SAMPLE_PAYLOAD = {
7
+ 'gross_income': 25000,
8
+ 'net_income': 19000,
9
+ 'living_expenses': 7000,
10
+ 'credit_score': 640,
11
+ 'age': 34,
12
+ 'monthly_instalment': 2800,
13
+ 'credit_bureau_monthly_debt': 2500,
14
+ 'loan_amount_requested': 50000,
15
+ 'loan_term_months': 24,
16
+ }
17
+
18
+
19
+ def test_health_endpoint():
20
+ with TestClient(app) as client:
21
+ response = client.get('/v1/health')
22
+ assert response.status_code == 200
23
+ data = response.json()
24
+ assert data['status'] == 'ok'
25
+ assert data['model_loaded'] is True
26
+ assert data['artifact_source'] in {'local', 'hub'}
27
+
28
+
29
+ def test_root_endpoint():
30
+ with TestClient(app) as client:
31
+ response = client.get('/')
32
+ assert response.status_code == 200
33
+ data = response.json()
34
+ assert data['service'] == 'MzansiScore Credit Risk API'
35
+ assert data['health'] == '/v1/health'
36
+
37
+
38
+ def test_model_info_endpoint():
39
+ with TestClient(app) as client:
40
+ response = client.get('/v1/model/info')
41
+ assert response.status_code == 200
42
+ data = response.json()
43
+ assert 'best_model' in data
44
+ assert 'holdout_auc' in data
45
+ assert 'top_features' in data
46
+ assert data['artifact_source'] in {'local', 'hub'}
47
+
48
+
49
+ def test_score_endpoint():
50
+ with TestClient(app) as client:
51
+ response = client.post('/v1/score', json=SAMPLE_PAYLOAD)
52
+ assert response.status_code == 200
53
+ data = response.json()
54
+ assert data['decision_id'].startswith('MZS-')
55
+ assert data['risk_band'] in {'LOW', 'MEDIUM', 'HIGH', 'DECLINE'}
56
+ assert data['recommendation'] in {'APPROVE', 'REVIEW', 'DECLINE'}
57
+ assert len(data['shap_top3']) == 3
tests/test_scorer.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from api.scorer import ModelScorer
2
+
3
+
4
+ SAMPLE_PAYLOAD = {
5
+ 'gross_income': 25000,
6
+ 'net_income': 19000,
7
+ 'living_expenses': 7000,
8
+ 'credit_score': 640,
9
+ 'age': 34,
10
+ 'monthly_instalment': 2800,
11
+ 'credit_bureau_monthly_debt': 2500,
12
+ 'loan_amount_requested': 50000,
13
+ 'loan_term_months': 24,
14
+ 'province': 'Gauteng',
15
+ 'employment_type': 'permanent',
16
+ 'education_level': 'diploma',
17
+ 'dependants': 1,
18
+ 'residence_type': 'rented_formal',
19
+ 'loan_product_type': 'personal_loan',
20
+ 'salary_frequency': 'Monthly',
21
+ }
22
+
23
+
24
+ def test_model_scorer_returns_expected_payload_shape():
25
+ scorer = ModelScorer()
26
+ result = scorer.score(SAMPLE_PAYLOAD)
27
+
28
+ assert result['decision_id'].startswith('MZS-')
29
+ assert 0 <= result['default_probability'] <= 1
30
+ assert 0 <= result['risk_score'] <= 999
31
+ assert result['risk_band'] in {'LOW', 'MEDIUM', 'HIGH', 'DECLINE'}
32
+ assert result['recommendation'] in {'APPROVE', 'REVIEW', 'DECLINE'}
33
+ assert result['nca_pass'] in {0, 1}
34
+ assert len(result['shap_top3']) == 3
35
+
36
+
37
+ def test_model_info_contains_core_metrics():
38
+ scorer = ModelScorer()
39
+ info = scorer.model_info()
40
+
41
+ assert info['best_model']
42
+ assert info['holdout_auc'] > 0.5
43
+ assert info['holdout_brier'] >= 0
44
+ assert isinstance(info['top_features'], list)