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 / longitude columns were mangled on export (decimal points lost, some values in scientific notation), so they are not reliable. The free-text place_guess column, however, still names the city — that is what we use to place each observation.
In [1]:
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.

In [2]:
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
Out[2]:
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.

In [3]:
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.

In [4]:
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.

In [5]:
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()
No description has been provided for this image

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.

In [6]:
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()
No description has been provided for this image

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).

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

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