"""Banco Ripley – Morosidad Predictor API. FastAPI service that exposes the trained LightGBM inference pipeline. Accepts raw financial / demographic features (same schema as data/raw/data.csv) and returns a binary prediction plus probability of morosidad. Target convention → 1 = Malo/Moroso | 0 = Bueno """ import os import pickle import time from pathlib import Path from typing import Any, Optional import numpy as np import pandas as pd from fastapi import FastAPI, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel, Field # ── Paths ───────────────────────────────────────────────────────────────────── BASE_DIR = Path(__file__).parent MODEL_PATH = BASE_DIR / "model.pkl" # ── Load model once at startup ──────────────────────────────────────────────── print(f"[startup] Loading pipeline from {MODEL_PATH} …") with open(MODEL_PATH, "rb") as f: PIPELINE = pickle.load(f) print("[startup] Pipeline loaded ✓") # ── FastAPI app ─────────────────────────────────────────────────────────────── app = FastAPI( title="Banco Ripley – Modelo Predictiva Early", description=( "API de inferencia para el modelo de morosidad temprana de Banco Ripley. " "El pipeline acepta datos financieros / demográficos crudos y devuelve " "la predicción binaria junto con la probabilidad de morosidad.\n\n" "**Convención del target:** `1 = Malo/Moroso` | `0 = Bueno`" ), version="1.0.0", docs_url="/docs", redoc_url="/redoc", ) # ── Schema ──────────────────────────────────────────────────────────────────── class ClientRecord(BaseModel): """Raw feature record – misma estructura que data/raw/data.csv. Los campos ``serie`` y ``PERIODO`` son **ignorados** por el pipeline (el ColumnSelector los descarta antes del modelo); no es necesario enviarlos. """ PERIODO: Optional[Any] = Field(None, description="Ignorado por el pipeline – no requerido") serie: Optional[Any] = Field(None, description="Ignorado por el pipeline – no requerido") MESES_ANT_RCC: Optional[Any] = None EDAD: Optional[Any] = None GENERO: Optional[str] = None FLAG_ENTIDAD_PRINCIPAL: Optional[Any] = None GRADO_INSTRUCCION: Optional[str] = None DEPARTAMENTO: Optional[str] = None PROVINCIA: Optional[str] = None DISTRITO: Optional[str] = None FLAG_TC_MODELOS: Optional[Any] = None FLAG_MES: Optional[Any] = None CONTAR_COMP: Optional[Any] = None MARCA_HP: Optional[Any] = None MARCA_SEG_VIDA: Optional[Any] = None MARCA_DIF: Optional[Any] = None MARCA_CONV: Optional[Any] = None DMAX_SIN_HP: Optional[Any] = None DOTROS_DTOTAL: Optional[Any] = None DMES_DTOTAL: Optional[Any] = None LINEA_BASE: Optional[Any] = None RATIO_CONS: Optional[Any] = None UTIL_TARJ: Optional[Any] = None UTIL_EFEC: Optional[Any] = None UTIL_COMP: Optional[Any] = None RATIO_PRES: Optional[Any] = None INT_SALDO: Optional[Any] = None GARAN_SALDO: Optional[Any] = None CONV_PREST: Optional[Any] = None CONV_DEUDA: Optional[Any] = None MAX_CALIF3: Optional[Any] = None MAX_ATRASO3: Optional[Any] = None MAX_KTOT_M3: Optional[Any] = None MAX_CONV_PREST3: Optional[Any] = None PROM_UTIL_TARJ3: Optional[Any] = None PROM_UTIL_COMP3: Optional[Any] = None PROM_RATIO_PRES3: Optional[Any] = None PROM_UTIL_EFEC3: Optional[Any] = None MAX_INT_SALDO3: Optional[Any] = None MARCA_DIF3: Optional[Any] = None MARCA_SEG_VIDA3: Optional[Any] = None MARCA_GAR3: Optional[Any] = None DIF_BUE_MAL3: Optional[Any] = None DIF_BUE_MAL100_3: Optional[Any] = None PROM_DOTROS_DTOTAL3: Optional[Any] = None MAX_DMES_DTOTAL3: Optional[Any] = None PROM_DMES_DTOTAL3: Optional[Any] = None MAX_DOTROS_DTOTAL3: Optional[Any] = None MAX_LINEA3: Optional[Any] = None PROM_LINEA3: Optional[Any] = None MAX_CALIF6: Optional[Any] = None MAX_KTOT_M6: Optional[Any] = None PROM_UTIL_TARJ6: Optional[Any] = None PROM_UTIL_COMP6: Optional[Any] = None PROM_RATIO_PRES6: Optional[Any] = None PROM_UTIL_EFEC6: Optional[Any] = None MAX_INT_SALDO6: Optional[Any] = None MARCA_DIF6: Optional[Any] = None MARCA_SEG_VIDA6: Optional[Any] = None MARCA_GAR6: Optional[Any] = None MARCA_HIP6: Optional[Any] = None DIF_BUE_MAL6: Optional[Any] = None DIF_BUE_MAL100_6: Optional[Any] = None PROM_DOTROS_DTOTAL6: Optional[Any] = None MAX_DMES_DTOTAL6: Optional[Any] = None PROM_DMES_DTOTAL6: Optional[Any] = None MAX_DOTROS_DTOTAL6: Optional[Any] = None MAX_LINEA6: Optional[Any] = None PROM_LINEA6: Optional[Any] = None MAX_KTOT_M12: Optional[Any] = None PROM_UTIL_TARJ12: Optional[Any] = None PROM_UTIL_COMP12: Optional[Any] = None PROM_RATIO_PRES12: Optional[Any] = None PROM_UTIL_EFEC12: Optional[Any] = None MAX_INT_SALDO12: Optional[Any] = None MARCA_DIF12: Optional[Any] = None MARCA_SEG_VIDA12: Optional[Any] = None MARCA_GAR12: Optional[Any] = None MARCA_HIP12: Optional[Any] = None DIF_BUE_MAL12: Optional[Any] = None DIF_BUE_MAL100_12: Optional[Any] = None PROM_DOTROS_DTOTAL12: Optional[Any] = None MAX_DMES_DTOTAL12: Optional[Any] = None PROM_DMES_DTOTAL12: Optional[Any] = None MAX_DOTROS_DTOTAL12: Optional[Any] = None MAX_LINEA12: Optional[Any] = None PROM_LINEA12: Optional[Any] = None D12_MAXD: Optional[Any] = None D12_MAXD6: Optional[Any] = None D12_MAXD3: Optional[Any] = None PROM3_PROM12: Optional[Any] = None PROM3_PROM6: Optional[Any] = None PROM6_PROM12: Optional[Any] = None Max_AumKP: Optional[Any] = None Max_AumMORA: Optional[Any] = None Max_AumKT: Optional[Any] = None Max_DismDTOTAL: Optional[Any] = None Max_AumMES: Optional[Any] = None Max_DismMES: Optional[Any] = None Max_DismCONS: Optional[Any] = None Max_AumCONS: Optional[Any] = None Max_AumDTOTAL: Optional[Any] = None Max_DismKP: Optional[Any] = None Max_DismKT: Optional[Any] = None NMES_UMORA: Optional[Any] = None PROMDIR_IND12: Optional[Any] = None PROMDIR_IND6: Optional[Any] = None PROMDIR_IND3: Optional[Any] = None RATIO_CONS12: Optional[Any] = None LINT_PROM12: Optional[Any] = None MAX_PORC_ACR12: Optional[Any] = None MAX_CALIF12: Optional[Any] = None MARCA_CONV3: Optional[Any] = None MAX_CONV_DEUDA3: Optional[Any] = None MAX_ENT_REP3: Optional[Any] = None MARCA_HIP3: Optional[Any] = None MAX_PORC_ACR3: Optional[Any] = None MAX_RATIO_PRES3: Optional[Any] = None MAX_UTIL_COMP3: Optional[Any] = None MAX_UTIL_EFEC3: Optional[Any] = None MAX_UTIL_TARJ3: Optional[Any] = None RATIOS_PROM_COMP_DEUDA3: Optional[Any] = None RATIOS_PROM_EFEC_DEUDA3: Optional[Any] = None MAX_ENT_ACR3: Optional[Any] = None MAX_GARAN_SALDO3: Optional[Any] = None MARCA_CONV6: Optional[Any] = None MAX_CONV_DEUDA6: Optional[Any] = None MAX_ENT_REP6: Optional[Any] = None MAX_PORC_ACR6: Optional[Any] = None MAX_RATIO_PRES6: Optional[Any] = None MAX_UTIL_COMP6: Optional[Any] = None MAX_UTIL_EFEC6: Optional[Any] = None MAX_UTIL_TARJ6: Optional[Any] = None RATIOS_PROM_COMP_DEUDA6: Optional[Any] = None RATIOS_PROM_EFEC_DEUDA6: Optional[Any] = None MAX_ENT_ACR6: Optional[Any] = None MAX_GARAN_SALDO6: Optional[Any] = None MAX_ATRASO6: Optional[Any] = None MAX_CONV_PREST6: Optional[Any] = None MARCA_CONV12: Optional[Any] = None MAX_CONV_DEUDA12: Optional[Any] = None MAX_ENT_REP12: Optional[Any] = None MAX_RATIO_PRES12: Optional[Any] = None MAX_UTIL_COMP12: Optional[Any] = None MAX_UTIL_EFEC12: Optional[Any] = None MAX_UTIL_TARJ12: Optional[Any] = None RATIOS_PROM_COMP_DEUDA12: Optional[Any] = None RATIOS_PROM_EFEC_DEUDA12: Optional[Any] = None MAX_ENT_ACR12: Optional[Any] = None MAX_GARAN_SALDO12: Optional[Any] = None MAX_ATRASO12: Optional[Any] = None MAX_CONV_PREST12: Optional[Any] = None MARCA_GAR: Optional[Any] = None FLAG_TENENCIA_VEHICULAR: Optional[Any] = None SITUACION_LABORAL: Optional[str] = None ESTADO_CIVIL: Optional[str] = None class Config: extra = "allow" class PredictRequest(BaseModel): records: list[ClientRecord] class PredictionResult(BaseModel): prediction: int = Field(..., description="0=Bueno | 1=Malo/Moroso") probability_malo: float = Field(..., description="Probabilidad de morosidad (clase 1)") label: str = Field(..., description="Etiqueta legible: Bueno | Malo") class PredictResponse(BaseModel): predictions: list[PredictionResult] model_version: str = "lgbm-inference-pipeline-v1" run_id: str = "8967c637226f47209eeb1dba83e7519e" # ── Helpers ─────────────────────────────────────────────────────────────────── def _predict(records: list[dict]) -> list[PredictionResult]: df = pd.DataFrame(records) probas = PIPELINE.predict_proba(df)[:, 1] preds = (probas >= 0.5).astype(int) return [ PredictionResult( prediction=int(p), probability_malo=round(float(pr), 6), label="Malo" if p == 1 else "Bueno", ) for p, pr in zip(preds, probas) ] # ── Endpoints ───────────────────────────────────────────────────────────────── @app.get("/", tags=["Info"]) def root(): return { "service": "predictiva_early", "description": "Modelo de morosidad temprana – Banco Ripley", "target_convention": {"0": "Bueno", "1": "Malo/Moroso"}, "docs": "/docs", } @app.get("/health", tags=["Info"]) def health(): return {"status": "ok"} @app.post("/predict", response_model=PredictResponse, tags=["Inference"]) def predict_single(record: ClientRecord): """Predice morosidad para **un único cliente**. Envía un registro con la misma estructura que `data/raw/data.csv`. Devuelve la clase predicha (`0=Bueno`, `1=Malo/Moroso`) y la probabilidad. """ try: results = _predict([record.model_dump()]) except Exception as exc: raise HTTPException(status_code=422, detail=str(exc)) return PredictResponse(predictions=results) @app.post("/predict/batch", response_model=PredictResponse, tags=["Inference"]) def predict_batch(request: PredictRequest): """Predice morosidad para un **lote de clientes** (máximo 500 registros). Envía una lista de registros bajo la clave `records`. """ if len(request.records) > 500: raise HTTPException( status_code=413, detail="Batch demasiado grande. Máximo 500 registros por llamada.", ) try: results = _predict([r.model_dump() for r in request.records]) except Exception as exc: raise HTTPException(status_code=422, detail=str(exc)) return PredictResponse(predictions=results)