Crowdsourced Vegetation Monitoring: Global Plant Trait Mapping¶
Category: Botany · Size: 8.4 GB · Format: ZIP License: CC-BY-4.0 · Zenodo record · Data sheet on the CSDH
Source data and 1 km-resolution plant trait maps, integrating citizen science (GBIF) with professional data (sPlot) to map plant functional traits globally.
The data is mounted read-only at /srv/data/global-plant-traits/.
Save anything you produce in your personal folder (~/).
⚠️ Large dataset (8.4 GB). Your session has 4 GB RAM and your home folder is shared — don't extract the whole archive. Read the entries you need straight from inside the ZIP (see below); if you must extract, take only specific files, not everything.
What's in the dataset¶
This dataset backs a global effort to map plant functional traits (leaf size, plant height, seed mass, wood density, ...) at 1 km resolution by combining two very different sources:
- CIT — citizen science observations from GBIF (opportunistic photos/records uploaded by the public).
- SCI — professional vegetation surveys from sPlot (standardised plots measured by botanists).
- COMB — a model trained on both sources together.
There are two archives. SCI_CIT_sparse_maps_1km.zip (7.6 GB) holds the raw 1 km raster maps — we deliberately do not open it here. Everything we need lives in the much smaller SourceData.zip, which contains a metadata spreadsheet plus two Parquet tables of model results. We read them straight from inside the ZIP.
from pathlib import Path
DATA = Path('/srv/data/global-plant-traits')
for f in sorted(DATA.rglob('*')):
if f.is_file():
print(f"{f.relative_to(DATA)} ({f.stat().st_size/1e9:,.2f} GB)")
SCI_CIT_sparse_maps_1km.zip (7.66 GB) SourceData.zip (0.73 GB)
Reference tables from SourceData.zip¶
SourceData.xlsx (15 MB) is small enough to read in full. Three sheets matter here:
trait_id_mapping— turns cryptic TRY codes likeX3106into readable names (Plant height).splot_gbif_correlation— for each trait, how well the citizen map and the professional map spatially agree.all_results— cross-validated model skill (R²) for the CIT, SCI and COMB models.
import io, zipfile
import pandas as pd
src_zip = zipfile.ZipFile(DATA / 'SourceData.zip')
print('Inside SourceData.zip:')
for i in src_zip.infolist():
print(f" {i.filename} ({i.file_size/1e6:,.1f} MB)")
# Load the small Excel workbook from inside the ZIP (no extraction to disk)
xlsx = io.BytesIO(src_zip.read('SourceData.xlsx'))
names = pd.read_excel(xlsx, sheet_name='trait_id_mapping')
xlsx.seek(0); corr = pd.read_excel(xlsx, sheet_name='splot_gbif_correlation')
xlsx.seek(0); results = pd.read_excel(xlsx, sheet_name='all_results')
# Lookup: trait code -> friendly name (add the "_mean" suffix used in the maps)
name_of = dict(zip(names['trait_id'], names['trait_name']))
print(f"\n{len(names)} traits mapped. A few examples:")
print(names.head(8).to_string(index=False))
Inside SourceData.zip: SourceData.xlsx (15.2 MB) spatial_folds.parquet (188.1 MB) cv_obs_vs_pred.parquet (592.8 MB)
37 traits mapped. A few examples:
trait_id trait_name
X1080 SRL
X13 Leaf C
X138 Seed number
X14 Leaf N (mass)
X144 Leaf length
X145 Leaf width
X146 Leaf C/N ratio
X15 Leaf P
Q1 — Do citizen and professional maps agree, trait by trait?¶
The splot_gbif_correlation sheet gives the spatial Pearson correlation between the sPlot (professional) map and the GBIF (citizen) map for every trait. We look at the finest 1 km resolution and sort the traits from best- to worst-agreeing. A high bar means the crowd and the professionals paint the same global picture for that trait.
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style='whitegrid')
c1 = corr[corr['resolution'] == '1km'].copy()
c1['trait'] = c1['trait_id'].map(name_of).fillna(c1['trait_id'])
c1 = c1.sort_values('pearsonr')
fig, ax = plt.subplots(figsize=(8, 10))
colors = plt.cm.RdYlGn(((c1['pearsonr'] - c1['pearsonr'].min()) /
(c1['pearsonr'].max() - c1['pearsonr'].min())).values)
ax.barh(c1['trait'], c1['pearsonr'], color=colors)
ax.set_xlabel('sPlot vs GBIF spatial correlation (Pearson r) at 1 km')
ax.set_title('How well do citizen and professional trait maps agree?')
ax.margins(y=0.01)
plt.tight_layout()
plt.show()
print('Best agreement :', c1.iloc[-1]['trait'], f"(r={c1.iloc[-1]['pearsonr']:.2f})")
print('Worst agreement:', c1.iloc[0]['trait'], f"(r={c1.iloc[0]['pearsonr']:.2f})")
Best agreement : Leaf P (r=0.51) Worst agreement: Wood ray density (r=0.18)
Agreement is modest across the board (correlations well below 1). Structural traits tend to agree better than reproductive or chemical ones — a first hint that crowdsourced photos capture some traits far more reliably than others.
Q2 — Which data source makes the better predictor?¶
all_results reports the cross-validated R² for a model trained on citizen data (CIT), professional data (SCI), or both (COMB). We use the power-transformed runs and average the repeated cross-validation folds per trait. Plotting CIT R² against SCI R² shows, trait by trait, which source is more predictive — and the COMB point tells us whether combining them actually helps.
r = results[results['transform'] == 'power']
skill = (r.groupby(['trait_id', 'trait_set_abbr'])['r2']
.mean().unstack('trait_set_abbr'))
skill['trait'] = [name_of.get(t, t) for t in skill.index]
fig, ax = plt.subplots(figsize=(7.5, 7.5))
lim = [-0.05, max(skill[['CIT', 'SCI']].max()) + 0.05]
ax.plot(lim, lim, '--', color='grey', lw=1, label='equal skill')
sc = ax.scatter(skill['CIT'], skill['SCI'], c=skill['COMB'],
cmap='viridis', s=60, edgecolor='k', linewidth=0.4)
fig.colorbar(sc, label='COMB (both sources) R²')
# Label the strongest traits so the plot tells a story
for _, row in skill.sort_values('COMB').tail(6).iterrows():
ax.annotate(row['trait'], (row['CIT'], row['SCI']),
fontsize=8, xytext=(4, 4), textcoords='offset points')
ax.set_xlim(lim); ax.set_ylim(lim)
ax.set_xlabel('Citizen-data model R² (CIT)')
ax.set_ylabel('Professional-data model R² (SCI)')
ax.set_title('Predictive skill: citizen vs professional (colour = combined)')
ax.legend(loc='upper left')
plt.tight_layout(); plt.show()
better = (skill['SCI'] > skill['CIT']).mean()
print(f"Professional (SCI) beats citizen (CIT) for {better:.0%} of traits.")
print(f"Mean R² CIT={skill['CIT'].mean():.3f} SCI={skill['SCI'].mean():.3f} COMB={skill['COMB'].mean():.3f}")
Professional (SCI) beats citizen (CIT) for 100% of traits. Mean R² CIT=0.022 SCI=0.308 COMB=0.297
Most points sit above the diagonal: the professional survey model usually predicts better, and the combined model (bright colours) is typically best of all — crowdsourcing adds information rather than replacing it.
Q3 — Map where citizen and professional predictions disagree¶
Now the spatial payoff. The big table cv_obs_vs_pred.parquet (35.5 M rows) holds, for every 1 km cell, the observed value and each model's prediction. We stream it from inside the ZIP but read only one trait and only the columns we need, so memory stays modest. We pick Plant height and compare the citizen-trained prediction against the professional-trained one at each location.
Coordinates x, y are already in an equal-area world projection (metres), so we can scatter them directly to draw a map.
import numpy as np
import pyarrow.parquet as pq
TRAIT = 'X3106_mean' # Plant height
TRAIT_LABEL = name_of.get(TRAIT.replace('_mean', ''), TRAIT)
# Read just this trait's CIT & SCI predictions, projecting only 4 columns
buf = io.BytesIO(src_zip.read('cv_obs_vs_pred.parquet'))
tbl = pq.read_table(
buf,
columns=['x', 'y', 'pred', 'trait_set_abbr'],
filters=[('trait_id', '=', TRAIT),
('trait_set_abbr', 'in', ['CIT', 'SCI'])],
)
d = tbl.to_pandas()
del buf, tbl # free the raw bytes promptly
# One row per location, columns = the two model predictions
wide = d.pivot_table(index=['x', 'y'], columns='trait_set_abbr',
values='pred').dropna().reset_index()
wide['diff'] = wide['CIT'] - wide['SCI']
print(f"{TRAIT_LABEL}: {len(wide):,} 1 km cells with both predictions")
print(wide[['CIT', 'SCI', 'diff']].describe().round(3).to_string())
Plant height: 369,576 1 km cells with both predictions trait_set_abbr CIT SCI diff count 369576.000 369576.000 369576.000 mean -0.484 -0.058 -0.427 std 0.239 0.481 0.441 min -1.306 -1.196 -2.419 25% -0.619 -0.407 -0.670 50% -0.515 -0.135 -0.350 75% -0.380 0.260 -0.119 max 1.248 1.647 1.078
fig, ax = plt.subplots(figsize=(13, 6.2))
lim = np.nanpercentile(np.abs(wide['diff']), 98) # symmetric, robust colour range
sc = ax.scatter(wide['x'], wide['y'], c=wide['diff'], cmap='RdBu_r',
vmin=-lim, vmax=lim, s=3, linewidths=0)
cb = fig.colorbar(sc, ax=ax, shrink=0.8, pad=0.01)
cb.set_label('Citizen − Professional prediction (standardised units)')
ax.set_title(f'Where citizen and professional models disagree — {TRAIT_LABEL}')
ax.set_xlabel('x (equal-area metres)'); ax.set_ylabel('y (equal-area metres)')
ax.set_aspect('equal'); ax.set_facecolor('#f7f7f7')
plt.tight_layout(); plt.show()
print('Red = citizen model predicts taller plants than the professional model')
print('Blue = citizen model predicts shorter plants')
Red = citizen model predicts taller plants than the professional model Blue = citizen model predicts shorter plants
Red and blue regions reveal systematic, geographic disagreement rather than random noise: the two data sources sample the world differently (GBIF is dense where people live and travel; sPlot follows research plots), so their maps diverge most where one source is data-poor. That spatial pattern of (dis)agreement is exactly the signal this dataset was built to expose.
Try next: swap TRAIT for another code from names (e.g. X3117_mean = SLA, X26_mean = Seed mass), or bin the map into the equal-area grid to compute a per-continent agreement score.
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 Crowdsourced Vegetation Monitoring: Global Plant Trait Mapping, license CC-BY-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 -- global-plant-traits.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"