CoolFace
Apppublic

ITNovaML/PCAgentinAI

sourceHugging Faceupdated 5mo agoView on Hugging Face
0likes
agent4_pricing.py452 linesDownload Raw Back to root
1"""2══════════════════════════════════════════════════════════════════════════════3AGENT 4 — ML Pricing Agent  (Step 4 of 5)4══════════════════════════════════════════════════════════════════════════════5PURPOSE  : Calculate final premium using XGBoost + GLM ensemble.6           Reads UW approved decision from Agent 3.7           Produces premium, confidence interval, and SHAP explanation.8 9INPUT    : silver/uw_decisions/{sub_id}_uw.json10OUTPUT   : silver/premium_predictions/{sub_id}_pricing.json11 12PREMIUM FORMULA (actuarial base):13    base_rate            = 0.0065   (0.65% of dwelling value)14    credit_modifier      = 1 + max(0, (720 - credit_score) / 720) × 0.3515    risk_modifier        = 1 + (overall_risk / 100) × 0.8016    age_modifier         = 1 + min(property_age / 100, 0.40)17    coverage_modifier    = per coverage type (HO-3: 1.0, HO-5: 1.15 etc.)18    noise                = random [0.92, 1.08]19    premium = base × limit × credit_mod × risk_mod × age_mod × cov_mod × noise20 21ML MODEL  : XGBoost regressor trained on approved Bronze records.22            Ensemble: 60% XGBoost + 40% GLM actuarial formula.23══════════════════════════════════════════════════════════════════════════════24"""25 26import json27import pickle28import datetime29import numpy as np30import pandas as pd31# mysql.connector kept as fallback; primary driver is PyMySQL via SQLAlchemy32import mysql.connector33try:34    from sqlalchemy import create_engine, text35    from urllib.parse import quote_plus as _qp36    SQLALCHEMY_AVAILABLE = True37except ImportError:38    SQLALCHEMY_AVAILABLE = False39from pathlib import Path40from sklearn.model_selection import train_test_split41from sklearn.metrics import mean_absolute_error, mean_squared_error, r2_score42from sklearn.linear_model import Ridge43import xgboost as xgb44 45# ─── CONFIG ──────────────────────────────────────────────────────────────────46# ─── DB CONFIG — supports local MySQL and HuggingFace + Clever Cloud ─────────47import os as _os48 49def _is_huggingface() -> bool:50    return (51        _os.environ.get("SPACE_ID")            is not None52        or _os.environ.get("HUGGINGFACE_SPACE") is not None53        or _os.environ.get("MYSQL_ADDON_HOST")  is not None54        or _os.environ.get("MYSQL_HOST")        is not None55    )56 57def _env(addon_key: str, generic_key: str, default: str = "") -> str:58    """Reads MYSQL_ADDON_* first (Clever Cloud), then MYSQL_* (generic), then default."""59    return _os.environ.get(addon_key) or _os.environ.get(generic_key) or default60 61if _is_huggingface():62    DB = dict(63        host     = _env("MYSQL_ADDON_HOST",     "MYSQL_HOST"),64        port     = int(_env("MYSQL_ADDON_PORT", "MYSQL_PORT", "3306")),65        user     = _env("MYSQL_ADDON_USER",     "MYSQL_USER"),66        password = _env("MYSQL_ADDON_PASSWORD", "MYSQL_PASSWORD"),67        database = _env("MYSQL_ADDON_DB",       "MYSQL_DATABASE"),68    )69else:70    DB = dict(host="localhost", port=3306, user="root", password="root@123", database="bronze")71 72def T(layer: str, table: str) -> str:73    """74    Returns the correct table reference for the active environment.75    HuggingFace (single schema):  `bronze_submissions`76    Local (separate schemas):     `bronze`.`submissions`77    """78    return f"`{layer}_{table}`" if _is_huggingface() else f"`{layer}`.`{table}`"79MODEL_PATH  = Path("models/agent4_pricing.pkl")80SILVER_OUT  = Path("silver/premium_predictions")81MODEL_PATH.parent.mkdir(exist_ok=True)82SILVER_OUT.mkdir(parents=True, exist_ok=True)83 84# ── Coverage type base rate multipliers ──────────────────────────────────────85COVERAGE_MODIFIER = {86    "HO-3": 1.00, "HO-5": 1.15, "HO-4": 0.35, "HO-6": 0.42,87    "DP-3": 0.88, "DP-1": 0.72, "BOP":  1.30, "FARM": 1.45, "WC-3": 0.95,88}89 90# ── State load factors (based on historical loss data) ──────────────────────91STATE_LOAD = {92    "FL": 1.35, "TX": 1.25, "LA": 1.30, "CA": 1.20, "NC": 1.05,93    "SC": 1.08, "GA": 1.02, "AL": 1.10, "MS": 1.15, "AZ": 0.95,94    "CO": 1.00, "WA": 0.98, "IL": 0.92, "NY": 1.10, "PA": 0.88,95    "KS": 1.05, "NV": 0.90, "OH": 0.88,96}97 98BASE_RATE        = 0.0065   # 0.65% of coverage limit99CONFIDENCE_WIDTH = 0.12     # ±12% confidence interval100 101# ─── ACTUARIAL FORMULA ───────────────────────────────────────────────────────102def actuarial_premium(103    coverage_limit: float,104    credit_score:   float,105    overall_risk:   float,106    year_built:     int,107    coverage_type:  str,108    state:          str,109    deductible:     float = 1_000,110    add_noise:      bool  = False,111) -> float:112    """113    Pure actuarial formula — used as GLM component in the ensemble,114    and as fallback when the ML model is not yet trained.115    """116    # Modifiers117    credit_mod   = 1.0 + max(0.0, (720.0 - float(credit_score)) / 720.0) * 0.35118    risk_mod     = 1.0 + (float(overall_risk) / 100.0) * 0.80119    prop_age     = max(0, 2024 - int(year_built))120    age_mod      = 1.0 + min(prop_age / 100.0, 0.40)121    cov_mod      = COVERAGE_MODIFIER.get(coverage_type, 1.0)122    state_mod    = STATE_LOAD.get(str(state).upper(), 1.0)123 124    # Deductible credit (higher deductible = lower premium)125    ded_pct      = float(deductible) / max(float(coverage_limit), 1) * 100126    ded_credit   = max(0.0, 1.0 - (ded_pct / 100.0) * 0.40)127 128    noise = np.random.uniform(0.93, 1.07) if add_noise else 1.0129 130    premium = (131        BASE_RATE * float(coverage_limit)132        * credit_mod * risk_mod * age_mod * cov_mod * state_mod * ded_credit * noise133    )134    return round(max(premium, 300.0), 2)   # floor $300135 136# ─── FEATURE ENGINEERING ─────────────────────────────────────────────────────137def extract_pricing_features(df: pd.DataFrame) -> pd.DataFrame:138    """139    Full feature set for the XGBoost pricing regressor.140    Uses all available Bronze + derived Silver signals.141    """142    feats = pd.DataFrame()143    n     = len(df)144 145    feats["coverage_limit"]  = pd.to_numeric(df.get("requested_coverage_limit", pd.Series([300_000]*n)), errors="coerce").fillna(300_000)146    feats["deductible"]      = pd.to_numeric(df.get("requested_deductible", pd.Series([1_000]*n)), errors="coerce").fillna(1_000)147    feats["credit_score"]    = pd.to_numeric(df.get("credit_score", pd.Series([680]*n)), errors="coerce").fillna(680)148    feats["overall_risk"]    = pd.to_numeric(df.get("prop_risk_score", df.get("overall_risk", pd.Series([30]*n))), errors="coerce").fillna(30)149    feats["property_age"]    = (2024 - pd.to_numeric(df.get("year_built", pd.Series([1990]*n)), errors="coerce").fillna(1990)).clip(0, 150)150    feats["roof_age"]        = (2024 - pd.to_numeric(df.get("roof_year",  pd.Series([2010]*n)), errors="coerce").fillna(2010)).clip(0, 50)151 152    cov = df.get("coverage_type_code", pd.Series(["HO-3"]*n))153    feats["coverage_mod"]    = cov.map(COVERAGE_MODIFIER).fillna(1.0)154    feats["state_load"]      = df.get("state_code", df.get("state", pd.Series(["XX"]*n))).map(STATE_LOAD).fillna(1.0)155 156    sqft = pd.to_numeric(df.get("square_footage", pd.Series([1800]*n)), errors="coerce").fillna(1800).clip(500, 15000)157    feats["sqft"]            = sqft158    feats["limit_per_sqft"]  = (feats["coverage_limit"] / sqft).clip(0, 3000)159    feats["deductible_pct"]  = (feats["deductible"] / feats["coverage_limit"].clip(lower=1) * 100).clip(0, 20)160 161    # Actuarial sub-factors (let model learn interaction weights)162    feats["credit_mod"]      = 1.0 + (np.maximum(0, 720 - feats["credit_score"]) / 720) * 0.35163    feats["risk_mod"]        = 1.0 + (feats["overall_risk"] / 100) * 0.80164    feats["age_mod"]         = 1.0 + np.minimum(feats["property_age"] / 100, 0.40)165    feats["actuarial_base"]  = BASE_RATE * feats["coverage_limit"] * feats["credit_mod"] * feats["risk_mod"] * feats["age_mod"] * feats["coverage_mod"] * feats["state_load"]166 167    return feats168 169# ─── DATA LOADING ────────────────────────────────────────────────────────────170def load_bronze_pricing_data() -> pd.DataFrame:171    print("Connecting to Bronze MySQL...")172    if SQLALCHEMY_AVAILABLE:173        _pwd = _qp(DB['password'])174        eng  = create_engine(175            f"mysql+pymysql://{DB['user']}:{_pwd}@{DB['host']}:{DB['port']}/{DB['database']}?charset=utf8mb4",176            pool_pre_ping=True, pool_recycle=280177        )178        conn = eng.connect()179    else:180        conn = mysql.connector.connect(**DB)181    query = f"""182        183        SELECT184            s.submission_id,185            s.coverage_type_code,186            s.requested_coverage_limit,187            s.requested_deductible,188            s.final_outcome,189            s.pipeline_status,190            s.raw_payload,191            pr.state_code,192            pr.property_type,193            pr.year_built,194            pr.roof_year,195            pr.square_footage196        FROM {T('bronze','submissions')}  s197        JOIN {T('bronze','properties')}   pr ON s.property_id = pr.property_id198        WHERE s.final_outcome = 'APPROVED'199          AND s.submitted_at  BETWEEN '2024-01-01' AND '2024-12-31 23:59:59'200        ORDER BY s.submitted_at201    """202    df = pd.read_sql(query, conn)203    conn.close()204    print(f"  Loaded {len(df)} approved Bronze records for pricing")205 206    def parse_pricing(row):207        try:208            p = json.loads(row["raw_payload"])209            return {210                "credit_score":  p.get("insured", {}).get("credit_score", 680),211                "prop_risk_score": p.get("property", {}).get("prop_risk_score", 30),212                "premium":       p.get("agent_results", {}).get("premium"),213            }214        except Exception:215            return {"credit_score": 680, "prop_risk_score": 30, "premium": None}216 217    parsed = df.apply(parse_pricing, axis=1, result_type="expand")218    df     = pd.concat([df.drop(columns=["raw_payload"]), parsed], axis=1)219    df     = df.dropna(subset=["premium"])220    df["premium"] = pd.to_numeric(df["premium"], errors="coerce")221    df     = df[df["premium"] > 0]222    print(f"  Usable records (premium > 0): {len(df)}")223    print(f"  Premium range: ${df['premium'].min():,.0f} – ${df['premium'].max():,.0f}")224    print(f"  Avg premium  : ${df['premium'].mean():,.0f}")225    return df226 227# ─── TRAINING ────────────────────────────────────────────────────────────────228def train_pricing_model():229    print("\n" + "═"*60)230    print("AGENT 4 — ML Pricing Model Training")231    print("═"*60)232 233    df    = load_bronze_pricing_data()234    X     = extract_pricing_features(df)235    y     = df["premium"]236 237    FEATURES = X.columns.tolist()238    print(f"\nFeatures ({len(FEATURES)}): {FEATURES}")239 240    X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)241 242    # XGBoost regressor243    xgb_model = xgb.XGBRegressor(244        n_estimators          = 400,245        max_depth             = 5,246        learning_rate         = 0.04,247        subsample             = 0.80,248        colsample_bytree      = 0.75,249        min_child_weight      = 3,250        reg_alpha             = 0.05,251        reg_lambda            = 1.0,252        eval_metric           = "rmse",253        early_stopping_rounds = 25,254        random_state          = 42,255        verbosity             = 0,256    )257    xgb_model.fit(X_train, y_train, eval_set=[(X_test, y_test)], verbose=False)258 259    # Ridge GLM (trained on actuarial_base feature only — captures pure actuarial relationship)260    glm_model = Ridge(alpha=1.0)261    glm_model.fit(X_train[["actuarial_base"]], y_train)262 263    # Ensemble predictions: 60% XGBoost + 40% GLM264    xgb_pred  = xgb_model.predict(X_test)265    glm_pred  = glm_model.predict(X_test[["actuarial_base"]])266    ens_pred  = 0.60 * xgb_pred + 0.40 * glm_pred267 268    mae_xgb   = mean_absolute_error(y_test, xgb_pred)269    mae_ens   = mean_absolute_error(y_test, ens_pred)270    rmse_ens  = np.sqrt(mean_squared_error(y_test, ens_pred))271    r2_ens    = r2_score(y_test, ens_pred)272 273    print(f"\n  XGBoost MAE      : ${mae_xgb:,.0f}")274    print(f"  Ensemble MAE     : ${mae_ens:,.0f}")275    print(f"  Ensemble RMSE    : ${rmse_ens:,.0f}")276    print(f"  Ensemble R²      : {r2_ens:.4f}")277 278    imp = pd.Series(xgb_model.feature_importances_, index=FEATURES).sort_values(ascending=False)279    print("\n  Top Feature Importances (XGBoost):")280    for feat, val in imp.head(8).items():281        print(f"    {feat:<30} {val:.4f}")282 283    # Residual std for confidence interval284    residuals = y_test - ens_pred285    ci_std    = float(residuals.std())286 287    artefact = {288        "xgb_model":   xgb_model,289        "glm_model":   glm_model,290        "features":    FEATURES,291        "ensemble_weights": {"xgb": 0.60, "glm": 0.40},292        "ci_std":      ci_std,293        "metrics":     {"MAE": round(mae_ens, 2), "RMSE": round(rmse_ens, 2), "R2": round(r2_ens, 4)},294        "trained_at":  datetime.datetime.now().isoformat(),295        "version":     "1.0",296    }297    with open(MODEL_PATH, "wb") as f:298        pickle.dump(artefact, f)299    print(f"\n  Model saved → {MODEL_PATH}")300    return artefact301 302# ─── INFERENCE ───────────────────────────────────────────────────────────────303def run_pricing_agent(uw_decision: dict, property_risk: dict, submission_json: dict) -> dict:304    """305    Parameters306    ----------307    uw_decision     : dict  Output from Agent 3 (silver/uw_decisions/)308    property_risk   : dict  Output from Agent 2 (silver/property_risk/)309    submission_json : dict  Full Bronze JSON payload310 311    Returns312    -------313    dict  Pricing output written to silver/premium_predictions/314    """315    sub_id = submission_json.get("submission_id", "UNKNOWN")316 317    # Guard: only run if UW approved318    if uw_decision.get("status") != "UW_APPROVED":319        return {"submission_id": sub_id, "status": "SKIPPED",320                "skip_reason": f"{uw_decision.get('status')} — pipeline halted at Step 3"}321 322    insured = submission_json.get("insured", {})323    prop    = submission_json.get("property", {})324    policy  = submission_json.get("policy_request", {})325    peril   = property_risk.get("peril_scores", {}) if property_risk else {}326 327    credit_score   = float(insured.get("credit_score", 680) or 680)328    overall_risk   = float(peril.get("overall_risk", 30) or 30)329    coverage_limit = float(policy.get("limit", 300_000) or 300_000)330    deductible     = float(policy.get("deductible", 1_000) or 1_000)331    coverage_type  = policy.get("coverage_type", "HO-3")332    state          = prop.get("state", "XX")333    year_built     = int(prop.get("year_built", 1990) or 1990)334    roof_year      = int(prop.get("roof_year",  2010) or 2010)335    sqft           = float(prop.get("square_footage", 1800) or 1800)336 337    # Actuarial base (always calculated)338    act_premium = actuarial_premium(339        coverage_limit, credit_score, overall_risk,340        year_built, coverage_type, state, deductible341    )342 343    try:344        with open(MODEL_PATH, "rb") as f:345            art = pickle.load(f)346 347        row = pd.DataFrame([{348            "requested_coverage_limit": coverage_limit,349            "requested_deductible":     deductible,350            "credit_score":             credit_score,351            "overall_risk":             overall_risk,352            "prop_risk_score":          overall_risk,353            "year_built":               year_built,354            "roof_year":                roof_year,355            "coverage_type_code":       coverage_type,356            "state_code":               state,357            "square_footage":           sqft,358        }])359        feats    = extract_pricing_features(row)[art["features"]]360        xgb_pred = float(art["xgb_model"].predict(feats)[0])361        glm_pred = float(art["glm_model"].predict(feats[["actuarial_base"]])[0])362        w_xgb    = art["ensemble_weights"]["xgb"]363        w_glm    = art["ensemble_weights"]["glm"]364        ml_premium   = w_xgb * xgb_pred + w_glm * glm_pred365        final_premium = round(max(ml_premium, 300.0), 2)366 367        ci_std     = art["ci_std"]368        ci_lo      = round(max(final_premium - 1.96 * ci_std, 200.0), 2)369        ci_hi      = round(final_premium + 1.96 * ci_std, 2)370 371    except FileNotFoundError:372        # Model not yet trained — use actuarial formula only373        final_premium = act_premium374        ci_lo = round(final_premium * 0.88, 2)375        ci_hi = round(final_premium * 1.12, 2)376 377    # ── Premium breakdown (explainability) ──378    credit_mod = 1.0 + max(0.0, (720 - credit_score) / 720) * 0.35379    risk_mod   = 1.0 + (overall_risk / 100) * 0.80380    age_mod    = 1.0 + min((2024 - year_built) / 100, 0.40)381    cov_mod    = COVERAGE_MODIFIER.get(coverage_type, 1.0)382    state_mod  = STATE_LOAD.get(str(state).upper(), 1.0)383 384    output = {385        "submission_id":       sub_id,386        "agent":               "ML_Pricing_Agent",387        "step":                4,388        "status":              "PRICED",389        "final_premium":       final_premium,390        "actuarial_premium":   act_premium,391        "confidence_interval": {"lo_95": ci_lo, "hi_95": ci_hi},392        "premium_breakdown": {393            "base_rate":         BASE_RATE,394            "coverage_limit":    coverage_limit,395            "credit_modifier":   round(credit_mod, 4),396            "risk_modifier":     round(risk_mod, 4),397            "age_modifier":      round(age_mod, 4),398            "coverage_modifier": round(cov_mod, 4),399            "state_load_factor": round(state_mod, 4),400        },401        "coverage_type":       coverage_type,402        "annual_premium":      final_premium,403        "monthly_premium":     round(final_premium / 12, 2),404        "processed_at":        datetime.datetime.now().isoformat(),405        "next_step":           "Issuance_Agent",406        "s3_output_uri":       f"s3://pcins-silver/premium_predictions/{sub_id}_pricing.json",407    }408 409    out_file = SILVER_OUT / f"{sub_id}_pricing.json"410    with open(out_file, "w") as f:411        json.dump(output, f, indent=2)412    return output413 414# ─── MAIN ────────────────────────────────────────────────────────────────────415if __name__ == "__main__":416    train_pricing_model()417 418    print("\n" + "─"*60)419    print("SMOKE TESTS")420    print("─"*60)421 422    uw_ok   = {"status": "UW_APPROVED"}423    prop_ok = {"status": "RISK_ACCEPTABLE", "peril_scores": {"overall_risk": 28}}424 425    tests = [426        {   # Low risk, good credit → should be cheap427            "submission_id": "SUB-TEST-001",428            "insured":  {"credit_score": 780},429            "property": {"state": "PA", "year_built": 2010, "roof_year": 2010, "square_footage": 2200},430            "policy_request": {"coverage_type": "HO-3", "limit": 380_000, "deductible": 2_500},431        },432        {   # Medium risk, average credit → mid-range premium433            "submission_id": "SUB-TEST-002",434            "insured":  {"credit_score": 650},435            "property": {"state": "TX", "year_built": 1985, "roof_year": 2005, "square_footage": 1800},436            "policy_request": {"coverage_type": "HO-3", "limit": 320_000, "deductible": 1_000},437        },438        {   # High value, coastal → expensive439            "submission_id": "SUB-TEST-003",440            "insured":  {"credit_score": 820},441            "property": {"state": "FL", "year_built": 2015, "roof_year": 2015, "square_footage": 4000},442            "policy_request": {"coverage_type": "HO-5", "limit": 1_800_000, "deductible": 10_000},443        },444    ]445    for t in tests:446        r = run_pricing_agent(uw_ok, prop_ok, t)447        print(f"  {t['submission_id']} | {t['policy_request']['coverage_type']} "448              f"${t['policy_request']['limit']:,.0f} | Credit {t['insured']['credit_score']} "449              f"| State {t['property']['state']} "450              f"→ Premium ${r['final_premium']:,.0f}  "451              f"  CI [${r['confidence_interval']['lo_95']:,.0f}–${r['confidence_interval']['hi_95']:,.0f}]")452