Interpreting OBIS data

OBIS aggregates observations that thousands of people collected for their own purposes, over two centuries, with methods that were never coordinated. The records are real; what they collectively represent is not obvious. This page covers the four things that most often turn a correct query into a wrong conclusion.

OBIS states the general caution itself, in its data policy:

Appropriate caution is necessary in the interpretation of results derived from OBIS. Users must recognize that the analysis and interpretation of data require background knowledge and expertise about marine biodiversity (including ecosystems and taxonomy). Users should be aware of possible errors, including in the use of species names, geo-referencing, data handling, and mapping. They should cross check their results for possible errors and qualify their interpretation of any results accordingly.

Print it in full with OBISClient.disclaimer.

Record counts measure sampling effort

This is the mistake with the largest consequences, because the resulting numbers look like biology.

A record exists because somebody looked, identified what they found, and published it. The number of records for a taxon in a place and year is therefore the product of how much of it there was and how hard people looked — and across the history of OBIS, how hard people looked varies by orders of magnitude more than the biology does.

You can see the shape of it directly:

using OBISClient

years = OBISClient.statistics_years("Abra alba")

for e in years
    e["year"] >= 1960 || continue
    e["year"] % 10 == 0 || continue
    println(e["year"], "  ", lpad(e["records"], 6), "  ", "▪"^(e["records"] ÷ 200))
end
Records of Abra alba per year worldwide

The series for almost any well-recorded taxon rises steeply through the late twentieth century and then moves sharply with individual events. Those movements are survey programmes starting and ending, museum collections being digitized, and national nodes joining OBIS and contributing decades of backlog in a single year. Abra alba has records from 1841 onward, and the global OBIS year range starts in 1103 — a range that reflects the history of natural history, not of the ocean.

Three consequences:

  • A time series of record counts is not an abundance trend. Two taxa in the same region can have opposite record trends because one is monitored by a long-running programme and the other is not.
  • A map of record density is a map of survey coverage. Coastal Europe and North America are dense; the open ocean and the southern hemisphere are sparse. That is a statement about ships and funding.
  • Comparisons need a denominator. If you must compare, compare against total records from the same datasets, region and period — the effort that produced them:
target = OBISClient.statistics("Abra alba"; areaid = 259)["records"]
effort = OBISClient.statistics(; areaid = 259)["records"]
share  = target / effort

That is still crude, but it is a proportion of a sampled community rather than a raw count. Properly accounting for effort is a modelling problem, which is outside this package's scope.

The default view is filtered in two directions

A default query does not return everything OBIS holds about a taxon. Two classes of record are excluded, and they are excluded for different reasons.

Dropped records

OBIS runs a quality pipeline over everything it ingests. Records failing certain checks are dropped from the main index: missing or impossible coordinates, coordinates at exactly zero, and names that cannot be matched to the World Register of Marine Species, or that match a taxon WoRMS considers exclusively freshwater or terrestrial.

OBISClient.DROPPING_FLAGS

Dropped records are not deleted, only hidden by default. They are the record of what went wrong, and they are worth looking at when a dataset returns fewer records than expected:

dropped = OBISClient.occurrence("Abra alba"; dropped = :only)

# Why were they dropped?
using DataFrames
counts = Dict{String,Int}()
for fs in dropped.flags, f in fs
    counts[f] = get(counts, f, 0) + 1
end
sort(collect(counts); by = last, rev = true)

A large NO_MATCH count usually means a naming or scientificNameID problem in the source dataset rather than anything about the organisms.

Absence records

An absence record says a species was looked for at a place and time and not found. OBIS marks a record as an absence when occurrenceStatus is absent, or when individualCount is zero.

This is a different kind of information from a presence, and analytically it is often the scarcer half. For Abra alba, the absences number roughly a quarter again of the presences, across fourteen datasets:

OBISClient.statistics("Abra alba")["records"]                      # presences
OBISClient.statistics("Abra alba"; absence = :only)["records"]      # absences
OBISClient.statistics("Abra alba"; absence = :include)["records"]   # both

Absence records are excluded from the default view on either access route, and asked for with absence = :include or :only. OBIS's data access page says the Mapper downloads and the bulk export contain none at all; for the bulk export that is stale — the files do carry them, verified count for count against /statistics (NOTES.md §7.3), and OBISClient.read_export reads them with the same tri-state selection the API takes. The Mapper was not checked, so treat the page's statement about it as it stands.

Why it matters: a presence-only dataset cannot distinguish "not recorded here" from "looked for and not found here". Any method that needs a contrast between occupied and unoccupied sites — and most do — has to construct the unoccupied set somehow, usually as pseudo-absences drawn from the background. A recorded absence is an observation; a pseudo-absence is an assumption. Where recorded absences exist, using them removes that assumption from the analysis.

# Presences and absences together, labelled.
both = OBISClient.occurrence("Abra alba"; absence = :include, limit = 5000)
count(both.absence)          # how many are absences

Quality flags are attached to records you do get

Flags do not remove a record. ON_LAND, NO_DEPTH, DEPTH_EXCEEDS_BATH, NO_ACCEPTED_NAME and the rest ride along on records that are returned normally, and ignoring them silently accepts every problem they describe.

recs = OBISClient.occurrence("Abra alba"; limit = 5000)

# How many marine records are positioned on land?
count(fs -> "ON_LAND" in fs, recs.flags)

# Exclude them at the source instead.
clean = OBISClient.occurrence("Abra alba"; exclude = "ON_LAND", limit = 5000)

ON_LAND is a georeferencing error most of the time: a coordinate transposed, truncated, or given as a locality centroid inland. For a habitat or distribution analysis those records are actively misleading. Mapped, they cluster on the coast and in estuaries, in the pattern a transposed or truncated coordinate produces:

Occurrences of Abra alba in the southern North Sea, with ON_LAND records shown separately

Not every flag is an error, though, and no rule covers this — you need to know the domain. DEPTH_EXCEEDS_BATH compares the recorded depth against GEBCO bathymetry, and OBIS notes that a measured depth is sometimes more accurate than the gridded bathymetry it is checked against. In a canyon or a trench, the flag can be the thing that is wrong. MARINE_UNSURE reflects uncertainty in WoRMS about the taxon's habitat, not about the observation.

The flags in OBISClient.KNOWN_FLAGS are the documented vocabulary, and the QC pipeline's own reference at github.com/iobis/obis-qc defines each check. The vocabulary is open — OBIS adds checks — so an unrecognized flag is passed through with a warning rather than rejected.

statistics_qc summarizes flags and missing fields for a whole query, which is a better first move than inspecting records:

qc = OBISClient.statistics_qc("Abra alba")
qc["flags"]
qc["onland"]

Flags are case-sensitive

The API matches flags in upper case. A lower-case flag is not an error: flags = "on_land" returns zero records, and exclude = "on_land" applies no filter at all, both with a success status. The package normalizes case for you, so either spelling works here — but the behaviour is worth knowing if you also query the API directly.

Names are matched, not authoritative

scientificName in a result is the name OBIS matched against WoRMS, not necessarily the name the provider wrote. The provider's original is kept as originalScientificName, and the two differ whenever a synonym was resolved, a misspelling corrected, or a name updated.

recs = OBISClient.occurrence("Abra alba"; limit = 1000)
count(i -> recs.scientificName[i] != recs.originalScientificName[i], 1:OBISClient.nrow(recs))

Querying by taxonid (an AphiaID) rather than by name avoids the ambiguity entirely, since it addresses a WoRMS concept directly:

OBISClient.occurrence(; taxonid = 141433, limit = 100)     # Abra alba, unambiguously

Where a name could not be matched, records carry NO_MATCH and are dropped, and the WoRMS team's notes on the name are available:

OBISClient.taxon_annotations(; scientificname = "Abra alba")

A short checklist

Before drawing a conclusion from an OBIS query:

  1. Have you looked at statistics_qc for the query?
  2. Do you know whether absence records exist for this taxon, and whether you want them?
  3. Have you decided what to do about each flag present, rather than ignoring all of them?
  4. If you are comparing counts across time or space, what is the denominator?
  5. Are you citing the datasets, and do their licences permit what you plan to do? See Licensing and citation.