Rural decline - A basic analysis¶

Demographic analysis of the Apennines parishes in the Casentino Forests National Park¶


Context¶

The Tuscan-Romagnolo Apennines preserve landscapes of extraordinary pristine beauty: silver fir forests, centuries-old beech groves, and stone villages clinging to the ridges. Yet, during the twentieth century, this region experienced one of the most dramatic demographic phenomena in Italy: the systematic depopulation of mountain communities.

This notebook analyzes the historical population series of seven parishes in the Parco Nazionale delle Foreste Casentinesi, Monte Falterona e Campigna: Corniolo, San Paolo in Alpe, Ridracoli, Strabatenza, Pietrapazza, Poggio alla Lastra and Casanova dell'Alpe.

The data were collected from a variety of historical sources, parish archives, and local monographic studies—and reflect the fragmented nature of statistical memory for marginalized areas.

This notebook is part of the rural-decline-gis project¶

The original project is developed in a GIS environment (QGIS + PostGIS + QGIS Server) and is available on Codeberg. This notebook is its analytical extension in Python, focusing on pandas, numpy, and data visualization.


Methodological note: Population values ​​of 0 indicate documented total depopulation. Missing data for some parishes in certain years reflect gaps in the sources, not a lack of population.

1. Import and configuration¶

In [8]:
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.ticker as mticker
import matplotlib.patches as mpatches
import seaborn as sns
import psycopg2
from psycopg2 import sql
import warnings

warnings.filterwarnings('ignore')

PALETTE = {
    'Corniolo':           '#4A7C59',  
    'San Paolo in Alpe':  '#C0392B',  
    'Ridracoli':          '#2471A3',  
    'Strabatenza':        '#D68910',  
    'Pietrapazza':        '#7D3C98',  
    "Casanova dell'Alpe": '#1A5276',  
    "Poggio alla Lastra": '#345434'
}


FONT_TITLE  = {'fontsize': 15, 'fontweight': 'bold', 'color': '#2C3E50'}
FONT_LABEL  = {'fontsize': 11, 'color': '#555555'}
FONT_ANNOT  = {'fontsize':  9, 'color': '#888888', 'style': 'italic'}
BG_COLOR    = '#FAFAF8'
GRID_COLOR  = '#E5E5E0'

plt.rcParams.update({
    'figure.facecolor':   BG_COLOR,
    'axes.facecolor':     BG_COLOR,
    'axes.spines.top':    False,
    'axes.spines.right':  False,
    'axes.grid':          True,
    'grid.color':         GRID_COLOR,
    'grid.linewidth':     0.8,
    'font.family':        'sans-serif',
    'axes.labelcolor':    '#555555',
    'xtick.color':        '#888888',
    'ytick.color':        '#888888',
})

print("Libraries loaded:")
print(f"  pandas    {pd.__version__}")
print(f"  numpy     {np.__version__}")
print(f"  seaborn   {sns.__version__}")
print(f"  psycopg2  {psycopg2.__version__}")
Libraries loaded:
  pandas    2.3.3
  numpy     2.4.6
  seaborn   0.13.2
  psycopg2  2.9.12 (dt dec pq3 ext lo64)

2. Postgres connection and data loading¶

In [10]:
# ── Connection parameter ──────────────────────────────────────────────
# Modify connection parameters as required
# Password is read from ~/.pgpass file, there's an example one in analysis/python folder
DB_PARAMS = {
    'host':     'localhost',
    'port':     5432,
    'dbname':   'rural-decline-gis',  
    'user':     'gabriele',        
}

QUERY = """
    SELECT
        p.name        AS parish,
        pp.year       AS year,
        pp.population AS population
    FROM parish_population pp
    JOIN parish p ON p.id = pp.parish_id
    ORDER BY p.name, pp.year;
"""

try:
    conn = psycopg2.connect(**DB_PARAMS)
    df = pd.read_sql(QUERY, conn, dtype={'year': int, 'population': float})
    conn.close()
    print("Succesfull connection")
except psycopg2.OperationalError as e:
    print(f"Failed connection: {e}")
    raise

print(f"Loaded dataset: {df.shape[0]} rows, {df.shape[1]} columns")
print(f"Parishes: {df['parish'].nunique()}")
print()
df.info()
Succesfull connection
Loaded dataset: 50 rows, 3 columns
Parishes: 7

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 50 entries, 0 to 49
Data columns (total 3 columns):
 #   Column      Non-Null Count  Dtype  
---  ------      --------------  -----  
 0   parish      50 non-null     object 
 1   year        50 non-null     int64  
 2   population  50 non-null     float64
dtypes: float64(1), int64(1), object(1)
memory usage: 1.3+ KB
In [13]:
# Preview with base statistics
df.groupby('parish').agg(
    censuses=('year', 'count'),
    oldest_data=('year', 'min'),
    newest_data=('year', 'max'),
    max_population_recorded=('population', 'max'),
).astype({'max_population_recorded': 'Int64'}).sort_values('max_population_recorded', ascending=False)
Out[13]:
censuses oldest_data newest_data max_population_recorded
parish
Strabatenza 15 1371 1971 436
San Paolo in Alpe 6 1716 1962 350
Pietrapazza 17 1705 1971 245
Casanova dell'Alpe 9 1833 1981 200
Corniolo 1 1347 1347 50
Ridracoli 1 1347 1347 30
Poggio alla Lastra 1 1371 1371 25

3. Data cleaning and validation¶

The historical data presents some critical issues typical of archival sources:

  • Uneven temporal coverage: not all parishes are surveyed in the same years
  • Zero values: indicate confirmed total depopulation (not a missing data point)
  • Chronological order: to be verified for each series
In [14]:
# ── 1. Null values ───────────────────────────────────────────────────────
print(f"\nNull values per column:")
print(df.isnull().sum().to_string())

# ── 2. Separation: zero = total depopulation vs NaN = missing data ─────
depopulated = df[df['population'] == 0][['parish', 'year']]
print(f"\nParishes with documented total depopulation:")
print(depopulated.to_string(index=False))

df['year'] = df['year'].astype(int)

print("\nData sorted and validated.")
Null values per column:
parish        0
year          0
population    0

Parishes with documented total depopulation:
            parish  year
Casanova dell'Alpe  1981
       Pietrapazza  1971
       Strabatenza  1971

Data sorted and validated.
In [17]:
# Time coverage map by parish
data_pivot = df.pivot_table(
    index='parish',
    columns='year',
    values='population',
    aggfunc='first'
)

coverage = data_pivot.notna().astype(int)

fig, ax = plt.subplots(figsize=(13, 3.2))
sns.heatmap(
    coverage,
    cmap=['#E8E8E3', '#4A7C59'],
    linewidths=0.5,
    linecolor='white',
    cbar=False,
    ax=ax
)
ax.set_title('Temporal coverage of censuses by parish', **FONT_TITLE, pad=12)
ax.set_xlabel('Year', **FONT_LABEL)
ax.set_ylabel('')
ax.tick_params(axis='x', rotation=45)

# Legend
available = mpatches.Patch(color='#4A7C59', label='Available data')
missing  = mpatches.Patch(color='#E8E8E3', label='Missing data')
ax.legend(handles=[available, missing], loc='lower right', fontsize=9, framealpha=0.9)

plt.tight_layout()
plt.savefig('../figures/00_copertura_temporale.png', dpi=150, bbox_inches='tight')
plt.show()
No description has been provided for this image

4. Conclusions¶


TODO¶

Future development¶

TODO