Spaces:
Sleeping
Sleeping
| """ | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| AGENT 4 β ML Pricing Agent (Step 4 of 5) | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| PURPOSE : Calculate final premium using XGBoost + GLM ensemble. | |
| Reads UW approved decision from Agent 3. | |
| Produces premium, confidence interval, and SHAP explanation. | |
| INPUT : silver/uw_decisions/{sub_id}_uw.json | |
| OUTPUT : silver/premium_predictions/{sub_id}_pricing.json | |
| PREMIUM FORMULA (actuarial base): | |
| base_rate = 0.0065 (0.65% of dwelling value) | |
| credit_modifier = 1 + max(0, (720 - credit_score) / 720) Γ 0.35 | |
| risk_modifier = 1 + (overall_risk / 100) Γ 0.80 | |
| age_modifier = 1 + min(property_age / 100, 0.40) | |
| coverage_modifier = per coverage type (HO-3: 1.0, HO-5: 1.15 etc.) | |
| noise = random [0.92, 1.08] | |
| premium = base Γ limit Γ credit_mod Γ risk_mod Γ age_mod Γ cov_mod Γ noise | |
| ML MODEL : XGBoost regressor trained on approved Bronze records. | |
| Ensemble: 60% XGBoost + 40% GLM actuarial formula. | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| """ | |
| import json | |
| import pickle | |
| import datetime | |
| import numpy as np | |
| import pandas as pd | |
| # mysql.connector kept as fallback; primary driver is PyMySQL via SQLAlchemy | |
| import mysql.connector | |
| try: | |
| from sqlalchemy import create_engine, text | |
| from urllib.parse import quote_plus as _qp | |
| SQLALCHEMY_AVAILABLE = True | |
| except ImportError: | |
| SQLALCHEMY_AVAILABLE = False | |
| from pathlib import Path | |
| from sklearn.model_selection import train_test_split | |
| from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score | |
| from sklearn.linear_model import Ridge | |
| import xgboost as xgb | |
| # βββ CONFIG ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # βββ DB CONFIG β supports local MySQL and HuggingFace + Clever Cloud βββββββββ | |
| import os as _os | |
| def _is_huggingface() -> bool: | |
| return ( | |
| _os.environ.get("SPACE_ID") is not None | |
| or _os.environ.get("HUGGINGFACE_SPACE") is not None | |
| or _os.environ.get("MYSQL_ADDON_HOST") is not None | |
| or _os.environ.get("MYSQL_HOST") is not None | |
| ) | |
| def _env(addon_key: str, generic_key: str, default: str = "") -> str: | |
| """Reads MYSQL_ADDON_* first (Clever Cloud), then MYSQL_* (generic), then default.""" | |
| return _os.environ.get(addon_key) or _os.environ.get(generic_key) or default | |
| if _is_huggingface(): | |
| DB = dict( | |
| host = _env("MYSQL_ADDON_HOST", "MYSQL_HOST"), | |
| port = int(_env("MYSQL_ADDON_PORT", "MYSQL_PORT", "3306")), | |
| user = _env("MYSQL_ADDON_USER", "MYSQL_USER"), | |
| password = _env("MYSQL_ADDON_PASSWORD", "MYSQL_PASSWORD"), | |
| database = _env("MYSQL_ADDON_DB", "MYSQL_DATABASE"), | |
| ) | |
| else: | |
| DB = dict(host="localhost", port=3306, user="root", password="root@123", database="bronze") | |
| def T(layer: str, table: str) -> str: | |
| """ | |
| Returns the correct table reference for the active environment. | |
| HuggingFace (single schema): `bronze_submissions` | |
| Local (separate schemas): `bronze`.`submissions` | |
| """ | |
| return f"`{layer}_{table}`" if _is_huggingface() else f"`{layer}`.`{table}`" | |
| MODEL_PATH = Path("models/agent4_pricing.pkl") | |
| SILVER_OUT = Path("silver/premium_predictions") | |
| MODEL_PATH.parent.mkdir(exist_ok=True) | |
| SILVER_OUT.mkdir(parents=True, exist_ok=True) | |
| # ββ Coverage type base rate multipliers ββββββββββββββββββββββββββββββββββββββ | |
| COVERAGE_MODIFIER = { | |
| "HO-3": 1.00, "HO-5": 1.15, "HO-4": 0.35, "HO-6": 0.42, | |
| "DP-3": 0.88, "DP-1": 0.72, "BOP": 1.30, "FARM": 1.45, "WC-3": 0.95, | |
| } | |
| # ββ State load factors (based on historical loss data) ββββββββββββββββββββββ | |
| STATE_LOAD = { | |
| "FL": 1.35, "TX": 1.25, "LA": 1.30, "CA": 1.20, "NC": 1.05, | |
| "SC": 1.08, "GA": 1.02, "AL": 1.10, "MS": 1.15, "AZ": 0.95, | |
| "CO": 1.00, "WA": 0.98, "IL": 0.92, "NY": 1.10, "PA": 0.88, | |
| "KS": 1.05, "NV": 0.90, "OH": 0.88, | |
| } | |
| BASE_RATE = 0.0065 # 0.65% of coverage limit | |
| CONFIDENCE_WIDTH = 0.12 # Β±12% confidence interval | |
| # βββ ACTUARIAL FORMULA βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def actuarial_premium( | |
| coverage_limit: float, | |
| credit_score: float, | |
| overall_risk: float, | |
| year_built: int, | |
| coverage_type: str, | |
| state: str, | |
| deductible: float = 1_000, | |
| add_noise: bool = False, | |
| ) -> float: | |
| """ | |
| Pure actuarial formula β used as GLM component in the ensemble, | |
| and as fallback when the ML model is not yet trained. | |
| """ | |
| # Modifiers | |
| credit_mod = 1.0 + max(0.0, (720.0 - float(credit_score)) / 720.0) * 0.35 | |
| risk_mod = 1.0 + (float(overall_risk) / 100.0) * 0.80 | |
| prop_age = max(0, 2024 - int(year_built)) | |
| age_mod = 1.0 + min(prop_age / 100.0, 0.40) | |
| cov_mod = COVERAGE_MODIFIER.get(coverage_type, 1.0) | |
| state_mod = STATE_LOAD.get(str(state).upper(), 1.0) | |
| # Deductible credit (higher deductible = lower premium) | |
| ded_pct = float(deductible) / max(float(coverage_limit), 1) * 100 | |
| ded_credit = max(0.0, 1.0 - (ded_pct / 100.0) * 0.40) | |
| noise = np.random.uniform(0.93, 1.07) if add_noise else 1.0 | |
| premium = ( | |
| BASE_RATE * float(coverage_limit) | |
| * credit_mod * risk_mod * age_mod * cov_mod * state_mod * ded_credit * noise | |
| ) | |
| return round(max(premium, 300.0), 2) # floor $300 | |
| # βββ FEATURE ENGINEERING βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_pricing_features(df: pd.DataFrame) -> pd.DataFrame: | |
| """ | |
| Full feature set for the XGBoost pricing regressor. | |
| Uses all available Bronze + derived Silver signals. | |
| """ | |
| feats = pd.DataFrame() | |
| n = len(df) | |
| feats["coverage_limit"] = pd.to_numeric(df.get("requested_coverage_limit", pd.Series([300_000]*n)), errors="coerce").fillna(300_000) | |
| feats["deductible"] = pd.to_numeric(df.get("requested_deductible", pd.Series([1_000]*n)), errors="coerce").fillna(1_000) | |
| feats["credit_score"] = pd.to_numeric(df.get("credit_score", pd.Series([680]*n)), errors="coerce").fillna(680) | |
| feats["overall_risk"] = pd.to_numeric(df.get("prop_risk_score", df.get("overall_risk", pd.Series([30]*n))), errors="coerce").fillna(30) | |
| feats["property_age"] = (2024 - pd.to_numeric(df.get("year_built", pd.Series([1990]*n)), errors="coerce").fillna(1990)).clip(0, 150) | |
| feats["roof_age"] = (2024 - pd.to_numeric(df.get("roof_year", pd.Series([2010]*n)), errors="coerce").fillna(2010)).clip(0, 50) | |
| cov = df.get("coverage_type_code", pd.Series(["HO-3"]*n)) | |
| feats["coverage_mod"] = cov.map(COVERAGE_MODIFIER).fillna(1.0) | |
| feats["state_load"] = df.get("state_code", df.get("state", pd.Series(["XX"]*n))).map(STATE_LOAD).fillna(1.0) | |
| sqft = pd.to_numeric(df.get("square_footage", pd.Series([1800]*n)), errors="coerce").fillna(1800).clip(500, 15000) | |
| feats["sqft"] = sqft | |
| feats["limit_per_sqft"] = (feats["coverage_limit"] / sqft).clip(0, 3000) | |
| feats["deductible_pct"] = (feats["deductible"] / feats["coverage_limit"].clip(lower=1) * 100).clip(0, 20) | |
| # Actuarial sub-factors (let model learn interaction weights) | |
| feats["credit_mod"] = 1.0 + (np.maximum(0, 720 - feats["credit_score"]) / 720) * 0.35 | |
| feats["risk_mod"] = 1.0 + (feats["overall_risk"] / 100) * 0.80 | |
| feats["age_mod"] = 1.0 + np.minimum(feats["property_age"] / 100, 0.40) | |
| feats["actuarial_base"] = BASE_RATE * feats["coverage_limit"] * feats["credit_mod"] * feats["risk_mod"] * feats["age_mod"] * feats["coverage_mod"] * feats["state_load"] | |
| return feats | |
| # βββ DATA LOADING ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_bronze_pricing_data() -> pd.DataFrame: | |
| print("Connecting to Bronze MySQL...") | |
| if SQLALCHEMY_AVAILABLE: | |
| _pwd = _qp(DB['password']) | |
| eng = create_engine( | |
| f"mysql+pymysql://{DB['user']}:{_pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4", | |
| pool_pre_ping=True, pool_recycle=280 | |
| ) | |
| conn = eng.connect() | |
| else: | |
| conn = mysql.connector.connect(**DB) | |
| query = f""" | |
| SELECT | |
| s.submission_id, | |
| s.coverage_type_code, | |
| s.requested_coverage_limit, | |
| s.requested_deductible, | |
| s.final_outcome, | |
| s.pipeline_status, | |
| s.raw_payload, | |
| pr.state_code, | |
| pr.property_type, | |
| pr.year_built, | |
| pr.roof_year, | |
| pr.square_footage | |
| FROM {T('bronze','submissions')} s | |
| JOIN {T('bronze','properties')} pr ON s.property_id = pr.property_id | |
| WHERE s.final_outcome = 'APPROVED' | |
| AND s.submitted_at BETWEEN '2024-01-01' AND '2024-12-31 23:59:59' | |
| ORDER BY s.submitted_at | |
| """ | |
| df = pd.read_sql(query, conn) | |
| conn.close() | |
| print(f" Loaded {len(df)} approved Bronze records for pricing") | |
| def parse_pricing(row): | |
| try: | |
| p = json.loads(row["raw_payload"]) | |
| return { | |
| "credit_score": p.get("insured", {}).get("credit_score", 680), | |
| "prop_risk_score": p.get("property", {}).get("prop_risk_score", 30), | |
| "premium": p.get("agent_results", {}).get("premium"), | |
| } | |
| except Exception: | |
| return {"credit_score": 680, "prop_risk_score": 30, "premium": None} | |
| parsed = df.apply(parse_pricing, axis=1, result_type="expand") | |
| df = pd.concat([df.drop(columns=["raw_payload"]), parsed], axis=1) | |
| df = df.dropna(subset=["premium"]) | |
| df["premium"] = pd.to_numeric(df["premium"], errors="coerce") | |
| df = df[df["premium"] > 0] | |
| print(f" Usable records (premium > 0): {len(df)}") | |
| print(f" Premium range: ${df['premium'].min():,.0f} β ${df['premium'].max():,.0f}") | |
| print(f" Avg premium : ${df['premium'].mean():,.0f}") | |
| return df | |
| # βββ TRAINING ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def train_pricing_model(): | |
| print("\n" + "β"*60) | |
| print("AGENT 4 β ML Pricing Model Training") | |
| print("β"*60) | |
| df = load_bronze_pricing_data() | |
| X = extract_pricing_features(df) | |
| y = df["premium"] | |
| FEATURES = X.columns.tolist() | |
| print(f"\nFeatures ({len(FEATURES)}): {FEATURES}") | |
| X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) | |
| # XGBoost regressor | |
| xgb_model = xgb.XGBRegressor( | |
| n_estimators = 400, | |
| max_depth = 5, | |
| learning_rate = 0.04, | |
| subsample = 0.80, | |
| colsample_bytree = 0.75, | |
| min_child_weight = 3, | |
| reg_alpha = 0.05, | |
| reg_lambda = 1.0, | |
| eval_metric = "rmse", | |
| early_stopping_rounds = 25, | |
| random_state = 42, | |
| verbosity = 0, | |
| ) | |
| xgb_model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False) | |
| # Ridge GLM (trained on actuarial_base feature only β captures pure actuarial relationship) | |
| glm_model = Ridge(alpha=1.0) | |
| glm_model.fit(X_train[["actuarial_base"]], y_train) | |
| # Ensemble predictions: 60% XGBoost + 40% GLM | |
| xgb_pred = xgb_model.predict(X_test) | |
| glm_pred = glm_model.predict(X_test[["actuarial_base"]]) | |
| ens_pred = 0.60 * xgb_pred + 0.40 * glm_pred | |
| mae_xgb = mean_absolute_error(y_test, xgb_pred) | |
| mae_ens = mean_absolute_error(y_test, ens_pred) | |
| rmse_ens = np.sqrt(mean_squared_error(y_test, ens_pred)) | |
| r2_ens = r2_score(y_test, ens_pred) | |
| print(f"\n XGBoost MAE : ${mae_xgb:,.0f}") | |
| print(f" Ensemble MAE : ${mae_ens:,.0f}") | |
| print(f" Ensemble RMSE : ${rmse_ens:,.0f}") | |
| print(f" Ensemble RΒ² : {r2_ens:.4f}") | |
| imp = pd.Series(xgb_model.feature_importances_, index=FEATURES).sort_values(ascending=False) | |
| print("\n Top Feature Importances (XGBoost):") | |
| for feat, val in imp.head(8).items(): | |
| print(f" {feat:<30} {val:.4f}") | |
| # Residual std for confidence interval | |
| residuals = y_test - ens_pred | |
| ci_std = float(residuals.std()) | |
| artefact = { | |
| "xgb_model": xgb_model, | |
| "glm_model": glm_model, | |
| "features": FEATURES, | |
| "ensemble_weights": {"xgb": 0.60, "glm": 0.40}, | |
| "ci_std": ci_std, | |
| "metrics": {"MAE": round(mae_ens, 2), "RMSE": round(rmse_ens, 2), "R2": round(r2_ens, 4)}, | |
| "trained_at": datetime.datetime.now().isoformat(), | |
| "version": "1.0", | |
| } | |
| with open(MODEL_PATH, "wb") as f: | |
| pickle.dump(artefact, f) | |
| print(f"\n Model saved β {MODEL_PATH}") | |
| return artefact | |
| # βββ INFERENCE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def run_pricing_agent(uw_decision: dict, property_risk: dict, submission_json: dict) -> dict: | |
| """ | |
| Parameters | |
| ---------- | |
| uw_decision : dict Output from Agent 3 (silver/uw_decisions/) | |
| property_risk : dict Output from Agent 2 (silver/property_risk/) | |
| submission_json : dict Full Bronze JSON payload | |
| Returns | |
| ------- | |
| dict Pricing output written to silver/premium_predictions/ | |
| """ | |
| sub_id = submission_json.get("submission_id", "UNKNOWN") | |
| # Guard: only run if UW approved | |
| if uw_decision.get("status") != "UW_APPROVED": | |
| return {"submission_id": sub_id, "status": "SKIPPED", | |
| "skip_reason": f"{uw_decision.get('status')} β pipeline halted at Step 3"} | |
| insured = submission_json.get("insured", {}) | |
| prop = submission_json.get("property", {}) | |
| policy = submission_json.get("policy_request", {}) | |
| peril = property_risk.get("peril_scores", {}) if property_risk else {} | |
| credit_score = float(insured.get("credit_score", 680) or 680) | |
| overall_risk = float(peril.get("overall_risk", 30) or 30) | |
| coverage_limit = float(policy.get("limit", 300_000) or 300_000) | |
| deductible = float(policy.get("deductible", 1_000) or 1_000) | |
| coverage_type = policy.get("coverage_type", "HO-3") | |
| state = prop.get("state", "XX") | |
| year_built = int(prop.get("year_built", 1990) or 1990) | |
| roof_year = int(prop.get("roof_year", 2010) or 2010) | |
| sqft = float(prop.get("square_footage", 1800) or 1800) | |
| # Actuarial base (always calculated) | |
| act_premium = actuarial_premium( | |
| coverage_limit, credit_score, overall_risk, | |
| year_built, coverage_type, state, deductible | |
| ) | |
| try: | |
| with open(MODEL_PATH, "rb") as f: | |
| art = pickle.load(f) | |
| row = pd.DataFrame([{ | |
| "requested_coverage_limit": coverage_limit, | |
| "requested_deductible": deductible, | |
| "credit_score": credit_score, | |
| "overall_risk": overall_risk, | |
| "prop_risk_score": overall_risk, | |
| "year_built": year_built, | |
| "roof_year": roof_year, | |
| "coverage_type_code": coverage_type, | |
| "state_code": state, | |
| "square_footage": sqft, | |
| }]) | |
| feats = extract_pricing_features(row)[art["features"]] | |
| xgb_pred = float(art["xgb_model"].predict(feats)[0]) | |
| glm_pred = float(art["glm_model"].predict(feats[["actuarial_base"]])[0]) | |
| w_xgb = art["ensemble_weights"]["xgb"] | |
| w_glm = art["ensemble_weights"]["glm"] | |
| ml_premium = w_xgb * xgb_pred + w_glm * glm_pred | |
| final_premium = round(max(ml_premium, 300.0), 2) | |
| ci_std = art["ci_std"] | |
| ci_lo = round(max(final_premium - 1.96 * ci_std, 200.0), 2) | |
| ci_hi = round(final_premium + 1.96 * ci_std, 2) | |
| except FileNotFoundError: | |
| # Model not yet trained β use actuarial formula only | |
| final_premium = act_premium | |
| ci_lo = round(final_premium * 0.88, 2) | |
| ci_hi = round(final_premium * 1.12, 2) | |
| # ββ Premium breakdown (explainability) ββ | |
| credit_mod = 1.0 + max(0.0, (720 - credit_score) / 720) * 0.35 | |
| risk_mod = 1.0 + (overall_risk / 100) * 0.80 | |
| age_mod = 1.0 + min((2024 - year_built) / 100, 0.40) | |
| cov_mod = COVERAGE_MODIFIER.get(coverage_type, 1.0) | |
| state_mod = STATE_LOAD.get(str(state).upper(), 1.0) | |
| output = { | |
| "submission_id": sub_id, | |
| "agent": "ML_Pricing_Agent", | |
| "step": 4, | |
| "status": "PRICED", | |
| "final_premium": final_premium, | |
| "actuarial_premium": act_premium, | |
| "confidence_interval": {"lo_95": ci_lo, "hi_95": ci_hi}, | |
| "premium_breakdown": { | |
| "base_rate": BASE_RATE, | |
| "coverage_limit": coverage_limit, | |
| "credit_modifier": round(credit_mod, 4), | |
| "risk_modifier": round(risk_mod, 4), | |
| "age_modifier": round(age_mod, 4), | |
| "coverage_modifier": round(cov_mod, 4), | |
| "state_load_factor": round(state_mod, 4), | |
| }, | |
| "coverage_type": coverage_type, | |
| "annual_premium": final_premium, | |
| "monthly_premium": round(final_premium / 12, 2), | |
| "processed_at": datetime.datetime.now().isoformat(), | |
| "next_step": "Issuance_Agent", | |
| "s3_output_uri": f"s3://pcins-silver/premium_predictions/{sub_id}_pricing.json", | |
| } | |
| out_file = SILVER_OUT / f"{sub_id}_pricing.json" | |
| with open(out_file, "w") as f: | |
| json.dump(output, f, indent=2) | |
| return output | |
| # βββ MAIN ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| train_pricing_model() | |
| print("\n" + "β"*60) | |
| print("SMOKE TESTS") | |
| print("β"*60) | |
| uw_ok = {"status": "UW_APPROVED"} | |
| prop_ok = {"status": "RISK_ACCEPTABLE", "peril_scores": {"overall_risk": 28}} | |
| tests = [ | |
| { # Low risk, good credit β should be cheap | |
| "submission_id": "SUB-TEST-001", | |
| "insured": {"credit_score": 780}, | |
| "property": {"state": "PA", "year_built": 2010, "roof_year": 2010, "square_footage": 2200}, | |
| "policy_request": {"coverage_type": "HO-3", "limit": 380_000, "deductible": 2_500}, | |
| }, | |
| { # Medium risk, average credit β mid-range premium | |
| "submission_id": "SUB-TEST-002", | |
| "insured": {"credit_score": 650}, | |
| "property": {"state": "TX", "year_built": 1985, "roof_year": 2005, "square_footage": 1800}, | |
| "policy_request": {"coverage_type": "HO-3", "limit": 320_000, "deductible": 1_000}, | |
| }, | |
| { # High value, coastal β expensive | |
| "submission_id": "SUB-TEST-003", | |
| "insured": {"credit_score": 820}, | |
| "property": {"state": "FL", "year_built": 2015, "roof_year": 2015, "square_footage": 4000}, | |
| "policy_request": {"coverage_type": "HO-5", "limit": 1_800_000, "deductible": 10_000}, | |
| }, | |
| ] | |
| for t in tests: | |
| r = run_pricing_agent(uw_ok, prop_ok, t) | |
| print(f" {t['submission_id']} | {t['policy_request']['coverage_type']} " | |
| f"${t['policy_request']['limit']:,.0f} | Credit {t['insured']['credit_score']} " | |
| f"| State {t['property']['state']} " | |
| f"β Premium ${r['final_premium']:,.0f} " | |
| f" CI [${r['confidence_interval']['lo_95']:,.0f}β${r['confidence_interval']['hi_95']:,.0f}]") | |