Skip to main content
← All writingKylan Thomson

Geospatial ML · Forecasting

Machine Learning Mushroom Forecasting for Washington State

A forecast map is a promise about where to spend a Saturday. This is what SporeCast predicts, how a date and a place become a probability, and where the model knows it is guessing.

Published
Reading time
9 minutes
Project
SporeCast case study

A mushroom forecast map is a promise about where to spend a Saturday. SporeCast makes that promise for twenty edible species across Washington State, from satellite embeddings, terrain, climate, and this season's soil moisture. This is what the map predicts, how a place and a month become a probability, and where the model knows it is guessing.

Two companion pieces go deep on the parts that were hardest to get right: the observer bias in the sightings the model learns from, and the evaluation that keeps its accuracy claims honest. Here I want to stay at the level of what a forager sees and what is underneath it.

What the map predicts, and what it does not

Each of 13,103 cells covering the state, about seven kilometres across, carries a suitability score per species: a calibrated, relative habitat score in the range zero to one, where a high value means “this ground looks like the ground this species is found on.” It is not a probability of finding a mushroom. The absolute chance of any single cell producing a chanterelle on a given weekend depends on things no dataset records (who picked it last Tuesday), so the map is built to rank places, not to promise yields.

The score you see on the map at a given month is that suitability multiplied by a fruiting weight: how far into its season the species is at that place. Both halves are learned. Suitability comes from a species distribution model trained on 12,449 thinned sightings pooled from iNaturalist, GBIF, Mushroom Observer, and digitised herbarium records. The fruiting curve comes from the monthly pattern of those sightings, corrected for when people were out looking, and shifted per cell by elevation and latitude.

def _month_score(slug, month):
    return grid[slug].to_numpy() * _fruiting_weight(slug, month, elev, lat)

# the pooled curve slides later with altitude and latitude, then is read circularly
delta = (sh["days_per_1000m"] * (elev - sh["elev_ref"]) / 1000.0
         + sh["days_per_deg_north"] * (lat - sh["lat_ref"]))
pos = (month - 1) - clip(delta, -MAX_SHIFT, MAX_SHIFT) / DAYS_PER_MONTH
return _interp_circular(base, pos)
mushroom_sightings.py: the served score is suitability times a per-cell fruiting weight for the chosen month.

For the golden chanterelle the fitted shift is about 17 days later per thousand metres of elevation, so a slope in the Cascades peaks weeks after the Puget lowlands. A static seasonal average is the obvious trap here: it is right on median and wrong every actual year. The served curve is nudged by the current season's regional soil-moisture anomaly, computed per region so a dry east-Cascades autumn and a soaked Olympic one diverge on the same map.

  • All fungi observations (observer effort)
  • Target species observations
1×2×3×4×Jan: 1,370 all-fungi observations (0.7× mean)Jan: 831 target-species observations (0.4× mean)JanFeb: 770 all-fungi observations (0.4× mean)Feb: 349 target-species observations (0.2× mean)FebMar: 979 all-fungi observations (0.5× mean)Mar: 369 target-species observations (0.2× mean)MarApr: 1,490 all-fungi observations (0.7× mean)Apr: 802 target-species observations (0.4× mean)AprMay: 1,702 all-fungi observations (0.9× mean)May: 1,664 target-species observations (0.8× mean)MayJun: 1,101 all-fungi observations (0.6× mean)Jun: 1,684 target-species observations (0.8× mean)JunJul: 769 all-fungi observations (0.4× mean)Jul: 1,590 target-species observations (0.8× mean)JulAug: 833 all-fungi observations (0.4× mean)Aug: 2,201 target-species observations (1.1× mean)AugSep: 1,837 all-fungi observations (0.9× mean)Sep: 3,783 target-species observations (1.9× mean)SepOct: 5,696 all-fungi observations (2.8× mean)Oct: 5,721 target-species observations (2.8× mean)OctNov: 5,686 all-fungi observations (2.8× mean)Nov: 3,754 target-species observations (1.9× mean)NovDec: 1,767 all-fungi observations (0.9× mean)Dec: 1,518 target-species observations (0.8× mean)DecMULTIPLE OF THE SERIES' MEAN MONTH
View as table
Monthly observation counts
MonthAll fungi (effort)Target species
Jan1,370831
Feb770349
Mar979369
Apr1,490802
May1,7021,664
Jun1,1011,684
Jul7691,590
Aug8332,201
Sep1,8373,783
Oct5,6965,721
Nov5,6863,754
Dec1,7671,518
Why the fruiting curve has to be corrected before it is used. Monthly observations of all fungi (the effort signal) peak at four times the mean month in October and November, because that is when people go looking. The target species curve is what the model would mistake for phenology if it did not divide out that effort.

What it learns from

The model sees sixty features for every cell and every sighting, drawn from sixteen public sources at resolutions that disagree by three orders of magnitude: 10-metre satellite embeddings that encode vegetation texture and moisture, a 13-metre elevation model and the slope and aspect derived from it, 30-metre land cover and forest type, 250-metre soil, and 20-kilometre climate normals that are downscaled to the terrain by the standard lapse rate. Every source is a keyless public API or a server-side reduction, so there is no raster download anywhere and the whole pipeline rebuilds from scratch. The feature stack is the subject of the species distribution model article.

SporeCast An architecture diagram generated by Archify. Biodiversity archives · iNaturalist · GBIF · MO · Architecture component Biodiversity archives iNaturalist · GBIF · MO Environmental APIs · NASA POWER · Open-Meteo · Architecture component Environmental APIs NASA POWER · Open-Meteo Earth Engine · AlphaEarth embeddings · Architecture component · Google Cloud Earth Engine AlphaEarth embeddings pipeline.py · ingest · enrich · grid · Batch pipeline · Python pipeline.py ingest · enrich · grid model.py · 7 learners · isotonic · Batch pipeline · spatially blocked CV · Python model.py 7 learners · isotonic spatially blocked CV Baked artifacts · suitability · masks · phenology · Batch pipeline Baked artifacts suitability · masks · phenology Flask API · gunicorn · stateless · Cloud Run · serve-time slope from DEM tiles · Flask Flask API gunicorn · stateless serve-time slope from DEM tiles Leaflet map · canvas raster · client gates · Architecture component Leaflet map canvas raster · client gates presence records climate · soil server-side reductions 40+ covariates versioned bake read at startup JSON · PNG tiles Batch pipeline Cloud Run Legend Frontend Backend Database Cloud External
A batch pipeline and a stateless server. The pipeline pools presences and covariates, trains the per-species ensembles, and bakes versioned artifacts; a Flask service on Cloud Run reads those artifacts at startup and serves the map, so serving never depends on a live model.

Reading the map as a forager

The species list on the left is the forecast in words. Each row carries a season badge (peak, in season, shoulder, off season) computed from the month you have selected, a one-to-five rating from the species' share of its own annual peak, and a confidence badge built from how well the model validated, how many finds it learned from, and how much its seven learners disagree. A “select in-season” button picks every species whose peak months include the current one.

The raster is a canvas overlay of the best score across the selected species, resampled to sit exactly on the map tiles. Under the cursor it reads back the habitat value at that point. Several gates then reshape it, all on the server:

  • Elevation band. Each species carries an elevation range from its own finds (the golden chanterelle sits between roughly 34 and 660 metres), and as you zoom in the surface is progressively confined to it.
  • Host trees. Mycorrhizal species are gated on a host-affinity surface built from forest-type data, so a chanterelle score fades where there is no Douglas fir or hemlock, and lobster mushroom is gated on the modelled suitability of the russulas it parasitises.
  • Focus and niche. The satellite embeddings give each species a lookalike mask, ground with the fine-scale signature of the places it has been found, and a niche envelope that admits at most about half the state.
  • Slope, water, and access. A slope filter drops ground steeper than you want to walk, water bodies are masked, and a public-land screen, on by default, removes anywhere harvesting is not legal. A prediction you cannot act on is not a feature.

From a good cell to a walk

A cell seven kilometres across is not a place to park. Dropping a pin asks the server for the cell and its neighbours, the share of ground within it that clears the species' niche threshold at 30-metre resolution, and a snapped best point inside it using a small elevation patch. From there a route planner fetches the roads and trails from OpenStreetMap around the spot, samples real elevation at every node, and plans a loop with Tobler's hiking function for walking time, or a time-budgeted multi-stop trip with a pace you choose. The forecast ends as a walk, which is the only form in which a forager can use it.

How good is it

Every species has a headline accuracy near 0.97 and I do not report it, because against a background that is mostly sagebrush and alpine rock it measures forest versus desert. The number that describes the map is accuracy within forested habitat, and it is honest to the point of discomfort for some species.

The twenty served species: training presences after thinning, peak months, headline AUC, and AUC within forested habitat
SpeciesPresencesPeakAUCWithin habitat
Pacific golden chanterelle1,502Aug to Nov0.9770.805
White chanterelle574Sep, Oct0.9660.781
Cascade chanterelle121Sep to Nov0.9690.831
Winter chanterelle1,191Dec to Feb0.9760.772
Pacific black trumpet169Jan to Mar0.9850.921
Pacific matsutake426Oct to Dec0.9400.596
Landscape morel514Apr0.9900.977
Mountain blond morel324May, Jun0.9630.833
King bolete1,123Aug, Sep0.9660.758
Spring king bolete304May, Jun0.9710.847
Hedgehog132Aug to Nov0.9480.668
Depressed hedgehog158Jan to Apr0.9600.783
Conifer lion's mane458Sep, Oct0.9640.688
Lion's mane337Nov to Jan0.9730.915
Cauliflower mushroom768Sep to Nov0.9750.800
Lobster mushroom1,767Aug, Sep0.9820.867
Summer oyster679May to Jul0.9810.870
Chicken of the woods1,230Aug, Sep0.9660.703
Shrimp russula442Aug to Nov0.9750.850
Candy cap230Dec, Jan0.9830.866
The twenty species with their thinned training presences, peak months, headline AUC, and AUC scored within forested habitat, from the July 2026 build. Matsutake is the species to be most careful with; landscape morel and black trumpet are the ones the map is best at.

The confidence badge on each species row is built from these numbers. A species with a strong Boyce index, hundreds of finds, and low disagreement among the ensemble members gets a high badge; matsutake, with a within-habitat AUC under 0.60 and the highest ensemble uncertainty of the twenty, does not, and the map says so rather than painting it in the same confident red. The thresholds behind the badge are tuned constants ▓▓▓ and are the least principled part of the system.

Serving a state from a stateless container

  1. Everything the server needs is baked into the image at build time: the suitability grid, the phenology curves, the masks, about 34 megabytes in all. Nothing is fetched from a model at request time.
  2. The store loads once at import, so two preloaded workers share it, and each optional artifact is checked for row-count agreement with the grid before it is trusted. A stale artifact disables its feature; it never misaligns the map.
  3. Elevation tiles, road networks, and heat rasters are cached in a bounded per-instance cache, because Cloud Run's temporary filesystem is billed as memory and an unbounded cache is a slow way to run out of it.
  4. Every optional gate degrades to off, every route request returns a note rather than an error when there is no trail nearby, and a missing artifact shows a clear “not built” page instead of crash-looping the revision.

The map runs in us-west1 with two CPUs and two gigabytes, and the pipeline that builds its artifacts runs elsewhere, on its own schedule. That split is the reason the site keeps working when a soil API is down or Earth Engine is slow: the serving side has no dependencies it did not ship with.

The full system, with the seven-learner ensemble, the effort corrections, and the evaluation, is documented in the SporeCast case study. If you only remember one thing about a forecast map: rank places, publish the honest accuracy, and end with a route, because _month_score is not a forecast until someone can walk to it.

Book a 30-min callEmail about this work