02 · Centrality & backbone

RQ1 — who is central, and where does Austria rank? Computes degree, strength, betweenness, eigenvector centrality and PageRank on the directed weighted graph, ranks the hubs through SQL, and extracts the disparity-filter backbone.

Why weighted betweenness uses distance = 1 / value_kusd, and why unweighted betweenness is identically zero here, is explained on the Data & method page.

Show code
from __future__ import annotations

import pandas as pd
from IPython.display import Markdown, display

from eu_trade_network import config, data_loader, db, graph, metrics, viz

Graph + centralities

Rebuild the edge list / digraph for config.YEAR, compute strengths and centralities, then refresh the DuckDB nodes table.

Show code
edgelist = data_loader.build_edgelist()
G = graph.build_graph(edgelist)
summary = graph.graph_summary(G)
cent = metrics.compute_centralities(G)

node_meta = (
    pd.DataFrame([{"iso3": n, **attrs} for n, attrs in G.nodes(data=True)])
    .sort_values("iso3")
    .reset_index(drop=True)
)
nodes_db = node_meta.merge(cent, on="iso3", how="inner")

con = db.connect()
db.init_schema(con)
db.write_table(
    con,
    "nodes",
    nodes_db[
        [
            "iso3",
            "name",
            "grp",
            "out_strength",
            "in_strength",
            "degree",
            "betweenness",
            "betweenness_u",
            "eigenvector",
            "pagerank",
        ]
    ],
)

# Keep edges in sync if NB01 has not been run in this environment.
edges_db = edgelist.copy()
edges_db["year"] = config.YEAR
db.write_table(con, "edges", edges_db[["exporter_iso3", "importer_iso3", "value_kusd", "year"]])

print(summary)
print(f"Updated {len(nodes_db)} nodes → {config.DB_PATH}")
cent.sort_values("betweenness", ascending=False).head(10)

SQL rankings (RQ1)

Hub ranking by weighted betweenness, then Austria’s bilateral partners.

Show code
hubs = db.read_sql(con, config.SQL_DIR / "queries" / "01_top_hubs_by_betweenness.sql")
hubs
Show code
aut_partners = db.read_sql(con, config.SQL_DIR / "queries" / "03_austria_trade_partners.sql")
aut_partners
Show code
aut = hubs.loc[hubs["iso3"] == "AUT"].iloc[0]
aut_pr_rank = int(
    hubs.sort_values("pagerank", ascending=False)
    .reset_index(drop=True)
    .assign(r=lambda d: d.index + 1)
    .loc[lambda d: d["iso3"] == "AUT", "r"]
    .iloc[0]
)
aut_str_rank = int(
    cent.sort_values("out_strength", ascending=False)
    .reset_index(drop=True)
    .assign(r=lambda d: d.index + 1)
    .loc[lambda d: d["iso3"] == "AUT", "r"]
    .iloc[0]
)
n_zero_b = int((hubs["betweenness"] == 0).sum())
n_pos_b = int((hubs["betweenness"] > 0).sum())
top_hub = hubs.iloc[0]

display(
    Markdown(
        f"**{top_hub['name']} leads, and position is not volume.** "
        f"{top_hub['name']} ({top_hub['iso3']}) scores "
        f"{float(top_hub['betweenness']):.3f} on weighted betweenness. Only {n_pos_b} of "
        f"{len(hubs)} economies score above zero at all, because the digraph is complete "
        f"(density = {summary['density']:.2f}): for most pairs the direct link is already "
        f"the cheapest path, so nobody sits between them.\n\n"
        f"**Austria is not a bridge.** It is one of the {n_zero_b} economies with "
        f"betweenness = 0 — the SQL rank of {int(aut['rank'])} is an arbitrary tie-break "
        f"among those zeros, not a ranking. Its partners already trade with each other "
        f"directly. By export strength Austria is {aut_str_rank}th of {len(hubs)} and by "
        f"PageRank {aut_pr_rank}th: a mid-tier EU economy whose largest partner is Germany "
        f"on both sides of the trade balance."
    )
)

Strength distribution

The network is dense (near-complete bilateral coverage). We plot the strength ranking and do not fit a power law.

Show code
fig_strength = viz.plot_degree_distribution(cent)
fig_strength.show()

Disparity-filter backbone

Statistically significant edges at config.DISPARITY_ALPHA (Serrano, Boguñá & Vespignani 2009). Also compute the rich-club coefficient on the undirected projection.

Show code
backbone = metrics.disparity_filter(G, alpha=config.DISPARITY_ALPHA)
bb_edges = pd.DataFrame(
    [
        {"exporter_iso3": u, "importer_iso3": v, "value_kusd": float(d["weight"])}
        for u, v, d in backbone.edges(data=True)
    ]
)
rc = metrics.rich_club(G)

print(
    f"Backbone keeps {backbone.number_of_edges()} / {G.number_of_edges()} edges "
    f"at alpha={config.DISPARITY_ALPHA}"
)
rc.tail(10)
Show code
# Two-panel backbone map: Europe detail + global context (kaleido-safe).
fig_backbone = viz.plot_backbone_map(
    bb_edges,
    nodes_db,
    title=(
        f"Disparity-filter backbone (α={config.DISPARITY_ALPHA}): "
        f"{backbone.number_of_edges()} significant edges"
    ),
)
out = viz.save_fig(fig_backbone, "02_backbone_map.png", headline=True)
print(f"Saved {out}")
fig_backbone.show()

con.close()