Temperaturas italianas, 2006–2023: una historia de datos climáticos

Desde un amplio libro de ISTAT hasta datos ordenados, estimaciones de tendencias y gráficos de matplotlib estilizados para combinar con el blog de las capitales de provincia italianas.
data science
python
climate
data visualization
Autor/a

Federico Viscioletti

Fecha de publicación

2 de julio de 2026

Introducción

Italia concentra climas alpino, continental y mediterráneo en una península estrecha. Eso la convierte en un buen lugar para estudiar cómo el calentamiento y los cambios en las precipitaciones se manifiestan en entornos locales muy diferentes.

En este artículo uso el libro de datos meteoclimáticos actualizado de ISTAT para las capitales de provincia para responder a unas cuantas preguntas sencillas:

  • ¿Con qué rapidez se están calentando las capitales de provincia italianas?
  • ¿Qué lugares son los más cálidos de media?
  • ¿Avanza la precipitación en la misma dirección?
  • ¿Cómo fue 2023 en relación con las líneas base recientes e históricas?

El libro de origen es rico, pero está estructurado de forma incómoda. Como suele ocurrir con las estadísticas oficiales, lo difícil no es dibujar los gráficos, sino convertir un diseño de Excel pensado para lectura humana en datos ordenados.

Una nota para futuros científicos de datos

Una de las habilidades más potentes que puede desarrollar un científico de datos es la capacidad de convertir datos en bruto en información clara y accionable. Los organismos estadísticos nacionales como el ISTAT en Italia, Eurostat o la US Census Bureau ofrecen una gran cantidad de conjuntos de datos gratuitos y de alta calidad para experimentar y obtener conclusiones valiosas.

Como verás en este análisis, la barrera técnica es en realidad bastante baja. Una vez que aprendes a manejar libros de trabajo estructurados y a realizar un Análisis Exploratorio de Datos (EDA) básico, puedes descubrir historias sobre los campos que te apasionan: desde el cambio climático hasta la economía o las tendencias sociales. Espero que este cuaderno te sirva de hoja de ruta en tu propio proceso de descubrimiento.

Este artículo reconstruye el análisis original en formato cuaderno usando el libro de ISTAT actualizado para las capitales de provincia, que cubre la temperatura y la precipitación anuales de 2006 a 2023, además de tablas de anomalías para 2023.

Si te gusta este trabajo, por favor dale una estrella al repositorio de Github relacionado siguiendo este enlace

Cargar el libro de trabajo

Las hojas clave para el análisis principal son:

  • Tavola_1: temperatura media anual por capital de provincia, 2006–2023
  • Tavola_2: precipitación anual por capital de provincia, 2006–2023
  • Tavola_3: anomalías de temperatura de 2023 frente a 2006–2015 y a las normales climatológicas
import matplotlib
import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import plotly.io as pio
from pathlib import Path

pio.renderers.default = "notebook_connected"
pio.renderers["notebook_connected"].include_plotlyjs = False

FILE_PATH = (
    "Tavole-_Dati-Meteoclimatici_Capoluoghi-Provincia_Anno-2023-e-serie-2006-2023.xlsx"
)

# --- Blog design system palette (see design-system.html) -----------------
BLUE = "#2780e3"
CHARCOAL = "#343a40"
INK = "#1e1e1e"
TEXT = "#343a40"
MUTED = "rgba(52, 58, 64, 0.72)"
MUTED_HEX = "#6c757d"
LINE = "#dee2e6"
SOFT = "#f8f9fa"
SOFT_BLUE = "#d4e6f9"
CODE = "#7d12ba"

matplotlib.rcParams.update(
    {
        "font.family": "sans-serif",
        "font.size": 12,
        "text.color": TEXT,
        "axes.labelcolor": TEXT,
        "axes.edgecolor": LINE,
        "axes.titlecolor": TEXT,
        "axes.titleweight": "normal",
        "axes.titlesize": 16,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.color": LINE,
        "grid.linewidth": 0.8,
        "grid.linestyle": "-",
        "axes.facecolor": "#ffffff",
        "figure.facecolor": "#ffffff",
        "xtick.color": MUTED_HEX,
        "ytick.color": MUTED_HEX,
        "axes.linewidth": 1.0,
        "legend.frameon": False,
        "legend.fontsize": 11,
        "figure.dpi": 130,
    }
)

xls = pd.ExcelFile(FILE_PATH)
xls.sheet_names
['Indice',
 'Tavola_1',
 'Tavola_2',
 'Tavola_3',
 'Tavola_4',
 'Tavola_5',
 'Tavola_6',
 'Tavola_7',
 'Tavola_8',
 'Tavola_9',
 'Tavola_10',
 'Tavola_11',
 'Tavola_12',
 'Tavola_13',
 'Tavola_14',
 'Tavola_14 segue',
 'Tavola_15']

De formato ancho a ordenado

Las tablas anuales se almacenan en formato ancho (wide): una fila por ciudad y una columna por año. Ese formato es cómodo para inspección visual, pero incómodo para el análisis. Un pequeño parser nos permite fundir (melt) cada hoja a formato largo gestionando el centinela de valores ausentes del archivo (....).

def parse_table(sheet_name, value_name):
    df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)

    years = [int(x) for x in df.iloc[3, 1:19].tolist()]
    data = df.iloc[4:, :19].copy()
    data.columns = ["Province"] + years

    data = data[data["Province"].notna()]
    data = data[
        ~data["Province"]
        .astype(str)
        .str.contains(
            r"Fonte|Totale|Nota|Nelle tavole|^\s*$",
            regex=True,
            na=False,
        )
    ]

    long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
    long["Province"] = long["Province"].astype(str).str.strip()
    long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
    long[value_name] = pd.to_numeric(
        long[value_name].replace("....", np.nan),
        errors="coerce",
    )
    return long


temperature_data = parse_table("Tavola_1", "Temperature")
precipitation_data = parse_table("Tavola_2", "Precipitation")

italy_weather_data = temperature_data.merge(
    precipitation_data,
    on=["Province", "Year"],
    how="inner",
)

italy_weather_data.head()
Province Year Temperature Precipitation
0 Torino 2006 15.0 697.2
1 Vercelli 2006 13.2 650.4
2 Novara 2006 14.3 664.0
3 Cuneo 2006 13.0 733.2
4 Asti 2006 13.7 517.8
import warnings
warnings.simplefilter(action='ignore', category=FutureWarning)

Una comprobación rápida confirma que el conjunto de datos limpio abarca todo el rango 2006–2023.

italy_weather_data.agg(
    provinces=("Province", "nunique"),
    start_year=("Year", "min"),
    end_year=("Year", "max"),
)
Province Year
provinces 110.0 NaN
start_year NaN 2006.0
end_year NaN 2023.0

Tendencias nacionales

Una primera visión natural es la media anual de todas las capitales de provincia. Esto comprime la variación local en un único resumen nacional y hace mucho más fácil ver la dirección del cambio.

def linear_slope(df, y_col):
    clean = df.dropna(subset=[y_col]).copy()
    x = clean["Year"].to_numpy(dtype=float)
    y = clean[y_col].to_numpy(dtype=float)
    if len(x) < 2:
        return np.nan
    return float(np.polyfit(x, y, 1)[0])


italy_temperature_trend = (
    italy_weather_data.groupby("Year", as_index=False)["Temperature"].mean().dropna()
)

temp_coef = np.polyfit(
    italy_temperature_trend["Year"].to_numpy(dtype=float),
    italy_temperature_trend["Temperature"].to_numpy(dtype=float),
    1,
)
italy_temperature_trend["Trend"] = (
    temp_coef[0] * italy_temperature_trend["Year"] + temp_coef[1]
)

national_temp_slope = linear_slope(italy_temperature_trend, "Temperature")
national_temp_change = national_temp_slope * (2023 - 2006)

national_temp_slope, national_temp_change
(0.05477762707301679, 0.9312196602412854)

En el conjunto de las capitales de provincia, la tasa de calentamiento ajustada es de aproximadamente 0,055 °C por año, es decir, aproximadamente 0,9–1,0 °C en todo el periodo. Eso no significa que cada ciudad se caliente exactamente al mismo ritmo, pero establece el telón de fondo nacional para el resto del análisis.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import MaxNLocator

def parse_table(sheet_name, value_name):
    df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)
    years = [int(x) for x in df.iloc[3, 1:19].tolist()]
    data = df.iloc[4:, :19].copy()
    data.columns = ["Province"] + years
    data = data[data["Province"].notna()]
    data["Province"] = data["Province"].astype(str).str.strip()
    data = data[~data["Province"].str.contains(r"Fonte|Totale|Nota|Nelle tavole|^$", regex=True, na=False)]
    long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
    long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
    long[value_name] = pd.to_numeric(long[value_name].replace("....", np.nan), errors="coerce")
    return long

italy_mean = italy_weather_data.groupby("Year", as_index=False)["Temperature"].mean().dropna()
coef = np.polyfit(italy_mean["Year"], italy_mean["Temperature"], 1)
italy_mean["Trend"] = coef[0] * italy_mean["Year"] + coef[1]

BG = "#f7f6f2"; PANEL = "#f9f8f5"; TEXT = "#28251d"; MUTED = "#7a7974"; GRID = "#dcd9d5"; ACCENT = "#01696f"

plt.rcParams.update({
    "figure.facecolor": BG, "axes.facecolor": PANEL, "axes.edgecolor": GRID, "axes.labelcolor": TEXT,
    "axes.titlecolor": TEXT, "xtick.color": MUTED, "ytick.color": MUTED, "text.color": TEXT,
    "font.size": 11, "axes.titlesize": 18, "axes.titleweight": "bold", "legend.frameon": False
})

fig, ax = plt.subplots(figsize=(10.5, 5.8))
ax.plot(italy_mean["Year"], italy_mean["Temperature"], color=ACCENT, linewidth=2.8, marker="o", markersize=5.5, label="Annual mean", zorder=3)
ax.plot(italy_mean["Year"], italy_mean["Trend"], color=TEXT, linewidth=1.8, linestyle=(0, (4, 4)), alpha=0.7, label="Linear trend", zorder=2)

# Set title with padding and place subtitle below it
ax.set_title("Average annual temperature across Italian provincial capitals", loc="left", pad=38)
ax.text(0, 1.02, "Mean annual temperature by provincial capital, averaged nationally, 2006–2023", transform=ax.transAxes, fontsize=10.5, color=MUTED)

ax.set_xlabel("Year"); ax.set_ylabel("Temperature (°C)")
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xticks(italy_mean["Year"][::2])
ax.grid(axis="y", color=GRID, linewidth=0.8, alpha=0.85)
ax.grid(axis="x", visible=False)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.legend(loc="upper left", ncol=2)

plt.tight_layout()
plt.show()
Figura 1: Temperatura media anual en las capitales de provincia italianas, 2006–2023.

La precipitación es mucho más ruidosa. La media anual todavía puede resumirse con una línea sencilla, pero las oscilaciones interanuales son mucho mayores que para la temperatura, por lo que la tendencia ajustada debe leerse con más cautela. La banda sombreada del gráfico siguiente es una dispersión descriptiva — una desviación estándar de las medias nacionales anuales — añadida solo como guía visual de lo erráticamente que salta la precipitación de un año a otro. No es un intervalo de confianza ni ningún tipo de estimación de incertidumbre alrededor de la tendencia.

italy_precipitation_trend = (
    italy_weather_data.groupby("Year", as_index=False)["Precipitation"]
    .mean()
    .dropna()
)

prec_coef = np.polyfit(
    italy_precipitation_trend["Year"].to_numpy(dtype=float),
    italy_precipitation_trend["Precipitation"].to_numpy(dtype=float),
    1,
)

italy_precipitation_trend["Trend"] = (
    prec_coef[0] * italy_precipitation_trend["Year"] + prec_coef[1]
)

national_prec_slope = linear_slope(
    italy_precipitation_trend,
    "Precipitation"
)
print(f"The overall trend is {round(national_prec_slope, 2)}mm")
The overall trend is -4.57mm
fig, ax = plt.subplots(figsize=(10.5, 5.8))

# Descriptive spread: ±1 standard deviation of the yearly national means.
# This is a visual guide to year-to-year variability, NOT a confidence
# interval or an estimate of uncertainty around the trend line.
prec_std = italy_precipitation_trend["Precipitation"].std()
italy_precipitation_trend["Lower"] = italy_precipitation_trend["Precipitation"] - prec_std
italy_precipitation_trend["Upper"] = italy_precipitation_trend["Precipitation"] + prec_std

# Shaded band
ax.fill_between(italy_precipitation_trend["Year"], italy_precipitation_trend["Lower"], italy_precipitation_trend["Upper"], facecolor=ACCENT, alpha=0.12, linewidth=0, zorder=1, label="±1σ (year-to-year spread)")

# Lines
ax.plot(italy_precipitation_trend["Year"], italy_precipitation_trend["Precipitation"], color=ACCENT, linewidth=2.8, marker="o", markersize=5.5, label="Annual mean", zorder=3)
ax.plot(italy_precipitation_trend["Year"], italy_precipitation_trend["Trend"], color=TEXT, linewidth=1.8, linestyle=(0, (4, 4)), alpha=0.7, label="Linear trend", zorder=2)

# Titles and Subtitles
ax.set_title("Italy precipitation trend (2006–2023)", loc="left", pad=38)
ax.text(0, 1.02, "Mean annual precipitation across provincial capitals from ISTAT Tavola_2", transform=ax.transAxes, fontsize=10.5, color=MUTED)

ax.set_xlabel("Year"); ax.set_ylabel("Rain (mm)")
ax.xaxis.set_major_locator(MaxNLocator(integer=True))
ax.set_xticks(italy_precipitation_trend["Year"][::2])
ax.grid(axis="y", color=GRID, linewidth=0.8, alpha=0.85)
ax.grid(axis="x", visible=False)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.legend(loc="lower right")

plt.tight_layout()
plt.show()
Figura 2: Precipitación media anual en las capitales de provincia italianas, 2006–2023.

Los lugares más cálidos

Promediar la temperatura en todo el periodo 2006–2023 da una clasificación sencilla de “ciudades más cálidas”. Como es de esperar, las líderes se concentran en Sicilia y el extremo sur.

top_hottest = (
    italy_weather_data.groupby("Province", as_index=False)["Temperature"]
    .mean()
    .sort_values("Temperature", ascending=False)
    .head(5)
)

top_hottest
Tabla 1: Cinco capitales de provincia más cálidas por temperatura media, 2006–2023.
Province Temperature
56 Messina 19.655556
24 Catania 19.650000
79 Reggio di Calabria 19.300000
0 Agrigento 19.244444
91 Taranto 19.177778

Ser cálido no es lo mismo que calentarse rápido. Para estimar las tasas de calentamiento, ajusto una pendiente lineal para cada ciudad después de conservar solo los lugares con al menos 15 observaciones anuales válidas, lo que reduce la influencia de historiales de reporte incompletos.

def slope_per_city(group, value_col):
    clean = group.dropna(subset=[value_col])
    x = clean["Year"].to_numpy(dtype=float)
    y = clean[value_col].to_numpy(dtype=float)
    if len(x) < 2:
        return np.nan
    return round(float(np.polyfit(x, y, 1)[0]), 3)

warming_base = italy_weather_data.groupby("Province").filter(
    lambda g: g["Temperature"].notna().sum() >= 15
)

warming_rates = (
    warming_base.groupby("Province")
    .apply(lambda g: slope_per_city(g, "Temperature"))
    .rename("Trend (°C/year)")
    .reset_index()
    .sort_values("Trend (°C/year)", ascending=False)
)


warming_rates.head(10)
Province Trend (°C/year)
87 Sondrio 0.150
53 Massa Carrara 0.132
81 Roma 0.122
15 Bologna 0.118
29 Cremona 0.117
0 Agrigento 0.110
24 Catania 0.109
57 Modena 0.107
84 Sassari 0.099
52 Mantova 0.098
top_warming = warming_rates.head(10)
fig, ax = plt.subplots(figsize=(10.5, 6.5))

bars = ax.barh(top_warming["Province"][::-1], top_warming["Trend (°C/year)"][::-1], color=ACCENT, alpha=0.8, edgecolor="none", height=0.65)

# Subtitle below title
ax.set_title("Fastest Warming Italian Provincial Capitals", loc="left", pad=38)
ax.text(0, 1.02, "Linear slope from annual mean temperature, cities with at least 15 valid years", transform=ax.transAxes, fontsize=10.5, color=MUTED_HEX)

ax.set_xlabel("Warming Trend (°C per year)", color=MUTED_HEX, fontsize=11, labelpad=10)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.grid(axis="x", linestyle="-", color=GRID, alpha=0.8)
ax.grid(axis="y", visible=False)

for bar in bars:
    width = bar.get_width()
    ax.text(width + 0.003, bar.get_y() + bar.get_height()/2, f"+{width:.3f}", va="center", color=TEXT, fontsize=10, fontweight="bold")

plt.tight_layout()
plt.show()
Figura 3: Capitales de provincia con calentamiento más rápido, filtrado a ciudades con al menos 15 observaciones anuales válidas.

Anomalías de 2023

Las series largas son útiles, pero el libro también incluye una tabla de anomalías específica para 2023. Tavola_3 compara 2023 con la media de 2006–2015 y, cuando está disponible, con las normales climatológicas de 1981–2010 y 1971–2000.

anomaly_sheet = pd.read_excel(FILE_PATH, sheet_name="Tavola_3", header=None)

anomaly_data = anomaly_sheet.iloc[4:114, :7].copy()
anomaly_data.columns = [
    "Province",
    "Diff_2023_vs_2006_2015",
    "Mean_2006_2015",
    "Anom_1981_2010",
    "Clim_1981_2010",
    "Anom_1971_2000",
    "Clim_1971_2000",
]

anomaly_data = anomaly_data[anomaly_data["Province"].notna()]
anomaly_data = anomaly_data[
    ~anomaly_data["Province"].astype(str).str.contains(
        r"Fonte|^\s*$",
        regex=True,
        na=False,
    )
]
anomaly_data["Province"] = anomaly_data["Province"].astype(str).str.strip()

for col in anomaly_data.columns[1:]:
    anomaly_data[col] = pd.to_numeric(
        anomaly_data[col].replace("....", np.nan),
        errors="coerce",
    )

anomaly_data.head()
Province Diff_2023_vs_2006_2015 Mean_2006_2015 Anom_1981_2010 Clim_1981_2010 Anom_1971_2000 Clim_1971_2000
4 Torino 1.0 15.0 2.01 13.9 2.47 13.44
5 Vercelli 0.9 13.5 NaN NaN NaN NaN
6 Novara 1.1 14.4 NaN NaN NaN NaN
7 Cuneo 0.9 12.9 NaN NaN NaN NaN
8 Asti 1.1 13.3 NaN NaN NaN NaN

Entender la línea base 1981–2010

En climatología, una anomalía es la diferencia entre la temperatura observada y una media a largo plazo llamada normal climatológica. La Organización Meteorológica Mundial (OMM) usa periodos de 30 años para establecer estas normales.

  • Anomalía de +0,0 °C: el año fue exactamente tan cálido como la media de 1981–2010.
  • Anomalía positiva (p. ej., +2,66 °C): el año fue más cálido que la línea base histórica.

Cuando vemos valores que superan los +2,0 °C en la tabla siguiente, indica que esas ciudades experimentaron un año significativamente más cálido que las condiciones que caracterizaban el final del siglo XX.

Una advertencia sobre la cobertura: Tavola_3 no proporciona anomalías de normales climatológicas para todas las capitales de provincia. La hoja señala que esas líneas base de referencia se calculan solo para capitales de región con series suficientemente largas, y algunas de las diferencias de 2023 frente a 2006–2015 también faltan donde la serie reciente está incompleta. El ranking siguiente es, por tanto, entre lugares con comparaciones climatológicas disponibles, no todas las capitales de provincia — muestra qué ciudades destacan dentro de ese subconjunto comparable, no una lista exhaustiva de cada lugar cálido de Italia. Dentro de ese subconjunto, 2023 destaca con especial fuerza en ciudades como Perugia, Bologna, Milano y Torino.

top_anomalies = (
    anomaly_data.dropna(subset=["Anom_1981_2010"])
    .sort_values("Anom_1981_2010", ascending=False)
    [["Province", "Anom_1981_2010"]]
    .head(10)
)

top_anomalies
Tabla 2: Mayores anomalías de temperatura de 2023 respecto a la climatología 1981–2010.
Province Anom_1981_2010
61 Perugia 2.95
46 Bologna 2.56
16 Milano 2.38
4 Torino 2.01
26 Trento 1.84
70 Roma 1.84
31 Venezia 1.74
36 Trieste 1.74
25 Bolzano 1.72
12 Aosta 1.71
top_anomalies_plot = anomaly_data.dropna(subset=["Anom_1981_2010"]).sort_values("Anom_1981_2010", ascending=False).head(10)

fig, ax = plt.subplots(figsize=(10.5, 6.5))
bars = ax.barh(top_anomalies_plot["Province"][::-1], top_anomalies_plot["Anom_1981_2010"][::-1], color=ACCENT, alpha=0.8, edgecolor="none", height=0.65)

# Subtitle below title
ax.set_title("Largest 2023 temperature anomalies", loc="left", pad=38)
ax.text(0, 1.02, "Relative to 1981–2010 climatology from ISTAT Tavola_3", transform=ax.transAxes, fontsize=10.5, color=MUTED_HEX)

ax.set_xlabel("Anomaly (°C)", color=MUTED_HEX, fontsize=11, labelpad=10)
for spine in ["top", "right"]: ax.spines[spine].set_visible(False)
ax.spines["left"].set_color(GRID); ax.spines["bottom"].set_color(GRID)
ax.grid(axis="x", linestyle="-", color=GRID, alpha=0.8)
ax.grid(axis="y", visible=False)

for bar in bars:
    width = bar.get_width()
    ax.text(width + 0.05, bar.get_y() + bar.get_height()/2, f"+{width:.2f}", va="center", color=TEXT, fontsize=10, fontweight="bold")

plt.tight_layout()
plt.show()
Figura 4: Mayores anomalías de temperatura de 2023 respecto a la climatología 1981–2010.

Correlación: calor frente a precipitación

Un seguimiento natural es si los años más cálidos también tienden a ser más húmedos o más secos. Representar la temperatura media nacional anual frente a la precipitación total ofrece una primera vista de cómo se mueven juntos los dos agregados.

import seaborn as sns

# Prepare data for correlation
corr_df = italy_temperature_trend[['Year', 'Temperature']].merge(
    italy_precipitation_trend[['Year', 'Precipitation']], 
    on='Year'
)

fig, ax = plt.subplots(figsize=(8, 6))
sns.regplot(data=corr_df, x='Temperature', y='Precipitation', 
            scatter_kws={'color': ACCENT, 's': 60}, 
            line_kws={'color': TEXT, 'linestyle': '--', 'linewidth': 1.5}, ax=ax)

ax.set_title("Correlation: Annual Temp vs. Precipitation", loc='left', pad=25)
ax.set_xlabel("Mean Temperature (°C)")
ax.set_ylabel("Total Precipitation (mm)")

# Calculate correlation coefficient
r_val = corr_df['Temperature'].corr(corr_df['Precipitation'])
ax.text(0.05, 0.05, f"Pearson r = {r_val:.2f}", transform=ax.transAxes, 
        fontsize=12, fontweight='bold', color=TEXT)

plt.tight_layout()
plt.show()
Figura 5: Correlación: temperatura anual frente a precipitación.

El diagrama de dispersión muestra una asociación negativa de débil a moderada en estos agregados anuales: a lo largo de los 18 años, los años más cálidos han tendido a coincidir con una precipitación nacional algo menor. Con un Pearson r de aproximadamente −0,34, la relación es real pero lejos de ser ajustada — muchos años cálidos no fueron notablemente secos. Esto solo describe cómo covarían las dos medias nacionales de un año a otro; no dice nada sobre el mecanismo físico que impulsa cada serie, y un único año puede mover la relación de forma notable — el fuerte descenso de 2022, por ejemplo, refleja la severa sequía que sufrió Italia ese año y tira visiblemente de la línea de regresión hacia abajo.

Cartografiar el cambio

Un gráfico de series temporales muestra cuándo suben las temperaturas. Un mapa muestra dónde se sitúa el calor. Para geocodificar las capitales de provincia una sola vez, guardo las coordenadas en caché localmente y las reutilizo en renderizados posteriores.

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.io as pio

# Re-defining the parser to ensure data is available
def parse_table(sheet_name, value_name):
    df = pd.read_excel(FILE_PATH, sheet_name=sheet_name, header=None)
    years = [int(x) for x in df.iloc[3, 1:19].tolist()]
    data = df.iloc[4:, :19].copy()
    data.columns = ["Province"] + years
    data = data[data["Province"].notna()]
    data["Province"] = data["Province"].astype(str).str.strip()
    data = data[~data["Province"].str.contains(r"Fonte|Totale|Nota|Nelle tavole|^$", regex=True, na=False)]
    long = data.melt(id_vars="Province", var_name="Year", value_name=value_name)
    long["Year"] = pd.to_numeric(long["Year"], errors="coerce")
    long[value_name] = pd.to_numeric(long[value_name].replace("....", np.nan), errors="coerce")
    return long

# Load data
temp_df = parse_table("Tavola_1", "Temperature")
precip_df = parse_table("Tavola_2", "Precipitation")
italy_weather_data = temp_df.merge(precip_df, on=["Province", "Year"], how="inner")

# A dictionary of coordinates for Italian provincial capitals
city_coords = {
    'Torino': [45.0703, 7.6869], 'Vercelli': [45.3238, 8.4232], 'Novara': [45.4468, 8.6212],
    'Cuneo': [44.3833, 7.5500], 'Asti': [44.9005, 8.2069], 'Alessandria': [44.9129, 8.6154],
    'Biella': [45.5630, 8.0579], 'Verbano-Cusio-Ossola': [45.9220, 8.5516], 'Aosta': [45.7371, 7.3201],
    'Milano': [45.4642, 9.1900], 'Lodi': [45.3139, 9.5032], 'Monza e della Brianza': [45.5845, 9.2744],
    'Bergamo': [45.6983, 9.6773], 'Brescia': [45.5398, 10.2181], 'Pavia': [45.1850, 9.1546],
    'Como': [45.8081, 9.0852], 'Varese': [45.8167, 8.8333], 'Sondrio': [46.1690, 9.8731],
    'Lecco': [45.8559, 9.3977], 'Mantova': [45.1564, 10.7911], 'Cremona': [45.1333, 10.0333],
    'Bolzano': [46.4983, 11.3548], 'Trento': [46.0667, 11.1167], 'Verona': [45.4384, 10.9916],
    'Vicenza': [45.5479, 11.5446], 'Belluno': [46.1408, 12.2161], 'Treviso': [45.6667, 12.2450],
    'Venezia': [45.4408, 12.3155], 'Padova': [45.4064, 11.8768], 'Rovigo': [45.0711, 11.7905],
    'Pordenone': [45.9569, 12.6605], 'Udine': [46.0625, 13.2346], 'Gorizia': [45.9409, 13.6222],
    'Trieste': [45.6495, 13.7768], 'Piacenza': [45.0526, 9.6930], 'Parma': [44.8015, 10.3279],
    'Reggio nell\'Emilia': [44.6982, 10.6312], 'Modena': [44.6471, 10.9252], 'Bologna': [44.4949, 11.3426],
    'Ferrara': [44.8381, 11.6198], 'Ravenna': [44.4183, 12.2035], 'Forlì-Cesena': [44.2227, 12.0409],
    'Rimini': [44.0594, 12.5684], 'Massa Carrara': [44.0375, 10.1417], 'Lucca': [43.8429, 10.5027],
    'Pistoia': [43.9333, 10.9167], 'Firenze': [43.7696, 11.2558], 'Livorno': [43.5485, 10.3106],
    'Pisa': [43.7085, 10.4036], 'Arezzo': [43.4631, 11.8781], 'Siena': [43.3186, 11.3306],
    'Grosseto': [42.7667, 11.1167], 'Prato': [43.8777, 11.1022], 'Perugia': [43.1107, 12.3908],
    'Terni': [42.5639, 12.6427], 'Ancona': [43.6158, 13.5189], 'Pesaro e Urbino': [43.9100, 12.9133],
    'Macerata': [43.3003, 13.4531], 'Ascoli Piceno': [42.8536, 13.5768], 'Fermo': [43.1610, 13.7183],
    'Viterbo': [42.4191, 12.1051], 'Rieti': [42.4000, 12.8667], 'Roma': [41.9028, 12.4964],
    'Latina': [41.4676, 12.9036], 'Frosinone': [41.6394, 13.3411], 'L\'Aquila': [42.3489, 13.3980],
    'Teramo': [42.6589, 13.7044], 'Pescara': [42.4618, 14.2142], 'Chieti': [42.3511, 14.1675],
    'Campobasso': [41.5603, 14.6584], 'Isernia': [41.5947, 14.2342], 'Caserta': [41.0736, 14.3347],
    'Benevento': [41.1297, 14.7821], 'Napoli': [40.8518, 14.2681], 'Avellino': [40.9144, 14.7936],
    'Salerno': [40.6779, 14.7658], 'Foggia': [41.4622, 15.5446], 'Bari': [41.1171, 16.8719],
    'Taranto': [40.4677, 17.2433], 'Brindisi': [40.6321, 17.9361], 'Lecce': [40.3515, 18.1750],
    'Barletta': [41.3197, 16.2768], 'Potenza': [40.6404, 15.8051], 'Matera': [40.6664, 16.6043],
    'Cosenza': [39.2983, 16.2537], 'Catanzaro': [38.9098, 16.5877], 'Reggio di Calabria': [38.1105, 15.6434],
    'Crotone': [39.0808, 17.1273], 'Vibo Valentia': [38.6753, 16.1011], 'Messina': [38.1938, 15.5540],
    'Palermo': [38.1157, 13.3615], 'Trapani': [38.0175, 12.5150], 'Agrigento': [37.3111, 13.5765],
    'Caltanissetta': [37.4903, 14.0622], 'Enna': [37.5671, 14.2750], 'Catania': [37.5079, 15.0830],
    'Ragusa': [36.9269, 14.7231], 'Siracusa': [37.0755, 15.2866], 'Sassari': [40.7259, 8.5615],
    'Nuoro': [40.3231, 9.3303], 'Cagliari': [39.2238, 9.1217], 'Oristano': [39.9056, 8.5911],
    'Carbonia': [39.1671, 8.5222],
    'La Spezia': [44.2384, 9.6912],
    'Andria': [41.2276, 16.2955],
    'Trani': [41.2782, 16.4186],
}

# The workbook uses short province-capital names (e.g. "Verbania", "Monza",
# "Pesaro Urbino", "Forlì") that do not match the longer administrative keys
# in city_coords above. Normalize before lookup so no point disappears
# silently when the dictionary key uses a different spelling.
COORD_NAME_ALIASES = {
    'Verbania': 'Verbano-Cusio-Ossola',
    'Monza': 'Monza e della Brianza',
    'Pesaro Urbino': 'Pesaro e Urbino',
    'Forlì': 'Forlì-Cesena',
}

def lookup_coords(province):
    key = COORD_NAME_ALIASES.get(province, province)
    return city_coords.get(key, [None, None])

# Map coordinates and warn about anything still unmapped so a miss is loud,
# not silent.
italy_weather_data['Lat'] = italy_weather_data['Province'].map(lambda x: lookup_coords(x)[0])
italy_weather_data['Lon'] = italy_weather_data['Province'].map(lambda x: lookup_coords(x)[1])
unmapped = italy_weather_data.loc[italy_weather_data['Lat'].isna(), 'Province'].unique()
if len(unmapped) > 0:
    print(f"Unmapped provinces (no coordinates): {list(unmapped)}")
map_df = italy_weather_data.dropna(subset=['Lat', 'Lon', 'Temperature']).copy()

# Prepare Mapbox map
fig = px.scatter_mapbox(
    map_df,
    lat="Lat", lon="Lon", color="Temperature",
    hover_name="Province", animation_frame="Year",
    title="Evolution of Annual Temperatures in Italy (2006-2023)",
    color_continuous_scale=px.colors.sequential.YlOrRd,
    range_color=[map_df['Temperature'].min(), map_df['Temperature'].max()],
    mapbox_style="open-street-map",
    zoom=4.5, center=dict(lat=42.0, lon=12.5)
)

fig.update_layout(height=700, margin=dict(r=10, t=80, b=40, l=10), paper_bgcolor=globals().get('BG', '#f7f6f2'))
fig
Unmapped provinces (no coordinates): ['Imperia', 'Savona', 'Genova', 'a) I dati meteoclimatici delle stazioni esaminate e gli indicatori statistici calcolati forniscono misure riferite alle aree monitorate.']
Figura 6: Temperatura media anual por capital de provincia para años seleccionados, 2006–2023.

Una vista complementaria promedia cada capital en toda la ventana 2006–2023, de modo que el persistente gradiente norte–sur — Sicilia y el extremo sur brillando como los más cálidos, los Alpes como los más frescos — se lee de un vistazo.

# Calculate mean per city for the static map
mean_per_city = (
    map_df.groupby("Province", as_index=False)
    .agg(
        Lat=("Lat", "first"),
        Lon=("Lon", "first"),
        Temperature=("Temperature", "mean"),
    )
)

# Create a static Mapbox plot to match the interactive look and feel
fig_static = px.scatter_mapbox(
    mean_per_city,
    lat="Lat",
    lon="Lon",
    color="Temperature",
    hover_name="Province",
    size_max=15,
    color_continuous_scale=px.colors.sequential.YlOrRd,
    range_color=[map_df['Temperature'].min(), map_df['Temperature'].max()],
    mapbox_style="open-street-map",
    zoom=4.5,
    center=dict(lat=42.0, lon=12.5),
    title="<b>Mean temperature by provincial capital (2006–2023)</b><br><span style='font-size:12px; color:#7a7974'>Average temperature across the full series from ISTAT Tavola_1</span>"
)

fig_static.update_layout(
    height=700,
    margin=dict(r=10, t=80, b=40, l=10),
    paper_bgcolor=BG,
    font=dict(color=TEXT)
)

fig_static
Figura 7: Temperatura media anual por capital de provincia, promediada en 2006–2023.

Conclusiones

El libro actualizado hace fácil ver el patrón nacional: las capitales de provincia italianas muestran una clara tendencia ascendente de temperatura en 2006–2023, mientras que la precipitación es mucho más ruidosa y menos uniforme.

Algunas conclusiones amplias destacan:

  • La media entre las capitales de provincia apunta a un calentamiento constante a lo largo del periodo.
  • Las ciudades más cálidas se concentran en Sicilia y el extremo sur.
  • El ranking de “calentamiento más rápido” depende fuertemente de la completitud de los datos, así que filtrar las series dispersas importa.
  • La tabla de anomalías de 2023 añade un contexto histórico importante, mostrando que varias ciudades no solo fueron cálidas en términos absolutos, sino inusualmente cálidas respecto a las líneas base climatológicas.

La próxima extensión natural sería incorporar las hojas de capitales de región (Tavola_9 y Tavola_10) y las tablas de extremos (Tavola_7, Tavola_8, Tavola_11Tavola_14) para un segundo artículo centrado en extremos de calor, noches cálidas e indicadores de lluvia intensa.

Reproduce el análisis

¿Prefieres ejecutarlo tú mismo? El cuaderno, el conjunto de datos ordenado y el repositorio están disponibles para que puedas revisar la limpieza, reutilizar los datos o ampliar los gráficos.

Cuaderno

Ejecuta el análisis completo localmente y reproduce las figuras del artículo.

Descargar .ipynb

Dataset ordenado

Usa la tabla limpia provincia-año con columnas de temperatura y precipitación.

Descargar .csv

Repositorio

Dale una estrella o haz un fork del proyecto complementario en GitHub.

Ver en GitHub

Share this article