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.
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).
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.
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:
| 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.
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:
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 asWorld, 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¶
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:
| 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.
# 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):
| 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.
# 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:
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:
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).- Sovereign countries only — OWID includes aggregate rows (
World, continents such asAsia/Europe, and income-group buckets such asHigh-income countries) alongside individual countries. These aggregates have noiso_codein this dataset, while every real country does, so we filter oniso_code.notna()to keep only actual countries and drop double-counted aggregates.
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)¶
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.
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.
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.
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
| 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¶
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)
| 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¶
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)
| 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¶
# 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)
| 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).
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
| 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¶
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).
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¶
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)
| 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.
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
| 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¶
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
| 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¶
# 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
| 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).
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¶
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
| 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.
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.
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)
| 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).
- 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_statecan reorder which feature looks "most important." - 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_encodedfeature 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. - 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.
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.
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
| 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).
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¶
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)
| 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¶
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.
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)
| 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¶
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.
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¶
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
| 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¶
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)
| 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).
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)
| 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¶
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)
| 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¶
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.
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¶
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.
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)
| 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:
- 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.
- 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.
- 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.
- 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.
- 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.