Show code
from __future__ import annotations
import pandas as pd
from IPython.display import Markdown, display
from eu_trade_network import config, data_loader, graph, metrics, resilience, viz
N_RANDOM_RUNS = 20RQ3 — how much of the network survives losing its hubs? Economies are removed one at a time, targeted and at random, tracking both connectivity and retained trade value.
The removal protocol is described on the Data & method page.
The simulation runs twice, and the pairing is the point. The full graph is complete, so it cannot fragment topologically — there the value curve carries the whole story. The disparity-filter backbone keeps only statistically significant links, and that is where asking whether the network can break apart actually means something.
Rebuild the directed weighted graph for config.YEAR and extract the disparity-filter backbone.
edgelist = data_loader.build_edgelist()
G = graph.build_graph(edgelist)
backbone = metrics.disparity_filter(G, alpha=config.DISPARITY_ALPHA)
summary = graph.graph_summary(G)
n_nodes = int(summary["n_nodes"])
print(
f"Full graph: {n_nodes} economies, {int(summary['n_edges'])} edges, "
f"density = {summary['density']:.2f}, "
f"total trade = {summary['total_value_kusd'] / 1e6:,.0f} bn USD"
)
print(
f"Backbone (alpha = {config.DISPARITY_ALPHA}): {backbone.number_of_nodes()} economies, "
f"{backbone.number_of_edges()} significant edges"
)The two targeted hit lists. Out-strength ranks by export volume; weighted betweenness ranks by how often an economy sits on the cheapest trade path between two others.
top_n = 8
by_strength = resilience.simulate_removal(G, "strength")
by_betweenness = resilience.simulate_removal(G, "betweenness")
hit_lists = pd.DataFrame(
{
"rank": range(1, top_n + 1),
"by out-strength": by_strength["removed_node"].tolist()[1 : top_n + 1],
"by betweenness": by_betweenness["removed_node"].tolist()[1 : top_n + 1],
}
).set_index("rank")
hit_listsOn a complete digraph the largest component always holds every survivor, so the left panel is flat at 100% by construction. That is the finding: this network has no articulation point. The right panel shows what is actually at stake.
curves_full = resilience.run_random_vs_targeted(G, n_random_runs=N_RANDOM_RUNS)
fig_full = viz.plot_resilience(
curves_full,
title=f"Full trade network ({n_nodes} economies, density = 1) — hub attack vs random failure",
)
out = viz.save_fig(fig_full, "04_resilience_full_graph.png", headline=False)
print(f"Saved {out}")
fig_full.show()Repeat the simulation on the disparity-filter backbone, i.e. the sub-network of links that carry a significantly larger share of a country’s trade than a random allocation would give them. This is the headline figure for RQ3.
curves_bb = resilience.run_random_vs_targeted(backbone, n_random_runs=N_RANDOM_RUNS)
fig_bb = viz.plot_resilience(
curves_bb,
title=(
f"Significant-trade backbone (alpha = {config.DISPARITY_ALPHA}) — "
"hub attack vs random failure"
),
)
out = viz.save_fig(fig_bb, "04_resilience.png", headline=True)
print(f"Saved {out}")
fig_bb.show()critical_threshold reports the first removal fraction at which the largest component holds less than half of the surviving economies; value_threshold does the same for retained trade value. A critical fraction of 100% means “never fragments” — the curve only crosses when the graph is emptied.
rows = []
for network, curves in (("full graph", curves_full), ("backbone", curves_bb)):
for strategy, frame in curves.groupby("strategy", sort=False):
crit = resilience.critical_threshold(frame)
half = resilience.value_threshold(frame)
rows.append(
{
"network": network,
"strategy": str(strategy),
"critical_fraction": crit,
"economies_to_fragment": round(crit * n_nodes),
"half_value_fraction": half,
"economies_to_halve_value": round(half * n_nodes),
}
)
thresholds = pd.DataFrame(rows)
thresholds.style.format({"critical_fraction": "{:.1%}", "half_value_fraction": "{:.1%}"}).hide(
axis="index"
)The paragraphs below are generated from the run above, so the numbers always match the figures.
def _pick(network: str, strategy: str, column: str) -> float:
row = thresholds.loc[(thresholds["network"] == network) & (thresholds["strategy"] == strategy)]
return float(row[column].to_numpy()[0])
crit_strength = _pick("backbone", resilience.STRENGTH_STRATEGY, "critical_fraction")
crit_between = _pick("backbone", resilience.BETWEENNESS_STRATEGY, "critical_fraction")
crit_random = _pick("backbone", resilience.RANDOM_STRATEGY, "critical_fraction")
half_targeted = _pick("full graph", resilience.STRENGTH_STRATEGY, "half_value_fraction")
half_random = _pick("full graph", resilience.RANDOM_STRATEGY, "half_value_fraction")
n_half = round(half_targeted * n_nodes)
top_exporters = by_strength["removed_node"].tolist()[1 : n_half + 1]
lost_first = 1.0 - float(by_strength["trade_value_retained"].to_numpy()[1])
lost_deu = 1.0 - float(by_betweenness["trade_value_retained"].to_numpy()[1])
aut_rank = by_strength["removed_node"].tolist().index("AUT")
aut_curve = resilience.simulate_removal(G, ["AUT"])
aut_share = 1.0 - float(aut_curve["trade_value_retained"].to_numpy()[1])
paragraphs = [
f"**Critical fraction.** On the significant-trade backbone the network breaks apart once "
f"{crit_between:.0%} of economies ({round(crit_between * n_nodes)} of {n_nodes}) are removed "
f"in betweenness order, or {crit_strength:.0%} ({round(crit_strength * n_nodes)}) in "
f"export-volume order — *broken* meaning the largest surviving component no longer holds "
f"half the remaining economies. Random failure has to take out {crit_random:.0%} "
f"({round(crit_random * n_nodes)}) before the same thing happens, so the backbone is about "
f"**{crit_random / crit_between:.1f}× more tolerant of accidents than of a deliberate hit "
f"list**. Bridge economies bite sooner than big exporters: targeting betweenness fragments "
f"the backbone {(crit_strength - crit_between) * n_nodes:.0f} economies earlier than "
f"targeting export volume.",
"**Connectivity is not the weak point.** The full network is complete (density = 1): every "
"economy trades directly with every other, so nothing short of emptying it leaves anyone "
"stranded. There is no bridge to cut, and no trade agreement can add redundancy that is not "
"already there. Resilience here is not about whether the links exist — it is about how "
"lopsidedly the value sits on them.",
f"**Value is the weak point.** Losing the single largest exporter ({top_exporters[0]}) "
f"already strands {lost_first:.0%} of this {n_nodes}-economy network's merchandise trade "
f"value; losing Germany alone strands {lost_deu:.0%}. **Half of all trade value is gone once "
f"{n_half} economies ({', '.join(top_exporters)}) are removed** — {half_targeted:.0%} of the "
f"nodes. Random failure needs {round(half_random * n_nodes)} economies ({half_random:.0%}) "
f"for the same damage, making a targeted shock roughly "
f"{half_random / half_targeted:.1f}× more efficient than an untargeted one.",
"**Policy reading.**",
"1. **Stress-test the backbone, not the full graph** — shocks travel along the significant "
"corridors, and that is the object that actually fragments.\n"
"2. **Diversification, not redundancy, is the lever** — the links already exist; the volume "
"on them is what is concentrated, so exposure falls only by shifting trade away from the top "
"handful of partners.\n"
"3. **Watch bridge economies as well as big exporters** — on the backbone, betweenness "
"targeting fragments the network faster than volume targeting, which is exactly the failure "
"mode a supply-chain contingency plan should model.\n"
f"4. **Austria** — a mid-sized open economy: {aut_rank}th of {n_nodes} by export volume and "
f"{aut_share:.1%} of the network's trade value, with zero betweenness on the full graph "
f"(NB02) because its partners already trade directly. Austria is therefore not a chokepoint "
f"— removing it changes nothing structurally — but it sits downstream of the economies that "
f"are. Its resilience question is dependency on Germany and the other top hubs, not its own "
f"criticality.",
]
display(Markdown("\n\n".join(paragraphs)))