Trends in Butterfly Populations in UK Gardens¶

Category: Entomology · Size: 52.9 kB · Format: CSV, TXT License: CC-BY-4.0 · Zenodo record · Data sheet on the CSDH

Annual abundance indices and trends for 22 butterfly species in UK gardens over 2007-2020, from the BTO Garden BirdWatch citizen monitoring scheme.

The data is mounted read-only at /srv/data/uk-butterflies/. Save anything you produce in your personal folder (~/).

What's in the dataset¶

Three small CSV files (plus a readme.txt describing them):

file what it holds
bfly_species_list.csv the 22 species: 5-letter code, common name, scientific name
bfly_garden_collated_indices.csv a yearly abundance index per species, 2007-2020
bfly_garden_trends.csv one overall % trend per species, for gardens (GBW) and the countryside (UKBMS)

Let's load all three and stitch the species names onto the numbers so the tables are readable.

In [1]:
from pathlib import Path
import pandas as pd

DATA = Path('/srv/data/uk-butterflies')

species = pd.read_csv(DATA / 'bfly_species_list.csv')             # 22 species + names
indices = pd.read_csv(DATA / 'bfly_garden_collated_indices.csv')  # yearly index
trends  = pd.read_csv(DATA / 'bfly_garden_trends.csv')            # overall % trend

# Attach the readable common name to every table via the 5-letter species code
names = species.set_index('species')['spname']
indices['spname'] = indices['species'].map(names)
trends['spname']  = trends['species'].map(names)

print('species :', species.shape)
print('indices :', indices.shape, '| years', indices['year'].min(), '-', indices['year'].max(),
      '|', indices['species'].nunique(), 'species')
print('trends  :', trends.shape)
species
species : (22, 4)
indices : (308, 12) | years 2007 - 2020 | 22 species
trends  : (66, 9)
Out[1]:
taxorder species spname sciname
0 342350 XSMSK Small Skipper Thymelicus sylvestris
1 342380 XLASK Large Skipper Ochlodes sylvanus
2 342460 XBRIM Brimstone Gonepteryx rhamni
3 342470 XLAWH Large White Pieris brassicae
4 342480 XSMWH Small White Pieris rapae
5 342490 XGVWH Green-veined White Pieris napi
6 342500 XORTI Orange-tip Anthocharis cardamines
7 342570 XSMCO Small Copper Lycaena phlaeas
8 342650 XCOBL Common Blue Polyommatus icarus
9 342660 XHOBL Holly Blue Celastrina argiolus
10 342720 XREAD Red Admiral Vanessa atalanta
11 342730 XPALA Painted Lady Vanessa cardui
12 342740 XSMTO Small Tortoiseshell Aglais urticae
13 342760 XPEAC Peacock Aglais io
14 342770 XCOMM Comma Polygonia c-album
15 342860 XSPWO Speckled Wood Pararge aegeria
16 342870 XWLBR Wall Lasiommata megera
17 342890 XMAWH Marbled White Melanargia galathea
18 342910 XGATE Gatekeeper Pyronia tithonus
19 342920 XMEBR Meadow Brown Maniola jurtina
20 342930 XSMHE Small Heath Coenonympha pamphilus
21 342950 XRING Ringlet Aphantopus hyperantus

Understanding the index columns¶

For each species and year the indices table gives a collated index of relative abundance. The most useful column for comparing species is lci = log10 of that index, which the authors anchor to 2.0 in the first year (2007) for every species.

That anchoring is handy: because everyone starts at the same value, we can turn lci into a plain "relative to 2007" multiplier and read change directly.

  • 10 ** (lci - 2) = abundance relative to 2007  (1.0 = same as 2007, 2.0 = doubled)
  • nsite / nsite_obs = how many gardens were monitored / recorded the species that year
In [2]:
# Confirm the anchor: every species' lci equals 2.0 in 2007
assert (indices.loc[indices['year'] == 2007, 'lci'].round(6) == 2.0).all()

# Abundance relative to the 2007 baseline (1.0 = unchanged since 2007)
indices['rel2007'] = 10 ** (indices['lci'] - 2)

# Monitoring effort has grown a lot over the years -- worth knowing
effort = indices.groupby('year')[['nsite']].first()
print('Gardens monitored: {} in 2007  ->  {} in 2020'.format(
    int(effort.loc[2007, 'nsite']), int(effort.loc[2020, 'nsite'])))

indices[['species', 'spname', 'year', 'col_index', 'lci', 'rel2007']].head()
Gardens monitored: 749 in 2007  ->  5113 in 2020
Out[2]:
species spname year col_index lci rel2007
0 XBRIM Brimstone 2007 1.437071 2.000000 1.000000
1 XBRIM Brimstone 2008 1.068299 1.871215 0.743386
2 XBRIM Brimstone 2009 1.736926 2.082303 1.208657
3 XBRIM Brimstone 2010 1.580160 2.041223 1.099570
4 XBRIM Brimstone 2011 1.552585 2.033577 1.080381

1. Winners and losers: ranking the overall trend¶

The trends file already gives one headline number per species: the percentage change in garden abundance across the whole series (2007-2020), from the Garden BirdWatch scheme (GBW). Let's rank all 22 species and colour them by direction. The sig column flags statistically significant trends, which we mark with a star.

In [3]:
import matplotlib.pyplot as plt
import numpy as np

# Whole-series garden trend, one row per species
gbw = (trends[(trends['scheme'] == 'GBW') & (trends['years'] == '2007-2020')]
       .sort_values('trend'))

colors = np.where(gbw['trend'] >= 0, '#2a9d8f', '#e76f51')  # teal up, red down
fig, ax = plt.subplots(figsize=(9, 7))
ax.barh(gbw['spname'], gbw['trend'], color=colors)

# Mark statistically significant trends with a star
for y, (t, s) in enumerate(zip(gbw['trend'], gbw['sig'])):
    if pd.notna(s):
        ax.text(t + (6 if t >= 0 else -6), y, '*', va='center',
                ha='left' if t >= 0 else 'right', fontsize=14, color='#444')

ax.axvline(0, color='0.3', lw=0.8)
ax.set_xlabel('Change in garden abundance 2007-2020 (%)')
ax.set_title('UK garden butterflies: winners and losers (2007-2020)\n* = statistically significant')
plt.tight_layout()
plt.show()

print('Only species that DECLINED:', gbw.loc[gbw['trend'] < 0, 'spname'].tolist())
No description has been provided for this image
Only species that DECLINED: ['Wall']

Most garden species actually rose over the period -- Marbled White, Large Skipper and Holly Blue more than doubled or tripled -- while the Wall is the only clear decliner. Now let's watch a few of these trajectories year by year.

In [4]:
# Pick the 3 biggest risers, the single decliner, and a mid-pack species
top3   = gbw.tail(3)['species'].tolist()
bottom = gbw.head(1)['species'].tolist()
picks  = bottom + top3

fig, ax = plt.subplots(figsize=(9, 5.5))
for code in picks:
    d = indices[indices['species'] == code].sort_values('year')
    ax.plot(d['year'], d['rel2007'], marker='o', label=d['spname'].iloc[0])

ax.axhline(1.0, color='0.5', ls='--', lw=1)  # 2007 baseline
ax.set_xlabel('Year')
ax.set_ylabel('Abundance relative to 2007  (1.0 = 2007 level)')
ax.set_title('Trajectories of the biggest risers and the one decliner')
ax.legend(title='Species', fontsize=9)
plt.tight_layout()
plt.show()
No description has been provided for this image

2. Was there a year when everything crashed together?¶

Trends smooth over the ups and downs. To spot a shared bad year we build a simple community index -- the average of every species' relative abundance each year -- and count how many of the 22 species fell versus the year before.

In [5]:
wide = indices.pivot(index='year', columns='spname', values='rel2007')
community = wide.mean(axis=1)                 # average across the 22 species
n_down = (wide.diff() < 0).sum(axis=1)        # how many species fell vs last year

fig, (a1, a2) = plt.subplots(2, 1, figsize=(9, 6.5), sharex=True,
                             gridspec_kw={'height_ratios': [2, 1]})
a1.plot(community.index, community.values, marker='o', color='#264653')
a1.axhline(1.0, color='0.5', ls='--', lw=1)
a1.set_ylabel('Community index\n(mean rel. to 2007)')
a1.set_title('A wet, cold 2012 pulled almost every garden species down at once')

worst = int(n_down.idxmax())
bars = a2.bar(n_down.index, n_down.values,
              color=['#e76f51' if y == worst else '#adb5bd' for y in n_down.index])
a2.set_ylabel('# species\nfalling')
a2.set_xlabel('Year')
a2.set_ylim(0, 22)
for a in (a1, a2):
    a.axvline(worst, color='#e76f51', lw=1, ls=':')
plt.tight_layout()
plt.show()

print('Worst shared year: {} -- {} of 22 species declined, community index = {:.2f}'
      .format(worst, int(n_down.max()), community.loc[worst]))
No description has been provided for this image
Worst shared year: 2012 -- 18 of 22 species declined, community index = 0.93

The dip is unmistakable: in 2012, 18 of the 22 species fell and the community index dropped below its 2007 starting point -- matching the UK's famously wet, cold summer that year. A heatmap of all 22 species makes the shared crash visible as a single dark vertical stripe.

In [6]:
import seaborn as sns

# Order species by their overall trend so risers and fallers group together
order = gbw['spname'].tolist()
heat = np.log10(wide[order]).T          # log10(rel-to-2007): 0 = baseline

fig, ax = plt.subplots(figsize=(10, 7))
sns.heatmap(heat, cmap='RdYlGn', center=0, ax=ax,
            cbar_kws={'label': 'log10(abundance relative to 2007)'})
ax.axvline(list(wide.index).index(2012) + 0.5, color='black', lw=1.5)
ax.set_xlabel('Year')
ax.set_ylabel('Species (ordered by overall trend)')
ax.set_title('Every species x every year -- red = below 2007, green = above')
plt.tight_layout()
plt.show()
No description has been provided for this image

3. Bonus: are gardens telling the same story as the wider countryside?¶

The trends file also carries UK Butterfly Monitoring Scheme (UKBMS) trends for the overlapping 2011-2020 window. Plotting garden trends against countryside trends shows whether butterflies behave the same in both settings.

In [7]:
both = (trends[trends['years'] == '2011-2020']
        .pivot(index='spname', columns='scheme', values='trend')
        .dropna())

fig, ax = plt.subplots(figsize=(7, 7))
ax.scatter(both['BMS'], both['GBW'], color='#5b5f97', zorder=3)
lim = [both.min().min() - 10, both.max().max() + 10]
ax.plot(lim, lim, ls='--', color='0.5')      # y = x : agreement line
ax.axhline(0, color='0.8'); ax.axvline(0, color='0.8')
for sp, r in both.iterrows():
    ax.annotate(sp, (r['BMS'], r['GBW']), fontsize=7, alpha=0.8)
ax.set_xlabel('Countryside trend 2011-2020 (UKBMS, %)')
ax.set_ylabel('Garden trend 2011-2020 (GBW, %)')
ax.set_title('Garden vs countryside trends (points above the line = better in gardens)')
plt.tight_layout()
plt.show()
No description has been provided for this image

Takeaways

  • Over 2007-2020 most garden butterflies increased; the Wall was the notable loser.
  • 2012 stands out as a synchronised crash across nearly all species -- weather can hit the whole community in one bad summer.
  • Garden and countryside trends broadly agree, but several species do visibly better in gardens, hinting that gardens can act as refuges.

Ideas to take further: fit your own linear slope to each species' lci series and compare it with the published trend; add the 95% confidence bands (l95conf/u95conf) to the time-series; or cluster species by the shape of their trajectory with scikit-learn.

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 Trends in Butterfly Populations in UK Gardens, license CC-BY-4.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 -- uk-butterflies.ipynb && echo "Restored. Now: File → Reload Notebook from Disk"