import numpy as np
import pandas as pd
import expdpy as exanalyze_beta_convergence
Run this page interactively in Google Colab — no install required:
This page is two things at once: an extended user guide for analyze_beta_convergence — what it does, every argument, and everything it returns — and a testing environment that generates synthetic data with known parameters and checks that the function recovers them. If a cell’s assert ever fails, the function is broken.
What is β-convergence?
β-convergence asks whether units that start behind grow faster and so catch up. For each unit \(i\) we regress its average growth rate over a horizon \(T\) on its initial level:
\[ g_i \;=\; \frac{y_{i,\text{end}} - y_{i,\text{start}}}{T} \;=\; \alpha + \beta\, y_{i,\text{start}} + \varepsilon_i . \]
Canonically \(y\) is log GDP per capita, so \(g_i\) is the annualized growth rate and the x-axis is the initial log level. A negative slope \(\beta\) is convergence. The slope maps to a structural speed of convergence and a half-life:
\[ \lambda = -\frac{\ln(1 + \beta\,T)}{T}, \qquad \text{half-life} = \frac{\ln 2}{\lambda}. \]
Unconditional (absolute) convergence uses the initial level alone. Conditional convergence adds controls for each unit’s steady-state determinants and, by the Frisch–Waugh–Lovell theorem, reads the convergence slope off a partial-regression scatter that holds those controls fixed. The variable is used as you supply it — the function never logs anything — so pass log GDP per capita for the income case, or a level for schooling/health.
1. The method in one cell
analyze_beta_convergence(df, var, ...) only needs the variable and the panel ids. Here is absolute convergence of (log) GDP per capita across countries in the bundled gapminder panel:
from expdpy.data import load_gapminder
gap = load_gapminder()
gap["log_gdppc"] = np.log(gap["gdpPercap"]) # we log it ourselves — the function does not
res = ex.analyze_beta_convergence(gap, "log_gdppc", entity="country", time="year")
res.figHover any point to read off the country. The annotation reports the slope β, its standard error, the R², N, and (when there is convergence) the speed λ and half-life.
2. How the function works
Arguments
| argument | what it does | when to change it |
|---|---|---|
var |
the panel variable analysed (used as-is, no log) | pass log GDP per capita for the income case; a level for rates |
controls |
name(s) reduced to their initial-year value and partialled out (FWL) → conditional convergence | when units have different steady states (human capital, institutions) |
entity, time |
the panel ids | omit if declared once via set_panel |
start, end |
first/last year used to build the growth rate (shared horizon T) |
to fix a comparable window; default = earliest/latest year present |
rolling, window |
estimate β on every sliding window of window periods |
window defaults to half the periods; set it to control the smoothing |
min_obs |
minimum units required per cross-section / window | lower it on small panels |
vcov |
"hetero" (HC1, default) or "iid" standard errors |
"iid" for classical SEs; never changes the point estimate |
Conditional convergence (controls)
With controls, the conditional slope is the coefficient on the initial level once the controls’ initial values are partialled out. fig_conditional is the Frisch–Waugh–Lovell partial-regression scatter:
res_c = ex.analyze_beta_convergence(
gap, "log_gdppc", controls=["lifeExp"], entity="country", time="year"
)
res_c.fig_conditionalThe comparison table
gt renders the unconditional and conditional fits side by side — slope, R², N, speed λ and half-life — and summary is the numeric frame behind it:
res_c.gt| β-convergence: log_gdppc | ||
| growth over a 55-period horizon vs. initial level | ||
| Unconditional | Conditional | |
|---|---|---|
| β (initial level) | 0.001266 | -0.007393 |
| Std. error | 0.001427 | 0.001733 |
| R² | 0.008 | 0.319 |
| N | 142 | 142 |
| Speed of convergence (λ) | -0.001224 | 0.00949 |
| Half-life | — | 73.04 |
| β < 0 indicates convergence. Speed λ = -ln(1 + β·T)/T per period; half-life = ln 2 / λ. Conditional partials out the initial-year controls (FWL). | ||
The rolling view
With rolling=True (the default) the function re-estimates β on every fixed-width window and returns the time path in rolling plus the figure fig_rolling:
res.fig_rollingEverything it returns
print("scalars :", {k: round(getattr(res, k), 4) for k in
["beta", "se", "r2", "speed", "half_life", "horizon"]})
print("figures :", [n for n in ("fig", "fig_conditional", "fig_rolling")
if getattr(res, n) is not None])
print("tables : gt, summary", list(res.summary.columns))
res.glance()scalars : {'beta': 0.0013, 'se': 0.0014, 'r2': 0.0084, 'speed': -0.0012, 'half_life': nan, 'horizon': 55.0}
figures : ['fig', 'fig_rolling']
tables : gt, summary ['metric', 'unconditional']
| var | horizon | beta | se | r2 | n_obs | speed | half_life | |
|---|---|---|---|---|---|---|---|---|
| 0 | log_gdppc | 55.0 | 0.001266 | 0.001427 | 0.008425 | 142 | -0.001224 | NaN |
.interpret() reads the result in plain language, and .explain() returns the concept explainer:
print(res_c.interpret())Across 142 units over a 55-period horizon, the average growth rate of **log_gdppc** is positively associated with its initial level (β = 0.00127). Units that start higher tend to grow faster — **divergence** rather than convergence.
Holding lifeExp fixed at their initial values (via the Frisch-Waugh-Lovell theorem), the convergence slope is β = -0.00739 — steeper than the unconditional 0.00127, the pattern of **conditional β-convergence**, a speed of λ = 0.00949 per period (half-life ≈ 73 periods).
Across fixed-width rolling windows the convergence slope moved lower over time (β = 0.00214 in the earliest window to 0.000637 in the latest).
_These are associations, not causal effects. A causal reading needs a research design — see `explain('correlation_vs_causation')`._
3. Does it recover the truth?
The cleanest test uses an AR(1) in logs, \(x_{t+1} = a + \rho\,x_t + \varepsilon\), because its convergence parameters are known exactly: over a horizon \(T\) the slope is \(\beta = (\rho^{T}-1)/T\) and the structural speed is \(\lambda = -\ln\rho\) (independent of the horizon), with half-life \(\ln 2/\lambda\). We simulate it, run the function, and compare.
def ar1_panel(*, n_units=150, n_years=21, rho=0.9, gamma=0.0, corr=0.6,
noise=0.005, seed=0):
"""Annual AR(1) panel x_{t+1}=a+rho*x_t+gamma*z_i+eps; z_i a trait correlated with x_0."""
rng = np.random.default_rng(seed)
a = (1.0 - rho) * 10.0 # steady-state level ~ 10
z = rng.normal(size=n_units) # fixed steady-state determinant
x0 = 10.0 + 2.0 * (corr * z +
np.sqrt(max(0.0, 1 - corr**2)) * rng.normal(size=n_units))
rows = []
for i in range(n_units):
x = float(x0[i])
for t in range(n_years):
rows.append((f"C{i:03d}", t, x, float(z[i])))
x = a + rho * x + gamma * float(z[i]) + rng.normal(0.0, noise)
return pd.DataFrame(rows, columns=["country", "year", "x", "z"])
RHO, T = 0.9, 20
panel = ar1_panel(rho=RHO, seed=1)
fit = ex.analyze_beta_convergence(panel, "x", entity="country", time="year")
beta_true = (RHO**T - 1) / T
speed_true = -np.log(RHO)
half_true = np.log(2) / speed_true
check = pd.DataFrame(
{
"quantity": ["β (slope)", "speed λ", "half-life"],
"true": [beta_true, speed_true, half_true],
"recovered": [fit.beta, fit.speed, fit.half_life],
}
)
check["abs_error"] = (check["recovered"] - check["true"]).abs()
check| quantity | true | recovered | abs_error | |
|---|---|---|---|---|
| 0 | β (slope) | -0.043921 | -0.043931 | 0.000009 |
| 1 | speed λ | 0.105361 | 0.105438 | 0.000078 |
| 2 | half-life | 6.578813 | 6.573953 | 0.004860 |
# The function recovers the AR(1) truth to within tight tolerances.
assert abs(fit.beta - beta_true) < 2e-3
assert abs(fit.speed - speed_true) < 5e-3
assert abs(fit.half_life - half_true) < 0.3
print("✅ unconditional β, speed and half-life recovered")✅ unconditional β, speed and half-life recovered
Conditional convergence removes omitted-variable bias
Now let a fixed determinant z (correlated with the initial level) shift each unit’s steady state. Omitting z biases the unconditional slope; conditioning on it recovers the truth.
panel_c = ar1_panel(rho=RHO, gamma=0.6, corr=0.7, seed=2)
fit_c = ex.analyze_beta_convergence(
panel_c, "x", controls=["z"], entity="country", time="year"
)
print(f"unconditional β : {fit_c.beta:+.4f} (biased — omits z)")
print(f"conditional β : {fit_c.beta_cond:+.4f} (recovers true {beta_true:+.4f})")
assert abs(fit_c.beta_cond - beta_true) < 4e-3 # conditional ≈ truth
assert abs(fit_c.beta - beta_true) > abs(fit_c.beta_cond - beta_true) # uncond. biased
print("✅ conditional convergence recovers the true slope; unconditional is biased")unconditional β : +0.0380 (biased — omits z)
conditional β : -0.0439 (recovers true -0.0439)
✅ conditional convergence recovers the true slope; unconditional is biased
Rolling windows recover each window’s slope
For a fixed-width window of w periods the true slope is \((\rho^{w}-1)/w\). Every window should match it, and the implied speed should equal \(-\ln\rho\) in every window.
roll = ex.analyze_beta_convergence(
panel, "x", entity="country", time="year", window=4
).rolling
roll = roll.assign(
beta_true=lambda d: (RHO ** d["horizon"] - 1) / d["horizon"],
speed_true=speed_true,
)
for _, r in roll.iterrows():
assert abs(r["beta"] - r["beta_true"]) < 3e-3
assert abs(r["speed"] - r["speed_true"]) < 1e-2
print(f"✅ all {len(roll)} rolling windows match (rho^w - 1)/w and speed -ln rho")
roll[["window_start", "window_end", "beta", "beta_true", "speed"]].round(4)✅ all 17 rolling windows match (rho^w - 1)/w and speed -ln rho
| window_start | window_end | beta | beta_true | speed | |
|---|---|---|---|---|---|
| 0 | 0.0 | 4.0 | -0.0861 | -0.086 | 0.1055 |
| 1 | 1.0 | 5.0 | -0.0860 | -0.086 | 0.1055 |
| 2 | 2.0 | 6.0 | -0.0861 | -0.086 | 0.1055 |
| 3 | 3.0 | 7.0 | -0.0859 | -0.086 | 0.1053 |
| 4 | 4.0 | 8.0 | -0.0859 | -0.086 | 0.1053 |
| 5 | 5.0 | 9.0 | -0.0859 | -0.086 | 0.1052 |
| 6 | 6.0 | 10.0 | -0.0860 | -0.086 | 0.1054 |
| 7 | 7.0 | 11.0 | -0.0860 | -0.086 | 0.1055 |
| 8 | 8.0 | 12.0 | -0.0860 | -0.086 | 0.1054 |
| 9 | 9.0 | 13.0 | -0.0860 | -0.086 | 0.1054 |
| 10 | 10.0 | 14.0 | -0.0857 | -0.086 | 0.1049 |
| 11 | 11.0 | 15.0 | -0.0860 | -0.086 | 0.1054 |
| 12 | 12.0 | 16.0 | -0.0861 | -0.086 | 0.1055 |
| 13 | 13.0 | 17.0 | -0.0862 | -0.086 | 0.1057 |
| 14 | 14.0 | 18.0 | -0.0865 | -0.086 | 0.1061 |
| 15 | 15.0 | 19.0 | -0.0860 | -0.086 | 0.1054 |
| 16 | 16.0 | 20.0 | -0.0860 | -0.086 | 0.1054 |
4. Convergence across countries (gapminder)
Back to real data. Across all 142 countries from 1952 to 2007 there is essentially no absolute convergence — poor and rich countries grew at similar rates (the classic “convergence controversy”):
print(res.interpret())Across 142 units over a 55-period horizon, the average growth rate of **log_gdppc** is positively associated with its initial level (β = 0.00127). Units that start higher tend to grow faster — **divergence** rather than convergence.
Across fixed-width rolling windows the convergence slope moved lower over time (β = 0.00214 in the earliest window to 0.000637 in the latest).
_These are associations, not causal effects. A causal reading needs a research design — see `explain('correlation_vs_causation')`._
But once we condition on a steady-state determinant — here initial life expectancy, a proxy for human capital and health — conditional convergence appears: the slope turns negative and significant, implying catch-up relative to each country’s own steady state.
res_c.gt| β-convergence: log_gdppc | ||
| growth over a 55-period horizon vs. initial level | ||
| Unconditional | Conditional | |
|---|---|---|
| β (initial level) | 0.001266 | -0.007393 |
| Std. error | 0.001427 | 0.001733 |
| R² | 0.008 | 0.319 |
| N | 142 | 142 |
| Speed of convergence (λ) | -0.001224 | 0.00949 |
| Half-life | — | 73.04 |
| β < 0 indicates convergence. Speed λ = -ln(1 + β·T)/T per period; half-life = ln 2 / λ. Conditional partials out the initial-year controls (FWL). | ||
print(res_c.interpret())Across 142 units over a 55-period horizon, the average growth rate of **log_gdppc** is positively associated with its initial level (β = 0.00127). Units that start higher tend to grow faster — **divergence** rather than convergence.
Holding lifeExp fixed at their initial values (via the Frisch-Waugh-Lovell theorem), the convergence slope is β = -0.00739 — steeper than the unconditional 0.00127, the pattern of **conditional β-convergence**, a speed of λ = 0.00949 per period (half-life ≈ 73 periods).
Across fixed-width rolling windows the convergence slope moved lower over time (β = 0.00214 in the earliest window to 0.000637 in the latest).
_These are associations, not causal effects. A causal reading needs a research design — see `explain('correlation_vs_causation')`._
The takeaway is the textbook one (Barro & Sala-i-Martin; Mankiw–Romer–Weil): absolute convergence fails across heterogeneous economies, while conditional convergence holds.
See also
ex.learn_beta_convergence()— a runnable Learn sandbox that demonstrates the unconditional-vs-conditional distinction on a known-parameter panel.ex.explain("beta_convergence")— the concept explainer (alsores.explain()).
ex.explain("beta_convergence")Beta convergence
What it is. β-convergence asks whether units that start behind grow faster and so catch up. The test regresses each unit’s average growth rate over a horizon on its initial level — canonically the growth of GDP per capita on initial log GDP per capita. A negative slope β is convergence: lower starting points are associated with faster growth. The slope maps to a structural speed of convergence λ = -ln(1 + β·T) / T (per period) and a half-life ln 2 / λ, the time to close half of an initial gap. Unconditional (absolute) convergence uses the initial level alone; conditional convergence adds controls for each unit’s steady-state determinants and, by the Frisch-Waugh-Lovell theorem, reads the convergence slope from a partial-regression scatter that holds those controls fixed. The same machinery works for any variable — income, schooling, health.
When to use it. Use it to summarise catch-up dynamics in a panel: are poorer economies (or lower-scoring regions/firms) closing the gap, and how fast? Reach for unconditional convergence to describe raw catch-up, and conditional convergence when units have different steady states (different savings, human capital, institutions) so that catch-up is only expected relative to each unit’s own steady state. A rolling-window version shows whether the convergence speed has itself changed over time.
Watch out for. - β-convergence is a descriptive association between growth and an initial level, not a causal mechanism; regression to the mean and measurement error in the initial level can both produce a negative slope (Galton’s fallacy / Quah’s critique). - The estimate depends on the chosen start and end years and the horizon T — report them, and prefer a common window across units when comparing. - Conditional convergence is conditional on the controls you include; a different control set implies a different steady state and can change the slope. - Speed and half-life are only well defined when 1 + β·T > 0; a non-negative slope (divergence) has no finite positive half-life.
See also: fwl, fixed_effects, correlation_vs_causation
References: Barro & Sala-i-Martin, Economic Growth (2nd ed.), ch. 11-12; Sala-i-Martin (1996), ‘The Classical Approach to Convergence Analysis’, EJ