Upload folder using huggingface_hub
Browse files- .claude/session_2026-05-07_fastapi_setup.md +201 -0
- .claude/settings.local.json +7 -1
- README.md +9 -9
- api/inference_assembler.py +1 -2
- api/main.py +2 -3
- api/predictor.py +1 -2
- api/schemas.py +66 -66
- api/settings.py +2 -0
- feature_engineering/aggregations.py +2 -2
- feature_engineering/orchestrator.py +8 -5
- scripts/build_no_history_template.py +4 -4
- scripts/export_model.py +6 -3
.claude/session_2026-05-07_fastapi_setup.md
CHANGED
|
@@ -427,3 +427,204 @@ Côté Kevin (manuel, une seule fois) :
|
|
| 427 |
3. Vérifier la présence du parquet sur HF Dataset.
|
| 428 |
4. Re-trigger le `workflow_dispatch` du CI → build Docker passe, Space
|
| 429 |
démarre, lifespan télécharge le parquet une fois et le cache.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 427 |
3. Vérifier la présence du parquet sur HF Dataset.
|
| 428 |
4. Re-trigger le `workflow_dispatch` du CI → build Docker passe, Space
|
| 429 |
démarre, lifespan télécharge le parquet une fois et le cache.
|
| 430 |
+
|
| 431 |
+
---
|
| 432 |
+
|
| 433 |
+
## Session 2026-05-08 — Code review complète + Tier 1 cleanup
|
| 434 |
+
|
| 435 |
+
Quatre subagents lancés en parallèle (python-reviewer, security-reviewer,
|
| 436 |
+
code-reviewer, architect) sur l'ensemble du repo. Tests post-cleanup :
|
| 437 |
+
**45/45 pass, ruff clean, coverage 96.56 %**.
|
| 438 |
+
|
| 439 |
+
### Fix critique de la session : libgomp.so.1 sur HF Space
|
| 440 |
+
|
| 441 |
+
`OSError: libgomp.so.1: cannot open shared object file` au cold start.
|
| 442 |
+
LightGBM dépend d'OpenMP au runtime, absent de `python:3.12-slim`.
|
| 443 |
+
|
| 444 |
+
**Faux ami `packages.txt`** : ce fichier n'est lu par HF Spaces que pour
|
| 445 |
+
`sdk: gradio` ou `sdk: streamlit`. Pour `sdk: docker`, **il est ignoré** —
|
| 446 |
+
les paquets système doivent être installés dans le `Dockerfile` via
|
| 447 |
+
`apt-get`.
|
| 448 |
+
|
| 449 |
+
Fix appliqué (Dockerfile) :
|
| 450 |
+
|
| 451 |
+
```dockerfile
|
| 452 |
+
RUN apt-get update \
|
| 453 |
+
&& apt-get install -y --no-install-recommends libgomp1 \
|
| 454 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 455 |
+
```
|
| 456 |
+
|
| 457 |
+
`packages.txt` supprimé du repo.
|
| 458 |
+
|
| 459 |
+
### Tier 1 — fixes appliqués (12 items, zéro changement comportemental)
|
| 460 |
+
|
| 461 |
+
| Fichier | Fix |
|
| 462 |
+
|---------|-----|
|
| 463 |
+
| `api/inference_assembler.py:93` | drop `inplace=True` après `.copy()` |
|
| 464 |
+
| `api/main.py:115-117` | comment FR retiré, return type, tag `meta` |
|
| 465 |
+
| `api/predictor.py` + `api/schemas.py` | `Decision` dédupliqué (DRY) |
|
| 466 |
+
| `api/schemas.py` | `Optional[X]` → `X \| None` (PEP 604, Pydantic v2) |
|
| 467 |
+
| `feature_engineering/aggregations.py:201-202` | `.apply(lambda)` → `.clip(lower=0)` |
|
| 468 |
+
| `feature_engineering/orchestrator.py` | `print()` → `logger.info()` |
|
| 469 |
+
| `scripts/build_no_history_template.py` | `list(...)`, type widening `\| None` |
|
| 470 |
+
| `scripts/export_model.py:55` | guard `if mv.run_id is None` (mypy) |
|
| 471 |
+
| `scripts/export_model.py` | emojis `✅⚠️` → `[OK]`/`[WARN]` |
|
| 472 |
+
| `api/settings.py:42-44` | comment expliquant le seuil 0.33 |
|
| 473 |
+
| `README.md` | XGBoost → LightGBM (5 occurrences + badge) |
|
| 474 |
+
| `README.md` | exemple `decision: "GRANTED"` au lieu de `false` |
|
| 475 |
+
|
| 476 |
+
### Tier 2 — suggestions reportées (à attaquer plus tard si besoin)
|
| 477 |
+
|
| 478 |
+
#### A. Split deps prod / offline → image Docker -700 MB à -1 GB
|
| 479 |
+
|
| 480 |
+
`pyproject.toml` actuellement liste comme deps prod : `mlflow`, `optuna`,
|
| 481 |
+
`shap`, `xgboost`, `jupyter`, `ipykernel`, `matplotlib`, `seaborn`,
|
| 482 |
+
`plotly`. Aucune n'est importée par `api/`. Plan :
|
| 483 |
+
|
| 484 |
+
```toml
|
| 485 |
+
[project]
|
| 486 |
+
dependencies = [
|
| 487 |
+
"fastapi>=0.136.1",
|
| 488 |
+
"huggingface-hub>=1.14.0",
|
| 489 |
+
"joblib", # ajouter explicitement
|
| 490 |
+
"lightgbm>=4.0.0",
|
| 491 |
+
"numpy>=2.4.3",
|
| 492 |
+
"pandas==2.3.3",
|
| 493 |
+
"pyarrow>=23.0.1",
|
| 494 |
+
"pydantic>=2.13.3",
|
| 495 |
+
"uvicorn[standard]>=0.46.0",
|
| 496 |
+
]
|
| 497 |
+
|
| 498 |
+
[dependency-groups]
|
| 499 |
+
dev = ["httpx", "pytest", "pytest-cov", "ruff", "mypy"]
|
| 500 |
+
offline = [
|
| 501 |
+
"mlflow>=2.18,<3", "optuna", "shap", "xgboost", "matplotlib",
|
| 502 |
+
"seaborn", "plotly", "jupyter", "ipykernel",
|
| 503 |
+
]
|
| 504 |
+
```
|
| 505 |
+
|
| 506 |
+
Dockerfile reste sur `uv sync --frozen --no-dev` (le groupe offline est
|
| 507 |
+
exclu par défaut sauf si activé).
|
| 508 |
+
|
| 509 |
+
**Risque** : faible. Vérifier que `api/predictor.py` n'importe pas mlflow
|
| 510 |
+
au runtime (à confirmer — le modèle est désérialisé via joblib mais
|
| 511 |
+
`joblib.load` peut tirer mlflow lors de l'unpickle si le modèle est un
|
| 512 |
+
`PyFuncModel`). Si oui → alternative : option B ci-dessous.
|
| 513 |
+
|
| 514 |
+
#### B. Re-export modèle en LightGBM Booster natif → 3-5× faster + supprime mlflow
|
| 515 |
+
|
| 516 |
+
Actuellement `models/model.joblib` est un `mlflow.pyfunc.PyFuncModel`
|
| 517 |
+
contenant le Booster. À l'inférence on appelle `model.predict(df)` qui
|
| 518 |
+
re-route via le wrapper PyFunc.
|
| 519 |
+
|
| 520 |
+
Plan :
|
| 521 |
+
|
| 522 |
+
```python
|
| 523 |
+
# Dans OC_P6, à côté de la sauvegarde MLflow :
|
| 524 |
+
booster = lgbm_model.booster_ # ou .estimator.booster_ si pipeline
|
| 525 |
+
booster.save_model("models/model.txt")
|
| 526 |
+
```
|
| 527 |
+
|
| 528 |
+
Côté API (`predictor.py`) :
|
| 529 |
+
|
| 530 |
+
```python
|
| 531 |
+
import lightgbm as lgb
|
| 532 |
+
model = lgb.Booster(model_file=str(model_path))
|
| 533 |
+
proba = model.predict(features.values)[0]
|
| 534 |
+
```
|
| 535 |
+
|
| 536 |
+
**Bénéfice** : élimine mlflow du runtime (déblocage du A), latence
|
| 537 |
+
3-5× plus faible sur single-row, image plus légère.
|
| 538 |
+
|
| 539 |
+
**Risque** : moyen. Demande un test de parité (probabilité identique
|
| 540 |
+
±1e-9 entre l'ancien et le nouveau modèle sur un échantillon de 1000
|
| 541 |
+
clients).
|
| 542 |
+
|
| 543 |
+
#### C. Multi-stage Dockerfile + pin base image
|
| 544 |
+
|
| 545 |
+
```dockerfile
|
| 546 |
+
FROM python:3.12.10-slim AS builder
|
| 547 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 548 |
+
build-essential && rm -rf /var/lib/apt/lists/*
|
| 549 |
+
COPY --from=ghcr.io/astral-sh/uv:0.5.4 /uv /usr/local/bin/uv
|
| 550 |
+
WORKDIR /app
|
| 551 |
+
COPY pyproject.toml uv.lock ./
|
| 552 |
+
RUN uv sync --frozen --no-dev --no-install-project
|
| 553 |
+
|
| 554 |
+
FROM python:3.12.10-slim AS runtime
|
| 555 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 556 |
+
libgomp1 && rm -rf /var/lib/apt/lists/*
|
| 557 |
+
COPY --from=builder /opt/venv /opt/venv
|
| 558 |
+
ENV PATH=/opt/venv/bin:$PATH
|
| 559 |
+
WORKDIR /app
|
| 560 |
+
COPY api/ ./api/
|
| 561 |
+
COPY models/ ./models/
|
| 562 |
+
EXPOSE 7860
|
| 563 |
+
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 564 |
+
```
|
| 565 |
+
|
| 566 |
+
**Bénéfice** : -100-200 MB sur l'image finale (pas de cache uv,
|
| 567 |
+
pas de build-essential), reproductibilité (digest pinning possible
|
| 568 |
+
en remplaçant `:3.12.10-slim` par `@sha256:...`).
|
| 569 |
+
|
| 570 |
+
**Risque** : minimal.
|
| 571 |
+
|
| 572 |
+
#### D. DataFrame downcast au cold start → RAM -40-60 %
|
| 573 |
+
|
| 574 |
+
Le parquet (235 MB sur disque) → ~1.0-1.8 GB en RAM avec dtypes par défaut
|
| 575 |
+
(float64, int64). Conversion :
|
| 576 |
+
|
| 577 |
+
```python
|
| 578 |
+
# Dans InferenceArtefacts.load() :
|
| 579 |
+
feature_store = pd.read_parquet(feature_store_path)
|
| 580 |
+
for col in feature_store.select_dtypes(include="float64").columns:
|
| 581 |
+
feature_store[col] = feature_store[col].astype("float32")
|
| 582 |
+
for col in feature_store.select_dtypes(include="int64").columns:
|
| 583 |
+
feature_store[col] = pd.to_numeric(feature_store[col], downcast="integer")
|
| 584 |
+
```
|
| 585 |
+
|
| 586 |
+
**Bénéfice** : RAM 600-900 MB au lieu de 1-1.8 GB, `.loc` plus rapide.
|
| 587 |
+
|
| 588 |
+
**Risque** : faible si LightGBM a été entraîné sur float32 (à vérifier ;
|
| 589 |
+
si entraîné float64, faible perte de précision sur les agrégats — à
|
| 590 |
+
mesurer via test de parité).
|
| 591 |
+
|
| 592 |
+
#### E. Précompute numpy template → latence -5 à -15 ms par requête
|
| 593 |
+
|
| 594 |
+
Le hot path actuel fait `pd.DataFrame([raw])` + `pd.Categorical` × 14 +
|
| 595 |
+
`get_dummies` + `concat` + `reindex(768)` + `replace(inf, nan)` pour
|
| 596 |
+
chaque requête. Plan : remplacer par une copie de template numpy pré-aligné
|
| 597 |
+
sur `feature_names`, avec un mapping `name → index` calculé une fois au boot.
|
| 598 |
+
|
| 599 |
+
**Bénéfice** : élimine pandas du hot path, latence p50 probablement de
|
| 600 |
+
20-40 ms à 3-8 ms.
|
| 601 |
+
|
| 602 |
+
**Risque** : moyen. Demande un golden-file test exhaustif vs assemble()
|
| 603 |
+
actuel (sortie strictement identique sur ≥100 cas couvrant les deux
|
| 604 |
+
branches connu/inconnu, toutes les catégorielles).
|
| 605 |
+
|
| 606 |
+
#### F. Sécurité advisory (non bloquant pour formation OC)
|
| 607 |
+
|
| 608 |
+
- **Rate limiting** : `slowapi` per-IP sur `/predict` (10 req/min).
|
| 609 |
+
- **CORS** : `CORSMiddleware` avec `allow_origins=["https://...your-frontend..."]`.
|
| 610 |
+
- **API key** : header `X-API-Key` via dépendance FastAPI si besoin
|
| 611 |
+
d'auth simple.
|
| 612 |
+
- **Non-root user dans Dockerfile** : `RUN useradd -m appuser && USER appuser`
|
| 613 |
+
(HF Spaces enforce déjà un user namespace, mais defense-in-depth).
|
| 614 |
+
|
| 615 |
+
À faire **uniquement** si l'API doit être exposée à du trafic réel.
|
| 616 |
+
|
| 617 |
+
### Findings non-bloquants laissés tels quels
|
| 618 |
+
|
| 619 |
+
- **Chemins absolus** dans `scripts/build_feature_store.py:41`,
|
| 620 |
+
`scripts/export_model.py:27-30`, `scripts/check_registry.py:4` —
|
| 621 |
+
scripts personnels one-shot, pas de gain immédiat à env-var-iser.
|
| 622 |
+
- **CI action versions** (`actions/checkout@v6`, `setup-python@v6`,
|
| 623 |
+
`upload-artifact@v7`) — flagged par code-reviewer comme non-existantes,
|
| 624 |
+
mais le CI tourne, donc soit elles existent, soit GitHub résout
|
| 625 |
+
silencieusement. Non-bloquant.
|
| 626 |
+
- **Duplication ratios** offline / runtime (orchestrator vs `api/ratios.py`)
|
| 627 |
+
— intentionnelle et confirmée par 2 reviewers (le runtime a besoin du
|
| 628 |
+
scrub `inf → NaN` que le offline n'a pas).
|
| 629 |
+
- **`joblib.load` pickle-based** — modèle baked-in à l'image build,
|
| 630 |
+
pas un vecteur d'attaque exploitable.
|
.claude/settings.local.json
CHANGED
|
@@ -5,7 +5,13 @@
|
|
| 5 |
"Bash(python -c ' *)",
|
| 6 |
"Bash(uv run --active python -c ' *)",
|
| 7 |
"WebFetch(domain:github.com)",
|
| 8 |
-
"WebSearch"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
]
|
| 10 |
}
|
| 11 |
}
|
|
|
|
| 5 |
"Bash(python -c ' *)",
|
| 6 |
"Bash(uv run --active python -c ' *)",
|
| 7 |
"WebFetch(domain:github.com)",
|
| 8 |
+
"WebSearch",
|
| 9 |
+
"Bash(uv run *)",
|
| 10 |
+
"Bash(git -C \"C:\\\\Users\\\\Kevin\\\\projects\\\\OC_P8\" diff --staged)",
|
| 11 |
+
"Bash(git -C \"C:\\\\Users\\\\Kevin\\\\projects\\\\OC_P8\" diff)",
|
| 12 |
+
"Bash(git -C \"C:\\\\Users\\\\Kevin\\\\projects\\\\OC_P8\" log --oneline -10)",
|
| 13 |
+
"PowerShell(uv run ruff check api feature_engineering tests scripts)",
|
| 14 |
+
"PowerShell(uv run pytest --cov=api --cov-fail-under=80 -q)"
|
| 15 |
]
|
| 16 |
}
|
| 17 |
}
|
README.md
CHANGED
|
@@ -18,7 +18,7 @@ pinned: false
|
|
| 18 |
<div align="center">
|
| 19 |
<h1 align="center">OC P8 — Credit Scoring API</h1>
|
| 20 |
<p align="center">
|
| 21 |
-
Production-grade FastAPI wrapper around the
|
| 22 |
trained in OC_P6. Built for <em>Prêt à Dépenser</em>'s Crédit Express department:
|
| 23 |
real-time default risk prediction for loan officers.
|
| 24 |
<br />
|
|
@@ -63,7 +63,7 @@ pinned: false
|
|
| 63 |
The **Credit Scoring API** exposes a single `POST /predict` endpoint. Given a loan application (`SK_ID_CURR` + 120 raw `application_train` fields), it returns:
|
| 64 |
|
| 65 |
- `probability_default` — model score between 0 and 1
|
| 66 |
-
- `decision` — `
|
| 67 |
- `threshold`, `model_version`, `client_known` — explainability metadata
|
| 68 |
|
| 69 |
The threshold **0.33** is optimised for an asymmetric cost function (10 × false negatives + false positives), meaning the model is intentionally conservative: missing a bad borrower costs 10× more than wrongly refusing a good one.
|
|
@@ -74,7 +74,7 @@ The threshold **0.33** is optimised for an asymmetric cost function (10 × false
|
|
| 74 |
|
| 75 |
[![Python][python-badge]][python-url]
|
| 76 |
[![FastAPI][fastapi-badge]][fastapi-url]
|
| 77 |
-
[![
|
| 78 |
[![uv][uv-badge]][uv-url]
|
| 79 |
[![Docker][docker-badge]][docker-url]
|
| 80 |
[![GitHub Actions][gha-badge]][gha-url]
|
|
@@ -199,14 +199,14 @@ Example response:
|
|
| 199 |
{
|
| 200 |
"sk_id_curr": 100001,
|
| 201 |
"probability_default": 0.1523,
|
| 202 |
-
"decision":
|
| 203 |
"threshold": 0.33,
|
| 204 |
-
"model_version": "
|
| 205 |
"client_known": true
|
| 206 |
}
|
| 207 |
```
|
| 208 |
|
| 209 |
-
`decision:
|
| 210 |
|
| 211 |
You can also use the interactive **Swagger UI** at `/docs` → `POST /predict` → **Try it out**.
|
| 212 |
|
|
@@ -249,7 +249,7 @@ JSON {SK_ID_CURR + 120 raw application_train fields}
|
|
| 249 |
| **Known client** | `SK_ID_CURR` found in `features_store.parquet` | Pre-computed bureau / prev / POS / CC / install |
|
| 250 |
| **Unknown client** | `SK_ID_CURR` not found | `no_history_template.json` (counts=0, rest NaN) |
|
| 251 |
|
| 252 |
-
The unknown-client path preserves
|
| 253 |
|
| 254 |
### Data layer — code/data separation
|
| 255 |
|
|
@@ -408,8 +408,8 @@ Internal project — Prêt à Dépenser MLOps formation OpenClassrooms.
|
|
| 408 |
[python-url]: https://www.python.org/
|
| 409 |
[fastapi-badge]: https://img.shields.io/badge/FastAPI-0.115-009688?style=for-the-badge&logo=fastapi&logoColor=white
|
| 410 |
[fastapi-url]: https://fastapi.tiangolo.com/
|
| 411 |
-
[
|
| 412 |
-
[
|
| 413 |
[uv-badge]: https://img.shields.io/badge/uv-package%20manager-DE5FE9?style=for-the-badge
|
| 414 |
[uv-url]: https://docs.astral.sh/uv/
|
| 415 |
[docker-badge]: https://img.shields.io/badge/Docker-container-2496ED?style=for-the-badge&logo=docker&logoColor=white
|
|
|
|
| 18 |
<div align="center">
|
| 19 |
<h1 align="center">OC P8 — Credit Scoring API</h1>
|
| 20 |
<p align="center">
|
| 21 |
+
Production-grade FastAPI wrapper around the LightGBM credit scoring model
|
| 22 |
trained in OC_P6. Built for <em>Prêt à Dépenser</em>'s Crédit Express department:
|
| 23 |
real-time default risk prediction for loan officers.
|
| 24 |
<br />
|
|
|
|
| 63 |
The **Credit Scoring API** exposes a single `POST /predict` endpoint. Given a loan application (`SK_ID_CURR` + 120 raw `application_train` fields), it returns:
|
| 64 |
|
| 65 |
- `probability_default` — model score between 0 and 1
|
| 66 |
+
- `decision` — `"REFUSED"` if `proba ≥ 0.33`, `"GRANTED"` otherwise
|
| 67 |
- `threshold`, `model_version`, `client_known` — explainability metadata
|
| 68 |
|
| 69 |
The threshold **0.33** is optimised for an asymmetric cost function (10 × false negatives + false positives), meaning the model is intentionally conservative: missing a bad borrower costs 10× more than wrongly refusing a good one.
|
|
|
|
| 74 |
|
| 75 |
[![Python][python-badge]][python-url]
|
| 76 |
[![FastAPI][fastapi-badge]][fastapi-url]
|
| 77 |
+
[![LightGBM][lightgbm-badge]][lightgbm-url]
|
| 78 |
[![uv][uv-badge]][uv-url]
|
| 79 |
[![Docker][docker-badge]][docker-url]
|
| 80 |
[![GitHub Actions][gha-badge]][gha-url]
|
|
|
|
| 199 |
{
|
| 200 |
"sk_id_curr": 100001,
|
| 201 |
"probability_default": 0.1523,
|
| 202 |
+
"decision": "GRANTED",
|
| 203 |
"threshold": 0.33,
|
| 204 |
+
"model_version": "2",
|
| 205 |
"client_known": true
|
| 206 |
}
|
| 207 |
```
|
| 208 |
|
| 209 |
+
`decision: "GRANTED"` = loan **granted** · `decision: "REFUSED"` = loan **refused**
|
| 210 |
|
| 211 |
You can also use the interactive **Swagger UI** at `/docs` → `POST /predict` → **Try it out**.
|
| 212 |
|
|
|
|
| 249 |
| **Known client** | `SK_ID_CURR` found in `features_store.parquet` | Pre-computed bureau / prev / POS / CC / install |
|
| 250 |
| **Unknown client** | `SK_ID_CURR` not found | `no_history_template.json` (counts=0, rest NaN) |
|
| 251 |
|
| 252 |
+
The unknown-client path preserves LightGBM's training-time NaN signal ("no historical data") rather than imputing fictitious medians.
|
| 253 |
|
| 254 |
### Data layer — code/data separation
|
| 255 |
|
|
|
|
| 408 |
[python-url]: https://www.python.org/
|
| 409 |
[fastapi-badge]: https://img.shields.io/badge/FastAPI-0.115-009688?style=for-the-badge&logo=fastapi&logoColor=white
|
| 410 |
[fastapi-url]: https://fastapi.tiangolo.com/
|
| 411 |
+
[lightgbm-badge]: https://img.shields.io/badge/LightGBM-4.x-2E8B57?style=for-the-badge
|
| 412 |
+
[lightgbm-url]: https://lightgbm.readthedocs.io/
|
| 413 |
[uv-badge]: https://img.shields.io/badge/uv-package%20manager-DE5FE9?style=for-the-badge
|
| 414 |
[uv-url]: https://docs.astral.sh/uv/
|
| 415 |
[docker-badge]: https://img.shields.io/badge/Docker-container-2496ED?style=for-the-badge&logo=docker&logoColor=white
|
api/inference_assembler.py
CHANGED
|
@@ -90,8 +90,7 @@ def assemble(
|
|
| 90 |
|
| 91 |
# 2. Aggregate portion — lookup or template.
|
| 92 |
if sk_id_curr in artefacts.feature_store.index:
|
| 93 |
-
agg_part = artefacts.feature_store.loc[[sk_id_curr]].
|
| 94 |
-
agg_part.reset_index(drop=True, inplace=True)
|
| 95 |
client_known = True
|
| 96 |
else:
|
| 97 |
agg_part = pd.DataFrame([artefacts.no_history_template])
|
|
|
|
| 90 |
|
| 91 |
# 2. Aggregate portion — lookup or template.
|
| 92 |
if sk_id_curr in artefacts.feature_store.index:
|
| 93 |
+
agg_part = artefacts.feature_store.loc[[sk_id_curr]].reset_index(drop=True)
|
|
|
|
| 94 |
client_known = True
|
| 95 |
else:
|
| 96 |
agg_part = pd.DataFrame([artefacts.no_history_template])
|
api/main.py
CHANGED
|
@@ -112,9 +112,8 @@ async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONR
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
async def read_root():
|
| 118 |
return {"message": "Welcome to the CREDIT DEFAULT predictor API for Prêt à Dépenser"}
|
| 119 |
|
| 120 |
|
|
|
|
| 112 |
)
|
| 113 |
|
| 114 |
|
| 115 |
+
@app.get("/", tags=["meta"])
|
| 116 |
+
async def read_root() -> dict[str, str]:
|
|
|
|
| 117 |
return {"message": "Welcome to the CREDIT DEFAULT predictor API for Prêt à Dépenser"}
|
| 118 |
|
| 119 |
|
api/predictor.py
CHANGED
|
@@ -10,13 +10,12 @@ from __future__ import annotations
|
|
| 10 |
|
| 11 |
import json
|
| 12 |
from pathlib import Path
|
| 13 |
-
from typing import Literal
|
| 14 |
|
| 15 |
import joblib
|
| 16 |
import numpy as np
|
| 17 |
import pandas as pd
|
| 18 |
|
| 19 |
-
|
| 20 |
|
| 21 |
|
| 22 |
class CreditScoringPredictor:
|
|
|
|
| 10 |
|
| 11 |
import json
|
| 12 |
from pathlib import Path
|
|
|
|
| 13 |
|
| 14 |
import joblib
|
| 15 |
import numpy as np
|
| 16 |
import pandas as pd
|
| 17 |
|
| 18 |
+
from api.schemas import Decision
|
| 19 |
|
| 20 |
|
| 21 |
class CreditScoringPredictor:
|
api/schemas.py
CHANGED
|
@@ -12,7 +12,7 @@ nonsensical inputs at the API boundary.
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
-
from typing import Literal
|
| 16 |
|
| 17 |
from pydantic import BaseModel, ConfigDict, Field
|
| 18 |
|
|
@@ -178,9 +178,9 @@ class PredictionRequest(BaseModel):
|
|
| 178 |
CNT_CHILDREN: int = Field(ge=0, le=20)
|
| 179 |
AMT_INCOME_TOTAL: float = Field(gt=0)
|
| 180 |
AMT_CREDIT: float = Field(gt=0)
|
| 181 |
-
AMT_ANNUITY:
|
| 182 |
-
AMT_GOODS_PRICE:
|
| 183 |
-
NAME_TYPE_SUITE:
|
| 184 |
NAME_INCOME_TYPE: IncomeType
|
| 185 |
NAME_EDUCATION_TYPE: EducationType
|
| 186 |
NAME_FAMILY_STATUS: FamilyStatus
|
|
@@ -194,14 +194,14 @@ class PredictionRequest(BaseModel):
|
|
| 194 |
)
|
| 195 |
DAYS_REGISTRATION: float = Field(le=0, ge=-25000)
|
| 196 |
DAYS_ID_PUBLISH: int = Field(le=0, ge=-10000)
|
| 197 |
-
OWN_CAR_AGE:
|
| 198 |
FLAG_MOBIL: int = Field(ge=0, le=1)
|
| 199 |
FLAG_EMP_PHONE: int = Field(ge=0, le=1)
|
| 200 |
FLAG_WORK_PHONE: int = Field(ge=0, le=1)
|
| 201 |
FLAG_CONT_MOBILE: int = Field(ge=0, le=1)
|
| 202 |
FLAG_PHONE: int = Field(ge=0, le=1)
|
| 203 |
FLAG_EMAIL: int = Field(ge=0, le=1)
|
| 204 |
-
OCCUPATION_TYPE:
|
| 205 |
CNT_FAM_MEMBERS: float = Field(ge=1, le=20)
|
| 206 |
REGION_RATING_CLIENT: int = Field(ge=1, le=3)
|
| 207 |
REGION_RATING_CLIENT_W_CITY: int = Field(ge=1, le=3)
|
|
@@ -216,64 +216,64 @@ class PredictionRequest(BaseModel):
|
|
| 216 |
ORGANIZATION_TYPE: OrganizationType
|
| 217 |
|
| 218 |
# External scoring sources ----------------------------------------------
|
| 219 |
-
EXT_SOURCE_1:
|
| 220 |
-
EXT_SOURCE_2:
|
| 221 |
-
EXT_SOURCE_3:
|
| 222 |
|
| 223 |
# Building characteristics (mostly nullable, ratios in [0, 1]) ----------
|
| 224 |
-
APARTMENTS_AVG:
|
| 225 |
-
BASEMENTAREA_AVG:
|
| 226 |
-
YEARS_BEGINEXPLUATATION_AVG:
|
| 227 |
-
YEARS_BUILD_AVG:
|
| 228 |
-
COMMONAREA_AVG:
|
| 229 |
-
ELEVATORS_AVG:
|
| 230 |
-
ENTRANCES_AVG:
|
| 231 |
-
FLOORSMAX_AVG:
|
| 232 |
-
FLOORSMIN_AVG:
|
| 233 |
-
LANDAREA_AVG:
|
| 234 |
-
LIVINGAPARTMENTS_AVG:
|
| 235 |
-
LIVINGAREA_AVG:
|
| 236 |
-
NONLIVINGAPARTMENTS_AVG:
|
| 237 |
-
NONLIVINGAREA_AVG:
|
| 238 |
-
APARTMENTS_MODE:
|
| 239 |
-
BASEMENTAREA_MODE:
|
| 240 |
-
YEARS_BEGINEXPLUATATION_MODE:
|
| 241 |
-
YEARS_BUILD_MODE:
|
| 242 |
-
COMMONAREA_MODE:
|
| 243 |
-
ELEVATORS_MODE:
|
| 244 |
-
ENTRANCES_MODE:
|
| 245 |
-
FLOORSMAX_MODE:
|
| 246 |
-
FLOORSMIN_MODE:
|
| 247 |
-
LANDAREA_MODE:
|
| 248 |
-
LIVINGAPARTMENTS_MODE:
|
| 249 |
-
LIVINGAREA_MODE:
|
| 250 |
-
NONLIVINGAPARTMENTS_MODE:
|
| 251 |
-
NONLIVINGAREA_MODE:
|
| 252 |
-
APARTMENTS_MEDI:
|
| 253 |
-
BASEMENTAREA_MEDI:
|
| 254 |
-
YEARS_BEGINEXPLUATATION_MEDI:
|
| 255 |
-
YEARS_BUILD_MEDI:
|
| 256 |
-
COMMONAREA_MEDI:
|
| 257 |
-
ELEVATORS_MEDI:
|
| 258 |
-
ENTRANCES_MEDI:
|
| 259 |
-
FLOORSMAX_MEDI:
|
| 260 |
-
FLOORSMIN_MEDI:
|
| 261 |
-
LANDAREA_MEDI:
|
| 262 |
-
LIVINGAPARTMENTS_MEDI:
|
| 263 |
-
LIVINGAREA_MEDI:
|
| 264 |
-
NONLIVINGAPARTMENTS_MEDI:
|
| 265 |
-
NONLIVINGAREA_MEDI:
|
| 266 |
-
FONDKAPREMONT_MODE:
|
| 267 |
-
HOUSETYPE_MODE:
|
| 268 |
-
TOTALAREA_MODE:
|
| 269 |
-
WALLSMATERIAL_MODE:
|
| 270 |
-
EMERGENCYSTATE_MODE:
|
| 271 |
|
| 272 |
# Social circle ---------------------------------------------------------
|
| 273 |
-
OBS_30_CNT_SOCIAL_CIRCLE:
|
| 274 |
-
DEF_30_CNT_SOCIAL_CIRCLE:
|
| 275 |
-
OBS_60_CNT_SOCIAL_CIRCLE:
|
| 276 |
-
DEF_60_CNT_SOCIAL_CIRCLE:
|
| 277 |
|
| 278 |
DAYS_LAST_PHONE_CHANGE: float = Field(le=0, ge=-15000)
|
| 279 |
|
|
@@ -300,12 +300,12 @@ class PredictionRequest(BaseModel):
|
|
| 300 |
FLAG_DOCUMENT_21: int = Field(ge=0, le=1)
|
| 301 |
|
| 302 |
# Credit bureau request volume -----------------------------------------
|
| 303 |
-
AMT_REQ_CREDIT_BUREAU_HOUR:
|
| 304 |
-
AMT_REQ_CREDIT_BUREAU_DAY:
|
| 305 |
-
AMT_REQ_CREDIT_BUREAU_WEEK:
|
| 306 |
-
AMT_REQ_CREDIT_BUREAU_MON:
|
| 307 |
-
AMT_REQ_CREDIT_BUREAU_QRT:
|
| 308 |
-
AMT_REQ_CREDIT_BUREAU_YEAR:
|
| 309 |
|
| 310 |
|
| 311 |
# ---------------------------------------------------------------------------
|
|
|
|
| 12 |
|
| 13 |
from __future__ import annotations
|
| 14 |
|
| 15 |
+
from typing import Literal
|
| 16 |
|
| 17 |
from pydantic import BaseModel, ConfigDict, Field
|
| 18 |
|
|
|
|
| 178 |
CNT_CHILDREN: int = Field(ge=0, le=20)
|
| 179 |
AMT_INCOME_TOTAL: float = Field(gt=0)
|
| 180 |
AMT_CREDIT: float = Field(gt=0)
|
| 181 |
+
AMT_ANNUITY: float | None = Field(default=None, gt=0)
|
| 182 |
+
AMT_GOODS_PRICE: float | None = Field(default=None, gt=0)
|
| 183 |
+
NAME_TYPE_SUITE: TypeSuite | None = None
|
| 184 |
NAME_INCOME_TYPE: IncomeType
|
| 185 |
NAME_EDUCATION_TYPE: EducationType
|
| 186 |
NAME_FAMILY_STATUS: FamilyStatus
|
|
|
|
| 194 |
)
|
| 195 |
DAYS_REGISTRATION: float = Field(le=0, ge=-25000)
|
| 196 |
DAYS_ID_PUBLISH: int = Field(le=0, ge=-10000)
|
| 197 |
+
OWN_CAR_AGE: float | None = Field(default=None, ge=0, le=100)
|
| 198 |
FLAG_MOBIL: int = Field(ge=0, le=1)
|
| 199 |
FLAG_EMP_PHONE: int = Field(ge=0, le=1)
|
| 200 |
FLAG_WORK_PHONE: int = Field(ge=0, le=1)
|
| 201 |
FLAG_CONT_MOBILE: int = Field(ge=0, le=1)
|
| 202 |
FLAG_PHONE: int = Field(ge=0, le=1)
|
| 203 |
FLAG_EMAIL: int = Field(ge=0, le=1)
|
| 204 |
+
OCCUPATION_TYPE: OccupationType | None = None
|
| 205 |
CNT_FAM_MEMBERS: float = Field(ge=1, le=20)
|
| 206 |
REGION_RATING_CLIENT: int = Field(ge=1, le=3)
|
| 207 |
REGION_RATING_CLIENT_W_CITY: int = Field(ge=1, le=3)
|
|
|
|
| 216 |
ORGANIZATION_TYPE: OrganizationType
|
| 217 |
|
| 218 |
# External scoring sources ----------------------------------------------
|
| 219 |
+
EXT_SOURCE_1: float | None = Field(default=None, ge=0, le=1)
|
| 220 |
+
EXT_SOURCE_2: float | None = Field(default=None, ge=0, le=1)
|
| 221 |
+
EXT_SOURCE_3: float | None = Field(default=None, ge=0, le=1)
|
| 222 |
|
| 223 |
# Building characteristics (mostly nullable, ratios in [0, 1]) ----------
|
| 224 |
+
APARTMENTS_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 225 |
+
BASEMENTAREA_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 226 |
+
YEARS_BEGINEXPLUATATION_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 227 |
+
YEARS_BUILD_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 228 |
+
COMMONAREA_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 229 |
+
ELEVATORS_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 230 |
+
ENTRANCES_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 231 |
+
FLOORSMAX_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 232 |
+
FLOORSMIN_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 233 |
+
LANDAREA_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 234 |
+
LIVINGAPARTMENTS_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 235 |
+
LIVINGAREA_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 236 |
+
NONLIVINGAPARTMENTS_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 237 |
+
NONLIVINGAREA_AVG: float | None = Field(default=None, ge=0, le=1)
|
| 238 |
+
APARTMENTS_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 239 |
+
BASEMENTAREA_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 240 |
+
YEARS_BEGINEXPLUATATION_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 241 |
+
YEARS_BUILD_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 242 |
+
COMMONAREA_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 243 |
+
ELEVATORS_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 244 |
+
ENTRANCES_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 245 |
+
FLOORSMAX_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 246 |
+
FLOORSMIN_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 247 |
+
LANDAREA_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 248 |
+
LIVINGAPARTMENTS_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 249 |
+
LIVINGAREA_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 250 |
+
NONLIVINGAPARTMENTS_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 251 |
+
NONLIVINGAREA_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 252 |
+
APARTMENTS_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 253 |
+
BASEMENTAREA_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 254 |
+
YEARS_BEGINEXPLUATATION_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 255 |
+
YEARS_BUILD_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 256 |
+
COMMONAREA_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 257 |
+
ELEVATORS_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 258 |
+
ENTRANCES_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 259 |
+
FLOORSMAX_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 260 |
+
FLOORSMIN_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 261 |
+
LANDAREA_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 262 |
+
LIVINGAPARTMENTS_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 263 |
+
LIVINGAREA_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 264 |
+
NONLIVINGAPARTMENTS_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 265 |
+
NONLIVINGAREA_MEDI: float | None = Field(default=None, ge=0, le=1)
|
| 266 |
+
FONDKAPREMONT_MODE: FondKapremontMode | None = None
|
| 267 |
+
HOUSETYPE_MODE: HouseTypeMode | None = None
|
| 268 |
+
TOTALAREA_MODE: float | None = Field(default=None, ge=0, le=1)
|
| 269 |
+
WALLSMATERIAL_MODE: WallsMaterialMode | None = None
|
| 270 |
+
EMERGENCYSTATE_MODE: EmergencyStateMode | None = None
|
| 271 |
|
| 272 |
# Social circle ---------------------------------------------------------
|
| 273 |
+
OBS_30_CNT_SOCIAL_CIRCLE: float | None = Field(default=None, ge=0, le=500)
|
| 274 |
+
DEF_30_CNT_SOCIAL_CIRCLE: float | None = Field(default=None, ge=0, le=500)
|
| 275 |
+
OBS_60_CNT_SOCIAL_CIRCLE: float | None = Field(default=None, ge=0, le=500)
|
| 276 |
+
DEF_60_CNT_SOCIAL_CIRCLE: float | None = Field(default=None, ge=0, le=500)
|
| 277 |
|
| 278 |
DAYS_LAST_PHONE_CHANGE: float = Field(le=0, ge=-15000)
|
| 279 |
|
|
|
|
| 300 |
FLAG_DOCUMENT_21: int = Field(ge=0, le=1)
|
| 301 |
|
| 302 |
# Credit bureau request volume -----------------------------------------
|
| 303 |
+
AMT_REQ_CREDIT_BUREAU_HOUR: float | None = Field(default=None, ge=0, le=500)
|
| 304 |
+
AMT_REQ_CREDIT_BUREAU_DAY: float | None = Field(default=None, ge=0, le=500)
|
| 305 |
+
AMT_REQ_CREDIT_BUREAU_WEEK: float | None = Field(default=None, ge=0, le=500)
|
| 306 |
+
AMT_REQ_CREDIT_BUREAU_MON: float | None = Field(default=None, ge=0, le=500)
|
| 307 |
+
AMT_REQ_CREDIT_BUREAU_QRT: float | None = Field(default=None, ge=0, le=500)
|
| 308 |
+
AMT_REQ_CREDIT_BUREAU_YEAR: float | None = Field(default=None, ge=0, le=500)
|
| 309 |
|
| 310 |
|
| 311 |
# ---------------------------------------------------------------------------
|
api/settings.py
CHANGED
|
@@ -40,4 +40,6 @@ HF_DATASET_REPO_ID = os.getenv("OC_P8_HF_DATASET_REPO_ID", "KLEB38/oc-p8-feature
|
|
| 40 |
HF_DATASET_FILENAME = os.getenv("OC_P8_HF_DATASET_FILENAME", "features_store.parquet")
|
| 41 |
|
| 42 |
# Default fallback if model_info.json does not expose the optimised threshold.
|
|
|
|
|
|
|
| 43 |
DEFAULT_THRESHOLD = 0.33
|
|
|
|
| 40 |
HF_DATASET_FILENAME = os.getenv("OC_P8_HF_DATASET_FILENAME", "features_store.parquet")
|
| 41 |
|
| 42 |
# Default fallback if model_info.json does not expose the optimised threshold.
|
| 43 |
+
# 0.33 minimises the business cost function 10*FN + FP from OC_P6 — re-run the
|
| 44 |
+
# threshold search if the model is retrained.
|
| 45 |
DEFAULT_THRESHOLD = 0.33
|
feature_engineering/aggregations.py
CHANGED
|
@@ -198,8 +198,8 @@ def installments_payments(
|
|
| 198 |
|
| 199 |
ins["DPD"] = ins["DAYS_ENTRY_PAYMENT"] - ins["DAYS_INSTALMENT"]
|
| 200 |
ins["DBD"] = ins["DAYS_INSTALMENT"] - ins["DAYS_ENTRY_PAYMENT"]
|
| 201 |
-
ins["DPD"] = ins["DPD"].
|
| 202 |
-
ins["DBD"] = ins["DBD"].
|
| 203 |
|
| 204 |
aggregations: dict[str, list[str]] = {
|
| 205 |
"NUM_INSTALMENT_VERSION": ["nunique"],
|
|
|
|
| 198 |
|
| 199 |
ins["DPD"] = ins["DAYS_ENTRY_PAYMENT"] - ins["DAYS_INSTALMENT"]
|
| 200 |
ins["DBD"] = ins["DAYS_INSTALMENT"] - ins["DAYS_ENTRY_PAYMENT"]
|
| 201 |
+
ins["DPD"] = ins["DPD"].clip(lower=0)
|
| 202 |
+
ins["DBD"] = ins["DBD"].clip(lower=0)
|
| 203 |
|
| 204 |
aggregations: dict[str, list[str]] = {
|
| 205 |
"NUM_INSTALMENT_VERSION": ["nunique"],
|
feature_engineering/orchestrator.py
CHANGED
|
@@ -8,6 +8,7 @@ the model.
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import gc
|
|
|
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
import numpy as np
|
|
@@ -22,6 +23,8 @@ from feature_engineering.aggregations import (
|
|
| 22 |
previous_applications,
|
| 23 |
)
|
| 24 |
|
|
|
|
|
|
|
| 25 |
|
| 26 |
def app_train_clean(
|
| 27 |
data_dir: Path, num_rows: int | None = None, nan_as_category: bool = False
|
|
@@ -56,31 +59,31 @@ def merge_files(data_dir: Path, debug: bool = False) -> pd.DataFrame:
|
|
| 56 |
df = app_train_clean(data_dir, num_rows)
|
| 57 |
|
| 58 |
bureau = bureau_and_balance(data_dir, num_rows)
|
| 59 |
-
|
| 60 |
df = df.join(bureau, how="left", on="SK_ID_CURR")
|
| 61 |
del bureau
|
| 62 |
gc.collect()
|
| 63 |
|
| 64 |
prev = previous_applications(data_dir, num_rows)
|
| 65 |
-
|
| 66 |
df = df.join(prev, how="left", on="SK_ID_CURR")
|
| 67 |
del prev
|
| 68 |
gc.collect()
|
| 69 |
|
| 70 |
pos = pos_cash(data_dir, num_rows)
|
| 71 |
-
|
| 72 |
df = df.join(pos, how="left", on="SK_ID_CURR")
|
| 73 |
del pos
|
| 74 |
gc.collect()
|
| 75 |
|
| 76 |
ins = installments_payments(data_dir, num_rows)
|
| 77 |
-
|
| 78 |
df = df.join(ins, how="left", on="SK_ID_CURR")
|
| 79 |
del ins
|
| 80 |
gc.collect()
|
| 81 |
|
| 82 |
cc = credit_card_balance(data_dir, num_rows)
|
| 83 |
-
|
| 84 |
df = df.join(cc, how="left", on="SK_ID_CURR")
|
| 85 |
del cc
|
| 86 |
gc.collect()
|
|
|
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
import gc
|
| 11 |
+
import logging
|
| 12 |
from pathlib import Path
|
| 13 |
|
| 14 |
import numpy as np
|
|
|
|
| 23 |
previous_applications,
|
| 24 |
)
|
| 25 |
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
|
| 29 |
def app_train_clean(
|
| 30 |
data_dir: Path, num_rows: int | None = None, nan_as_category: bool = False
|
|
|
|
| 59 |
df = app_train_clean(data_dir, num_rows)
|
| 60 |
|
| 61 |
bureau = bureau_and_balance(data_dir, num_rows)
|
| 62 |
+
logger.info("Bureau df shape: %s", bureau.shape)
|
| 63 |
df = df.join(bureau, how="left", on="SK_ID_CURR")
|
| 64 |
del bureau
|
| 65 |
gc.collect()
|
| 66 |
|
| 67 |
prev = previous_applications(data_dir, num_rows)
|
| 68 |
+
logger.info("Previous applications df shape: %s", prev.shape)
|
| 69 |
df = df.join(prev, how="left", on="SK_ID_CURR")
|
| 70 |
del prev
|
| 71 |
gc.collect()
|
| 72 |
|
| 73 |
pos = pos_cash(data_dir, num_rows)
|
| 74 |
+
logger.info("Pos-cash balance df shape: %s", pos.shape)
|
| 75 |
df = df.join(pos, how="left", on="SK_ID_CURR")
|
| 76 |
del pos
|
| 77 |
gc.collect()
|
| 78 |
|
| 79 |
ins = installments_payments(data_dir, num_rows)
|
| 80 |
+
logger.info("Installments payments df shape: %s", ins.shape)
|
| 81 |
df = df.join(ins, how="left", on="SK_ID_CURR")
|
| 82 |
del ins
|
| 83 |
gc.collect()
|
| 84 |
|
| 85 |
cc = credit_card_balance(data_dir, num_rows)
|
| 86 |
+
logger.info("Credit card balance df shape: %s", cc.shape)
|
| 87 |
df = df.join(cc, how="left", on="SK_ID_CURR")
|
| 88 |
del cc
|
| 89 |
gc.collect()
|
scripts/build_no_history_template.py
CHANGED
|
@@ -35,10 +35,10 @@ def main() -> None:
|
|
| 35 |
feature_names = json.loads(FEATURE_NAMES_PATH.read_text())
|
| 36 |
store = pd.read_parquet(FEATURE_STORE_PATH)
|
| 37 |
|
| 38 |
-
aggregate_cols =
|
| 39 |
print(f"Inspecting {len(aggregate_cols)} aggregate columns...")
|
| 40 |
|
| 41 |
-
template: dict[str, float | int] = {}
|
| 42 |
count_cols: list[str] = []
|
| 43 |
nan_cols: list[str] = []
|
| 44 |
|
|
@@ -56,12 +56,12 @@ def main() -> None:
|
|
| 56 |
print("Sample NaN columns: ", nan_cols[:5])
|
| 57 |
|
| 58 |
OUT_TEMPLATE.write_text(json.dumps(template, indent=2))
|
| 59 |
-
print(f"\n
|
| 60 |
|
| 61 |
# Sanity check: every aggregate column from the store is also in feature_names
|
| 62 |
missing = [c for c in aggregate_cols if c not in feature_names]
|
| 63 |
if missing:
|
| 64 |
-
print(f"
|
| 65 |
print(f" first 5: {missing[:5]}")
|
| 66 |
|
| 67 |
|
|
|
|
| 35 |
feature_names = json.loads(FEATURE_NAMES_PATH.read_text())
|
| 36 |
store = pd.read_parquet(FEATURE_STORE_PATH)
|
| 37 |
|
| 38 |
+
aggregate_cols = list(store.columns)
|
| 39 |
print(f"Inspecting {len(aggregate_cols)} aggregate columns...")
|
| 40 |
|
| 41 |
+
template: dict[str, float | int | None] = {}
|
| 42 |
count_cols: list[str] = []
|
| 43 |
nan_cols: list[str] = []
|
| 44 |
|
|
|
|
| 56 |
print("Sample NaN columns: ", nan_cols[:5])
|
| 57 |
|
| 58 |
OUT_TEMPLATE.write_text(json.dumps(template, indent=2))
|
| 59 |
+
print(f"\n[OK] {OUT_TEMPLATE} ({len(template)} entries)")
|
| 60 |
|
| 61 |
# Sanity check: every aggregate column from the store is also in feature_names
|
| 62 |
missing = [c for c in aggregate_cols if c not in feature_names]
|
| 63 |
if missing:
|
| 64 |
+
print(f"[WARN] {len(missing)} columns in parquet but absent from feature_names")
|
| 65 |
print(f" first 5: {missing[:5]}")
|
| 66 |
|
| 67 |
|
scripts/export_model.py
CHANGED
|
@@ -52,6 +52,9 @@ print(f" Version : {mv.version}")
|
|
| 52 |
print(f" Run ID : {mv.run_id}")
|
| 53 |
print(f" Source : {mv.source}")
|
| 54 |
|
|
|
|
|
|
|
|
|
|
| 55 |
run = client.get_run(mv.run_id)
|
| 56 |
print("\n Run metrics:")
|
| 57 |
for k, v in run.data.metrics.items():
|
|
@@ -72,7 +75,7 @@ model = mlflow.pyfunc.load_model(str(P6_MODEL_PATH))
|
|
| 72 |
print("\n Model signature:")
|
| 73 |
sig = model.metadata.signature
|
| 74 |
if sig is None:
|
| 75 |
-
print("
|
| 76 |
n_features = None
|
| 77 |
feature_names = None
|
| 78 |
else:
|
|
@@ -87,7 +90,7 @@ else:
|
|
| 87 |
# ============================================================
|
| 88 |
joblib.dump(model, OUTPUT_MODEL)
|
| 89 |
size_mb = OUTPUT_MODEL.stat().st_size / 1e6
|
| 90 |
-
print(f"\n
|
| 91 |
|
| 92 |
# ============================================================
|
| 93 |
# 4. Save provenance metadata
|
|
@@ -106,6 +109,6 @@ info = {
|
|
| 106 |
}
|
| 107 |
with open(OUTPUT_INFO, "w") as f:
|
| 108 |
json.dump(info, f, indent=2, default=str)
|
| 109 |
-
print(f"
|
| 110 |
|
| 111 |
print("=" * 60)
|
|
|
|
| 52 |
print(f" Run ID : {mv.run_id}")
|
| 53 |
print(f" Source : {mv.source}")
|
| 54 |
|
| 55 |
+
if mv.run_id is None:
|
| 56 |
+
raise RuntimeError(f"Model version {MODEL_NAME}/{MODEL_VERSION} has no associated run_id")
|
| 57 |
+
|
| 58 |
run = client.get_run(mv.run_id)
|
| 59 |
print("\n Run metrics:")
|
| 60 |
for k, v in run.data.metrics.items():
|
|
|
|
| 75 |
print("\n Model signature:")
|
| 76 |
sig = model.metadata.signature
|
| 77 |
if sig is None:
|
| 78 |
+
print(" [WARN] No signature logged - inputs will not be validated at inference!")
|
| 79 |
n_features = None
|
| 80 |
feature_names = None
|
| 81 |
else:
|
|
|
|
| 90 |
# ============================================================
|
| 91 |
joblib.dump(model, OUTPUT_MODEL)
|
| 92 |
size_mb = OUTPUT_MODEL.stat().st_size / 1e6
|
| 93 |
+
print(f"\n[OK] Model saved to: {OUTPUT_MODEL} ({size_mb:.2f} MB)")
|
| 94 |
|
| 95 |
# ============================================================
|
| 96 |
# 4. Save provenance metadata
|
|
|
|
| 109 |
}
|
| 110 |
with open(OUTPUT_INFO, "w") as f:
|
| 111 |
json.dump(info, f, indent=2, default=str)
|
| 112 |
+
print(f"[OK] Metadata saved to: {OUTPUT_INFO}")
|
| 113 |
|
| 114 |
print("=" * 60)
|