""" Smokey the Bear Partnership — Portfolio Showcase Run: streamlit run app/streamlit_app.py """ from __future__ import annotations import json import warnings from datetime import date from pathlib import Path from media import inline_img import numpy as np import pandas as pd import plotly.express as px import plotly.graph_objects as go import streamlit as st warnings.filterwarnings("ignore") # ── Paths ────────────────────────────────────────────────────────────────────── _HF_DATA = Path("/data") ROOT = Path(__file__).parent.parent RESOURCES = _HF_DATA / "resources" if _HF_DATA.exists() else ROOT / "resources" MODELS_DIR = _HF_DATA / "resources" if _HF_DATA.exists() else ROOT / "models" ICONS = RESOURCES / "icons" CACHE = RESOURCES / "cache" MODELS_RES = RESOURCES / "models" EMBEDDING_COLS = [f"A{i:02d}" for i in range(64)] FEATURE_COLS = EMBEDDING_COLS + ["drift_magnitude"] LOSS_LABEL_MAP = {0: "No Loss", 1: "Forest Loss"} FOCUS_AREAS = { "canada": "Canada", "amazon_basin": "Amazon Basin", } OTHER_AREAS = { "guinea": "Guinea", "congo_basin_drc": "Congo Basin DRC", "indonesia_malaysia": "Indonesia-Malaysia", "mekong_region": "Mekong Region", "cerrado_brazil": "Cerrado Brazil", } AOI_BOUNDS = { "Canada": (-125.0, 54.0, -105.0, 61.0), "Guinea": (-15.0, 8.4, -8.5, 11.6), "Amazon Basin": (-65.0, -10.0, -45.0, 2.0), "Congo Basin DRC": (22.0, -5.0, 30.0, 3.0), "Indonesia-Malaysia": (108.0, -4.0, 118.0, 4.0), "Mekong Region": (98.0, 18.0, 108.0, 26.0), "Cerrado Brazil": (-52.0, -18.0, -42.0, -8.0), } AOI_FUN_FACTS = { "Canada": "🍁 Canada's boreal forests store an enormous amount of carbon, an estimated 327 billion tons. Unlike most forests, Canadian boreal\nforests store most of their " "carbon in the ground, in the form of peatlands. A square meter of peatland can contain up to 5x the amount of carbon as a similar sized area in the Amazon (take that tropics)", "Guinea": "🌿 Like all tropical forests, the Upper Guinea Forests are critical for carbon storage and climate regulation. Only about 30% of the original forest remains thanks " "to logging, mining and oil/cocoa plantations. You know, the classics. Half of the remaining forest is in Liberia and protection efforts are underway with 7 designated " "protected areas in Liberia alone. \n\n [Factoid Source](https://www.usgs.gov/centers/eros/science/deforestation-upper-guinean-forest) \n [Google Maps Location](https://www.google.com/maps/@10.0,-11.75,6z)", "Amazon Basin": "🌳 Lungs of the world? Everyone knows that. A lesser known fact is that the Amazon is home to medicinal plants like cat’s claw, dragon’s blood, and " "graviola. Sounds like stuff we would steal from Snape", "Congo Basin DRC": "🦍 The Congo Basin is home to 20% of all living species found on Earth, some of which can only be found here, like the Okapi \n\n " "[Factoid Source](https://www.worldwildlife.org/places/congo-basin/) \n [Google Maps Location](https://www.google.com/maps/@-1.0,26.0,6z)", "Indonesia-Malaysia": "🐅 This area covers much of what is known as the Peninsular Malaysian and Borneo rainforests. These forests are home to giant trees called " "Dipterocarp Trees. They grow anywhere from 40-70m tall on average. The tallest one ever recorded is called 'Menara'. Clocking in at 100.8 m, Menara is the tallest " "tropical tree on record. \n\n [Factoid Source](https://www.rainforestjournal.com/dipterocarp-trees/) \n [Menara's Location (Close to the NE corner of the AOI)](https://www.google.com/maps/place/4%C2%B057'28.4%22N+117%C2%B051'58.8%22E/@4.957885,117.866341,10z)", "Mekong Region": "🐬 The Mekong region is home to the Mekong river, the 12th longest river is the world. Fun fact about this place, it is the only river in the world that " "is home to both freshwater and saltwater dolphins \n\n [Factoid Source](https://fme.travel/fun-facts-about-the-mekong-river/) \n [Google Maps Location](https://www.google.com/maps/@22.0,103.0,6z)", "Cerrado Brazil":"🌾 You've probably never heard of this place before, but this biome is amazing. Eight of the country's 12 major river basins originate here thanks to the " "extremely deep root systems of the local plant life. It's estimated that 40% of the regions entire biomass lives underground. Unfortunately, this area has lost more " "vegetation than the entire Amazon, partly because it doesn't receive the attention that the Amazon does. Apparently not even nature can escape the attention economy. \n\n [Factoid Source](https://scienceinsights.org/what-is-cerrado-brazils-upside-down-forest/) \n [Google Maps Location](https://www.google.com/maps/@-13.0,-47.0,6z)", } # ── Theme (change these values after running app/theme_preview.py) ───────────── THEME = { "name": "Canopy Light", "primary": "#2d6a4f", "accent": "#40916c", "bg": "#f4fdf7", "bg_gradient": "linear-gradient(160deg, #f4fdf7 0%, #e2f4ea 100%)", "card_bg": "#ffffff", "text": "#1b4332", "text_muted": "#52796f", "border": "#b7e4c7", "dark": False, "font": "Manrope", "font_url": "https://fonts.googleapis.com/css2?family=Manrope:wght@300;400;500;600;700&display=swap", # Rounded & Soft border style "card_radius": "16px", "badge_radius": "20px", "border_width": "1px", "shadow": "0 4px 14px rgba(0,0,0,0.07)", # Chart colors (plotly_colors key kept for backward compat with chart code) "plotly_colors": ["#2d6a4f", "#e76f51", "#f4a261", "#457b9d", "#c77dff", "#e9c46a"], } # ── Page config (must be first Streamlit call) ──────────────────────────────── st.set_page_config( page_title="Smokey the Bear Partnership", page_icon="🌲", layout="wide", initial_sidebar_state="auto", ) # ── CSS injection ────────────────────────────────────────────────────────────── def _inject_theme(): t = THEME p = t["primary"] a = t["accent"] bg = t["bg"] grad = t["bg_gradient"] card = t["card_bg"] txt = t["text"] muted = t["text_muted"] bdr = t["border"] font = t["font"] r = t["card_radius"] br = t["badge_radius"] bw = t["border_width"] shadow = t["shadow"] df_glow = (f"0 0 0 1px {_rgba(a, 0.55)}, " f"0 0 20px {_rgba(a, 0.35)}, " f"0 0 55px {_rgba(a, 0.12)}") css = f""" @import url('{t["font_url"]}'); html, body, [class*='css'], .stApp {{ font-family: '{font}', sans-serif !important; color: {txt} !important; }} body, .stApp, [data-testid='stApp'], [data-testid='stAppViewContainer'] {{ background: {grad} !important; background-color: {bg} !important; }} [data-testid='stHeader'] {{ background-color: {bg} !important; border-bottom: {bw} solid {bdr}55 !important; }} .main .block-container, [data-testid='stMainBlockContainer'] {{ background: transparent !important; padding-top: 3.5rem !important; padding-left: 1rem !important; padding-right: 1rem !important; max-width: 100% !important; }} h1, h2, h3, h4, h5, h6 {{ font-family: '{font}', sans-serif !important; color: {txt} !important; }} p, label, li {{ font-family: '{font}', sans-serif !important; }} [data-testid='stMarkdownContainer'] *, .stMarkdown * {{ font-family: '{font}', sans-serif !important; }} [data-testid='stWidgetLabel'] p, [data-testid='stWidgetLabel'], .stRadio label, [data-testid='stRadio'] label, [data-testid='stMarkdownContainer'] p, [data-testid='stCaption'] p {{ color: {txt} !important; }} [data-testid='metric-container'] {{ background: {card} !important; border: 2px solid {a} !important; border-radius: {r} !important; padding: 16px !important; box-shadow: {shadow} !important; }} [data-testid='stMetricValue'] {{ color: {p} !important; font-size: 1.8rem !important; font-weight: 800 !important; }} [data-testid='stMetricLabel'] {{ color: {txt} !important; font-size: 0.8rem !important; font-weight: 600 !important; }} [data-testid='stMetricDelta'] {{ font-size: 0.8rem !important; }} img.emoji, .stMarkdown .emoji, h1 .twemoji, h2 .twemoji, h3 .twemoji, [data-testid='stMarkdownContainer'] img[alt$=':'] {{ filter: saturate(2) brightness(1.1) !important; }} .stButton > button {{ border-radius: {br} !important; font-family: '{font}', sans-serif !important; font-weight: 600 !important; box-shadow: {shadow} !important; }} [data-testid='baseButton-primary'] {{ background: {p} !important; color: #ffffff !important; border: none !important; }} [data-testid='baseButton-primary']:hover {{ background: {a} !important; }} [data-testid='baseButton-secondary'] {{ background: transparent !important; color: {a} !important; border: 1px solid {bdr} !important; }} [data-testid='baseButton-secondary']:hover {{ background: {_rgba(a, 0.08)} !important; border-color: {a} !important; }} [data-testid='stExpander'] .stButton > button, [data-testid='stExpander'] [data-testid='baseButton-primary'], [data-testid='stExpander'] [data-testid='baseButton-secondary'] {{ padding: 0.2rem 0.5rem !important; min-height: unset !important; font-size: 0.8rem !important; font-weight: 500 !important; box-shadow: none !important; line-height: 1.4 !important; }} [data-testid='stVerticalBlockBorderWrapper'] {{ transition: box-shadow 0.25s ease, border-color 0.25s ease !important; }} [data-testid='stVerticalBlockBorderWrapper']:hover {{ border-color: {a} !important; box-shadow: 0 0 18px {_rgba(a, 0.22)} !important; }} [data-testid='stPillsInput'] {{ flex-direction: column !important; align-items: flex-start !important; gap: 4px !important; }} [data-testid='stPillsInput'] button {{ text-align: left !important; justify-content: flex-start !important; width: 100% !important; }} [data-testid='stExpander'] {{ background: {card} !important; border: {bw} solid {bdr}88 !important; border-radius: {r} !important; box-shadow: {shadow} !important; }} [data-testid='stExpanderDetails'] {{ background: {card} !important; }} [data-testid='stExpander'] summary p {{ font-weight: 700 !important; font-size: 1.1rem !important; color: {a} !important; letter-spacing: 0.04em !important; text-align: center !important; }} [data-baseweb='select'] > div {{ background: {card} !important; border: {bw} solid {bdr}88 !important; border-radius: {r} !important; color: {txt} !important; }} .stTabs [data-baseweb='tab'] {{ color: {muted} !important; font-family: '{font}', sans-serif !important; }} .stTabs [aria-selected='true'] {{ color: {a} !important; border-bottom: 2px solid {a} !important; background: {_rgba(a, 0.07)} !important; }} .stTabs [data-baseweb='tab-list'] {{ border-bottom: 1px solid {bdr}55 !important; background: transparent !important; }} .stTabs [data-baseweb='tab-highlight'] {{ background-color: {a} !important; }} :root, .stApp, [data-testid='stApp'], [data-testid='stAppViewContainer'], [data-testid='stMain'] {{ --primary-color: {p}; --primary: {p}; --background-color: {bg}; --secondary-background-color: {card}; --text-color: {txt}; }} [data-testid='stRadio'] label, [data-testid='stRadio'] p {{ font-family: '{font}', sans-serif !important; color: {txt} !important; }} [data-baseweb='tag'] {{ background-color: {_rgba(p, 0.12)} !important; border: 1px solid {a} !important; }} [data-baseweb='tag'] span {{ color: {a} !important; font-family: '{font}', sans-serif !important; }} [data-baseweb='tag'] [role='button'] svg {{ fill: {a} !important; }} [data-testid='stSlider'] [data-testid='stWidgetLabel'] p {{ font-family: '{font}', sans-serif !important; color: {txt} !important; }} [data-testid='stTickBar'] {{ color: {muted} !important; font-family: '{font}', sans-serif !important; }} [data-testid='stAlert'] {{ background-color: {_rgba(p, 0.08)} !important; border-radius: {r} !important; border: {bw} solid {_rgba(bdr, 0.35)} !important; border-left: 4px solid {a} !important; box-shadow: none !important; }} [data-testid='stAlert'] > div, [data-testid='stAlert'] > div > div {{ background: transparent !important; border: none !important; border-radius: 0 !important; box-shadow: none !important; }} [data-testid='stPlotlyChart'] {{ border-radius: {r} !important; overflow: hidden !important; border: {bw} solid {_rgba(bdr, 0.45)} !important; box-shadow: {shadow} !important; background: {card} !important; }} [data-testid='stPlotlyChart'] > div {{ border-radius: {r} !important; background: transparent !important; }} [data-testid='stDataFrame'], .stDataFrame {{ border-radius: {r} !important; overflow: hidden !important; box-shadow: {df_glow} !important; }} .sh-popup {{ display: none; position: absolute; top: calc(100% + 8px); left: 0; z-index: 9999; width: 320px; background: #fff; border-radius: 10px; padding: 5px; box-shadow: 0 4px 18px rgba(0,0,0,0.18); pointer-events: none; }} .sh-hover:hover .sh-popup {{ display: block !important; }} hr {{ border-color: {bdr}44 !important; }} [data-testid="stSidebar"] {{ background: {card} !important; border-right: 1px solid {bdr}66 !important; }} [data-testid="stSidebar"] > div:first-child {{ padding-top: 1.5rem !important; }} [data-testid="stSidebar"] [data-testid="stDataFrame"] {{ border: 1px solid {bdr}44 !important; border-radius: 6px !important; }} """ # Tags must start at column 0 so Python-Markdown treats them as HTML blocks st.markdown( f'\n' f'\n' f'', unsafe_allow_html=True, ) # ── Data loaders ─────────────────────────────────────────────────────────────── @st.cache_data def _load_kpi() -> dict: path = CACHE / "kpi_summary.json" if not path.exists(): return {} return json.loads(path.read_text()) @st.cache_data def _load_embedding_profile() -> pd.DataFrame: path = CACHE / "mean_embedding_profile.parquet" if not path.exists(): return pd.DataFrame() return pd.read_parquet(path) @st.cache_data def _load_drift_by_area() -> pd.DataFrame: path = CACHE / "drift_by_area_year.parquet" if not path.exists(): return pd.DataFrame() return pd.read_parquet(path) @st.cache_data def _load_comparison_kpis() -> dict: path = CACHE / "comparison_kpis.json" if not path.exists(): return {} return json.loads(path.read_text()) @st.cache_data def _load_area_feature_importance(area_key: str) -> list[dict]: path = CACHE / f"{area_key}_feature_importance.json" if not path.exists(): return [] return json.loads(path.read_text()) @st.cache_data def _load_area_confusion_matrix(area_key: str) -> dict: path = CACHE / f"{area_key}_confusion_matrix.json" if not path.exists(): return {} return json.loads(path.read_text()) @st.cache_data def _load_area_best_trial(area_key: str) -> dict: path = CACHE / f"{area_key}_best_trial.json" if not path.exists(): return {} return json.loads(path.read_text()) @st.cache_data def _load_area_data_sample(area_key: str) -> pd.DataFrame: path = CACHE / f"{area_key}_data_sample.parquet" if not path.exists(): return pd.DataFrame() return pd.read_parquet(path) @st.cache_data def _load_aoi_stats() -> dict: path = CACHE / "aoi_stats.json" if not path.exists(): return {} return json.loads(path.read_text()) @st.cache_data def _load_test_predictions(area_key: str) -> pd.DataFrame: path = CACHE / f"{area_key}_test_predictions.parquet" if not path.exists(): return pd.DataFrame() return pd.read_parquet(path) # ── Shared helpers ───────────────────────────────────────────────────────────── def _rgba(hex_color: str, alpha: float) -> str: """Convert 6-digit hex to rgba() — Plotly does not accept 8-digit hex colors.""" h = hex_color.lstrip("#") if len(h) == 3: h = "".join(c * 2 for c in h) r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) return f"rgba({r},{g},{b},{alpha})" def _plotly_defaults(fig: go.Figure, title: str | None = None) -> go.Figure: t = THEME grid = _rgba(t["text_muted"], 0.15) plot_bg = _rgba(t["primary"], 0.05) # subtle primary tint for contrast on light bg fig.update_layout( paper_bgcolor="rgba(0,0,0,0)", # transparent — CSS container provides card bg plot_bgcolor=plot_bg, font=dict(family=t["font"], color=t["text"], size=12), title_font=dict(family=t["font"], color=t["text"], size=15), legend=dict(bgcolor=t["card_bg"], bordercolor=_rgba(t["border"], 0.27), borderwidth=1), margin=dict(l=20, r=20, t=50 if title else 20, b=20), ) fig.update_xaxes(gridcolor=grid, zerolinecolor=grid, color=t["text_muted"]) fig.update_yaxes(gridcolor=grid, zerolinecolor=grid, color=t["text_muted"]) if title: fig.update_layout(title_text=title) return fig def _card(content: str): t = THEME st.markdown( f"
{content}
", unsafe_allow_html=True, ) def _section_header(text: str, emoji: str = "", img_path: str | None = None, hover_gif: str | None = None): t = THEME if img_path: raw_img = inline_img(img_path, height="1.3em") if hover_gif: icon = ( f"{raw_img}" f"" f"" f"" ) else: icon = raw_img else: icon = emoji st.markdown( f"

" f"{icon} {text}

", unsafe_allow_html=True, ) # ── Footer ───────────────────────────────────────────────────────────────────── def _render_footer(): t = THEME st.markdown("---") st.markdown( f"
" f"🌲 " f"Smokey the Bear Partnership  |  Deforestation Detection via Satellite Embeddings" f" |  Built with Streamlit + XGBoost + Google Earth Engine" f"
", unsafe_allow_html=True, ) # ── Top navigation ───────────────────────────────────────────────────────────── def _render_nav() -> str: # Sync page from URL query param (set by nav link clicks) qp = st.query_params.get("page", None) if "page" not in st.session_state: st.session_state.page = qp if qp else "Project Overview" elif qp and qp != st.session_state.page: st.session_state.page = qp pages = [ ("project_overview.png", "Project Overview"), ("explore.png", "Explore the Data"), ("crystal_ball.png", "Model Results"), ("construction_guy.png", "How I Built This"), ] with st.sidebar: t = THEME font = t["font"] accent = t["accent"] txt = t["text"] _label_style = ( f"font-family:{font},sans-serif;font-weight:900;" f"font-size:1.0rem;letter-spacing:0.1em;color:{accent};" f"text-transform:uppercase;margin-bottom:0.5rem;" ) st.markdown(f"

Navigate

", unsafe_allow_html=True) # Native GET form buttons — browser-level navigation, same tab, no JS needed nav_css = ( f"" ) nav_items = "" for icon_file, name in pages: is_active = st.session_state.page == name img = inline_img(ICONS / icon_file, height="22px", fallback="") cls = "ni na" if is_active else "ni" nav_items += ( f"
" f"" f"
" ) st.html(nav_css + nav_items) st.divider() st.markdown(f"

Other Regions

", unsafe_allow_html=True) selected_aoi = st.selectbox( "region", options=list(OTHER_AREAS.keys()), format_func=lambda k: OTHER_AREAS[k], key="sidebar_aoi", label_visibility="collapsed", ) aoi_stats = _load_aoi_stats() stats = aoi_stats.get(selected_aoi, {}) if stats: _bounds = AOI_BOUNDS.get(OTHER_AREAS[selected_aoi], ()) _lat = f"S: {_bounds[1]}°, N: {_bounds[3]}°" if _bounds else "—" _lon = f"W: {_bounds[0]}°, E: {_bounds[2]}°" if _bounds else "—" rows = [ ("Pixel-years", f"{stats['n_pixel_years']:,}"), ("Unique pixels", f"{stats['n_pixels']:,}" if stats.get("n_pixels") else "—"), ("Loss rate", f"{stats['loss_rate'] * 100:.1f}%"), ("Drift mean", f"{stats['drift_mean']:.3f}"), ("Drift max", f"{stats['drift_max']:.3f}"), ("Drift std", f"{stats['drift_std']:.3f}"), ("PR-AUC", f"{stats['pr_auc']:.4f}"), ("F1", f"{stats['f1']:.4f}"), ("Δ vs Canada", f"{stats['auc_delta']:+.4f}"), ("Latitude", _lat), ("Longitude", _lon), ] st.dataframe( pd.DataFrame(rows, columns=["Metric", "Value"]), hide_index=True, width='stretch', ) else: st.caption("Run `python resources/precompute.py` to generate stats.") fact = AOI_FUN_FACTS.get(OTHER_AREAS[selected_aoi], "") if fact: st.caption(fact) return st.session_state.page # ═══════════════════════════════════════════════════════════════════════════════ # PAGE 1 — PROJECT OVERVIEW # ═══════════════════════════════════════════════════════════════════════════════ def _render_page_1(): t = THEME kpi = _load_kpi() # Smokey's birthday easter egg if date.today().month == 8 and date.today().day == 9: st.balloons() st.info("🐻 Happy Birthday, Smokey! Born August 9th, 1944 — the most famous fire prevention bear in history.") # ── Hero ────────────────────────────────────────────────────────────────── col_hero, col_anim = st.columns([3, 1]) with col_hero: st.markdown( f"
" f"

" f"Detecting Deforestation Through
" f"Satellite Embedding Drift" f"

" f"

" f"A machine learning pipeline spanning 7 global regions, 5.7 million 30m resolution pixel-years, and 8 years of satellite data." f"

", unsafe_allow_html=True, ) st.info( "**What this project does:** This project uses annual satellite embeddings from Google Earth Engine " "to detect deforestation by measuring how much a pixel's spectral signature *drifts* year over year. " "A unique XGBoost Classifier is trained on 7 separate areas of interest (AOI) representing various " "biomes and known deforestation events. The goal is to see how well deforestation events can be captured " "using only the satellite embeddings and annual drift magnitude " ) st.markdown("
", unsafe_allow_html=True) with col_anim: from streamlit_lottie import st_lottie anim = json.loads((RESOURCES / "smokey_animation.json").read_text()) st_lottie(anim, height=220, key="hero_lottie") # ── Tale of two forests ────────────────────────────────────────────────── _check_img = inline_img(ICONS / "checkmark.png", height="1.2em", fallback="✅") cmp = _load_comparison_kpis() if cmp: _section_header("A Tale of Two Forests", img_path=str(ICONS / "forest_earth.png")) ca = cmp.get("canada", {}) am = cmp.get("amazon_basin", {}) delta = round(ca.get("pr_auc", 0) - am.get("pr_auc", 0), 4) col_ca, col_am, col_delta = st.columns([2, 2, 1]) with col_ca: st.markdown( f"
" f"
🍁 Canada
" f"
PR-AUC {ca.get('pr_auc', 0):.4f} {_check_img}
" f"
" f"F1 {ca.get('f1', 0):.4f}  |  Recall {ca.get('recall', 0):.4f}  |  " f"Precision {ca.get('precision', 0):.4f}
" f"
" f"Loss rate {ca.get('loss_rate', 0)*100:.2f}%  |  {ca.get('n_rows', 0):,} pixel-years
" f"
", unsafe_allow_html=True, ) with col_am: st.markdown( f"
" f"
🌳 Amazon Basin
" f"
PR-AUC {am.get('pr_auc', 0):.4f} ⚠️
" f"
" f"F1 {am.get('f1', 0):.4f}  |  Recall {am.get('recall', 0):.4f}  |  " f"Precision {am.get('precision', 0):.4f}
" f"
" f"Loss rate {am.get('loss_rate', 0)*100:.2f}%  |  {am.get('n_rows', 0):,} pixel-years
" f"
", unsafe_allow_html=True, ) with col_delta: st.markdown( f"
" f"
Δ PR-AUC
" f"
-{delta:.4f}
" f"
Same model.
Same features.
See Page 3 for why.
" f"
", unsafe_allow_html=True, ) st.markdown("
", unsafe_allow_html=True) # ── Why this matters ───────────────────────────────────────────────────── _panda_img = inline_img(ICONS / "panda-svgrepo-com.svg", height="1.5em") st.markdown( f"
" f"

" f"Why this matters: " "Forests cover 31% of Earth's land and are net carbon sinks, sequestering ~7.6 billion metric tonnes of CO₂ annually. " "Early, accurate detection of deforestation is critical for conservation response, " f"carbon accounting, and international restoration commitments. Plus I think we can all agree that everyone loves pandas {_panda_img}, " "and we should use all resources at our disposal to keep their homes intact" "

", unsafe_allow_html=True, ) # ── KPI cards ───────────────────────────────────────────────────────────── _section_header("Key Stats", img_path=str(ICONS / "key_stats.png")) c1, c2, c3, c4, c5, c6, c7 = st.columns(7) with c1: st.metric("Pixel-Years", f"{kpi.get('total_rows', 803054):,}") with c2: st.metric("Regions", str(kpi.get("n_areas", 7))) with c3: st.metric("Features Engineered", str(kpi.get("n_features_engineered", 19))) with c4: st.metric("Best PR-AUC", f"{kpi.get('pr_auc', 0.42):.4f}") with c5: st.metric("Best F1", f"{kpi.get('f1', 0.38):.4f}") with c6: st.metric("Brier Score", f"{kpi.get('brier', 0.041):.4f}") with c7: lift = kpi.get("improvement_pct", 121.0) baseline = kpi.get("baseline_pr_auc", 0.19) st.metric("PR-AUC Lift", f"+{lift:.0f}%", f"vs LR baseline ({baseline:.4f})") # ── Tech stack ──────────────────────────────────────────────────────────── _section_header("Tech Stack", img_path=str(ICONS / "tech_stack.png")) badges = [ ("Python 3.13", inline_img(ICONS / "python.png", height="1.4em"), None), ("XGBoost", inline_img(ICONS / "boost.png", height="1.4em"), None), ("Scikit-learn", inline_img(ICONS / "microscope.png", height="1.4em"), None), ("Optuna", inline_img(ICONS / "tuning.png", height="1.4em"), None), ("MLflow", inline_img(ICONS / "flow.png", height="1.2em"), None), ("Streamlit", inline_img(ICONS / "app.png", height="1.4em"), None), ("Google Earth Engine", inline_img(ICONS / "earth.png", height="1.4em", fallback="🛰️"), "https://earth.google.com/web/search/San+Diego,+CA/@32.82448779,-117.10775989,126.15911997a,125030.2390174d,35y,360h,0t,0r/data=CooBGk0SRwokMHg4MGQ5NTMwZmFkOTIxZTRiOjB4ZDNhMjFmZGZkMTVkZjc5GZJAg02dW0BAIZY6bTJPSl3AKg1TYW4gRGllZ28sIENBGAIgASImCiQJkZXllQlzHcARp18n3R-UIcAZL4vEuZNYUsAhMwHwNx4DU8A6DQgBEQAAAAAAAPA_IAFCAggBOgMKATBCAggASg0I____________ARAA"), ] _span_style = ( f"background:{t['primary']}33;color:{t['accent']};" f"border:1px solid {t['primary']}66;border-radius:20px;" f"padding:6px 14px;margin:4px;display:inline-block;" f"font-family:{t['font']},sans-serif;font-size:0.85rem;font-weight:600;" ) _tooltip_css = f""" """ badge_parts = [] for name, icon, url in badges: span = f"{icon} {name}" if url: badge_parts.append( f"" f"{span}" f"" ) else: badge_parts.append(span) badge_html = " ".join(badge_parts) st.markdown(f"{_tooltip_css}
{badge_html}
", unsafe_allow_html=True) # ═══════════════════════════════════════════════════════════════════════════════ # PAGE 2 — EXPLORE THE DATA # ═══════════════════════════════════════════════════════════════════════════════ def _render_page_2(): t = THEME _section_header("Explore the Data", img_path=str(ICONS / "explore.png")) explore_area = st.radio( "View data for", options=list(FOCUS_AREAS.keys()), format_func=lambda k: FOCUS_AREAS[k], horizontal=True, key="explore_area", ) area_df = _load_area_data_sample(explore_area) tab1, tab2, tab3, tab4 = st.tabs([ "📡 Embedding Profiles", "🎯 Target Distribution", "🌊 Drift + Timelapse", "🔬 Drift Explorer", ]) # ── Tab 1: Embedding profiles ───────────────────────────────────────────── with tab1: profile_df = _load_embedding_profile() if profile_df.empty: st.warning("No embedding profile data found. Run resources/precompute.py first.") return # Filter to selected area area_profile = profile_df[profile_df["name"] == explore_area] if "name" in profile_df.columns else profile_df available_years = sorted(area_profile["year"].unique().tolist()) if "year" in area_profile.columns else list(range(2018, 2025)) cf1, cf2 = st.columns([2, 2]) with cf1: sel_years = st.multiselect("Filter by Year", available_years, default=available_years[-2:], key="emb_years") with cf2: view_by = st.radio("Group by", ["Binary (Loss / No Loss)", "By Year"], horizontal=True, key="emb_view") filtered = area_profile.copy() if sel_years and "year" in filtered.columns: filtered = filtered[filtered["year"].isin(sel_years)] if view_by == "Binary (Loss / No Loss)": agg = filtered.groupby(["loss_label", "dim_idx"])["mean_value"].mean().reset_index() agg["group"] = agg["loss_label"].map(LOSS_LABEL_MAP) else: agg = filtered.groupby(["year", "dim_idx"])["mean_value"].mean().reset_index() agg["group"] = agg["year"].astype(str) fig_emb = px.line( agg, x="dim_idx", y="mean_value", color="group", color_discrete_sequence=t["plotly_colors"], labels={"dim_idx": "Embedding Dimension (A00–A63)", "mean_value": "Mean Delta", "group": "Group"}, ) fig_emb = _plotly_defaults(fig_emb, f"Mean Embedding Delta Profile — {FOCUS_AREAS[explore_area]}") st.plotly_chart(fig_emb, width='stretch') # Divergence bar for selected area loss_profile = area_profile[area_profile["loss_label"] == 1].groupby("dim_idx")["mean_value"].mean() noloss_profile = area_profile[area_profile["loss_label"] == 0].groupby("dim_idx")["mean_value"].mean() div = (loss_profile - noloss_profile).abs().sort_values(ascending=False).head(10).reset_index() div.columns = ["dim_idx", "divergence"] div["dim"] = div["dim_idx"].apply(lambda i: f"A{i:02d}") fig_div = px.bar( div, x="divergence", y="dim", orientation="h", color="divergence", color_continuous_scale=[[0, t["primary"]], [1, t["accent"]]], ) fig_div = _plotly_defaults(fig_div, f"Top 10 Most Divergent Dims — {FOCUS_AREAS[explore_area]}") fig_div.update_layout(coloraxis_showscale=False, height=320, yaxis={"categoryorder": "total ascending"}) st.plotly_chart(fig_div, width='stretch') if explore_area == "canada": st.info("💡 **Key finding:** Year-over-year drift is noisier for loss pixels in both regions, but Canada's no-loss pixels show notably more YOY variation " "than the Amazon's. Could be driven by boreal seasonality (snowpack timing, greenup variability) that the tropics don't experience. The effect largely averages out over multi-year windows.") elif explore_area == "amazon_basin": st.info("💡 **Key finding:** A single embedding dimension dominates the deforestation signal in the Amazon, with drift magnitude that stays consistent year over year. " "Unlike Canada's noisy no-loss baseline, undisturbed Amazon pixels are spectrally stable — making the one-way shift in that dominant dimension a clean, reliable indicator of loss.") # ── Tab 2: Target distribution ──────────────────────────────────────────── with tab2: cmp = _load_comparison_kpis() area_cmp = cmp.get(explore_area, {}) if cmp else {} if not area_cmp: st.warning("No distribution data found. Run resources/precompute.py first.") else: full_rows = int(area_cmp.get("n_rows", 0)) full_rate = float(area_cmp.get("loss_rate", 0)) loss = round(full_rows * full_rate) noloss = full_rows - loss dc1, dc2 = st.columns([1, 1]) with dc1: fig_pie = px.pie( values=[noloss, loss], names=["No Forest Loss", "Forest Loss"], color_discrete_sequence=[t["primary"], t["accent"]], ) fig_pie = _plotly_defaults(fig_pie, f"Class Distribution — {FOCUS_AREAS[explore_area]}") fig_pie.update_layout(height=320) st.plotly_chart(fig_pie, width='stretch') with dc2: st.metric("Total Pixel-Years", f"{full_rows:,}") st.metric("Forest Loss Pixels", f"{loss:,}", f"{full_rate*100:.2f}% positive rate") st.metric("No-Loss Pixels", f"{noloss:,}") st.metric("Class Imbalance Ratio", f"{noloss // max(loss, 1)}:1") st.warning( f"⚠️ **Severe class imbalance:** {full_rate*100:.2f}% positive rate across {full_rows:,} pixel-years. " "PR-AUC is a more reliable gauge of model performance in these instances. A naive classifier predicting 'no loss' " "achieves 95%+ accuracy while detecting nothing. And ROC can overestimate model performance given that a relative large number of false positives " "can be hidden by a majority negative class that dominates that distribution" ) # ── Tab 3: Drift + timelapse ────────────────────────────────────────────── with tab3: drift_df = _load_drift_by_area() area_drift = drift_df[drift_df["name"] == explore_area] if not drift_df.empty and "name" in drift_df.columns else pd.DataFrame() if not area_drift.empty: _drift_grouped = area_drift.copy() _drift_grouped["Label"] = _drift_grouped["loss_label"].map(LOSS_LABEL_MAP) fig_drift = px.bar( _drift_grouped.sort_values("year"), x="year", y="mean_drift", color="Label", barmode="group", color_discrete_map={"No Loss": t["primary"], "Forest Loss": "#e76f51"}, labels={"mean_drift": "Mean Drift Magnitude", "year": "Year"}, ) fig_drift = _plotly_defaults(fig_drift, f"Mean Drift Magnitude by Year — {FOCUS_AREAS[explore_area]}") fig_drift.update_layout(legend={"title": None, "orientation": "h", "y": 1.08}) st.plotly_chart(fig_drift, width='stretch') _forest_icon = inline_img(ICONS / "forest_loss.png", height="1.8em") st.markdown( f"
" f"{_forest_icon} Forest loss pixels show elevated drift magnitude in their loss year. " "The drift magnitude in the Amazon Basin is consistent year over year while Canada's is variable — " "suggesting deforestation in the Amazon AOI is systemic while in Canada it's event-driven." f"
", unsafe_allow_html=True, ) st.divider() if not area_df.empty and "latitude" in area_df.columns: _section_header("Drift Timelapse Animation (2018–2024)", "🎬") st.caption("Each point is a sampled pixel. Size = drift magnitude. Color = label. Drag the year slider to animate.") _tl = area_df.copy() _tl["loss_label"] = _tl["loss_label"].astype(int) _tl["year"] = _tl["year"].astype(int) _tl["Label"] = _tl["loss_label"].map(LOSS_LABEL_MAP) _tl["drift_display"] = _tl["drift_magnitude"].round(4) _lon_min, _lat_min, _lon_max, _lat_max = AOI_BOUNDS.get(FOCUS_AREAS[explore_area], (-180, -90, 180, 90)) _center_lat = (_lat_min + _lat_max) / 2 _center_lon = (_lon_min + _lon_max) / 2 _span = max(_lat_max - _lat_min, _lon_max - _lon_min) _zoom = max(1, min(10, round(8.5 - np.log2(_span + 1e-9)))) fig_tl = px.scatter_mapbox( _tl, lat="latitude", lon="longitude", color="Label", size="drift_display", animation_frame="year", color_discrete_map={"No Loss": t["primary"], "Forest Loss": "#e63946"}, size_max=10, opacity=0.7, zoom=_zoom, center={"lat": _center_lat, "lon": _center_lon}, mapbox_style="open-street-map", labels={"drift_display": "Drift"}, category_orders={"Label": ["No Loss", "Forest Loss"]}, hover_data={"latitude": False, "longitude": False, "drift_display": True}, ) _fun_fact = AOI_FUN_FACTS.get(FOCUS_AREAS[explore_area], "") _aoi_name = FOCUS_AREAS[explore_area] # Visible border line fig_tl.add_trace(go.Scattermapbox( lat=[_lat_min, _lat_min, _lat_max, _lat_max, _lat_min], lon=[_lon_min, _lon_max, _lon_max, _lon_min, _lon_min], mode="lines", line={"color": "#e76f51", "width": 2}, showlegend=False, hoverinfo="skip", )) # Invisible dense points along all 4 edges so hover works anywhere on the border if _fun_fact: _n_edge = 30 _edge_lats, _edge_lons = [], [] for _a, _b in [ ((_lat_min, _lon_min), (_lat_min, _lon_max)), ((_lat_min, _lon_max), (_lat_max, _lon_max)), ((_lat_max, _lon_max), (_lat_max, _lon_min)), ((_lat_max, _lon_min), (_lat_min, _lon_min)), ]: _edge_lats += list(np.linspace(_a[0], _b[0], _n_edge)) _edge_lons += list(np.linspace(_a[1], _b[1], _n_edge)) _hover_text = _fun_fact.replace("\n", "
") fig_tl.add_trace(go.Scattermapbox( lat=_edge_lats, lon=_edge_lons, mode="markers", marker={"size": 8, "color": "rgba(0,0,0,0)"}, showlegend=False, hovertemplate=f"{_aoi_name}
{_hover_text}", )) fig_tl.update_layout( paper_bgcolor=t["bg"], font=dict(family=t["font"], color=t["text"]), height=680, margin=dict(l=60, r=60, t=30, b=0), legend={"title": None}, ) st.plotly_chart(fig_tl, width='stretch') st.caption(""" The bounding box traces the rectangle of the AOI coordinates that the pixels were sampled from. Some pixels may 'leak' because of discrepancies between Google Earth Engine coordinates and those of the plotly map interface """, text_alignment="center") if explore_area == "canada": st.caption("Source: [WWF Canada Carbon Map](https://wwf.ca/carbonmap/)", text_alignment="center") elif explore_area == "amazon_basin": st.caption("Source: [100 Incredible Facts About the Amazon Rainforest](https://factsbreak.com/100-incredible-facts-about-the-amazon-rainforest/)", text_alignment="center") # ── Tab 4: Drift explorer ───────────────────────────────────────────────── with tab4: if area_df.empty: st.warning("No area data sample found. Run resources/precompute.py first.") else: _df_plot = area_df.copy() _df_plot["loss_label"] = _df_plot["loss_label"].astype(int) _years = sorted(_df_plot["year"].unique()) st.markdown("

Year

", unsafe_allow_html=True) _sel_year = st.selectbox("Year", options=_years, index=len(_years) - 1, key="hist_year", label_visibility="collapsed", width=100) _yr_df = _df_plot[_df_plot["year"] == _sel_year] import plotly.colors as pc _plasma = pc.get_colorscale("Plasma") _global_min = float(_df_plot["drift_magnitude"].quantile(0.02)) _global_max = float(_df_plot["drift_magnitude"].quantile(0.98)) _n_bins = 40 _bin_edges = np.linspace(_global_min, _global_max, _n_bins + 1) _bin_width = _bin_edges[1] - _bin_edges[0] _bin_centers = (_bin_edges[:-1] + _bin_edges[1:]) / 2 fig_feat = go.Figure() for label, label_name, plasma_lo, plasma_hi, opacity in [ (0, "No Loss", 0.0, 0.45, 0.5), (1, "Forest Loss", 0.55, 1.0, 0.85), ]: _sub = _yr_df[_yr_df["loss_label"] == label]["drift_magnitude"].clip(_global_min, _global_max) if _sub.empty: continue _counts, _ = np.histogram(_sub, bins=_bin_edges) _density = _counts / max(_counts.sum(), 1) _norms = plasma_lo + (_bin_centers - _global_min) / max(_global_max - _global_min, 1e-9) * (plasma_hi - plasma_lo) _bar_colors = [ f"rgba({{}},{{}},{{}},{opacity})".format( *[int(float(x)) for x in str(pc.sample_colorscale(_plasma, [float(n)])[0])[4:-1].split(",")] ) for n in _norms ] fig_feat.add_trace(go.Bar( x=_bin_centers, y=_density, name=label_name, marker={"color": _bar_colors, "line": {"width": 0}}, width=_bin_width * 0.95, )) fig_feat = _plotly_defaults(fig_feat, f"Drift Distribution — {FOCUS_AREAS[explore_area]} ({_sel_year})") fig_feat.update_layout( barmode="overlay", xaxis={"title": "Drift Magnitude", "range": [_global_min * 0.9, _global_max * 1.05]}, yaxis={"title": "% of pixels", "tickformat": ".1%"}, legend={"title": None}, ) st.caption("Overlaid histograms to visualize the distribution of drift. Gradient is tied to drift magnitude on the x-axis. No loss is represented by the cooler end " "of the color spectrum. Select year from drop down to compare years.") st.plotly_chart(fig_feat, width='stretch') # ═══════════════════════════════════════════════════════════════════════════════ # PAGE 3 — MODEL RESULTS # ═══════════════════════════════════════════════════════════════════════════════ def _render_cm_col(cm: dict, label: str, trial: dict, color: str): t = THEME if not cm: st.warning(f"No confusion matrix for {label}.") return tn, fp, fn, tp = cm.get("tn", 0), cm.get("fp", 0), cm.get("fn", 0), cm.get("tp", 0) total = max(tn + fp + fn + tp, 1) st.markdown( f"
{label}
", unsafe_allow_html=True, ) m1, m2, m3 = st.columns(3) with m1: st.metric("PR-AUC", f"{trial.get('pr_auc', 0):.4f}") with m2: st.metric("F1", f"{trial.get('f1', 0):.4f}") with m3: st.metric("Recall", f"{trial.get('recall', 0):.4f}") matrix = np.array([[tn, fp], [fn, tp]]) text_ann = [ [f"{tn:,}
({tn/total*100:.1f}%)", f"{fp:,}
({fp/total*100:.1f}%)"], [f"{fn:,}
({fn/total*100:.1f}%)", f"{tp:,}
({tp/total*100:.1f}%)"], ] fig = go.Figure(go.Heatmap( z=matrix, text=text_ann, texttemplate="%{text}", colorscale=[[0, t["bg"]], [0.5, t["primary"]], [1, color]], showscale=False, x=["Pred No Loss", "Pred Loss"], y=["Actual No Loss", "Actual Loss"], )) _plotly_defaults(fig, f" Confusion Matrix — {label}") fig.update_layout(height=300, margin=dict(l=60, r=10, t=50, b=50)) st.plotly_chart(fig, width='stretch') tpr = tp / max(tp + fn, 1) fnr = fn / max(fn + tp, 1) st.caption(f"Recall {tpr:.2%} | FNR {fnr:.2%} — each false negative = a missed deforestation event") def _render_page_3(): t = THEME _section_header("Canada vs. Amazon Basin", img_path=str(ICONS / "crystal_ball.png"), hover_gif="https://media0.giphy.com/media/10TI5vPyELuuHu/giphy.gif") st.markdown( f"

" f"The same XGBoost architecture, the same 65 features, trained separately on two regions. " f"Canada scores PR-AUC 0.91. Amazon Basin scores PR-AUC 0.62. " f"The sections below hypothesize what the numbers reveal.

", unsafe_allow_html=True, ) ca_trial = _load_area_best_trial("canada") am_trial = _load_area_best_trial("amazon_basin") ca_cm = _load_area_confusion_matrix("canada") am_cm = _load_area_confusion_matrix("amazon_basin") ca_fi = _load_area_feature_importance("canada") am_fi = _load_area_feature_importance("amazon_basin") # ── Section 1: Metrics + confusion matrices side by side ────────────────── _section_header("Metrics & Confusion Matrices", img_path=str(ICONS / "measure.png")) col_ca, col_am = st.columns(2) with col_ca: _render_cm_col(ca_cm, "🍁 Canada", ca_trial, t["accent"]) with col_am: _render_cm_col(am_cm, "🌳 Amazon Basin", am_trial, "#e76f51") # ── Section 2: Feature importance side by side ───────────────────────────── _section_header("Feature Importance (XGBoost Gain)", img_path=str(ICONS / "feature_importance.png")) col_fi_ca, col_fi_am = st.columns(2) def _fi_chart(fi: list[dict], title: str, color_hi: str) -> go.Figure: if not fi: return go.Figure() fi_df = pd.DataFrame(fi).sort_values("importance") fig = px.bar( fi_df, x="importance", y="feature", orientation="h", color="importance", color_continuous_scale=[[0, t["primary"]], [1, color_hi]], labels={"importance": "Gain", "feature": ""}, ) _plotly_defaults(fig, title) fig.update_layout(coloraxis_showscale=False, height=420) return fig with col_fi_ca: if ca_fi: st.plotly_chart(_fi_chart(ca_fi, " Canada — Top 15 Features", "#e76f51"), width='stretch') else: st.warning("No feature importance for Canada.") with col_fi_am: if am_fi: st.plotly_chart(_fi_chart(am_fi, " Amazon Basin — Top 15 Features", "#e76f51"), width='stretch') else: st.warning("No feature importance for Amazon Basin.") st.caption("Gain is the average relative gain of a feature across all splits. Compare which embedding dimensions dominate in each region.") st.write("\n") # ── Section 3: Hypotheses ────────────────────────────────────────────────── with st.expander("💡 Why does Canada score 0.91 and Amazon only 0.62?", expanded=False): st.markdown(""" The simplest answer is that Canada has roughly twice as many positive records — a 1.46% loss rate vs. 0.61% in the Amazon AOI. But a 0.85 percentage point difference in class balance alone doesn't fully explain the performance gap. At least I don't think **The fire year paradox** Both AOIs saw their worst fire years on record in [2023](https://www.bbc.com/news/world-us-canada-66543362) and [2024](https://climateadaptationplatform.com/whats-fueling-brazils-worst-fire-season-in-a-decade/), and this shows up clearly in the data — loss labels spike in both areas, dramatically so in Canada. But counterintuitively, the embedding distributions for those years show *more overlap* between Loss and No Loss pixels than earlier years. The model's signal weakens in the years with the most fire. Rapid, repeated disturbance erodes the intact-forest baseline that makes loss pixels spectrally distinctive in the first place. **Forest loss ≠ deforestation** The Hansen labels detect *forest loss*, not deforestation in the formal sense. Deforestation is officially defined as permanent clearing; forest loss includes disturbance that grows back. Wildfires — the dominant recorded loss driver in both AOIs in recent years — often fall into the latter category. The land regrows, the spectral signature shifts again, and year-over-year embedding drift stays variable and high. Variable drift is a strong signal for the model. Agricultural expansion is the primary cause of *permanent* deforestation in both regions. [Don't take my word for it](https://maweb.org/what-are-the-main-causes-of-deforestation-in-brazil/) [🔥](https://madeinca.ca/deforestation-statistics-canada/). Canada's footprint is relatively limited, responsible for less than 1% of global deforestation. Brazil's is far larger. But agricultural clearing is spectrally subtler: land is converted gradually to a stable new use, like pasture or cropland, producing low, consistent year-over-year drift rather than a sharp spike. Slow, steady drift is harder to detect than the volatile signal left by fire. **Feature importance as evidence** `drift_magnitude` is the dominant feature in the Amazon Basin model, suggesting it leans heavily on a single signal. Canada's feature importance is more evenly distributed across embedding dimensions, a sign the model found richer, more varied structure in the data and had more to work with. **Cloud cover** Finally, the Amazon's persistent tropical cloud cover reduces the quality of annual satellite composites, introducing noise into the embeddings before the model ever sees them. Canada's boreal environment produces cleaner observations throughout the year. """) # ── Section 4: Hyperparameters ───────────────────────────────────────────── with st.expander("⚙️ Best Optuna hyperparameters — Canada vs. Amazon Basin", expanded=False): ca_params = ca_trial.get("params", {}) am_params = am_trial.get("params", {}) all_keys = sorted(set(list(ca_params.keys()) + list(am_params.keys()))) rows = [{"Parameter": k, "Canada": ca_params.get(k, "—"), "Amazon Basin": am_params.get(k, "—")} for k in all_keys] if rows: params_df = pd.DataFrame(rows) st.dataframe(params_df, hide_index=True, width='stretch') n_ca = ca_trial.get("n_trials", "?") n_am = am_trial.get("n_trials", "?") st.caption(f"**Canada:** {n_ca} Optuna trials | **Amazon Basin:** {n_am} Optuna trials — objective: PR-AUC") # ── Section 5: Threshold Explorer ───────────────────────────────────────── _section_header("Threshold Explorer", "🎚️") st.markdown( f"

" f"The default 0.5 threshold treats false positives and false negatives equally. " f"In conservation, a missed deforestation event (false negative) may cost far more than a false alarm. " f"Drag the slider to see how the threshold shifts precision, recall, and the confusion matrix in real time " f"across the held-out test set.

", unsafe_allow_html=True, ) te_area = st.radio( "**Area**", list(FOCUS_AREAS.keys()), format_func=lambda k: FOCUS_AREAS[k], horizontal=True, key="te_area", ) te_preds = _load_test_predictions(te_area) if te_preds.empty: st.warning("No test predictions found. Run `python resources/precompute.py` first.") else: from sklearn.metrics import ( precision_score, recall_score, f1_score, confusion_matrix as _cm_fn, precision_recall_curve, average_precision_score, ) y_true = te_preds["true_label"].to_numpy() y_prob = te_preds["probability"].to_numpy() # ── Score distribution + slider ────────────────────────────────────── threshold = st.slider( "Classification Threshold", min_value=0.01, max_value=0.99, value=0.50, step=0.01, key="te_threshold", ) _te_bins = np.linspace(0, 1, 41) _te_centers = (_te_bins[:-1] + _te_bins[1:]) / 2 _te_bw = _te_bins[1] - _te_bins[0] fig_dist = go.Figure() for _lv, _ln, _lc, _lo in [ (0, "No Loss", t["primary"], 0.55), (1, "Forest Loss", "#e76f51", 0.85), ]: _mask = y_true == _lv _counts, _ = np.histogram(y_prob[_mask], bins=_te_bins) _density = _counts / max(_counts.sum(), 1) fig_dist.add_trace(go.Bar( x=_te_centers, y=_density, name=_ln, marker_color=_rgba(_lc, _lo), width=_te_bw * 0.9, )) fig_dist.add_vline( x=threshold, line=dict(color="#e63946", width=2, dash="dash"), annotation=dict( text=f"Threshold: {threshold:.2f}", font=dict(color="#e63946", size=11, family=t["font"]), yref="paper", y=1.06, ), ) _plotly_defaults(fig_dist, f"Score Distribution — {FOCUS_AREAS[te_area]}") fig_dist.update_layout( barmode="overlay", height=260, xaxis=dict(title="Predicted Probability", range=[0, 1]), yaxis=dict(title="% of class pixels", tickformat=".1%"), legend=dict(title=None, x=0.72, y=0.95), margin=dict(l=20, r=20, t=50, b=40), ) st.plotly_chart(fig_dist, width='stretch') st.caption( "Each bar shows what fraction of that class's pixels received that probability score. " "Overlapping distributions make classification harder — the gap between the curves shows " "how cleanly the model separates loss from no-loss pixels." ) # ── Live metrics ───────────────────────────────────────────────────── y_pred = (y_prob >= threshold).astype(int) _mat = _cm_fn(y_true, y_pred) tn, fp, fn, tp = _mat.ravel() precision = float(precision_score(y_true, y_pred, zero_division=0)) recall = float(recall_score(y_true, y_pred, zero_division=0)) f1 = float(f1_score(y_true, y_pred, zero_division=0)) fnr = fn / max(fn + tp, 1) fpr = fp / max(fp + tn, 1) total = max(tn + fp + fn + tp, 1) col_cm, col_met = st.columns([3, 2]) with col_cm: _mat_z = np.array([[tn, fp], [fn, tp]]) _mat_text = [ [f"{tn:,}
({tn/total*100:.1f}%)", f"{fp:,}
({fp/total*100:.1f}%)"], [f"{fn:,}
({fn/total*100:.1f}%)", f"{tp:,}
({tp/total*100:.1f}%)"], ] fig_cm = go.Figure(go.Heatmap( z=_mat_z, text=_mat_text, texttemplate="%{text}", colorscale=[[0, t["bg"]], [0.5, t["primary"]], [1, t["accent"]]], showscale=False, x=["Pred: No Loss", "Pred: Loss"], y=["Actual: No Loss", "Actual: Loss"], )) _plotly_defaults(fig_cm, f"Confusion Matrix @ threshold {threshold:.2f}") fig_cm.update_layout(height=320, margin=dict(l=80, r=10, t=50, b=60)) st.plotly_chart(fig_cm, width='stretch') with col_met: st.markdown("
", unsafe_allow_html=True) mc1, mc2, mc3 = st.columns(3) with mc1: st.metric("Precision", f"{precision:.3f}") with mc2: st.metric("Recall", f"{recall:.3f}") with mc3: st.metric("F1", f"{f1:.3f}") st.markdown("
", unsafe_allow_html=True) st.markdown( f"
" f"
" f"
True Positives
" f"{tp:,}
" f"
False Positives
" f"{fp:,}
" f"
False Negatives
" f"{fn:,}
" f"
True Negatives
" f"{tn:,}
" f"
" f"
" f"FNR {fnr:.2%} — missed deforestation events
" f"FPR {fpr:.2%} — false alarms" f"
", unsafe_allow_html=True, ) # ── PR curve with operating point ───────────────────────────────────── _prec_curve, _rec_curve, _ = precision_recall_curve(y_true, y_prob) _ap = average_precision_score(y_true, y_prob) _pos_rate = float(y_true.mean()) fig_pr = go.Figure() fig_pr.add_trace(go.Scatter( x=_rec_curve, y=_prec_curve, mode="lines", name=f"PR Curve (AP={_ap:.4f})", line=dict(color=t["accent"], width=2.5), fill="tozeroy", fillcolor=_rgba(t["accent"], 0.07), )) fig_pr.add_hline( y=_pos_rate, line=dict(color=_rgba(t["text_muted"], 0.5), dash="dot", width=1.5), annotation=dict( text=f"Random ({_pos_rate:.2%} positive rate)", font=dict(size=10, color=t["text_muted"], family=t["font"]), x=0.01, xanchor="left", ), ) fig_pr.add_trace(go.Scatter( x=[recall], y=[precision], mode="markers", name=f"Threshold {threshold:.2f}", marker=dict( size=14, color="#e63946", symbol="circle", line=dict(width=2.5, color="white"), ), )) _plotly_defaults(fig_pr, f"Precision-Recall Curve — {FOCUS_AREAS[te_area]}") fig_pr.update_layout( height=320, xaxis=dict(title="Recall", range=[0, 1.01]), yaxis=dict(title="Precision", range=[0, 1.05]), legend=dict(x=0.5, y=0.06, xanchor="center"), ) st.plotly_chart(fig_pr, width='stretch') st.caption( "The red dot marks your current threshold's operating point on the PR curve. " "Move the slider to trace the precision-recall tradeoff. " "Area under the curve (AP) summarises performance across all thresholds." ) # ═══════════════════════════════════════════════════════════════════════════════ # PAGE 4 — HOW I BUILT THIS # ═══════════════════════════════════════════════════════════════════════════════ def _render_page_4(): t = THEME _section_header("How I Built This", img_path=str(ICONS / "construction_guy.png")) tab_build, tab_credits = st.tabs([" 🛠️ **Build**", " 💳 **Credits**"], on_change='rerun') with tab_build: # ── Section 1: Architecture diagram ────────────────────────────────────── _section_header("Pipeline Architecture", img_path=str(ICONS / "pipeline.png")) nodes = { "Google Earth Engine": "GEE hosts the GOOGLE/SATELLITE_EMBEDDING/V1/ANNUAL ImageCollection — 64-band annual embeddings produced by AlphaEarth Foundations. This geospatial embedding model uses multiple data streams like optical, radar, LiDAR, etc. Sampled ~100k pixels per AOI via the Python Earth Engine API, across 8 years (2017-2024)", "Feature Engineering": "Year-over-year embedding deltas (A00–A63), drift_magnitude (L2 norm), summary statistics, within-area z-scores, rolling drift, and prototype similarity features. 19 derived features total. Only drift_magnitude used in final modeling, the rest had minimal effect", "Baseline Experimentation": "Model and data-approach comparison. Three main models tested were logistic regression, random forest, and xgboost classifier. Different data approaches included testing model performance on a single AOI, training an LSTM on the sequence of pixel changes year over year, and training a single model on pixels from multiple AOI's. Best results were found to be from training a single model on a specific area", "Optuna + MLflow": "50+ trial Bayesian hyperparameter search via Optuna, maximizing PR-AUC on a geo-stratified (pixel coordinates) 80/20 split. All trials logged to MLFlow. XGBoost early stopping prevents overfitting.", "XGBoost Classifier": "Trained on 65 features (64 embedding deltas + drift_magnitude), balanced class weights, tuned hyperparameters. Canada: PR-AUC 0.91. Amazon Basin: PR-AUC 0.62.", "Model Evaluation": "PR-AUC, F1, Brier score, confusion matrix, and threshold analysis across the held-out test set. Geo-stratified split prevents spatial leakage.", "Streamlit + Docker": "Single-file Streamlit app with st.cache_data resource loading. Pre-computed artifacts keep every page under 1s. Containerized via Docker, deployed to HuggingFace Spaces via GitHub Actions CI/CD.", } node_list = list(nodes.keys()) # ── Animated pipeline data-flow diagram ─────────────────────────────────── _px = [0.04, 0.20, 0.36, 0.52, 0.68, 0.84, 0.975] _py = [0.72, 0.28, 0.72, 0.28, 0.72, 0.28, 0.72 ] _ph = [ "Google Earth Engine
64-band AEF embeddings
~100k pixels × 7 AOIs · 2017–2024", "Feature Engineering
Year-over-year deltas · L2 drift
19 derived features", "Baseline Experiments
Single-area · sequence · multi-AOI
Validated separate-model approach", "Optuna + MLflow
50+ trial Bayesian search
PR-AUC objective · all trials logged", "XGBoost Classifier
65 features · balanced weights
Geo-stratified 80/20 split", "Model Evaluation
PR-AUC · F1 · Brier · threshold
Canada 0.91 vs Amazon 0.62", "Streamlit + Docker
Pre-computed artifacts
HuggingFace · GitHub Actions", ] _pcards = [ "GEE
100k · 7 AOIs", "Feat Eng
19 features", "Baseline
3 formats", "Optuna
50+ trials", "XGBoost
0.91 / 0.62", "Evaluation
4 metrics", "Deploy
HF + Docker", ] _pedges = [(0, 1), (1, 2), (2, 3), (3, 4), (4, 5), (5, 6)] _emx = [(_px[s] + _px[tg]) / 2 for s, tg in _pedges] _emy = [0.50] * 6 _elabs = ["raw
embeddings", "65
features", "best
format", "best
params", "test
probs", "app
+ model"] _N = 7 _JBM = "JetBrains Mono" _BG = "rgba(0,0,0,0)" # transparent — CSS handles the background # Techy dark palette — cyan/sky/indigo/purple gradient trail _tfc = ["#22d3ee", "#38bdf8", "#818cf8", "#c084fc", "#6b7280"] # fill colors _ttc = ["#0a192f", "#0a192f", "#ffffff", "#ffffff", "#e2e8f0"] # text on fills _tsz = [84, 74, 68, 63, 59] # marker sizes _tbw = [3, 2.5, 2, 1.5, 1.2] # border widths _tbc = [ # border colors "#ffffff", "rgba(56,189,248,0.8)", "rgba(129,140,248,0.6)", "rgba(192,132,252,0.4)", "rgba(107,114,128,0.3)", ] _iac, _iat, _iasz = "#1e293b", "#475569", 52 # inactive style def _node_props(i, active): if i > active: return _iac, _iasz, "rgba(71,85,105,0.4)", 1, _iat k = min(active - i, len(_tfc) - 1) return _tfc[k], _tsz[k], _tbc[k], _tbw[k], _ttc[k] def _ptrace(active): traces = [] for s, tg in _pedges: lit = tg <= active traces.append({ "type": "scatter", "x": [_px[s], _px[tg], None], "y": [_py[s], _py[tg], None], "mode": "lines", "line": { "color": "rgba(34,211,238,0.9)" if lit else "rgba(71,85,105,0.3)", "width": 2.5 if lit else 1.2, }, "showlegend": False, "hoverinfo": "skip", }) _ec = [ "rgba(34,211,238,0.95)" if (ei + 1) <= active else "rgba(0,0,0,0)" for ei in range(6) ] traces.append({ "type": "scatter", "x": _emx, "y": _emy, "mode": "text", "text": _elabs, "textfont": {"family": _JBM, "size": 10, "color": _ec}, "showlegend": False, "hoverinfo": "skip", }) traces.append({ "type": "scatter", "x": _px, "y": _py, "mode": "markers", "marker": { "color": [ "rgba(34,211,238,0.2)" if i == active else "rgba(0,0,0,0)" for i in range(_N) ], "size": 110, "symbol": "circle", "line": {"width": 0}, }, "showlegend": False, "hoverinfo": "skip", }) nc, ns, nbc, nbw, ntc = [], [], [], [], [] for i in range(_N): fill, size, bc, bw, tc = _node_props(i, active) nc.append(fill) ns.append(size) nbc.append(bc) nbw.append(bw) ntc.append(tc) traces.append({ "type": "scatter", "x": _px, "y": _py, "mode": "markers+text", "marker": { "color": nc, "size": ns, "line": {"color": nbc, "width": nbw}, "symbol": "path://M -0.7,-1 L 0.7,-1 Q 1,-1 1,-0.7 L 1,0.7 Q 1,1 0.7,1 L -0.7,1 Q -1,1 -1,0.7 L -1,-0.7 Q -1,-1 -0.7,-1 Z", }, "text": _pcards, "textposition": "middle center", "textfont": {"family": _JBM, "size": 11, "color": ntc}, "hovertext": _ph, "hovertemplate": "%{hovertext}", "showlegend": False, }) return traces _frames = [{"name": str(fi), "data": _ptrace(fi - 1)} for fi in range(_N + 1)] _fig = { "data": _ptrace(-1), "layout": { "paper_bgcolor": _BG, "plot_bgcolor": _BG, "xaxis": {"visible": False, "range": [-0.03, 1.08]}, "yaxis": {"visible": False, "range": [0.0, 1.0]}, "margin": {"l": 10, "r": 10, "t": 58, "b": 10}, "height": 390, "font": {"family": _JBM, "color": "#e2e8f0", "size": 11}, "title": { "text": "ML Pipeline", "font": {"family": _JBM, "color": "#ffffff", "size": 22}, "x": 0.5, "xanchor": "center", "y": 0.95, }, "updatemenus": [], "sliders": [], "showlegend": False, }, "frames": _frames, } _fj = json.dumps(_fig) _html = ( "" "" "" "" "
" "
" "" ) st.markdown( "", unsafe_allow_html=True, ) st.iframe(_html, height=410) st.caption("Each node pulses as data flows through the pipeline. Hover over a node for details.") _stage_img = inline_img(path=str(ICONS / "explore_stage.png"), height="1.2em") st.markdown( f"

{_stage_img} {"Explore a Stage"}

", unsafe_allow_html=True, ) selected_node = st.selectbox("Explore a stage", node_list, key="arch_node", label_visibility="collapsed") _card( f"{selected_node}
" f"{nodes[selected_node]}" ) # ── Section 2: Build timeline ───────────────────────────────────────────── _section_header("Build Timeline", img_path=str(ICONS / "timeline.png")) timeline = [ ("Day 1", "GEE Data Acquisition", "Set up config.yaml with 7 AOIs, authenticated GEE, ran sample export, implemented quality gate and data cleaner."), ("Day 2", "EDA", "Explored class imbalance (range of 0.5% - 1.6%), PCA of embedding space, mean profiles by class and area, cosine similarity analysis."), ("Day 3", "Feature Engineering", "Built year-over-year drift pipeline: embedding deltas, L2 drift magnitude, z-scores, rolling windows, cosine similarity to loss/no-loss prototypes."), ("Day 4", "Baseline Models", "Trained logistic regression/SGD, random forest, and XGBoost classifier with balanced class weights. Established evaluation framework with PR-AUC as primary metric."), ("Day 5", "Data Approach Comparison", "Tested three data strategies: single-area models, sequential time-series framing, and joint multi-AOI training. The multi-area approach degraded performance in high-signal regions. Decided to train separate models per AOI to preserve region-specific signal."), ("Day 6", "Optuna Tuning + Results", "50 +trial Bayesian optimization with MLflow tracking across both focus regions. Canada reached PR-AUC 0.91; Amazon Basin stalled at 0.62 despite identical features and tuning budget. The performance gap became the story: What does the model learn in boreal Canada that it can't transfer elsewhere?"), ("Day 7", "Portfolio App", "Built multi-page Streamlit showcase with pre-computed artifacts, interactive threshold explorer, per-region model comparison, animated pipeline diagram, and professional theming. Containerized with Docker."), ("Day 8", "CI/CD Pipeline", "Added GitHub Actions for automated testing and deployment. On merge to main, the workflow builds the Docker image, runs the test suite, and pushes updated artifacts to HuggingFace Spaces."), ] _r = 27 _circ = 2 * np.pi * _r _total = len(timeline) for _idx, (day, title, desc) in enumerate(timeline): _n = _idx + 1 _offset = _circ * (1 - _n / _total) _arc = t["accent"] if _n < _total else t["primary"] _svg = ( f"" f"" f"" f"DAY" f"{_n}" f"" ) st.markdown( f"
" f"
{_svg}
" f"
" f"{title} " f"— {desc}" f"
", unsafe_allow_html=True, ) # ── Section 3: Key decisions ────────────────────────────────────────────── _section_header("Key Decisions & Lessons", img_path=str(ICONS / "key_decision.png")) decisions = [ ("Same Pixel, Different Years", "The initial setup treated each pixel from a single year as an independent sample — a snapshot with no temporal context. Results were modest. " "The first shift was sampling the same ~100k pixels across all 8 available years (2017–2024), producing ~800k rows per AOI. " "This opened the door to year-over-year drift: for each pixel, how much had the embedding shifted from the prior year? That delta became the core signal. " "A sequential framing was tested next — treating each pixel's 8-year history as a time series — but this added sequence modeling complexity without improving PR-AUC over the simpler per-year approach. " "The final structure landed on: each (pixel, year) pair is one independent row, with year-over-year cosine drift engineered as a feature. " "It captures temporal change without requiring a sequence model, and scales cleanly across all 7 AOIs."), ("Why compare multiple regions instead of one model", "A model trained only on Canada performed well there but dropped below 0.5 PR-AUC when validated on Guinea — worse than chance. " "The spectral signature of a Canadian clear-cut (sudden, large shift) is structurally different from tropical encroachment (gradual thinning, persistent cloud noise). " "Training a single global model across all 7 AOIs produced mediocre results everywhere — it couldn't specialize. " "Separate per-region models brought Canada to 0.91 but left Amazon at 0.62 despite identical features and tuning budget. " "That gap stopped being a failure and became the central question: what does the model learn in boreal Canada that it can't transfer elsewhere?"), ("Drift magnitude as the only engineered feature", "The original hypothesis was that pixels with a larger drift signal from the previous year would be a proxy for loss of tree coverage. The signal is not as clear cut as I " "originally thought. Satellite embedding contain information for the entire year of the image and contain noisy signals like weather patterns that would affect drift. " "In cases of deforestation, some areas could have gradual drift in the preceeding years until true loss occurs. Still, it does contain some predictive signal. " "Other engineered features were tested and none improved results"), ("Handling severe class imbalance", "Used balanced class weights in all models, optimized for PR-AUC over accuracy, and chose Brier score to validate calibration. A naive classifier hits 95%+ accuracy by predicting no loss — this metric is misleading for rare-event detection"), ("Geo-stratified train/test split", "The same pixel was sampled from Earth Engine 8 times, once for each year of satellite availabilty. Standard random splits would mean the model would be trained and then " "tested on the same coordinates from different years, introducing data leakage. Splitting by .geo (pixel location) ensures no pixel appears in both train and test"), ("Optuna over grid search", "An Optuna framework for tuning is overkill for Logistic Regression but it's perfect for Random Forest and XGboost both in terms of sampling (TPE sampling that exploits Bayesian " "Optimization) and for tracking the larger number of tunable parameters and ranges"), ] for title, content in decisions: with st.expander(f"{title}", expanded=False): st.markdown(content) # ── Section 4: GitHub ───────────────────────────────────────────────────── _section_header("Source Code", img_path=str(ICONS / "source_code.png")) col_gh, _ = st.columns([2, 4]) with col_gh: st.link_button("🔗 **View on GitHub**", url="https://github.com/tkbarb10/earth-engine", width=300) st.caption("Experiment yourself and improve on anything I did") if tab_credits.open: with tab_credits: st.balloons() # ── Data Sources ────────────────────────────────────────────────────── _section_header("Data Sources", "🛰️") _data_sources = [ ( "Google Earth Engine", "Cloud computing platform for planetary-scale geospatial analysis. Used to sample the satellite embedding ImageCollection across 7 AOIs, 2017–2024.", "Google", "https://earthengine.google.com", ), ( "Satellite Embeddings — GOOGLE/SATELLITE_EMBEDDING/V1/ANNUAL", "64-band annual geospatial embeddings produced by AlphaEarth Foundations (AEF) using optical, radar, and LiDAR data streams. The core feature input for all models.", "AlphaEarth / Google", "https://developers.google.com/earth-engine/datasets/catalog/GOOGLE_SATELLITE_EMBEDDING_V1_ANNUAL", ), ( "Global Forest Change — Tree Cover Loss Labels", "Annual tree cover loss labels (2000–2023) from Hansen et al., 2013, University of Maryland. Hosted and distributed by Global Forest Watch.", "Hansen et al. / UMD / Global Forest Watch", "https://www.globalforestwatch.org", ), ] for _ds_name, _ds_desc, _ds_credit, _ds_url in _data_sources: st.markdown( f"
" f"
{_ds_name}
" f"
{_ds_desc}
" f"{_ds_credit} ↗" f"
", unsafe_allow_html=True, ) # ── Icons ───────────────────────────────────────────────────────────── _section_header("Icons", "🎨") st.markdown( f"

" f"All icons from Flaticon, used under the free license with attribution.

", unsafe_allow_html=True, ) _icons = [ ("flow.png", "Air flow", "Freepik", "https://www.flaticon.com/free-icons/air-flow"), ("forest_loss.png", "Deforestation", "Flat Icons", "https://www.flaticon.com/free-icons/deforestation"), ("construction_guy.png", "Builder", "Good Ware", "https://www.flaticon.com/free-icons/builder"), ("key_stats.png", "Vital signs", "Elzicon", "https://www.flaticon.com/free-icons/vital-signs"), ("project_overview.png", "Strategy", "Freepik", "https://www.flaticon.com/free-icons/strategy"), ("explore.png", "Explore", "Freepik", "https://www.flaticon.com/free-icons/explore"), ("crystal_ball.png", "Crystal ball", "Freepik", "https://www.flaticon.com/free-icons/crystal-ball"), ("tech_stack.png", "Tech stack", "Freepik", "https://www.flaticon.com/free-icons/data"), ("earth.png", "Ecology", "Fahrul Oktaviana", "https://www.flaticon.com/free-icons/ecology"), ("pipeline.png", "Data flow", "Freepik", "https://www.flaticon.com/free-icons/data-flow"), ("timeline.png", "Timer", "Freepik", "https://www.flaticon.com/free-icons/timer"), ("key_decision.png", "Possibility", "Nuricon", "https://www.flaticon.com/free-icons/possibility"), ("source_code.png", "Open source", "juicy_fish", "https://www.flaticon.com/free-icons/open-source"), ("feature_importance.png","Priority", "Freepik", "https://www.flaticon.com/free-icons/priority"), ("explore_stage.png", "Open book", "Karyative", "https://www.flaticon.com/free-icons/open-book"), ] _ccols = st.columns(2) for _ci, (_fname, _iname, _creator, _url) in enumerate(_icons): with _ccols[_ci % 2]: _img = inline_img(ICONS / _fname, height="32px", fallback="") st.markdown( f"
" f"{_img}" f"
{_iname}
" f"
" f"{_creator}" f" · Flaticon
", unsafe_allow_html=True, ) # ═══════════════════════════════════════════════════════════════════════════════ # MAIN ROUTING # ═══════════════════════════════════════════════════════════════════════════════ def main(): _inject_theme() page = _render_nav() if page == "Project Overview": _render_page_1() elif page == "Explore the Data": _render_page_2() elif page == "Model Results": _render_page_3() elif page == "How I Built This": _render_page_4() _render_footer() if __name__ == "__main__": main()