MARCSI: Inventory of Marine Citizen Science Initiatives (Global)¶
Category: Marine Biodiversity · Size: 1.4 MB · Format: CSV, JSON License: CC-BY-SA-4.0 (ShareAlike: derivative notebooks CC-BY-SA-4.0) · Zenodo record · Data sheet on the CSDH
Global inventory of marine citizen science initiatives, documenting past and present projects worldwide and assessing the FAIR compliance of the data they produce.
The data is mounted read-only at /srv/data/marcsi-marine/.
Save anything you produce in your personal folder (~/).
What's in the dataset¶
MARCSI is a hand-curated inventory of marine citizen-science initiatives worldwide. Each row is one project/initiative; the columns describe what it studies, where and when it runs, and crucially how open and FAIR the data it produces is.
There are two files: the data itself (.csv) and a companion CSVW schema (.json) that
documents every column and, handily, tells us the CSV's delimiter and how each field is coded.
from pathlib import Path
DATA = Path('/srv/data/marcsi-marine')
for f in sorted(DATA.rglob('*')):
if f.is_file():
print(f"{f.relative_to(DATA)} ({f.stat().st_size/1e6:,.2f} MB)")
MARCSI database_final.csv (1.38 MB) MARCSI database_final_csvw.json (0.01 MB)
Loading it correctly¶
This CSV has two quirks that trip up a naive pd.read_csv:
- the delimiter is a semicolon (
;), not a comma; - it's encoded in Latin-1 (there are accented characters), not UTF-8.
Both facts are declared in the companion CSVW JSON, so we don't have to guess. We also strip the
stray quote characters from the column names and trim whitespace from the categorical text (several
categories otherwise appear twice, e.g. 'National' vs 'National ').
import pandas as pd
df = pd.read_csv(DATA / 'MARCSI database_final.csv', sep=';', encoding='latin-1')
# tidy up: column names arrive wrapped in single quotes; trim whitespace in text cells
df.columns = [c.strip().strip("'") for c in df.columns]
obj_cols = df.select_dtypes('object').columns
df[obj_cols] = df[obj_cols].apply(lambda s: s.str.strip())
print('rows, cols:', df.shape)
df[['Marine citizen science initiative title', 'Scientific topic (1)',
'Geographical scale', 'Geographic location - country/countries',
'Status', 'Open Access']].head()
rows, cols: (1267, 29)
| Marine citizen science initiative title | Scientific topic (1) | Geographical scale | Geographic location - country/countries | Status | Open Access | |
|---|---|---|---|---|---|---|
| 0 | Tangaroa Blue Foundation Australian Marine D... | Ecology (e.g. coastal ecology, the state of ce... | National | Australia | active | Yes (raw data is available) |
| 1 | Litter Intelligence | Pollution (e.g. marine litter or the effect of... | National | New Zealand | active | Yes (raw data is available) |
| 2 | Citclops | Environmental variables (e.g. water quality, t... | Global | Global | completed | Yes (raw data is available) |
| 3 | Marine Debris Tracker | Pollution (e.g. marine litter or the effect of... | Global | Global | active | Yes (raw data is available) |
| 4 | Phytoplankton Monitoring Network (PMN) | Environmental variables (e.g. water quality, t... | National | USA | active | Yes (raw data is available) |
A first profile of the initiatives¶
Before any charts, a couple of value_counts tell us the shape of the collection: most projects are
active, and they span every geographic scale from a single beach (Local) to the whole planet
(Global).
print('Status of initiatives')
print(df['Status'].value_counts(dropna=False), '\n')
print('Geographical scale')
print(df['Geographical scale'].value_counts(dropna=False))
Status of initiatives Status active 703 completed 343 Not found 182 NaN 19 periodically active 9 abandoned 6 on hold 4 upcoming 1 Name: count, dtype: int64 Geographical scale Geographical scale National 387 Regional 367 Local 218 Global 156 International 109 Not found 19 NaN 10 Not applicable 1 Name: count, dtype: int64
What do these projects study?¶
The Scientific topic (1) column classifies each initiative into one primary theme. The stored
values are long and self-documenting (they include examples in parentheses), so we shorten them to
the leading keyword for plotting.
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style='whitegrid')
# keep the keyword before the '(' — 'Single species (e.g. ...)' -> 'Single species'
topic = df['Scientific topic (1)'].str.split('(').str[0].str.strip()
topic_counts = topic.value_counts()
fig, ax = plt.subplots(figsize=(9, 5))
sns.barplot(x=topic_counts.values, y=topic_counts.index, color='#2c7fb8', ax=ax)
ax.set(xlabel='Number of initiatives', ylabel='',
title='Primary scientific topic of marine citizen-science initiatives')
for i, v in enumerate(topic_counts.values):
ax.text(v + 3, i, str(v), va='center')
plt.tight_layout()
plt.show()
Single-species monitoring (marine mammals, birds, seaweeds, invasive species…) and broad biodiversity mapping dominate, together with pollution (marine litter, oil spills). This matches the intuition that charismatic species and visible litter are the easiest things for the public to record.
Where in the world?¶
The Geographic location field is free text (some rows say Global, others name a country or even a
US state). We take the top countries to see which nations host the most documented marine CS activity.
loc = df['Geographic location - country/countries'].replace({'Not found': None}).dropna()
# drop the 'Global' pseudo-location so real countries stand out
top = loc[loc != 'Global'].value_counts().head(15)
fig, ax = plt.subplots(figsize=(9, 6))
sns.barplot(x=top.values, y=top.index, color='#41ab5d', ax=ax)
ax.set(xlabel='Number of initiatives', ylabel='',
title='Top 15 countries hosting marine citizen-science initiatives')
plt.tight_layout()
plt.show()
n_global = (loc == 'Global').sum()
print(f"(plus {n_global} initiatives tagged 'Global' — not tied to one country)")
(plus 151 initiatives tagged 'Global' — not tied to one country)
The English-speaking Atlantic and Pacific rim — the UK, Australia, Ireland, USA, New Zealand — leads the inventory, partly a real signal (strong volunteering traditions) and partly a survey bias (the inventory was compiled largely from English-language sources).
The core question: how FAIR is the data?¶
The inventory's scientific purpose is to assess whether the data these projects produce is FAIR — Findable, Accessible, Interoperable, Reusable. First, is the raw data even open at all?
oa = df['Open Access'].str.split('(').str[0].str.strip()
oa_counts = oa.value_counts()
colmap = {'Yes': '#238b45', 'Partially': '#fe9929', 'No': '#cb181d'}
bar_colors = [colmap.get(k.split(' ')[0].rstrip(','), '#999999') for k in oa_counts.index]
fig, ax = plt.subplots(figsize=(8, 4.5))
ax.barh(range(len(oa_counts)), oa_counts.values, color=bar_colors)
ax.set_yticks(range(len(oa_counts)))
ax.set_yticklabels(oa_counts.index)
ax.invert_yaxis()
ax.set(xlabel='Number of initiatives', title='Is the raw data openly available?')
for i, v in enumerate(oa_counts.values):
ax.text(v + 4, i, str(v), va='center')
plt.tight_layout()
plt.show()
print((oa_counts / oa_counts.sum() * 100).round(1).astype(str) + ' %')
Open Access No 58.9 % Partially 23.0 % Yes 18.1 % Name: count, dtype: object
Only a minority of projects release raw data openly; most keep it closed or share it only partially (buried in a report or paper).
Now the four FAIR principles. These were only assessed for projects whose data is at least partly available, so we look at the share meeting each principle among the projects that were scored.
principles = ['Findable', 'Accessible', 'Interoperable', 'Reusable']
rows = []
for p in principles:
s = df[p].dropna()
yes = (s == 'Yes').sum()
rows.append({'Principle': p, 'assessed': len(s), 'Yes': yes,
'pct_yes': 100 * yes / len(s)})
fair = pd.DataFrame(rows)
print(fair)
fig, ax = plt.subplots(figsize=(8, 4.5))
sns.barplot(data=fair, x='pct_yes', y='Principle', color='#6a51a3', ax=ax)
ax.set(xlabel='% of assessed initiatives meeting the principle', ylabel='',
title='FAIR compliance of marine citizen-science data', xlim=(0, 100))
for i, v in enumerate(fair['pct_yes']):
ax.text(v + 1, i, f'{v:.0f}%', va='center')
plt.tight_layout()
plt.show()
Principle assessed Yes pct_yes 0 Findable 690 13 1.884058 1 Accessible 690 240 34.782609 2 Interoperable 690 10 1.449275 3 Reusable 690 72 10.434783
The verdict is stark: even among projects whose data is available, it is often Accessible (you can reach it via a URL) but rarely Findable, Interoperable or Reusable — it lacks persistent identifiers, standard vocabularies and clear licences. In FAIR terms, marine citizen science has an access culture but not yet an infrastructure culture.
Bonus: growth over time¶
Finally, when did these initiatives start? The Start date field is messy free text
('2004', 'enero 2009', 'Not found'), but we can pull a 4-digit year out with a regex and watch
the field grow.
year = pd.to_numeric(
df['Start date'].str.extract(r'(19\d{2}|20\d{2})')[0], errors='coerce')
year = year[(year >= 1970) & (year <= 2024)]
per_year = year.value_counts().sort_index()
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.bar(per_year.index, per_year.values, color='#2c7fb8', label='started that year')
ax2 = ax.twinx()
ax2.plot(per_year.index, per_year.cumsum(), color='#cb181d', lw=2, label='cumulative')
ax.set(xlabel='Start year', ylabel='Initiatives started that year',
title='When marine citizen-science initiatives were founded')
ax2.set_ylabel('Cumulative number')
ax.legend(loc='upper left'); ax2.legend(loc='lower right')
plt.tight_layout()
plt.show()
print(f"Median start year: {int(year.median())} · "
f"{(year >= 2010).mean()*100:.0f}% started in 2010 or later")
Median start year: 2013 · 63% started in 2010 or later
Working with data larger than memory¶
Your session has a 4 GB RAM limit, but you can analyse files of 10 GB or more without loading them whole:
- Read only the columns you need:
pd.read_csv(f, usecols=[...])/pd.read_parquet(f, columns=[...]). - Process in chunks and keep only the result:
total = 0 for chunk in pd.read_csv(file, chunksize=1_000_000): total += len(chunk)
- Query with SQL without loading anything — DuckDB (already installed) reads
CSV and Parquet straight from disk and only brings the result into memory:
import duckdb duckdb.sql("SELECT column, count(*) FROM '/srv/data/.../file.parquet' GROUP BY column").df()
Your turn¶
This is just the starting point. Some ideas:
- Check the dataset challenge on its CSDH data sheet.
- Work on a copy: right-click the file → Duplicate (or Save Notebook As…). Your changes only live in your Hub space — they're never pushed to GitHub.
- Edited this notebook and want the original back? Use the Restore cell
below (or the
restore.ipynbnotebook). - Questions and results: on the platform forum.
Attribution: data from MARCSI: Inventory of Marine Citizen Science Initiatives (Global), license CC-BY-SA-4.0. Notebook from the Citizen Science Data Hub (CSDH) — Fundación Ibercivis.
# ⚠️ RESTORE: this DISCARDS YOUR CHANGES to this notebook and resets it to the original.
# 1. Uncomment the line below (remove the #) 2. Run this cell
# 3. Then: menu File → Reload Notebook from Disk
# !git -C ~/citizen-science-data fetch -q origin && git -C ~/citizen-science-data checkout origin/main -- marcsi-marine.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"