analyze_kuznets_waves

source

analyze_kuznets_waves(
    df,
    inequality='gini_regional',
    development='log_gdp_pc',
    controls=None,
    *,
    entity=None,
    time=None,
    degree=4,
    vcov='hetero',
    n_sample=1000,
    seed=0,
    title=None,
)

Estimate the extended Kuznets curve (“Kuznets waves”) across three panel estimators.

Fits inequality = b_1 g + b_2 g^2 + ... + b_degree g^degree (with g the development variable, used as supplied — pass log GDP per capita) under pooled OLS, the between estimator (one observation per entity, the polynomial formed from the entity means) and the within estimator (two-way entity + time fixed effects). Each estimator is reported as a cumulative-stepwise comparison table — the linear model, then the quadratic, up to the full degree-order polynomial — so the curvature can be read as higher-order terms are added. The relationship is associational, not causal.

Three figures accompany the tables. The first is the raw scatter of inequality against g with the pooled polynomial overlaid. The second (between) and third (within) are partial-residual / component plots: the fitted wave drawn over inequality after the optional controls — and, for the within view, the two-way fixed effects — are partialled out by the Frisch-Waugh-Lovell theorem, so the wave is read on the development axis itself.

Parameters

Name Type Description Default
df pd.DataFrame Panel data frame. required
inequality str Numeric outcome (an inequality measure such as a Gini). Default "gini_regional". 'gini_regional'
development str Numeric development regressor, used as supplied (typically log GDP per capita); its powers g^2 .. g^degree are formed internally. Default "log_gdp_pc". 'log_gdp_pc'
controls Sequence[str] | str | None Optional covariate name(s) partialled out of the between and within figures via the Frisch-Waugh-Lovell theorem. They do not enter the comparison tables, which are pure polynomial buildups. None (default) partials out only the fixed effects (in the within figure). None
entity str | None Panel identifiers. Default to those declared via :func:expdpy.set_panel. None
time str | None Panel identifiers. Default to those declared via :func:expdpy.set_panel. None
degree int Polynomial order in [2, 6] (default 4, the quartic “waves” specification). The tables show degree nested columns; degree=2 recovers the classic inverted-U test. 4
vcov Literal['hetero', 'iid'] Standard-error type for the pooled and between tables: "hetero" (HC1, the default) or "iid". The within table always uses standard errors clustered by entity. Does not change any point estimate. 'hetero'
n_sample int | None Number of points drawn in the raw and within scatters (default 1000); the fitted curves always use every row. None plots every point. The between scatter (one point per entity) is never subsampled. 1000
seed int Seed for the point-subsampling RNG (default 0), for reproducible figures. 0
title str | None Title for the raw figure (the between/within figures get descriptive titles). None

Returns

Name Type Description
KuznetsWavesResult The stacked tidy coefficient frame df; the raw scatter fig and the between / within partial-residual figures fig_between / fig_within; the three comparison tables gt_pooled / gt_between / gt_within; the per-estimator curvature summary; the fitted models (keyed by estimator); and the run metadata (inequality, development, controls, degree, entity, time, n_obs). Use .interpret() / .explain() for plain-language output.

Notes

The classic Kuznets (1955) hypothesis is the quadratic inverted-U; the waves extension raises the development term to the quartic, admitting up to three turning points. By the Frisch-Waugh-Lovell theorem the polynomial coefficients are identical whether the controls (and fixed effects) are included directly or first partialled out of both sides, which is what the component plots exploit: the fitted curve is the within/between polynomial, drawn over the residualized inequality. The between estimator forms the polynomial from each entity’s mean development, so it is the cross-country curve; the within estimator uses two-way fixed effects, so it is identified purely from within-entity, within-year movements.

Examples

The headline Kuznets-waves analysis on the bundled dataset:

import expdpy as ex
from expdpy.data import load_kuznets, load_kuznets_data_def

df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
res = ex.analyze_kuznets_waves(df)
res.gt_within        # the within (two-way FE) comparison table
res.fig_within       # the within partial-residual wave
print(res.interpret())
Across 880 observations, a degree-4 polynomial relates **gini_regional** to **log_gdp_pc** (the extended Kuznets-waves specification). The three estimators read the association at different levels of variation:
- **Pooled OLS** (the raw cross-sectional pattern): the fitted curve shows two turning points (an N or inverted-N shape), peaking near log_gdp_pc = 7.93; its highest-order term is not statistically significant at conventional levels (R² = 0.744).
- **Between (cross-country)** (comparing country averages): the fitted curve shows two turning points (an N or inverted-N shape), peaking near log_gdp_pc = 7.94; its highest-order term is not statistically significant at conventional levels (R² = 0.83).
- **Within (two-way FE)** (within-country variation net of common year effects): the fitted curve shows two turning points (an N or inverted-N shape), peaking near log_gdp_pc = 7.94; its highest-order term is not statistically significant at conventional levels (R² = 0.521).
All three estimators agree on the curvature, so the shape is not an artefact of cross-country versus within-country variation.

_These are associations, not causal effects. A causal reading needs a research design — see `explain('correlation_vs_causation')`._

Partialling covariates out of the between and within waves, with a cubic specification:

import expdpy as ex
from expdpy.data import load_kuznets, load_kuznets_data_def

df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)
ex.analyze_kuznets_waves(
    df, controls=["trade_share", "resource_rents"], degree=3
).fig_between