Spaces:
Running
Running
| """ | |
| 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'<link rel="preconnect" href="https://fonts.googleapis.com">\n' | |
| f'<link rel="stylesheet" href="{t["font_url"]}">\n' | |
| f'<style>\n{css}\n</style>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ββ Data loaders βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _load_kpi() -> dict: | |
| path = CACHE / "kpi_summary.json" | |
| if not path.exists(): | |
| return {} | |
| return json.loads(path.read_text()) | |
| def _load_embedding_profile() -> pd.DataFrame: | |
| path = CACHE / "mean_embedding_profile.parquet" | |
| if not path.exists(): | |
| return pd.DataFrame() | |
| return pd.read_parquet(path) | |
| 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) | |
| def _load_comparison_kpis() -> dict: | |
| path = CACHE / "comparison_kpis.json" | |
| if not path.exists(): | |
| return {} | |
| return json.loads(path.read_text()) | |
| 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()) | |
| 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()) | |
| 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()) | |
| 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) | |
| def _load_aoi_stats() -> dict: | |
| path = CACHE / "aoi_stats.json" | |
| if not path.exists(): | |
| return {} | |
| return json.loads(path.read_text()) | |
| 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"<div style='background:{t['card_bg']};border:1px solid {t['border']}44;" | |
| f"border-radius:12px;padding:1.2rem 1.4rem;margin-bottom:0.8rem;'>{content}</div>", | |
| 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"<span class='sh-hover' style='position:relative;display:inline-block;" | |
| f"vertical-align:middle;cursor:default;'>{raw_img}" | |
| f"<span class='sh-popup'>" | |
| f"<img src='{hover_gif}' style='width:100%;border-radius:7px;display:block;'>" | |
| f"</span></span>" | |
| ) | |
| else: | |
| icon = raw_img | |
| else: | |
| icon = emoji | |
| st.markdown( | |
| f"<h3 style='color:{t['text']};font-family:{t['font']},sans-serif;" | |
| f"margin-top:1.5rem;margin-bottom:0.8rem;display:flex;align-items:center;gap:0.4em;'>" | |
| f"{icon} {text}</h3>", | |
| unsafe_allow_html=True, | |
| ) | |
| # ββ Footer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _render_footer(): | |
| t = THEME | |
| st.markdown("---") | |
| st.markdown( | |
| f"<div style='text-align:center;padding:1rem 0 0.5rem;" | |
| f"font-family:{t['font']},sans-serif;font-size:0.8rem;color:{t['text_muted']};'>" | |
| f"π² <a href='https://smokeybear.com/' target='_blank' " | |
| f"title='This isnt a real partnership, but I thought it could be an \ninteresting resource'" | |
| f"style='color:{t['text_muted']};text-decoration:none;font-weight:600;'>" | |
| f"Smokey the Bear Partnership</a> | Deforestation Detection via Satellite Embeddings" | |
| f" | Built with Streamlit + XGBoost + Google Earth Engine" | |
| f"</div>", | |
| 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"<p style='{_label_style}'>Navigate</p>", unsafe_allow_html=True) | |
| # Native GET form buttons β browser-level navigation, same tab, no JS needed | |
| nav_css = ( | |
| f"<style>" | |
| f"form.nf{{margin:0;padding:0;}}" | |
| f"button.ni{{display:flex;align-items:center;gap:10px;width:200px;max-width:100%;" | |
| f"padding:0.45rem 0.8rem;border-radius:8px;background:transparent;" | |
| f"border:none;border-left:3px solid transparent;" | |
| f"color:{txt};font-family:{font},sans-serif;font-size:0.9rem;" | |
| f"font-weight:500;cursor:pointer;transition:background 0.15s;" | |
| f"margin-bottom:3px;text-align:left;}}" | |
| f"button.ni:hover{{background:rgba(64,145,108,0.09);}}" | |
| f"button.ni.na{{background:rgba(64,145,108,0.13);" | |
| f"border-left-color:{accent};font-weight:600;}}" | |
| f"</style>" | |
| ) | |
| 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"<form class='nf' method='GET' action=''>" | |
| f"<button type='submit' name='page' value='{name}' class='{cls}'>" | |
| f"{img} {name}</button>" | |
| f"</form>" | |
| ) | |
| st.html(nav_css + nav_items) | |
| st.divider() | |
| st.markdown(f"<p style='{_label_style}'>Other Regions</p>", 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"<div style='padding:2rem 0 1rem;'>" | |
| f"<h1 style='font-family:{t['font']},sans-serif;font-size:2.8rem;" | |
| f"font-weight:800;color:{t['text']};line-height:1.15;margin:0 0 0.6rem;'>" | |
| f"Detecting Deforestation Through<br>" | |
| f"<span style='color:{t['accent']};'>Satellite Embedding Drift</span>" | |
| f"</h1>" | |
| f"<p style='font-family:{t['font']},sans-serif;font-size:1.15rem;" | |
| f"color:{t['accent']};font-weight:500;margin:0 0 1rem;'>" | |
| f"A machine learning pipeline spanning 7 global regions, 5.7 million 30m resolution pixel-years, and 8 years of satellite data." | |
| f"</p>", | |
| 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("</div>", 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"<div style='background:{t['card_bg']};border:1px solid {t['border']};" | |
| f"border-top:4px solid {t['primary']};border-radius:{t['card_radius']};" | |
| f"padding:1.2rem 1.4rem;'>" | |
| f"<div style='font-size:1.1rem;font-weight:700;color:{t['text']};margin-bottom:0.6rem;'>π Canada</div>" | |
| f"<div style='color:{t['accent']};font-size:1.6rem;font-weight:800;'>PR-AUC {ca.get('pr_auc', 0):.4f} {_check_img}</div>" | |
| f"<div style='color:{t['text_muted']};font-size:0.88rem;margin-top:0.4rem;'>" | |
| f"F1 {ca.get('f1', 0):.4f} | Recall {ca.get('recall', 0):.4f} | " | |
| f"Precision {ca.get('precision', 0):.4f}</div>" | |
| f"<div style='color:{t['text_muted']};font-size:0.85rem;margin-top:0.3rem;'>" | |
| f"Loss rate {ca.get('loss_rate', 0)*100:.2f}% | {ca.get('n_rows', 0):,} pixel-years</div>" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| with col_am: | |
| st.markdown( | |
| f"<div style='background:{t['card_bg']};border:1px solid {t['border']};" | |
| f"border-top:4px solid #e76f51;border-radius:{t['card_radius']};" | |
| f"padding:1.2rem 1.4rem;'>" | |
| f"<div style='font-size:1.1rem;font-weight:700;color:{t['text']};margin-bottom:0.6rem;'>π³ Amazon Basin</div>" | |
| f"<div style='color:#e76f51;font-size:1.6rem;font-weight:800;'>PR-AUC {am.get('pr_auc', 0):.4f} β οΈ</div>" | |
| f"<div style='color:{t['text_muted']};font-size:0.88rem;margin-top:0.4rem;'>" | |
| f"F1 {am.get('f1', 0):.4f} | Recall {am.get('recall', 0):.4f} | " | |
| f"Precision {am.get('precision', 0):.4f}</div>" | |
| f"<div style='color:{t['text_muted']};font-size:0.85rem;margin-top:0.3rem;'>" | |
| f"Loss rate {am.get('loss_rate', 0)*100:.2f}% | {am.get('n_rows', 0):,} pixel-years</div>" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| with col_delta: | |
| st.markdown( | |
| f"<div style='background:{t['card_bg']};border:1px solid {t['border']};" | |
| f"border-radius:{t['card_radius']};padding:1.2rem 1rem;text-align:center;'>" | |
| f"<div style='color:{t['text_muted']};font-size:0.8rem;font-weight:600;'>Δ PR-AUC</div>" | |
| f"<div style='color:#e76f51;font-size:1.5rem;font-weight:800;margin:0.3rem 0;'>-{delta:.4f}</div>" | |
| f"<div style='color:{t['text_muted']};font-size:0.78rem;'>Same model.<br>Same features.<br>See Page 3 for why.</div>" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("<div style='margin-bottom:1rem;'></div>", unsafe_allow_html=True) | |
| # ββ Why this matters βββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _panda_img = inline_img(ICONS / "panda-svgrepo-com.svg", height="1.5em") | |
| st.markdown( | |
| f"<div style='background:{t['card_bg']};border-left:4px solid {t['primary']};" | |
| f"border-radius:0 8px 8px 0;padding:1rem 1.4rem;margin:0.5rem 0 1.5rem;'>" | |
| f"<p style='font-family:{t['font']},sans-serif;color:{t['text']};margin:0;font-size:0.95rem;'>" | |
| f"<strong style='color:{t['accent']};'>Why this matters:</strong> " | |
| "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" | |
| "</p></div>", | |
| 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""" | |
| <style> | |
| .gee-tip {{ | |
| position: relative; | |
| display: inline-block; | |
| }} | |
| .gee-tip::after {{ | |
| content: "You found it! Click here for a link that'll take you to Google Earth and run a timelapse of land change in the San Diego area"; | |
| position: absolute; | |
| bottom: calc(100% + 10px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: {t['card_bg']}; | |
| color: {t['text']}; | |
| border: 1px solid {t['border']}; | |
| padding: 10px 14px; | |
| border-radius: 10px; | |
| font-size: 0.8rem; | |
| font-family: {t['font']}, sans-serif; | |
| width: 240px; | |
| text-align: center; | |
| white-space: normal; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.2s ease; | |
| z-index: 1000; | |
| box-shadow: 0 6px 20px rgba(0,0,0,0.35); | |
| }} | |
| .gee-tip::before {{ | |
| content: ''; | |
| position: absolute; | |
| bottom: calc(100% + 4px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| border: 6px solid transparent; | |
| border-top-color: {t['border']}; | |
| pointer-events: none; | |
| opacity: 0; | |
| transition: opacity 0.2s ease; | |
| z-index: 1000; | |
| }} | |
| .gee-tip:hover::after, | |
| .gee-tip:hover::before {{ | |
| opacity: 1; | |
| }} | |
| </style> | |
| """ | |
| badge_parts = [] | |
| for name, icon, url in badges: | |
| span = f"<span style='{_span_style}'>{icon} {name}</span>" | |
| if url: | |
| badge_parts.append( | |
| f"<span class='gee-tip'>" | |
| f"<a href='{url}' target='_blank' style='text-decoration:none;cursor:pointer;'>{span}</a>" | |
| f"</span>" | |
| ) | |
| else: | |
| badge_parts.append(span) | |
| badge_html = " ".join(badge_parts) | |
| st.markdown(f"{_tooltip_css}<div style='margin-bottom:1rem;'>{badge_html}</div>", 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"<div style='background:#d4edda;border:1px solid #b7e4c7;border-radius:8px;padding:0.75rem 1rem;'>" | |
| 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"</div>", | |
| 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", "<br>") | |
| 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"<b>{_aoi_name}</b><br>{_hover_text}<extra></extra>", | |
| )) | |
| 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("<div style='margin-top:1.2rem;width:100px;'><p style='font-size:1rem;font-weight:700;margin-bottom:0.3rem;text-align:center;'>Year</p></div>", 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"<div style='font-size:1rem;font-weight:700;color:{t['text']};margin-bottom:0.5rem;'>{label}</div>", | |
| 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:,}<br>({tn/total*100:.1f}%)", f"{fp:,}<br>({fp/total*100:.1f}%)"], | |
| [f"{fn:,}<br>({fn/total*100:.1f}%)", f"{tp:,}<br>({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"<p style='color:{t['text_muted']};font-size:0.95rem;margin-bottom:1.5rem;'>" | |
| f"The same XGBoost architecture, the same 65 features, trained separately on two regions. " | |
| f"Canada scores <strong>PR-AUC 0.91</strong>. Amazon Basin scores <strong>PR-AUC 0.62</strong>. " | |
| f"The sections below hypothesize what the numbers reveal.</p>", | |
| 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"<p style='font-family:{t['font']},sans-serif;color:{t['text_muted']};font-size:0.95rem;margin-bottom:1rem;'>" | |
| 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.</p>", | |
| 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:,}<br>({tn/total*100:.1f}%)", f"{fp:,}<br>({fp/total*100:.1f}%)"], | |
| [f"{fn:,}<br>({fn/total*100:.1f}%)", f"{tp:,}<br>({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("<div style='height:1rem;'></div>", 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("<div style='height:0.4rem;'></div>", unsafe_allow_html=True) | |
| st.markdown( | |
| f"<div style='background:{t['card_bg']};border:1px solid {t['border']}44;" | |
| f"border-radius:12px;padding:1rem 1.2rem;" | |
| f"font-family:{t['font']},sans-serif;font-size:0.88rem;'>" | |
| f"<div style='display:grid;grid-template-columns:1fr 1fr;gap:0.7rem;'>" | |
| f"<div><span style='color:{t['text_muted']};'>True Positives</span><br>" | |
| f"<strong style='color:{t['accent']};font-size:1.15rem;'>{tp:,}</strong></div>" | |
| f"<div><span style='color:{t['text_muted']};'>False Positives</span><br>" | |
| f"<strong style='color:#f4a261;font-size:1.15rem;'>{fp:,}</strong></div>" | |
| f"<div><span style='color:{t['text_muted']};'>False Negatives</span><br>" | |
| f"<strong style='color:#e76f51;font-size:1.15rem;'>{fn:,}</strong></div>" | |
| f"<div><span style='color:{t['text_muted']};'>True Negatives</span><br>" | |
| f"<strong style='color:{t['primary']};font-size:1.15rem;'>{tn:,}</strong></div>" | |
| f"</div>" | |
| f"<div style='margin-top:0.8rem;padding-top:0.6rem;" | |
| f"border-top:1px solid {t['border']}44;color:{t['text_muted']};font-size:0.8rem;'>" | |
| f"FNR {fnr:.2%} β missed deforestation events<br>" | |
| f"FPR {fpr:.2%} β false alarms" | |
| f"</div></div>", | |
| 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 = [ | |
| "<b>Google Earth Engine</b><br>64-band AEF embeddings<br>~100k pixels Γ 7 AOIs Β· 2017β2024", | |
| "<b>Feature Engineering</b><br>Year-over-year deltas Β· L2 drift<br>19 derived features", | |
| "<b>Baseline Experiments</b><br>Single-area Β· sequence Β· multi-AOI<br>Validated separate-model approach", | |
| "<b>Optuna + MLflow</b><br>50+ trial Bayesian search<br>PR-AUC objective Β· all trials logged", | |
| "<b>XGBoost Classifier</b><br>65 features Β· balanced weights<br>Geo-stratified 80/20 split", | |
| "<b>Model Evaluation</b><br>PR-AUC Β· F1 Β· Brier Β· threshold<br>Canada 0.91 vs Amazon 0.62", | |
| "<b>Streamlit + Docker</b><br>Pre-computed artifacts<br>HuggingFace Β· GitHub Actions", | |
| ] | |
| _pcards = [ | |
| "GEE<br>100k Β· 7 AOIs", | |
| "Feat Eng<br>19 features", | |
| "Baseline<br>3 formats", | |
| "Optuna<br>50+ trials", | |
| "XGBoost<br>0.91 / 0.62", | |
| "Evaluation<br>4 metrics", | |
| "Deploy<br>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<br>embeddings", "65<br>features", "best<br>format", "best<br>params", "test<br>probs", "app<br>+ 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}<extra></extra>", | |
| "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 = ( | |
| "<!DOCTYPE html><html><head>" | |
| "<link href='https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap' rel='stylesheet'>" | |
| "<script src='https://cdn.plot.ly/plotly-2.27.0.min.js'></script>" | |
| "<style>" | |
| "html,body{margin:0;padding:0;overflow:hidden;border-radius:8px;" | |
| "background:#112240;" | |
| "background-image:" | |
| "radial-gradient(circle,rgba(34,211,238,0.18) 1px,transparent 1px)," | |
| "radial-gradient(ellipse at 5% 92%,rgba(99,102,241,0.22) 0%,transparent 48%)," | |
| "radial-gradient(ellipse at 95% 8%,rgba(34,211,238,0.15) 0%,transparent 40%);" | |
| "background-size:28px 28px,100% 100%,100% 100%;}" | |
| "#pc{width:100%;height:390px;}" | |
| "</style></head><body>" | |
| "<div id='pc'></div>" | |
| "<div style='position:fixed;inset:0;pointer-events:none;z-index:999;" | |
| "background:" | |
| "linear-gradient(to right,rgba(10,25,55,0.65) 0%,transparent 10%,transparent 90%,rgba(10,25,55,0.65) 100%)," | |
| "linear-gradient(to bottom,rgba(10,25,55,0.45) 0%,transparent 5%,transparent 92%,rgba(10,25,55,0.55) 100%);" | |
| "'></div>" | |
| "<script>" | |
| "var fd=" + _fj + ";" | |
| "Plotly.newPlot('pc',fd.data,fd.layout,{displayModeBar:false,responsive:true})" | |
| ".then(function(){" | |
| "Plotly.addFrames('pc',fd.frames);" | |
| "function go(){" | |
| "Plotly.animate('pc',null,{" | |
| "frame:{duration:750,redraw:true}," | |
| "transition:{duration:450,easing:'cubic-in-out'}," | |
| "fromcurrent:false,mode:'immediate'" | |
| "}).then(function(){setTimeout(go,1600);});}" | |
| "go();});" | |
| "</script></body></html>" | |
| ) | |
| st.markdown( | |
| "<style>" | |
| "[data-testid='stIFrame']>iframe," | |
| "[data-testid='stCustomComponentV1']>iframe," | |
| "iframe[scrolling='no']," | |
| ".stHtml iframe," | |
| ".element-container iframe{" | |
| "border-radius:14px!important;overflow:hidden!important;" | |
| "box-shadow:0 8px 36px rgba(0,0,0,0.38),0 2px 10px rgba(0,0,0,0.22)!important;" | |
| "}</style>", | |
| 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"<h3 style='color:{t['text']};font-family:{t['font']},sans-serif;" | |
| f"margin-top:1.5rem;margin-bottom:1.0rem;'>{_stage_img} {"Explore a Stage"}</h3>", | |
| unsafe_allow_html=True, | |
| ) | |
| selected_node = st.selectbox("Explore a stage", node_list, key="arch_node", label_visibility="collapsed") | |
| _card( | |
| f"<strong style='color:{t['accent']};'>{selected_node}</strong><br>" | |
| f"<span style='font-size:0.9rem;color:{t['text']};'>{nodes[selected_node]}</span>" | |
| ) | |
| # ββ 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"<svg width='76' height='76' viewBox='0 0 72 72'>" | |
| f"<circle cx='36' cy='36' r='{_r}' fill='{t['card_bg']}' " | |
| f"stroke='{t['border']}' stroke-width='4'/>" | |
| f"<circle cx='36' cy='36' r='{_r}' fill='none' stroke='{_arc}' stroke-width='4' " | |
| f"stroke-dasharray='{_circ:.2f}' stroke-dashoffset='{_offset:.2f}' " | |
| f"stroke-linecap='round' transform='rotate(-90 36 36)'/>" | |
| f"<text x='36' y='30' text-anchor='middle' dominant-baseline='middle' " | |
| f"font-family='{t['font']},sans-serif' font-size='7' font-weight='600' " | |
| f"fill='{t['text_muted']}' letter-spacing='1'>DAY</text>" | |
| f"<text x='36' y='42' text-anchor='middle' dominant-baseline='middle' " | |
| f"font-family='{t['font']},sans-serif' font-size='16' font-weight='800' " | |
| f"fill='{t['primary']}'>{_n}</text>" | |
| f"</svg>" | |
| ) | |
| st.markdown( | |
| f"<div style='display:flex;align-items:center;gap:1rem;margin-bottom:0.8rem;'>" | |
| f"<div style='flex-shrink:0;'>{_svg}</div>" | |
| f"<div style='flex:1;background:{t['card_bg']};border:1px solid {t['border']}44;" | |
| f"border-radius:12px;padding:1.2rem 1.4rem;'>" | |
| f"<strong style='color:{t['accent']};'>{title}</strong> " | |
| f"<span style='font-size:0.88rem;color:{t['text_muted']};'>β {desc}</span>" | |
| f"</div></div>", | |
| 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"<div style='background:{t['card_bg']};border:1px solid {t['border']}44;" | |
| f"border-radius:12px;padding:1rem 1.2rem;margin-bottom:0.6rem;'>" | |
| f"<div style='font-weight:700;font-size:0.9rem;color:{t['text']};margin-bottom:0.25rem;'>{_ds_name}</div>" | |
| f"<div style='font-size:0.83rem;color:{t['text_muted']};margin-bottom:0.3rem;'>{_ds_desc}</div>" | |
| f"<a href='{_ds_url}' target='_blank' style='font-size:0.78rem;color:{t['accent']};'>{_ds_credit} β</a>" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| # ββ Icons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| _section_header("Icons", "π¨") | |
| st.markdown( | |
| f"<p style='color:{t['text_muted']};font-size:0.85rem;margin-bottom:1rem;'>" | |
| f"All icons from <a href='https://www.flaticon.com' target='_blank' " | |
| f"style='color:{t['accent']};'>Flaticon</a>, used under the free license with attribution.</p>", | |
| 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"<div style='display:flex;align-items:center;gap:12px;" | |
| f"padding:0.55rem 0;border-bottom:1px solid {t['border']}33;'>" | |
| f"{_img}" | |
| f"<div><div style='font-weight:600;font-size:0.85rem;color:{t['text']};'>{_iname}</div>" | |
| f"<div style='font-size:0.75rem;color:{t['text_muted']};'>" | |
| f"<a href='{_url}' target='_blank' style='color:{t['accent']};'>{_creator}</a>" | |
| f" · Flaticon</div></div></div>", | |
| 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() | |