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¶
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¶
# ── 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
# 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)
| 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
# ── 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.
# 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()