Cleaning GBIF Occurrence Records with Python
A GBIF download for a forest tree species will contain records at the country centroid, records at the coordinates of a natural history museum, records digitised from a herbarium label to the nearest degree, records of cultivated specimens in a botanic garden, and several thousand genuine field observations. Feeding the whole file to a model produces a distribution that includes museums and capital cities. This guide is the filtering step that prevents it, inside Presence-Only Data Preparation, part of Species Distribution Modeling with MaxEnt.
The artefacts, in the order they bite
| Artefact | Signature | Filter |
|---|---|---|
| Country or province centroid | Many records at one exact coordinate, often mid-country | Distance to known centroids; duplicate coordinate count |
| Institution coordinates | Records at a museum, herbarium or university | Distance to an institution gazetteer |
| Zero/zero and equal coordinates | lat = lon, or both zero | Explicit test |
| Low coordinate precision | Coordinates rounded to 0.1° or 1° | Decimal-place count; coordinateUncertaintyInMeters |
| Cultivated or captive specimens | establishmentMeans, basisOfRecord |
Field filter |
| Fossil and preserved specimens | basisOfRecord |
Field filter, unless intended |
| Records predating the climate baseline | year far outside the climatology |
Date window |
| Duplicates across datasets | Same species, coordinate, date from several publishers | Deduplicate on a rounded key |
The order matters because each filter changes what the next one sees. Remove the obvious coordinate failures first, then the metadata-based ones, then deduplicate — deduplicating first hides the centroid pile-ups that are the strongest signal a coordinate is fabricated.
Minimal reproducible example
import numpy as np
import pandas as pd
BAD_BASIS = {"FOSSIL_SPECIMEN", "LIVING_SPECIMEN", "MATERIAL_CITATION"}
BAD_ESTABLISHMENT = {"MANAGED", "INTRODUCED", "CULTIVATED"}
def clean_gbif(df: pd.DataFrame, min_year: int = 1970,
max_uncertainty_m: float = 1000.0,
min_decimals: int = 3) -> tuple[pd.DataFrame, dict]:
"""Filter a GBIF occurrence export into a modelling-ready presence set."""
n0 = len(df)
log = {"raw": n0}
d = df.copy()
d = d.dropna(subset=["decimalLatitude", "decimalLongitude"])
log["has_coords"] = len(d)
lat, lon = d["decimalLatitude"], d["decimalLongitude"]
d = d[(lat.between(-90, 90)) & (lon.between(-180, 180))
& ~((lat == 0) & (lon == 0)) & (lat != lon)]
log["valid_coords"] = len(d)
if "coordinateUncertaintyInMeters" in d:
unc = d["coordinateUncertaintyInMeters"]
d = d[unc.isna() | (unc <= max_uncertainty_m)]
log["precise_enough"] = len(d)
# Coordinates rounded to the degree or the tenth of a degree are label transcriptions.
def decimals(series):
s = series.astype(str).str.split(".").str[-1]
return s.str.len().where(series.astype(str).str.contains(r"\."), 0)
d = d[(decimals(d["decimalLatitude"]) >= min_decimals)
& (decimals(d["decimalLongitude"]) >= min_decimals)]
log["enough_decimals"] = len(d)
if "basisOfRecord" in d:
d = d[~d["basisOfRecord"].isin(BAD_BASIS)]
if "establishmentMeans" in d:
d = d[~d["establishmentMeans"].str.upper().isin(BAD_ESTABLISHMENT).fillna(False)]
log["wild_observed"] = len(d)
if "year" in d:
d = d[d["year"].between(min_year, 2100)]
log["in_date_window"] = len(d)
return d.reset_index(drop=True), log
The decimal-place test catches something no metadata field records: a herbarium label reading “48.5 N, 9.1 E” was transcribed from a written description, and its true position is somewhere in a 10 km box. GBIF often carries no coordinateUncertaintyInMeters for such records, so the coordinate’s own precision is the only evidence available.
Remove centroid and institution artefacts
Records placed at an administrative centroid or at the collecting institution are the artefacts most likely to survive every other filter, because their coordinates are perfectly valid.
import numpy as np
import pandas as pd
def flag_pile_ups(d: pd.DataFrame, min_count: int = 20,
round_to: int = 5) -> pd.DataFrame:
"""Flag coordinates shared by an implausible number of records."""
key = (d["decimalLatitude"].round(round_to).astype(str) + "," +
d["decimalLongitude"].round(round_to).astype(str))
counts = key.map(key.value_counts())
out = d.copy()
out["coord_pile_up"] = counts >= min_count
return out
def flag_near_points(d: pd.DataFrame, reference: pd.DataFrame,
tol_m: float = 2000.0) -> pd.DataFrame:
"""Flag records within tol_m of any reference point (centroids, institutions)."""
from scipy.spatial import cKDTree
# Approximate metres per degree; adequate for a 2 km tolerance test.
lat0 = float(d["decimalLatitude"].mean())
mx = 111_320.0 * np.cos(np.radians(lat0))
my = 110_540.0
ref = np.column_stack([reference["lon"] * mx, reference["lat"] * my])
pts = np.column_stack([d["decimalLongitude"] * mx, d["decimalLatitude"] * my])
dist, _ = cKDTree(ref).query(pts)
out = d.copy()
out["near_reference"] = dist <= tol_m
return out
Flag rather than delete. A pile-up of 40 records at one coordinate is usually a centroid, but it can also be a long-running monitoring plot, and the distinction needs a human look. Carrying the flag through to the modelling step lets you fit with and without and see whether it mattered.
Road bias is a different problem with a different fix — not deletion, but a matched background, which is the subject of Generating Target-Group Background Points in Python.
Parameter reference
| Parameter | Type | Default | Range | Rationale |
|---|---|---|---|---|
min_year |
int | 1970 | 1950–2000 | Match the climate baseline the predictors describe |
max_uncertainty_m |
float | 1000 | 250–5000 | Should not exceed the predictor cell size |
min_decimals |
int | 3 | 2–4 | Three decimals is roughly 100 m; two is 1 km |
min_count (pile-up) |
int | 20 | 10–50 | Lower in a small download, higher in a large one |
tol_m (institutions) |
float | 2000 | 500–5000 | Institution coordinates are themselves imprecise |
| dedup rounding | int | 4 dp | 3–5 | About 10 m; coarser merges genuinely distinct records |
Expected output and verification
The cleaning log is the deliverable as much as the cleaned table. Each step’s count is a claim someone may need to audit.
def audit_clean(log: dict, cleaned) -> dict:
"""Report retention at each stage and flag suspicious losses."""
stages = list(log.items())
losses = [{"stage": b[0], "removed": a[1] - b[1],
"removed_pct": round(100.0 * (a[1] - b[1]) / max(a[1], 1), 1)}
for a, b in zip(stages, stages[1:])]
return {
"raw": log["raw"],
"final": len(cleaned),
"retained_pct": round(100.0 * len(cleaned) / max(log["raw"], 1), 1),
"by_stage": losses,
}
rep = audit_clean(log, cleaned)
assert rep["final"] >= 30, f"too few records left to model: {rep}"
worst = max(rep["by_stage"], key=lambda r: r["removed_pct"])
assert worst["removed_pct"] < 80, \
f"one filter removed {worst['removed_pct']}% at {worst['stage']} — check it"
The second assertion catches an over-aggressive filter. A min_decimals of 4 applied to a historical herbarium dataset will remove almost everything, and without the check the pipeline reports a clean, tiny, unrepresentative presence set as a success.
The other verification is spatial: sample the predictor stack at the cleaned coordinates and confirm none land outside it.
import numpy as np
import rasterio
def check_against_predictors(cleaned, raster_path: str) -> dict:
"""Every presence must fall on a valid predictor pixel."""
with rasterio.open(raster_path) as src:
coords = list(zip(cleaned["decimalLongitude"], cleaned["decimalLatitude"]))
vals = np.array([v[0] for v in src.sample(coords)], dtype="float64")
nodata = src.nodata
off = ~np.isfinite(vals) | (vals == nodata)
return {"n": len(vals), "off_raster": int(off.sum()),
"off_pct": round(100.0 * off.mean(), 2)}
chk = check_against_predictors(cleaned, "bio1.tif")
assert chk["off_pct"] < 5.0, f"presences falling outside the predictors: {chk}"
Presences on NoData are usually coastal records whose coordinates place them a pixel offshore. A few percent is normal and they should be dropped; twenty percent means a CRS mismatch between the records and the stack.
Common pitfalls
- Deduplicating before flagging pile-ups. Collapsing 40 centroid records to one makes the artefact invisible and leaves a fabricated presence in the model.
- Filtering on
coordinateUncertaintyInMetersalone. It is missing for a large share of records, so a null-tolerant filter silently keeps the worst offenders. - Dropping records with no year. Many herbarium records lack a date and are perfectly good occurrences; treat missing as unknown rather than as out-of-window.
- Deduplicating at the raw coordinate. Two records 3 m apart are the same observation; rounding to four decimal places before deduplicating is what removes them.
- Cleaning without a log. The counts at each stage are the evidence that the presence set is what you say it is.
Frequently Asked Questions
Should I use a dedicated cleaning package instead?
Where one is available for your workflow it is a reasonable choice — the tests are the same ones implemented here, plus a maintained gazetteer of centroids and institutions, which is the part that is tedious to assemble. The value of writing it out is that every threshold stays visible and adjustable, and the log comes out in a form you control.
How many records do I need after cleaning?
Thirty is a workable floor for a tuned MaxEnt model with few features; below about fifteen the model cannot be regularised meaningfully. What matters more is environmental coverage: fifty records spanning the species’ full climatic range beat three hundred from one valley.
Should I remove records outside the study area?
Yes for fitting, but consider a wider extent first. Occurrences from beyond the study region constrain the response curves at their edges, which improves transferability. Clip the background to the study area and let presences come from the fuller extent where the data supports it.
Related
- Presence-Only Data Preparation — the wider preparation workflow
- Spatial Thinning of Occurrence Records in Python — the step that follows this one
- Handling Sampling Bias in Presence-Only Data — the bias that cleaning cannot fix
- Generating Target-Group Background Points in Python — the matched-background answer to road bias
Up: Presence-Only Data Preparation · Species Distribution Modeling with MaxEnt