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.

How a GBIF download shrinks through the cleaning pipeline A vertical funnel of horizontal bars, each shorter than the one above. The raw download holds twelve thousand four hundred records. Removing invalid and missing coordinates leaves ten thousand one hundred. Removing low-precision coordinates leaves eight thousand two hundred. Filtering basis of record and cultivated specimens leaves seven thousand four hundred. Applying a date window leaves six thousand one hundred. Removing exact coordinate duplicates leaves two thousand three hundred. Spatial thinning at one kilometre leaves eight hundred and seventy records for modelling. raw download valid coordinates precision ≤ 1 km wild, observed date window unique coordinates thinned at 1 km 12 400 10 100 8 200 7 400 6 100 2 300 870 The largest single drop is deduplication — most raw records are the same place recorded many times.

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.

Coordinate artefacts as they appear on a map A schematic map. Genuine occurrence records are scattered irregularly across a shaded forest area. Three artefacts are called out: a tight cluster of many records at a single point in the geometric middle of the country outline, another tight cluster at a labelled museum inside a city, and a linear string of records following a road, which is sampling bias rather than a coordinate error. country centroid museum road bias — real records, biased sampling The first two are coordinate errors and get deleted; the third is bias and is handled by background design.

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.

What each decimal place of a coordinate is worth on the ground A ladder of coordinate precisions. Zero decimal places corresponds to about one hundred and eleven kilometres of positional uncertainty, one decimal to eleven kilometres, two to one point one kilometres, three to one hundred and ten metres, four to eleven metres and five to about one metre. A shaded band marks the precisions that are usable against a one kilometre predictor grid, which begins at three decimal places. 48° 48.5° 48.53° 48.527° 48.5271° 48.52713° 111 km 11 km 1.1 km 110 m 11 m 1 m shaded: usable against a 1 km predictor grid Two decimals is a whole predictor cell of uncertainty and no metadata field records it.

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 coordinateUncertaintyInMeters alone. 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.

Up: Presence-Only Data Preparation · Species Distribution Modeling with MaxEnt