Spaces:
Sleeping
Sleeping
| """ | |
| RiskRadar — Agent 12: Geo Risk Intelligence | |
| ============================================= | |
| Peril-level property risk scoring at address level. | |
| Scores Wind, Flood, Fire, Earthquake 0-100 per property. | |
| Training: call train_riskradar_model() | |
| Inference: call run_riskradar_agent(payload) ← used by app.py /riskradar/score | |
| OR score_address(payload) ← used by app.py /risk/score | |
| """ | |
| import os, logging | |
| 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, 'agent12_riskradar.pkl') | |
| # ── State-level base peril risk (FEMA/NOAA derived) ────────── | |
| STATE_RISK = { | |
| 'FL': (88, 72, 18, 5), 'TX': (75, 65, 42, 8), | |
| 'LA': (82, 85, 20, 5), 'MS': (78, 70, 22, 5), | |
| 'AL': (72, 62, 25, 5), 'GA': (55, 48, 35, 8), | |
| 'SC': (60, 52, 30, 6), 'NC': (58, 55, 32, 8), | |
| 'VA': (45, 42, 28, 10), 'MD': (42, 45, 22, 8), | |
| 'NJ': (48, 52, 18, 6), 'NY': (45, 48, 20, 8), | |
| 'CT': (44, 46, 18, 6), 'MA': (46, 44, 20, 5), | |
| 'RI': (48, 50, 16, 4), 'ME': (42, 38, 22, 4), | |
| 'NH': (40, 36, 25, 4), 'VT': (38, 40, 24, 5), | |
| 'PA': (38, 42, 22, 8), 'OH': (35, 38, 20, 12), | |
| 'MI': (38, 32, 22, 6), 'IN': (42, 40, 24, 10), | |
| 'IL': (45, 44, 26, 12), 'WI': (40, 35, 24, 6), | |
| 'MN': (42, 38, 28, 5), 'IA': (48, 45, 30, 10), | |
| 'MO': (52, 48, 32, 18), 'KS': (65, 42, 38, 14), | |
| 'NE': (60, 40, 36, 10), 'SD': (55, 35, 32, 8), | |
| 'ND': (50, 32, 28, 6), 'MT': (45, 28, 52, 15), | |
| 'WY': (48, 22, 48, 18), 'CO': (42, 20, 55, 22), | |
| 'NM': (38, 18, 60, 15), 'AZ': (35, 20, 65, 15), | |
| 'UT': (35, 22, 52, 32), 'NV': (32, 15, 60, 18), | |
| 'ID': (38, 25, 55, 20), 'OR': (45, 42, 52, 28), | |
| 'WA': (48, 45, 45, 32), 'CA': (35, 38, 75, 45), | |
| 'AK': (55, 30, 25, 55), 'HI': (72, 68, 35, 35), | |
| 'OK': (68, 45, 42, 14), 'AR': (58, 55, 28, 14), | |
| 'TN': (48, 52, 30, 18), 'KY': (45, 48, 26, 15), | |
| 'WV': (40, 45, 28, 12), 'DE': (44, 50, 18, 6), | |
| 'DC': (38, 42, 20, 8), | |
| } | |
| STATE_BASE_RISK = STATE_RISK # alias for /risk/states route | |
| ZIP_WIND_UPLIFT = {'331','332','333','334','335','336','337','338','700','701','704','775','776','777','778','283','284','285','295','296'} | |
| ZIP_FLOOD_UPLIFT = {'700','701','703','704','331','332','339','775','776','283','284','081','082','083','084','085'} | |
| ZIP_FIRE_UPLIFT = {'900','901','902','903','904','945','946','947','948','949','958','959','960','855','856','857','800','801','802','803','804'} | |
| ZIP_QUAKE_UPLIFT = {'900','901','902','903','940','941','942','943','944','970','971','972','973','974','980','981','982','983','984','995','996','997','998','999'} | |
| FEATURE_COLS = [ | |
| 'state_wind_base','state_flood_base','state_fire_base','state_quake_base', | |
| 'zip_wind_uplift','zip_flood_uplift','zip_fire_uplift','zip_quake_uplift', | |
| 'prop_age','roof_age','is_masonry','is_frame','is_coastal_state', | |
| 'is_fire_state','is_quake_state','stories', | |
| ] | |
| COASTAL_STATES = {'FL','TX','LA','MS','AL','GA','SC','NC','VA','MD','NJ','NY','CT','MA','RI','ME','NH','DE','HI'} | |
| FIRE_STATES = {'CA','OR','WA','CO','AZ','NM','NV','MT','WY','ID','UT'} | |
| QUAKE_STATES = {'CA','OR','WA','AK','UT','NV','HI','MT','WY','CO','ID'} | |
| STATE_CENTROIDS = { | |
| 'FL':(27.8,-81.7),'TX':(31.0,-99.0),'CA':(36.7,-119.4),'NY':(42.9,-75.5), | |
| 'LA':(30.9,-91.8),'IL':(40.0,-89.2),'PA':(40.9,-77.8),'OH':(40.4,-82.7), | |
| 'GA':(32.6,-83.4),'NC':(35.5,-79.4),'MI':(44.3,-85.4),'NJ':(40.0,-74.5), | |
| 'VA':(37.4,-78.7),'WA':(47.4,-120.5),'AZ':(34.2,-111.7),'CO':(39.0,-105.5), | |
| 'TN':(35.8,-86.3),'IN':(39.8,-86.2),'MO':(38.4,-92.3),'MD':(39.0,-76.8), | |
| 'WI':(44.3,-89.8),'MN':(46.4,-93.1),'SC':(33.8,-80.9),'AL':(32.8,-86.8), | |
| 'OR':(44.0,-120.5),'KY':(37.5,-85.3),'OK':(35.5,-97.5),'CT':(41.6,-72.7), | |
| 'UT':(39.3,-111.1),'IA':(42.0,-93.2),'NV':(38.5,-117.1),'AR':(34.8,-92.2), | |
| 'MS':(32.7,-89.7),'KS':(38.5,-98.4),'NM':(34.5,-106.0),'NE':(41.5,-99.9), | |
| 'ID':(44.3,-114.5),'WV':(38.6,-80.6),'HI':(20.7,-157.0),'ME':(45.3,-69.2), | |
| 'NH':(43.7,-71.6),'MT':(46.9,-110.4),'RI':(41.7,-71.5),'DE':(38.9,-75.5), | |
| 'SD':(44.3,-99.4),'ND':(47.5,-100.5),'AK':(64.0,-153.0),'DC':(38.9,-77.0), | |
| 'VT':(44.0,-72.7),'WY':(42.8,-107.5), | |
| } | |
| def _state_base(state): | |
| return STATE_RISK.get(str(state).upper().strip(), (45, 40, 35, 15)) | |
| def _zip_uplifts(zip_code): | |
| p = str(zip_code).strip()[:3] | |
| return ( | |
| 15 if p in ZIP_WIND_UPLIFT else 0, | |
| 18 if p in ZIP_FLOOD_UPLIFT else 0, | |
| 20 if p in ZIP_FIRE_UPLIFT else 0, | |
| 12 if p in ZIP_QUAKE_UPLIFT else 0, | |
| ) | |
| def _build_features(payload): | |
| state = str(payload.get('state_code') or payload.get('state', 'TX')).upper().strip() | |
| zip_code = str(payload.get('zip') or payload.get('zip_code', '75001')) | |
| yr_built = int(payload.get('year_built') or 2000) | |
| roof_yr = int(payload.get('roof_year') or yr_built + 5) | |
| constr = str(payload.get('construction_type', 'Frame')).lower() | |
| stories = int(payload.get('num_stories') or 1) | |
| sb = _state_base(state) | |
| zu = _zip_uplifts(zip_code) | |
| return { | |
| 'state_wind_base': sb[0], 'state_flood_base': sb[1], | |
| 'state_fire_base': sb[2], 'state_quake_base': sb[3], | |
| 'zip_wind_uplift': zu[0], 'zip_flood_uplift': zu[1], | |
| 'zip_fire_uplift': zu[2], 'zip_quake_uplift': zu[3], | |
| 'prop_age': max(0, 2026 - yr_built), | |
| 'roof_age': max(0, 2026 - roof_yr), | |
| 'is_masonry': int('mason' in constr or 'brick' in constr or 'concrete' in constr), | |
| 'is_frame': int('frame' in constr or 'wood' in constr), | |
| 'is_coastal_state': int(state in COASTAL_STATES), | |
| 'is_fire_state': int(state in FIRE_STATES), | |
| 'is_quake_state': int(state in QUAKE_STATES), | |
| 'stories': min(stories, 5), | |
| } | |
| def _rules_score(feats): | |
| def clamp(x): return int(np.clip(round(x), 0, 100)) | |
| return { | |
| 'wind': clamp(feats['state_wind_base'] + feats['zip_wind_uplift'] + feats['prop_age']*0.12 + feats['roof_age']*0.25 - feats['is_masonry']*8), | |
| 'flood': clamp(feats['state_flood_base'] + feats['zip_flood_uplift'] + feats['prop_age']*0.08 - feats['stories']*3), | |
| 'fire': clamp(feats['state_fire_base'] + feats['zip_fire_uplift'] + feats['prop_age']*0.15 + feats['is_frame']*10 - feats['is_masonry']*12), | |
| 'quake': clamp(feats['state_quake_base'] + feats['zip_quake_uplift'] + feats['prop_age']*0.06), | |
| } | |
| def _risk_band(score): | |
| if score >= 75: return 'CRITICAL' | |
| if score >= 55: return 'HIGH' | |
| if score >= 35: return 'MEDIUM' | |
| return 'LOW' | |
| def _lat_lng_from_zip(zip_code, state): | |
| base = STATE_CENTROIDS.get(str(state).upper(), (39.5, -98.4)) | |
| rng = np.random.default_rng(int(str(zip_code).zfill(5)[:5])) | |
| return round(base[0] + rng.uniform(-0.8, 0.8), 5), round(base[1] + rng.uniform(-0.8, 0.8), 5) | |
| def train_riskradar_model(): | |
| 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) | |
| rng = np.random.default_rng(99) | |
| states = list(STATE_RISK.keys()) | |
| rows = [] | |
| for _ in range(5000): | |
| state = rng.choice(states) | |
| zip_code = str(rng.integers(10000, 99999)).zfill(5) | |
| yr_built = int(rng.integers(1930, 2024)) | |
| roof_yr = int(rng.integers(yr_built, min(yr_built+40, 2024))) | |
| constr = rng.choice(['Frame','Masonry','Frame','Frame','Masonry','Concrete']) | |
| stories = int(rng.integers(1, 4)) | |
| feats = _build_features({'state_code':state,'zip':zip_code,'year_built':yr_built, | |
| 'roof_year':roof_yr,'construction_type':constr,'num_stories':stories}) | |
| rs = _rules_score(feats) | |
| for p in ['wind','flood','fire','quake']: | |
| rs[p] = int(np.clip(rs[p] + rng.normal(0, 4), 0, 100)) | |
| rows.append({**feats, **rs}) | |
| df = pd.DataFrame(rows) | |
| X = df[FEATURE_COLS] | |
| models = {} | |
| for peril in ['wind','flood','fire','quake']: | |
| y = df[peril] | |
| X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42) | |
| reg = XGBRegressor(n_estimators=200, max_depth=5, learning_rate=0.07, | |
| subsample=0.85, random_state=42, verbosity=0) | |
| reg.fit(X_tr, y_tr) | |
| log.info(f"[RISKRADAR] {peril.upper()} MAE: {mean_absolute_error(y_te, reg.predict(X_te)):.2f}") | |
| models[peril] = reg | |
| models['feature_cols'] = FEATURE_COLS | |
| joblib.dump(models, MODEL_PATH) | |
| log.info(f"[RISKRADAR] Saved → {MODEL_PATH}") | |
| return models | |
| def run_riskradar_agent(payload: dict) -> dict: | |
| """Primary entry point — called by app.py POST /riskradar/score""" | |
| state = str(payload.get('state_code') or payload.get('state', 'TX')).upper().strip() | |
| zip_code = str(payload.get('zip') or payload.get('zip_code', '75001')) | |
| address = payload.get('address', '') | |
| log.info(f"[RISKRADAR] Scoring {address or zip_code}, {state}") | |
| feats = _build_features(payload) | |
| scores = {} | |
| method = 'rules' | |
| try: | |
| import joblib | |
| models = joblib.load(MODEL_PATH) | |
| X = pd.DataFrame([feats])[models['feature_cols']] | |
| for p in ['wind','flood','fire','quake']: | |
| scores[p] = int(np.clip(round(models[p].predict(X)[0]), 0, 100)) | |
| method = 'xgboost' | |
| except Exception as e: | |
| log.warning(f"[RISKRADAR] ML unavailable ({e}) — rules fallback") | |
| scores = _rules_score(feats) | |
| bands = {p: _risk_band(scores[p]) for p in scores} | |
| overall_band = _risk_band(max(scores.values())) | |
| overall_score = int(np.mean(list(scores.values()))) | |
| lat, lng = _lat_lng_from_zip(zip_code, state) | |
| if payload.get('latitude') and payload.get('longitude'): | |
| lat, lng = float(payload['latitude']), float(payload['longitude']) | |
| elif payload.get('lat') and payload.get('lon'): | |
| lat, lng = float(payload['lat']), float(payload['lon']) | |
| top_peril, top_score = sorted(scores.items(), key=lambda x: x[1], reverse=True)[0] | |
| narrative = ( | |
| f"Property at {address or zip_code + ', ' + state} carries overall {overall_band} risk " | |
| f"(composite score {overall_score}/100). Dominant peril: {top_peril.upper()} " | |
| f"at {top_score}/100 ({bands[top_peril]} band). " | |
| ) + ( | |
| "Requires elevated underwriting scrutiny; surcharge may apply." | |
| if overall_band in ('CRITICAL','HIGH') else | |
| "Risk profile is within standard underwriting appetite." | |
| ) | |
| heatmap = [] | |
| for i in range(8): | |
| angle = i * 45 | |
| dist = np.random.default_rng(i + 42).uniform(0.01, 0.06) | |
| nearby = {p: int(np.clip(scores[p] + np.random.default_rng(i*10+ord(p[0])).integers(-12,12), 0, 100)) for p in scores} | |
| heatmap.append({ | |
| 'lat': round(lat + dist * np.cos(np.radians(angle)), 5), | |
| 'lng': round(lng + dist * np.sin(np.radians(angle)), 5), | |
| **nearby, | |
| 'overall': int(np.mean(list(nearby.values()))), | |
| }) | |
| heatmap.append({'lat': lat, 'lng': lng, **scores, 'overall': overall_score, 'is_subject': True}) | |
| return { | |
| 'address': address, | |
| 'state_code': state, | |
| 'zip_code': zip_code, | |
| 'latitude': lat, | |
| 'longitude': lng, | |
| 'scores': scores, | |
| 'bands': bands, | |
| 'overall_band': overall_band, | |
| 'overall_score': overall_score, | |
| 'narrative': narrative, | |
| 'heatmap_points': heatmap, | |
| 'features_used': feats, | |
| '_method': method, | |
| } | |
| # Alias — used by app.py /risk/score and /risk/batch | |
| def score_address(payload: dict) -> dict: | |
| return run_riskradar_agent(payload) | |