"
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""
)
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"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)
# ═══════════════════════════════════════════════════════════════════════════════
# 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"
"
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"
",
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""
)
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"