PCAgentinAI / agent12_riskradar.py
ITNovaML's picture
Radar Deploy
fdf97ea
Raw
History Blame Contribute Delete
11.9 kB
"""
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)