nicolasIsmaelUTP
fix: strip CLI deps, clean requirements, add curl examples
38b642a
Raw
History Blame Contribute Delete
11.8 kB
"""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)