Power law vs lognormal¶
Trade networks are often described as "scale-free", meaning that the number of partners (degree) or the traded volume (strength) follows a power law. A straight line on a log-log plot is the usual evidence, and it is weak evidence: lognormal and other heavy-tailed distributions also look nearly straight over one or two decades.
nv.stats.degree_distribution_fit follows Clauset, Shalizi and Newman (2009), "Power-law distributions in empirical data", SIAM Review 51(4):
- For every candidate lower bound
xmin, fit the exponentalphaofp(x) ~ x^-alphaby maximum likelihood, and keep thexminwhose fit is closest to the data in Kolmogorov-Smirnov (KS) distance. - Fit lognormal and exponential distributions to the same tail.
- Compare the power law with each alternative using Vuong's likelihood-ratio test.
This notebook applies it to degree and out-strength in the 2022 wheat, maize and soya bean networks, using the bundled sample.
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"
graphs = nv.graphs_by(faostat.load_sample(years=2022), by="category")
for item, g in graphs.items():
print(f"{item:14s} {g.number_of_nodes()} countries, {g.number_of_edges():,} flows")
Maize (corn) 177 countries, 2,526 flows Soya beans 164 countries, 1,461 flows Wheat 166 countries, 1,646 flows
One fit, read closely¶
Out-strength is the total a country exports, in tonnes. Only countries with positive out-strength enter the fit, so n counts exporters, not all countries.
wheat = graphs["Wheat"]
fit = nv.stats.degree_distribution_fit(wheat, "out_strength")
print(fit.summary())
out_strength: alpha=1.632, xmin=326460, tail 39 of 119, KS=0.117. vs lognormal: R=-0.48, p=0.537 (inconclusive). vs exponential: R=17.32, p=0.013 (power_law).
How to read this:
alphaandxmindescribe the fitted tail: abovexmin(about 326,000 tonnes), the fitted density falls asx^-1.63.tail 39 of 119: the fit covers the 39 largest of 119 exporters. The other 80 are not modelled at all.KSis the largest gap between the fitted and observed cumulative distributions over the tail.Ris the log-likelihood of the power law minus that of the alternative, summed over the tail. Positive favours the power law, negative the alternative.pis the two-sided p-value for "R is different from zero". A largepmeans the sign ofRcould be noise.- The word in brackets applies Clauset et al.'s threshold of 0.1: if
p < 0.1, the sign ofRpicks the winner; otherwise the comparison isinconclusive.
So for wheat exporters, a power law fits the tail better than an exponential (R = 17.3, p = 0.013), but it cannot be told apart from a lognormal (R = -0.48, p = 0.54).
nv.plot.degree_distribution draws the empirical complementary CDF with the three fitted tails:
nv.plot.degree_distribution(fit).show()
The power-law and lognormal curves lie almost on top of each other over the range of the data. They would only separate further out in the tail, where there are no more countries.
Degree¶
Degree is the number of trading partners, an integer, so the fit uses discrete likelihoods.
fit_deg = nv.stats.degree_distribution_fit(wheat, "degree")
print(fit_deg.summary())
nv.plot.degree_distribution(fit_deg).show()
degree: alpha=4.276, xmin=54, tail 19 of 166, KS=0.084. vs lognormal: R=-0.76, p=0.444 (inconclusive). vs exponential: R=-0.81, p=0.263 (inconclusive).
The chosen xmin is 54 partners, which leaves only 19 of 166 countries in the tail, and alpha is 4.3. Neither test is conclusive. A tail of 19 points cannot say much about its shape.
All three items¶
The same two fits for wheat, maize and soya beans:
rows = []
for item, g in graphs.items():
for quantity in ("degree", "out_strength"):
f = nv.stats.degree_distribution_fit(g, quantity)
rows.append(
{
"item": item,
"quantity": quantity,
"alpha": f.alpha,
"xmin": f.xmin,
"n": f.n,
"n_tail": f.n_tail,
"KS": f.ks_distance,
"R vs lognormal": f.vs_lognormal.loglikelihood_ratio,
"p vs lognormal": f.vs_lognormal.p_value,
"vs lognormal": f.vs_lognormal.favoured(),
"R vs exponential": f.vs_exponential.loglikelihood_ratio,
"p vs exponential": f.vs_exponential.p_value,
"vs exponential": f.vs_exponential.favoured(),
}
)
table = pd.DataFrame(rows).set_index(["item", "quantity"])
table.round(
{
"alpha": 2,
"xmin": 0,
"KS": 3,
"R vs lognormal": 2,
"p vs lognormal": 3,
"R vs exponential": 2,
"p vs exponential": 3,
}
)
| alpha | xmin | n | n_tail | KS | R vs lognormal | p vs lognormal | vs lognormal | R vs exponential | p vs exponential | vs exponential | ||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| item | quantity | |||||||||||
| Maize (corn) | degree | 5.11 | 87.0 | 177 | 12 | 0.085 | -0.40 | 0.547 | inconclusive | -0.40 | 0.365 | inconclusive |
| out_strength | 1.67 | 524425.0 | 148 | 28 | 0.091 | -0.36 | 0.546 | inconclusive | 15.96 | 0.006 | power_law | |
| Soya beans | degree | 3.08 | 35.0 | 164 | 25 | 0.082 | -0.56 | 0.485 | inconclusive | -0.67 | 0.623 | inconclusive |
| out_strength | 1.51 | 52870.0 | 128 | 33 | 0.069 | -0.13 | 0.733 | inconclusive | 58.51 | 0.000 | power_law | |
| Wheat | degree | 4.28 | 54.0 | 166 | 19 | 0.084 | -0.76 | 0.444 | inconclusive | -0.81 | 0.263 | inconclusive |
| out_strength | 1.63 | 326460.0 | 119 | 39 | 0.117 | -0.48 | 0.537 | inconclusive | 17.32 | 0.013 | power_law |
The pattern is the same for all three commodities:
- Out-strength. The power law beats the exponential in every case (p between 0.000 and 0.013), with
alphabetween 1.51 and 1.67. It never beats the lognormal: every comparison is inconclusive, with p of 0.54 or more. - Degree. Nothing is conclusive. The tails hold 12 to 25 countries, and the fitted exponents (3.1 to 5.1) are steep.
What the tests do and do not show¶
They show that out-strength is heavy-tailed. Rejecting the exponential means that a few exporters account for far more volume than a thin-tailed distribution would allow. That is a real, testable statement about concentration.
They do not show a power law. Beating the exponential is a low bar: any heavy-tailed distribution clears it. The informative comparison is against the lognormal, and there the data cannot decide. A lognormal is as consistent with these tails as a power law.
The fits cover only the tail. The power law is fitted above xmin, which for wheat out-strength leaves 39 of 119 exporters. Nothing is said about the rest.
There is no goodness-of-fit p-value here. Clauset et al. also test whether a power law is plausible at all, by bootstrapping the KS distance. netviz-tools reports the KS distance but does not run that bootstrap, so "the power law is plausible" is not established by these results either.
Degree has a ceiling. A country can trade with at most every other country. On a directed graph degree counts incoming and outgoing edges, so it is bounded by twice the number of other countries. A bounded quantity cannot follow a power law far into its tail:
others = wheat.number_of_nodes() - 1
deg = pd.Series(dict(wheat.degree()))
n_partners = pd.Series(
{c: len(set(wheat.predecessors(c)) | set(wheat.successors(c))) for c in wheat}
)
print(f"degree (in + out): max {deg.max()}, ceiling {2 * others}")
print(
f"distinct partners: max {n_partners.max()} of {others} possible, median {n_partners.median():.0f}"
)
degree (in + out): max 119, ceiling 330 distinct partners: max 83 of 165 possible, median 10
The choice of xmin matters¶
xmin is chosen by KS distance, and the verdicts depend on it. Fixing xmin lower for wheat degree puts more countries in the tail and changes the answer:
rows = []
for x in (5, 10, 20, 30, None):
f = nv.stats.degree_distribution_fit(wheat, "degree", xmin=x)
rows.append(
{
"xmin": f"{f.xmin:g}" + ("" if x is not None else " (chosen by KS)"),
"n_tail": f.n_tail,
"alpha": round(f.alpha, 2),
"KS": round(f.ks_distance, 3),
"vs lognormal": f"R={f.vs_lognormal.loglikelihood_ratio:.1f}, p={f.vs_lognormal.p_value:.3f} ({f.vs_lognormal.favoured()})",
"vs exponential": f"R={f.vs_exponential.loglikelihood_ratio:.1f}, p={f.vs_exponential.p_value:.3f} ({f.vs_exponential.favoured()})",
}
)
pd.DataFrame(rows).set_index("xmin")
| n_tail | alpha | KS | vs lognormal | vs exponential | |
|---|---|---|---|---|---|
| xmin | |||||
| 5 | 120 | 1.72 | 0.166 | R=-17.1, p=0.000 (lognormal) | R=-15.8, p=0.022 (exponential) |
| 10 | 90 | 2.01 | 0.103 | R=-6.6, p=0.024 (lognormal) | R=-6.5, p=0.186 (inconclusive) |
| 20 | 48 | 2.20 | 0.189 | R=-10.3, p=0.003 (lognormal) | R=-9.6, p=0.000 (exponential) |
| 30 | 38 | 2.72 | 0.151 | R=-4.5, p=0.065 (lognormal) | R=-4.4, p=0.007 (exponential) |
| 54 (chosen by KS) | 19 | 4.28 | 0.084 | R=-0.8, p=0.444 (inconclusive) | R=-0.8, p=0.263 (inconclusive) |
With more of the distribution in the fit, the power law loses to the lognormal at every fixed xmin from 5 to 30, and to the exponential at three of the four. The KS-chosen xmin of 54 is where the power law fits best, and even there it wins nothing. The tail that looks most like a power law is the part with too few points to reject one.
A control: data that are lognormal by construction¶
To see what the test can detect, draw samples from a lognormal distribution (so the right answer is known) and fit them the same way with nv.stats.fit_power_law:
rng = np.random.default_rng(0)
rows = []
for n in (100, 1_000, 10_000):
sample = rng.lognormal(mean=10, sigma=2.5, size=n)
f = nv.stats.fit_power_law(sample, discrete=False, quantity="lognormal sample")
rows.append(
{
"sample size": n,
"n_tail": f.n_tail,
"alpha": round(f.alpha, 2),
"vs lognormal": f"R={f.vs_lognormal.loglikelihood_ratio:.2f}, p={f.vs_lognormal.p_value:.3f} ({f.vs_lognormal.favoured()})",
"vs exponential": f"R={f.vs_exponential.loglikelihood_ratio:.1f}, p={f.vs_exponential.p_value:.3f} ({f.vs_exponential.favoured()})",
}
)
pd.DataFrame(rows).set_index("sample size")
| n_tail | alpha | vs lognormal | vs exponential | |
|---|---|---|---|---|
| sample size | ||||
| 100 | 54 | 1.49 | R=-3.71, p=0.083 (lognormal) | R=15.5, p=0.080 (power_law) |
| 1000 | 148 | 1.82 | R=-0.61, p=0.449 (inconclusive) | R=89.8, p=0.000 (power_law) |
| 10000 | 357 | 2.07 | R=-2.37, p=0.229 (inconclusive) | R=136.1, p=0.009 (power_law) |
Even with 10,000 draws from a true lognormal, the power law beats the exponential and the comparison with the lognormal is inconclusive. That is the same pattern as the trade data. So the trade results are what you would expect from lognormal data as much as from power-law data. The honest summary is "heavy-tailed, shape undetermined".
nv.plot.degree_distribution(
nv.stats.fit_power_law(
rng.lognormal(mean=10, sigma=2.5, size=1_000), discrete=False, quantity="lognormal sample"
),
title="1,000 draws from a lognormal, fitted as if it might be a power law",
).show()
Summary¶
- For 2022 wheat, maize and soya beans, exporters' volumes are heavy-tailed: a power-law tail beats an exponential in every case.
- A lognormal tail fits as well as a power law in every case. The data do not support calling these networks scale-free.
- Degree tails are short (12 to 25 countries) and bounded by the number of countries; no comparison is conclusive at the KS-chosen
xmin, and at lowerxminthe lognormal wins. - Synthetic lognormal data give the same pattern, which is why a win over the exponential, or a straight line on a log-log plot, should not be reported as evidence of a power law.
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.