04 · Resilience

RQ3 — 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.

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 = 20

Graph + backbone

Rebuild the directed weighted graph for config.YEAR and extract the disparity-filter backbone.

Show code
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"
)

Who goes first?

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.

Show code
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_lists

Full network — connectivity never breaks, value does

On 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.

Show code
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()

Significant-trade backbone — where fragmentation happens

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.

Show code
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 fraction

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.

Show code
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"
)

What this means (RQ3)

The paragraphs below are generated from the run above, so the numbers always match the figures.

Show code
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)))