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.

In [1]:
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 like X3106 into 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.
In [2]:
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.

In [3]:
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})")
No description has been provided for this image
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.

In [4]:
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}")
No description has been provided for this image
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.

In [5]:
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
In [6]:
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')
No description has been provided for this image
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.ipynb notebook).
  • 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.

In [7]:
# ⚠️ 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"