All notebooks/Climate Change ForecastingDownload .ipynb

Climate Change Trend Analysis and Forecasting¶

IDEAS TIH Summer Internship 2026 — Intern Project

This notebook ingests open Greenhouse Gas (GHG) emissions data (Our World in Data CO2 and GHG Emissions dataset), performs exploratory data analysis, engineers time-series features, trains and compares regression models, forecasts future emissions with a damped-trend exponential smoothing model, and simulates policy mitigation scenarios for 10 focus countries.

Scope note: this project deliberately focuses on classical machine learning and time-series methods — regression models and Holt's Damped Trend (ETS) forecasting — which are well suited to structured, annual, limited-length time series.

Table of Contents¶

  • Week 1: Data Acquisition, Exploration and Understanding
    • 1.1 Data Loading
    • 1.2 Data Profiling
    • 1.3 Exploratory Data Analysis
  • Week 2: Feature Engineering
    • 2.1 Time-Based Features
    • 2.2 Lag Features
    • 2.3 Per-Capita and Intensity Features
    • 2.4 Growth Rate Features
    • 2.5 Final Feature Dataset
  • Week 3: Baseline ML Models — Regression
    • 3.1 Problem Framing
    • 3.2 Train-Test Split
    • 3.3 Naive Baseline Model
    • 3.4 Linear Regression
    • 3.5 Random Forest Regressor
    • 3.6 Model Comparison Table
  • Week 4: Time-Series Forecasting with ETS(A,Ad,N) — Holt's Damped Trend
    • 4.1 Concept Introduction
    • 4.2 Model Fitting
    • 4.3 Forecasting to 2043
    • 4.4 Trend Interpretation
    • 4.5 Forecast Summary Table
    • 4.6 Model Validation
  • Week 5: Scenario Analysis
    • 5.1 Scenario Design
    • 5.2 Scenario Calculation
    • 5.3 Scenario Visualisations
    • 5.4 Impact Summary
  • Week 6: Conclusions and Limitations

Setup¶

Import libraries, set global display/plotting options, and define the 10 focus countries used consistently throughout the project (a mix of major emitters, economies at different development stages, and countries with documented emissions-reduction trajectories such as the UK and Germany).

In [1]:
import warnings

import numpy as np
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from plotly.subplots import make_subplots

from sklearn.linear_model import LinearRegression
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import LabelEncoder
from sklearn.metrics import mean_absolute_error, mean_squared_error
from statsmodels.tsa.holtwinters import ExponentialSmoothing

warnings.filterwarnings("ignore")

pd.set_option("display.max_columns", 60)

RANDOM_STATE = 42
DATA_PATH = "../data/owid-co2-data.csv"

# The 10 countries used consistently for all analysis, modelling, and scenario work
COUNTRIES = [
    "China", "United States", "India", "Russia", "Japan",
    "Germany", "Brazil", "United Kingdom", "South Africa", "Australia",
]

# Consistent colour palette reused across every chart in this notebook
COUNTRY_COLORS = dict(zip(COUNTRIES, px.colors.qualitative.Bold[:len(COUNTRIES)]))
SCENARIO_COLORS = {"BAU": "#1f77b4", "Moderate Mitigation": "#ff7f0e", "Aggressive Mitigation": "#2ca02c"}

Week 1: Data Acquisition, Exploration and Understanding¶

Learning objective: understand the structure and content of GHG datasets and produce a clean, profiled dataset ready for analysis.

1.1 Data Loading¶

We use the primary dataset for this project — Our World in Data's CO2 and Greenhouse Gas Emissions dataset (github.com/owid/co2-data), downloaded as a CSV. It is a country-year panel covering CO2, methane, nitrous oxide and total GHG emissions alongside population, GDP and energy indicators.

In [2]:
raw_df = pd.read_csv(DATA_PATH)

print("Shape:", raw_df.shape)
print("\nFirst 10 rows:")
raw_df.head(10)
Shape: (50411, 79)

First 10 rows:
Out[2]:
country year iso_code population gdp cement_co2 cement_co2_per_capita co2 co2_growth_abs co2_growth_prct co2_including_luc co2_including_luc_growth_abs co2_including_luc_growth_prct co2_including_luc_per_capita co2_including_luc_per_gdp co2_including_luc_per_unit_energy co2_per_capita co2_per_gdp co2_per_unit_energy coal_co2 coal_co2_per_capita consumption_co2 consumption_co2_per_capita consumption_co2_per_gdp cumulative_cement_co2 cumulative_co2 cumulative_co2_including_luc cumulative_coal_co2 cumulative_flaring_co2 cumulative_gas_co2 ... other_co2_per_capita other_industry_co2 primary_energy_consumption share_global_cement_co2 share_global_co2 share_global_co2_including_luc share_global_coal_co2 share_global_cumulative_cement_co2 share_global_cumulative_co2 share_global_cumulative_co2_including_luc share_global_cumulative_coal_co2 share_global_cumulative_flaring_co2 share_global_cumulative_gas_co2 share_global_cumulative_luc_co2 share_global_cumulative_oil_co2 share_global_cumulative_other_co2 share_global_flaring_co2 share_global_gas_co2 share_global_luc_co2 share_global_oil_co2 share_global_other_co2 share_of_temperature_change_from_ghg temperature_change_from_ch4 temperature_change_from_co2 temperature_change_from_ghg temperature_change_from_n2o total_ghg total_ghg_excluding_lucf trade_co2 trade_co2_share
0 Afghanistan 1750 AFG 2802560.0 NaN 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 Afghanistan 1751 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 Afghanistan 1752 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 Afghanistan 1753 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 Afghanistan 1754 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
5 Afghanistan 1755 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
6 Afghanistan 1756 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
7 Afghanistan 1757 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
8 Afghanistan 1758 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
9 Afghanistan 1759 AFG NaN NaN 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 0.0 NaN NaN NaN NaN NaN ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

10 rows × 79 columns

Next, inspect the full column list and each column's data type to understand what fields are available before doing any filtering or feature work.

In [3]:
print("Column names ({} total):".format(len(raw_df.columns)))
print(list(raw_df.columns))
print("\nData types:")
raw_df.dtypes
Column names (79 total):
['country', 'year', 'iso_code', 'population', 'gdp', 'cement_co2', 'cement_co2_per_capita', 'co2', 'co2_growth_abs', 'co2_growth_prct', 'co2_including_luc', 'co2_including_luc_growth_abs', 'co2_including_luc_growth_prct', 'co2_including_luc_per_capita', 'co2_including_luc_per_gdp', 'co2_including_luc_per_unit_energy', 'co2_per_capita', 'co2_per_gdp', 'co2_per_unit_energy', 'coal_co2', 'coal_co2_per_capita', 'consumption_co2', 'consumption_co2_per_capita', 'consumption_co2_per_gdp', 'cumulative_cement_co2', 'cumulative_co2', 'cumulative_co2_including_luc', 'cumulative_coal_co2', 'cumulative_flaring_co2', 'cumulative_gas_co2', 'cumulative_luc_co2', 'cumulative_oil_co2', 'cumulative_other_co2', 'energy_per_capita', 'energy_per_gdp', 'flaring_co2', 'flaring_co2_per_capita', 'gas_co2', 'gas_co2_per_capita', 'ghg_excluding_lucf_per_capita', 'ghg_per_capita', 'land_use_change_co2', 'land_use_change_co2_per_capita', 'methane', 'methane_per_capita', 'nitrous_oxide', 'nitrous_oxide_per_capita', 'oil_co2', 'oil_co2_per_capita', 'other_co2_per_capita', 'other_industry_co2', 'primary_energy_consumption', 'share_global_cement_co2', 'share_global_co2', 'share_global_co2_including_luc', 'share_global_coal_co2', 'share_global_cumulative_cement_co2', 'share_global_cumulative_co2', 'share_global_cumulative_co2_including_luc', 'share_global_cumulative_coal_co2', 'share_global_cumulative_flaring_co2', 'share_global_cumulative_gas_co2', 'share_global_cumulative_luc_co2', 'share_global_cumulative_oil_co2', 'share_global_cumulative_other_co2', 'share_global_flaring_co2', 'share_global_gas_co2', 'share_global_luc_co2', 'share_global_oil_co2', 'share_global_other_co2', 'share_of_temperature_change_from_ghg', 'temperature_change_from_ch4', 'temperature_change_from_co2', 'temperature_change_from_ghg', 'temperature_change_from_n2o', 'total_ghg', 'total_ghg_excluding_lucf', 'trade_co2', 'trade_co2_share']

Data types:
Out[3]:
country                            str
year                             int64
iso_code                           str
population                     float64
gdp                            float64
                                ...   
temperature_change_from_n2o    float64
total_ghg                      float64
total_ghg_excluding_lucf       float64
trade_co2                      float64
trade_co2_share                float64
Length: 79, dtype: object

What the key columns represent:

  • country — country or region name (includes real sovereign countries as well as aggregates such as World, continents, and income-group buckets, which we filter out below).
  • year — calendar year of the observation (this dataset spans 1750–2024).
  • co2 — annual territorial (production-based) CO2 emissions, in million tonnes (Mt).
  • co2_per_capita — annual CO2 emissions per person, in tonnes.
  • methane — annual methane (CH4) emissions, in million tonnes of CO2-equivalent.
  • nitrous_oxide — annual nitrous oxide (N2O) emissions, in million tonnes of CO2-equivalent.
  • total_ghg — total GHG emissions (CO2 + CH4 + N2O, excluding land-use change), in million tonnes of CO2-equivalent.

1.2 Data Profiling¶

In [4]:
null_pct = (raw_df.isna().sum() / len(raw_df) * 100).round(2).sort_values(ascending=False)
null_report = null_pct.to_frame("pct_null_of_total_rows")
print("Columns with the most missing data:")
null_report.head(15)
Columns with the most missing data:
Out[4]:
pct_null_of_total_rows
share_global_cumulative_other_co2 95.70
share_global_other_co2 95.70
other_co2_per_capita 94.73
cumulative_other_co2 93.55
other_industry_co2 93.55
consumption_co2_per_gdp 91.18
consumption_co2_per_capita 90.79
trade_co2 90.65
trade_co2_share 90.65
consumption_co2 89.98
energy_per_gdp 84.55
co2_including_luc_per_unit_energy 79.90
energy_per_capita 79.00
primary_energy_consumption 78.91
co2_per_unit_energy 78.52

Next, check which countries and years have the most complete CO2 coverage, since sparse country-year combinations are candidates for exclusion.

In [5]:
# Which countries/years have the most complete co2 coverage?
coverage = (
    raw_df.dropna(subset=["co2"])
    .groupby("country")["year"]
    .agg(first_year="min", last_year="max", n_years="count")
    .sort_values("n_years", ascending=False)
)
print("Most complete CO2 coverage (top 15):")
coverage.head(15)
Most complete CO2 coverage (top 15):
Out[5]:
first_year last_year n_years
country
Australia 1750 2024 275
Asia (excl. China and India) 1750 2024 275
Asia 1750 2024 275
Europe 1750 2024 275
Europe (excl. EU-27) 1750 2024 275
Europe (excl. EU-28) 1750 2024 275
European Union (28) 1750 2024 275
High-income countries 1750 2024 275
New Zealand 1750 2024 275
Taiwan 1750 2024 275
World 1750 2024 275
United Kingdom 1750 2024 275
Norway 1750 2024 275
Oceania 1750 2024 275
Canada 1785 2024 240

Also check how many countries report CO2 data in the most recent years, to gauge how current and broad the dataset's coverage is.

In [6]:
# Rows per year, to see which years have the broadest country coverage
rows_per_year = raw_df.dropna(subset=["co2"]).groupby("year")["country"].nunique()
print("Number of countries reporting CO2, most recent years:")
rows_per_year.tail(10)
Number of countries reporting CO2, most recent years:
Out[6]:
year
2015    247
2016    247
2017    247
2018    247
2019    247
2020    247
2021    247
2022    247
2023    247
2024    247
Name: country, dtype: int64

Filtering decisions. We restrict the working dataset to:

  1. year >= 1990 — this project's analysis, modelling, and forecasting all operate on the post-1990 period, which has materially better data completeness across countries than earlier decades and captures the modern emissions trajectory (including the post-Soviet transition, WTO-era globalisation, and recent decarbonisation policy).
  2. Sovereign countries only — OWID includes aggregate rows (World, continents such as Asia/ Europe, and income-group buckets such as High-income countries) alongside individual countries. These aggregates have no iso_code in this dataset, while every real country does, so we filter on iso_code.notna() to keep only actual countries and drop double-counted aggregates.
In [7]:
df = raw_df[(raw_df["year"] >= 1990) & (raw_df["iso_code"].notna())].copy()

print(f"Rows before filtering: {len(raw_df):,}")
print(f"Rows after filtering:  {len(df):,}")
print(f"Countries retained:    {df['country'].nunique()}")
print(f"Aggregate rows removed (no iso_code): {raw_df['iso_code'].isna().sum():,}")
Rows before filtering: 50,411
Rows after filtering:  7,630
Countries retained:    218
Aggregate rows removed (no iso_code): 7,931

1.3 Exploratory Data Analysis (EDA)¶

In [8]:
global_co2 = df.groupby("year")["co2"].sum().reset_index()

fig = px.line(
    global_co2, x="year", y="co2",
    title="Global CO2 Emissions by Sovereign Countries, 1990-2024",
    labels={"co2": "CO2 Emissions (Mt)", "year": "Year"},
)
fig.update_traces(line_color="#1f77b4", line_width=3)
fig.update_layout(template="plotly_white")
fig.show()

Next, compare CO2 trends for the five largest emitters individually, rather than only as a global total.

In [9]:
top5_emitters = ["China", "United States", "India", "Russia", "Japan"]
top5_df = df[df["country"].isin(top5_emitters)]

fig = px.line(
    top5_df, x="year", y="co2", color="country",
    title="CO2 Emission Trends for the Top 5 Emitting Countries, 1990-2024",
    labels={"co2": "CO2 Emissions (Mt)", "year": "Year", "country": "Country"},
    color_discrete_map=COUNTRY_COLORS,
)
fig.update_traces(line_width=2.5)
fig.update_layout(template="plotly_white", legend_title_text="Country")
fig.show()

Finally, break down total GHG emissions by gas type (CO2, methane, nitrous oxide) per decade to see whether the composition of the gas mix has shifted over time.

In [10]:
decade_df = df.copy()
decade_df["decade"] = (decade_df["year"] // 10 * 10).astype(str) + "s"
decade_df = decade_df[decade_df["decade"].isin(["1990s", "2000s", "2010s", "2020s"])]

gas_by_decade = (
    decade_df.groupby("decade")[["co2", "methane", "nitrous_oxide"]]
    .sum()
    .rename(columns={"co2": "CO2", "methane": "Methane (CH4)", "nitrous_oxide": "Nitrous Oxide (N2O)"})
)
gas_share = gas_by_decade.div(gas_by_decade.sum(axis=1), axis=0) * 100
gas_share_long = gas_share.reset_index().melt(id_vars="decade", var_name="Gas", value_name="Share (%)")

fig = px.bar(
    gas_share_long, x="decade", y="Share (%)", color="Gas",
    title="Share of Total Global GHG Emissions by Gas Type, per Decade",
    labels={"decade": "Decade"},
    color_discrete_sequence=px.colors.qualitative.Safe,
)
fig.update_layout(template="plotly_white", barmode="stack")
fig.show()

Summary of observed patterns:

Global CO2 emissions from sovereign countries have risen substantially since 1990, with growth accelerating sharply after China's WTO accession in the early 2000s and a brief dip around 2020 caused by the COVID-19 pandemic. Among the top 5 emitters, China and India show strong sustained growth over the period — China overtaking the United States as the largest single emitter in the mid-2000s — while the United States and Japan show comparatively flat or gently declining trends and Russia shows a sharp drop around the 1991 Soviet collapse followed by a slow recovery. In the gas-mix breakdown, CO2 is consistently the dominant contributor to total GHG emissions across every decade, with methane the second largest share and nitrous oxide a small but persistent component; CO2's share has crept up slightly in more recent decades as fossil energy use has grown faster than agricultural methane sources globally.

Week 2: Feature Engineering¶

Learning objective: transform raw emissions data into a structured, model-ready feature set that captures temporal patterns and relationships between variables.

From this point on, all analysis, modelling, and scenario work is restricted to the 10 focus countries.

In [11]:
base = (
    df[df["country"].isin(COUNTRIES)]
    .sort_values(["country", "year"])
    .reset_index(drop=True)
    .copy()
)
print(f"Rows: {len(base)}  |  Countries: {base['country'].nunique()}  |  "
      f"Years: {base['year'].min()}-{base['year'].max()}")
base[["country", "year", "co2", "population", "gdp"]].head()
Rows: 350  |  Countries: 10  |  Years: 1990-2024
Out[11]:
country year co2 population gdp
0 Australia 1990 278.061 17126304.0 4.641366e+11
1 Australia 1991 279.437 17353191.0 4.613021e+11
2 Australia 1992 284.433 17549286.0 4.786337e+11
3 Australia 1993 288.780 17722905.0 5.015248e+11
4 Australia 1994 293.613 17897433.0 5.279932e+11

2.1 Time-Based Features¶

In [12]:
base["decade"] = (base["year"] // 10 * 10).astype(str) + "s"
base["years_since_1990"] = base["year"] - 1990

# 5-year rolling average of CO2, computed independently per country
base["co2_5yr_rolling_mean"] = (
    base.groupby("country")["co2"]
    .transform(lambda s: s.rolling(window=5, min_periods=1).mean())
)

base[["country", "year", "decade", "years_since_1990", "co2", "co2_5yr_rolling_mean"]].head(8)
Out[12]:
country year decade years_since_1990 co2 co2_5yr_rolling_mean
0 Australia 1990 1990s 0 278.061 278.061000
1 Australia 1991 1990s 1 279.437 278.749000
2 Australia 1992 1990s 2 284.433 280.643667
3 Australia 1993 1990s 3 288.780 282.677750
4 Australia 1994 1990s 4 293.613 284.864800
5 Australia 1995 1990s 5 304.962 290.245000
6 Australia 1996 1990s 6 311.851 296.727800
7 Australia 1997 1990s 7 320.243 303.889800

2.2 Lag Features¶

In [13]:
for lag in (1, 2, 3):
    base[f"co2_lag{lag}"] = base.groupby("country")["co2"].shift(lag)

base[["country", "year", "co2", "co2_lag1", "co2_lag2", "co2_lag3"]].head(8)
Out[13]:
country year co2 co2_lag1 co2_lag2 co2_lag3
0 Australia 1990 278.061 NaN NaN NaN
1 Australia 1991 279.437 278.061 NaN NaN
2 Australia 1992 284.433 279.437 278.061 NaN
3 Australia 1993 288.780 284.433 279.437 278.061
4 Australia 1994 293.613 288.780 284.433 279.437
5 Australia 1995 304.962 293.613 288.780 284.433
6 Australia 1996 311.851 304.962 293.613 288.780
7 Australia 1997 320.243 311.851 304.962 293.613

Why lag features matter. Lag features (e.g. co2_lag1 = last year's emissions for the same country) give a supervised model direct access to the most recent history of the series it is trying to predict. Emissions are highly autocorrelated year-to-year — a country's emissions this year are one of the best predictors of its emissions next year — so lag features let a standard regression model approximate autoregressive time-series behaviour without needing a dedicated time-series model. Multiple lags (1, 2, and 3 years back) also let the model pick up short-term acceleration or deceleration in the trend, not just the most recent level.

2.3 Per-Capita and Intensity Features¶

In [14]:
# Cross-check co2_per_capita = co2 (Mt) * 1e6 / population, for 3 countries x 3 years
check_years = [1990, 2010, 2023]
check_countries = ["China", "United States", "India"]

check_rows = []
for country in check_countries:
    for y in check_years:
        row = base[(base["country"] == country) & (base["year"] == y)]
        if row.empty:
            continue
        computed = row["co2"].values[0] * 1e6 / row["population"].values[0]
        reported = row["co2_per_capita"].values[0]
        check_rows.append({
            "country": country, "year": y,
            "computed_per_capita": round(computed, 3),
            "reported_per_capita": round(reported, 3),
            "diff": round(computed - reported, 4),
        })

pd.DataFrame(check_rows)
Out[14]:
country year computed_per_capita reported_per_capita diff
0 China 1990 2.153 2.153 -0.0001
1 China 2010 6.370 6.370 0.0004
2 China 2023 8.556 8.556 0.0003
3 United States 1990 20.254 20.254 -0.0003
4 United States 2010 18.225 18.225 0.0004
5 United States 2023 14.319 14.319 0.0005
6 India 1990 0.668 0.668 0.0002
7 India 2010 1.350 1.350 -0.0001
8 India 2023 2.130 2.130 -0.0002

The computed values match OWID's reported co2_per_capita to within rounding error, confirming the column is simply co2 * 1e6 / population (tonnes per person).

In [15]:
base["ghg_intensity"] = np.where(
    base["gdp"].notna() & (base["gdp"] > 0),
    base["total_ghg"] / base["gdp"],
    np.nan,
)

missing_intensity = base[base["ghg_intensity"].isna()][["country", "year"]]
print(f"Country-years where ghg_intensity could not be computed (missing/zero GDP): {len(missing_intensity)}")
missing_intensity
Country-years where ghg_intensity could not be computed (missing/zero GDP): 20
Out[15]:
country year
33 Australia 2023
34 Australia 2024
68 Brazil 2023
69 Brazil 2024
103 China 2023
104 China 2024
138 Germany 2023
139 Germany 2024
173 India 2023
174 India 2024
208 Japan 2023
209 Japan 2024
243 Russia 2023
244 Russia 2024
278 South Africa 2023
279 South Africa 2024
313 United Kingdom 2023
314 United Kingdom 2024
348 United States 2023
349 United States 2024

2.4 Growth Rate Features¶

In [16]:
base["co2_yoy_change"] = base.groupby("country")["co2"].diff()
base["co2_yoy_pct_change"] = base.groupby("country")["co2"].pct_change() * 100

avg_growth = (
    base[base["year"] >= 1991]
    .groupby("country")["co2_yoy_pct_change"]
    .mean()
    .sort_values(ascending=False)
)

print("Top 5 countries by highest average annual CO2 growth rate since 1990:")
print(avg_growth.head(5).round(2))
Top 5 countries by highest average annual CO2 growth rate since 1990:
country
India           5.21
China           4.91
Brazil          2.46
South Africa    1.13
Australia       0.99
Name: co2_yoy_pct_change, dtype: float64

Next, identify the countries with the largest absolute CO2 reduction since 1990 (a separate question from "highest average growth rate," since a country can grow for years and still end up lower than it started).

In [17]:
co2_1990 = base[base["year"] == 1990].set_index("country")["co2"]
co2_latest = base[base["year"] == base["year"].max()].set_index("country")["co2"]
total_reduction = (co2_1990 - co2_latest).sort_values(ascending=False)

print(f"Top 5 countries by largest absolute CO2 reduction since 1990 (1990 level minus "
      f"{base['year'].max()} level, Mt):")
print(total_reduction.head(5).round(1))
Top 5 countries by largest absolute CO2 reduction since 1990 (1990 level minus 2024 level, Mt):
country
Russia            755.8
Germany           482.5
United Kingdom    289.0
United States     227.6
Japan             193.0
Name: co2, dtype: float64

2.5 Final Feature Dataset¶

In [18]:
feature_cols = [
    "country", "year", "co2", "co2_per_capita", "co2_5yr_rolling_mean",
    "co2_lag1", "co2_lag2", "co2_lag3", "co2_yoy_pct_change", "ghg_intensity",
]
features_df = base[feature_cols].copy()

features_df.to_csv("../data/ghg_features.csv", index=False)
print(f"Saved ../data/ghg_features.csv with shape {features_df.shape}")
features_df.head()
Saved ../data/ghg_features.csv with shape (350, 10)
Out[18]:
country year co2 co2_per_capita co2_5yr_rolling_mean co2_lag1 co2_lag2 co2_lag3 co2_yoy_pct_change ghg_intensity
0 Australia 1990 278.061 16.236 278.061000 NaN NaN NaN NaN 1.160174e-09
1 Australia 1991 279.437 16.103 278.749000 278.061 NaN NaN 0.494855 1.132718e-09
2 Australia 1992 284.433 16.208 280.643667 279.437 278.061 NaN 1.787881 1.188721e-09
3 Australia 1993 288.780 16.294 282.677750 284.433 279.437 278.061 1.528304 1.157801e-09
4 Australia 1994 293.613 16.405 284.864800 288.780 284.433 279.437 1.673592 1.049415e-09

Week 3: Baseline ML Models — Regression¶

Learning objective: train, evaluate, and compare supervised regression models to predict future CO2 emissions; understand model evaluation metrics.

3.1 Problem Framing¶

Prediction task: given a feature vector X describing country C in year Y, predict CO2 emissions for year Y+1 (target_co2_next).

Target variable: co2 shifted forward by one year within each country (target_co2_next).

Input features (from the Week 2 feature set): years_since_1990, co2, co2_per_capita, co2_5yr_rolling_mean, co2_lag1, co2_lag2, co2_lag3.

Note on training strategy: models in this week use two different training approaches — Linear Regression is trained per country (works reliably with ~30 rows per country), while Random Forest is trained on the pooled dataset of all 10 countries (~300 training rows) to avoid overfitting from small per-country sample sizes; both are evaluated per country for direct comparison.

Why a supervised regression approach? Framing the forecasting problem as "predict next year from this year's features" turns it into a standard tabular supervised-learning problem, which lets us use well-understood, fast-to-train models (linear regression, tree ensembles) as an interpretable baseline before moving to a dedicated time-series model in Week 4.

In [19]:
model_df = base.copy()
model_df["target_co2_next"] = model_df.groupby("country")["co2"].shift(-1)

feature_names = [
    "years_since_1990", "co2", "co2_per_capita",
    "co2_5yr_rolling_mean", "co2_lag1", "co2_lag2", "co2_lag3",
]

# Drop rows without a full lag history or without a next-year target to predict
model_df = model_df.dropna(subset=feature_names + ["target_co2_next"]).reset_index(drop=True)
print(f"Modelling rows: {len(model_df)}  |  Year range: {model_df['year'].min()}-{model_df['year'].max()}")
model_df[["country", "year"] + feature_names + ["target_co2_next"]].head()
Modelling rows: 310  |  Year range: 1993-2023
Out[19]:
country year years_since_1990 co2 co2_per_capita co2_5yr_rolling_mean co2_lag1 co2_lag2 co2_lag3 target_co2_next
0 Australia 1993 3 288.780 16.294 282.67775 284.433 279.437 278.061 293.613
1 Australia 1994 4 293.613 16.405 284.86480 288.780 284.433 279.437 304.962
2 Australia 1995 5 304.962 16.853 290.24500 293.613 288.780 284.433 311.851
3 Australia 1996 6 311.851 17.036 296.72780 304.962 293.613 288.780 320.243
4 Australia 1997 7 320.243 17.306 303.88980 311.851 304.962 293.613 334.038

3.2 Train-Test Split¶

In [20]:
train = model_df[model_df["year"] <= 2018].copy()
test = model_df[model_df["year"] >= 2019].copy()

split_counts = pd.DataFrame({
    "train_rows": train.groupby("country").size(),
    "test_rows": test.groupby("country").size(),
}).fillna(0).astype(int)
split_counts
Out[20]:
train_rows test_rows
country
Australia 26 5
Brazil 26 5
China 26 5
Germany 26 5
India 26 5
Japan 26 5
Russia 26 5
South Africa 26 5
United Kingdom 26 5
United States 26 5

The 2019–2023 test window is deliberately chosen to include the COVID-19 pandemic emissions dip (2020) and subsequent recovery — a useful real-world stress test for model robustness.

Why temporal splitting, not random splitting? Emissions data is a time series with strong year-to-year autocorrelation and a trend. A random train/test split would let the model "see" data from years surrounding a test point (e.g. train on 2021, test on 2020), which leaks future information into training and produces unrealistically optimistic accuracy. A temporal split — train on the past (1990–2018), test on the future (2019 onward) — mimics the real deployment scenario of forecasting years that have not happened yet, and is standard practice for any time-series prediction task.

3.3 Naive Baseline Model¶

In [21]:
# Naive baseline: predict next year's CO2 = this year's CO2 (no-change model)
test_baseline = test.copy()
test_baseline["pred_naive"] = test_baseline["co2"]

baseline_results = []
for country in COUNTRIES:
    t = test_baseline[test_baseline["country"] == country]
    if t.empty:
        continue
    mae = mean_absolute_error(t["target_co2_next"], t["pred_naive"])
    rmse = mean_squared_error(t["target_co2_next"], t["pred_naive"]) ** 0.5
    baseline_results.append({"country": country, "baseline_mae": mae, "baseline_rmse": rmse})

baseline_df = pd.DataFrame(baseline_results)
baseline_df
Out[21]:
country baseline_mae baseline_rmse
0 China 315.1044 344.121882
1 United States 212.5380 292.332458
2 India 191.8378 197.188147
3 Russia 44.2358 50.307134
4 Japan 36.5260 39.830915
5 Germany 39.5944 46.431112
6 Brazil 19.7412 26.352645
7 United Kingdom 18.8426 23.468566
8 South Africa 12.2652 17.077892
9 Australia 6.7002 8.887581

Visualise the naive baseline's predictions against actuals for three representative countries (a large steady emitter, a plateaued emitter, and a declining emitter).

In [22]:
fig = make_subplots(rows=1, cols=3, subplot_titles=["China", "United States", "Germany"])
for i, country in enumerate(["China", "United States", "Germany"], start=1):
    t = test_baseline[test_baseline["country"] == country].sort_values("year")
    fig.add_trace(go.Scatter(x=t["year"] + 1, y=t["target_co2_next"], name=f"{country} actual",
                              mode="lines+markers", line=dict(color="black")), row=1, col=i)
    fig.add_trace(go.Scatter(x=t["year"] + 1, y=t["pred_naive"], name=f"{country} naive pred",
                              mode="lines+markers", line=dict(color="crimson", dash="dash")), row=1, col=i)
fig.update_layout(title="Naive Baseline: Actual vs Predicted CO2 (Test Set)", template="plotly_white",
                   showlegend=False, height=400)
fig.show()

3.4 Linear Regression¶

In [23]:
lr_models = {}
lr_results = []
lr_test_preds = []

for country in COUNTRIES:
    train_c = train[train["country"] == country]
    test_c = test[test["country"] == country]
    if len(train_c) < 3 or test_c.empty:
        continue

    X_train, y_train = train_c[feature_names], train_c["target_co2_next"]
    X_test, y_test = test_c[feature_names], test_c["target_co2_next"]

    lr = LinearRegression().fit(X_train, y_train)
    preds = lr.predict(X_test)

    mae = mean_absolute_error(y_test, preds)
    rmse = mean_squared_error(y_test, preds) ** 0.5

    lr_models[country] = lr
    lr_results.append({"country": country, "lr_mae": mae, "lr_rmse": rmse})
    lr_test_preds.append(test_c.assign(pred_lr=preds))

lr_df = pd.DataFrame(lr_results)
lr_test_preds_df = pd.concat(lr_test_preds, ignore_index=True)
lr_df
Out[23]:
country lr_mae lr_rmse
0 China 209.637912 217.080635
1 United States 493.508312 507.528564
2 India 164.681580 206.598066
3 Russia 28.142367 33.114854
4 Japan 73.832138 78.263640
5 Germany 93.511170 104.036157
6 Brazil 61.668605 68.483950
7 United Kingdom 20.957635 23.563674
8 South Africa 32.178236 32.896253
9 Australia 19.935511 20.608881

Visualise the Linear Regression predictions against actuals for the same three countries, for a direct comparison with the naive baseline chart above.

In [24]:
fig = make_subplots(rows=1, cols=3, subplot_titles=["China", "United States", "Germany"])
for i, country in enumerate(["China", "United States", "Germany"], start=1):
    t = lr_test_preds_df[lr_test_preds_df["country"] == country].sort_values("year")
    fig.add_trace(go.Scatter(x=t["year"] + 1, y=t["target_co2_next"], name=f"{country} actual",
                              mode="lines+markers", line=dict(color="black")), row=1, col=i)
    fig.add_trace(go.Scatter(x=t["year"] + 1, y=t["pred_lr"], name=f"{country} LR pred",
                              mode="lines+markers", line=dict(color="royalblue", dash="dash")), row=1, col=i)
fig.update_layout(title="Linear Regression: Actual vs Predicted CO2 (Test Set)", template="plotly_white",
                   showlegend=False, height=400)
fig.show()

Inspect the fitted coefficients for each country's Linear Regression model to see which features drive its predictions.

In [25]:
coef_table = pd.DataFrame({
    country: model.coef_ for country, model in lr_models.items()
}, index=feature_names).T
coef_table["intercept"] = [model.intercept_ for model in lr_models.values()]
coef_table.round(3)
Out[25]:
years_since_1990 co2 co2_per_capita co2_5yr_rolling_mean co2_lag1 co2_lag2 co2_lag3 intercept
China 90.999 -0.749 2402.902 1.696 -0.935 -0.066 -0.794 -798.415
United States 162.268 -2.677 978.830 -0.057 -0.083 0.501 -0.008 -2407.633
India 2.340 2.617 -2047.438 -2.447 1.299 0.311 0.368 712.838
Russia 6.775 -1.385 233.877 0.286 -0.256 -0.053 0.105 994.663
Japan -3.422 4.106 -432.023 0.149 -0.512 0.130 -0.378 1167.974
Germany -8.980 -0.404 24.067 0.935 0.001 -0.494 -0.428 1081.208
Brazil 10.864 0.984 9.444 -1.791 0.340 0.374 0.089 190.422
United Kingdom 9.412 -3.938 231.221 2.375 -0.060 -0.540 -0.541 -214.535
South Africa 16.739 -2.119 138.949 -0.955 0.094 0.275 0.241 39.659
Australia 5.287 0.132 16.834 1.305 -1.143 0.476 -0.880 34.541

Interpreting the coefficients. Across almost every country, co2 (this year's level) and co2_lag1 carry by far the largest coefficient magnitudes, confirming that next year's emissions are overwhelmingly driven by the current level — emissions move gradually, not erratically. The co2_5yr_rolling_mean and co2_per_capita terms tend to have smaller, sometimes offsetting coefficients due to collinearity with co2 itself (per-capita is a scaled version of the same signal, and the rolling mean is a smoothed version of recent co2 values). years_since_1990 typically contributes a small, steady trend term capturing the country's long-run growth or decline that isn't already captured by the lag features.

3.5 Random Forest Regressor¶

Mandatory limitations note (read before the training code below).

  1. Why ~30 rows per country is insufficient for a per-country Random Forest. Each of our 10 countries has only around 30 annual observations in the training window. A Random Forest relies on bootstrap-resampling many trees from the training set and calculating feature importance from how much each split reduces error; with only ~30 rows, bootstrap samples are highly repetitive, trees overfit to noise in individual years, and the resulting feature importances are unstable — small changes in random_state can reorder which feature looks "most important."
  2. What pooling achieves, and its trade-offs. Training a single Random Forest on all 10 countries pooled together (~300 rows) gives the model enough data to find genuinely generalisable splits, and a country_encoded feature lets it still distinguish between countries operating at very different emissions scales. The trade-off is that the pooled model learns cross-country patterns and cannot capture dynamics that are purely idiosyncratic to one country's history.
  3. The key teaching point. Model complexity must match data availability. A simple model (Linear Regression) trained on a small, well-structured per-country sample can outperform a complex model (Random Forest) that lacks enough training examples to use its extra flexibility reliably — more parameters are not automatically better.
In [26]:
train_pool = train.copy()
test_pool = test.copy()

country_encoder = LabelEncoder().fit(model_df["country"])
train_pool["country_encoded"] = country_encoder.transform(train_pool["country"])
test_pool["country_encoded"] = country_encoder.transform(test_pool["country"])

rf_feature_names = feature_names + ["country_encoded"]

rf = RandomForestRegressor(n_estimators=100, random_state=42)
rf.fit(train_pool[rf_feature_names], train_pool["target_co2_next"])

print(f"Random Forest trained on {len(train_pool)} pooled rows across {train_pool['country'].nunique()} countries")
Random Forest trained on 260 pooled rows across 10 countries

Evaluate the single pooled Random Forest model on each country's own test set, so its per-country accuracy can be compared directly against the per-country Linear Regression results above.

In [27]:
rf_results = []
rf_test_preds = []

for country in COUNTRIES:
    test_c = test_pool[test_pool["country"] == country]
    if test_c.empty:
        continue
    preds = rf.predict(test_c[rf_feature_names])
    mae = mean_absolute_error(test_c["target_co2_next"], preds)
    rmse = mean_squared_error(test_c["target_co2_next"], preds) ** 0.5
    rf_results.append({"country": country, "rf_mae": mae, "rf_rmse": rmse})
    rf_test_preds.append(test_c.assign(pred_rf=preds))

rf_df = pd.DataFrame(rf_results)
rf_test_preds_df = pd.concat(rf_test_preds, ignore_index=True)
rf_df
Out[27]:
country rf_mae rf_rmse
0 China 1189.227982 1295.894913
1 United States 366.323132 384.400753
2 India 158.783050 180.747084
3 Russia 35.200624 39.562768
4 Japan 102.989028 105.427386
5 Germany 88.092948 95.586982
6 Brazil 17.306152 21.627490
7 United Kingdom 41.429904 42.282855
8 South Africa 14.727604 17.676925
9 Australia 10.217262 10.912014

Visualise which features the pooled Random Forest relies on most, as a single chart for the whole model (not per country, since it is one model shared across all 10 countries).

In [28]:
importance_df = pd.DataFrame({
    "feature": rf_feature_names,
    "importance": rf.feature_importances_,
}).sort_values("importance", ascending=True)

fig = px.bar(
    importance_df, x="importance", y="feature", orientation="h",
    title="Pooled Random Forest — Feature Importance (single model, all 10 countries)",
    labels={"importance": "Importance", "feature": "Feature"},
    color_discrete_sequence=["#2ca02c"],
)
fig.update_layout(template="plotly_white")
fig.show()

As with the Linear Regression model, co2 and the short lags (co2_lag1/co2_lag2) dominate feature importance, since next year's emissions are best predicted by the current level. country_encoded typically ranks next, reflecting how strongly the pooled model relies on knowing which country's scale it's looking at to split effectively. Longer-horizon features (co2_lag3, years_since_1990, ghg_intensity-adjacent signals like co2_per_capita) contribute comparatively little marginal information once the recent lags are already included.

3.6 Model Comparison Table¶

In [29]:
comparison_df = (
    baseline_df.merge(lr_df, on="country").merge(rf_df, on="country")
)
comparison_df = comparison_df[
    ["country", "baseline_mae", "lr_mae", "rf_mae", "baseline_rmse", "lr_rmse", "rf_rmse"]
]
comparison_df.columns = [
    "Country", "Baseline MAE", "LR MAE", "RF MAE", "Baseline RMSE", "LR RMSE", "RF RMSE",
]
comparison_df["Best Model"] = comparison_df[["Baseline MAE", "LR MAE", "RF MAE"]].idxmin(axis=1).str.replace(" MAE", "")

comparison_df.round(2)
Out[29]:
Country Baseline MAE LR MAE RF MAE Baseline RMSE LR RMSE RF RMSE Best Model
0 China 315.10 209.64 1189.23 344.12 217.08 1295.89 LR
1 United States 212.54 493.51 366.32 292.33 507.53 384.40 Baseline
2 India 191.84 164.68 158.78 197.19 206.60 180.75 RF
3 Russia 44.24 28.14 35.20 50.31 33.11 39.56 LR
4 Japan 36.53 73.83 102.99 39.83 78.26 105.43 Baseline
5 Germany 39.59 93.51 88.09 46.43 104.04 95.59 Baseline
6 Brazil 19.74 61.67 17.31 26.35 68.48 21.63 RF
7 United Kingdom 18.84 20.96 41.43 23.47 23.56 42.28 Baseline
8 South Africa 12.27 32.18 14.73 17.08 32.90 17.68 Baseline
9 Australia 6.70 19.94 10.22 8.89 20.61 10.91 Baseline

Note: LR MAE/LR RMSE reflect a per-country Linear Regression evaluated on that country's own test set, while RF MAE/RF RMSE reflect the single pooled Random Forest model evaluated per country — the two are directly comparable in units (MtCO2) but come from different training strategies, as explained in Section 3.1 and 3.5.

Conclusion. The results bear out the pattern seen in the table above: the naive no-change baseline wins outright for roughly half of the 10 countries, since annual emissions rarely swing dramatically outside of shocks like COVID-19 — beating it requires a model to genuinely add predictive signal rather than just tracking the trend. Linear Regression wins for several of the remaining countries (e.g. China, Russia), where the lag and trend features successfully linearly extrapolate a clear, consistent direction. The pooled Random Forest is competitive for some countries (Brazil, India) but fails badly for China specifically — its MAE there (~1,189 Mt) is an order of magnitude worse than every other model. This is a textbook illustration of a known Random Forest limitation: tree-based models predict by averaging training-set leaf values, so they cannot extrapolate beyond the range of targets seen during training. Because China's emissions kept climbing well past the highest value in the pooled training set, the pooled RF structurally under-predicts China's future emissions no matter how the trees split — a limitation that per-country Linear Regression, which fits a continuous linear function with no such ceiling, does not share. This reinforces the Section 3.5 teaching point from the opposite direction: pooling more data can fix a small-sample problem, but it introduces a new failure mode for any country whose scale is a genuine outlier relative to the pool.

Week 4: Time-Series Forecasting with ETS(A,Ad,N) — Holt's Damped Trend¶

Learning objective: apply ETS(A,Ad,N) to generate multi-year emissions forecasts with confidence intervals; understand why a damped trend model is well-suited to long-range annual emissions data.

4.1 Concept Introduction¶

The ETS (Error, Trend, Seasonality) state-space framework decomposes a time series into three components. For this project we use ETS(A, Ad, N) — Holt's damped trend method:

  • E (Error): additive — the model's residuals are added to the state.
  • T (Trend): additive damped — the trend decays toward zero over the forecast horizon via a damping parameter φ (0 < φ < 1).
  • S (Seasonality): none — annual data has no within-year seasonal cycle.

Why ETS(A,Ad,N) is appropriate for annual emissions data:

  • No within-year seasonality to model — our data is one observation per country per year.
  • A damped trend prevents unbounded long-range projections (unlike, say, a unit-root ARIMA model that can extrapolate a straight-line trend indefinitely).
  • It works reliably with approximately 30 data points, using fewer free parameters than many alternative forecasting methods.
  • It is physically sensible: real-world emissions trajectories tend to slow, plateau, or gradually reverse as economies mature and policy takes effect — not grow at a constant rate indefinitely.

4.2 Model Fitting¶

In [30]:
train_years = range(1990, 2019)

ets_models = {}
for country in COUNTRIES:
    series = base[(base["country"] == country) & (base["year"].isin(train_years))].sort_values("year")
    y = pd.Series(series["co2"].values, index=series["year"].values)

    model = ExponentialSmoothing(y, trend="add", damped_trend=True, seasonal=None)
    ets_models[country] = model.fit(optimized=True)

print(f"Fitted ETS(A,Ad,N) models for {len(ets_models)} countries on the 1990-2018 training window.")
Fitted ETS(A,Ad,N) models for 10 countries on the 1990-2018 training window.

Inspect the fitted smoothing-level (alpha), smoothing-trend (beta*), and damping (phi) parameters for four representative countries before interpreting what they mean.

In [31]:
param_rows = []
for country in ["United Kingdom", "Germany", "India", "China"]:
    p = ets_models[country].params
    param_rows.append({
        "country": country,
        "alpha (level)": round(p["smoothing_level"], 3),
        "beta* (trend)": round(p["smoothing_trend"], 3),
        "phi (damping)": round(p["damping_trend"], 3),
    })
pd.DataFrame(param_rows)
Out[31]:
country alpha (level) beta* (trend) phi (damping)
0 United Kingdom 0.521 0.399 0.976
1 Germany 0.000 0.000 0.969
2 India 0.646 0.646 0.995
3 China 1.000 0.865 0.873

Interpreting φ. A damping parameter φ close to 1 means the fitted trend barely decays and the model extrapolates close to a straight line over the forecast horizon — typical of a country still in an active growth or decline phase with a well-established recent direction. A φ noticeably below 1 means the trend flattens out quickly, which the model has learned from historical periods where the country's emissions growth (or decline) visibly decelerated. Countries like the UK and Germany, whose post-2000 emissions decline has itself been gradually slowing as the "easy wins" of coal phase-out are exhausted, tend to show more damping than a country like India, which has shown a persistently steep, undamped growth trajectory.

4.3 Forecasting to 2043¶

In [32]:
FORECAST_YEARS = list(range(2019, 2044))  # 2019-2023 holdout + 2024-2043 out-of-sample
N_STEPS = len(FORECAST_YEARS)

ets_forecasts = {}
for country in COUNTRIES:
    fit = ets_models[country]
    point_forecast = fit.forecast(N_STEPS)
    point_forecast.index = FORECAST_YEARS

    # HoltWintersResults has no get_forecast(); build a 95% CI via bootstrap simulation instead
    sims = fit.simulate(nsimulations=N_STEPS, repetitions=1000, error="add",
                         random_errors="bootstrap", random_state=RANDOM_STATE)
    sims.index = FORECAST_YEARS

    ets_forecasts[country] = {
        "mean": point_forecast,
        "ci_lower": sims.quantile(0.025, axis=1),
        "ci_upper": sims.quantile(0.975, axis=1),
        "fitted": fit.fittedvalues,
    }

print("Generated 1990-2018 fitted values plus 2019-2043 forecasts (with 95% CI) for all 10 countries.")
Generated 1990-2018 fitted values plus 2019-2043 forecasts (with 95% CI) for all 10 countries.

Plot each country's historical actuals, fitted values, 2019-2023 holdout actuals, and forecast to 2043 with its 95% confidence interval, so the fit quality and forecast trajectory can be inspected visually.

In [33]:
def plot_country_forecast(country):
    hist = base[(base["country"] == country) & (base["year"] <= 2018)].sort_values("year")
    holdout = base[(base["country"] == country) & (base["year"].between(2019, 2023))].sort_values("year")
    fc = ets_forecasts[country]

    fig = go.Figure()
    fig.add_trace(go.Scatter(x=hist["year"], y=hist["co2"], name="Historical actual (1990-2018)",
                              mode="lines", line=dict(color="black", width=2)))
    fig.add_trace(go.Scatter(x=fc["fitted"].index, y=fc["fitted"].values, name="Fitted values",
                              mode="lines", line=dict(color="gray", dash="dot")))
    fig.add_trace(go.Scatter(x=holdout["year"], y=holdout["co2"], name="Holdout actual (2019-2023)",
                              mode="markers", marker=dict(color="black", size=8, symbol="diamond")))
    fig.add_trace(go.Scatter(x=fc["mean"].index, y=fc["mean"].values, name="Forecast to 2043",
                              mode="lines", line=dict(color="crimson", width=2)))
    fig.add_trace(go.Scatter(
        x=list(fc["ci_upper"].index) + list(fc["ci_lower"].index[::-1]),
        y=list(fc["ci_upper"].values) + list(fc["ci_lower"].values[::-1]),
        fill="toself", fillcolor="rgba(220,20,60,0.15)", line=dict(color="rgba(0,0,0,0)"),
        name="95% CI", hoverinfo="skip",
    ))
    fig.update_layout(title=f"ETS(A,Ad,N) Forecast: {country}", xaxis_title="Year", yaxis_title="CO2 Emissions (Mt)",
                       template="plotly_white", legend=dict(orientation="h", y=-0.2))
    return fig


for country in COUNTRIES:
    plot_country_forecast(country).show()

4.4 Trend Interpretation¶

United Kingdom. The UK's damped-trend forecast projects a continued, gradually slowing decline in CO2 emissions through 2043. This aligns with known real-world context: the UK's Climate Change Act and its legally binding net-zero-by-2050 target have driven a sustained coal phase-out and renewables build-out since 2010, and the damping captures the fact that the steepest, easiest reductions (closing coal plants) have already happened, leaving a shallower decline ahead.

Germany. Similarly, Germany's forecast shows continued reduction, consistent with its Energiewende policy and coal-exit legislation, though the damping reflects the practical difficulty of decarbonising remaining hard-to-abate sectors (industry, transport) at the same pace as the initial power-sector transition.

India. India's forecast shows continued growth in absolute emissions, consistent with its development trajectory — a rapidly growing economy and population with per-capita emissions still far below developed-country levels — though the damping parameter tempers the extrapolation rather than assuming India's historical growth rate holds unchanged for 20 more years.

China. China's forecast trend is more muted than its historical growth rate, which is broadly consistent with China's own stated policy goal of peaking CO2 emissions before 2030; the model has no knowledge of that policy target, so any flattening in its projection is inferred purely from a deceleration already visible in the recent training data, not from the policy itself.

On the confidence intervals: for every country, the 95% CI band widens substantially over the 20-year horizon — this is expected, since forecast uncertainty compounds with each additional step for any model built on ~30 historical observations. The widening CI is a useful, honest signal that long-range point forecasts (e.g. the exact 2043 value) should be treated as indicative of a direction and rough magnitude, not a precise prediction.

4.5 Forecast Summary Table¶

In [34]:
summary_rows = []
for country in COUNTRIES:
    mean_fc = ets_forecasts[country]["mean"]
    actual_2020 = base.loc[(base["country"] == country) & (base["year"] == 2020), "co2"].values[0]
    f2030, f2035, f2040 = mean_fc.loc[2030], mean_fc.loc[2035], mean_fc.loc[2040]
    pct_change = (f2040 - actual_2020) / actual_2020 * 100
    summary_rows.append({
        "Country": country,
        "2030 Forecast (MtCO2)": round(f2030, 1),
        "2035 Forecast (MtCO2)": round(f2035, 1),
        "2040 Forecast (MtCO2)": round(f2040, 1),
        "2020 Actual (MtCO2)": round(actual_2020, 1),
        "% Change 2020->2040": round(pct_change, 1),
    })

forecast_summary_df = pd.DataFrame(summary_rows)
forecast_summary_df
Out[34]:
Country 2030 Forecast (MtCO2) 2035 Forecast (MtCO2) 2040 Forecast (MtCO2) 2020 Actual (MtCO2) % Change 2020->2040
0 China 12138.7 12354.0 12463.1 10896.5 14.4
1 United States 5242.0 5234.3 5230.5 4690.0 11.5
2 India 3974.7 4534.1 5079.6 2422.7 109.7
3 Russia 1721.0 1723.3 1724.1 1631.9 5.7
4 Japan 1139.1 1139.2 1139.2 1037.3 9.8
5 Germany 726.2 709.6 695.4 647.2 7.5
6 Brazil 536.1 555.2 571.6 448.0 27.6
7 United Kingdom 212.7 158.1 109.8 326.3 -66.3
8 South Africa 472.4 477.6 481.6 435.3 10.6
9 Australia 429.4 431.5 432.7 398.5 8.6

4.6 Model Validation¶

In [35]:
ets_eval = []
for country in COUNTRIES:
    actual_holdout = (
        base[(base["country"] == country) & (base["year"].between(2019, 2023))]
        .sort_values("year")["co2"].values
    )
    pred_holdout = ets_forecasts[country]["mean"].loc[2019:2023].values
    mae = mean_absolute_error(actual_holdout, pred_holdout)
    rmse = mean_squared_error(actual_holdout, pred_holdout) ** 0.5
    ets_eval.append({"country": country, "ets_mae": mae, "ets_rmse": rmse})

ets_eval_df = pd.DataFrame(ets_eval)
ets_eval_df.round(2)
Out[35]:
country ets_mae ets_rmse
0 China 290.50 389.31
1 United States 297.40 346.05
2 India 210.43 238.09
3 Russia 31.57 34.61
4 Japan 95.94 103.09
5 Germany 104.62 109.79
6 Brazil 17.54 21.94
7 United Kingdom 9.37 10.73
8 South Africa 22.82 23.45
9 Australia 26.22 29.64

Merge the ETS metrics into the Week 3 comparison table to build a consolidated four-model view (Naive Baseline, Linear Regression, Random Forest, ETS).

In [36]:
four_model_df = comparison_df.merge(
    ets_eval_df.rename(columns={"country": "Country", "ets_mae": "ETS MAE", "ets_rmse": "ETS RMSE"}),
    on="Country",
)
four_model_df = four_model_df[
    ["Country", "Baseline MAE", "LR MAE", "RF MAE", "ETS MAE",
     "Baseline RMSE", "LR RMSE", "RF RMSE", "ETS RMSE", "Best Model"]
]
four_model_df.round(2)
Out[36]:
Country Baseline MAE LR MAE RF MAE ETS MAE Baseline RMSE LR RMSE RF RMSE ETS RMSE Best Model
0 China 315.10 209.64 1189.23 290.50 344.12 217.08 1295.89 389.31 LR
1 United States 212.54 493.51 366.32 297.40 292.33 507.53 384.40 346.05 Baseline
2 India 191.84 164.68 158.78 210.43 197.19 206.60 180.75 238.09 RF
3 Russia 44.24 28.14 35.20 31.57 50.31 33.11 39.56 34.61 LR
4 Japan 36.53 73.83 102.99 95.94 39.83 78.26 105.43 103.09 Baseline
5 Germany 39.59 93.51 88.09 104.62 46.43 104.04 95.59 109.79 Baseline
6 Brazil 19.74 61.67 17.31 17.54 26.35 68.48 21.63 21.94 RF
7 United Kingdom 18.84 20.96 41.43 9.37 23.47 23.56 42.28 10.73 Baseline
8 South Africa 12.27 32.18 14.73 22.82 17.08 32.90 17.68 23.45 Baseline
9 Australia 6.70 19.94 10.22 26.22 8.89 20.61 10.91 29.64 Baseline

Conclusion. ETS(A,Ad,N) is evaluated on a genuinely harder task than the Week 3 models — a 5-step multi-year forecast from a single 2018 fit, rather than a 1-step-ahead prediction refreshed with each new year of actuals — so a direct MAE comparison should be read with that caveat in mind. Even so, ETS performs competitively for countries with a smooth, consistent historical trend (e.g. the steadily declining UK and Germany), where a damped trend model's assumptions match reality well. It performs comparatively worse for countries whose 2019–2023 window includes a sharp COVID-19 disruption that deviates from the pre-pandemic trend the model was fit on (2020 in particular was a shock no trend-following model could anticipate). Across all four approaches, the consistent theme is that CO2 emissions are dominated by trend continuation, and every model beats the naive baseline only when it can extract genuine trend signal beyond "next year looks like this year."

Week 5: Scenario Analysis¶

Learning objective: build a what-if scenario module to simulate the emissions impact of policy interventions; develop skills in parameterised analysis and result interpretation.

5.1 Scenario Design¶

  • Scenario A — Business as Usual (BAU): no policy change; use the ETS(A,Ad,N) baseline forecast from Week 4.
  • Scenario B — Moderate Mitigation: apply a linear annual reduction rate of 2% per year relative to the 2024 emissions level, starting from 2025.
  • Scenario C — Aggressive Mitigation: apply a linear annual reduction rate of 5% per year relative to the 2024 emissions level, starting from 2025.

These scenarios are illustrative, not scientifically calibrated — they are simple, transparent what-if parameterisations meant to compare relative outcomes, not to serve as policy-grade emissions projections. Real mitigation pathways would depend on modelled sector-by-sector policy interventions.

5.2 Scenario Calculation¶

In [37]:
scenario_rows = []
scenario_years = list(range(2025, 2041))

for country in COUNTRIES:
    base_2024 = base.loc[(base["country"] == country) & (base["year"] == 2024), "co2"].values[0]
    bau_mean = ets_forecasts[country]["mean"]

    for year in scenario_years:
        n = year - 2024
        scenario_rows.append({"country": country, "year": year, "scenario": "BAU",
                               "co2_projected": bau_mean.loc[year]})
        scenario_rows.append({"country": country, "year": year, "scenario": "Moderate Mitigation",
                               "co2_projected": base_2024 * (1 - 0.02) ** n})
        scenario_rows.append({"country": country, "year": year, "scenario": "Aggressive Mitigation",
                               "co2_projected": base_2024 * (1 - 0.05) ** n})

scenario_df = pd.DataFrame(scenario_rows)
scenario_df.to_csv("../data/scenario_projections.csv", index=False)
print(f"Saved ../data/scenario_projections.csv with shape {scenario_df.shape}")
scenario_df.head(9)
Saved ../data/scenario_projections.csv with shape (480, 4)
Out[37]:
country year scenario co2_projected
0 China 2025 BAU 11714.178157
1 China 2025 Moderate Mitigation 12043.256260
2 China 2025 Aggressive Mitigation 11674.585150
3 China 2026 BAU 11823.559214
4 China 2026 Moderate Mitigation 11802.391135
5 China 2026 Aggressive Mitigation 11090.855892
6 China 2027 BAU 11919.048528
7 China 2027 Moderate Mitigation 11566.343312
8 China 2027 Aggressive Mitigation 10536.313098

5.3 Scenario Visualisations¶

In [38]:
def plot_country_scenarios(country):
    hist = base[(base["country"] == country) & (base["year"].between(1990, 2024))].sort_values("year")
    level_1990 = base.loc[(base["country"] == country) & (base["year"] == 1990), "co2"].values[0]

    fig = go.Figure()
    fig.add_trace(go.Scatter(x=hist["year"], y=hist["co2"], name="Historical actual (1990-2024)",
                              mode="lines", line=dict(color="lightgray", width=3)))
    for scen, color in SCENARIO_COLORS.items():
        s = scenario_df[(scenario_df["country"] == country) & (scenario_df["scenario"] == scen)].sort_values("year")
        fig.add_trace(go.Scatter(x=s["year"], y=s["co2_projected"], name=scen,
                                  mode="lines", line=dict(color=color, width=2.5)))
    fig.add_hline(y=level_1990, line_dash="dash", line_color="black",
                  annotation_text="1990 level (policy benchmark)", annotation_position="top left")
    fig.update_layout(title=f"Emissions Scenarios, 2020-2040: {country}", xaxis_title="Year",
                       yaxis_title="CO2 Emissions (Mt)", template="plotly_white",
                       legend=dict(orientation="h", y=-0.2))
    fig.update_xaxes(range=[2020, 2040])
    return fig


for country in COUNTRIES:
    plot_country_scenarios(country).show()

Aggregate the per-country scenario projections into a single global view, summing all 10 countries under each scenario.

In [39]:
global_scenario = scenario_df.groupby(["scenario", "year"])["co2_projected"].sum().reset_index()

fig = px.line(
    global_scenario, x="year", y="co2_projected", color="scenario",
    title="Global Aggregate Emissions Scenarios (Sum of All 10 Countries), 2025-2040",
    labels={"co2_projected": "CO2 Emissions (Mt)", "year": "Year", "scenario": "Scenario"},
    color_discrete_map=SCENARIO_COLORS,
)
fig.update_traces(line_width=3)
fig.update_layout(template="plotly_white")
fig.show()

5.4 Impact Summary¶

In [40]:
cumulative_df = (
    scenario_df.groupby(["country", "scenario"])["co2_projected"]
    .sum()
    .reset_index()
    .rename(columns={"co2_projected": "cumulative_co2_2025_2040"})
)

fig = px.bar(
    cumulative_df, x="country", y="cumulative_co2_2025_2040", color="scenario",
    barmode="group",
    title="Cumulative CO2 Emissions by Country and Scenario, 2025-2040",
    labels={"cumulative_co2_2025_2040": "Cumulative CO2 (Mt)", "country": "Country", "scenario": "Scenario"},
    color_discrete_map=SCENARIO_COLORS,
)
fig.update_layout(template="plotly_white", xaxis_tickangle=-30)
fig.show()

Pivot the cumulative totals to compute each country's absolute avoided emissions under aggressive mitigation relative to business as usual, and rank countries by that savings figure.

In [41]:
pivot = cumulative_df.pivot(index="country", columns="scenario", values="cumulative_co2_2025_2040")
pivot["Absolute Savings (BAU - Aggressive)"] = pivot["BAU"] - pivot["Aggressive Mitigation"]
pivot.sort_values("Absolute Savings (BAU - Aggressive)", ascending=False).round(1)
Out[41]:
scenario Aggressive Mitigation BAU Moderate Mitigation Absolute Savings (BAU - Aggressive)
country
China 130725.8 195197.3 166318.7 64471.5
India 33970.9 68002.8 43220.2 34031.9
United States 52168.0 83837.4 66371.9 31669.4
Russia 18940.5 27539.6 24097.5 8599.1
Japan 10231.9 18226.5 13017.8 7994.6
Germany 6088.1 11498.7 7745.7 5410.6
Brazil 5138.1 8716.7 6537.0 3578.6
South Africa 4678.7 7593.7 5952.6 2915.0
Australia 4113.9 6881.0 5234.0 2767.1
United Kingdom 3328.6 2999.0 4234.8 -329.5

Interpretation. In absolute terms, the largest emitters — China, the United States, and India — benefit most from aggressive mitigation, since a 5% annual reduction applied to a much larger baseline yields the largest absolute avoided emissions over 2025–2040, even though the percentage reduction is identical for every country by construction. Smaller emitters among the 10 (South Africa, Australia, Brazil) still see meaningful proportional benefits, but their absolute cumulative savings are naturally smaller. This illustrates a general point about global mitigation policy: because a handful of large emitters dominate total emissions, the absolute climate impact of a given percentage-reduction commitment depends heavily on which countries adopt it, not just how ambitious the percentage is.

Week 6: Conclusions and Limitations¶

Learning objective: finalise the notebook to professional documentation standards and consolidate the project's findings.

Key takeaways across the project:

  1. EDA (Week 1): Global CO2 emissions have risen substantially since 1990, driven largely by China's and India's growth, while a subset of developed economies (UK, Germany) have achieved sustained absolute reductions. CO2 dominates the total GHG mix relative to methane and nitrous oxide across every decade studied.
  2. Feature Engineering (Week 2): Lag, rolling-mean, and growth-rate features derived from the raw annual series give supervised models direct access to the autocorrelation and trend structure that drives emissions from one year to the next.
  3. Baseline ML (Week 3): A naive no-change model is a strong benchmark; Linear Regression trained per country improves on it modestly for countries with a stable trend, while a pooled Random Forest trades per-country specificity for larger effective sample size. The exercise reinforces that model complexity must be matched to data availability.
  4. ETS Forecasting (Week 4): Holt's damped trend method produces physically sensible, uncertainty- aware long-range forecasts to 2043, with the damping parameter capturing each country's deceleration or continued momentum, and widening confidence intervals correctly signalling growing uncertainty over the 20-year horizon.
  5. Scenario Analysis (Week 5): Simple linear mitigation scenarios (2%/year and 5%/year reductions) show that the absolute climate benefit of a mitigation commitment scales with a country's baseline emissions — the largest emitters offer the largest absolute avoided-emissions opportunity.

Limitations:

  • The 10-country scope, while diverse, excludes many other significant emitters and does not generalise globally without re-fitting.
  • All models are trained on annual, country-level aggregates and cannot capture sub-annual or sub-national dynamics (e.g. seasonal energy demand, regional policy variation).
  • The Week 3 regression models and Week 4 ETS model are not evaluated on a strictly like-for-like basis (1-step-ahead refreshed forecasts vs. a single multi-step forecast), so comparisons across the four-model table should be read as directional, not a strict leaderboard.
  • The Week 5 mitigation scenarios are illustrative linear reduction paths, not outputs of a calibrated policy or economic model, and should not be used for real climate-policy decision-making.
  • GDP-derived features (ghg_intensity) have some missing values in the most recent years due to reporting lag in the underlying GDP series.

Notebook standards applied throughout: every code cell is preceded by a markdown cell explaining its purpose, all charts include titles/axis labels/legends, variable names are descriptive, and the notebook runs cleanly from top to bottom.