FLOW: Participant Survey Data from Freshwater Citizen Science Project¶
Category: Water Quality · Size: 407 kB · Format: PDF, XLSX License: CC-BY-4.0 · Zenodo record · Data sheet on the CSDH
Participant survey data from the FLOW project (2021) monitoring and protecting freshwater streams, analysing how citizen science fosters knowledge and collective action.
The data is mounted read-only at /srv/data/flow-freshwater/.
Save anything you produce in your personal folder (~/).
What's in the dataset¶
The FLOW project (2021) ran a quasi-experiment on freshwater citizen science.
Volunteers were surveyed several times and compared against people who did not take part.
Everything lives in a single Excel sheet (Data_FLOW_survey.xlsx); the PDF is the codebook.
from pathlib import Path
DATA = Path('/srv/data/flow-freshwater')
for f in sorted(DATA.rglob('*')):
if f.is_file():
print(f"{f.relative_to(DATA)} ({f.stat().st_size/1e3:,.0f} kB)")
Data_FLOW_survey.xlsx (149 kB) Dateset_Explanation.pdf (258 kB)
Load the survey and understand its shape¶
The file is wide and longitudinal: one row per participant, and each psychological measure is repeated across survey waves, encoded as a suffix on the column name:
*.1= baseline (before),*.2= post (after the monitoring season),*.3= follow-up (collected only for the treatment group).
Key columns:
group—Treatment(took part in FLOW stream monitoring) vsControl1/Control2.knowledge_score— an objective freshwater-knowledge quiz, scored 1-8.attitude,awareness,behavioral_control,collective_efficacy,group_id,nature_rel,skills,interest— self-report constructs on a 1-5 Likert scale.age,gender— demographics.
import pandas as pd
import numpy as np
df = pd.read_excel(DATA / 'Data_FLOW_survey.xlsx')
print('shape:', df.shape)
print('groups:', df['group'].value_counts().to_dict())
print('age: median %.0f (range %.0f-%.0f)' % (df.age.median(), df.age.min(), df.age.max()))
df[['group', 'age', 'gender',
'knowledge_score.1', 'knowledge_score.2', 'knowledge_score.3',
'attitude.1', 'attitude.2']].head()
shape: (555, 53)
groups: {'Control2': 250, 'Treatment': 213, 'Control1': 92}
age: median 41 (range 13-84)
| group | age | gender | knowledge_score.1 | knowledge_score.2 | knowledge_score.3 | attitude.1 | attitude.2 | |
|---|---|---|---|---|---|---|---|---|
| 0 | Treatment | 18.0 | male | 6.0 | 6.0 | NaN | 3.666667 | 3.500000 |
| 1 | Treatment | 16.0 | female | 1.0 | 3.0 | NaN | 5.000000 | 4.000000 |
| 2 | Treatment | 17.0 | female | 4.0 | 5.0 | NaN | 4.500000 | 4.500000 |
| 3 | Treatment | 47.0 | female | 8.0 | 8.0 | 8.0 | 5.000000 | 5.000000 |
| 4 | Treatment | 19.0 | male | 7.0 | 8.0 | NaN | 4.166667 | 4.666667 |
The core question: did the freshwater knowledge quiz improve?¶
knowledge_score is objective (a quiz, not an opinion), so it is the cleanest place to
look for a learning effect. We compare the mean score before (.1) and after (.2)
for each group. If citizen science teaches people, the Treatment group should jump while
the controls stay flat.
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.container import BarContainer
sns.set_theme(style='whitegrid', context='talk')
waves = {'Baseline (.1)': 'knowledge_score.1', 'Post (.2)': 'knowledge_score.2'}
groups = ['Treatment', 'Control1', 'Control2']
means = pd.DataFrame({w: [df.loc[df.group == g, col].mean() for g in groups]
for w, col in waves.items()}, index=groups)
sems = pd.DataFrame({w: [df.loc[df.group == g, col].sem() for g in groups]
for w, col in waves.items()}, index=groups)
print(means.round(2))
ax = means.plot(kind='bar', yerr=sems, capsize=4, figsize=(9, 5.5),
color=['#8bb8d0', '#1f6f8b'], edgecolor='black', rot=0)
ax.set_ylabel('Mean knowledge score (1-8)')
ax.set_title('Freshwater knowledge: before vs after participation')
ax.set_ylim(0, 8)
for c in ax.containers:
if isinstance(c, BarContainer):
ax.bar_label(c, fmt='%.1f', padding=3, fontsize=11)
plt.tight_layout(); plt.show()
Baseline (.1) Post (.2) Treatment 4.28 6.38 Control1 4.09 4.41 Control2 2.27 2.42
The treatment group starts a bit ahead and then climbs sharply after the season, while the control groups barely move. To make that "engagement effect" explicit, we plot the gain (post minus baseline) for every construct, contrasting Treatment with the pooled controls.
constructs = ['knowledge_score', 'attitude', 'awareness', 'behavioral_control',
'collective_efficacy', 'group_id', 'nature_rel', 'skills']
def gain(sub, c):
return (sub[f'{c}.2'] - sub[f'{c}.1']).mean()
treat = df[df.group == 'Treatment']
ctrl = df[df.group.isin(['Control1', 'Control2'])]
delta = pd.DataFrame({'Treatment': [gain(treat, c) for c in constructs],
'Control': [gain(ctrl, c) for c in constructs]}, index=constructs)
delta = delta.sort_values('Treatment')
print(delta.round(2))
fig, ax = plt.subplots(figsize=(9, 6.5))
y = np.arange(len(delta))
ax.hlines(y, delta['Control'], delta['Treatment'], color='#c9ccd1', lw=3, zorder=1)
ax.scatter(delta['Control'], y, s=110, color='#b0b7bf', label='Control', zorder=2, edgecolor='black')
ax.scatter(delta['Treatment'], y, s=110, color='#1f6f8b', label='Treatment', zorder=2, edgecolor='black')
ax.axvline(0, color='black', lw=1)
ax.set_yticks(y); ax.set_yticklabels(delta.index)
ax.set_xlabel('Mean change from baseline to post (.2 - .1)')
ax.set_title('Where does taking part move the needle?')
ax.legend(loc='lower right')
plt.tight_layout(); plt.show()
Treatment Control collective_efficacy 0.04 0.05 nature_rel 0.06 -0.01 attitude 0.08 -0.02 behavioral_control 0.13 -0.07 awareness 0.16 0.12 group_id 0.24 NaN skills 0.67 0.01 knowledge_score 2.11 0.21
Knowledge shows by far the largest treatment-vs-control gap; several attitudinal and collective-action measures also nudge upward for participants. Finally, the treatment group was surveyed a third time (follow-up), so we can check whether the knowledge gain sticks using a paired boxplot across the three waves for Treatment participants only.
long = (treat[['knowledge_score.1', 'knowledge_score.2', 'knowledge_score.3']]
.rename(columns={'knowledge_score.1': 'Baseline',
'knowledge_score.2': 'Post',
'knowledge_score.3': 'Follow-up'})
.melt(var_name='Wave', value_name='Knowledge score').dropna())
fig, ax = plt.subplots(figsize=(8.5, 5.5))
order = ['Baseline', 'Post', 'Follow-up']
sns.boxplot(data=long, x='Wave', y='Knowledge score', order=order,
palette=['#8bb8d0', '#1f6f8b', '#134b5f'], ax=ax)
sns.stripplot(data=long, x='Wave', y='Knowledge score', order=order,
color='black', alpha=0.15, size=3, ax=ax)
w_means = long.groupby('Wave')['Knowledge score'].mean().reindex(order)
ax.plot(range(len(order)), w_means.values, 'o-', color='crimson', lw=2, label='mean')
ax.set_ylim(0, 8.5); ax.set_title('Treatment group: does the knowledge gain last?')
ax.legend(); plt.tight_layout(); plt.show()
print('Treatment knowledge means:', w_means.round(2).to_dict())
/tmp/ipykernel_43599/3870522887.py:9: FutureWarning: Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect. sns.boxplot(data=long, x='Wave', y='Knowledge score', order=order,
Treatment knowledge means: {'Baseline': 4.28, 'Post': 6.38, 'Follow-up': 6.39}
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 FLOW: Participant Survey Data from Freshwater Citizen Science Project, 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 -- flow-freshwater.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"