ITNovaML/PCAgentinAI
0
1"""2ClaimSense — Agent 10: Severity & Reserve Agent3=================================================4XGBoost + GLM ensemble for claim severity classification5and initial reserve estimation.6 7Training: call train_severity_model()8Inference: call run_severity_agent(fnol_result, coverage_result, fraud_result, submission)9"""10 11import os, logging12import numpy as np13import pandas as pd14 15log = logging.getLogger(__name__)16MODELS_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'models')17MODEL_PATH = os.path.join(MODELS_DIR, 'agent10_severity.pkl')18 19SEVERITY_BANDS = ['MINOR', 'MODERATE', 'MAJOR', 'CATASTROPHIC']20 21FEATURE_COLS = [22 'estimated_damage_norm', 'coverage_limit_norm', 'amount_ratio',23 'fraud_score', 'days_to_report', 'incident_type_encoded',24 'property_age', 'deductible_norm', 'has_police_report',25 'credit_score_norm', 'net_payable_norm',26]27 28 29def _encode_incident(t: str) -> int:30 return {'FIRE':4,'EARTHQUAKE':4,'FLOOD':4,'STRUCTURAL':3,31 'WIND':3,'HAIL':2,'WATER':2,'THEFT':2,32 'LIABILITY':1,'VANDALISM':1,'OTHER':1}.get(str(t).upper(), 1)33 34 35def _build_features(fnol: dict, coverage: dict, fraud: dict, submission: dict) -> dict:36 norm = fnol.get('normalised_fields', {})37 estimated_dmg = float(norm.get('estimated_damage') or 0)38 coverage_limit= float(coverage.get('applicable_limit') or 300000)39 deductible = float(coverage.get('deductible') or 2500)40 net_payable = float(coverage.get('net_payable_est') or 0)41 fraud_score = int(fraud.get('fraud_score') or 0)42 days_to_report= int(fnol.get('days_to_report') or 0)43 prop = (submission.get('property') or {})44 yr_built = int(prop.get('year_built') or 2000)45 prop_age = max(0, 2026 - yr_built)46 credit_score = float((submission.get('insured') or {}).get('credit_score') or 650)47 has_police = int(bool((norm.get('has_police_report'))))48 49 return {50 'estimated_damage_norm': min(estimated_dmg / 500000, 3.0),51 'coverage_limit_norm': coverage_limit / 1000000,52 'amount_ratio': min(estimated_dmg / max(coverage_limit, 1), 3.0),53 'fraud_score': fraud_score,54 'days_to_report': days_to_report,55 'incident_type_encoded': _encode_incident(fnol.get('incident_type', 'OTHER')),56 'property_age': min(prop_age, 100),57 'deductible_norm': deductible / 50000,58 'has_police_report': has_police,59 'credit_score_norm': credit_score / 850,60 'net_payable_norm': min(net_payable / 500000, 3.0),61 }62 63 64def _generate_synthetic_training(n=2500, seed=7):65 rng = np.random.default_rng(seed)66 rows = []67 68 for _ in range(n):69 coverage_limit = float(rng.choice([150000,250000,350000,500000,750000,1000000]))70 deductible = float(rng.choice([1000,2500,5000,10000]))71 incident_enc = int(rng.integers(1, 5))72 prop_age = int(rng.integers(0, 80))73 fraud_sc = int(rng.integers(0, 100))74 days_rep = int(rng.integers(0, 120))75 credit_sc = int(rng.integers(450, 820))76 77 # Base damage driven by incident severity78 base = {4: 0.65, 3: 0.35, 2: 0.18, 1: 0.08}.get(incident_enc, 0.1)79 estimated_dmg = float(coverage_limit * rng.uniform(base * 0.5, base * 1.5))80 estimated_dmg = min(estimated_dmg, coverage_limit * 1.1)81 net_payable = max(0, min(estimated_dmg, coverage_limit) - deductible)82 83 # Severity band ground truth84 pct = estimated_dmg / coverage_limit85 if pct < 0.10:86 band = 'MINOR'87 elif pct < 0.35:88 band = 'MODERATE'89 elif pct < 0.70:90 band = 'MAJOR'91 else:92 band = 'CATASTROPHIC'93 94 # Final payout (slightly below estimate after adjustment)95 adj_factor = rng.uniform(0.70, 0.95)96 final_payout = round(net_payable * adj_factor, 2)97 98 rows.append({99 'estimated_damage_norm': min(estimated_dmg / 500000, 3.0),100 'coverage_limit_norm': coverage_limit / 1000000,101 'amount_ratio': min(estimated_dmg / coverage_limit, 3.0),102 'fraud_score': fraud_sc,103 'days_to_report': days_rep,104 'incident_type_encoded': incident_enc,105 'property_age': prop_age,106 'deductible_norm': deductible / 50000,107 'has_police_report': int(rng.random() < 0.6),108 'credit_score_norm': credit_sc / 850,109 'net_payable_norm': min(net_payable / 500000, 3.0),110 'severity_band': band,111 'final_payout': final_payout,112 })113 114 return pd.DataFrame(rows)115 116 117def train_severity_model():118 import joblib119 from xgboost import XGBClassifier, XGBRegressor120 from sklearn.model_selection import train_test_split121 from sklearn.preprocessing import LabelEncoder122 from sklearn.metrics import accuracy_score, mean_absolute_error123 124 os.makedirs(MODELS_DIR, exist_ok=True)125 log.info("[SEVERITY] Generating synthetic training data...")126 df = _generate_synthetic_training(n=3000)127 128 le = LabelEncoder()129 le.fit(SEVERITY_BANDS)130 df['severity_encoded'] = le.transform(df['severity_band'])131 132 X = df[FEATURE_COLS]133 yc = df['severity_encoded']134 yr = df['final_payout']135 136 X_tr, X_te, yc_tr, yc_te, yr_tr, yr_te = train_test_split(137 X, yc, yr, test_size=0.2, random_state=42138 )139 140 clf = XGBClassifier(141 n_estimators=200, max_depth=5, learning_rate=0.07,142 subsample=0.85, colsample_bytree=0.85,143 eval_metric='mlogloss', random_state=42, verbosity=0144 )145 clf.fit(X_tr, yc_tr)146 acc = accuracy_score(yc_te, clf.predict(X_te))147 log.info(f"[SEVERITY] Classifier accuracy: {acc:.3f}")148 149 reg = XGBRegressor(150 n_estimators=200, max_depth=5, learning_rate=0.07,151 subsample=0.85, random_state=42, verbosity=0152 )153 reg.fit(X_tr, yr_tr)154 mae = mean_absolute_error(yr_te, reg.predict(X_te))155 log.info(f"[SEVERITY] Reserve regressor MAE: ${mae:,.0f}")156 157 model = {158 'classifier': clf, 'regressor': reg,159 'label_encoder': le, 'feature_cols': FEATURE_COLS,160 'accuracy': acc, 'mae': mae161 }162 joblib.dump(model, MODEL_PATH)163 log.info(f"[SEVERITY] Saved → {MODEL_PATH}")164 return model165 166 167def run_severity_agent(fnol_result: dict, coverage_result: dict,168 fraud_result: dict, submission: dict) -> dict:169 claim_id = fnol_result.get('claim_id', '')170 log.info(f"[SEVERITY] Agent 10 running for {claim_id}")171 172 feats = _build_features(fnol_result, coverage_result, fraud_result, submission)173 174 severity_band = 'MODERATE'175 reserve_estimate = 0.0176 method = 'rules'177 178 try:179 import joblib180 model = joblib.load(MODEL_PATH)181 X = pd.DataFrame([feats])[model['feature_cols']]182 pred = model['classifier'].predict(X)[0]183 severity_band = model['label_encoder'].inverse_transform([pred])[0]184 reserve_estimate = float(np.clip(model['regressor'].predict(X)[0], 0, 5000000))185 method = 'xgboost'186 except Exception as e:187 log.warning(f"[SEVERITY] ML unavailable: {e} — using rules")188 # Rules fallback189 ratio = feats['amount_ratio']190 if ratio < 0.10: severity_band = 'MINOR'191 elif ratio < 0.35: severity_band = 'MODERATE'192 elif ratio < 0.70: severity_band = 'MAJOR'193 else: severity_band = 'CATASTROPHIC'194 195 net = float(coverage_result.get('net_payable_est') or 0)196 reserve_estimate = net * 0.82197 198 # ── Reserve adjustment for fraud ─────────────────────────199 fraud_score = int(fraud_result.get('fraud_score') or 0)200 if fraud_score >= 70:201 reserve_estimate *= 0.5 # Hold lower reserve pending SIU202 reserve_note = f"Reserve reduced by 50% — high fraud score ({fraud_score})"203 elif fraud_score >= 40:204 reserve_estimate *= 0.75205 reserve_note = f"Reserve reduced by 25% — moderate fraud score ({fraud_score})"206 else:207 reserve_note = "Full reserve applied"208 209 reserve_estimate = round(reserve_estimate, 2)210 211 # ── Severity summary ──────────────────────────────────────212 severity_desc = {213 'MINOR': 'Low-value claim, likely straightforward settlement',214 'MODERATE': 'Standard claim requiring adjuster review',215 'MAJOR': 'Significant loss requiring senior adjuster and inspection',216 'CATASTROPHIC': 'Total or near-total loss — executive escalation required',217 }218 219 result = {220 'claim_id': claim_id,221 'status': 'SEVERITY_SCORED',222 'severity_band': severity_band,223 'severity_desc': severity_desc.get(severity_band, ''),224 'reserve_estimate': reserve_estimate,225 'reserve_note': reserve_note,226 '_method': method,227 }228 229 log.info(230 f"[SEVERITY] {claim_id}: band={severity_band} "231 f"reserve=${reserve_estimate:,.0f} method={method}"232 )233 return result234 