from __future__ import annotations import math from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Mapping def resolve_schema_path(base_dir: Path) -> Path: candidates = [ base_dir / "model" / "schema" / "runtime_schema_DE.json", base_dir / "model" / "resources-to-build" / "runtime_schema_DE.json", ] for candidate in candidates: if candidate.exists(): return candidate searched = ", ".join(str(path.relative_to(base_dir)) for path in candidates) raise FileNotFoundError(f"Runtime schema not found. Checked: {searched}") def to_float_or_none(value: Any) -> float | None: if value is None or value == "": return None try: number = float(value) return number if math.isfinite(number) else None except (TypeError, ValueError): return None def to_categorical(value: Any) -> str: if value is None: return "MISSING" text = str(value).strip() return text if text else "MISSING" def parse_date(value: Any) -> date | None: if value is None: return None if isinstance(value, date): return value for fmt in ("%Y-%m-%d", "%Y/%m/%d", "%d-%m-%Y", "%d/%m/%Y"): try: return datetime.strptime(str(value).strip(), fmt).date() except ValueError: continue return None def periodo_to_fecha_corte(periodo: int | str) -> date | None: value = str(periodo).strip() if len(value) != 6 or not value.isdigit(): return None year, month = int(value[:4]), int(value[4:]) if not 1 <= month <= 12: return None if month == 12: return date(year, 12, 31) return date(year, month + 1, 1) - timedelta(days=1) def years_diff_floor(start: date | None, end: date | None) -> int | None: if start is None or end is None: return None years = end.year - start.year if (end.month, end.day) < (start.month, start.day): years -= 1 return years def months_diff_floor(start: date | None, end: date | None) -> int | None: if start is None or end is None: return None months = (end.year - start.year) * 12 + (end.month - start.month) if end.day < start.day: months -= 1 return months def safe_log1p(value: Any) -> float | None: number = to_float_or_none(value) if number is None: return None return math.log1p(max(number, 0.0)) def cap_dias_mora(dias_mora: Any) -> float | None: value = to_float_or_none(dias_mora) if value is None: return None return min(value, 120.0) def bucket_mora(dias_mora: Any) -> str: value = to_float_or_none(dias_mora) if value is None: return "MISSING" if value <= 0: return "0" if value <= 8: return "1_8" if value <= 30: return "9_30" if value <= 60: return "31_60" if value <= 89: return "61_89" return "90_plus" def horizonte_hasta_dic(fecha_snapshot: date | None) -> int | None: if fecha_snapshot is None: return None return 12 - fecha_snapshot.month def safe_ratio(numerator: Any, denominator: Any) -> float | None: left = to_float_or_none(numerator) right = to_float_or_none(denominator) if left is None or right is None or right <= 0: return None return left / right def build_feature_map(raw: Mapping[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: fecha_snapshot = parse_date(raw.get("fecha_snapshot")) or periodo_to_fecha_corte(raw["periodo"]) fecha_nacimiento = parse_date(raw.get("fecha_nacimiento")) fecha_1er_desembolso = parse_date(raw.get("fecha_1er_desembolso")) fecha_vencimiento = parse_date(raw.get("fecha_vencimiento")) saldo_actual = to_float_or_none(raw.get("saldo_actual")) capital_desembolsado = to_float_or_none(raw.get("capital_desembolsado")) cuota_capital = to_float_or_none(raw.get("cuota_capital")) dias_mora = to_float_or_none(raw.get("dias_mora_capital")) dias_mora_capped = cap_dias_mora(dias_mora) derived = { "fecha_snapshot": str(fecha_snapshot) if fecha_snapshot else None, "horizonte_meses_hasta_dic": horizonte_hasta_dic(fecha_snapshot), "edad_cliente_t": years_diff_floor(fecha_nacimiento, fecha_snapshot), "edad_credito_meses_t": months_diff_floor(fecha_1er_desembolso, fecha_snapshot), "plazo_original_meses": months_diff_floor(fecha_1er_desembolso, fecha_vencimiento), "plazo_remanente_meses_t": months_diff_floor(fecha_snapshot, fecha_vencimiento), "dias_mora_capital_capped": dias_mora_capped, "mora_bucket": bucket_mora(dias_mora), "ratio_saldo_capital": safe_ratio(saldo_actual, capital_desembolsado), "ratio_cuota_saldo": safe_ratio(cuota_capital, saldo_actual), "ratio_cuota_capital_des": safe_ratio(cuota_capital, capital_desembolsado), "saldo_actual_log": safe_log1p(saldo_actual), "capital_desembolsado_log": safe_log1p(capital_desembolsado), "cuota_capital_log": safe_log1p(cuota_capital), } feature_map = { "id_cooperativa": to_categorical(raw.get("id_cooperativa")), "calificacion_credito": to_categorical(raw.get("calificacion_credito")), "saldo_actual": saldo_actual, "capital_desembolsado": capital_desembolsado, "cuota_capital": cuota_capital, "tasa_interes": to_float_or_none(raw.get("tasa_interes")), "tasa_pactada": to_float_or_none(raw.get("tasa_pactada")), "reestructurado": to_categorical(raw.get("reestructurado")), "status_credito": to_categorical(raw.get("status_credito")), "metodo_calculo": to_categorical(raw.get("metodo_calculo")), "tipo_garantia": to_categorical(raw.get("tipo_garantia")), "frecuencia_pago": to_categorical(raw.get("frecuencia_pago")), "id_agencia": to_categorical(raw.get("id_agencia")), "tipo_cliente": to_categorical(raw.get("tipo_cliente")), "categoria_cliente": to_categorical(raw.get("categoria_cliente")), "persona_pep": to_categorical(raw.get("persona_pep")), "persona_cpe": to_categorical(raw.get("persona_cpe")), **{key: value for key, value in derived.items() if key != "fecha_snapshot"}, } return feature_map, derived def get_categorical_feature_indices(schema: Mapping[str, Any]) -> list[int]: categorical = set(schema["categorical_features"]) return [ index for index, feature_name in enumerate(schema["feature_order"]) if feature_name in categorical ] def build_catboost_row( raw: Mapping[str, Any], schema: Mapping[str, Any], ) -> tuple[list[Any], dict[str, Any], dict[str, Any]]: feature_map, derived = build_feature_map(raw) categorical = set(schema["categorical_features"]) row: list[Any] = [] for feature_name in schema["feature_order"]: value = feature_map.get(feature_name) if feature_name in categorical: row.append(to_categorical(value)) else: row.append(float("nan") if value is None else float(value)) return row, feature_map, derived