Fruit-Bearing Plant Species Observations in Brazilian Cities (iNaturalist)¶
Category: Urban Botany · Size: 6.0 MB · Format: CSV License: CC0-1.0 · Zenodo record · Data sheet on the CSDH
iNaturalist observations of fruit-bearing plants across all 27 Brazilian state capitals, with 47 columns of taxonomic, temporal and spatial metadata.
The data is mounted read-only at /srv/data/fruit-plants-brazil/.
Save anything you produce in your personal folder (~/).
What's in the dataset¶
A single CSV, observations_22-08-2023.csv, exported from iNaturalist. Each
row is one photographed observation of a fruit-bearing plant in (or near) one of
Brazil's 27 state capitals. The 47 columns cover who recorded it, when and
where, the quality of the identification, and a full taxonomy (kingdom →
family → genus → species).
Two quirks to know before we start:
- The file is semicolon-separated (
;) with a UTF-8 byte-order mark, not the usual comma. - The
latitude/longitudecolumns were mangled on export (decimal points lost, some values in scientific notation), so they are not reliable. The free-textplace_guesscolumn, however, still names the city — that is what we use to place each observation.
from pathlib import Path
DATA = Path('/srv/data/fruit-plants-brazil')
for f in sorted(DATA.rglob('*')):
if f.is_file():
print(f"{f.relative_to(DATA)} ({f.stat().st_size/1e6:,.1f} MB)")
observations_22-08-2023.csv (6.0 MB)
Load the data¶
The file is small (~6 MB), so we can read it whole. We only need to tell pandas about the semicolon separator; the default UTF-8 handles the accented Portuguese place names.
import pandas as pd
CSV = DATA / 'observations_22-08-2023.csv'
df = pd.read_csv(CSV, sep=';', encoding='utf-8')
print(f"{len(df):,} observations x {df.shape[1]} columns")
df[['observed_on', 'place_guess', 'scientific_name',
'taxon_family_name', 'captive_cultivated', 'quality_grade']].head()
10,942 observations x 47 columns
| observed_on | place_guess | scientific_name | taxon_family_name | captive_cultivated | quality_grade | |
|---|---|---|---|---|---|---|
| 0 | 24/07/2011 | Curado - Pernambuco | Spondias mombin | Anacardiaceae | False | research |
| 1 | 16/08/2012 | Brasília | Syzygium jambos | Myrtaceae | True | casual |
| 2 | 26/06/2012 | Brazil | Dipteryx alata | Fabaceae | False | research |
| 3 | 22/04/2012 | Brasília | Sterculia striata | Malvaceae | False | research |
| 4 | 22/03/2013 | Campo Grande, BR-MS, BR | Dipteryx alata | Fabaceae | False | research |
Keep the columns that matter¶
For a question about plant diversity by city we only need a handful of columns:
the location text, the taxonomy, whether the plant was wild or cultivated, the
identification quality, and the date. We also parse observed_on (day/month/year)
into a real date so we can look at seasonality later.
cols = ['observed_on', 'place_guess', 'species_guess', 'scientific_name',
'common_name', 'taxon_family_name', 'taxon_genus_name',
'taxon_species_name', 'captive_cultivated', 'quality_grade']
obs = df[cols].copy()
# Real dates, then a month column for seasonality
obs['date'] = pd.to_datetime(obs['observed_on'], format='%d/%m/%Y', errors='coerce')
obs['month'] = obs['date'].dt.month
print('distinct species (taxon_species_name):', obs['taxon_species_name'].nunique())
print('distinct families :', obs['taxon_family_name'].nunique())
print('wild vs cultivated share:')
print(obs['captive_cultivated'].value_counts(normalize=True).round(3))
distinct species (taxon_species_name): 388 distinct families : 53 wild vs cultivated share: captive_cultivated False 0.707 True 0.293 Name: proportion, dtype: float64
Which city is each observation in?¶
There is no clean city column — place_guess is free text like
"Fortaleza, BR-CE, BR" or "Jardim Botânico de Curitiba, Curitiba, PR, BR".
Since every observation belongs to one of the 27 state capitals, we match each
place_guess against the list of capital names. This resolves about 91% of rows;
the rest are vague entries like "Brazil" that we simply drop from the city
analysis.
CAPITALS = ['Rio Branco', 'Maceió', 'Macapá', 'Manaus', 'Salvador', 'Fortaleza',
'Brasília', 'Vitória', 'Goiânia', 'São Luís', 'Cuiabá', 'Campo Grande',
'Belo Horizonte', 'Belém', 'João Pessoa', 'Curitiba', 'Recife',
'Teresina', 'Rio de Janeiro', 'Natal', 'Porto Alegre', 'Porto Velho',
'Boa Vista', 'Florianópolis', 'São Paulo', 'Aracaju', 'Palmas']
place_low = obs['place_guess'].fillna('').str.lower()
def match_city(text):
for city in CAPITALS:
if city.lower() in text:
return city
return None
obs['city'] = place_low.apply(match_city)
matched = obs['city'].notna().sum()
print(f"placed {matched:,} of {len(obs):,} observations "
f"({matched/len(obs):.0%}) into a state capital")
placed 9,976 of 10,942 observations (91%) into a state capital
1. Which capitals have the richest recorded fruit-plant diversity?¶
Diversity here means the number of distinct species recorded in a city. We rank the capitals by species richness and, alongside it, show the raw number of observations — because a city can rank high simply by having more active iNaturalist users, not more species.
import matplotlib.pyplot as plt
import seaborn as sns
sns.set_theme(style='whitegrid')
by_city = (obs.dropna(subset=['city'])
.groupby('city')
.agg(species=('taxon_species_name', 'nunique'),
observations=('date', 'size'))
.sort_values('species', ascending=False))
top = by_city.head(15).iloc[::-1] # reverse so the richest is on top
fig, axes = plt.subplots(1, 2, figsize=(12, 6), sharey=True)
axes[0].barh(top.index, top['species'], color='#2e7d32')
axes[0].set_title('Distinct species recorded')
axes[0].set_xlabel('number of species')
axes[1].barh(top.index, top['observations'], color='#8d6e63')
axes[1].set_title('Total observations (effort)')
axes[1].set_xlabel('number of observations')
fig.suptitle('Fruit-plant diversity across Brazilian state capitals',
fontsize=14, weight='bold')
plt.tight_layout()
plt.show()
Brasília leads on both counts, but notice how the two panels differ: some capitals with fewer observations still record a surprising number of species. A fairer diversity measure would account for effort — that is a good extension (see Your turn).
2. Wild or cultivated? The native-vs-introduced signal¶
iNaturalist flags whether a plant was wild or captive/cultivated (planted by people — gardens, orchards, street trees). This is the closest field to a "native vs introduced" split: cultivated fruit plants in cities are very often introduced species like mango or jackfruit. We compare the wild/cultivated balance across the busiest capitals.
top_cities = by_city.head(12).index
share = (obs[obs['city'].isin(top_cities)]
.groupby('city')['captive_cultivated']
.value_counts(normalize=True)
.unstack(fill_value=0)
.reindex(top_cities))
share = share.rename(columns={False: 'wild', True: 'cultivated'})
share = share.sort_values('cultivated')
fig, ax = plt.subplots(figsize=(10, 6))
ax.barh(share.index, share['wild']*100, color='#2e7d32', label='wild')
ax.barh(share.index, share['cultivated']*100, left=share['wild']*100,
color='#f9a825', label='cultivated')
ax.set_xlabel('share of observations (%)')
ax.set_xlim(0, 100)
ax.set_title('Wild vs cultivated fruit plants by capital', weight='bold')
ax.legend(loc='lower right')
plt.tight_layout()
plt.show()
Cities differ a lot: in some capitals most recorded fruit plants are cultivated (planted mangoes, guavas, jackfruit), while others are dominated by wild observations. That balance hints at whether a city's recorded diversity is driven by human-planted introduced species or by spontaneous native flora.
3. What is actually being recorded — families and seasonality¶
Finally, two views of what and when: the most-photographed plant families across the whole dataset, and the month-of-year pattern of observations (remember Brazil is in the Southern Hemisphere).
fig, axes = plt.subplots(1, 2, figsize=(13, 5))
fam = obs['taxon_family_name'].value_counts().head(12).iloc[::-1]
axes[0].barh(fam.index, fam.values, color='#00695c')
axes[0].set_title('Top 12 plant families', weight='bold')
axes[0].set_xlabel('observations')
monthly = obs['month'].value_counts().sort_index()
axes[1].plot(monthly.index, monthly.values, marker='o', color='#c62828')
axes[1].set_title('Observations by month', weight='bold')
axes[1].set_xlabel('month')
axes[1].set_ylabel('observations')
axes[1].set_xticks(range(1, 13))
axes[1].grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
print('Most-recorded species:')
print(obs['scientific_name'].value_counts().head(8))
Most-recorded species: scientific_name Eugenia uniflora 393 Caryocar brasiliense 317 Psidium guajava 295 Syagrus romanzoffiana 282 Mangifera indica 277 Monstera deliciosa 258 Artocarpus heterophyllus 250 Bixa orellana 225 Name: count, dtype: int64
Myrtaceae (guavas, jabuticaba, Surinam cherry) dominate — many are native Brazilian fruits — followed by palms (Arecaceae) and passionfruits (Passifloraceae). The monthly curve peaks around April–May, likely reflecting both fruiting seasons and iNaturalist "challenge" events rather than a single biological cause.
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 Fruit-Bearing Plant Species Observations in Brazilian Cities (iNaturalist), license CC0-1.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 -- fruit-plants-brazil.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"