Spaces:
Sleeping
Sleeping
| """ | |
| ActuarialOS — Agent 14: Reserve Adequacy / IBNR | |
| ================================================= | |
| Reads from silver_act_dev_triangles + silver_act_ibnr_reserves | |
| Writes to gold_act_ibnr_estimates + gold_act_audit_log | |
| Methods implemented: | |
| 1. Chain Ladder (Volume-Weighted LDFs) | |
| 2. Bornhuetter-Ferguson (ELR blend) | |
| 3. Cape Cod (Experience-based ELR) | |
| 4. Over-Dispersed Poisson (ODP Bootstrap) | |
| 5. XGBoost ML (pattern recognition across triangle cells) | |
| 6. Weighted ensemble with uncertainty quantification | |
| ETL: run_etl_triangles_to_silver() — bronze → silver dev triangles | |
| Train: train_agent14_model() | |
| Run: run_agent14(payload) — single LOB/state/AY | |
| Batch: run_agent14_batch(payload) — full portfolio sweep | |
| """ | |
| import os, json, logging, uuid | |
| from datetime import date, datetime | |
| import numpy as np | |
| import pandas as pd | |
| log = logging.getLogger(__name__) | |
| MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'models') | |
| MODEL_PATH = os.path.join(MODELS_DIR, 'agent14_ibnr.pkl') | |
| # ── Volume-weighted LDF benchmarks by LOB (industry priors) ── | |
| BENCHMARK_LDFS = { | |
| # 12→24 24→36 36→48 48→60 60→72 72→84 84→96 96→108 108→120 | |
| 'HO': [1.450, 1.150, 1.060, 1.030, 1.015, 1.007, 1.003, 1.001, 1.000], | |
| 'AUTO': [1.600, 1.200, 1.090, 1.040, 1.020, 1.009, 1.004, 1.001, 1.000], | |
| 'CMP': [1.700, 1.250, 1.120, 1.060, 1.030, 1.012, 1.005, 1.002, 1.001], | |
| 'GL': [2.100, 1.450, 1.200, 1.100, 1.050, 1.020, 1.008, 1.003, 1.001], | |
| 'WC': [1.900, 1.350, 1.160, 1.080, 1.040, 1.015, 1.006, 1.002, 1.001], | |
| } | |
| DEV_MONTHS = [12, 24, 36, 48, 60, 72, 84, 96, 108, 120] | |
| # Target loss ratios (ELR priors for BF method) | |
| ELR_PRIOR = { | |
| 'HO': 0.62, 'AUTO': 0.68, 'CMP': 0.58, 'GL': 0.55, 'WC': 0.72, | |
| } | |
| FEATURE_COLS_14 = [ | |
| 'lob_enc', 'dev_month_enc', 'accident_year_norm', | |
| 'log_cumulative', 'log_earned_premium', | |
| 'pct_developed', 'ata_factor', 'atf_to_ult', | |
| 'elr_prior', 'cc_elr_ratio', | |
| 'tail_factor', 'ldf_12_24', 'ldf_24_36', | |
| 'years_of_data', 'claim_count_log', | |
| ] | |
| LOB_ENC_14 = {'HO': 0, 'AUTO': 1, 'CMP': 2, 'GL': 3, 'WC': 4} | |
| # ══════════════════════════════════════════════════════════════ | |
| # DB HELPERS | |
| # ══════════════════════════════════════════════════════════════ | |
| def _get_engine(): | |
| from sqlalchemy import create_engine | |
| from sqlalchemy.pool import NullPool | |
| from urllib.parse import quote_plus as qp | |
| import os | |
| DB = dict( | |
| host = os.environ.get('MYSQL_ADDON_HOST', 'btvbbpqhvnttzvptguj3-mysql.services.clever-cloud.com'), | |
| port = int(os.environ.get('MYSQL_ADDON_PORT', '3306')), | |
| user = os.environ.get('MYSQL_ADDON_USER', 'utenclk29u394u1j'), | |
| password = os.environ.get('MYSQL_ADDON_PASSWORD', 'QXFZTmUtPnXrKFqZKpLQ'), | |
| database = os.environ.get('MYSQL_ADDON_DB', 'btvbbpqhvnttzvptguj3'), | |
| ) | |
| pwd = qp(DB['password']) | |
| return create_engine( | |
| f"mysql+pymysql://{DB['user']}:{pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4", | |
| poolclass=NullPool, connect_args={"connect_timeout": 15} | |
| ) | |
| def _safe_json(obj): | |
| import math | |
| if isinstance(obj, dict): return {k: _safe_json(v) for k, v in obj.items()} | |
| if isinstance(obj, list): return [_safe_json(v) for v in obj] | |
| if isinstance(obj, float): return None if (math.isnan(obj) or math.isinf(obj)) else round(obj, 6) | |
| if isinstance(obj, np.integer): return int(obj) | |
| if isinstance(obj, np.floating): | |
| v = float(obj); return None if (math.isnan(v) or math.isinf(v)) else round(v, 6) | |
| if isinstance(obj, np.ndarray): return obj.tolist() | |
| if isinstance(obj, (date, datetime)): return str(obj) | |
| return obj | |
| # ══════════════════════════════════════════════════════════════ | |
| # CORE ACTUARIAL METHODS | |
| # ══════════════════════════════════════════════════════════════ | |
| class Triangle: | |
| """ | |
| Represents a single LOB/state development triangle. | |
| Rows = accident years, columns = dev months. | |
| """ | |
| def __init__(self, lob: str, state: str, triangle_type: str = 'PAID_LOSS'): | |
| self.lob = lob | |
| self.state = state | |
| self.ttype = triangle_type | |
| self.data: dict = {} # {(ay, dev_mo): cumulative_value} | |
| self.premiums: dict = {} # {ay: earned_premium} | |
| self.claims: dict = {} # {ay: claim_count} | |
| def add_cell(self, ay: int, dev_mo: int, value: float, | |
| premium: float = None, claims: int = None): | |
| self.data[(ay, dev_mo)] = value | |
| if premium: self.premiums[ay] = premium | |
| if claims: self.claims[ay] = claims | |
| def accident_years(self): | |
| return sorted(set(ay for ay, _ in self.data)) | |
| def dev_periods(self): | |
| return sorted(set(d for _, d in self.data)) | |
| def get(self, ay, dev): | |
| return self.data.get((ay, dev)) | |
| def volume_weighted_ldfs(self) -> list: | |
| """Compute volume-weighted age-to-age factors.""" | |
| devs = self.dev_periods | |
| ldfs = [] | |
| for i in range(len(devs) - 1): | |
| d1, d2 = devs[i], devs[i + 1] | |
| num, den = 0.0, 0.0 | |
| for ay in self.accident_years: | |
| v1 = self.get(ay, d1) | |
| v2 = self.get(ay, d2) | |
| if v1 and v2 and v1 > 0: | |
| num += v2 | |
| den += v1 | |
| ldfs.append(round(num / den, 6) if den > 0 else | |
| BENCHMARK_LDFS.get(self.lob, BENCHMARK_LDFS['HO'])[i]) | |
| return ldfs | |
| def cdfs_to_ultimate(self, ldfs: list) -> dict: | |
| """CDF from each dev period to ultimate (tail = 1.000).""" | |
| devs = self.dev_periods | |
| cdfs = {} | |
| cdfs[devs[-1]] = 1.000 | |
| for i in range(len(devs) - 2, -1, -1): | |
| cdfs[devs[i]] = round(cdfs[devs[i + 1]] * ldfs[i], 6) | |
| return cdfs | |
| def chain_ladder(tri: Triangle) -> dict: | |
| """ | |
| Classic Chain Ladder / Development Method. | |
| Ultimate = Latest Diagonal × CDF to Ultimate | |
| IBNR = Ultimate - (Paid + Case) | |
| """ | |
| ldfs = tri.volume_weighted_ldfs() | |
| cdfs = tri.cdfs_to_ultimate(ldfs) | |
| devs = tri.dev_periods | |
| results = {} | |
| for ay in tri.accident_years: | |
| # Find latest diagonal | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| cdf = cdfs.get(latest_dev, 1.0) | |
| ultimate = round(latest_val * cdf, 2) | |
| ibnr = round(max(0, ultimate - latest_val), 2) | |
| results[ay] = dict( | |
| method='CHAIN_LADDER', ultimate=ultimate, ibnr=ibnr, | |
| latest_dev=latest_dev, latest_value=latest_val, | |
| cdf_applied=cdf, pct_developed=round(1/cdf, 5) | |
| ) | |
| return results | |
| def bornhuetter_ferguson(tri: Triangle, elr: float = None) -> dict: | |
| """ | |
| Bornhuetter-Ferguson Method. | |
| Ultimate = Actual Reported + Expected Unreported | |
| Expected Unreported = EP × ELR × (1 - 1/CDF) | |
| """ | |
| ldfs = tri.volume_weighted_ldfs() | |
| cdfs = tri.cdfs_to_ultimate(ldfs) | |
| devs = tri.dev_periods | |
| elr = elr or ELR_PRIOR.get(tri.lob, 0.65) | |
| results = {} | |
| for ay in tri.accident_years: | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| cdf = cdfs.get(latest_dev, 1.0) | |
| ep = tri.premiums.get(ay, latest_val / elr) | |
| pct_undev = round(1 - (1 / cdf), 5) | |
| exp_unrep = ep * elr * pct_undev | |
| ultimate = round(latest_val + exp_unrep, 2) | |
| ibnr = round(max(0, ultimate - latest_val), 2) | |
| results[ay] = dict( | |
| method='BORNHUETTER_FERGUSON', ultimate=ultimate, ibnr=ibnr, | |
| elr_used=elr, pct_undeveloped=pct_undev, | |
| expected_unreported=round(exp_unrep, 2), | |
| cdf_applied=cdf | |
| ) | |
| return results | |
| def cape_cod(tri: Triangle) -> dict: | |
| """ | |
| Cape Cod Method. | |
| ELR derived empirically from experience (not a priori). | |
| ELR_CC = Sum(Reported) / Sum(Used-up Premium) | |
| Used-up premium = EP × (1 - 1/CDF) | |
| """ | |
| ldfs = tri.volume_weighted_ldfs() | |
| cdfs = tri.cdfs_to_ultimate(ldfs) | |
| devs = tri.dev_periods | |
| total_reported = 0.0 | |
| total_used_up = 0.0 | |
| for ay in tri.accident_years: | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| cdf = cdfs.get(latest_dev, 1.0) | |
| ep = tri.premiums.get(ay, latest_val / ELR_PRIOR.get(tri.lob, 0.65)) | |
| total_reported += latest_val | |
| total_used_up += ep * (1 - 1/cdf) | |
| cc_elr = round(total_reported / total_used_up, 5) if total_used_up > 0 else ELR_PRIOR.get(tri.lob, 0.65) | |
| results = {} | |
| for ay in tri.accident_years: | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| cdf = cdfs.get(latest_dev, 1.0) | |
| ep = tri.premiums.get(ay, latest_val / cc_elr) | |
| pct_undev = round(1 - (1/cdf), 5) | |
| exp_unrep = ep * cc_elr * pct_undev | |
| ultimate = round(latest_val + exp_unrep, 2) | |
| ibnr = round(max(0, ultimate - latest_val), 2) | |
| results[ay] = dict( | |
| method='CAPE_COD', ultimate=ultimate, ibnr=ibnr, | |
| cc_elr=cc_elr, pct_undeveloped=pct_undev, | |
| expected_unreported=round(exp_unrep, 2) | |
| ) | |
| return results | |
| def odp_bootstrap(tri: Triangle, n_sims: int = 1000, seed: int = 14) -> dict: | |
| """ | |
| Over-Dispersed Poisson Bootstrap. | |
| Simulates the full predictive distribution of IBNR. | |
| Returns mean + percentiles (P50, P75, P90, P99). | |
| """ | |
| rng = np.random.default_rng(seed) | |
| ldfs = tri.volume_weighted_ldfs() | |
| cdfs = tri.cdfs_to_ultimate(ldfs) | |
| devs = tri.dev_periods | |
| # Fitted incremental values (using CL ratios) | |
| fitted = {} | |
| for ay in tri.accident_years: | |
| for i, d in enumerate(devs): | |
| if tri.get(ay, d) is not None: | |
| if i == 0: | |
| fitted[(ay, d)] = tri.get(ay, d) | |
| else: | |
| prev = tri.get(ay, devs[i-1]) | |
| if prev: | |
| fitted[(ay, d)] = prev * (ldfs[i-1] - 1) | |
| # Pearson residuals | |
| residuals = [] | |
| for (ay, d), fv in fitted.items(): | |
| actual = tri.get(ay, d) | |
| if actual and fv and fv > 0: | |
| r = (actual - fv) / (fv ** 0.5) | |
| residuals.append(r) | |
| residuals = np.array(residuals) if residuals else np.array([0.0]) | |
| # Bootstrap | |
| sim_totals = [] | |
| for _ in range(n_sims): | |
| total_ibnr = 0.0 | |
| for ay in tri.accident_years: | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| cdf = cdfs.get(latest_dev, 1.0) | |
| # Simulate future development | |
| noise = rng.choice(residuals) * (latest_val * (cdf-1)) ** 0.5 if cdf > 1 else 0 | |
| sim_ibnr = max(0, latest_val * (cdf - 1) + noise) | |
| total_ibnr += sim_ibnr | |
| sim_totals.append(total_ibnr) | |
| sim_totals = np.array(sim_totals) | |
| per_ay = {} | |
| for ay in tri.accident_years: | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| cdf = cdfs.get(latest_dev, 1.0) | |
| mean_ibnr = round(max(0, latest_val * (cdf - 1)), 2) | |
| per_ay[ay] = dict( | |
| method='ODP', ultimate=round(latest_val * cdf, 2), | |
| ibnr=mean_ibnr, cdf_applied=cdf | |
| ) | |
| return dict( | |
| per_ay = per_ay, | |
| total_ibnr_mean = round(float(np.mean(sim_totals)), 2), | |
| total_ibnr_p50 = round(float(np.percentile(sim_totals, 50)), 2), | |
| total_ibnr_p75 = round(float(np.percentile(sim_totals, 75)), 2), | |
| total_ibnr_p90 = round(float(np.percentile(sim_totals, 90)), 2), | |
| total_ibnr_p99 = round(float(np.percentile(sim_totals, 99)), 2), | |
| cv = round(float(np.std(sim_totals) / np.mean(sim_totals)), 4) if np.mean(sim_totals) > 0 else 0, | |
| n_sims = n_sims, | |
| ) | |
| def ensemble_ibnr(cl: dict, bf: dict, cc: dict, odp_per_ay: dict, | |
| ml_per_ay: dict = None, lob: str = 'HO') -> dict: | |
| """ | |
| Weighted ensemble across methods. | |
| Weights reflect reliability by development age: | |
| - Early AYs (little data): BF/CC weighted higher | |
| - Mature AYs (lots of data): CL weighted higher | |
| """ | |
| results = {} | |
| for ay in cl: | |
| if ay not in bf or ay not in cc: | |
| continue | |
| cl_ult = cl[ay]['ultimate'] | |
| bf_ult = bf[ay]['ultimate'] | |
| cc_ult = cc[ay]['ultimate'] | |
| odp_ult = odp_per_ay.get(ay, {}).get('ultimate', cl_ult) | |
| ml_ult = (ml_per_ay or {}).get(ay, {}).get('ultimate', cl_ult) | |
| pct_dev = cl[ay].get('pct_developed', 0.5) | |
| # Credibility weights — more developed = trust CL more | |
| if pct_dev >= 0.85: | |
| w_cl, w_bf, w_cc, w_odp, w_ml = 0.45, 0.20, 0.15, 0.10, 0.10 | |
| elif pct_dev >= 0.60: | |
| w_cl, w_bf, w_cc, w_odp, w_ml = 0.30, 0.28, 0.20, 0.12, 0.10 | |
| else: | |
| w_cl, w_bf, w_cc, w_odp, w_ml = 0.20, 0.32, 0.25, 0.13, 0.10 | |
| if ml_per_ay is None: | |
| # Redistribute ML weight | |
| tot = w_cl + w_bf + w_cc + w_odp | |
| w_cl /= tot; w_bf /= tot; w_cc /= tot; w_odp /= tot; w_ml = 0 | |
| ens_ult = (w_cl * cl_ult + w_bf * bf_ult + w_cc * cc_ult + | |
| w_odp * odp_ult + w_ml * ml_ult) | |
| latest_val = cl[ay]['latest_value'] | |
| ens_ibnr = max(0, ens_ult - latest_val) | |
| results[ay] = dict( | |
| cl_ultimate = round(cl_ult, 2), | |
| bf_ultimate = round(bf_ult, 2), | |
| cc_ultimate = round(cc_ult, 2), | |
| odp_ultimate = round(odp_ult, 2), | |
| ml_ultimate = round(ml_ult, 2) if ml_per_ay else None, | |
| ensemble_ultimate = round(ens_ult, 2), | |
| ibnr = round(ens_ibnr, 2), | |
| weights = dict(cl=w_cl, bf=w_bf, cc=w_cc, odp=w_odp, ml=w_ml), | |
| pct_developed = pct_dev, | |
| latest_value = latest_val, | |
| ) | |
| return results | |
| # ══════════════════════════════════════════════════════════════ | |
| # ETL: BRONZE → SILVER TRIANGLES | |
| # ══════════════════════════════════════════════════════════════ | |
| def run_etl_triangles_to_silver(): | |
| """Aggregate bronze_act_triangles → silver_act_dev_triangles with LDFs.""" | |
| from sqlalchemy import text | |
| eng = _get_engine() | |
| log.info("[AGENT14-ETL] bronze → silver triangles") | |
| with eng.connect() as conn: | |
| df = pd.read_sql(""" | |
| SELECT lob, state_code, triangle_type, accident_year, | |
| dev_month, AVG(cumulative_value) AS cumulative_value, | |
| AVG(earned_premium) AS earned_premium, | |
| AVG(claim_count) AS claim_count | |
| FROM bronze_act_triangles | |
| GROUP BY lob, state_code, triangle_type, accident_year, dev_month | |
| ORDER BY lob, state_code, triangle_type, accident_year, dev_month | |
| """, conn) | |
| if df.empty: | |
| log.warning("[AGENT14-ETL] No triangle data in bronze") | |
| return | |
| rows = [] | |
| groups = df.groupby(['lob', 'state_code', 'triangle_type']) | |
| for (lob, state, ttype), grp in groups: | |
| # Build Triangle object | |
| tri = Triangle(lob, state, ttype) | |
| for _, r in grp.iterrows(): | |
| tri.add_cell( | |
| int(r['accident_year']), int(r['dev_month']), | |
| float(r['cumulative_value']), | |
| premium=float(r['earned_premium']) if pd.notna(r['earned_premium']) else None, | |
| claims=int(r['claim_count']) if pd.notna(r['claim_count']) else None, | |
| ) | |
| ldfs = tri.volume_weighted_ldfs() | |
| cdfs = tri.cdfs_to_ultimate(ldfs) | |
| devs = tri.dev_periods | |
| for (ay, dev_mo), cum_val in tri.data.items(): | |
| idx = devs.index(dev_mo) if dev_mo in devs else -1 | |
| atf = cdfs.get(dev_mo, 1.0) | |
| ata = ldfs[idx] if 0 <= idx < len(ldfs) else 1.0 | |
| prev_dev = devs[idx-1] if idx > 0 else None | |
| prev_val = tri.get(ay, prev_dev) if prev_dev else None | |
| incremental = round(cum_val - prev_val, 2) if prev_val else None | |
| # BF ultimate for this cell | |
| ep = tri.premiums.get(ay) | |
| elr = ELR_PRIOR.get(lob, 0.65) | |
| bf_ult= round(cum_val + (ep * elr * (1 - 1/atf)), 2) if ep and atf > 1 else round(cum_val * atf, 2) | |
| rows.append(dict( | |
| lob=lob, state_code=state, triangle_type=ttype, | |
| accident_year=ay, dev_month=dev_mo, | |
| cumulative_value=round(cum_val, 2), | |
| incremental_value=incremental, | |
| ata_factor=round(ata, 6), | |
| atf_factor=round(atf, 6), | |
| bornhuetter_ult=bf_ult, | |
| cl_ult=round(cum_val * atf, 2), | |
| pct_developed=round(1/atf, 5), | |
| )) | |
| silver_df = pd.DataFrame(rows).where(pd.notna(pd.DataFrame(rows)), other=None) | |
| with eng.begin() as conn: | |
| conn.execute(text("DELETE FROM silver_act_dev_triangles")) | |
| silver_df.to_sql('silver_act_dev_triangles', conn, | |
| if_exists='append', index=False, method='multi', chunksize=500) | |
| log.info(f"[AGENT14-ETL] silver_act_dev_triangles: {len(silver_df)} rows") | |
| return {'triangle_rows': len(silver_df)} | |
| # ══════════════════════════════════════════════════════════════ | |
| # ML FEATURE BUILDER + TRAINING | |
| # ══════════════════════════════════════════════════════════════ | |
| def _build_features_14(lob: str, dev_month, ay: int, | |
| cumulative: float, ep: float, | |
| ldf_12_24: float, ldf_24_36: float, | |
| atf: float, claim_count: int = 50) -> dict: | |
| dev_month = int(dev_month) | |
| latest_ay = 2024 | |
| return { | |
| 'lob_enc': LOB_ENC_14.get(lob, 0), | |
| 'dev_month_enc': DEV_MONTHS.index(dev_month) if dev_month in DEV_MONTHS else 0, | |
| 'accident_year_norm': round((ay - 2015) / 9, 4), | |
| 'log_cumulative': float(np.log(max(cumulative, 1))), | |
| 'log_earned_premium': float(np.log(max(ep, 1))), | |
| 'pct_developed': round(1/atf if atf > 0 else 1.0, 5), | |
| 'ata_factor': ldf_12_24 if dev_month <= 24 else ldf_24_36, | |
| 'atf_to_ult': round(atf, 5), | |
| 'elr_prior': ELR_PRIOR.get(lob, 0.65), | |
| 'cc_elr_ratio': round(cumulative / max(ep * ELR_PRIOR.get(lob, 0.65), 1), 5), | |
| 'tail_factor': 1.005 if lob in ('GL','WC') else 1.001, | |
| 'ldf_12_24': ldf_12_24, | |
| 'ldf_24_36': ldf_24_36, | |
| 'years_of_data': latest_ay - ay, | |
| 'claim_count_log': float(np.log(max(claim_count, 1))), | |
| } | |
| def _generate_training_data_14(n=6000, seed=14): | |
| rng = np.random.default_rng(seed) | |
| lobs = list(LOB_ENC_14.keys()) | |
| rows = [] | |
| for _ in range(n): | |
| lob = rng.choice(lobs) | |
| ay = int(rng.integers(2015, 2025)) | |
| dev = int(rng.choice(DEV_MONTHS)) | |
| bench = BENCHMARK_LDFS.get(lob, BENCHMARK_LDFS['HO']) | |
| elr = ELR_PRIOR[lob] | |
| ep = float(rng.uniform(300000, 8000000)) | |
| # True ultimate | |
| true_ult = ep * elr * float(rng.lognormal(0, 0.12)) | |
| # CDF for this dev period | |
| idx = DEV_MONTHS.index(dev) | |
| cdf = 1.0 | |
| for f in bench[idx:]: cdf *= f | |
| cum = true_ult / cdf * float(rng.uniform(0.92, 1.08)) | |
| cum = max(cum, 1) | |
| ldf1 = bench[0] * float(rng.uniform(0.92, 1.08)) | |
| ldf2 = bench[1] * float(rng.uniform(0.92, 1.08)) | |
| cc = max(1, int(rng.poisson(ep / 15000))) | |
| feats = _build_features_14(lob, dev, ay, cum, ep, ldf1, ldf2, cdf, cc) | |
| feats['target_ultimate'] = true_ult | |
| rows.append(feats) | |
| return pd.DataFrame(rows) | |
| def train_agent14_model(): | |
| """Train XGBoost to predict ultimate loss from triangle cell features.""" | |
| import joblib | |
| from xgboost import XGBRegressor | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.metrics import mean_absolute_error | |
| os.makedirs(MODELS_DIR, exist_ok=True) | |
| log.info("[AGENT14] Generating training data...") | |
| df = _generate_training_data_14(n=8000) | |
| X = df[FEATURE_COLS_14] | |
| y = df['target_ultimate'] | |
| X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=14) | |
| model = XGBRegressor( | |
| n_estimators=350, max_depth=6, learning_rate=0.04, | |
| subsample=0.80, colsample_bytree=0.80, | |
| min_child_weight=8, reg_alpha=0.2, reg_lambda=1.5, | |
| random_state=14, verbosity=0 | |
| ) | |
| model.fit(X_tr, y_tr, eval_set=[(X_te, y_te)], verbose=False) | |
| mae = mean_absolute_error(y_te, model.predict(X_te)) | |
| mae_pct = mae / y_te.mean() | |
| log.info(f"[AGENT14] XGBoost MAE: {mae:,.0f} ({mae_pct:.2%} of mean)") | |
| bundle = dict( | |
| model=model, feature_cols=FEATURE_COLS_14, | |
| mae=mae, mae_pct=mae_pct, trained_at=str(date.today()) | |
| ) | |
| joblib.dump(bundle, MODEL_PATH) | |
| log.info(f"[AGENT14] Model saved → {MODEL_PATH}") | |
| return bundle | |
| # ══════════════════════════════════════════════════════════════ | |
| # MAIN AGENT ENTRY POINTS | |
| # ══════════════════════════════════════════════════════════════ | |
| def _build_triangle_from_db(eng, lob: str, state: str, | |
| ttype: str = 'PAID_LOSS') -> Triangle: | |
| """Load a Triangle object from silver_act_dev_triangles.""" | |
| with eng.connect() as conn: | |
| df = pd.read_sql(""" | |
| SELECT accident_year, dev_month, cumulative_value, | |
| pct_developed, atf_factor | |
| FROM silver_act_dev_triangles | |
| WHERE lob=%s AND state_code=%s AND triangle_type=%s | |
| ORDER BY accident_year, dev_month | |
| """, conn, params=(lob, state, ttype)) | |
| premiums = pd.read_sql(""" | |
| SELECT accident_year, SUM(earned_premium) AS ep | |
| FROM silver_act_loss_ratios | |
| WHERE lob=%s AND state_code=%s | |
| GROUP BY accident_year | |
| """, conn, params=(lob, state)) | |
| tri = Triangle(lob, state, ttype) | |
| prem_map = dict(zip(premiums['accident_year'], premiums['ep'])) if not premiums.empty else {} | |
| for _, row in df.iterrows(): | |
| tri.add_cell( | |
| int(row['accident_year']), int(row['dev_month']), | |
| float(row['cumulative_value']), | |
| premium=prem_map.get(int(row['accident_year'])) | |
| ) | |
| return tri | |
| def run_agent14(payload: dict) -> dict: | |
| """ | |
| Estimate IBNR for a single LOB/state combination. | |
| payload keys: | |
| lob, state_code, [eval_date], [run_id], | |
| [triangle_data: list of {ay, dev_month, value, premium}] | |
| Returns ensemble IBNR with all method outputs + uncertainty. | |
| """ | |
| from sqlalchemy import text | |
| lob = str(payload.get('lob', 'HO')) | |
| state = str(payload.get('state_code', 'TX')) | |
| run_id = payload.get('run_id') or f"AG14-{uuid.uuid4().hex[:12].upper()}" | |
| eval_dt = payload.get('eval_date') or str(date.today()) | |
| log.info(f"[AGENT14] IBNR estimate {lob}/{state} run={run_id}") | |
| eng = _get_engine() | |
| # Build triangle — from payload override or DB | |
| if payload.get('triangle_data'): | |
| tri = Triangle(lob, state, 'PAID_LOSS') | |
| for cell in payload['triangle_data']: | |
| tri.add_cell( | |
| int(cell['ay']), int(cell['dev_month']), | |
| float(cell['value']), | |
| premium=cell.get('premium'), | |
| ) | |
| else: | |
| try: | |
| tri = _build_triangle_from_db(eng, lob, state, 'PAID_LOSS') | |
| except Exception as e: | |
| log.warning(f"[AGENT14] DB triangle load failed: {e} — using synthetic fallback") | |
| tri = _synthetic_triangle(lob, state) | |
| if not tri.accident_years: | |
| return {'error': f'No triangle data for {lob}/{state}', 'run_id': run_id} | |
| # ── Run all actuarial methods ────────────────────────────── | |
| cl_res = chain_ladder(tri) | |
| bf_res = bornhuetter_ferguson(tri) | |
| cc_res = cape_cod(tri) | |
| odp_res = odp_bootstrap(tri, n_sims=2000) | |
| # ── ML prediction per AY ────────────────────────────────── | |
| ml_per_ay = None | |
| try: | |
| import joblib, shap as shap_lib | |
| bundle = joblib.load(MODEL_PATH) | |
| model = bundle['model'] | |
| ldfs = tri.volume_weighted_ldfs() | |
| cdfs = tri.cdfs_to_ultimate(ldfs) | |
| devs = tri.dev_periods | |
| bench = BENCHMARK_LDFS.get(lob, BENCHMARK_LDFS['HO']) | |
| ml_rows = [] | |
| ml_ays = [] | |
| for ay in tri.accident_years: | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| cum = tri.get(ay, latest_dev) | |
| ep = tri.premiums.get(ay, cum / ELR_PRIOR.get(lob, 0.65)) | |
| atf = cdfs.get(latest_dev, 1.0) | |
| feats = _build_features_14( | |
| lob, latest_dev, ay, cum, ep, | |
| bench[0], bench[1], atf | |
| ) | |
| ml_rows.append(feats) | |
| ml_ays.append(ay) | |
| X_ml = pd.DataFrame(ml_rows)[bundle['feature_cols']] | |
| preds = bundle['model'].predict(X_ml) | |
| ml_per_ay = {} | |
| for ay, pred_ult in zip(ml_ays, preds): | |
| latest_dev = max(d for d in devs if tri.get(ay, d) is not None) | |
| latest_val = tri.get(ay, latest_dev) | |
| ml_per_ay[ay] = dict( | |
| ultimate=round(float(pred_ult), 2), | |
| ibnr=round(max(0, float(pred_ult) - latest_val), 2) | |
| ) | |
| # SHAP on first AY for explainability | |
| explainer = shap_lib.TreeExplainer(model) | |
| shap_vals = explainer.shap_values(X_ml) | |
| shap_mean = {col: round(float(np.mean(np.abs(shap_vals[:, i]))), 6) | |
| for i, col in enumerate(bundle['feature_cols'])} | |
| top_shap = sorted(shap_mean.items(), key=lambda x: x[1], reverse=True)[:5] | |
| except Exception as e: | |
| log.warning(f"[AGENT14] ML unavailable: {e}") | |
| shap_mean, top_shap = {}, [] | |
| # ── Ensemble ────────────────────────────────────────────── | |
| ens = ensemble_ibnr(cl_res, bf_res, cc_res, odp_res['per_ay'], ml_per_ay, lob) | |
| # ── Aggregate totals ────────────────────────────────────── | |
| total_ibnr_ens = sum(v['ibnr'] for v in ens.values()) | |
| total_ibnr_cl = sum(v['ibnr'] for v in cl_res.values()) | |
| total_ibnr_bf = sum(v['ibnr'] for v in bf_res.values()) | |
| total_paid = sum(tri.get(ay, max(d for d in tri.dev_periods if tri.get(ay,d) is not None)) | |
| for ay in tri.accident_years) | |
| total_ep = sum(tri.premiums.values()) if tri.premiums else 0 | |
| # Uncertainty from ODP bootstrap | |
| cv = odp_res['cv'] | |
| p75_tot = round(total_ibnr_ens * (1 + cv * 0.674), 2) | |
| p90_tot = round(total_ibnr_ens * (1 + cv * 1.282), 2) | |
| p99_tot = round(total_ibnr_ens * (1 + cv * 2.326), 2) | |
| result = dict( | |
| run_id = run_id, | |
| lob = lob, | |
| state_code = state, | |
| eval_date = eval_dt, | |
| total_ibnr = round(total_ibnr_ens, 2), | |
| total_ibnr_cl = round(total_ibnr_cl, 2), | |
| total_ibnr_bf = round(total_ibnr_bf, 2), | |
| total_paid = round(total_paid, 2), | |
| total_ep = round(total_ep, 2), | |
| ibnr_cv = cv, | |
| ibnr_p75 = p75_tot, | |
| ibnr_p90 = p90_tot, | |
| ibnr_p99 = p99_tot, | |
| odp_p75 = odp_res['total_ibnr_p75'], | |
| odp_p90 = odp_res['total_ibnr_p90'], | |
| odp_p99 = odp_res['total_ibnr_p99'], | |
| by_accident_year = ens, | |
| shap_summary = shap_mean, | |
| top_drivers = [{'feature': k, 'importance': v} for k, v in top_shap], | |
| accident_years = sorted(tri.accident_years), | |
| ) | |
| # ── Persist to gold ─────────────────────────────────────── | |
| try: | |
| _persist_gold_14(result, eng) | |
| except Exception as e: | |
| log.warning(f"[AGENT14] Gold persist failed: {e}") | |
| return _safe_json(result) | |
| def _synthetic_triangle(lob: str, state: str) -> Triangle: | |
| """Fallback: generate a synthetic triangle when DB has no data.""" | |
| rng = np.random.default_rng(int.from_bytes(lob.encode(), 'big') % 9999) | |
| bench = BENCHMARK_LDFS.get(lob, BENCHMARK_LDFS['HO']) | |
| elr = ELR_PRIOR.get(lob, 0.65) | |
| tri = Triangle(lob, state, 'PAID_LOSS') | |
| for ay in range(2015, 2025): | |
| ep = float(rng.uniform(500000, 3000000)) | |
| ult = ep * elr * float(rng.lognormal(0, 0.10)) | |
| max_dev = min((2024 - ay + 1) * 12, 120) | |
| cdf = 1.0 | |
| for i, dev in enumerate(DEV_MONTHS): | |
| if dev > max_dev: break | |
| for f in bench[i:]: cdf *= f | |
| cum = ult / cdf * float(rng.uniform(0.95, 1.05)) | |
| tri.add_cell(ay, dev, max(1, cum), premium=ep) | |
| cdf = 1.0 | |
| return tri | |
| def _persist_gold_14(result: dict, eng): | |
| """Write IBNR estimates to gold tables.""" | |
| from sqlalchemy import text | |
| run_id = result['run_id'] | |
| lob = result['lob'] | |
| state = result['state_code'] | |
| eval_dt = result['eval_date'] | |
| with eng.begin() as conn: | |
| for ay, ens in result['by_accident_year'].items(): | |
| conn.execute(text(""" | |
| INSERT INTO gold_act_ibnr_estimates ( | |
| run_id, lob, state_code, accident_year, eval_date, | |
| cl_ultimate, bf_ultimate, odp_ultimate, ensemble_ultimate, | |
| ibnr_estimate, ibnr_cv, ibnr_p75, ibnr_p90, ibnr_p99, | |
| paid_to_date, ensemble_weights_json, model_version | |
| ) VALUES ( | |
| :run_id, :lob, :state, :ay, :eval_dt, | |
| :cl_ult, :bf_ult, :odp_ult, :ens_ult, | |
| :ibnr, :cv, :p75, :p90, :p99, | |
| :paid, :weights, '1.0' | |
| ) | |
| """), dict( | |
| run_id=run_id, lob=lob, state=state, ay=ay, eval_dt=eval_dt, | |
| cl_ult=ens.get('cl_ultimate'), | |
| bf_ult=ens.get('bf_ultimate'), | |
| odp_ult=ens.get('odp_ultimate'), | |
| ens_ult=ens.get('ensemble_ultimate'), | |
| ibnr=ens.get('ibnr'), | |
| cv=result['ibnr_cv'], | |
| p75=result['ibnr_p75'] / max(len(result['by_accident_year']), 1), | |
| p90=result['ibnr_p90'] / max(len(result['by_accident_year']), 1), | |
| p99=result['ibnr_p99'] / max(len(result['by_accident_year']), 1), | |
| paid=ens.get('latest_value'), | |
| weights=json.dumps(_safe_json(ens.get('weights', {}))), | |
| )) | |
| # Audit log | |
| conn.execute(text(""" | |
| INSERT INTO gold_act_audit_log ( | |
| run_id, agent_name, agent_version, lob, state_code, | |
| analysis_date, function_performed, | |
| input_summary_json, output_summary_json, | |
| decision_factors_json, assumptions_json, confidence_score | |
| ) VALUES ( | |
| :run_id, 'agent14_ibnr', '1.0', :lob, :state, | |
| :adate, 'IBNR_RESERVE_ESTIMATE', | |
| :inp, :out, :factors, :assump, :conf | |
| ) | |
| """), dict( | |
| run_id=run_id, lob=lob, state=state, | |
| adate=date.today(), | |
| inp=json.dumps(_safe_json({ | |
| 'accident_years': result['accident_years'], | |
| 'total_paid': result['total_paid'], | |
| 'total_ep': result['total_ep'], | |
| })), | |
| out=json.dumps(_safe_json({ | |
| 'total_ibnr': result['total_ibnr'], | |
| 'ibnr_p90': result['ibnr_p90'], | |
| 'ibnr_cv': result['ibnr_cv'], | |
| })), | |
| factors=json.dumps(_safe_json(result.get('top_drivers', []))), | |
| assump=json.dumps({ | |
| 'methods': ['CHAIN_LADDER','BF','CAPE_COD','ODP','XGBOOST'], | |
| 'odp_sims': 2000, | |
| 'tail_factor': 'LOB-specific', | |
| 'elr_priors': ELR_PRIOR, | |
| }), | |
| conf=round(max(0.5, 1 - result['ibnr_cv']), 4), | |
| )) | |
| log.info(f"[AGENT14] Gold persisted — run_id={run_id}") | |
| def run_agent14_batch(payload: dict = None) -> dict: | |
| """ | |
| Run Agent 14 across all LOB/state combinations in silver triangles. | |
| """ | |
| payload = payload or {} | |
| run_id = payload.get('run_id') or f"AG14-BATCH-{uuid.uuid4().hex[:8].upper()}" | |
| log.info(f"[AGENT14-BATCH] Starting run={run_id}") | |
| try: | |
| eng = _get_engine() | |
| with eng.connect() as conn: | |
| combos = pd.read_sql(""" | |
| SELECT DISTINCT lob, state_code | |
| FROM silver_act_dev_triangles | |
| ORDER BY lob, state_code | |
| """, conn) | |
| except Exception as e: | |
| return {'error': str(e), 'run_id': run_id} | |
| if combos.empty: | |
| return {'error': 'No silver triangle data. Run ETL first.', 'run_id': run_id} | |
| results, errors = [], [] | |
| for _, row in combos.iterrows(): | |
| if payload.get('lob_filter') and row['lob'] != payload['lob_filter']: | |
| continue | |
| try: | |
| r = run_agent14({'lob': row['lob'], 'state_code': row['state_code'], 'run_id': run_id}) | |
| results.append(r) | |
| except Exception as e: | |
| errors.append({'lob': row['lob'], 'state': row['state_code'], 'error': str(e)}) | |
| total_ibnr = sum(r.get('total_ibnr', 0) for r in results) | |
| total_p90 = sum(r.get('ibnr_p90', 0) for r in results) | |
| avg_cv = np.mean([r.get('ibnr_cv', 0) for r in results]) if results else 0 | |
| summary = dict( | |
| run_id=run_id, total_combos=len(results), errors=len(errors), | |
| total_ibnr=round(total_ibnr, 2), | |
| total_ibnr_p90=round(total_p90, 2), | |
| avg_cv=round(float(avg_cv), 4), | |
| ) | |
| log.info(f"[AGENT14-BATCH] Complete: {summary}") | |
| return {'run_id': run_id, 'summary': summary, | |
| 'results': _safe_json(results), 'errors': errors} | |