Skip to main content
← All writingKylan Thomson

Geospatial ML · Data Quality

Correcting Observer Bias in Citizen-Science ML Datasets

iNaturalist knows where the trailheads are. A model trained on raw sightings learns the road network, and the corrections are less about clever models than about being honest about who collected the data.

Published
Reading time
9 minutes
Project
SporeCast case study

A citizen-science dataset is a map of where people went, annotated with what they saw. Train a model on it naively and it learns the first thing, not the second. SporeCast is built on 24,266 mushroom sightings from four public archives, and most of the modelling effort went into being honest about who collected them.

The problem has a name in the species-distribution literature, sampling bias, and a standard set of remedies. What I want to do here is show what the bias looks like in an actual dataset, with numbers, and then walk through the four corrections SporeCast applies, including the one that produced a measurable improvement and the one whose effect I still cannot separate from the ecology.

Where people look

Ninety percent of the records are from iNaturalist. A few percent each come from iDigBio (digitised herbarium specimens), GBIF with the iNaturalist feed excluded, and Mushroom Observer. The archives do not just differ in volume; they differ in who is behind them. A herbarium record is a specimen someone vouchered on purpose, often decades ago. An iNaturalist record is a phone photo from a walk. The same species can be common in one archive and absent from another for reasons that have nothing to do with where it grows.

The first thing to measure is clustering, because it is the fingerprint of effort. If sightings were spread according to habitat they would be spread across a lot of habitat. They are not.

SHARE OF OBSERVATIONS WITH A NEIGHBOUR INSIDE THE RADIUS100 m100 m: 44.8% of observations45%500 m500 m: 71.6% of observations72%1 km1 km: 81.1% of observations81%5 km5 km: 96.8% of observations97%10 km10 km: 99.0% of observations99%
View as table
Observations with at least one neighbour within the radius
RadiusShare
100 m44.8%
500 m71.6%
1 km81.1%
5 km96.8%
10 km99.0%
For each of the 24,266 records, whether at least one other record lies within the radius. Nearly half have a neighbour within 100 metres and 81 percent within a kilometre. The median nearest-neighbour distance is 137 metres, and 6 percent of records share exact coordinates with another.

On a 1.1-kilometre lattice, the top tenth of occupied cells hold 41 percent of all records; on a 7-kilometre lattice, over half. That is trailheads, popular loops, and a few people's favourite patches. It is also, inconveniently, partly real: good habitat does cluster, and some of those patches are famous precisely because they are productive. There is no clean way to separate the two from the sightings alone. What you can do is stop the model from being rewarded for the part that is effort.

The other axis: when people look

Effort is not just spatial. The whole all-fungi observation record for the region peaks in October and November at four times the mean month, because that is when people go mushroom hunting. A species' monthly sighting curve is therefore the product of two things: when it fruits, and when anyone was outside to notice.

  • 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
Monthly observations as a multiple of each series' mean month. The all-fungi curve (24,000 records) is the observer effort signal; the target species curve (24,266 records) is what the model would see as phenology if it did not correct for it. The target curve is broader, partly because spring morels and boletes pull it earlier.

Nothing in the target curve tells you whether a species fruits in November or whether November is simply when the observers are out. The correction is to divide each species' monthly counts by the all-fungi effort curve before smoothing, with a floor so an empty winter month cannot blow up the ratio. The result is closer to when the fungus fruits than to when the foragers do, which matters because the served forecast shifts that curve with the current season's soil moisture, and a curve that was really an effort curve would shift the wrong thing.

Four corrections between the archives and the model An architecture diagram generated by Archify. Four archives · iNat · GBIF · MO · iDigBio · Architecture component · 90% iNaturalist Four archives iNat · GBIF · MO · iDigBio 90% iNaturalist De-duplicate & clean · 11 m · accuracy · water · urban · Presences · 39% of records dropped De-duplicate & clean 11 m · accuracy · water · urban 39% of records dropped Thin · best-vouchered point survives · Presences · 43% more dropped Thin best-vouchered point survives 43% more dropped All-fungi effort · 24,000 observations · Architecture component All-fungi effort 24,000 observations Effort surface · Gaussian-decayed count · Background Effort surface Gaussian-decayed count Hard background · forested cells near finds · Background Hard background forested cells near finds Per-species ensemble · weighted presences vs background · Architecture component Per-species ensemble weighted presences vs background 12,660 pooled 7,688 clean weighted rows where people looked cell weights reweighted Presences Background Legend Backend External
Where the four corrections sit. Presences are de-duplicated, cleaned, and thinned before they reach the model; the background is reweighted by an effort surface built from every fungus observation, then concentrated on forested cells near known finds.

Correction one: a background that knows where people went

A presence-only model needs something to contrast presences against. The naive choice is background points sampled uniformly across the region. Against that background, a sighting near a trailhead looks like evidence that trailheads are habitat, because uniform background is thin near trailheads and dense in the backcountry nobody visited.

The standard fix is target-group background: weight background points by how much observation effort the area received, using sightings of the whole taxonomic group as the proxy. SporeCast uses every fungus observation in the region, not just the twenty target species, because that is a far denser and less circular measure of where fungus-noticing people have been. Each background cell's weight is a Gaussian-decayed count of effort points within a few bandwidths, with a bandwidth of ▓▓▓ and a floor of ▓▓▓ so genuinely unvisited habitat still counts for something.

idx_lists = tree.query_ball_point(gx, r=3 * bandwidth_km)
eff = np.zeros(len(grid_coords))
for i, idxs in enumerate(idx_lists):
    if idxs:
        d = np.linalg.norm(tx[idxs] - gx[i], axis=1)
        eff[i] = np.exp(-(d / bandwidth_km) ** 2).sum()
return floor + (1.0 - floor) * (eff / eff.max())
model.py: effort at a background cell is a distance-decayed count of every fungus observation nearby.

The effect is that a presence near a trailhead is now contrasted with background that is also near trailheads. The model can no longer earn credit for learning the road network, because the road network is on both sides of the comparison.

Correction two: make the comparison hard on purpose

Effort weighting removes one bias and leaves a bigger one in place. Most of the Pacific Northwest is not forest, and a model asked to separate forest mushrooms from a regional background will learn forest-versus-not and stop, scoring 0.99 on the way. So a large share of the total background weight, ▓▓▓, is concentrated on forested cells within ▓▓▓ of known finds, with the total preserved so class balance downstream does not move. The model is forced to discriminate within habitat, which is the only discrimination the map needs.

This is the correction I can put a number on. In a four-species A/B with the hard stratum switched on and off, within-habitat AUC improved for four species out of four, by a mean of 0.014. Judged by a full-background metric the same change looks like a regression of 0.13 in Boyce index, which is the trap described in the companion piece on cross-validation: a metric that measures the easy split will punish you for getting better at the hard one.

Correction three: throw away almost half the data

Reweighting the background handles where the model looks. It does not handle the fact that fifty sightings from one famous chanterelle patch are not fifty independent pieces of evidence. Spatial thinning addresses that directly: no two retained presences may sit within ▓▓▓ of each other. Records are kept greedily, highest identification confidence first, so when a cluster is collapsed the point that survives is the best-vouchered one.

The cost is stark. Across the twenty modelled species, thinning takes the training set from 21,880 records to 12,479, a 43 percent reduction. Golden chanterelle loses 46 percent of its records, lobster mushroom 53 percent. It is uncomfortable to delete that much data, and it is correct: the deleted records were not telling the model anything the surviving one did not, except that someone had been back.

A small engineering note, because it bit me: the obvious implementation rebuilds a spatial index after every kept point and is quadratic, which was fine at a coarse thinning distance and fell over as the distance shrank. The current version hashes points onto a lattice at the thinning distance and checks the nine surrounding cells, which is linear and made experimenting with the distance cheap enough to actually do.

Correction four: distrust the record itself

Before any of the above, the records are cleaned, and the cleaning is also a bias correction, because the errors are not random. The two that matter most are specific to how citizen scientists behave.

  • Coarse coordinates. Any record with a reported positional accuracy worse than a kilometre is dropped. Records with no reported accuracy are kept, which is a judgement call: the herbarium archives rarely report one and dropping them would silently remove the oldest, best-vouchered records.
  • The “came back to Seattle and posted” problem. Records that land on open water, or in developed land cover, are dropped for forest-obligate species, because a chanterelle logged in a city is a find that was uploaded from home. The urban filter deliberately excludes the open-space developed class, which legitimately hosts edge-of-forest fungi, and exempts the three species (black morel, oyster, lion's mane) that really do grow in town.
RECORDS RETAINEDPooled from four archivesPooled from four archives: 12,660 records (100%)12,660De-duplicated (11 m, same day)De-duplicated (11 m, same day): 11,014 records (87%)11,014Accuracy > 1 km, open water, urban droppedAccuracy > 1 km, open water, urban dropped: 7,688 records (61%)7,688
Records retained through the cleaning stages in the July 2026 build. Of 12,660 pooled records, 13 percent are cross-archive duplicates (same species, same day, within 11 metres), and a further 30 percent fail the accuracy, water, or urban filters. Thinning then removes roughly another 43 percent.

Two smaller pieces of the same idea. Identification confidence is not a filter but a weight: an iNaturalist record with several agreeing identifiers, or a vouchered specimen, trains at full weight, while a bare research-grade record trains at a floor. And de-duplication across archives is necessary because iNaturalist records flow into GBIF; the pipeline excludes the iNaturalist dataset from the GBIF pull entirely and then de-duplicates on species, day, and rounded coordinates for whatever slips through.

What is left when you are done

The corrections do not produce a model with more skill. They produce a model whose skill is about mushrooms. Within-habitat AUC for the median species is 0.82, which is a modest number and a real one, and the map it produces sends people into forest they have not been to rather than back to the trailheads they already know. That second property is the entire value of a forecast built from citizen science, and it is the property a naive model is guaranteed not to have, because the trailheads are where its evidence is.

If you take one method from this: use the whole taxonomic group as your effort proxy, not the species you are modelling. It is the difference between a correction and a tautology. The rest of the system, from the seven-learner ensemble to the weather-shifted phenology, is documented in the SporeCast case study, and the code behind the_effort_weights snippet above is the reference implementation I would point anyone at.

Book a 30-min callEmail about this work