Sampling Bias in Occurrence Data Explained

On this page

Problem statement

A map of occurrence records is a map of two things multiplied together: where the species is, and where people looked. The second one usually dominates. Records cluster along roads, around towns, inside parks with car parks, in the months when the weather is pleasant, and on Saturdays.

The effect is not subtle. Black bear records in British Columbia sit a median of 39 km from the nearest town; random points on the same land sit 92 km away. Within ten kilometres of a town, records are over-represented elevenfold.

Quick answer

Compare the records against a null sample of the same area:

import io, os, zipfile
import numpy as np, pandas as pd, geopandas as gpd, requests
from shapely import STRtree
from gbif import search

def natural_earth(name, url):
    """Download a Natural Earth layer once and keep it."""
    path = f"bio/{name}.gpkg"
    if not os.path.exists(path):
        blob = requests.get(url, timeout=300).content
        zipfile.ZipFile(io.BytesIO(blob)).extractall(f"bio/{name}")
        shp = [f for f in os.listdir(f"bio/{name}") if f.endswith(".shp")][0]
        gpd.read_file(f"bio/{name}/{shp}").to_file(path, driver="GPKG")
    return gpd.read_file(path)

NE = "https://naciscdn.org/naturalearth/10m/cultural"
region = natural_earth("ne_states", f"{NE}/ne_10m_admin_1_states_provinces.zip")
region = region.query("name == 'British Columbia'").to_crs(3005)
towns = natural_earth("ne_places", f"{NE}/ne_10m_populated_places.zip").cx[-140:-114, 48:60]
towns = towns.to_crs(3005)

bears = search(taxonKey=2433407, country="CA", stateProvince="British Columbia",
               hasCoordinate="true")
records = gpd.GeoDataFrame(bears, crs=4326,
                           geometry=gpd.points_from_xy(bears.decimalLongitude,
                                                       bears.decimalLatitude)).to_crs(3005)

rng = np.random.default_rng(0)
minx, miny, maxx, maxy = region.total_bounds
land = region.union_all()
sample = []
while len(sample) < 5_000:                                  # a null: uniform over the land
    cand = gpd.GeoSeries(gpd.points_from_xy(rng.uniform(minx, maxx, 4000),
                                            rng.uniform(miny, maxy, 4000)), crs=3005)
    sample += list(cand[cand.within(land)])
null = gpd.GeoSeries(sample[:5_000], crs=3005)

tree = STRtree(towns.geometry.values)
def km_to_town(points):
    idx = tree.nearest(points.values)
    return points.distance(gpd.GeoSeries(towns.geometry.values[idx], crs=3005),
                           align=False) / 1000

observed, expected = km_to_town(records.geometry), km_to_town(null)
print(f"{len(records):,} records, {len(towns)} towns, {len(null):,} null points")
print(f"median distance to a town: records {observed.median():.1f} km, "
      f"random land {expected.median():.1f} km")
for cut in (10, 25, 50):
    o, e = (observed < cut).mean(), (expected < cut).mean()
    print(f"  within {cut:>2} km: records {o:.1%}, land {e:.1%}, "
          f"over-represented {o / e:.1f}Γ—")
7,560 records, 49 towns, 5,000 null points
median distance to a town: records 39.2 km, random land 91.5 km
  within 10 km: records 11.7%, land 1.0%, over-represented 11.7Γ—
  within 25 km: records 32.7%, land 5.3%, over-represented 6.2Γ—
  within 50 km: records 59.0%, land 19.7%, over-represented 3.0Γ—

Without the null sample there is nothing to compare against, and a clustered map looks like a species that likes towns.

Two maps of British Columbia side by side: on the left 7,560 black bear records concentrated along roads and in the south; on the right 5,000 random points spread evenly over the land. White dots mark 49 towns on both. Two maps of British Columbia side by side: on the left 7,560 black bear records concentrated along roads and in the south; on the right 5,000 random points spread evenly over the land. White dots mark 49 towns on both.
The comparison the code makes, drawn. The records (left) sit a median 39.2 km from one of the 49 towns and trace the highways; random points on the same land (right) sit a median 91.5 km away. Without the right-hand map, the left one reads as a statement about bears.
Occurrence records compared with a uniform null sample over the same region. the records alone say nothingthe recordsclustered along the roada uniform nulluniform over the landThe comparison is the measurement: 11.4Γ— over-represented within 10 km of a town spatialworkflow.io
The records on their own say nothing; the comparison with the null says everything.

Name the bias, build a matching null, compare ratios

1. Name the bias you are testing for

Access, effort, season, day of week, observer skill, platform. Each has a different null and a different remedy.

2. Build a null of the same shape

Random points over the study area for spatial access bias; the calendar for temporal bias; the records of a related group for taxonomic bias.

3. Measure the ratio, not the count

"11% of records are within 10 km of a town" means nothing alone. "11% against 1% of the land" is the finding.

4. Use a target group as a better null

Records of similar, similarly-recorded species share the observer effort. Where they exist, they are a much better background than uniform random points.

5. Correct rather than ignore

Spatial thinning, target-group background samples, effort offsets in a model, or coarser grain. All of them lose information; ignoring the bias loses correctness.

6. Report the residual bias

No correction removes it. State what was corrected and what was not.

Types of sampling bias with the null each needs and the usual correction. six biases, six nullsbiasthe null to compare againstusual correctionaccessrandom points on the same landthinning, target-group backgroundeffortrecords of a target grouprarefaction, effort offsetseasonthe calendarrestrict or model the seasonday of week2 in 7usually none neededtaxonomicrecords of related taxaanalyse within a groupplatformrecords from other publishersreport the dominant datasetno correction removes bias; each one trades data for comparabilityNaming the bias first is what decides the null spatialworkflow.io
Six biases, six nulls; the spatial one gets all the attention and the temporal ones are easier to fix.

Weekend and seasonal effects in GBIF records

The data used on this page

Records come from the GBIF API via gbif.search, the paging function from the download guide plus eventDate and countryCode. Climate is WorldClim 2.1 bioclim at 10β€² in bio/wc/; pa.protected_areas() is British Columbia's protected-area layer (EPSG:3005). GBIF changes daily, so the setup answers each request with the reply it got on 9 October 2026.

import hashlib
import io
import os
import zipfile

import requests

# GBIF adds and revises records daily. So that the numbers below reproduce, every GBIF request on
# this page is answered from the reply it got on 9 October 2026 (one file per request, named by a
# SHA-1 of its URL); any other request, or all of them once these lines are deleted, goes live.
SAVED = zipfile.ZipFile(io.BytesIO(requests.get(
    "https://data.spatialworkflow.io/gbif-api-2026-10-09.zip", timeout=300).content))
live_get = requests.get


def saved_get(url, params=None, **kwargs):
    prepared = requests.Request("GET", url, params=params).prepare()
    try:
        body = SAVED.read(hashlib.sha1(prepared.url.encode()).hexdigest()[:16] + ".json")
    except KeyError:
        return live_get(url, params=params, **kwargs)
    reply = requests.models.Response()
    reply.status_code, reply._content, reply.url = 200, body, prepared.url
    reply.headers["Content-Type"] = "application/json"
    return reply


requests.get = saved_get

os.makedirs("bio/wc", exist_ok=True)
with open("gbif.py", "w") as f:
    f.write('''
import pandas as pd, requests

API = "https://api.gbif.org/v1"
MAX_OFFSET = 100_000
FIELDS = ["key", "species", "speciesKey", "decimalLongitude", "decimalLatitude",
          "coordinateUncertaintyInMeters", "year", "month", "basisOfRecord",
          "datasetKey", "institutionCode", "issues", "eventDate", "countryCode"]


def count(**params):
    return requests.get(f"{API}/occurrence/search", params={**params, "limit": 0}, timeout=120).json()["count"]


def search(limit=None, page=300, **params):
    total = count(**params)
    if limit is None and total > MAX_OFFSET:
        raise ValueError(f"{total:,} records exceeds the {MAX_OFFSET:,} offset limit")
    rows, offset = [], 0
    target = total if limit is None else min(limit, total)
    while offset < target:
        body = requests.get(f"{API}/occurrence/search",
                            params={**params, "limit": min(page, target - offset), "offset": offset},
                            timeout=180).json()
        rows += body["results"]
        offset += len(body["results"])
        if body["endOfRecords"] or not body["results"]:
            break
    return pd.DataFrame([{f: r.get(f) for f in FIELDS} for r in rows])
''')
with open("pa.py", "w") as f:
    f.write('''
import os
import geopandas as gpd

URL = ("https://services6.arcgis.com/ubm4tcTYICKBpist/arcgis/rest/services/"
       "British_Columbia_Parks_Ecological_Reserves_and_Protected_Areas/FeatureServer/0/query"
       "?where=1%3D1&outFields=PROTECTED_LANDS_NAME&outSR=3005&f=geojson")


def protected_areas():
    if not os.path.exists("bio/bc_protected.gpkg"):
        gpd.read_file(URL).to_file("bio/bc_protected.gpkg")
    return gpd.read_file("bio/bc_protected.gpkg")
''')
if not os.path.exists("bio/wc/wc2.1_10m_bio_1.tif"):
    blob = requests.get("https://geodata.ucdavis.edu/climate/worldclim/2_1/base/wc2.1_10m_bio.zip", timeout=600).content
    zipfile.ZipFile(io.BytesIO(blob)).extractall("bio/wc")
print(len([n for n in os.listdir("bio/wc") if n.endswith(".tif")]), "WorldClim layers")
19 WorldClim layers

Example 1 β€” the weekend effect

dates = pd.to_datetime(records.eventDate, errors="coerce", utc=True)
dated = dates.notna()
print(f"{dated.sum():,} of {len(records):,} records carry a parsable date")

by_day = dates[dated].dt.dayofweek.value_counts().sort_index()
names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
for i, n in by_day.items():
    bar = "β–ˆ" * int(40 * n / by_day.max())
    print(f"  {names[int(i)]}  {n:>5,}  {bar}")
weekend = (dates[dated].dt.dayofweek >= 5).mean()
print(f"\nweekend share: {weekend:.1%} against {2 / 7:.1%} of days "
      f"({weekend / (2 / 7):.2f}Γ— )")
3,539 of 7,560 records carry a parsable date
  Mon    476  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  Tue    458  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  Wed    497  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  Thu    491  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  Fri    493  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  Sat    566  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  Sun    558  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ

weekend share: 31.8% against 28.6% of days (1.11Γ— )

Example 2 β€” the season, which is both bias and biology

by_month = dates[dated].dt.month.value_counts().sort_index()
for m, n in by_month.items():
    bar = "β–ˆ" * int(40 * n / by_month.max())
    print(f"  {int(m):>2}  {n:>5,}  {bar}")
peak, trough = by_month.idxmax(), by_month.idxmin()
print(f"\npeak month {int(peak)} with {by_month.max():,} records; "
      f"quietest {int(trough)} with {by_month.min():,}")
print(f"ratio {by_month.max() / by_month.min():.0f}:1")
print("black bears hibernate, and people hike in summer β€” "
      "this curve is both effects and cannot separate them")
   1     15  
   2     10  
   3     39  β–ˆβ–ˆ
   4    186  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
   5    615  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
   6    711  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
   7    582  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
   8    542  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
   9    529  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  10    229  β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
  11     64  β–ˆβ–ˆβ–ˆ
  12     17  

peak month 6 with 711 records; quietest 2 with 10
ratio 71:1
black bears hibernate, and people hike in summer β€” this curve is both effects and cannot separate them

Example 3 β€” what a target-group background changes

coyote = search(taxonKey=5219153, country="CA", stateProvince="British Columbia",
                hasCoordinate="true")                       # recorded by the same people
target = gpd.GeoDataFrame(coyote, crs=4326,
                          geometry=gpd.points_from_xy(coyote.decimalLongitude,
                                                      coyote.decimalLatitude)).to_crs(3005)
target_d = km_to_town(target.geometry)

print(f"{'sample':<28}{'n':>8}{'median km':>12}{'within 10 km':>14}")
for name, n, d in [("black bear records", len(records), observed),
                   ("coyote records", len(target), target_d),
                   ("uniform random land", len(null), expected)]:
    print(f"{name:<28}{n:>8,}{d.median():>12.1f}{(d < 10).mean():>13.1%}")

print(f"\nbear vs uniform land, within 10 km: "
      f"{(observed < 10).mean() / (expected < 10).mean():.1f}Γ— over-represented")
print(f"bear vs another recorded mammal:    "
      f"{(observed < 10).mean() / (target_d < 10).mean():.2f}Γ—")
sample                             n   median km  within 10 km
black bear records             7,560        39.2        11.7%
coyote records                 2,545        17.1        34.6%
uniform random land            5,000        91.5         1.0%

bear vs uniform land, within 10 km: 11.7Γ— over-represented
bear vs another recorded mammal:    0.34Γ—

Against uniform land the bears look enormously biased towards towns. Against another mammal recorded by the same people, they look like the species that is furthest from towns of the two β€” the ratio flips from elevenfold over-represented to a third of the target group's rate. The uniform null measures access bias plus habitat plus everything else and blames it all on bias; the target group measures only what distinguishes this species from the others, which is what a distribution model should be fitting.

Why target-group backgrounds work

Why uniform random points are the wrong null on their own

Uniform points assume that every square kilometre was equally likely to be searched, which is false everywhere. The ratio against a uniform null measures access bias plus habitat preference plus everything else at once, and attributing the whole of it to bias over-corrects.

Why the target-group background works

If you sample background points from the occurrence records of a group recorded by the same methods, the background carries the same access bias as the presences. What remains after the comparison is the part of the pattern that distinguishes this species from the others β€” which is the quantity a distribution model should be fitting.

Why the temporal biases are worth checking even so

A weekend effect tells you the records are recreational; a summer peak on a hibernating species tells you the seasonality is unusable without correction. Neither changes a spatial model much, and both change how the record set should be described, which is what a reader needs to judge it.

Share of records and of random land within 10, 25 and 50 km of a town. records against land, by distance to a townrecords within 10 km11.7% β€” 11.7Γ— the landrandom land within 10 km1.0%records within 25 km32.7% β€” 6.2Γ—random land within 25 km5.3%records within 50 km59.0% β€” 3.0Γ—random land within 50 km19.7%030%60%7,560 black bear records in British Columbia against 5,000 random land points spatialworkflow.io
Elevenfold over-representation within 10 km of a town, falling to threefold at 50 km.

Roads, protected areas and taxonomic bias

  • Roads are a better access proxy than towns where a road layer exists; distance-to-road is the classic covariate.
  • Protected areas attract recording effort, which makes "more species in parks" partly an artefact.
  • eBird publishes checklists with effort β€” duration, distance, complete-checklist flags β€” which is far better than presence-only.
  • Museum collections are biased towards expedition routes and institution neighbourhoods, in a different pattern from citizen science.
  • Taxonomic bias: birds and butterflies are recorded orders of magnitude more than beetles.
  • Correcting twice β€” thinning and a target-group background and an effort offset β€” can over-correct; test one at a time.

FAQ

Why do occurrence records cluster near roads and towns?

Because that is where people are. Records are a sample of observer effort as much as of the species.

How do I tell bias from real habitat preference?

Compare the records with a null sample of the same area, and preferably with a target group recorded by the same methods.

What is a target-group background?

Background points drawn from the records of similar species, so the background carries the same sampling bias as the presences.

Does thinning fix it?

It reduces the clustering, not the underlying access bias. It is a partial correction, and it costs records.

Is a weekend effect a problem?

For a spatial model, rarely. For anything about timing or effort, it tells you what kind of data you have.

Can bias be removed?

No. It can be measured, partly corrected, and reported.