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)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
View as table
| Month | All fungi (effort) | Target species |
|---|---|---|
| Jan | 1,370 | 831 |
| Feb | 770 | 349 |
| Mar | 979 | 369 |
| Apr | 1,490 | 802 |
| May | 1,702 | 1,664 |
| Jun | 1,101 | 1,684 |
| Jul | 769 | 1,590 |
| Aug | 833 | 2,201 |
| Sep | 1,837 | 3,783 |
| Oct | 5,696 | 5,721 |
| Nov | 5,686 | 3,754 |
| Dec | 1,767 | 1,518 |
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.
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.
| Species | Presences | Peak | AUC | Within habitat |
|---|---|---|---|---|
| Pacific golden chanterelle | 1,502 | Aug to Nov | 0.977 | 0.805 |
| White chanterelle | 574 | Sep, Oct | 0.966 | 0.781 |
| Cascade chanterelle | 121 | Sep to Nov | 0.969 | 0.831 |
| Winter chanterelle | 1,191 | Dec to Feb | 0.976 | 0.772 |
| Pacific black trumpet | 169 | Jan to Mar | 0.985 | 0.921 |
| Pacific matsutake | 426 | Oct to Dec | 0.940 | 0.596 |
| Landscape morel | 514 | Apr | 0.990 | 0.977 |
| Mountain blond morel | 324 | May, Jun | 0.963 | 0.833 |
| King bolete | 1,123 | Aug, Sep | 0.966 | 0.758 |
| Spring king bolete | 304 | May, Jun | 0.971 | 0.847 |
| Hedgehog | 132 | Aug to Nov | 0.948 | 0.668 |
| Depressed hedgehog | 158 | Jan to Apr | 0.960 | 0.783 |
| Conifer lion's mane | 458 | Sep, Oct | 0.964 | 0.688 |
| Lion's mane | 337 | Nov to Jan | 0.973 | 0.915 |
| Cauliflower mushroom | 768 | Sep to Nov | 0.975 | 0.800 |
| Lobster mushroom | 1,767 | Aug, Sep | 0.982 | 0.867 |
| Summer oyster | 679 | May to Jul | 0.981 | 0.870 |
| Chicken of the woods | 1,230 | Aug, Sep | 0.966 | 0.703 |
| Shrimp russula | 442 | Aug to Nov | 0.975 | 0.850 |
| Candy cap | 230 | Dec, Jan | 0.983 | 0.866 |
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
- 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.
- 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.
- 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.
- 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.