Community structure in the 2022 wheat network¶
Which countries trade wheat mostly with each other? This notebook partitions the 2022 wheat trade network into communities, measures how good and how stable the partition is, and compares two algorithms. It uses only the bundled sample and runs offline.
Community detection maximises modularity: the share of flow weight that falls inside communities, minus the share expected if flows were placed at random with the same country totals. The graph is directed, so the directed form of modularity is used.
import networkx as nx
import numpy as np
import pandas as pd
import plotly.io as pio
import netviz_tools as nv
from netviz_tools.datasets import faostat
pio.renderers.default = "notebook_connected"
flows = faostat.load_sample(items="Wheat", years=2022)
g = nv.build_graph(flows, node_attrs=faostat.countries())
print(f"{g.number_of_nodes()} countries, {g.number_of_edges():,} flows")
166 countries, 1,646 flows
Louvain communities¶
nv.metrics.communities runs the Louvain algorithm from NetworkX with a fixed seed. Community ids are numbered by decreasing size, so the same seed always gives the same labels.
part = nv.metrics.communities(g, method="louvain", seed=0)
q = nv.metrics.modularity(g, part)
print(f"{part.nunique()} communities, modularity {q:.3f}")
7 communities, modularity 0.504
strength = pd.Series(dict(g.degree(weight="weight")), name="strength")
continent = pd.Series(nx.get_node_attributes(g, "continent"), name="continent")
def summarize(partition, n_top=5):
"""One row per community: size, share of total trade, main continent, largest members."""
rows = []
for cid, members in partition.groupby(partition).groups.items():
s = strength[list(members)].sort_values(ascending=False)
rows.append(
{
"community": cid,
"size": len(members),
"share of strength": round(s.sum() / strength.sum(), 3),
"most common continent": continent[list(members)].value_counts().idxmax(),
"largest members": ", ".join(s.index[:n_top]),
}
)
return pd.DataFrame(rows).set_index("community")
summarize(part)
| size | share of strength | most common continent | largest members | |
|---|---|---|---|---|
| community | ||||
| 0 | 38 | 0.212 | Europe | France, Germany, Morocco, Spain, Netherlands (... |
| 1 | 33 | 0.219 | Asia | Australia, China, mainland, Indonesia, Philipp... |
| 2 | 28 | 0.166 | Caribbean | United States of America, Canada, Japan, Mexic... |
| 3 | 27 | 0.093 | Africa | Argentina, Brazil, South Africa, Kenya, Chile |
| 4 | 27 | 0.232 | Asia | Russian Federation, Türkiye, Ukraine, Egypt, R... |
| 5 | 10 | 0.046 | Europe | Italy, Hungary, Austria, Slovakia, Slovenia |
| 6 | 3 | 0.031 | Asia | Kazakhstan, Uzbekistan, Tajikistan |
The communities are easy to read as trade blocs:
- 0 (38 countries): the EU and North Africa, led by France, Germany, Morocco and Spain.
- 1 (33): Australia with East and Southeast Asia (China, Indonesia, the Philippines, the Republic of Korea).
- 2 (28): the United States and Canada with Japan, Mexico, Colombia and Peru, and most of Central America and the Caribbean.
- 3 (27): Argentina, Brazil and much of sub-Saharan Africa.
- 4 (27): the Black Sea bloc: the Russian Federation, Ukraine, Türkiye, Egypt, Romania and Iran.
- 5 (10): Italy and Central Europe (Hungary, Austria, Slovakia, Slovenia, Croatia).
- 6 (3): Kazakhstan with Uzbekistan and Tajikistan.
Continents alone would not give this: the Black Sea bloc mixes Europe, Asia and Africa, and Australia sits with Asia.
The community graph¶
nv.metrics.community_graph collapses each community into one node. Edge weights are the total flow between communities; flow inside a community becomes a self-loop. The matrix below is in million tonnes, with exporting communities as rows.
cg = nv.metrics.community_graph(g, part)
between = nx.to_pandas_adjacency(cg, weight="weight").div(1e6).round(2)
between.index.name, between.columns.name = "from", "to"
between
| to | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|---|
| from | |||||||
| 0 | 25.19 | 2.07 | 0.07 | 2.11 | 5.02 | 2.15 | 0.00 |
| 1 | 0.29 | 25.54 | 1.82 | 1.28 | 1.18 | 0.14 | 0.00 |
| 2 | 1.94 | 10.09 | 18.34 | 1.05 | 0.39 | 1.11 | 0.00 |
| 3 | 2.20 | 2.81 | 1.70 | 8.69 | 1.28 | 0.00 | 0.00 |
| 4 | 3.54 | 2.26 | 0.59 | 1.26 | 28.61 | 1.60 | 1.67 |
| 5 | 0.79 | 0.02 | 0.01 | 0.00 | 0.61 | 4.38 | 0.00 |
| 6 | 0.04 | 0.03 | 0.00 | 0.00 | 0.68 | 0.27 | 3.76 |
total = sum(w for *_, w in cg.edges(data="weight"))
inside = sum(w for u, v, w in cg.edges(data="weight") if u == v)
print(f"{inside / total:.1%} of world wheat volume stays inside a community")
68.7% of world wheat volume stays inside a community
About 69% of the volume flows inside communities. The largest flow between communities goes from community 2 (North America) to community 1 (East Asia): about 10 million tonnes.
Drawing the partition¶
layout="community" places each community in its own cluster, and color_by="community" colours nodes by community id (the default colouring, made explicit here). partition= passes the seed-0 partition computed above, so the chart and the tables agree. Only edges in the top quarter by weight are drawn, and the 20 largest countries are labelled.
nv.plot.network(
g,
top_n=None,
layout="community",
color_by="community",
partition=part,
min_weight_quantile=0.75,
show_labels=20,
title="Wheat trade communities, 2022 (Louvain, seed 0)",
height=700,
).show()
A node-link diagram of 166 countries hides most of the 1,646 flows. The adjacency matrix shows every flow between the 60 largest countries, ordered by the same partition: exporters are rows, importers are columns, and flows inside a community fall in the blocks along the diagonal.
nv.plot.adjacency(
g, partition=part, top_n=60, title="Wheat trade, 2022: 60 largest countries by community"
).show()
Stability across seeds¶
Louvain is randomised: it visits nodes in a random order, and different orders can end in different partitions of similar modularity. A partition is worth interpreting only if it survives a change of seed. Here we run 20 seeds.
seeds = range(20)
runs = {s: nv.metrics.communities(g, method="louvain", seed=s) for s in seeds}
per_seed = pd.DataFrame(
{
"communities": {s: p.nunique() for s, p in runs.items()},
"modularity": {s: nv.metrics.modularity(g, p) for s, p in runs.items()},
}
)
print(per_seed["communities"].value_counts().sort_index().to_string())
per_seed["modularity"].describe().round(4)
communities 5 2 6 5 7 13
count 20.0000 mean 0.5044 std 0.0015 min 0.5013 25% 0.5031 50% 0.5048 75% 0.5058 max 0.5059 Name: modularity, dtype: float64
Modularity barely varies (0.501 to 0.506), but the number of communities does (5 to 7). To compare partitions directly we use two measures, written here with NumPy and pandas only:
- the adjusted Rand index (ARI): agreement on which node pairs are grouped together, corrected for chance, so 1 means identical partitions and 0 means chance-level agreement;
- the co-assignment share: for each pair of countries, the fraction of runs in which they share a community.
def adjusted_rand(a, b):
"""Adjusted Rand index of two partitions given as Series indexed by node."""
table = pd.crosstab(a, b.reindex(a.index)).to_numpy()
def pairs(x):
return float((x * (x - 1) / 2).sum())
index = pairs(table)
rows, cols = pairs(table.sum(axis=1)), pairs(table.sum(axis=0))
expected = rows * cols / pairs(np.array([table.sum()]))
return (index - expected) / ((rows + cols) / 2 - expected)
aris = pd.Series(
[adjusted_rand(runs[i], runs[j]) for i in seeds for j in seeds if i < j], name="ARI"
)
aris.describe().round(3)
count 190.000 mean 0.890 std 0.070 min 0.729 25% 0.837 50% 0.889 75% 0.944 max 1.000 Name: ARI, dtype: float64
nodes = sorted(g)
labels = np.array([runs[s].reindex(nodes).to_numpy() for s in seeds])
co = pd.DataFrame(
(labels[:, :, None] == labels[:, None, :]).mean(axis=0), index=nodes, columns=nodes
)
upper = co.to_numpy()[np.triu_indices(len(nodes), k=1)]
print(f"{len(upper):,} country pairs")
print(f"always together in all 20 runs: {(upper == 1).mean():.1%}")
print(f"never together: {(upper == 0).mean():.1%}")
print(f"split (sometimes together): {((upper > 0) & (upper < 1)).mean():.1%}")
13,695 country pairs always together in all 20 runs: 14.7% never together: 74.4% split (sometimes together): 10.9%
Median ARI between two seeds is about 0.89, and 89% of country pairs get the same answer in all 20 runs (always together or never together). The core blocs are stable; the disagreement is concentrated in a few places. The co-assignment shares for some pairs of interest:
pairs_of_interest = [
("Russian Federation", "Ukraine"),
("Russian Federation", "Türkiye"),
("Russian Federation", "Egypt"),
("United States of America", "Canada"),
("France", "Germany"),
("France", "Italy"),
("Australia", "China, mainland"),
("Argentina", "Brazil"),
("Kazakhstan", "Uzbekistan"),
("Kazakhstan", "Russian Federation"),
("South Africa", "Argentina"),
("United Arab Emirates", "Australia"),
("United Arab Emirates", "Russian Federation"),
]
pd.DataFrame(
[(a, b, co.loc[a, b]) for a, b in pairs_of_interest],
columns=["country A", "country B", "share of runs together"],
)
| country A | country B | share of runs together | |
|---|---|---|---|
| 0 | Russian Federation | Ukraine | 1.00 |
| 1 | Russian Federation | Türkiye | 1.00 |
| 2 | Russian Federation | Egypt | 1.00 |
| 3 | United States of America | Canada | 1.00 |
| 4 | France | Germany | 1.00 |
| 5 | France | Italy | 0.10 |
| 6 | Australia | China, mainland | 1.00 |
| 7 | Argentina | Brazil | 1.00 |
| 8 | Kazakhstan | Uzbekistan | 1.00 |
| 9 | Kazakhstan | Russian Federation | 0.35 |
| 10 | South Africa | Argentina | 0.70 |
| 11 | United Arab Emirates | Australia | 0.35 |
| 12 | United Arab Emirates | Russian Federation | 0.65 |
Russia, Ukraine, Türkiye and Egypt share a community in every run, as do the United States and Canada, and Australia and mainland China. Kazakhstan joins Russia's community in 7 of 20 runs and otherwise leads its own Central Asian group. Italy is grouped with France in only 2 of 20 runs. South Africa stays with Argentina in 14 of 20 runs.
To find the countries whose grouping changes most, score each country by its mean "decisiveness": for every other country, how far their co-assignment share is from a clear 0 or 1 (1 means always decided, 0 means a coin flip).
decisive = (2 * (co - 0.5).abs()).mean(axis=1).rename("decisiveness")
unstable = decisive.sort_values().head(10).to_frame()
unstable["seed-0 community"] = part.reindex(unstable.index)
unstable["strength (Mt)"] = (strength.reindex(unstable.index) / 1e6).round(2)
unstable.round(3)
| decisiveness | seed-0 community | strength (Mt) | |
|---|---|---|---|
| Maldives | 0.742 | 1 | 0.00 |
| Yemen | 0.742 | 1 | 0.00 |
| United Arab Emirates | 0.742 | 1 | 2.62 |
| Seychelles | 0.742 | 1 | 0.00 |
| South Africa | 0.792 | 3 | 2.17 |
| Botswana | 0.792 | 3 | 0.12 |
| Lesotho | 0.792 | 3 | 0.07 |
| Namibia | 0.792 | 3 | 0.11 |
| Zimbabwe | 0.792 | 3 | 0.17 |
| Zambia | 0.792 | 3 | 0.04 |
The United Arab Emirates moves between the Australia and East Asia community and the Black Sea community, and takes Yemen, the Maldives and the Seychelles with it. A group of southern African countries around South Africa sometimes leaves Argentina's community for a larger one. These are countries with ties to two blocs; their label in any single run should not be over-read.
Resolution¶
The modularity resolution parameter sets the scale: values above 1 favour more, smaller communities. The seed-0 partition at a few resolutions:
pd.DataFrame(
{
r: {
"communities": (p := nv.metrics.communities(g, seed=0, resolution=r)).nunique(),
"largest": int(p.value_counts().max()),
"modularity (resolution 1)": round(nv.metrics.modularity(g, p), 3),
}
for r in (0.5, 1.0, 1.5, 2.0)
}
).T.rename_axis("resolution")
| communities | largest | modularity (resolution 1) | |
|---|---|---|---|
| resolution | |||
| 0.5 | 3.0 | 82.0 | 0.472 |
| 1.0 | 7.0 | 38.0 | 0.504 |
| 1.5 | 7.0 | 38.0 | 0.499 |
| 2.0 | 10.0 | 30.0 | 0.462 |
nv.metrics.modularity always scores at resolution 1, so the last column compares the partitions on the same scale.
Greedy modularity¶
method="greedy_modularity" uses the Clauset-Newman-Moore algorithm: it starts with every country alone and repeatedly merges the pair of communities that raises modularity most. It is deterministic, so it needs no seed.
greedy = nv.metrics.communities(g, method="greedy_modularity")
print(
f"greedy: {greedy.nunique()} communities, modularity {nv.metrics.modularity(g, greedy):.3f}; "
f"louvain seed 0: {part.nunique()} communities, modularity {q:.3f}"
)
print(f"ARI between them: {adjusted_rand(part, greedy):.3f}")
summarize(greedy)
greedy: 6 communities, modularity 0.505; louvain seed 0: 7 communities, modularity 0.504 ARI between them: 0.659
| size | share of strength | most common continent | largest members | |
|---|---|---|---|---|
| community | ||||
| 0 | 55 | 0.368 | Asia | Australia, United States of America, Canada, C... |
| 1 | 41 | 0.212 | Europe | France, Germany, Morocco, Spain, Netherlands (... |
| 2 | 32 | 0.245 | Asia | Russian Federation, Türkiye, Ukraine, Egypt, R... |
| 3 | 25 | 0.098 | Africa | Argentina, Brazil, South Africa, Peru, Kenya |
| 4 | 10 | 0.046 | Europe | Italy, Hungary, Austria, Slovakia, Slovenia |
| 5 | 3 | 0.031 | Asia | Kazakhstan, Uzbekistan, Tajikistan |
pd.crosstab(part.rename("louvain (seed 0)"), greedy.rename("greedy modularity"))
| greedy modularity | 0 | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|---|
| louvain (seed 0) | ||||||
| 0 | 1 | 36 | 0 | 1 | 0 | 0 |
| 1 | 27 | 1 | 5 | 0 | 0 | 0 |
| 2 | 27 | 0 | 0 | 1 | 0 | 0 |
| 3 | 0 | 4 | 0 | 23 | 0 | 0 |
| 4 | 0 | 0 | 27 | 0 | 0 | 0 |
| 5 | 0 | 0 | 0 | 0 | 10 | 0 |
| 6 | 0 | 0 | 0 | 0 | 0 | 3 |
Both methods reach almost the same modularity (0.504 and 0.505), but they cut the network differently: the ARI between them is 0.66, lower than for any pair of Louvain seeds (minimum 0.73). Greedy modularity finds six communities. The cross-tabulation shows the main difference: it merges Louvain's East Asia community (1) and North America community (2) into one community of 55 countries led by Australia, the United States and Canada. The Black Sea, Central European and Central Asian communities are identical in both. Two partitions with nearly equal modularity and different shapes is a common result, and it is a reason to report community structure as "these blocs are stable across methods and seeds" rather than "the network has exactly seven communities".
Summary¶
- Louvain finds seven communities in the 2022 wheat network with modularity 0.504. About 69% of traded volume stays inside a community.
- The blocs are geographic but not continental: a Black Sea bloc around Russia, Ukraine, Türkiye and Egypt; an EU and North Africa bloc; Australia with East Asia; North America with Japan and Mexico.
- Across 20 seeds modularity varies by less than 0.005, while the number of communities varies from 5 to 7. Median pairwise ARI is 0.89.
- Greedy modularity reaches the same modularity with a different, coarser partition.
Data: FAO. 2025. FAOSTAT: Detailed trade matrix. Accessed on 27 September 2026. https://www.fao.org/faostat/en/#data/TM. Licence: CC-BY-4.0.