KLEB38 commited on
Commit
c1e60e8
·
verified ·
1 Parent(s): 229e4be

Upload folder using huggingface_hub

Browse files
.claude/settings.local.json CHANGED
@@ -11,7 +11,9 @@
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
  }
 
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
+ "Bash(ls \"C:/Users/Kevin/projects/OC_P6/notebooks/\" 2>&1)",
16
+ "Read(//c/Users/Kevin/projects/OC_P6/notebooks/**)"
17
  ]
18
  }
19
  }
Example_JSON/examples.md CHANGED
@@ -1,6 +1,10 @@
1
  # Exemples réalistes pour POST /predict
2
 
3
- Trois profils-types couvrant le spectre de risque attendu par le modèle.
 
 
 
 
4
  Toutes les valeurs respectent les bornes définies dans `api/schemas.py`.
5
 
6
  ---
@@ -13,7 +17,7 @@ ratio crédit/revenu modéré.
13
 
14
  ```json
15
  {
16
- "SK_ID_CURR": 100001,
17
  "NAME_CONTRACT_TYPE": "Cash loans",
18
  "CODE_GENDER": "F",
19
  "FLAG_OWN_CAR": "Y",
@@ -53,9 +57,9 @@ ratio crédit/revenu modéré.
53
  "REG_CITY_NOT_WORK_CITY": 0,
54
  "LIVE_CITY_NOT_WORK_CITY": 0,
55
  "ORGANIZATION_TYPE": "Bank",
56
- "EXT_SOURCE_1": 0.72,
57
- "EXT_SOURCE_2": 0.78,
58
- "EXT_SOURCE_3": 0.69,
59
  "APARTMENTS_AVG": 0.1242,
60
  "BASEMENTAREA_AVG": 0.0876,
61
  "YEARS_BEGINEXPLUATATION_AVG": 0.9821,
@@ -146,7 +150,7 @@ ancienneté 4 ans, locataire, EXT_SOURCE autour de 0.4-0.5, ratio crédit/revenu
146
 
147
  ```json
148
  {
149
- "SK_ID_CURR": 100002,
150
  "NAME_CONTRACT_TYPE": "Cash loans",
151
  "CODE_GENDER": "M",
152
  "FLAG_OWN_CAR": "N",
@@ -187,8 +191,8 @@ ancienneté 4 ans, locataire, EXT_SOURCE autour de 0.4-0.5, ratio crédit/revenu
187
  "LIVE_CITY_NOT_WORK_CITY": 1,
188
  "ORGANIZATION_TYPE": "Industry: type 3",
189
  "EXT_SOURCE_1": 0.42,
190
- "EXT_SOURCE_2": 0.51,
191
- "EXT_SOURCE_3": 0.38,
192
  "APARTMENTS_AVG": null,
193
  "BASEMENTAREA_AVG": null,
194
  "YEARS_BEGINEXPLUATATION_AVG": null,
@@ -280,7 +284,7 @@ plusieurs requêtes au crédit bureau récentes, défauts sociaux observés.
280
 
281
  ```json
282
  {
283
- "SK_ID_CURR": 100003,
284
  "NAME_CONTRACT_TYPE": "Cash loans",
285
  "CODE_GENDER": "M",
286
  "FLAG_OWN_CAR": "N",
@@ -320,9 +324,279 @@ plusieurs requêtes au crédit bureau récentes, défauts sociaux observés.
320
  "REG_CITY_NOT_WORK_CITY": 1,
321
  "LIVE_CITY_NOT_WORK_CITY": 1,
322
  "ORGANIZATION_TYPE": "Construction",
323
- "EXT_SOURCE_1": 0.12,
324
- "EXT_SOURCE_2": 0.18,
325
- "EXT_SOURCE_3": 0.09,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  "APARTMENTS_AVG": null,
327
  "BASEMENTAREA_AVG": null,
328
  "YEARS_BEGINEXPLUATATION_AVG": null,
 
1
  # Exemples réalistes pour POST /predict
2
 
3
+ Cinq profils-types couvrant le spectre de risque attendu par le modèle :
4
+ trois clients **connus** (SK_ID_CURR présent dans `features_store.parquet`,
5
+ donc historique bureau/prev/POS/CC/install agrégé) et deux clients
6
+ **inconnus** (SK_ID_CURR absent, le `no_history_template` neutralise tout
7
+ l'historique et le score ne repose plus que sur les inputs `app_train`).
8
  Toutes les valeurs respectent les bornes définies dans `api/schemas.py`.
9
 
10
  ---
 
17
 
18
  ```json
19
  {
20
+ "SK_ID_CURR": 282751,
21
  "NAME_CONTRACT_TYPE": "Cash loans",
22
  "CODE_GENDER": "F",
23
  "FLAG_OWN_CAR": "Y",
 
57
  "REG_CITY_NOT_WORK_CITY": 0,
58
  "LIVE_CITY_NOT_WORK_CITY": 0,
59
  "ORGANIZATION_TYPE": "Bank",
60
+ "EXT_SOURCE_1": 0.80,
61
+ "EXT_SOURCE_2": 0.85,
62
+ "EXT_SOURCE_3": 0.78,
63
  "APARTMENTS_AVG": 0.1242,
64
  "BASEMENTAREA_AVG": 0.0876,
65
  "YEARS_BEGINEXPLUATATION_AVG": 0.9821,
 
150
 
151
  ```json
152
  {
153
+ "SK_ID_CURR": 207397,
154
  "NAME_CONTRACT_TYPE": "Cash loans",
155
  "CODE_GENDER": "M",
156
  "FLAG_OWN_CAR": "N",
 
191
  "LIVE_CITY_NOT_WORK_CITY": 1,
192
  "ORGANIZATION_TYPE": "Industry: type 3",
193
  "EXT_SOURCE_1": 0.42,
194
+ "EXT_SOURCE_2": 0.48,
195
+ "EXT_SOURCE_3": 0.39,
196
  "APARTMENTS_AVG": null,
197
  "BASEMENTAREA_AVG": null,
198
  "YEARS_BEGINEXPLUATATION_AVG": null,
 
284
 
285
  ```json
286
  {
287
+ "SK_ID_CURR": 244757,
288
  "NAME_CONTRACT_TYPE": "Cash loans",
289
  "CODE_GENDER": "M",
290
  "FLAG_OWN_CAR": "N",
 
324
  "REG_CITY_NOT_WORK_CITY": 1,
325
  "LIVE_CITY_NOT_WORK_CITY": 1,
326
  "ORGANIZATION_TYPE": "Construction",
327
+ "EXT_SOURCE_1": 0.08,
328
+ "EXT_SOURCE_2": 0.10,
329
+ "EXT_SOURCE_3": 0.06,
330
+ "APARTMENTS_AVG": null,
331
+ "BASEMENTAREA_AVG": null,
332
+ "YEARS_BEGINEXPLUATATION_AVG": null,
333
+ "YEARS_BUILD_AVG": null,
334
+ "COMMONAREA_AVG": null,
335
+ "ELEVATORS_AVG": null,
336
+ "ENTRANCES_AVG": null,
337
+ "FLOORSMAX_AVG": null,
338
+ "FLOORSMIN_AVG": null,
339
+ "LANDAREA_AVG": null,
340
+ "LIVINGAPARTMENTS_AVG": null,
341
+ "LIVINGAREA_AVG": null,
342
+ "NONLIVINGAPARTMENTS_AVG": null,
343
+ "NONLIVINGAREA_AVG": null,
344
+ "APARTMENTS_MODE": null,
345
+ "BASEMENTAREA_MODE": null,
346
+ "YEARS_BEGINEXPLUATATION_MODE": null,
347
+ "YEARS_BUILD_MODE": null,
348
+ "COMMONAREA_MODE": null,
349
+ "ELEVATORS_MODE": null,
350
+ "ENTRANCES_MODE": null,
351
+ "FLOORSMAX_MODE": null,
352
+ "FLOORSMIN_MODE": null,
353
+ "LANDAREA_MODE": null,
354
+ "LIVINGAPARTMENTS_MODE": null,
355
+ "LIVINGAREA_MODE": null,
356
+ "NONLIVINGAPARTMENTS_MODE": null,
357
+ "NONLIVINGAREA_MODE": null,
358
+ "APARTMENTS_MEDI": null,
359
+ "BASEMENTAREA_MEDI": null,
360
+ "YEARS_BEGINEXPLUATATION_MEDI": null,
361
+ "YEARS_BUILD_MEDI": null,
362
+ "COMMONAREA_MEDI": null,
363
+ "ELEVATORS_MEDI": null,
364
+ "ENTRANCES_MEDI": null,
365
+ "FLOORSMAX_MEDI": null,
366
+ "FLOORSMIN_MEDI": null,
367
+ "LANDAREA_MEDI": null,
368
+ "LIVINGAPARTMENTS_MEDI": null,
369
+ "LIVINGAREA_MEDI": null,
370
+ "NONLIVINGAPARTMENTS_MEDI": null,
371
+ "NONLIVINGAREA_MEDI": null,
372
+ "FONDKAPREMONT_MODE": null,
373
+ "HOUSETYPE_MODE": null,
374
+ "TOTALAREA_MODE": null,
375
+ "WALLSMATERIAL_MODE": null,
376
+ "EMERGENCYSTATE_MODE": null,
377
+ "OBS_30_CNT_SOCIAL_CIRCLE": 7,
378
+ "DEF_30_CNT_SOCIAL_CIRCLE": 3,
379
+ "OBS_60_CNT_SOCIAL_CIRCLE": 8,
380
+ "DEF_60_CNT_SOCIAL_CIRCLE": 4,
381
+ "DAYS_LAST_PHONE_CHANGE": -45,
382
+ "FLAG_DOCUMENT_2": 0,
383
+ "FLAG_DOCUMENT_3": 1,
384
+ "FLAG_DOCUMENT_4": 0,
385
+ "FLAG_DOCUMENT_5": 0,
386
+ "FLAG_DOCUMENT_6": 0,
387
+ "FLAG_DOCUMENT_7": 0,
388
+ "FLAG_DOCUMENT_8": 0,
389
+ "FLAG_DOCUMENT_9": 0,
390
+ "FLAG_DOCUMENT_10": 0,
391
+ "FLAG_DOCUMENT_11": 0,
392
+ "FLAG_DOCUMENT_12": 0,
393
+ "FLAG_DOCUMENT_13": 0,
394
+ "FLAG_DOCUMENT_14": 0,
395
+ "FLAG_DOCUMENT_15": 0,
396
+ "FLAG_DOCUMENT_16": 0,
397
+ "FLAG_DOCUMENT_17": 0,
398
+ "FLAG_DOCUMENT_18": 0,
399
+ "FLAG_DOCUMENT_19": 0,
400
+ "FLAG_DOCUMENT_20": 0,
401
+ "FLAG_DOCUMENT_21": 0,
402
+ "AMT_REQ_CREDIT_BUREAU_HOUR": 0,
403
+ "AMT_REQ_CREDIT_BUREAU_DAY": 1,
404
+ "AMT_REQ_CREDIT_BUREAU_WEEK": 2,
405
+ "AMT_REQ_CREDIT_BUREAU_MON": 4,
406
+ "AMT_REQ_CREDIT_BUREAU_QRT": 6,
407
+ "AMT_REQ_CREDIT_BUREAU_YEAR": 14
408
+ }
409
+ ```
410
+
411
+ ---
412
+
413
+ ## 4) Client **inconnu** — bon profil
414
+
415
+ `SK_ID_CURR=999001` n'existe pas dans `features_store.parquet` → l'API
416
+ remplit toutes les agrégations bureau / prev / POS / CC / install avec le
417
+ `no_history_template` (counts=0, autres NaN). Le score repose alors
418
+ uniquement sur les inputs `app_train`. Mêmes valeurs que le profil 1
419
+ (cadre stable, EXT_SOURCE élevés) mais SK_ID hors du référentiel.
420
+
421
+ ```json
422
+ {
423
+ "SK_ID_CURR": 999001,
424
+ "NAME_CONTRACT_TYPE": "Cash loans",
425
+ "CODE_GENDER": "F",
426
+ "FLAG_OWN_CAR": "Y",
427
+ "FLAG_OWN_REALTY": "Y",
428
+ "CNT_CHILDREN": 1,
429
+ "AMT_INCOME_TOTAL": 270000,
430
+ "AMT_CREDIT": 600000,
431
+ "AMT_ANNUITY": 28500,
432
+ "AMT_GOODS_PRICE": 540000,
433
+ "NAME_TYPE_SUITE": "Unaccompanied",
434
+ "NAME_INCOME_TYPE": "Commercial associate",
435
+ "NAME_EDUCATION_TYPE": "Higher education",
436
+ "NAME_FAMILY_STATUS": "Married",
437
+ "NAME_HOUSING_TYPE": "House / apartment",
438
+ "REGION_POPULATION_RELATIVE": 0.035792,
439
+ "DAYS_BIRTH": -15400,
440
+ "DAYS_EMPLOYED": -3650,
441
+ "DAYS_REGISTRATION": -4200.0,
442
+ "DAYS_ID_PUBLISH": -2800,
443
+ "OWN_CAR_AGE": 4,
444
+ "FLAG_MOBIL": 1,
445
+ "FLAG_EMP_PHONE": 1,
446
+ "FLAG_WORK_PHONE": 0,
447
+ "FLAG_CONT_MOBILE": 1,
448
+ "FLAG_PHONE": 1,
449
+ "FLAG_EMAIL": 1,
450
+ "OCCUPATION_TYPE": "Managers",
451
+ "CNT_FAM_MEMBERS": 3,
452
+ "REGION_RATING_CLIENT": 2,
453
+ "REGION_RATING_CLIENT_W_CITY": 2,
454
+ "WEEKDAY_APPR_PROCESS_START": "WEDNESDAY",
455
+ "HOUR_APPR_PROCESS_START": 11,
456
+ "REG_REGION_NOT_LIVE_REGION": 0,
457
+ "REG_REGION_NOT_WORK_REGION": 0,
458
+ "LIVE_REGION_NOT_WORK_REGION": 0,
459
+ "REG_CITY_NOT_LIVE_CITY": 0,
460
+ "REG_CITY_NOT_WORK_CITY": 0,
461
+ "LIVE_CITY_NOT_WORK_CITY": 0,
462
+ "ORGANIZATION_TYPE": "Bank",
463
+ "EXT_SOURCE_1": 0.80,
464
+ "EXT_SOURCE_2": 0.85,
465
+ "EXT_SOURCE_3": 0.78,
466
+ "APARTMENTS_AVG": 0.1242,
467
+ "BASEMENTAREA_AVG": 0.0876,
468
+ "YEARS_BEGINEXPLUATATION_AVG": 0.9821,
469
+ "YEARS_BUILD_AVG": 0.7553,
470
+ "COMMONAREA_AVG": 0.0432,
471
+ "ELEVATORS_AVG": 0.08,
472
+ "ENTRANCES_AVG": 0.1379,
473
+ "FLOORSMAX_AVG": 0.1667,
474
+ "FLOORSMIN_AVG": 0.2083,
475
+ "LANDAREA_AVG": 0.0481,
476
+ "LIVINGAPARTMENTS_AVG": 0.0973,
477
+ "LIVINGAREA_AVG": 0.1099,
478
+ "NONLIVINGAPARTMENTS_AVG": 0.0039,
479
+ "NONLIVINGAREA_AVG": 0.0144,
480
+ "APARTMENTS_MODE": 0.1257,
481
+ "BASEMENTAREA_MODE": 0.0901,
482
+ "YEARS_BEGINEXPLUATATION_MODE": 0.9826,
483
+ "YEARS_BUILD_MODE": 0.7649,
484
+ "COMMONAREA_MODE": 0.0413,
485
+ "ELEVATORS_MODE": 0.0775,
486
+ "ENTRANCES_MODE": 0.1379,
487
+ "FLOORSMAX_MODE": 0.1667,
488
+ "FLOORSMIN_MODE": 0.2083,
489
+ "LANDAREA_MODE": 0.0492,
490
+ "LIVINGAPARTMENTS_MODE": 0.0991,
491
+ "LIVINGAREA_MODE": 0.1129,
492
+ "NONLIVINGAPARTMENTS_MODE": 0.0036,
493
+ "NONLIVINGAREA_MODE": 0.0151,
494
+ "APARTMENTS_MEDI": 0.1252,
495
+ "BASEMENTAREA_MEDI": 0.0884,
496
+ "YEARS_BEGINEXPLUATATION_MEDI": 0.9822,
497
+ "YEARS_BUILD_MEDI": 0.7585,
498
+ "COMMONAREA_MEDI": 0.0432,
499
+ "ELEVATORS_MEDI": 0.08,
500
+ "ENTRANCES_MEDI": 0.1379,
501
+ "FLOORSMAX_MEDI": 0.1667,
502
+ "FLOORSMIN_MEDI": 0.2083,
503
+ "LANDAREA_MEDI": 0.0482,
504
+ "LIVINGAPARTMENTS_MEDI": 0.0978,
505
+ "LIVINGAREA_MEDI": 0.1109,
506
+ "NONLIVINGAPARTMENTS_MEDI": 0.0039,
507
+ "NONLIVINGAREA_MEDI": 0.0148,
508
+ "FONDKAPREMONT_MODE": "reg oper account",
509
+ "HOUSETYPE_MODE": "block of flats",
510
+ "TOTALAREA_MODE": 0.1023,
511
+ "WALLSMATERIAL_MODE": "Panel",
512
+ "EMERGENCYSTATE_MODE": "No",
513
+ "OBS_30_CNT_SOCIAL_CIRCLE": 1,
514
+ "DEF_30_CNT_SOCIAL_CIRCLE": 0,
515
+ "OBS_60_CNT_SOCIAL_CIRCLE": 1,
516
+ "DEF_60_CNT_SOCIAL_CIRCLE": 0,
517
+ "DAYS_LAST_PHONE_CHANGE": -1850,
518
+ "FLAG_DOCUMENT_2": 0,
519
+ "FLAG_DOCUMENT_3": 1,
520
+ "FLAG_DOCUMENT_4": 0,
521
+ "FLAG_DOCUMENT_5": 0,
522
+ "FLAG_DOCUMENT_6": 0,
523
+ "FLAG_DOCUMENT_7": 0,
524
+ "FLAG_DOCUMENT_8": 0,
525
+ "FLAG_DOCUMENT_9": 0,
526
+ "FLAG_DOCUMENT_10": 0,
527
+ "FLAG_DOCUMENT_11": 0,
528
+ "FLAG_DOCUMENT_12": 0,
529
+ "FLAG_DOCUMENT_13": 0,
530
+ "FLAG_DOCUMENT_14": 0,
531
+ "FLAG_DOCUMENT_15": 0,
532
+ "FLAG_DOCUMENT_16": 0,
533
+ "FLAG_DOCUMENT_17": 0,
534
+ "FLAG_DOCUMENT_18": 0,
535
+ "FLAG_DOCUMENT_19": 0,
536
+ "FLAG_DOCUMENT_20": 0,
537
+ "FLAG_DOCUMENT_21": 0,
538
+ "AMT_REQ_CREDIT_BUREAU_HOUR": 0,
539
+ "AMT_REQ_CREDIT_BUREAU_DAY": 0,
540
+ "AMT_REQ_CREDIT_BUREAU_WEEK": 0,
541
+ "AMT_REQ_CREDIT_BUREAU_MON": 0,
542
+ "AMT_REQ_CREDIT_BUREAU_QRT": 0,
543
+ "AMT_REQ_CREDIT_BUREAU_YEAR": 2
544
+ }
545
+ ```
546
+
547
+ ---
548
+
549
+ ## 5) Client **inconnu** — mauvais profil
550
+
551
+ `SK_ID_CURR=999002` absent du référentiel → `no_history_template` appliqué.
552
+ Mêmes signaux app_train que le profil 3 (jeune, faibles revenus, EXT_SOURCE
553
+ très bas, sollicitations bureau nombreuses, défauts sociaux).
554
+
555
+ ```json
556
+ {
557
+ "SK_ID_CURR": 999002,
558
+ "NAME_CONTRACT_TYPE": "Cash loans",
559
+ "CODE_GENDER": "M",
560
+ "FLAG_OWN_CAR": "N",
561
+ "FLAG_OWN_REALTY": "N",
562
+ "CNT_CHILDREN": 2,
563
+ "AMT_INCOME_TOTAL": 81000,
564
+ "AMT_CREDIT": 720000,
565
+ "AMT_ANNUITY": 42300,
566
+ "AMT_GOODS_PRICE": 675000,
567
+ "NAME_TYPE_SUITE": "Family",
568
+ "NAME_INCOME_TYPE": "Working",
569
+ "NAME_EDUCATION_TYPE": "Secondary / secondary special",
570
+ "NAME_FAMILY_STATUS": "Civil marriage",
571
+ "NAME_HOUSING_TYPE": "With parents",
572
+ "REGION_POPULATION_RELATIVE": 0.008575,
573
+ "DAYS_BIRTH": -9125,
574
+ "DAYS_EMPLOYED": -240,
575
+ "DAYS_REGISTRATION": -800.0,
576
+ "DAYS_ID_PUBLISH": -450,
577
+ "OWN_CAR_AGE": null,
578
+ "FLAG_MOBIL": 1,
579
+ "FLAG_EMP_PHONE": 1,
580
+ "FLAG_WORK_PHONE": 0,
581
+ "FLAG_CONT_MOBILE": 1,
582
+ "FLAG_PHONE": 0,
583
+ "FLAG_EMAIL": 0,
584
+ "OCCUPATION_TYPE": "Low-skill Laborers",
585
+ "CNT_FAM_MEMBERS": 4,
586
+ "REGION_RATING_CLIENT": 3,
587
+ "REGION_RATING_CLIENT_W_CITY": 3,
588
+ "WEEKDAY_APPR_PROCESS_START": "SATURDAY",
589
+ "HOUR_APPR_PROCESS_START": 19,
590
+ "REG_REGION_NOT_LIVE_REGION": 1,
591
+ "REG_REGION_NOT_WORK_REGION": 1,
592
+ "LIVE_REGION_NOT_WORK_REGION": 0,
593
+ "REG_CITY_NOT_LIVE_CITY": 1,
594
+ "REG_CITY_NOT_WORK_CITY": 1,
595
+ "LIVE_CITY_NOT_WORK_CITY": 1,
596
+ "ORGANIZATION_TYPE": "Construction",
597
+ "EXT_SOURCE_1": 0.08,
598
+ "EXT_SOURCE_2": 0.10,
599
+ "EXT_SOURCE_3": 0.06,
600
  "APARTMENTS_AVG": null,
601
  "BASEMENTAREA_AVG": null,
602
  "YEARS_BEGINEXPLUATATION_AVG": null,
api/predictor.py CHANGED
@@ -12,7 +12,6 @@ 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
@@ -22,7 +21,9 @@ class CreditScoringPredictor:
22
  """Singleton-style wrapper. Build once via load(), reuse for every request."""
23
 
24
  def __init__(self, model, threshold: float, model_version: str) -> None:
25
- self._model = model
 
 
26
  self._threshold = threshold
27
  self._model_version = model_version
28
 
@@ -33,14 +34,13 @@ class CreditScoringPredictor:
33
  model_info_path: Path,
34
  default_threshold: float,
35
  ) -> "CreditScoringPredictor":
36
- model = joblib.load(model_path)
37
-
38
  info = json.loads(model_info_path.read_text())
39
- metrics = info.get("metrics", {})
40
- threshold = float(metrics.get("best_threshold_mean", default_threshold))
41
- version = str(info.get("version", "unknown"))
42
-
43
- return cls(model=model, threshold=threshold, model_version=version)
 
44
 
45
  @property
46
  def threshold(self) -> float:
@@ -52,20 +52,6 @@ class CreditScoringPredictor:
52
 
53
  def predict(self, features: pd.DataFrame) -> tuple[float, Decision]:
54
  """Return (probability_of_default, decision)."""
55
- proba = self._predict_proba(features)
56
  decision: Decision = "REFUSED" if proba >= self._threshold else "GRANTED"
57
  return proba, decision
58
-
59
- def _predict_proba(self, features: pd.DataFrame) -> float:
60
- """Extract the positive-class probability from the underlying model."""
61
- raw = self._model.predict(features)
62
-
63
- # MLflow PyFunc models return arrays; sklearn classifiers return 2D probs.
64
- arr = np.asarray(raw)
65
- if arr.ndim == 2 and arr.shape[1] == 2:
66
- return float(arr[0, 1])
67
- if arr.ndim == 1:
68
- return float(arr[0])
69
- raise ValueError(
70
- f"Unexpected prediction shape {arr.shape}; expected (n,) or (n, 2)."
71
- )
 
12
  from pathlib import Path
13
 
14
  import joblib
 
15
  import pandas as pd
16
 
17
  from api.schemas import Decision
 
21
  """Singleton-style wrapper. Build once via load(), reuse for every request."""
22
 
23
  def __init__(self, model, threshold: float, model_version: str) -> None:
24
+ # MLflow PyFunc wraps the sklearn model; unwrap so we can call
25
+ # predict_proba (PyFunc.predict() returns class labels, not probas).
26
+ self._model = model.get_raw_model() if hasattr(model, "get_raw_model") else model
27
  self._threshold = threshold
28
  self._model_version = model_version
29
 
 
34
  model_info_path: Path,
35
  default_threshold: float,
36
  ) -> "CreditScoringPredictor":
 
 
37
  info = json.loads(model_info_path.read_text())
38
+ threshold = float(info.get("metrics", {}).get("best_threshold_mean", default_threshold))
39
+ return cls(
40
+ model=joblib.load(model_path),
41
+ threshold=threshold,
42
+ model_version=str(info.get("version", "unknown")),
43
+ )
44
 
45
  @property
46
  def threshold(self) -> float:
 
52
 
53
  def predict(self, features: pd.DataFrame) -> tuple[float, Decision]:
54
  """Return (probability_of_default, decision)."""
55
+ proba = float(self._model.predict_proba(features)[0, 1])
56
  decision: Decision = "REFUSED" if proba >= self._threshold else "GRANTED"
57
  return proba, decision
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/conftest.py CHANGED
@@ -172,13 +172,13 @@ VALID_PAYLOAD: dict[str, Any] = {
172
 
173
 
174
  class FakeModel:
175
- """Minimal stand-in for the real LightGBM PyFunc.
176
 
177
  Returns a probability driven by AMT_INCOME_TOTAL / AMT_CREDIT so tests can
178
  deterministically exercise both GRANTED and REFUSED branches.
179
  """
180
 
181
- def predict(self, df: pd.DataFrame) -> np.ndarray:
182
  income = float(df.iloc[0].get("AMT_INCOME_TOTAL", 0) or 0)
183
  credit = float(df.iloc[0].get("AMT_CREDIT", 1) or 1)
184
  ratio = income / max(credit, 1)
 
172
 
173
 
174
  class FakeModel:
175
+ """Minimal stand-in for the unwrapped LightGBM sklearn classifier.
176
 
177
  Returns a probability driven by AMT_INCOME_TOTAL / AMT_CREDIT so tests can
178
  deterministically exercise both GRANTED and REFUSED branches.
179
  """
180
 
181
+ def predict_proba(self, df: pd.DataFrame) -> np.ndarray:
182
  income = float(df.iloc[0].get("AMT_INCOME_TOTAL", 0) or 0)
183
  credit = float(df.iloc[0].get("AMT_CREDIT", 1) or 1)
184
  ratio = income / max(credit, 1)
tests/unit/test_predictor.py CHANGED
@@ -13,104 +13,84 @@ import pytest
13
  from api.predictor import CreditScoringPredictor
14
 
15
 
16
- class _FixedProbModel:
 
 
17
  def __init__(self, proba: float) -> None:
18
  self.proba = proba
19
 
20
- def predict(self, df: pd.DataFrame) -> np.ndarray:
21
  return np.array([[1 - self.proba, self.proba]])
22
 
23
 
24
- class _WeirdModel:
25
- def predict(self, df: pd.DataFrame) -> np.ndarray:
26
- return np.array([[[0.1, 0.9]]]) # 3D — invalid
27
-
28
-
29
- class _FlatModel:
30
- def predict(self, df: pd.DataFrame) -> np.ndarray:
31
- return np.array([0.6])
32
-
33
-
34
- def _write_model_info(path: Path, threshold: float, version: str = "test-1") -> None:
35
- path.write_text(
36
- json.dumps(
37
- {
38
- "model_name": "fake",
39
- "version": version,
40
- "metrics": {"best_threshold_mean": threshold},
41
- }
42
- )
43
- )
44
 
45
 
46
  def _build(tmp_path: Path, proba: float, threshold: float) -> CreditScoringPredictor:
47
  model_path = tmp_path / "model.joblib"
48
- info_path = tmp_path / "info.json"
49
- joblib.dump(_FixedProbModel(proba), model_path)
50
- _write_model_info(info_path, threshold)
51
  return CreditScoringPredictor.load(
52
- model_path=model_path, model_info_path=info_path, default_threshold=0.5
 
 
53
  )
54
 
55
 
56
  def test_threshold_loaded_from_model_info(tmp_path):
57
- pred = _build(tmp_path, proba=0.1, threshold=0.42)
58
- assert pred.threshold == 0.42
59
 
60
 
61
  def test_threshold_falls_back_to_default(tmp_path):
62
  """If model_info lacks best_threshold_mean, default applies."""
63
- info = tmp_path / "info.json"
64
- info.write_text(json.dumps({"model_name": "fake", "version": "1", "metrics": {}}))
65
- model = tmp_path / "model.joblib"
66
- joblib.dump(_FixedProbModel(0.0), model)
67
- pred = CreditScoringPredictor.load(model, info, default_threshold=0.7)
 
68
  assert pred.threshold == 0.7
69
 
70
 
71
- def test_decision_granted_below_threshold(tmp_path):
72
- pred = _build(tmp_path, proba=0.1, threshold=0.33)
73
- proba, decision = pred.predict(pd.DataFrame([{}]))
74
- assert proba == pytest.approx(0.1)
75
- assert decision == "GRANTED"
 
 
 
 
 
 
 
 
76
 
 
 
 
 
77
 
78
- def test_decision_refused_above_threshold(tmp_path):
79
- pred = _build(tmp_path, proba=0.5, threshold=0.33)
80
- proba, decision = pred.predict(pd.DataFrame([{}]))
81
- assert decision == "REFUSED"
82
 
 
 
83
 
84
- def test_decision_at_threshold_is_refused(tmp_path):
85
- """proba >= threshold → REFUSED (boundary test)."""
86
- pred = _build(tmp_path, proba=0.33, threshold=0.33)
87
- _, decision = pred.predict(pd.DataFrame([{}]))
88
- assert decision == "REFUSED"
89
 
 
 
90
 
91
- def test_proba_in_unit_interval(tmp_path):
92
- pred = _build(tmp_path, proba=0.42, threshold=0.5)
 
 
 
 
 
 
93
  proba, _ = pred.predict(pd.DataFrame([{}]))
94
- assert 0.0 <= proba <= 1.0
95
-
96
-
97
- def test_unexpected_prediction_shape_raises(tmp_path):
98
- info = tmp_path / "info.json"
99
- _write_model_info(info, 0.5)
100
- model = tmp_path / "model.joblib"
101
- joblib.dump(_WeirdModel(), model)
102
- pred = CreditScoringPredictor.load(model, info, default_threshold=0.5)
103
- with pytest.raises(ValueError, match="Unexpected prediction shape"):
104
- pred.predict(pd.DataFrame([{}]))
105
-
106
-
107
- def test_1d_array_prediction_supported(tmp_path):
108
- """Some PyFunc wrappers return a 1D probability array — supported too."""
109
- info = tmp_path / "info.json"
110
- _write_model_info(info, 0.5)
111
- model = tmp_path / "model.joblib"
112
- joblib.dump(_FlatModel(), model)
113
- pred = CreditScoringPredictor.load(model, info, default_threshold=0.5)
114
- proba, decision = pred.predict(pd.DataFrame([{}]))
115
- assert proba == pytest.approx(0.6)
116
- assert decision == "REFUSED"
 
13
  from api.predictor import CreditScoringPredictor
14
 
15
 
16
+ class _FakeClassifier:
17
+ """Stand-in sklearn-style classifier: returns a fixed positive-class proba."""
18
+
19
  def __init__(self, proba: float) -> None:
20
  self.proba = proba
21
 
22
+ def predict_proba(self, df: pd.DataFrame) -> np.ndarray:
23
  return np.array([[1 - self.proba, self.proba]])
24
 
25
 
26
+ def _make_info(path: Path, threshold: float | None = 0.33) -> Path:
27
+ metrics = {"best_threshold_mean": threshold} if threshold is not None else {}
28
+ path.write_text(json.dumps({"version": "test-1", "metrics": metrics}))
29
+ return path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  def _build(tmp_path: Path, proba: float, threshold: float) -> CreditScoringPredictor:
33
  model_path = tmp_path / "model.joblib"
34
+ joblib.dump(_FakeClassifier(proba), model_path)
 
 
35
  return CreditScoringPredictor.load(
36
+ model_path=model_path,
37
+ model_info_path=_make_info(tmp_path / "info.json", threshold),
38
+ default_threshold=0.5,
39
  )
40
 
41
 
42
  def test_threshold_loaded_from_model_info(tmp_path):
43
+ assert _build(tmp_path, proba=0.1, threshold=0.42).threshold == 0.42
 
44
 
45
 
46
  def test_threshold_falls_back_to_default(tmp_path):
47
  """If model_info lacks best_threshold_mean, default applies."""
48
+ joblib.dump(_FakeClassifier(0.0), tmp_path / "model.joblib")
49
+ pred = CreditScoringPredictor.load(
50
+ model_path=tmp_path / "model.joblib",
51
+ model_info_path=_make_info(tmp_path / "info.json", threshold=None),
52
+ default_threshold=0.7,
53
+ )
54
  assert pred.threshold == 0.7
55
 
56
 
57
+ @pytest.mark.parametrize(
58
+ "proba, expected",
59
+ [
60
+ (0.10, "GRANTED"), # below threshold
61
+ (0.50, "REFUSED"), # above threshold
62
+ (0.33, "REFUSED"), # boundary: proba >= threshold → REFUSED
63
+ ],
64
+ )
65
+ def test_decision_logic(tmp_path, proba, expected):
66
+ p, decision = _build(tmp_path, proba=proba, threshold=0.33).predict(pd.DataFrame([{}]))
67
+ assert p == pytest.approx(proba)
68
+ assert decision == expected
69
+
70
 
71
+ def test_proba_is_continuous_not_label(tmp_path):
72
+ """Regression: predict() must surface predict_proba output, not class labels."""
73
+ proba, _ = _build(tmp_path, proba=0.27, threshold=0.5).predict(pd.DataFrame([{}]))
74
+ assert proba == pytest.approx(0.27)
75
 
 
 
 
 
76
 
77
+ def test_pyfunc_wrapper_is_unwrapped():
78
+ """A model exposing get_raw_model() (MLflow PyFunc) is unwrapped at init."""
79
 
80
+ class _PyFuncLike:
81
+ def __init__(self, inner):
82
+ self._inner = inner
 
 
83
 
84
+ def get_raw_model(self):
85
+ return self._inner
86
 
87
+ def predict(self, df): # PyFunc would return labels — must NOT be called
88
+ raise AssertionError("PyFunc.predict() must not be called")
89
+
90
+ pred = CreditScoringPredictor(
91
+ model=_PyFuncLike(_FakeClassifier(0.42)),
92
+ threshold=0.5,
93
+ model_version="v1",
94
+ )
95
  proba, _ = pred.predict(pd.DataFrame([{}]))
96
+ assert proba == pytest.approx(0.42)