!pip install xlrd # Apparently xls files requires this specifically and we need it for the GNI data
import pandas as pd
import numpy as np
from re import sub
import textwrap
import matplotlib.pyplot as plt # just for the static plots in the explainer notebook
import seaborn as sns # just for the static plots in the explainer notebook
import plotly.express as px
import plotly.graph_objects as go
import plotly.express as px
from plotly.subplots import make_subplots
import warnings
warnings.filterwarnings('ignore')
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
from sklearn.mixture import GaussianMixture
from scipy import linalg
from sklearn.metrics import recall_score, precision_score
from sklearn.metrics import confusion_matrix
template = 'plotly_white'
Run to view results
class OpinionEconomy:
pcv_columns_to_keep = [
'Country',
'Question Text',
'Question Text (Short)',
'Response',
'Education',
'Age',
'Weighted Mean',
'Category',
]
country_mapping = {
'Comoros (the)': 'Comoros',
"Côte d'Ivoire": "Cote d'Ivoire",
'Democratic Republic of the Congo': 'Congo, Dem. Rep.',
'Dominican Republic (the)': 'Dominican Republic',
'Egypt': 'Egypt, Arab Rep.',
'Iran (Islamic Republic of)': 'Iran, Islamic Rep.',
'Kyrgyzstan': 'Kyrgyz Republic',
'Niger (the)': 'Niger',
'Philippines (the)': 'Philippines',
'Republic of Korea (the)': 'Korea, Rep.',
'Russian Federation (the)': 'Russian Federation',
'Sudan (the)': 'Sudan',
'Tanzania (the United Republic of)': 'Tanzania',
'United Kingdom of Great Britain and Northern Ireland (the)': 'United Kingdom',
'United States of America (the)': 'United States',
}
def __init__(
self,
peoples_climate_vote: pd.DataFrame,
economy_classification: pd.DataFrame,
gni: pd.DataFrame,
):
self.pcv_raw = peoples_climate_vote
self.pcv = self.pcv_raw[self.pcv_columns_to_keep]
self.pcv['Country'] = self.pcv['Country'].apply(lambda x: self.country_mapping[x] if x in self.country_mapping.keys() else x)
self.pcv = self.pcv[
(self.pcv['Education'] == 'All Education') &
(self.pcv['Age'] == 'All Ages') &
(self.pcv['Response'] != "Don't know") &
(self.pcv['Country'] != 'Global')
]
self.ec = economy_classification.iloc[10:228, [0, 1, 39]]
self.ec = self.ec.rename(columns={
self.ec.columns[0]: 'Country Code',
self.ec.columns[1]: "Country",
self.ec.columns[2]: "Economy"
}
)
self.gni = gni.iloc[3:269, [0, 1, 68]]
self.gni = self.gni.rename(columns={
self.gni.columns[0]: "Country",
self.gni.columns[1]: "Country Code",
self.gni.columns[2]: "GNI"
}
)
self.data = self.build_wide_table()
self.y_true = self.data['Economy']
def build_wide_table(self):
ecgni = self.ec.merge(self.gni, on='Country Code', how='inner', suffixes=('_eco', ''))[['Country', 'Economy', 'GNI']]
ecgni['Economy'] = ecgni['Economy'].apply(lambda x: 'High income' if x == 'H' else 'Non-high income')
wide = pd.concat([
self.pcv['Country'],
(
self.pcv['Question Text (Short)'].map(lambda x: x.strip().lower().replace(" ", "_")) +
'_' +
self.pcv['Response'].map(lambda x: sub(r"[^\w]", "", "_".join(x.replace("-", " ").split()).lower()))
).rename('Question Response'),
self.pcv['Weighted Mean']
],
axis=1).pivot(index = 'Country', columns ='Question Response', values='Weighted Mean')
return wide.join(ecgni.set_index('Country')).dropna()
def pca(self):
scaler = StandardScaler()
X = scaler.fit_transform(OE.data.drop('Economy', axis=1))
pca = PCA(n_components = 10)
X_pca = pca.fit_transform(X)
components = pca.components_
explained_variance_ratio = pca.explained_variance_ratio_
print("Explained variance:", explained_variance_ratio)
print("Cumulative:", np.cumsum(explained_variance_ratio))
return X_pca, components , explained_variance_ratio
def cluster(self, X_pca):
gmm = GaussianMixture(
n_components=2,
covariance_type='full',
random_state=42
)
gmm.fit(X_pca)
y_pred = gmm.predict(X_pca)
centroids = gmm.means_
covariances = gmm.covariances_
clusters = gmm.n_components
return y_pred, centroids, covariances, clusters
def summarize_questions(self, to_html=False):
subset = self.pcv_raw[(self.pcv_raw['Country']=='Global') & (self.pcv_raw['Age']=='All Ages') & (self.pcv_raw['Education']=='All Education')]
categories = subset['Category'].unique()
subset = subset.sort_values('Weighted Mean', ascending=True)
def wrap_text(text, width=40):
if pd.isna(text):
return text
wrapped = textwrap.fill(str(text), width=width)
return wrapped.replace('\n', '<br>')
subset['Question Text'] = subset['Question Text'].apply(lambda x: wrap_text(x, width=60))
subset['Response'] = subset['Response'].apply(lambda x: wrap_text(x, width=30))
majority_idx = subset.groupby('Question Text')['Weighted Mean'].transform(max) == subset['Weighted Mean']
subset['is_majority'] = majority_idx
majority_responses = (
subset[subset['is_majority']]
.set_index('Question Text')['Response']
.to_dict()
)
unique_majority_responses = subset[subset['is_majority']]['Response'].unique()
colors = px.colors.qualitative.Plotly
majority_color_map = {resp: colors[i % len(colors)] for i, resp in enumerate(unique_majority_responses)}
GREY = '#BBBBBB'
default_category = "Peoples' perspective"
if default_category not in categories:
default_category = categories[0] # fallback
fig = go.Figure()
for category in categories:
cat_data = subset[subset['Category'] == category]
for response in cat_data['Response'].unique():
resp_data = cat_data[cat_data['Response'] == response]
bar_colors = []
for _, row in resp_data.iterrows():
q = row['Question Text']
if row['is_majority'] and majority_responses.get(q) == response:
bar_colors.append(majority_color_map[response])
else:
bar_colors.append(GREY)
is_majority_trace = any(
row['is_majority'] and majority_responses.get(row['Question Text']) == response
for _, row in resp_data.iterrows()
)
fig.add_trace(go.Bar(
x=resp_data['Weighted Mean'],
y=resp_data['Question Text'],
name=response if is_majority_trace else 'Other responses',
orientation='h',
marker_color=bar_colors,
text=[
f"{row['Response']}<br>{row['Weighted Mean']:.1f}%"
for _, row in resp_data.iterrows()
],
hovertemplate='%{text}<extra></extra>',
textposition='none',
visible=(category == default_category),
legendgroup=response if is_majority_trace else 'grey',
showlegend=False,
meta={'category': category}
))
buttons = []
for category in categories:
visibility = [t.meta['category'] == category for t in fig.data]
buttons.append(dict(
label=category,
method='update',
args=[
{'visible': visibility},
{
'title.text': (
f'Global distribution of responses per question<br>'
f'<sup>Category: {category}</sup>'
),
}
]
))
default_idx = list(categories).index(default_category)
fig.update_layout(
margin=dict(t=120),
updatemenus=[dict(
buttons=buttons,
direction='down',
showactive=True,
active=default_idx,
x=1.0, # moved to the right
xanchor='right',
y=1.25,
yanchor='top'
)],
title=dict(
text=(
f'Global distribution of responses by category<br>'
f'<sup>Category: {default_category}</sup>'
),
),
barmode='stack',
template=template,
width=700,
height=500,
yaxis=dict(
tickfont=dict(size=12),
title=dict(text=''),
ticklabelposition='outside',
ticklabelstandoff=20,
),
xaxis=dict(
visible=False,
autorange='reversed'
),
uniformtext_minsize=8,
uniformtext_mode='hide',
)
fig.show()
if to_html:
fig.write_html("pcv_response_distribution.html")
OE = OpinionEconomy(
peoples_climate_vote = pd.read_excel('Peoples_Climate_Vote_Database_2024.xlsx'),
economy_classification = pd.read_excel('OGHIST_2026_03_10.xlsx', sheet_name='Country Analytical History'), # https://datahelpdesk.worldbank.org/knowledgebase/articles/906519-world-bank-country-and-lending-groups
gni = pd.read_excel('API_NY.GNP.PCAP.CD_DS2_en_excel_v2_463.xls') # https://data.worldbank.org/indicator/NY.GNP.PCAP.CD
)
Run to view results
# TODO: Clean up, but not that important
def summarize_pca_loadings(X_pca, components, explained_variance_ratio, feature_names, pc_x=0, pc_y=1, top_n=10):
loadings = components
explained = explained_variance_ratio
for pc_idx in [pc_x, pc_y]:
pc_loadings = loadings[pc_idx]
# Sort by absolute loading, descending
order = np.argsort(np.abs(pc_loadings))[::-1][:top_n]
print(f"\n{'─' * 55}")
print(f" PC{pc_idx + 1} — explains {explained[pc_idx]*100:.1f}% of variance")
print(f"{'─' * 55}")
print(f" {'Variable':<35} {'Loading':>8} {'Direction'}")
print(f" {'·' * 35} {'·' * 8} {'·' * 9}")
for i in order:
direction = '▲ positive' if pc_loadings[i] > 0 else '▼ negative'
print(f" {feature_names[i]:<35} {pc_loadings[i]:>+8.3f} {direction}")
print(f"\n{'─' * 55}")
print(f" Combined top {top_n} by magnitude across both PCs")
print(f"{'─' * 55}")
print(f" {'Variable':<35} {'PC{}'.format(pc_x+1):>6} {'PC{}'.format(pc_y+1):>6} {'Magnitude':>9}")
print(f" {'·' * 35} {'·' * 6} {'·' * 6} {'·' * 9}")
combined = np.sqrt(loadings[pc_x]**2 + loadings[pc_y]**2)
order = np.argsort(combined)[::-1][:top_n]
for i in order:
print(
f" {feature_names[i]:<35} "
f"{loadings[pc_x, i]:>+6.3f} "
f"{loadings[pc_y, i]:>+6.3f} "
f"{combined[i]:>9.3f}"
)
def gmm_ellipse_plotly(mean, covar, dims=(0, 2), n_points=100):
idx = list(dims)
covar_2d = covar[np.ix_(idx, idx)]
mean_2d = mean[idx]
v, w = linalg.eigh(covar_2d)
v = 2.0 * np.sqrt(2.0) * np.sqrt(v) # axis lengths (2-sigma)
u = w[0] / linalg.norm(w[0]) # principal direction
angle = np.arctan2(u[1], u[0]) # rotation angle in radians
t = np.linspace(0, 2 * np.pi, n_points)
xs = (v[0] / 2) * np.cos(t)
ys = (v[1] / 2) * np.sin(t)
cos_a, sin_a = np.cos(angle), np.sin(angle)
x_rot = cos_a * xs - sin_a * ys + mean_2d[0]
y_rot = sin_a * xs + cos_a * ys + mean_2d[1]
return x_rot, y_rot
def summarise_stats(y_true, y_pred):
px.imshow(
confusion_matrix(y_true, y_pred).T,
labels=dict(x="Real economy type", y="GMM cluster", color="Count"),
x=['Non-high GNI', 'High GNI',],
y=['Cluster 2 (Non-high GNI)', 'Cluster 1 (High GNI)'],
text_auto=True,
template=template,
color_continuous_scale=px.colors.sequential.Purples
).update_xaxes(side="top").show()
print("GMM recall: ", recall_score(y_true, y_pred))
print("GMM precision: ", precision_score(y_true, y_pred))
def build_scatter_fig(X_pca, y_true, y_pred, size, dim=2):
if dim == 2:
fig = px.scatter(
x = X_pca[:, 0],
y = X_pca[:, 1],
color = y_true,
symbol = y_pred,
size = size
)
elif dim == 3:
fig = px.scatter_3d(
x = X_pca[:, 0],
y = X_pca[:, 1],
z = X_pca[:, 2],
color = y_true,
symbol = y_pred,
size = size
)
else:
print("Can't build a PCA scatter with dimension higher than 3")
return fig
def build_cluster_fig(centroids, covariances, clusters, dim=2):
colors = ['#636EFA', '#EF553B']
label_map = {0: 'Cluster A', 1: 'Cluster B'}
fig = go.Figure()
for k in range(clusters):
ex, ey = gmm_ellipse_plotly(
mean=centroids[k],
covar=covariances[k],
dims=(0, 1)
)
fig.add_trace(go.Scatter(
x=ex, y=ey,
mode='lines',
fill='toself',
fillcolor=colors[k],
opacity=0.3,
line=dict(color=colors[k], width=2),
name=label_map[k],
showlegend=True
))
fig_centroids = px.scatter(
x=centroids[:, 0],
y=centroids[:, 1],
)
fig_centroids.update_traces(
marker_symbol='star' if dim == 2 else 'cross',
marker_color='#00CC96',
marker=dict(size=14),
selector=dict(mode='markers')
)
for trace in fig_centroids.data:
fig.add_trace(trace)
return fig
def plot_pca_clustering(X, y_true, y_pred, centroids, covariances, clusters, size, dim=2, to_html=False):
label_map = {0: 'Cluster A', 1: 'Cluster B'}
mapped_labels = pd.Series(y_pred).map(label_map).values
pca_fig = build_scatter_fig(X_pca, y_true, mapped_labels, size, dim=dim)
if dim == 2:
cluster_fig = build_cluster_fig(centroids, covariances, clusters, dim=dim)
fig = go.Figure(data = pca_fig.data + cluster_fig.data)
quadrant_labels = [
(7, 5, "Status quo +<br>Optimistic"),
(-3, 4, "Pro-action +<br>Optimistic"),
(-4, -3, "Pro-action +<br>Pessimistic"),
(8, -4, "Status quo +<br>Pessimistic"),
]
for x, y_pos, label in quadrant_labels:
fig.add_annotation(
x=x, y=y_pos,
text=label,
showarrow=False,
font=dict(size=10, color='rgba(100,100,100,0.6)'),
align='center'
)
elif dim == 3:
fig = go.Figure(data = pca_fig.data)
else:
print("Cannot plot dimensions higher than 3. Pick either 1 or 2 lol")
fig.update_layout(
template=template,
margin=dict(b=120),
xaxis=dict(title=dict(
text="PC1 (23.2%)<br>Demand for more action ↔ Keep status quo",
standoff=20
)),
yaxis=dict(title=dict(
text="PC2 (15.4%)<br>Climate change pessimism ↔ Climate change optimism"
)),
legend=dict(
orientation='h',
x=0.5,
xanchor='center',
y=-0.25,
yanchor='top',
)
)
fig.show()
if to_html:
fig.write_html("pcv_pca_gmm.html")
def plot_choropleth(y_pred, country_iso, to_html=False):
cluster_df = pd.DataFrame(
[[pred] for pred in y_pred], index=country_iso, columns=['Cluster']
)
cluster_df = cluster_df.join(OE.gni.set_index('Country'))
cluster_df['Cluster'] = cluster_df['Cluster'].map({0: 'Cluster A', 1: 'Cluster B'}).values
fig = px.choropleth(
cluster_df,
locations="Country Code",
color="Cluster",
hover_name=cluster_df.index,
color_discrete_map={'Cluster A': '#1d6996', 'Cluster B': '#cc503e'},
category_orders={"Cluster": ["Cluster A", "Cluster B"]},
)
fig.update_layout(
legend=dict(
title=None,
orientation='h',
y=0,
),
margin=dict(l=20, r=20, t=0, b=0)
)
fig.show()
if to_html:
fig.write_html("pcv_choropleth.html")
Run to view results
# Main plots
# Fig 6
OE.summarize_questions(to_html = False)
# Fig 7
X_pca, components, explained_variance_ratio = OE.pca()
y_pred, centroids, covariances, clusters = OE.cluster(X_pca)
plot_pca_clustering(
X_pca,
OE.y_true,
y_pred,
centroids,
covariances,
clusters=clusters,
size = OE.data['GNI'],
dim = 2,
to_html = False
)
summarize_pca_loadings(
X_pca,
components,
explained_variance_ratio,
feature_names=OE.data.columns,
pc_x = 0, # PC1
pc_y = 1, # PC2
top_n = 10
)
# Fig 8
plot_choropleth(
y_pred,
country_iso = OE.data.index,
to_html = False
)
Run to view results
# Just some additional plots mainly for sanity check
plot_pca_clustering(
X_pca,
OE.y_true,
y_pred,
centroids,
covariances,
clusters=clusters,
size = OE.data['GNI'],
dim=3,
to_html=False
)
summarise_stats(list(map(lambda x: 1 if x == 'High income' else 0, OE.y_true)), y_pred)
Run to view results
# ── Load EM-DAT
emdat = pd.read_csv('public_emdat.csv', sep=';', low_memory=False)
# Standardise column names (strip whitespace)
emdat.columns = emdat.columns.str.strip()
# Convert numeric cols
emdat['Total Affected'] = pd.to_numeric(emdat['Total Affected'], errors='coerce').fillna(0)
emdat['Total Deaths'] = pd.to_numeric(emdat['Total Deaths'], errors='coerce').fillna(0)
emdat['Start Year'] = pd.to_numeric(emdat['Start Year'], errors='coerce')
# Filter 2000-2024
emdat_all = emdat[(emdat['Start Year'] >= 2000) & (emdat['Start Year'] <= 2024)].copy()
# Climate-related disaster types of interest
CLIMATE_TYPES = [
'Wildfire',
'Epidemic',
'Flood',
'Drought',
'Storm',
'Extreme temperature',
'Glacial lake outburst flood',
'Mass movement (wet)',
]
# Normalise disaster type capitalisation for matching
emdat_all['Disaster Type Norm'] = emdat_all['Disaster Type'].str.strip().str.lower()
climate_types_lower = [t.lower() for t in CLIMATE_TYPES]
emdat_climate = emdat_all[emdat_all['Disaster Type Norm'].isin(climate_types_lower)].copy()
# Map back to display label with consistent capitalisation
type_map = {t.lower(): t for t in CLIMATE_TYPES}
emdat_climate['Disaster Type Display'] = emdat_climate['Disaster Type Norm'].map(type_map)
#print(f'EM-DAT rows (2000-2024): {len(emdat_all):,}')
#print(f'Climate rows : {len(emdat_climate):,}')
#print(f'Disaster types found : {emdat_climate["Disaster Type Display"].unique()}')
# ── Load WPP Population Data
wpp = pd.read_csv('WPP2024_Demographic_Indicators_Medium.csv', low_memory=False)
wpp.columns = wpp.columns.str.strip()
# Keep only country-level rows (ISO3_code is present, LocTypeID == 4 for countries)
wpp_countries = wpp[wpp['ISO3_code'].notna() & (wpp['ISO3_code'] != '')].copy()
wpp_countries = wpp_countries[['ISO3_code', 'Time', 'TPopulation1July']].copy()
wpp_countries.rename(columns={'ISO3_code': 'ISO', 'Time': 'Year', 'TPopulation1July': 'Population_thousands'}, inplace=True)
wpp_countries['Year'] = pd.to_numeric(wpp_countries['Year'], errors='coerce')
wpp_countries['Population_thousands'] = pd.to_numeric(wpp_countries['Population_thousands'], errors='coerce')
# WPP population is in thousands → convert to actual persons
wpp_countries['Population'] = wpp_countries['Population_thousands'] * 1000
wpp_countries = wpp_countries[['ISO', 'Year', 'Population']].dropna()
#print(f'\nWPP rows: {len(wpp_countries):,}')
#print(f'WPP years: {wpp_countries["Year"].min()}-{wpp_countries["Year"].max()}')
Run to view results
affected_values = emdat_climate['Total Affected']
affected_values = affected_values[affected_values > 0]
mean_val = affected_values.mean()
median_val = affected_values.median()
log_values = np.log10(affected_values)
fig_dist = make_subplots(
rows=2, cols=1,
subplot_titles=[
'Total Affected (Linear Scale)',
'Total Affected (Log₁₀ Scale)'
],
vertical_spacing=0.12
)
# ── TOP: Linear
fig_dist.add_trace(go.Histogram(
x=affected_values,
nbinsx=80,
marker_color='steelblue',
hovertemplate='Affected: %{x:,.0f}<br>Count: %{y}<extra></extra>'
), row=1, col=1)
fig_dist.add_vline(
x=mean_val,
line_width=2,
line_dash="dash",
line_color="red",
annotation_text=f"Mean: {mean_val:,.0f}",
annotation_font=dict(color="red"),
annotation_position="top",
row=1, col=1
)
fig_dist.add_vline(
x=median_val,
line_width=2,
line_dash="dot",
line_color="black",
annotation_text=f"Median: {median_val:,.0f}",
annotation_font=dict(color="black"),
annotation_position="bottom",
row=1, col=1
)
# ── BOTTOM: Log
fig_dist.add_trace(go.Histogram(
x=log_values,
nbinsx=80,
marker_color='indianred',
hovertemplate='log10(Affected): %{x:.2f}<br>Count: %{y}<extra></extra>'
), row=2, col=1)
fig_dist.add_vline(
x=np.log10(mean_val),
line_width=2,
line_dash="dash",
line_color="red",
annotation_text=f"Mean: {mean_val:,.0f}",
annotation_font=dict(color="red"),
row=2, col=1
)
fig_dist.add_vline(
x=np.log10(median_val),
line_width=2,
line_dash="dot",
line_color="black",
annotation_text=f"Median: {median_val:,.0f}",
annotation_font=dict(color="black"),
row=2, col=1
)
fig_dist.update_layout(
title='Distribution of Disaster Impacts: Linear vs Log Scale',
template='plotly_white',
height=750,
showlegend=False,
bargap=0.05
)
fig_dist.update_xaxes(title_text='Total Affected', row=1, col=1)
fig_dist.update_xaxes(title_text='log₁₀(Total Affected)', row=2, col=1)
fig_dist.update_yaxes(title_text='Number of Events', row=1, col=1)
fig_dist.update_yaxes(title_text='Number of Events', row=2, col=1)
fig_dist.show()
Run to view results
# ── Aggregate: Total Affected per country / year / disaster type
agg = (
emdat_climate
.groupby(['ISO', 'Country', 'Start Year', 'Disaster Type Display'], as_index=False)['Total Affected']
.sum()
)
agg.rename(columns={'Start Year': 'Year'}, inplace=True)
# "All" option: sum all climate types per country/year
agg_all = (
agg.groupby(['ISO', 'Country', 'Year'], as_index=False)['Total Affected']
.sum()
)
agg_all['Disaster Type Display'] = 'All Climate Related Disasters'
full_agg = pd.concat([agg, agg_all], ignore_index=True)
# ── Merge with population
full_agg = full_agg.merge(wpp_countries, on=['ISO', 'Year'], how='left')
full_agg['Total Affected per 1M'] = np.where(
full_agg['Population'] > 0,
full_agg['Total Affected'] / full_agg['Population'] * 1_000_000,
np.nan
)
YEARS = list(range(2000, 2025))
DD_OPTIONS = ['All Climate Related Disasters'] + sorted(CLIMATE_TYPES)
# ── Compute global colour scale limits (fixed across all years & types)
# Absolute
max_abs = full_agg[full_agg['Total Affected'] > 0]['Total Affected'].max()
min_abs = full_agg[full_agg['Total Affected'] > 0]['Total Affected'].min()
log_max_abs = np.log10(max_abs)
log_min_abs = np.log10(max(min_abs, 1))
# Per-1M
max_per1m = full_agg[full_agg['Total Affected per 1M'] > 0]['Total Affected per 1M'].max()
min_per1m = full_agg[full_agg['Total Affected per 1M'] > 0]['Total Affected per 1M'].min()
log_max_per1m = np.log10(max_per1m)
log_min_per1m = np.log10(max(min_per1m, 0.001))
#print(f'Abs range : {min_abs:,.0f} – {max_abs:,.0f}')
#print(f'Per-1M range: {min_per1m:,.2f} – {max_per1m:,.2f}')
traces_abs = [] # absolute traces
traces_per1m = [] # per-1M traces
for dtype in DD_OPTIONS:
for yr in YEARS:
sub = full_agg[(full_agg['Disaster Type Display'] == dtype) & (full_agg['Year'] == yr)]
# ── absolute ──
log_vals_abs = np.where(sub['Total Affected'] > 0, np.log10(sub['Total Affected']), np.nan)
hover_abs = [
f"<b>{c}</b><br>Total Affected: {int(v):,}"
for c, v in zip(sub['Country'], sub['Total Affected'])
]
traces_abs.append(go.Choropleth(
locations=sub['ISO'],
z=log_vals_abs,
zmin=log_min_abs,
zmax=log_max_abs,
colorscale='Spectral_r',
showscale=True,
colorbar=dict(
title='Total Affected (log₁₀)',
tickvals=[1, 2, 3, 4, 5, 6, 7, 8],
ticktext=['10', '100', '1K', '10K', '100K', '1M', '10M', '100M'],
len=0.6,
),
text=hover_abs,
hovertemplate='%{text}<extra></extra>',
visible=False,
name=f'{dtype}|{yr}|abs',
))
# ── per 1M ──
log_vals_per1m = np.where(
sub['Total Affected per 1M'] > 0,
np.log10(sub['Total Affected per 1M']),
np.nan
)
hover_per1m = [
f"<b>{c}</b><br>Total Affected per 1M: {v:,.1f}"
for c, v in zip(sub['Country'], sub['Total Affected per 1M'].fillna(0))
]
traces_per1m.append(go.Choropleth(
locations=sub['ISO'],
z=log_vals_per1m,
zmin=log_min_per1m,
zmax=log_max_per1m,
colorscale='Spectral_r',
showscale=True,
colorbar=dict(
title='Affected per 1M<br>Population (log₁₀)',
tickvals=[-1, 0, 1, 2, 3, 4, 5],
ticktext=['0.1', '1', '10', '100', '1K', '10K', '100K'],
len=0.6,
),
text=hover_per1m,
hovertemplate='%{text}<extra></extra>',
visible=False,
name=f'{dtype}|{yr}|per1m',
))
N = len(DD_OPTIONS) * len(YEARS) # traces per mode (abs / per1m)
all_traces = traces_abs + traces_per1m # total 2*N traces
fig = go.Figure(data=all_traces)
# ── Helper: get trace index
def trace_idx(dtype_idx, year_idx, per1m=False):
base = N if per1m else 0
return base + dtype_idx * len(YEARS) + year_idx
# ── Default visibility: 2024, All Climate, absolute
DEFAULT_DTYPE_IDX = 0 # 'All Climate Related Disasters'
DEFAULT_YEAR_IDX = YEARS.index(2024)
DEFAULT_PER1M = True
visible_default = [False] * len(all_traces)
visible_default[trace_idx(DEFAULT_DTYPE_IDX, DEFAULT_YEAR_IDX, DEFAULT_PER1M)] = True
for i, tr in enumerate(fig.data):
tr.visible = visible_default[i]
# ── Preparing data-arays
# Pre-compute data for all (dtype, year, mode) combinations
DATA_Z = {} # [dtype][mode][year] -> list of log values
DATA_LOC = {} # [dtype][year] -> list of ISO codes
DATA_TEXT = {} # [dtype][mode][year] -> list of hover texts
DATA_CTRY = {} # [dtype][year] -> list of country names
for dtype in DD_OPTIONS:
DATA_Z[dtype] = {'abs': {}, 'per1m': {}}
DATA_TEXT[dtype] = {'abs': {}, 'per1m': {}}
DATA_LOC[dtype] = {}
DATA_CTRY[dtype] = {}
for yr in YEARS:
sub = full_agg[(full_agg['Disaster Type Display'] == dtype) & (full_agg['Year'] == yr)].copy()
DATA_LOC[dtype][yr] = sub['ISO'].tolist()
DATA_CTRY[dtype][yr] = sub['Country'].tolist()
# absolute log z
z_abs = [np.log10(v) if v > 0 else None for v in sub['Total Affected']]
DATA_Z[dtype]['abs'][yr] = z_abs
DATA_TEXT[dtype]['abs'][yr] = [
f'<b>{c}</b><br>Total Affected: {int(v):,}'
for c, v in zip(sub['Country'], sub['Total Affected'])
]
# per-1M log z
z_per = [np.log10(v) if (v and v > 0) else None for v in sub['Total Affected per 1M'].fillna(0)]
DATA_Z[dtype]['per1m'][yr] = z_per
DATA_TEXT[dtype]['per1m'][yr] = [
f'<b>{c}</b><br>Affected per 1M pop: {v:,.1f}'
for c, v in zip(sub['Country'], sub['Total Affected per 1M'].fillna(0))
]
# ── Build single-trace figure with dynamic updates
def build_map_figure():
init_dtype = 'All Climate Related Disasters'
init_year = 2024
init_mode = 'abs'
fig = go.Figure()
# Single choropleth trace
fig.add_trace(go.Choropleth(
locations=DATA_LOC[init_dtype][init_year],
z=DATA_Z[init_dtype][init_mode][init_year],
zmin=log_min_abs,
zmax=log_max_abs,
colorscale='Spectral_r',
showscale=True,
colorbar=dict(
title='Total Affected (log₁₀)',
tickvals=[1, 2, 3, 4, 5, 6, 7, 8],
ticktext=['10', '100', '1K', '10K', '100K', '1M', '10M', '100M'],
len=0.65,
x=1.0,
),
text=DATA_TEXT[init_dtype][init_mode][init_year],
hovertemplate='%{text}<extra></extra>',
name='',
))
# ── Slider steps (one per year)
def make_slider_steps(dtype, mode):
zmin = log_min_per1m if mode == 'per1m' else log_min_abs
zmax = log_max_per1m if mode == 'per1m' else log_max_abs
steps = []
for yr in YEARS:
steps.append(dict(
method='update',
args=[
{
'z': [DATA_Z[dtype][mode][yr]],
'locations': [DATA_LOC[dtype][yr]],
'text': [DATA_TEXT[dtype][mode][yr]],
'zmin': [zmin],
'zmax': [zmax],
},
{'title.text': f'<b>{dtype}</b> – Total Affected – {yr}'}
],
label=str(yr),
))
return steps
init_steps = make_slider_steps(init_dtype, init_mode)
sliders = [dict(
active=YEARS.index(init_year),
steps=init_steps,
currentvalue=dict(prefix='Year: ', font=dict(size=14)),
pad=dict(t=15, b=0),
x=0.05,
y=0.03,
len=0.9,
)]
# ── Dropdown buttons
def make_dropdown_buttons():
buttons = []
for mode, mode_label in [('abs', 'Absolute Count'), ('per1m', 'Per 1 Million Population')]:
zmin = log_min_per1m if mode == 'per1m' else log_min_abs
zmax = log_max_per1m if mode == 'per1m' else log_max_abs
cb_title = 'Affected per<br>1M Population<br>(log₁₀)' if mode == 'per1m' else 'Total Affected<br>(log₁₀)'
cb_tvals = [-1, 0, 1, 2, 3, 4, 5] if mode == 'per1m' else [1, 2, 3, 4, 5, 6, 7, 8]
cb_ttext = ['0.1','1','10','100','1K','10K','100K'] if mode == 'per1m' else ['10','100','1K','10K','100K','1M','10M','100M']
for dtype in DD_OPTIONS:
new_steps = make_slider_steps(dtype, mode)
buttons.append(dict(
label=f'{dtype} [{mode_label}]',
method='update',
args=[
{
'z': [DATA_Z[dtype][mode][init_year]],
'locations': [DATA_LOC[dtype][init_year]],
'text': [DATA_TEXT[dtype][mode][init_year]],
'zmin': [zmin],
'zmax': [zmax],
'colorbar.title.text': [cb_title],
'colorbar.tickvals': [cb_tvals],
'colorbar.ticktext': [cb_ttext],
},
{
'title.text': f'<b>{dtype}</b> – Total Affected – {init_year}',
'sliders[0].steps': new_steps,
'sliders[0].active': YEARS.index(init_year),
}
],
))
return buttons
buttons = make_dropdown_buttons()
updatemenus = [dict(
buttons=buttons,
direction='down',
showactive=True,
x=0.01,
xanchor='left',
y=1.10,
yanchor='top',
bgcolor='white',
bordercolor='#ccc',
font=dict(size=12),
)]
fig.update_layout(
title=dict(
text=f'All Climate Related Disasters – Total Affected – {init_year}',
x=0.5, xanchor='center', font=dict(size=16), y=0.95
),
geo=dict(
showframe=False,
showcoastlines=True,
projection_type='natural earth',
bgcolor='#eaf2ff',
landcolor='#f5f5f0',
coastlinecolor='#aaaaaa',
),
updatemenus=updatemenus,
sliders=sliders,
margin=dict(l=20, r=20, t=130, b=30),
annotations=[
dict(
text='Disaster Type & Normalisation:',
x=0.01, y=1.2, xref='paper', yref='paper',
showarrow=False, font=dict(size=12)
)
],
paper_bgcolor='white',
plot_bgcolor='white',
width=700,
height=500
)
return fig
fig_map = build_map_figure()
fig_map.write_html('em-dat_interactive_world_map.html')
fig_map.show()
Run to view results
# Total affected per country, summed 2000-2024, for all climate disaster types combined
static_agg = (
emdat_climate
.groupby(['ISO', 'Country'], as_index=False)['Total Affected']
.sum()
)
# Merge with average population (use 2012 as mid-point proxy)
pop_avg = (
wpp_countries[wpp_countries['Year'].between(2000, 2024)]
.groupby('ISO', as_index=False)['Population']
.mean()
)
static_agg = static_agg.merge(pop_avg, on='ISO', how='left')
static_agg['Affected per 1M'] = np.where(
static_agg['Population'] > 0,
static_agg['Total Affected'] / static_agg['Population'] * 1_000_000,
np.nan
)
static_agg['log_affected_per1m'] = np.where(
static_agg['Affected per 1M'] > 0,
np.log10(static_agg['Affected per 1M']),
np.nan
)
static_agg['hover'] = static_agg.apply(
lambda r: f"<b>{r['Country']}</b><br>Total Affected (2000–2024): {int(r['Total Affected']):,}<br>Per 1M pop: {r['Affected per 1M']:,.0f}",
axis=1
)
fig_static = go.Figure(go.Choropleth(
locations=static_agg['ISO'],
z=static_agg['log_affected_per1m'],
colorscale='Spectral_r',
showscale=True,
colorbar=dict(
title='Total Affected per<br>1M Population (log₁₀)',
tickvals=[2, 3, 4, 5, 6, 7, 8, 9],
ticktext=['100', '1K', '10K', '100K', '1M', '10M', '100M', '1B'],
len=0.65,
),
text=static_agg['hover'],
hovertemplate='%{text}<extra></extra>',
))
fig_static.update_layout(
title=dict(
text='Total Affected per 1M Population by Climate Disasters – Cumulative 2000–2024<br><sup>All climate-related disaster types combined | Logarithmic colour scale</sup>',
x=0.5, xanchor='center', font=dict(size=15)
),
geo=dict(
showframe=False,
showcoastlines=True,
projection_type='natural earth',
bgcolor='#eaf2ff',
landcolor='#f5f5f0',
coastlinecolor='#aaaaaa',
),
height=550,
margin=dict(l=0, r=0, t=80, b=20),
)
fig_static.write_html('em-dat_static_world_map.html')
fig_static.show()
Run to view results
# Top 10 – not normalised
top10_abs = (
static_agg.nlargest(10, 'Total Affected')[['Country', 'Total Affected']]
.sort_values('Total Affected')
)
# Top 10 – normalised per 1M
top10_norm = (
static_agg.dropna(subset=['Affected per 1M'])
.nlargest(10, 'Affected per 1M')[['Country', 'Affected per 1M']]
.sort_values('Affected per 1M')
)
fig_top = make_subplots(
rows=1, cols=2,
subplot_titles=[
'Top 10 Countries - Total Affected (Absolute)',
'Top 10 Countries - Affected per 1M Population'
],
horizontal_spacing=0.5
)
fig_top.add_trace(
go.Bar(
x=top10_abs['Total Affected'],
y=top10_abs['Country'],
orientation='h',
marker_color='steelblue',
hovertemplate='<b>%{y}</b><br>Total Affected: %{x:,.0f}<extra></extra>',
name='Absolute',
),
row=1, col=1
)
fig_top.add_trace(
go.Bar(
x=top10_norm['Affected per 1M'],
y=top10_norm['Country'],
orientation='h',
marker_color='firebrick',
hovertemplate='<b>%{y}</b><br>Affected per 1M: %{x:,.0f}<extra></extra>',
name='Per 1M',
),
row=1, col=2
)
fig_top.update_layout(
title=dict(
text='Top 10 Countries by Climate Disaster Impact (2000–2024)',
x=0.5, xanchor='center'
),
showlegend=False,
height=450,
template='plotly_white',
)
fig_top.update_xaxes(row=1, col=1, title='Total Affected')
fig_top.update_xaxes(row=1, col=2, title='Affected per 1 Million People')
fig_top.write_html('em-dat_top10_by_climate_diseaster_impact.html')
fig_top.show()
# ── Separate figure: Top 10 per 1M population only
fig_top_norm = go.Figure()
fig_top_norm.add_trace(
go.Bar(
x=top10_norm['Affected per 1M'],
y=top10_norm['Country'],
orientation='h',
marker_color='firebrick',
hovertemplate=(
'<b>%{y}</b><br>'
'Affected per 1M: %{x:,.0f}<extra></extra>'
),
name='Per 1M',
)
)
fig_top_norm.update_layout(
title=dict(
text='Top 10 Countries by Climate Disaster Impact<br>per 1M Population (2000–2024)',
x=0.5,
xanchor='center'
),
template='plotly_white',
showlegend=False,
height=650,
width=500,
margin=dict(l=20, r=20, t=80, b=40),
)
fig_top_norm.update_xaxes(
title='Affected per 1 Million People'
)
fig_top_norm.update_yaxes(
title=''
)
# Export
fig_top_norm.write_html(
'em-dat_top10_per1m_population.html'
)
fig_top_norm.write_html('em-dat_norm_top10_by_climate_diseaster_impact.html')
fig_top_norm.show()
Run to view results
yearly_type = (
emdat_climate
.groupby(['Start Year', 'Disaster Type Display'])
.agg(
Total_Affected=('Total Affected', 'sum'),
Total_Deaths=('Total Deaths', 'sum'),
N_Disasters=('DisNo.', 'count')
)
.reset_index()
.rename(columns={'Start Year': 'Year'})
)
colors = px.colors.qualitative.Bold
# Disaster Types
disaster_types = yearly_type['Disaster Type Display'].unique()
# Subplots
fig_yr = make_subplots(
rows=3, cols=1,
subplot_titles=[
'Total Affected by Climate Disaster Type (Stacked) – 2000–2024',
'Total Deaths by Climate Disaster Type (Stacked) – 2000–2024',
'Number of Climate Disasters by Type (Stacked) – 2000–2024',
],
vertical_spacing=0.10,
shared_xaxes=True
)
# Traces
for i, disaster in enumerate(disaster_types):
subset = yearly_type[
yearly_type['Disaster Type Display'] == disaster
]
color = colors[i % len(colors)]
# Total Affected
fig_yr.add_trace(
go.Scatter(
x=subset['Year'],
y=subset['Total_Affected'],
mode='lines',
stackgroup='affected',
name=disaster,
line=dict(width=0.5, color=color),
hovertemplate=(
'%{x}<br>'
f'{disaster}<br>'
'Total Affected: %{y:,.0f}<extra></extra>'
),
legendgroup=disaster,
),
row=1, col=1
)
# Total Deaths
fig_yr.add_trace(
go.Scatter(
x=subset['Year'],
y=subset['Total_Deaths'],
mode='lines',
stackgroup='deaths',
name=disaster,
line=dict(width=0.5, color=color),
hovertemplate=(
'%{x}<br>'
f'{disaster}<br>'
'Total Deaths: %{y:,.0f}<extra></extra>'
),
legendgroup=disaster,
showlegend=False
),
row=2, col=1
)
# Number of Disasters
fig_yr.add_trace(
go.Scatter(
x=subset['Year'],
y=subset['N_Disasters'],
mode='lines',
stackgroup='count',
name=disaster,
line=dict(width=0.5, color=color),
hovertemplate=(
'%{x}<br>'
f'{disaster}<br>'
'Disasters: %{y}<extra></extra>'
),
legendgroup=disaster,
showlegend=False
),
row=3, col=1
)
fig_yr.update_layout(
height=950,
template='plotly_white',
title=dict(
text='Climate Disaster Trends by Disaster Type (2000–2024)',
x=0.5,
xanchor='center'
),
hovermode='x unified'
)
fig_yr.update_yaxes(title_text='Affected', row=1, col=1)
fig_yr.update_yaxes(title_text='Deaths', row=2, col=1)
fig_yr.update_yaxes(title_text='Count', row=3, col=1)
fig_yr.update_xaxes(title_text='Year', row=3, col=1)
fig_yr.show()
# ── Event frequency, absolute numbers, not stacked
events_year_type = (
emdat_climate
.groupby(['Start Year', 'Disaster Type'])
.agg(N_Events=('DisNo.', 'count'))
.reset_index()
.rename(columns={'Start Year': 'Year'})
)
fig_events = go.Figure()
types = events_year_type['Disaster Type'].unique()
colors = px.colors.qualitative.Set2
for i, dtype in enumerate(types):
subset = events_year_type[events_year_type['Disaster Type'] == dtype]
fig_events.add_trace(go.Scatter(
x=subset['Year'],
y=subset['N_Events'],
mode='lines',
name=dtype,
line=dict(width=2, color=colors[i % len(colors)]),
hovertemplate=f'{dtype}<br>Year: %{{x}}<br>Events: %{{y}}<extra></extra>'
))
fig_events.update_layout(
title='Climate Disaster Frequency by Type (2000–2024)',
xaxis_title='Year',
yaxis_title='Number of Events',
template='plotly_white',
height=500
)
fig_events.show()
Run to view results
climate_by_type_year = (
emdat_climate
.groupby(['Start Year', 'Disaster Type Display'])
.agg(Total_Affected=('Total Affected', 'sum'))
.reset_index()
.rename(columns={'Start Year': 'Year'})
)
pivot_affected = climate_by_type_year.pivot(
index='Disaster Type Display', columns='Year', values='Total_Affected'
).fillna(0)
# Log transform for visualisation
pivot_log = np.log10(pivot_affected.replace(0, np.nan))
fig_heat = go.Figure(go.Heatmap(
z=pivot_log.values,
x=pivot_log.columns.tolist(),
y=pivot_log.index.tolist(),
colorscale='Spectral_r',
colorbar=dict(
title='Total Affected (log₁₀)',
tickvals=[2, 3, 4, 5, 6, 7, 8],
ticktext=['100', '1K', '10K', '100K', '1M', '10M', '100M'],
),
hovertemplate='<b>%{y}</b><br>Year: %{x}<br>Total Affected: %{customdata:,.0f}<extra></extra>',
customdata=pivot_affected.values,
))
fig_heat.update_layout(
title=dict(text='Heatmap: Total Affected by Disaster Type & Year', x=0.5, xanchor='center'),
height=400, template='plotly_white',
xaxis_title='Year', yaxis_title='Disaster Type',
)
fig_heat.show()
Run to view results
#read data file
df_co2 = pd.read_csv("IEA_EDGAR_CO2_1970_2024_csv.csv", sep=';')
#Cleaning
id_vars = ['Name', 'Substance', 'ipcc_code_2006_for_standard_report_name','Country_code_A3']
value_vars = [col for col in df_co2.columns if col.startswith('Y_')]
df_co2 = df_co2.melt(id_vars=id_vars, value_vars=value_vars,
var_name='Year', value_name='Emissions')
df_co2['Year'] = df_co2['Year'].str.replace('Y_', '').astype(int)
if df_co2['Emissions'].dtype == 'object':
df_co2['Emissions'] = df_co2['Emissions'].astype(str).str.replace(',', '.').astype(float)
df_co2['Emissions'] = df_co2['Emissions'].fillna(0)
#rename
df_co2 = df_co2.rename(columns={'Name': 'Country'})
df_co2 = df_co2.rename(columns={'Emissions': 'Emissions (kton)'})
df_co2 = df_co2.rename(columns={'Country_code_A3': 'ISO'})
#Aggregation
df_co2 = df_co2.groupby(['Country', 'Year', 'ISO'])['Emissions (kton)'].sum().reset_index()
df_co2_pivot = df_co2.pivot(index='Country', columns='Year', values='Emissions (kton)')
df_co2_pivot.head()
unique_countries_co2 = df_co2['Country'].nunique()
print(f"There are {unique_countries_co2} unique countries in df_co2.")
Run to view results
# data aggregation with emdat and population data
# sum up all CO2 data per contry from 1970-2024, EDGAR data
df_co2_total = df_co2.groupby(['ISO', 'Country'])['Emissions (kton)'].sum().reset_index()
# Prepare climat disasters
# Galcial lake outburst flood seams to be not relevant
climate_disasters = ['Flood', 'Storm', 'Drought', 'Wildfire',
'Extreme temperature',
'Epidemic', 'Mass movement (wet)'
]
#get emdat data , disaster data
df_emdat = pd.read_csv("public_emdat.csv", sep=";", encoding="utf-8", low_memory=False)
df_emdat["Total Affected"] = pd.to_numeric(df_emdat["Total Affected"], errors="coerce")
#summing up all total affected from 2000-2025 for all climate disasters
df_climate_emdat = df_emdat[df_emdat['Disaster Type'].isin(climate_disasters)]
df_affected = df_climate_emdat.groupby('ISO')['Total Affected'].sum().reset_index()
# Merge the datasets
df_correlation = pd.merge(df_co2_total, df_affected, on='ISO')
# Create the Log-Log Scatter Plot
df_correlation['Log_Emissions'] = np.log10(df_correlation['Emissions (kton)'] + 1)
df_correlation['Log_Affected'] = np.log10(df_correlation['Total Affected'] + 1)
# add population data for 2024
df_pop = pd.read_csv("WPP2024_Demographic_Indicators_Medium.csv", low_memory=False)
latest_year = 2024
df_pop_latest = df_pop[(df_pop['Time'] == latest_year) & (df_pop['Variant'] == 'Medium')]
# Keep only necessary columns and rename for merging
df_pop_clean = df_pop_latest[['Location', 'TPopulation1Jan', 'ISO3_code']].rename(
columns={'Location': 'Country', 'TPopulation1Jan': 'Population', 'ISO3_code':'ISO'}
)
#get absolut values
df_pop_clean['Population'] = df_pop_clean['Population'] * 1000
# Merge with your existing Correlation DataFrame
df_final = pd.merge(
df_correlation,
df_pop_clean.drop(columns=['Country']),
on='ISO',
how='inner'
)
# Calculate "Injustice per Capita"
# How many affected people per 100,000 citizens?
df_final['Affected_per_100k'] = (df_final['Total Affected'] / df_final['Population']) #* 100000
# Injustice Index : countries that suffer the most or least relative to what they contribute
df_final = df_final[df_final['Emissions (kton)'] > 0]
df_final['Injustice_Index'] = df_final['Total Affected'] / df_final['Emissions (kton)']
unique_countries_count = df_final['Country'].nunique()
print(f"There are {unique_countries_count} unique countries in df_final.")
df_final
Run to view results
# Global Emissions per Year
global_trend = df_co2.groupby('Year')['Emissions (kton)'].sum().reset_index()
fig = px.line(
global_trend,
template = 'plotly_white',
x='Year',
y='Emissions (kton)',
title='Global Emission Distribution (1970-2024)',
labels={'Emissions (kton)': 'Total Emissionen (kton)', 'Year': 'Year'}
)
fig.update_traces(line=dict(color='darkred', width=3))
fig.update_layout(
yaxis=dict(tickformat='.0f'),
hovermode="x unified"
)
fig.write_html("line_global_co2_1970-2024.html", include_plotlyjs="cdn")
fig.show()
Run to view results
# Assumes df_co2 is already loaded/created earlier in the notebook.
# Recompute latest_year and df_2024 from df_co2 to be safe
latest_year = df_co2['Year'].max()
# Select rows for latest year and keep both Country and Emissions for later use
_df_latest = df_co2[df_co2['Year'] == latest_year][['Country', 'Emissions (kton)']].copy()
# Ensure Emissions are numeric (this is fine)
_df_latest['Emissions (kton)'] = pd.to_numeric(_df_latest['Emissions (kton)'], errors='coerce')
# IMPORTANT FIX: Ensure 'Country' is treated as string/object, not mistakenly coerced to numeric
# The error suggests a prior attempt tried to convert a concatenated string of countries to numeric.
# We explicitly cast Country to string to avoid any accidental numeric conversions.
_df_latest['Country'] = _df_latest['Country'].astype(str)
# Create a clean numeric Series for plotting/stats, but keep it as a Series of numbers only
# Also keep a properly indexed country Series for lookups
emissions_series = _df_latest['Emissions (kton)']
country_series = _df_latest['Country']
# 2. Calculate Key Statistics (skip NaNs)
mean_val = emissions_series.mean(skipna=True)
median_val = emissions_series.median(skipna=True)
min_val = emissions_series.min(skipna=True)
max_val = emissions_series.max(skipna=True)
# Identify country with max emissions (handle all-NaN edge case)
if emissions_series.notna().any():
max_idx = emissions_series.idxmax()
# Guard against potential misalignment by ensuring index exists in country_series
max_country = country_series.loc[max_idx] if max_idx in country_series.index else 'N/A'
else:
max_country = 'N/A'
# 3. Create the Plot
plt.figure(figsize=(12, 6))
# Plot the distribution (The "Bell") - drop NaNs
sns.histplot(emissions_series.dropna(), kde=True, color="teal", bins=30, alpha=0.4)
# Add Vertical Lines for Stats (only if they are finite)
if np.isfinite(mean_val):
plt.axvline(mean_val, color='red', linestyle='--', linewidth=2, label=f'Average: {mean_val:,.0f}')
if np.isfinite(median_val):
plt.axvline(median_val, color='orange', linestyle='-', linewidth=2, label=f'Median: {median_val:,.0f}')
if np.isfinite(min_val):
plt.axvline(min_val, color='green', linestyle=':', linewidth=2, label='Lowest')
# 4. Annotate the "Outlier" (The Highest) if available
if np.isfinite(max_val):
plt.annotate(
f'Highest: {max_country}\n({max_val:,.0f} kton)',
xy=(max_val, 0),
xytext=(max_val * 0.7 if max_val > 0 else 0.1, 5),
arrowprops=dict(facecolor='black', shrink=0.05),
horizontalalignment='center'
)
# Formatting
plt.title(f'Distribution of Global Emissions per Country ({latest_year})', fontsize=15)
plt.xlabel('Emissions (kton)', fontsize=12)
plt.ylabel('Number of Countries', fontsize=12)
plt.legend()
plt.show()
# Apply a log10 transformation (drop NaNs, add 1 to avoid log(0))
data_log = np.log10(emissions_series.dropna() + 1)
plt.figure(figsize=(10, 6))
sns.histplot(data_log, kde=True, color="teal")
plt.title('Log-Transformed Distribution of Emissions')
plt.xlabel('Log10(Emissions)')
plt.show()
# Also preserve df_2024 for downstream cells that expect it to exist as a DataFrame
# Create a two-column DataFrame with Country and Emissions for latest year
# (This mirrors earlier cells that used df_2024 with both columns.)
df_2024 = _df_latest.copy()
Run to view results
# Prepare data for the latest year (2024)
latest_year = 2024
df_2024 = df_co2[df_co2['Year'] == latest_year].sort_values('Emissions (kton)', ascending=False)
# Top 5 Emitters
top_5 = df_2024.head(3).copy()
sum_top_5 = top_5['Emissions (kton)'].sum()
# Find how many bottom countries are needed to equal the sum_top_5
df_bottom_up = df_2024.sort_values('Emissions (kton)', ascending=True).copy()
df_bottom_up['Cumulative'] = df_bottom_up['Emissions (kton)'].cumsum()
# Filter until we reach the threshold
bottom_needed = df_bottom_up[df_bottom_up['Cumulative'] <= sum_top_5]
count_bottom_needed = len(bottom_needed)
# Plotting
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 6))
# Plot 1: Top 5
sns.barplot(data=top_5, x='Emissions (kton)', y='Country', ax=ax1, palette='Reds_r')
ax1.set_title(f'Top 3 Emittiers in 2024 (Total: {sum_top_5:,.0f} kton)')
# How many countries needed to match?
sns.barplot(data=bottom_needed, x='Emissions (kton)', y='Country', ax=ax2, palette='Blues_r')
ax2.set_yticklabels(ax2.get_yticklabels(), fontsize=3)
ax2.tick_params(axis='y', pad=0.5, length=2)
ax2.set_title(f'{count_bottom_needed} Countries Needed to Match Top 3')
plt.tight_layout()
plt.show()
print(f"Top 3 total emissions: {sum_top_5:,.0f} kton")
print(f"Number of bottom countries needed to match this: {count_bottom_needed}")
Run to view results
df_co2['Log_Emissions'] = np.log10(df_co2['Emissions (kton)'] + 1)
min_log = df_co2['Log_Emissions'].min()
max_log = df_co2['Log_Emissions'].max()
#map
fig = px.choropleth(
df_co2,
locations="Country",
locationmode="country names",
color="Log_Emissions",
hover_name="Country",
animation_frame="Year",
color_continuous_scale=px.colors.diverging.Spectral_r,
# HIER fixieren wir die Range:
range_color=[min_log, max_log],
title="Global CO2-Emissions (1970-2024)",
subtitle="Values scaled to Log10. This helps to show even small differences in Emission.",
labels={'Log_Emissions': 'Emissions (Log10 kton)'}
)
fig.write_html("co2_emissions_perYear_map_scaled_Log10.html", include_plotlyjs="cdn")
fig.show()
Run to view results
#adding all years together
df_co2['Total Emissions (kton)'] = df_co2.groupby('Country')['Emissions (kton)'].transform('sum')
# map
fig = px.choropleth(
df_co2,
locations="Country",
locationmode="country names",
color="Total Emissions (kton)",
hover_name="Country",
color_continuous_scale=px.colors.diverging.Spectral_r,
title="Global CO2-Emissions (Summed Up 1970-2024)",
labels={'Emissions': 'Emissions (kton)'}
)
fig.write_html("co2_emissions_map_TotalSum_1970-2024.html", include_plotlyjs="cdn")
fig.show()
Run to view results
fig = px.scatter(
df_final,
x='Emissions (kton)',
y='Total Affected',
size='Population',
hover_name='Country',
log_x=True,
log_y=True,
size_max=60,
title=f"Climate Injustice: Responsibility vs. Impact ({latest_year})",
labels={
'Emissions (kton)': 'Total CO2 Emissions (log scale)',
'Total Affected': 'Total People Affected by Climate Disasters (log scale)'
},
template="plotly_white"
)
fig.update_traces(marker=dict(sizemin=2))
fig.show()
Run to view results
fig = go.Figure()
# 2. Schleife für jeden Katastrophentyp (Punkte + Linie)
for d_type in climate_disasters:
# Merge
df_temp = df_emdat[df_emdat['Disaster Type'] == d_type]
df_aff = df_temp.groupby(['ISO', 'Country'])['Total Affected'].sum().reset_index()
df_merged = pd.merge(df_co2_total, df_aff, on='ISO', how='left')
df_merged = pd.merge(df_merged, df_pop_clean, on='ISO', how='inner')
# Use 1 for 0-affected to handle log scale
df_merged['Total Affected'] = df_merged['Total Affected'].fillna(1)
df_affected = df_merged[df_merged['Total Affected'] > 1]
df_safe = df_merged[df_merged['Total Affected'] <= 1]
# 3. CALCULATE TREND LINES
def get_trend(df):
if len(df) > 1:
lx = np.log10(df['Emissions (kton)'] + 1)
ly = np.log10(df['Total Affected'] + 1)
m, b = np.polyfit(lx, ly, 1)
xr = np.linspace(lx.min(), lx.max(), 100)
return 10**xr, 10**(m * xr + b)
return None, None
# Line B: Affected Only
aff_x, aff_y = get_trend(df_affected)
#Regression
lx = np.log10(df_affected['Emissions (kton)'] + 1)
ly = np.log10(df_affected['Total Affected'].clip(lower=1e-6))
# Calculate Correlation Coefficient (R)
correlation_matrix = np.corrcoef(lx, ly)
r_value = correlation_matrix[0, 1]
# 4. ADD TRACES
# Trace 1: Affected Countries (Reddish)
fig.add_trace(go.Scatter(
x=df_affected['Emissions (kton)'], y=df_affected['Total Affected'],
mode='markers', name=f"Country Affected by {d_type}", text=df_affected['ISO'],
visible=(d_type == 'Flood'),
marker=dict(size=df_affected['Population'], sizemode='area',
sizeref=2.*df_merged['Population'].max()/(60**2), color='indianred', opacity=0.7)
))
# Trace 2: Safe Countries (Grey/Blue)
fig.add_trace(go.Scatter(
x=df_safe['Emissions (kton)'], y=df_safe['Total Affected'],
mode='markers', name=f"Country Not Affected by {d_type}", text=df_safe['ISO'],
visible=(d_type == 'Flood'),
marker=dict(size=df_safe['Population'], sizemode='area',
sizeref=2.*df_merged['Population'].max()/(60**2), color='lightgrey', opacity=0.4)
))
# Trace 4: Trend Line (Affected Only - DASHED)
fig.add_trace(go.Scatter(
x=aff_x, y=aff_y, mode='lines', name=f"Regression of affected (R = {r_value:.2f})",
line=dict(color='red', dash='dash'), visible=(d_type == 'Flood'), hoverinfo='skip'
))
# 5. UPDATED DROPDOWN (Now we have 3 traces per disaster type!)
dropdown_buttons = []
for i, d_type in enumerate(climate_disasters):
visibility = [False] * (len(climate_disasters) * 3)
visibility[i*3 : i*3+3] = [True, True, True] # Show all 3 layers
dropdown_buttons.append(dict(
label=d_type,
method="update",
args=[
{"visible": visibility},
{
"title": {
"text": f"Correlation: <br> Responsibility for CO2 emissions vs. Impact of climate disasters",
"x": 0.5,
"y": 0.87,
"xanchor": "center",
"yanchor": "top"
}
}
]
))
x_max = np.log10(df_co2_total['Emissions (kton)'].max() + 1)
y_max = np.log10(df_emdat['Total Affected'].max() + 1)
# 4. Layout
fig.update_layout(
updatemenus=[{"buttons": dropdown_buttons, "direction": "down","showactive": True, "x": 0.1, "y": 1.2}],
xaxis_type="log", yaxis_type="log",
xaxis=dict(range=[-1, x_max + 1.2]),
yaxis=dict(range=[-1, y_max + 2.2]),
xaxis_title="CO2 Emissionen (kton)",
yaxis_title="# of Affected People by Disasters",
title={"text": "Correlation: <br> Responsibility for CO2 emissions vs. Impact of climate disasters",
"x": 0.5,
"y": 0.87,
"xanchor": "center",
"yanchor": "top"
},
legend=dict(
orientation='h',
x=0.5,
xanchor='center',
y=-0.25,
yanchor='top',
),
template="plotly_white",
)
fig.update_traces(marker=dict(sizemin=2))
fig.write_html("correlation_affected_co2.html", include_plotlyjs="cdn")
fig.show()
Run to view results
fig = go.Figure()
for d_type in climate_disasters:
df_temp = df_emdat[df_emdat['Disaster Type'] == d_type]
df_aff = df_temp.groupby('Country')['Total Affected'].sum().reset_index()
# Merges
df_merged = pd.merge(df_co2_total, df_aff, on='Country', how='left')
df_merged = pd.merge(df_merged, df_pop_clean, on='Country', how='inner')
#Ratio
df_merged['Total Affected'] = df_merged['Total Affected'].fillna(1)
df_merged['Affected_Ratio'] = df_merged['Total Affected'] / df_merged['Population']
# Data Splitting
df_affected = df_merged[df_merged['Total Affected'] > 1]
# TREND LINIEN
def get_trend(df):
if len(df) > 1:
lx = np.log10(df['Emissions (kton)'] + 1)
ly = np.log10(df['Affected_Ratio']) # Log der Ratio
m, b = np.polyfit(lx, ly, 1)
xr = np.linspace(lx.min(), lx.max(), 100)
return 10**xr, 10**(m * xr + b)
return None, None
aff_x, aff_y = get_trend(df_affected)
#Regression
lx = np.log10(df_affected['Emissions (kton)'] + 1)
ly = np.log10(df_affected['Affected_Ratio']+1)
# Calculate Correlation Coefficient (R)
correlation_matrix = np.corrcoef(lx, ly)
r_value = correlation_matrix[0, 1]
# 4. TRACES
# Trace 1: Affected Countries
fig.add_trace(go.Scatter(
x=df_affected['Emissions (kton)'], y=df_affected['Affected_Ratio'],
mode='markers', name=f"Affected: {d_type}", text=df_affected['Country'],
visible=(d_type == 'Flood'),
marker=dict(size=df_affected['Population'], sizemode='area',
sizeref=2.*df_merged['Population'].max()/(60**2), color='indianred', opacity=0.7)
))
# trend linie
fig.add_trace(go.Scatter(x=aff_x, y=aff_y, mode='lines', name=f"Regression of affected (R = {r_value:.2f})",
line=dict(color='red', dash='dash'), visible=(d_type == 'Flood'), hoverinfo='skip',))
# 5. DROPDOWN
dropdown_buttons = []
for i, d_type in enumerate(climate_disasters):
visibility = [False] * (len(climate_disasters) * 2)
visibility[i*2 : i*2+2] = [True, True]
dropdown_buttons.append(dict(label=d_type, method="update",
args=[{"visible": visibility},
{"title": {
"text": f"Climate Injustice: Normalized Impact per Country",
"x": 0.5,
"xanchor": "center"
}}]))
x_max = np.log10(df_co2_total['Emissions (kton)'].max() + 1)
y_min = -7
y_max = (df_merged['Affected_Ratio'].max())
# 6. LAYOUT
fig.update_layout(
updatemenus=[{"buttons": dropdown_buttons, "direction": "down","showactive": True, "x": 0.1, "y": 1.2}],
xaxis_type="log", yaxis_type="log",
xaxis=dict(range=[-1, x_max + 1], title="Total CO2 Emissions (kton)"),
yaxis=dict(range=[y_min, y_max + 1], title="Affected People / Population as log10 scale"),
title={
'text': "Climate Injustice: Normalized Impact per Contry",
'x': 0.5,
'xanchor': 'center'
},
legend=dict(
orientation='h',
x=0.5,
xanchor='center',
y=-0.25,
yanchor='top',
),
template="plotly_white",
)
fig.update_traces(marker=dict(sizemin=2))
fig.write_html("correlation_normalized_affected_co2.html", include_plotlyjs="cdn")
fig.show()
Run to view results
# These are countries that suffer the most or least relative to what they contribute
# je kleiner der wert ist um so weniger sind diese länder von ihrem eigenen CO2 ausstoß betroffen
# je größer der wert desto stärker sind sie betroffen obwohl sie einen geringen ausstoß haben
# the smaller the number the smaller the suffering
df_top_victims = df_final.sort_values('Injustice_Index', ascending=False).head(24)
# 5. Create the Bar Chart
fig = px.bar(
df_top_victims,
x='Injustice_Index',
y='Country',
orientation='h',
title="The Injustice Index: Who suffers most per kton of CO2 emitted?",
labels={'Injustice_Index': 'Affected People / kton CO2 emitted'},
template='plotly_white'
)
fig.update_traces(marker_color='steelblue')
# Sorting the Y-axis so the highest is at the top
fig.update_layout(yaxis={'categoryorder':'total ascending'})
fig.write_html("Injustice_Index_affected_co2_Barchart_victims.html", include_plotlyjs="cdn")
fig.show()
Run to view results
# je größer der wert desto stärker sind sie betroffen obwohl sie einen geringen ausstoß haben
df_top_culprits = df_final.sort_values('Injustice_Index', ascending=True).head(24)
# 5. Create the Bar Chart
fig = px.bar(
df_top_culprits,
x='Injustice_Index',
y='Country',
orientation='h',
title="The Injustice Index: Who suffers least per kton of CO2 emitted?",
labels={'Injustice_Index': 'Affected People / kton CO2 emitted'},
template='plotly_white'
)
fig.update_traces(marker_color='indianred')
fig.update_layout(yaxis={'categoryorder':'total ascending'})
fig.write_html("Injustice_Index_affected_co2_Barchart_culprits.html", include_plotlyjs="cdn")
fig.show()
Run to view results
df_final['Injustice_Index_log10']= np.log10(df_final['Injustice_Index'] + 1)
# Injustice index map
fig = px.choropleth(
df_final,
locations="Country",
locationmode="country names",
color="Injustice_Index_log10",
hover_name="Country",
color_continuous_scale=px.colors.diverging.Spectral_r,
title="Injustice_Index per contry",
labels={'Injustice_Index': 'Affected People/ kton CO2 emitted'}
)
fig.write_html("Injustice_Index_map.html", include_plotlyjs="cdn")
fig.show()
Run to view results