KLEB38 commited on
Commit
2bf86cd
·
verified ·
1 Parent(s): 3b38b91

Upload folder using huggingface_hub

Browse files
.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 XGBoost 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,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` — `true` (loan refused) if `proba ≥ 0.33`, `false` (loan 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,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
- [![XGBoost][xgboost-badge]][xgboost-url]
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": false,
203
  "threshold": 0.33,
204
- "model_version": "xgb-v1.0",
205
  "client_known": true
206
  }
207
  ```
208
 
209
- `decision: false` = loan **granted** · `decision: true` = loan **refused**
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 XGBoost's training-time NaN signal ("no historical data") rather than imputing fictitious medians.
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
- [xgboost-badge]: https://img.shields.io/badge/XGBoost-2.x-F7931E?style=for-the-badge
412
- [xgboost-url]: https://xgboost.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
 
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]].copy()
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
- @app.get("/") # La page d'accueil
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
- Decision = Literal["GRANTED", "REFUSED"]
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, Optional
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: Optional[float] = Field(default=None, gt=0)
182
- AMT_GOODS_PRICE: Optional[float] = Field(default=None, gt=0)
183
- NAME_TYPE_SUITE: Optional[TypeSuite] = None
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: Optional[float] = 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: Optional[OccupationType] = 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,64 +216,64 @@ class PredictionRequest(BaseModel):
216
  ORGANIZATION_TYPE: OrganizationType
217
 
218
  # External scoring sources ----------------------------------------------
219
- EXT_SOURCE_1: Optional[float] = Field(default=None, ge=0, le=1)
220
- EXT_SOURCE_2: Optional[float] = Field(default=None, ge=0, le=1)
221
- EXT_SOURCE_3: Optional[float] = Field(default=None, ge=0, le=1)
222
 
223
  # Building characteristics (mostly nullable, ratios in [0, 1]) ----------
224
- APARTMENTS_AVG: Optional[float] = Field(default=None, ge=0, le=1)
225
- BASEMENTAREA_AVG: Optional[float] = Field(default=None, ge=0, le=1)
226
- YEARS_BEGINEXPLUATATION_AVG: Optional[float] = Field(default=None, ge=0, le=1)
227
- YEARS_BUILD_AVG: Optional[float] = Field(default=None, ge=0, le=1)
228
- COMMONAREA_AVG: Optional[float] = Field(default=None, ge=0, le=1)
229
- ELEVATORS_AVG: Optional[float] = Field(default=None, ge=0, le=1)
230
- ENTRANCES_AVG: Optional[float] = Field(default=None, ge=0, le=1)
231
- FLOORSMAX_AVG: Optional[float] = Field(default=None, ge=0, le=1)
232
- FLOORSMIN_AVG: Optional[float] = Field(default=None, ge=0, le=1)
233
- LANDAREA_AVG: Optional[float] = Field(default=None, ge=0, le=1)
234
- LIVINGAPARTMENTS_AVG: Optional[float] = Field(default=None, ge=0, le=1)
235
- LIVINGAREA_AVG: Optional[float] = Field(default=None, ge=0, le=1)
236
- NONLIVINGAPARTMENTS_AVG: Optional[float] = Field(default=None, ge=0, le=1)
237
- NONLIVINGAREA_AVG: Optional[float] = Field(default=None, ge=0, le=1)
238
- APARTMENTS_MODE: Optional[float] = Field(default=None, ge=0, le=1)
239
- BASEMENTAREA_MODE: Optional[float] = Field(default=None, ge=0, le=1)
240
- YEARS_BEGINEXPLUATATION_MODE: Optional[float] = Field(default=None, ge=0, le=1)
241
- YEARS_BUILD_MODE: Optional[float] = Field(default=None, ge=0, le=1)
242
- COMMONAREA_MODE: Optional[float] = Field(default=None, ge=0, le=1)
243
- ELEVATORS_MODE: Optional[float] = Field(default=None, ge=0, le=1)
244
- ENTRANCES_MODE: Optional[float] = Field(default=None, ge=0, le=1)
245
- FLOORSMAX_MODE: Optional[float] = Field(default=None, ge=0, le=1)
246
- FLOORSMIN_MODE: Optional[float] = Field(default=None, ge=0, le=1)
247
- LANDAREA_MODE: Optional[float] = Field(default=None, ge=0, le=1)
248
- LIVINGAPARTMENTS_MODE: Optional[float] = Field(default=None, ge=0, le=1)
249
- LIVINGAREA_MODE: Optional[float] = Field(default=None, ge=0, le=1)
250
- NONLIVINGAPARTMENTS_MODE: Optional[float] = Field(default=None, ge=0, le=1)
251
- NONLIVINGAREA_MODE: Optional[float] = Field(default=None, ge=0, le=1)
252
- APARTMENTS_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
253
- BASEMENTAREA_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
254
- YEARS_BEGINEXPLUATATION_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
255
- YEARS_BUILD_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
256
- COMMONAREA_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
257
- ELEVATORS_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
258
- ENTRANCES_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
259
- FLOORSMAX_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
260
- FLOORSMIN_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
261
- LANDAREA_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
262
- LIVINGAPARTMENTS_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
263
- LIVINGAREA_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
264
- NONLIVINGAPARTMENTS_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
265
- NONLIVINGAREA_MEDI: Optional[float] = Field(default=None, ge=0, le=1)
266
- FONDKAPREMONT_MODE: Optional[FondKapremontMode] = None
267
- HOUSETYPE_MODE: Optional[HouseTypeMode] = None
268
- TOTALAREA_MODE: Optional[float] = Field(default=None, ge=0, le=1)
269
- WALLSMATERIAL_MODE: Optional[WallsMaterialMode] = None
270
- EMERGENCYSTATE_MODE: Optional[EmergencyStateMode] = None
271
 
272
  # Social circle ---------------------------------------------------------
273
- OBS_30_CNT_SOCIAL_CIRCLE: Optional[float] = Field(default=None, ge=0, le=500)
274
- DEF_30_CNT_SOCIAL_CIRCLE: Optional[float] = Field(default=None, ge=0, le=500)
275
- OBS_60_CNT_SOCIAL_CIRCLE: Optional[float] = Field(default=None, ge=0, le=500)
276
- DEF_60_CNT_SOCIAL_CIRCLE: Optional[float] = Field(default=None, ge=0, le=500)
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: Optional[float] = Field(default=None, ge=0, le=500)
304
- AMT_REQ_CREDIT_BUREAU_DAY: Optional[float] = Field(default=None, ge=0, le=500)
305
- AMT_REQ_CREDIT_BUREAU_WEEK: Optional[float] = Field(default=None, ge=0, le=500)
306
- AMT_REQ_CREDIT_BUREAU_MON: Optional[float] = Field(default=None, ge=0, le=500)
307
- AMT_REQ_CREDIT_BUREAU_QRT: Optional[float] = Field(default=None, ge=0, le=500)
308
- AMT_REQ_CREDIT_BUREAU_YEAR: Optional[float] = Field(default=None, ge=0, le=500)
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"].apply(lambda x: x if x > 0 else 0)
202
- ins["DBD"] = ins["DBD"].apply(lambda x: x if x > 0 else 0)
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
- print("Bureau df shape:", bureau.shape)
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
- print("Previous applications df shape:", prev.shape)
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
- print("Pos-cash balance df shape:", pos.shape)
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
- print("Installments payments df shape:", ins.shape)
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
- print("Credit card balance df shape:", cc.shape)
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 = [c for c in store.columns]
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 {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"⚠️ {len(missing)} columns in parquet but absent from feature_names")
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(" ⚠️ No signature logged - inputs will not be validated at inference!")
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 Model saved to: {OUTPUT_MODEL} ({size_mb:.2f} MB)")
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" Metadata saved to: {OUTPUT_INFO}")
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)