import streamlit as st
import pandas as pd
import numpy as np
import joblib
import os
import plotly.express as px
import plotly.graph_objects as go
# ─────────────────────────────────────────────
# PAGE CONFIG
# ─────────────────────────────────────────────
st.set_page_config(
page_title="GeoHazard ML",
page_icon="🌍",
layout="wide",
initial_sidebar_state="collapsed",
)
# ─────────────────────────────────────────────
# CUSTOM CSS
# ─────────────────────────────────────────────
st.markdown("""
""", unsafe_allow_html=True)
# ─────────────────────────────────────────────
# MODEL LOADING
# ─────────────────────────────────────────────
@st.cache_resource(show_spinner=False)
def load_models():
base = os.path.dirname(os.path.abspath(__file__))
model_dir = os.path.join(base, "models")
rf = joblib.load(os.path.join(model_dir, "rf_model.pkl"))
xgb = joblib.load(os.path.join(model_dir, "xgb_model.pkl"))
scaler = joblib.load(os.path.join(model_dir, "scaler.pkl"))
le = joblib.load(os.path.join(model_dir, "label_encoder.pkl"))
feat_cols = joblib.load(os.path.join(model_dir, "feature_columns.pkl"))
return rf, xgb, scaler, le, feat_cols
@st.cache_data(show_spinner=False)
def load_map_data():
base = os.path.dirname(os.path.abspath(__file__))
path = os.path.join(base, "data", "processed", "featured_dataset.csv")
df = pd.read_csv(path)
return df
# ─────────────────────────────────────────────
# PREDICTION ENGINE
# ─────────────────────────────────────────────
RISK_ORDER = ["Very Low", "Low", "Medium", "High", "Very High"]
RISK_COLORS = {
"Very Low": "#1565c0",
"Low": "#2e7d32",
"Medium": "#f57f17",
"High": "#e65100",
"Very High": "#b71c1c",
}
BADGE_CLASS = {
"Very Low": "badge-very-low",
"Low": "badge-low",
"Medium": "badge-medium",
"High": "badge-high",
"Very High": "badge-very-high",
}
def compute_derived(eq_count, eq_mag_mean, eq_depth_mean):
freq_score = np.log1p(eq_count)
energy_proxy = 10 ** (1.5 * eq_mag_mean) if eq_mag_mean > 0 else 1.0
mag_depth_ratio = eq_mag_mean / (eq_depth_mean + 1)
return freq_score, energy_proxy, mag_depth_ratio
def depth_cat(d):
if d == 0: return 0
if d < 70: return 1
if d <= 300: return 2
return 3
def predict(model_name, rf, xgb, scaler, le, feat_cols,
lat, lon, eq_count, eq_mag_mean, eq_depth_mean,
has_seismic, continent):
freq_score, energy_proxy, mag_depth_ratio = compute_derived(eq_count, eq_mag_mean, eq_depth_mean)
lat_abs = abs(lat)
lat_seismic_zone = 1 if lat_abs <= 60 else 0
seismic_intensity = energy_proxy * freq_score
weighted_mag = eq_mag_mean / (eq_depth_mean + 1)
dep_cat = depth_cat(eq_depth_mean)
row = {
"eq_count": eq_count,
"eq_mag_mean": eq_mag_mean,
"eq_depth_mean": eq_depth_mean,
"has_seismic_activity": int(has_seismic),
"eq_frequency_score": freq_score,
"seismic_energy_proxy": energy_proxy,
"mag_depth_ratio": mag_depth_ratio,
"depth_category": dep_cat,
"lat_abs": lat_abs,
"lat_seismic_zone": lat_seismic_zone,
"seismic_intensity": seismic_intensity,
"weighted_mag": weighted_mag,
"neighbor_eq_count": freq_score, # proxy: same as freq_score for live input
"continent_Asia": 1 if continent == "Asia" else 0,
"continent_Europe": 1 if continent == "Europe" else 0,
"continent_North America": 1 if continent == "North America" else 0,
"continent_Oceania": 1 if continent == "Oceania" else 0,
"continent_South America": 1 if continent == "South America" else 0,
}
df = pd.DataFrame([row])
df = df.reindex(columns=feat_cols, fill_value=0)
X = scaler.transform(df)
if model_name == "Random Forest":
model = rf
pred = model.predict(X)[0]
proba = model.predict_proba(X)[0]
classes = model.classes_
else:
model = xgb
pred_int = model.predict(X)[0]
pred = le.inverse_transform([int(pred_int)])[0]
proba = model.predict_proba(X)[0]
classes = le.classes_
prob_dict = {c: float(p) for c, p in zip(classes, proba)}
return pred, prob_dict
# ─────────────────────────────────────────────
# HERO
# ─────────────────────────────────────────────
st.markdown("""
🌍 GeoHazard ML
Geological Hazard Risk Classification · Powered by XGBoost & Random Forest · 166,293 USGS Events · 14,613 Grid Cells
""", unsafe_allow_html=True)
# ─────────────────────────────────────────────
# TABS
# ─────────────────────────────────────────────
tab1, tab2, tab3 = st.tabs(["🗺️ Global Risk Map", "🔮 Predict Risk", "📊 Model Performance"])
# ════════════════════════════════════════════
# TAB 1 — GLOBAL RISK MAP
# ════════════════════════════════════════════
with tab1:
st.markdown('Global INFORM Risk Distribution
', unsafe_allow_html=True)
st.caption("Each point is a 1°×1° grid cell coloured by INFORM Risk Label. Hover to see details.")
with st.spinner("Loading map data..."):
try:
df_map = load_map_data()
color_map = {
"Very Low": "#1565c0",
"Low": "#2e7d32",
"Medium": "#f57f17",
"High": "#e65100",
"Very High": "#b71c1c",
}
cat_order = ["Very Low", "Low", "Medium", "High", "Very High"]
fig_map = px.scatter_geo(
df_map,
lat="lat_grid", lon="lon_grid",
color="risk_label",
color_discrete_map=color_map,
category_orders={"risk_label": cat_order},
hover_data={
"lat_grid": ":.1f",
"lon_grid": ":.1f",
"eq_count": True,
"eq_mag_mean": ":.2f",
"risk_label": True,
},
labels={
"lat_grid": "Latitude",
"lon_grid": "Longitude",
"eq_count": "Earthquake Count",
"eq_mag_mean": "Mean Magnitude",
"risk_label": "Risk Level",
},
title="",
projection="natural earth",
)
fig_map.update_traces(marker=dict(size=3, opacity=0.75))
fig_map.update_layout(
paper_bgcolor="#0e1117",
plot_bgcolor="#0e1117",
geo=dict(
bgcolor="#0e1117",
showland=True, landcolor="#1a2035",
showocean=True, oceancolor="#0d1b2a",
showcoastlines=True, coastlinecolor="#2a3a5c",
showframe=False,
),
legend=dict(
title="Risk Level",
bgcolor="#1a2035",
bordercolor="#2a3a5c",
font=dict(color="#e0e0e0"),
),
margin=dict(l=0, r=0, t=10, b=0),
height=520,
)
st.plotly_chart(fig_map, use_container_width=True)
# Quick stats row
c1, c2, c3, c4, c5 = st.columns(5)
counts = df_map["risk_label"].value_counts()
for col, label in zip([c1, c2, c3, c4, c5], cat_order):
n = counts.get(label, 0)
col.markdown(f"""
""", unsafe_allow_html=True)
except Exception as e:
st.error(f"Could not load map data: {e}")
st.info("Make sure `data/processed/featured_dataset.csv` is in your repo.")
# ════════════════════════════════════════════
# TAB 2 — PREDICT RISK
# ════════════════════════════════════════════
with tab2:
# Load models
model_load_error = None
try:
rf_model, xgb_model, scaler, label_enc, feat_cols = load_models()
models_loaded = True
except Exception as e:
models_loaded = False
model_load_error = str(e)
if not models_loaded:
st.error(f"Could not load models: {model_load_error}")
st.info("Ensure all `.pkl` files are in the `models/` folder.")
else:
left, right = st.columns([1, 1], gap="large")
with left:
st.markdown('📍 Location
', unsafe_allow_html=True)
col_lat, col_lon = st.columns(2)
with col_lat:
lat_in = st.number_input("Latitude", min_value=-90.0, max_value=90.0, value=35.5, step=0.5, format="%.1f")
with col_lon:
lon_in = st.number_input("Longitude", min_value=-180.0, max_value=180.0, value=139.5, step=0.5, format="%.1f")
continent_in = st.selectbox("Continent", ["Africa", "Asia", "Europe", "North America", "Oceania", "South America"], index=1)
st.markdown('📡 Seismic Measurements
', unsafe_allow_html=True)
col_a, col_b = st.columns(2)
with col_a:
eq_count_in = st.number_input("Earthquake Count (M4.0+)", min_value=0, max_value=2000, value=45, step=1)
eq_mag_in = st.number_input("Mean Magnitude", min_value=0.0, max_value=10.0, value=4.8, step=0.1, format="%.1f")
with col_b:
eq_depth_in = st.number_input("Mean Depth (km)", min_value=0.0, max_value=750.0, value=32.0, step=1.0, format="%.1f")
has_seismic_in = st.checkbox("Has Seismic Activity", value=eq_count_in > 0)
st.markdown('⚙️ Auto-Computed Features
', unsafe_allow_html=True)
fs, ep, mdr = compute_derived(eq_count_in, eq_mag_in, eq_depth_in)
ca, cb, cc = st.columns(3)
ca.metric("Frequency Score", f"{fs:.3f}")
cb.metric("Energy Proxy", f"{ep:.2e}")
cc.metric("Mag/Depth Ratio", f"{mdr:.3f}")
st.markdown('🤖 Model
', unsafe_allow_html=True)
model_choice = st.radio("Select Model", ["XGBoost (Best)", "Random Forest"], horizontal=True)
model_name = "XGBoost" if "XGBoost" in model_choice else "Random Forest"
predict_btn = st.button("🔮 Predict Risk", use_container_width=True, type="primary")
with right:
st.markdown('🎯 Prediction Result
', unsafe_allow_html=True)
if predict_btn:
with st.spinner("Running model..."):
pred, prob_dict = predict(
model_name, rf_model, xgb_model, scaler, label_enc, feat_cols,
lat_in, lon_in, eq_count_in, eq_mag_in, eq_depth_in,
has_seismic_in, continent_in
)
st.session_state["pred"] = pred
st.session_state["prob_dict"] = prob_dict
st.session_state["lat_pred"] = lat_in
st.session_state["lon_pred"] = lon_in
st.session_state["model_used"] = model_name
if "pred" in st.session_state:
pred = st.session_state["pred"]
prob_dict = st.session_state["prob_dict"]
lat_p = st.session_state["lat_pred"]
lon_p = st.session_state["lon_pred"]
m_used = st.session_state["model_used"]
badge_cls = BADGE_CLASS.get(pred, "badge-medium")
conf = prob_dict.get(pred, 0) * 100
st.markdown(f"""
PREDICTED RISK LEVEL
{pred}
Confidence: {conf:.1f}% · Model: {m_used}
""", unsafe_allow_html=True)
# Probability bar chart
st.markdown('📊 Class Probabilities
', unsafe_allow_html=True)
prob_vals = [prob_dict.get(c, 0) * 100 for c in RISK_ORDER]
bar_colors = [RISK_COLORS[c] for c in RISK_ORDER]
fig_bar = go.Figure(go.Bar(
x=RISK_ORDER,
y=prob_vals,
marker_color=bar_colors,
text=[f"{v:.1f}%" for v in prob_vals],
textposition="outside",
textfont=dict(color="#e0e0e0", size=11),
))
fig_bar.update_layout(
paper_bgcolor="#0e1117",
plot_bgcolor="#1a2035",
xaxis=dict(tickfont=dict(color="#b0bec5"), gridcolor="#2a3a5c"),
yaxis=dict(tickfont=dict(color="#b0bec5"), gridcolor="#2a3a5c",
title="Probability (%)", range=[0, 110]),
margin=dict(l=10, r=10, t=10, b=10),
height=260,
showlegend=False,
)
st.plotly_chart(fig_bar, use_container_width=True)
# Location map
st.markdown('📍 Predicted Location
', unsafe_allow_html=True)
pin_color = RISK_COLORS.get(pred, "#f57f17")
fig_loc = go.Figure(go.Scattergeo(
lat=[lat_p], lon=[lon_p],
mode="markers",
marker=dict(size=14, color=pin_color, symbol="circle",
line=dict(width=2, color="white")),
text=[f"{pred} Risk
({lat_p}°, {lon_p}°)"],
hoverinfo="text",
))
fig_loc.update_layout(
paper_bgcolor="#0e1117",
geo=dict(
bgcolor="#0e1117",
showland=True, landcolor="#1a2035",
showocean=True, oceancolor="#0d1b2a",
showcoastlines=True, coastlinecolor="#2a3a5c",
showframe=False,
center=dict(lat=lat_p, lon=lon_p),
projection_scale=3,
),
margin=dict(l=0, r=0, t=0, b=0),
height=240,
)
st.plotly_chart(fig_loc, use_container_width=True)
else:
st.markdown("""
🔮
Fill in the values on the left
and click Predict Risk
""", unsafe_allow_html=True)
# ════════════════════════════════════════════
# TAB 3 — MODEL PERFORMANCE
# ════════════════════════════════════════════
with tab3:
st.markdown('Model Comparison
', unsafe_allow_html=True)
perf_data = {
"Model": ["Logistic Regression", "Random Forest", "XGBoost"],
"Accuracy": ["58.5%", "76.7%", "80.5%"],
"Macro F1": ["0.480", "0.650", "0.681"],
"ROC-AUC": ["0.906", "0.959", "0.961"],
"Status": ["Baseline", "Strong", "⭐ Best"],
}
df_perf = pd.DataFrame(perf_data)
# Colour the best row
def style_row(row):
if row["Status"] == "⭐ Best":
return ["background-color: #0d2137; color: #4fc3f7; font-weight:bold"] * len(row)
return [""] * len(row)
st.dataframe(df_perf.style.apply(style_row, axis=1), use_container_width=True, hide_index=True)
st.markdown('Per-Class Results (XGBoost)
', unsafe_allow_html=True)
cls_data = {
"Class": ["Very Low", "Low", "Medium", "High", "Very High"],
"Precision": [0.41, 0.90, 0.79, 0.67, 0.66],
"Recall": [0.36, 0.88, 0.84, 0.63, 0.68],
"F1-Score": [0.39, 0.89, 0.81, 0.65, 0.67],
"Test Samples": [47, 1307, 909, 405, 255],
}
df_cls = pd.DataFrame(cls_data)
fig_cls = px.bar(
df_cls.melt(id_vars="Class", value_vars=["Precision","Recall","F1-Score"]),
x="Class", y="value", color="variable", barmode="group",
color_discrete_sequence=["#4fc3f7", "#81c784", "#ffb74d"],
labels={"value": "Score", "variable": "Metric"},
title="",
category_orders={"Class": RISK_ORDER},
)
fig_cls.update_layout(
paper_bgcolor="#0e1117", plot_bgcolor="#1a2035",
xaxis=dict(tickfont=dict(color="#b0bec5"), gridcolor="#2a3a5c"),
yaxis=dict(tickfont=dict(color="#b0bec5"), gridcolor="#2a3a5c", range=[0, 1.1]),
legend=dict(bgcolor="#1a2035", font=dict(color="#e0e0e0")),
margin=dict(l=10, r=10, t=20, b=10), height=340,
)
st.plotly_chart(fig_cls, use_container_width=True)
st.markdown('Feature Importance (Random Forest)
', unsafe_allow_html=True)
fi_data = {
"Feature": ["lat_abs","neighbor_eq_count","continent_Europe","continent_South America",
"continent_North America","continent_Oceania","continent_Asia",
"lat_seismic_zone","eq_depth_mean","mag_depth_ratio","weighted_mag",
"seismic_intensity","eq_count","eq_frequency_score","seismic_energy_proxy",
"eq_mag_mean","depth_category","has_seismic_activity"],
"Importance": [32.7,17.6,11.6,10.0,7.4,3.9,3.9,3.8,1.6,1.3,1.3,1.1,1.0,0.9,0.9,0.9,0.3,0.1],
"Type": ["Geographic","Seismic","Geographic","Geographic","Geographic","Geographic","Geographic",
"Geographic","Seismic","Seismic","Seismic","Seismic","Seismic","Seismic","Seismic",
"Seismic","Seismic","Seismic"],
}
df_fi = pd.DataFrame(fi_data).sort_values("Importance", ascending=True)
fig_fi = px.bar(
df_fi, x="Importance", y="Feature", color="Type", orientation="h",
color_discrete_map={"Geographic": "#4fc3f7", "Seismic": "#81c784"},
labels={"Importance": "Importance (%)"},
)
fig_fi.update_layout(
paper_bgcolor="#0e1117", plot_bgcolor="#1a2035",
xaxis=dict(tickfont=dict(color="#b0bec5"), gridcolor="#2a3a5c"),
yaxis=dict(tickfont=dict(color="#b0bec5"), tickfont_size=11),
legend=dict(bgcolor="#1a2035", font=dict(color="#e0e0e0")),
margin=dict(l=10, r=10, t=10, b=10), height=480,
)
st.plotly_chart(fig_fi, use_container_width=True)
st.markdown('Dataset Summary
', unsafe_allow_html=True)
c1, c2, c3, c4 = st.columns(4)
stats = [
("166,293", "Raw Earthquake Events"),
("14,613", "Grid Cells (1°×1°)"),
("18", "Engineered Features"),
("80.5%", "Best Model Accuracy"),
]
for col, (val, lbl) in zip([c1, c2, c3, c4], stats):
col.markdown(f"""
""", unsafe_allow_html=True)