# expdpy - full reference for LLMs > expdpy (v0.5.2) is a Python library for interactive panel and cross-sectional data analysis - a port of the R package ExPanDaR - organized as Explore / Analyze / Learn modules. expdpy reports statistical associations, not proof of causation. Describe results with associational language ("is associated with", "co-moves with"), not causal language. Panel data uses entity (unit) + time vocabulary; declare it once with set_panel(entity=, time=). Every result exposes interpret() for a plain-language reading and explain(topic) for method caveats; prefer them over ad-hoc narration. expdpy — Explore, Analyze and Learn panel data. A Python port of the ExPanDaR R package (Joachim Gassen, TRR 266), organized around three conceptual modules: * **Explore** — exploratory analysis of panel and cross-sectional data: descriptive and correlation tables, distributions, time trends, group comparisons, missing-value maps and scatter plots, returning interactive Plotly figures and Great Tables output. * **Analyze** — panel estimators: OLS / fixed effects, random effects, correlated random effects (Mundlak), the Frisch-Waugh-Lovell plot, pooled / between models, the Hausman test, post-estimation, robust inference and event-study / difference-in-differences. * **Learn** — a teaching layer: concept explainers (``explain`` / ``list_topics``), plain-language ``.interpret()`` on every result, and runnable concept sandboxes. * **Utilities** — shared helpers used across modules: ``set_panel`` / ``resolve_panel`` (declare the panel once), ``set_labels`` / ``resolve_label`` (declare human-readable variable labels once), ``set_roles`` (mark the main outcome / covariates so figures and the apps default to them), ``build_data_def`` (infer a data dictionary from a raw frame), ``treat_outliers`` (winsorize/truncate), and the concept-explainer registry entry points (``explain`` / ``list_topics``). Three no-code ``ExPdPy`` apps (one per module) build on the same functions — see :mod:`expdpy.streamlit_app`. ## Explore Exploratory analysis — descriptive / correlation / extreme-observation tables, distributions, time trends, group comparisons, missing-value maps, scatter plots, within/between variation, per-unit trajectories, panel-structure diagnostics, distribution & transition dynamics, and animated composition (treemap / sunburst). ### explore_descriptive_table(df[, stats, digits, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_descriptive_table.html) Report descriptive statistics for the numeric/logical variables of ``df``. The table reflects the panel structure of the data. When a ``time`` column is known (declared via :func:`expdpy.set_panel` / :func:`expdpy.set_labels`, or passed explicitly), each statistic is shown **by period** — by default at the first and last period — under a spanning column header (e.g. ``Mean`` over ``2015`` and ``2025``). Without a time dimension the table falls back to a single column per statistic. Rows are labelled from the data dictionary when available, and the number of observations and any variable with missing data are reported in the notes beneath the table. Parameters ---------- df Data frame containing at least one numeric/logical variable and two observations. stats Statistics to display, in order, chosen from ``N``, ``Mean``, ``Std. dev.``, ``Min.``, ``25 %``, ``Median``, ``75 %``, ``Max.``. Defaults to ``Mean``, ``Std. dev.``, ``Median``, ``Min.``, ``Max.``. (The returned ``.df`` always carries all eight statistics regardless of this selection.) digits Number of decimals for the displayed statistics: a single ``int`` applied to all, or a ``{statistic: decimals}`` mapping for per-statistic overrides (``N`` is always shown as an integer). periods Periods to show as sub-columns in the by-period layout. ``None`` (default) shows the first and last period; otherwise the listed period values (those not present are dropped with a warning). Ignored when no time dimension is summarized. entity, time Optional panel identifiers (defaulting to those declared via :func:`expdpy.set_panel`). A resolved ``time`` drives the by-period layout; when both resolve, a note also reports the panel dimensions. For the within/between split of each variable see :func:`expdpy.explore_xtsum_table`. caption Table title used for the Great Tables header. Returns ------- DescriptiveTableResult ``df`` (the pooled eight-statistic summary), ``gt`` (a Great Tables object) and ``by_period`` (a tidy ``variable``-by-``period`` frame, or ``None``). Examples -------- Basic — declare the panel via the data dictionary, then summarize by period (first and last year) with labelled rows: ```python 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.explore_descriptive_table(df).gt ``` Advanced — choose the statistics and decimals, pick specific periods, and read the tidy by-period frame back from ``.by_period``: ```python 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) result = ex.explore_descriptive_table( df, stats=("Mean", "Median", "Std. dev."), digits=2, periods=[2015, 2020, 2025], caption="Kuznets panel", ) result.gt result.by_period.head() ``` ### explore_correlation_table(df[, digits, bold, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_correlation_table.html) Correlation table with Pearson above and Spearman below the diagonal. Parameters ---------- df Data frame with at least two numeric/logical variables and five observations. digits Number of decimals to display (``0 < digits < 5``). bold Correlations with a p-value below ``bold`` are shown in bold. Set to ``0`` to disable. Must satisfy ``0 <= bold < 1``. caption Optional table title. Returns ------- CorrelationTableResult ``df_corr``/``df_prob``/``df_n`` (numeric matrices keyed by the original variable names) and ``gt`` (a Great Tables object using letter labels). Examples -------- Basic — Pearson (above) and Spearman (below) correlations for a few variables (slice the columns first, then label so the data dictionary supplies row names): ```python import expdpy as ex from expdpy.data import load_kuznets, load_kuznets_data_def df = ex.set_labels( load_kuznets()[["gini_regional", "gdp_pc", "log_gdp_pc"]], load_kuznets_data_def(), ) ex.explore_correlation_table(df).gt ``` Advanced — more decimals, a stricter bold threshold, a caption, and the raw coefficient/p-value matrices from ``.df_corr`` / ``.df_prob``: ```python import expdpy as ex from expdpy.data import load_kuznets, load_kuznets_data_def df = ex.set_labels( load_kuznets()[["gini_regional", "gdp_pc", "log_gdp_pc", "trade_share"]], load_kuznets_data_def(), ) result = ex.explore_correlation_table( df, digits=3, bold=0.01, caption="Correlations (kuznets)", ) result.gt result.df_corr result.df_prob ``` ### explore_ext_obs_table(df[, n, var, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_ext_obs_table.html) Display the top and bottom ``n`` observations sorted by ``var``. Parameters ---------- df Data frame. n Number of extreme observations on each side (``2 * n <= len(df)``). var Variable to sort by (must be numeric). Defaults to the last numeric column that is not an identifier. entity Cross-sectional identifier column(s). Defaults to the panel ``entity`` declared via :func:`expdpy.set_panel`. If both ``entity`` and ``time`` are unset (and none is declared), all variables are tabulated; otherwise only the identifiers and ``var``. time Time identifier column. Defaults to the panel ``time``. digits Number of decimals for numeric cells. Returns ------- ExtObsTableResult ``df`` (the ``2 * n`` extreme rows) and ``gt`` (a Great Tables object grouping the highest and lowest blocks). Examples -------- Basic — the five highest and lowest observations (sorted by the last numeric column), tabulating all variables: ```python 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.explore_ext_obs_table(df, n=5).gt ``` Advanced — the ten most extreme observations of a chosen variable, showing only the panel identifiers and that variable (the panel is taken from the declared ``entity``/``time``): ```python 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.explore_ext_obs_table(df, n=10, var="gini_regional").gt ``` ### explore_histogram(df[, var, bins, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_histogram.html) Histogram of a numeric variable, optionally with density overlays. Parameters ---------- df Data frame containing ``var``. var Numeric column to bin. Defaults to the declared main outcome (:func:`expdpy.set_roles`) when omitted. bins Number of equal-width bins. kde Overlay a Gaussian kernel-density estimate of the distribution. normal Overlay a normal curve with the sample mean and standard deviation. Returns ------- HistogramResult ``df`` (columns ``bin_left``, ``bin_right``, ``count``) and the Plotly ``fig``. Notes ----- The density overlays are drawn on the **Density** scale, so requesting either one opens the figure in Density view; the built-in Count/Density toggle hides the overlays in Count view and shows them in Density view. Examples -------- Basic — a 30-bin histogram of a numeric variable, with readable labels from the data dictionary: ```python 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.explore_histogram(df, "gini_regional").fig ``` Advanced — overlay a kernel-density estimate and a normal curve: ```python 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.explore_histogram(df, "log_gdp_pc", bins=40, kde=True, normal=True).fig ``` ### explore_bar_plot(df, var[, order_by_count, color, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_bar_plot.html) Bar chart of category counts for a (typically categorical) variable. Parameters ---------- df Data frame containing ``var``. var Column whose value counts are charted. order_by_count If ``True``, bars are ordered by descending count; otherwise by category. color Bar fill color. Defaults to the primary theme color. Returns ------- BarChartResult ``df`` (columns ``var`` and ``count``) and the Plotly ``fig``. Examples -------- Basic — category counts of a (typically categorical) variable, with readable labels from the data dictionary: ```python 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.explore_bar_plot(df, "continent").fig ``` Advanced — order bars by descending count and set a custom color: ```python 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.explore_bar_plot(df, "continent", order_by_count=True, color="red").fig ``` ### explore_correlation_plot(df[, style, title, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_correlation_plot.html) Visualise a correlation matrix (Pearson above, Spearman below the diagonal). Parameters ---------- df Data frame with at least two numeric/logical variables and five observations. style ``"heatmap"`` (default) renders a Plotly heatmap; ``"ellipse"`` reproduces R's ``corrplot(method = "ellipse")`` look with one ellipse glyph per cell. Returns ------- CorrelationGraphResult ``df_corr``/``df_prob``/``df_n`` plus the Plotly ``fig``. Examples -------- Basic — a correlation heatmap for a few variables (slice the columns first, then attach labels so the axes read nicely): ```python import expdpy as ex from expdpy.data import load_kuznets, load_kuznets_data_def df = ex.set_labels( load_kuznets()[["gini_regional", "gdp_pc", "log_gdp_pc"]], load_kuznets_data_def(), ) ex.explore_correlation_plot(df).fig ``` Advanced — the ellipse style (R ``corrplot`` look), with the underlying correlation matrix available from ``.df_corr``: ```python import expdpy as ex from expdpy.data import load_kuznets, load_kuznets_data_def df = ex.set_labels( load_kuznets()[["gini_regional", "gdp_pc", "log_gdp_pc", "trade_share"]], load_kuznets_data_def(), ) result = ex.explore_correlation_plot(df, style="ellipse") result.fig result.df_corr ``` ### explore_trend_plot(df[, var, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_trend_plot.html) Line-plot the mean (with standard-error bars) of variables over time. Parameters ---------- df Data frame containing ``time`` and the numeric variables to plot. var Variables to plot. Defaults to all numeric columns other than ``time``/``entity``. time Column name of the time identifier. Defaults to the panel ``time`` declared via :func:`expdpy.set_panel`. entity Column name of the cross-sectional (unit) identifier. Only used when ``spaghetti=True``. Defaults to the panel ``entity`` declared via :func:`expdpy.set_panel`. spaghetti If ``True`` (and a single ``var`` and an ``entity`` are available), draw a faint per-unit trajectory backdrop behind the mean line. For richer per-unit views see :func:`expdpy.explore_spaghetti_plot`. Returns ------- TrendGraphResult ``df`` (columns ``variable``, ``time``, ``mean``, ``se``) and the Plotly ``fig``. Examples -------- Basic — mean of a single variable over time, with standard-error bars: ```python 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.explore_trend_plot(df, var=["log_gdp_pc"]).fig ``` Advanced — several variables on one chart, with the aggregated frame from ``.df``: ```python 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) result = ex.explore_trend_plot(df, var=["log_gdp_pc", "trade_share"]) result.fig result.df.head() ``` ### explore_quantile_trend_plot(df[, quantiles, var, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_quantile_trend_plot.html) Line-plot quantiles of a single variable over time. Parameters ---------- df Data frame containing ``time`` and the variable to plot. quantiles Quantile levels to plot (each in ``(0, 1)``). var Variable to plot. Defaults to the last numeric column that is not ``time``. time Column name of the time identifier. Defaults to the panel ``time`` declared via :func:`expdpy.set_panel`. points Whether to mark each observation with a point. Returns ------- QuantileTrendGraphResult ``df`` (long format: ``time``, ``quantile``, value) and the Plotly ``fig``. Examples -------- Basic — the default quantiles of a variable over time: ```python 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.explore_quantile_trend_plot(df, var="log_gdp_pc").fig ``` Advanced — custom quantile levels and no per-observation points: ```python 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.explore_quantile_trend_plot( df, quantiles=(0.1, 0.5, 0.9), var="log_gdp_pc", points=False ).fig ``` ### explore_bar_plot_by_group(df, by_var, var[, stat_fun, order_by_stat, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_bar_plot_by_group.html) Bar chart of a statistic of ``var`` computed within each ``by_var`` group. Parameters ---------- df Data frame containing the grouping factor and the numeric variable. by_var Grouping column. var Numeric column to summarise. stat_fun Statistic applied to the non-missing values of each group. Defaults to :func:`numpy.nanmean`. Missing values are dropped before the call, matching R's ``na.rm = TRUE``. order_by_stat If ``True``, bars are ordered by the statistic (largest at the top); otherwise the groups keep their order of appearance. color Bar fill color. Defaults to the primary theme color. Returns ------- ByGroupBarGraphResult ``df`` (columns ``by_var`` and ``stat_``) and the Plotly ``fig``. Examples -------- Basic — mean of a variable within each group: ```python 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.explore_bar_plot_by_group(df, "continent", "gini_regional").fig ``` Advanced — a different statistic, bars ordered by it, a custom color, and the per-group values from ``.df``: ```python import numpy as np 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) result = ex.explore_bar_plot_by_group( df, "continent", "gini_regional", stat_fun=np.nanmedian, order_by_stat=True, color="#4682b4", ) result.fig result.df ``` ### explore_trend_plot_by_group(df, group_var, var[, time, points, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_trend_plot_by_group.html) Line-plot the mean of ``var`` over time, one line per ``group_var`` level. Parameters ---------- df Data frame containing the time index, grouping factor and variable. group_var Grouping column. var Numeric variable to plot. time Time identifier column. Defaults to the panel ``time`` declared via :func:`expdpy.set_panel`. points Whether to mark each observation with a point. error_bars Whether to draw standard-error bars (``mean +/- se``). Returns ------- ByGroupTrendGraphResult ``df`` (columns ``time``, ``group_var``, ``mean``, ``se``) and the Plotly ``fig``. Examples -------- Basic — one line per group, tracking a variable's mean over time: ```python 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.explore_trend_plot_by_group( df, group_var="continent", var="gini_regional" ).fig ``` Advanced — add standard-error bars and drop the per-observation markers: ```python 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.explore_trend_plot_by_group( df, group_var="continent", var="gini_regional", error_bars=True, points=False, ).fig ``` ### explore_violin_plot_by_group(df, by_var, var[, order_by_mean, group_on_y, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_violin_plot_by_group.html) Violin plots of ``var`` distribution across ``by_var`` groups. Parameters ---------- df Data frame containing the grouping factor and the numeric variable. by_var Grouping column. var Numeric variable whose distribution is shown. order_by_mean If ``True``, groups are ordered by descending group mean. group_on_y If ``True`` (default), violins are oriented horizontally (groups on the y-axis). Returns ------- ByGroupViolinResult ``df`` (the complete-case ``[by_var, var]`` frame) and the Plotly ``fig``. Examples -------- Basic — distribution of a variable across groups: ```python 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.explore_violin_plot_by_group(df, "continent", "gini_regional").fig ``` Advanced — order groups by their mean and orient the violins vertically: ```python 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.explore_violin_plot_by_group( df, "continent", "gini_regional", order_by_mean=True, group_on_y=False ).fig ``` ### explore_box_plot(df, by_var, var[, time, order_by_mean, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_box_plot.html) Box plots of ``var`` across ``by_var`` groups, animated over ``time`` when available. One box per group summarises the distribution (median, quartiles, whiskers). On a panel each period becomes a frame, stepped through by a play button and a time slider, with the value axis pinned so shifts are comparable; without a resolvable time id a single static set of boxes is drawn. Parameters ---------- df Data frame containing the grouping factor, the numeric variable and (optionally) a time column. by_var Grouping column. var Numeric variable whose distribution is shown. time Time identifier that drives the animation. Defaults to the panel ``time`` declared via :func:`expdpy.set_panel`; when neither is available the boxes are static. order_by_mean If ``True``, groups are ordered by descending group mean. group_on_y If ``True`` (default), boxes are horizontal (groups on the y-axis). log Put the value axis on a log scale (default ``False``). points Which underlying points to overlay: ``"outliers"`` (default), ``"all"``, ``"suspectedoutliers"`` or ``False`` for none. entity Cross-sectional id (accepted for panel parity; only used to resolve the panel ``time``). Returns ------- BoxPlotResult ``df`` (the complete-case frame plotted) and ``fig`` (the Plotly box plot). Examples -------- Regional inequality by continent, animated across the years: ```python 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.explore_box_plot(df, "continent", "gini_regional", time="year").fig ``` ### explore_strip_plot(df, by_var, var[, time, order_by_mean, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_strip_plot.html) Strip plots of ``var`` across ``by_var`` groups, animated over ``time`` when available. A jittered cloud of every observation within each group — complementary to :func:`explore_box_plot`, showing the raw points rather than a summary. On a panel each period becomes a frame with the value axis pinned; without a resolvable time id the clouds are static. Large samples are thinned to ``max_units`` points so the animation stays responsive. Parameters ---------- df Data frame containing the grouping factor, the numeric variable and (optionally) a time column. by_var Grouping column. var Numeric variable whose observations are shown. time Time identifier that drives the animation. Defaults to the panel ``time``; when neither is available the clouds are static. order_by_mean If ``True``, groups are ordered by descending group mean. group_on_y If ``True`` (default), groups are on the y-axis. log Put the value axis on a log scale (default ``False``). alpha Point opacity (default ``0.6``). max_units Cap on the number of plotted points; above it a seeded random sample is drawn and an :class:`expdpy.ExpdpyWarning` is emitted. ``None`` disables sampling. sample_seed Seed for the point sample. entity Cross-sectional id (accepted for panel parity; only used to resolve the panel ``time``). Returns ------- StripPlotResult ``df`` (the complete-case frame plotted) and ``fig`` (the Plotly strip plot). Examples -------- Every region's inequality by continent, animated across the years: ```python 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.explore_strip_plot(df, "continent", "gini_regional", time="year").fig ``` ### explore_missing_values_plot(df[, time, entity, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_missing_values_plot.html) Heatmap of missing-value frequency by variable and panel dimension. Parameters ---------- df Data frame containing the data. time Time identifier column. Defaults to the panel ``time`` declared via :func:`expdpy.set_panel`. Required when ``by="time"``; must not contain missing values. entity Cross-sectional (unit) identifier column. Defaults to the panel ``entity``. Required when ``by="entity"``; must not contain missing values. by Whether to aggregate missingness over ``"time"`` periods (the default) or over ``"entity"`` units. no_factors If ``True``, limit the plot to numeric/logical variables. binary If ``True``, show only whether values are missing (any) rather than the fraction. Returns ------- MissingValuesResult ``df`` (the missingness matrix, rows = levels, columns = variables) and ``fig``. Examples -------- Basic — fraction of missing values by variable and year: ```python 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.explore_missing_values_plot(df).fig ``` Advanced — missingness by unit, restricted to numeric variables, shown as a flag: ```python 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.explore_missing_values_plot( df, by="entity", no_factors=True, binary=True ).fig ``` ### explore_scatter_plot(df[, x, y, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_scatter_plot.html) Scatter plot of ``y`` against ``x`` with optional aesthetics and a LOESS smoother. Parameters ---------- df Data frame containing the variables. x, y Column names for the axes (both must be numeric). When omitted, they default to the declared roles (:func:`expdpy.set_roles`): ``x`` to the first covariate and ``y`` to the main outcome. color Optional column mapped to marker color (numeric -> colorbar, otherwise discrete). size Optional numeric column mapped to marker size. loess ``0`` no smoother, ``1`` unweighted LOESS, ``2`` LOESS weighted by ``size``. alpha Marker opacity. If ``None``, a sample-size-based default is used. entity Cross-sectional (unit) identifier. Defaults to the panel ``entity`` declared via :func:`expdpy.set_panel`. Only used when ``connect=True``. time Time identifier used to order each unit's trajectory when ``connect=True``. Defaults to the panel ``time``. connect If ``True`` (and an ``entity`` is available), draw a faint line connecting each unit's points in time order — turning the scatter into a panel trajectory plot. Returns ------- ScatterPlotResult ``df`` (the complete-case frame plotted) and ``fig`` (the Plotly scatter). Examples -------- Basic — a plain scatter of two variables: ```python 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.explore_scatter_plot(df, x="log_gdp_pc", y="gini_regional").fig ``` Advanced — map color and marker size to other columns, add a size-weighted LOESS smoother (the N-shaped Kuznets curve) and tune opacity: ```python 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.explore_scatter_plot( df, x="log_gdp_pc", y="gini_regional", color="continent", size="population", loess=2, alpha=0.6, ).fig ``` ### explore_animated_scatter_plot(df, x, y[, size, color, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_animated_scatter_plot.html) Animated bubble scatter of ``y`` against ``x`` over time (a Gapminder-style view). Each period becomes a frame; a play button and slider step through them. Bubbles are sized by ``size`` and colored by ``color`` (discrete → one series per level with a legend; numeric → a colorbar). Axis ranges and the bubble size scale are fixed across all frames so movement is comparable from period to period. Parameters ---------- df Data frame containing the variables (a panel with a time identifier). x, y Column names for the axes (both must be numeric). size Optional numeric column mapped to bubble area. color Optional column mapped to bubble color (numeric → colorbar, otherwise discrete series). entity Cross-sectional (unit) identifier, used for hover labels. Defaults to the panel ``entity`` declared via :func:`expdpy.set_panel`. time Time identifier that drives the animation frames. Defaults to the panel ``time``; an explicit value or a declared panel is required. alpha Bubble opacity (default ``0.8``). log_x, log_y Put the corresponding axis on a log scale (default ``False``). Handy for heavy-tailed quantities such as GDP per capita — the canonical Gapminder x-axis. title Optional figure title. Returns ------- AnimatedScatterResult ``df`` (the complete-case frame plotted) and ``fig`` (the animated Plotly figure). Raises ------ ValueError If no time identifier can be resolved, or ``x`` / ``y`` are non-numeric. Examples -------- The classic Gapminder animation — income on the x-axis, life expectancy on the y-axis, bubbles sized by population and colored by continent, moving across the years: ```python import expdpy as ex from expdpy.data import load_gapminder, load_gapminder_data_def df = ex.set_labels(load_gapminder(), load_gapminder_data_def(), set_panel=True) ex.explore_animated_scatter_plot( df, x="gdpPercap", y="lifeExp", size="pop", color="continent" ).fig ``` ### explore_xtsum_table(df[, var, entity, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_xtsum_table.html) Decompose each variable's variation into overall / between / within (Stata ``xtsum``). For every numeric variable the table reports the overall mean and standard deviation, the **between** standard deviation (across unit means), and the **within** standard deviation (variation over time inside a unit), plus the number of observations, units and the average number of periods per unit. Parameters ---------- df Panel data frame. var Variables to summarise. Defaults to all numeric/logical columns except the identifiers. entity Cross-sectional (unit) identifier. Defaults to the panel ``entity`` declared via :func:`expdpy.set_panel`. time Time identifier (used only to exclude it from the default ``var`` list). Defaults to the panel ``time``. digits Decimals for the formatted statistics. caption Great Tables header title. Returns ------- XtsumTableResult ``df`` (long within/between frame) and ``gt`` (the Great Tables object). Examples -------- ```python 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.explore_xtsum_table(df, var=["gini_regional", "log_gdp_pc"]).gt ``` ### explore_scatter_plot_within_between(df[, x, y, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_scatter_plot_within_between.html) Scatter that decomposes the ``x``-``y`` relationship into between and within parts. Three views are drawn (switchable via a dropdown): the **pooled** cloud, the **between** cloud of unit means, and the **within** cloud of unit-demeaned deviations (recentered on the grand means). Their fitted slopes show how a pooled association blends a cross-unit and an over-time relationship. Parameters ---------- df Panel data frame. x, y Numeric column names for the axes. When omitted, they default to the declared roles (:func:`expdpy.set_roles`): ``x`` to the first covariate and ``y`` to the main outcome. entity Cross-sectional (unit) identifier. Defaults to the panel ``entity``. time Time identifier (carried into the hover data). Defaults to the panel ``time``. show Which view is visible initially: ``"overlay"`` (all), ``"pooled"``, ``"between"`` or ``"within"``. alpha Marker opacity for the pooled/within clouds. Defaults to a sample-size-based value. Returns ------- WithinBetweenScatterResult ``df`` (long plotted frame), ``fig`` and the three slopes ``slope_pooled`` / ``slope_between`` / ``slope_within``. Examples -------- ```python 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.explore_scatter_plot_within_between( df, x="log_gdp_pc", y="gini_regional" ).fig ``` ### explore_spaghetti_plot(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_spaghetti_plot.html) Plot every unit's trajectory of ``var`` over time, with a central-tendency overlay. Parameters ---------- df Panel data frame. var Numeric variable to plot. entity Cross-sectional (unit) identifier. Defaults to the panel ``entity``. time Time identifier. Defaults to the panel ``time``. overlay Bold overlay line: ``"mean"`` (default), ``"median"`` or ``"none"``. highlight Units to draw in saturated colour on top of the faint backdrop. alpha Opacity of the faint per-unit lines. Defaults to a sample-size-based value. max_units Cap on the number of units drawn; above it a seeded sample is shown (highlighted units are always kept) and a warning reports how many were dropped. ``None`` draws all units. facet Optional grouping column; when given, draws one small-multiple panel per level. sample_seed Seed for the unit subsample when ``max_units`` is exceeded. Returns ------- SpaghettiGraphResult ``df`` (plotted long frame), ``fig``, ``n_units`` (in the data) and ``n_shown``. Examples -------- ```python 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.explore_spaghetti_plot(df, var="log_gdp_pc").fig ``` ### explore_panel_structure(df[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_panel_structure.html) Summarise the panel's balance and coverage, with a unit-by-period presence grid. A general panel-completeness diagnostic. (For *treatment* structure on a staggered-adoption design, see :func:`expdpy.analyze_panel_view`.) Parameters ---------- df Panel data frame. entity Cross-sectional (unit) identifier. Defaults to the panel ``entity``. time Time identifier. Defaults to the panel ``time``. var Optional variable: when given, a cell counts as "present" only if ``var`` is non-missing there (rather than merely a row existing). max_units Cap on the number of units drawn in the presence grid (evenly sampled above it). caption Great Tables header for the summary. Returns ------- PanelStructureResult ``df_summary`` (tidy statistics), ``df_grid`` (full presence matrix), ``gt`` and the presence-grid ``fig``. Examples -------- ```python 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.explore_panel_structure(df) res.gt res.fig ``` ### explore_value_heatmap(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_value_heatmap.html) Heatmap of a variable over the unit-by-time grid (units by periods, colour = value). Parameters ---------- df Panel data frame. var Numeric variable to display. entity Cross-sectional (unit) identifier. Defaults to the panel ``entity``. time Time identifier. Defaults to the panel ``time``. standardize ``"none"`` shows raw values; ``"global"`` z-scores over all cells; ``"by_time"`` / ``"by_entity"`` z-score within each period / unit (revealing relative position). aggfunc Aggregator for duplicate ``(entity, time)`` cells (default ``"mean"``). max_units Cap on the number of units drawn (evenly sampled above it). sort_by Row order: by ``"mean"`` (default, descending), ``"first_value"`` or ``"label"``. Returns ------- ValueHeatmapResult ``df`` (the unit-by-time pivot) and the Plotly ``fig``. Examples -------- ```python 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.explore_value_heatmap(df, var="gini_regional", standardize="by_time").fig ``` ### explore_distribution_over_time(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_distribution_over_time.html) Show how the distribution of ``var`` shifts across periods. Parameters ---------- df Panel data frame. var Numeric variable whose distribution is tracked. entity Cross-sectional id (accepted for panel parity; the per-period distribution pools across units). Defaults to the panel ``entity``. time Time identifier. Defaults to the panel ``time``. style ``"ridgeline"`` (default; stacked per-period densities), ``"animated_hist"`` or ``"animated_violin"`` (Plotly animation over periods). bins Histogram bins (animated_hist and the KDE fallback). bandwidth KDE bandwidth (``bw_method``) for the ridgeline; ``None`` uses Scott's rule. max_periods Cap on the number of periods drawn (evenly sampled above it). Returns ------- DistributionOverTimeResult ``df`` (the complete-case ``(time, var)`` frame) and the Plotly ``fig``. Examples -------- ```python 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.explore_distribution_over_time(df, var="gini_regional").fig ``` ### explore_transition_matrix(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_transition_matrix.html) Period-to-period transition matrix of a discrete (or binned) state within units. Parameters ---------- df Panel data frame. var State variable. Categorical/object variables use their categories; numeric variables are binned into ``n_bins`` states. entity, time Panel identifiers (default to those declared via :func:`expdpy.set_panel`). n_bins Number of bins for a numeric ``var``. bin_method ``"quantile"`` (equal-mass, default) or ``"equal_width"``. bin_labels Optional labels for the bins (length must match the resulting number of bins). lag Number of periods ahead for the transition (default 1). Only consecutive observed periods that are exactly ``lag`` apart are counted; gaps are skipped. normalize ``"row"`` (default) gives conditional transition probabilities; ``"none"`` gives raw counts. caption Great Tables header. Returns ------- TransitionMatrixResult ``df`` (K-by-K matrix), ``counts``, ``fig``, ``gt`` and ``states``. Examples -------- ```python 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.explore_transition_matrix(df, var="gini_regional", n_bins=4).fig ``` ### explore_within_persistence(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_within_persistence.html) Within-unit serial correlation: this period's value against the previous one. Parameters ---------- df Panel data frame. var Numeric variable. entity, time Panel identifiers (default to those declared via :func:`expdpy.set_panel`). lag Lag (in periods) for the comparison (default 1). Only consecutive observed periods exactly ``lag`` apart are paired. demean If ``True`` (default), remove each unit's mean first, isolating the *within-unit* serial correlation (the part fixed-effects models exploit). alpha Marker opacity. Defaults to a sample-size-based value. Returns ------- WithinPersistenceResult ``df`` (lagged pairs), ``fig``, ``rho`` (within serial correlation), ``slope`` (AR fit), ``n_pairs`` and ``demeaned``. Examples -------- ```python 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.explore_within_persistence(df, var="gini_regional").fig ``` ### explore_treemap_plot(df[, path, size, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_treemap_plot.html) Treemap of a hierarchy sized by ``size``, animated over ``time`` when available. Nested rectangles show how a total is divided among the levels of ``path`` (e.g. countries within continents). On a panel each period becomes a frame, stepped through by a play button and a time slider; the color scale is held fixed across frames so shifts are comparable. Without a resolvable time id a single static treemap is drawn. Parameters ---------- df Data frame containing the hierarchy, value and (optionally) time columns. path Hierarchy columns from root to leaf, e.g. ``["continent", "country"]``. Defaults to the panel ``entity`` (a single level) when omitted. size Numeric column mapped to rectangle area. When omitted, each leaf is sized by its row count. color Optional column mapped to color: numeric → a fixed-range sequential colorbar; otherwise a stable discrete palette. entity Cross-sectional (unit) id. Defaults to the panel ``entity`` declared via :func:`expdpy.set_panel`; used to default ``path`` and to label leaves ``"Name (id)"`` when an ``entity_name`` is declared. time Time identifier that drives the animation. Defaults to the panel ``time``; when neither is available the treemap is static. max_units Cap on the number of distinct leaf units; above it a seeded random sample is drawn (once, so the same leaves appear in every period) and an :class:`expdpy.ExpdpyWarning` is emitted. ``None`` disables sampling. sample_seed Seed for the leaf sample. Returns ------- TreemapPlotResult ``df`` (the complete-case frame plotted) and ``fig`` (the Plotly treemap). Raises ------ ValueError If no hierarchy can be resolved, ``size`` is non-numeric, or nothing remains after dropping incomplete rows. Examples -------- World population by continent and country, colored by life expectancy, animated over the years: ```python import expdpy as ex from expdpy.data import load_gapminder, load_gapminder_data_def df = ex.set_labels(load_gapminder(), load_gapminder_data_def(), set_panel=True) ex.explore_treemap_plot( df, path=["continent", "country"], size="pop", color="lifeExp", time="year" ).fig ``` ### explore_sunburst_plot(df[, path, size, ...]) [Reference](https://cmg777.github.io/expdpy/reference/explore_sunburst_plot.html) Sunburst of a hierarchy sized by ``size``, animated over ``time`` when available. The radial counterpart to :func:`explore_treemap_plot`: concentric rings show the nested part-to-whole shares of ``path`` (e.g. countries within continents), and on a panel a play button and time slider step through the periods with a fixed color scale. Without a resolvable time id a single static sunburst is drawn. Parameters ---------- df Data frame containing the hierarchy, value and (optionally) time columns. path Hierarchy columns from root to leaf, e.g. ``["continent", "country"]``. Defaults to the panel ``entity`` (a single level) when omitted. size Numeric column mapped to wedge angle. When omitted, each leaf is sized by its row count. color Optional column mapped to color: numeric → a fixed-range sequential colorbar; otherwise a stable discrete palette. entity Cross-sectional (unit) id. Defaults to the panel ``entity``; used to default ``path`` and to label leaves ``"Name (id)"`` when an ``entity_name`` is declared. time Time identifier that drives the animation. Defaults to the panel ``time``; when neither is available the sunburst is static. max_units Cap on the number of distinct leaf units; above it a seeded random sample is drawn and an :class:`expdpy.ExpdpyWarning` is emitted. ``None`` disables sampling. sample_seed Seed for the leaf sample. Returns ------- SunburstPlotResult ``df`` (the complete-case frame plotted) and ``fig`` (the Plotly sunburst). Examples -------- The same hierarchy as a radial sunburst: ```python import expdpy as ex from expdpy.data import load_gapminder, load_gapminder_data_def df = ex.set_labels(load_gapminder(), load_gapminder_data_def(), set_panel=True) ex.explore_sunburst_plot( df, path=["continent", "country"], size="pop", color="lifeExp", time="year" ).fig ``` ## Analyze Panel estimators — OLS with fixed effects and clustered SEs, stepwise / Newey–West / Driscoll–Kraay estimation, instrumental variables / 2SLS (cross-section and panel, with a first-stage weak-instrument F), the Frisch–Waugh–Lovell and coefficient plots, pooled / between / fixed / random effects, correlated random effects (Mundlak), the Hausman test, post-estimation, robust inference, event-study / difference-in-differences, β-convergence (unconditional / conditional with speed, half-life and a rolling view), σ-convergence (the trend in cross-sectional dispersion over time), and club convergence (the Phillips–Sul log(t) test with data-driven clustering). ### analyze_regression_table(df[, dvs, idvs, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_regression_table.html) Build a regression table of one or more OLS models. Supports fixed effects and clustered standard errors (via pyfixest), multiple models side by side, or a single model estimated separately across the levels of ``byvar``. Parameters ---------- df Data frame containing the data. dvs Dependent variable name, or a list of names (one per model). Defaults to the declared main outcome (:func:`expdpy.set_roles`) when omitted. idvs Independent variable names. For multiple models, a list of lists. Defaults to the declared covariates (:func:`expdpy.set_roles`) when omitted. feffects Fixed-effects variable names (per model when multiple models are given). clusters Cluster variable names for clustered standard errors (per model when multiple). byvar A categorical variable to estimate the single model separately by. Only valid with a single dependent variable. Levels with too few observations to estimate the model (``n <= len(idvs) + 1``) are skipped. format Output format for the rendered ``etable``: ``"gt"`` (Great Tables), ``"tex"``, ``"md"``, ``"df"`` (DataFrame) or ``"html"``. Returns ------- RegressionTableResult ``models`` (fitted pyfixest models), ``etable`` (rendered table) and ``df`` (tidy coefficient frame). Examples -------- Basic — a pooled OLS regression of the cubic Kuznets curve (the data dictionary supplies the readable labels shown in the rendered table): ```python 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_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], ).etable ``` Advanced — absorb two-way (country + year) fixed effects with standard errors clustered by country, then read the tidy coefficient frame and fitted models: ```python 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) result = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ) result.etable result.df result.models ``` ### analyze_estimation(df, dv[, idvs, feffects, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_estimation.html) Estimate an OLS model, optionally several nested or multi-outcome models at once. A richer companion to :func:`expdpy.analyze_regression_table`: same OLS/fixed-effects core, but with stepwise nested-model comparison, serial-correlation-robust standard errors (Newey-West / Driscoll-Kraay), multiple outcomes and weights. Parameters ---------- df Data frame containing the variables. dv Dependent-variable name, or several names to estimate the same right-hand side for each outcome side by side. idvs Independent regressor name(s). feffects Fixed-effect variable name(s) absorbed during estimation. stepwise Wrap ``idvs`` in a stepwise sequence — ``"sw"``/``"sw0"`` (separate) or ``"csw"``/``"csw0"`` (cumulative) — to estimate nested models in one call and read them side by side (great for "watch the estimate move as I add controls"). vcov Standard-error type: ``"iid"``, ``"hetero"``/``"HC1"`` (``"HC2"``/``"HC3"`` without fixed effects), ``"CRV1"``/``"CRV3"`` (cluster-robust, with ``cluster=``), ``"NW"``/``"DK"`` (serial-correlation robust, with ``time_id=``/``panel_id=``), a pyfixest-style ``{"CRV1": "firm"}`` mapping, or a :class:`VCovSpec`. Defaults to clustered SEs when ``cluster`` is given, else ``"iid"``. cluster Cluster variable name(s) — a shortcut that selects ``"CRV1"`` standard errors. time_id, panel_id, lag Required for ``vcov="NW"``/``"DK"`` (the time and panel identifiers, optional lag). weights Optional weights column name. ssc Small-sample-correction object (defaults to the Stata-``reghdfe``-consistent one). format Output format for the rendered ``etable``. Returns ------- EstimationResult ``models``, ``etable``, ``df`` (tidy coefficients), ``model_kind``, ``fit_stats`` and ``notes``. Use ``.interpret()`` / ``.explain()`` for plain-language output. Examples -------- Basic — a single OLS regression with heteroskedastic standard errors: ```python 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_estimation( df, dv="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq"], vcov="hetero", ).etable ``` Advanced — a cumulative-stepwise comparison with heteroskedastic SEs: ```python 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_estimation( df, dv="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], stepwise="csw", vcov="hetero", ) res.etable # three nested models side by side res.fit_stats # N and R² per model print(res.interpret()) ``` ### analyze_iv_regression(df, dv, endog, instruments[, exog, feffects, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_iv_regression.html) Fit an instrumental-variables (2SLS) regression with a weak-instrument diagnostic. Endogenous regressors are instrumented with excluded ``instruments`` while ``exog`` (included exogenous controls) and ``feffects`` (absorbed fixed effects) enter directly. The fit delegates to pyfixest's native IV estimator, and the result carries the **first-stage F statistic** — the conventional check for weak instruments (a value below about 10 is the Staiger-Stock warning sign). For panel data, prefer :func:`analyze_panel_iv_regression`, which manages the entity/time fixed effects for you. Parameters ---------- df Data frame containing the data. dv Dependent (outcome) variable name. endog Endogenous regressor name(s) to be instrumented. instruments Excluded instrument name(s). At least as many instruments as endogenous regressors are required (the order condition); more instruments over-identify the model. exog Included exogenous regressor (control) names, if any. feffects Fixed-effects variable names absorbed by pyfixest. clusters Cluster variable name(s) for cluster-robust standard errors. format Output format for the rendered ``etable``: ``"gt"`` (Great Tables), ``"tex"``, ``"md"``, ``"df"`` (DataFrame) or ``"html"``. Returns ------- IVRegressionResult ``model`` (the fitted pyfixest IV model), ``etable`` (rendered table), ``df`` (tidy coefficient frame), the ``endog`` / ``instruments`` / ``exog`` names, and ``first_stage_f`` / ``first_stage_p`` (the first-stage weak-instrument F and p-value). Raises ------ ValueError If no endogenous regressor or instrument is given, or the model is under-identified (fewer instruments than endogenous regressors). NotImplementedError If the dependent variable is non-numeric (only OLS-family IV is supported). Examples -------- The canonical Acemoglu-Johnson-Robinson (2001) example: instrument average protection against expropriation risk with log settler mortality, on the 64-country base sample. The instrumented slope is the famous ≈ 0.94: ```python import expdpy as ex from expdpy.data import load_colonial_origins, load_colonial_origins_data_def df = ex.set_labels(load_colonial_origins(), load_colonial_origins_data_def()) base = df[df["base_sample"] == 1] result = ex.analyze_iv_regression( base, dv="log_gdp_pc_1995", endog="expropriation_risk", instruments="log_settler_mortality", ) result.etable result.first_stage_f print(result.interpret()) ``` ### analyze_panel_iv_regression(df, dv, endog, instruments[, exog, entity, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_panel_iv_regression.html) Fit a panel IV (2SLS) regression absorbing entity (and time) fixed effects. The panel analogue of :func:`analyze_iv_regression` and of Stata's ``xtivreg2 ... fe``: it instruments the endogenous regressor(s) while absorbing **entity** fixed effects (and, when ``twoway``, **time** fixed effects), clustering by entity by default. The panel ids follow the usual expdpy resolution — explicit ``entity`` / ``time`` win, otherwise the pair declared by :func:`expdpy.set_panel` is used. Parameters ---------- df Data frame containing the data. dv Dependent (outcome) variable name. endog Endogenous regressor name(s) to be instrumented. instruments Excluded instrument name(s) (order condition: at least as many as ``endog``). exog Included exogenous regressor (control) names, if any. entity, time Panel identifiers (unit and period). Resolved from :func:`expdpy.set_panel` when not passed; ``entity`` is required, ``time`` is needed only for the two-way specification. twoway If ``True`` (default) absorb both entity and time fixed effects; if ``False`` absorb only entity fixed effects (one-way). cluster_entity If ``True`` (default) cluster the standard errors by ``entity``. format Output format for the rendered ``etable`` (see :func:`analyze_iv_regression`). Returns ------- IVRegressionResult As :func:`analyze_iv_regression`, with the entity/time fixed effects absorbed. Examples -------- Instrument night-time lights (a proxy for local economic activity) with lagged rainfall and drought across African regions, absorbing region and year fixed effects and clustering by region — a panel ``xtivreg2 fe``. The first-stage F is well above 10: ```python import expdpy as ex from expdpy.data import load_regional_conflict, load_regional_conflict_data_def df = ex.set_labels( load_regional_conflict(), load_regional_conflict_data_def(), set_panel=True ) result = ex.analyze_panel_iv_regression( df, dv="conflict", endog="log_lights_lag1", instruments=["rain_lag2", "drought_lag2"], ) result.etable result.first_stage_f print(result.interpret()) ``` ### analyze_fwl_plot(df, dv, var[, controls, feffects, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_fwl_plot.html) Frisch-Waugh-Lovell scatter of ``dv`` against the focal regressor ``var``. Residualizes both ``dv`` and ``var`` on ``controls`` **and** ``feffects`` over a single complete-case sample, then plots the residuals with an OLS fit line and a 95% pointwise confidence band. Fixed effects are absorbed (group-demeaned) by ``pyfixest``, so the plot shows the relationship between ``var`` and ``dv`` net of *both* the other regressors and the fixed effects. By the Frisch-Waugh-Lovell theorem the slope of this residual regression equals the coefficient on ``var`` in the full model; the annotation states this and reports the full model's standard error (clustered when ``clusters`` is given, matching :func:`expdpy.analyze_regression_table`), the sample size, and the within-R². Parameters ---------- df Data frame containing the variables. dv Dependent (outcome) variable name. var Focal regressor whose partial relationship with ``dv`` is plotted. controls Additional regressors to residualize out (entered linearly). May be ``None``. feffects Fixed-effects variable name(s) absorbed during residualization. May be ``None``. clusters Cluster variable name(s) for the standard error reported in the annotation. Does not affect the point estimate or the plotted confidence band. n_sample Number of points drawn in the scatter (default 1000). The fit line and band are always computed on all complete-case rows. ``None`` plots every point. alpha Marker opacity for the scatter points (default 0.5). seed Seed for the point-subsampling RNG (default 0), for reproducible figures. Returns ------- FWLPlotResult ``df`` (residual frame), ``fig`` (Plotly figure) and the scalar statistics ``slope``, ``se``, ``intercept``, ``n_obs`` and ``r2_within``. Examples -------- Basic — partial relationship of the outcome and a single regressor: ```python 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_fwl_plot(df, dv="gini_regional", var="log_gdp_pc").fig ``` Advanced — residualize on the other cubic terms and two-way fixed effects, cluster the reported standard error by country, then read the FWL statistics back: ```python 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) result = ex.analyze_fwl_plot( df, dv="gini_regional", var="log_gdp_pc", controls=["log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ) result.fig result.slope, result.se, result.r2_within ``` ### analyze_coefficient_plot(models[, keep, drop, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_coefficient_plot.html) Plot coefficient estimates with confidence intervals for one or more models. Parameters ---------- models A fitted pyfixest model, a list of fitted models, or a :class:`~expdpy.RegressionTableResult` (its ``.models`` are used). This lets you do ``analyze_coefficient_plot(analyze_regression_table(...))`` directly. keep, drop Optional regular-expression patterns selecting which coefficients to show (and in which order). ``keep`` whitelists, ``drop`` blacklists; both use pyfixest's own coefficient-selection semantics. coef_labels Optional mapping from raw coefficient names to display labels. model_labels Optional legend labels, one per model (defaults to ``"Model 1"``, ``"Model 2"``…). alpha Significance level for the confidence intervals (default ``0.05`` → 95% intervals). joint If ``True``, draw simultaneous (joint) confidence bands via ``model.confint(joint= True)`` instead of pointwise intervals. horizontal If ``True`` (default), estimates run along the x-axis with coefficients listed down the y-axis (the most readable layout for many terms). drop_intercept If ``True`` (default), omit the intercept term. title Optional figure title. Returns ------- CoefficientPlotResult ``df`` (tidy long frame: ``model``, ``term``, ``estimate``, ``se``, ``ci_lower``, ``ci_upper``) and ``fig`` (the Plotly figure). Examples -------- Basic — plot a single fitted model's coefficients: ```python 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) result = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"] ) ex.analyze_coefficient_plot(result).fig ``` Advanced — compare several models side by side with custom labels: ```python 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) pooled = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc"] ) fe = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc"], feffects=["country", "year"] ) ex.analyze_coefficient_plot( [pooled, fe], model_labels=["Pooled OLS", "Two-way FE"], keep=["log_gdp_pc"], ).fig ``` ### analyze_marginal_effects_plot(df, dv, focal, moderator[, controls, feffects, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_marginal_effects_plot.html) Plot the marginal effect of ``focal`` across ``moderator`` for an interaction model. Fits ``dv ~ focal * moderator (+ controls) (| fixed effects)`` via pyfixest, then traces the marginal effect of ``focal`` — ``b_focal + b_interaction * moderator`` — across the moderator's observed range, with a delta-method confidence band. Also reports the **average marginal effect** (evaluated at the sample-mean moderator). Parameters ---------- df Data frame containing the data. dv Dependent (outcome) variable name. focal The focal regressor whose marginal effect is traced. moderator The moderating variable it is interacted with. controls Additional exogenous control variable names. feffects Fixed-effects variable names absorbed by pyfixest. clusters Cluster variable name(s) for cluster-robust standard errors (and hence the band). at Explicit moderator values at which to evaluate the marginal effect. Defaults to an evenly spaced grid over the moderator's observed range. n_points Number of grid points when ``at`` is not given (default 50). alpha Significance level for the confidence band (default ``0.05`` → 95%). title Optional figure title. Returns ------- MarginalEffectsResult ``df`` (the grid: ````, ``me``, ``se``, ``ci_lower``, ``ci_upper``), ``fig`` (the Plotly figure), ``focal`` / ``moderator``, and ``ame`` / ``ame_se`` (the average marginal effect and its standard error). Raises ------ NotImplementedError If the dependent variable is non-numeric. ValueError If no interaction term between ``focal`` and ``moderator`` is estimated. Examples -------- Does the gradient of inequality in income depend on trade openness? Trace the marginal effect of log GDP per capita across the range of the trade share: ```python 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) result = ex.analyze_marginal_effects_plot( df, dv="gini_regional", focal="log_gdp_pc", moderator="trade_share" ) result.fig result.ame print(result.interpret()) ``` ### analyze_panel_table(df, dv, idvs[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_panel_table.html) Estimate pooled / between / fixed / random-effects models side by side. A linearmodels-backed companion to :func:`expdpy.analyze_regression_table`, returned in the same :class:`~expdpy.RegressionTableResult` container so ``.df``, ``.interpret()`` and the apps work the same way. The one-way fixed-effects estimate matches ``analyze_regression_table(feffects=[entity])``. Parameters ---------- df Long panel data frame. dv Dependent variable name. idvs Independent variable name(s). entity, time The cross-section and time identifiers. Default to the panel declared via :func:`expdpy.set_panel` / :func:`expdpy.set_labels` (``set_panel=True``). models Which estimators to include, in order. cov_type Covariance estimator: ``"clustered"`` (default), ``"robust"`` or ``"unadjusted"``. cluster_entity Cluster by entity when ``cov_type="clustered"``. format Output format for the rendered comparison table. Returns ------- RegressionTableResult ``models`` (fitted linearmodels results), ``etable`` (the comparison table) and ``df`` (tidy coefficients with the same columns as the pyfixest path). Examples -------- Basic — pooled / between / FE / RE side by side. With the panel declared by ``set_panel=True``, ``entity`` and ``time`` are resolved automatically. ```python 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) tab = ex.analyze_panel_table(df, dv="gini_regional", idvs=["log_gdp_pc"]) tab.etable # comparison of the four estimators tab.df # tidy coefficients, same columns as the pyfixest path ``` Advanced — pick a subset of estimators, robust SEs, and read the interpretation. ```python 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) tab = ex.analyze_panel_table( df, dv="gini_regional", idvs=["log_gdp_pc", "trade_share"], models=("between", "fe"), cov_type="robust", ) tab.etable print(tab.interpret()) ``` ### analyze_cre_table(df, dv, idvs[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_cre_table.html) Estimate a Correlated Random Effects (Mundlak) model. Augments a random-effects model with the entity means of each time-varying regressor. By the Mundlak equivalence the coefficient on each original regressor equals its within (fixed-effects) estimate — so ``analyze_cre_table(df, "y", "x", ...)`` recovers the same slope on ``x`` as ``analyze_regression_table(df, "y", "x", feffects=[entity])`` — while the coefficient on ``x_mean`` measures the between-vs-within gap. A joint Wald test that all ``*_mean`` coefficients are zero is the regression-form Hausman test (reported on the fitted model as ``_cre_mundlak_stat`` / ``_cre_mundlak_df`` / ``_cre_mundlak_p``). Parameters ---------- df Long panel data frame. dv Dependent variable name. idvs Time-varying independent variable name(s). entity, time The cross-section and time identifiers. Both default to the declared panel (set via :func:`~expdpy.set_panel`), so they can be omitted when it is declared. cov_type Covariance estimator: ``"clustered"`` (default), ``"robust"`` or ``"unadjusted"``. cluster_entity Cluster by entity when ``cov_type="clustered"``. format Output format for the rendered table. Returns ------- CRETableResult ``models`` (the fitted linearmodels result), ``etable`` (the rendered table) and ``df`` (tidy coefficients for each regressor, its ``*_mean`` and the intercept). Examples -------- ```python 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) cre = ex.analyze_cre_table(df, dv="gini_regional", idvs=["log_gdp_pc"]) cre.etable # the log_gdp_pc coefficient == the within / fixed-effects estimate cre.df # rows for log_gdp_pc, log_gdp_pc_mean and Intercept print(cre.interpret()) ``` ### analyze_hausman_test(df, dv, idvs[, entity, time]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_hausman_test.html) Run the Hausman test comparing fixed-effects and random-effects estimates. Parameters ---------- df Long panel data frame. dv Dependent variable name. idvs Independent variable name(s). entity, time The cross-section and time identifiers. Default to the panel declared via :func:`expdpy.set_panel` / :func:`expdpy.set_labels` (``set_panel=True``). Returns ------- HausmanTestResult The test statistic, degrees of freedom, p-value and the compared coefficients. Examples -------- Basic — compare the FE and RE estimates. With the panel declared by ``set_panel=True``, ``entity`` and ``time`` are resolved automatically. ```python 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) ht = ex.analyze_hausman_test(df, dv="gini_regional", idvs=["log_gdp_pc"]) ht.statistic, ht.df_test, ht.p_value ``` Advanced — several regressors, then read the compared coefficients side by side. ```python 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) ht = ex.analyze_hausman_test( df, dv="gini_regional", idvs=["log_gdp_pc", "trade_share"] ) ht.fe_coefs # fixed-effects estimates ht.re_coefs # random-effects estimates print(ht.interpret()) ``` ### analyze_fixef_plot(result_or_model[, fixef, top_n, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_fixef_plot.html) Plot the estimated group intercepts (fixed effects) of a model, ranked by value. Parameters ---------- result_or_model A fitted model or a result object carrying ``.models``. fixef Which fixed-effect dimension to plot (e.g. ``"country"``). Defaults to the first. top_n Show at most this many levels; when there are more, the most extreme (lowest and highest) are kept. ``None`` shows every level. title Optional figure title. Returns ------- FixefPlotResult ``df`` (``fixef``, ``level``, ``value``) and the Plotly figure. Examples -------- Basic — plot the country fixed effects of a Kuznets-curve model: ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country"], ) ex.analyze_fixef_plot(model).fig ``` Advanced — show only the most extreme levels with a custom title: ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country"], ) ex.analyze_fixef_plot( model, fixef="country", top_n=10, title="Country intercepts" ).fig ``` ### analyze_predictions(result_or_model[, newdata]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_predictions.html) Return fitted values from a model (and residuals/actuals on the estimation sample). Parameters ---------- result_or_model A fitted model or a result object carrying ``.models``. newdata Optional data frame to predict on. When omitted, predictions are for the estimation sample and the frame also includes the ``actual`` outcome and the ``residual``. Returns ------- PredictionResult ``df`` with a ``predicted`` column (plus ``actual`` and ``residual`` in-sample). Examples -------- Basic — in-sample fitted values, actuals, and residuals: ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country"], ) ex.analyze_predictions(model).df.head() ``` Advanced — predict on new data (here a fresh slice of the panel): ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country"], ) newdata = df[df["year"] == df["year"].max()] ex.analyze_predictions(model, newdata=newdata).df.head() ``` ### analyze_joint_test(result_or_model[, hypotheses, distribution]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_joint_test.html) Run a Wald joint-significance test that a set of coefficients are all zero. Parameters ---------- result_or_model A fitted model or a result object carrying ``.models``. hypotheses Coefficient name(s) to test jointly. ``None`` tests all coefficients at once. distribution Reference distribution: ``"F"`` (default) or ``"chi2"``. Returns ------- JointTestResult ``statistic``, ``p_value``, the tested ``hypotheses`` and the ``distribution``. Examples -------- Basic — jointly test the two nonlinear Kuznets terms: ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country"], ) test = ex.analyze_joint_test(model, ["log_gdp_pc_sq", "log_gdp_pc_cu"]) test.statistic, test.p_value ``` Advanced — test all slope coefficients at once against a chi-square reference: ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country"], ) ex.analyze_joint_test(model, distribution="chi2").p_value ``` ### analyze_robust_inference(result_or_model, param[, method, reps, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_robust_inference.html) Run robust inference on one coefficient via randomization inference or wild bootstrap. Parameters ---------- result_or_model A fitted model or a result object carrying ``.models``. param The coefficient name to test (``H0: param = 0``). method ``"ritest"`` (randomization inference, native to pyfixest) or ``"wildboot"`` (wild cluster bootstrap, which requires the optional ``wildboottest`` package). reps Number of resamples / bootstrap replications. cluster Optional cluster variable for the resampling. seed Seed for reproducibility. Returns ------- RobustInferenceResult ``method``, ``param``, ``estimate``, ``p_value``, ``conf_int``, ``reps`` and the underlying ``raw`` pyfixest output. Examples -------- Basic — fit a regression (the data dictionary supplies the readable labels) and test the slope on trade openness via randomization inference: ```python 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) model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "trade_share"], ) result = ex.analyze_robust_inference(model, "trade_share", reps=200, seed=0) result.p_value ``` Advanced — cluster the randomization inference by country (resampling permutes treatment within clusters; the cluster column must be numeric, so encode the country labels as integer codes first), then read the estimate and the raw pyfixest output: ```python 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) df["country_id"] = df["country"].factorize()[0] model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "trade_share"], clusters=["country_id"], ) result = ex.analyze_robust_inference( model, "trade_share", cluster="country_id", reps=200, seed=0 ) result.estimate result.raw ``` ### analyze_event_study(df[, outcome, unit, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_event_study.html) Estimate and plot an event study for staggered treatment adoption. Parameters ---------- df Long panel data frame. outcome Outcome variable name. unit Unit (cross-section) identifier. Defaults to the declared panel entity. time Time identifier. Defaults to the declared panel time. cohort First-treated period for each unit; ``never_treated_value`` marks never-treated units. estimator ``"did2s"`` (Gardner two-stage, the default and robust to heterogeneity), ``"twfe"`` (classic two-way fixed effects — shown for comparison, biased under heterogeneous effects), ``"saturated"`` (Sun-Abraham, one curve per cohort) or ``"lpdid"`` (local-projections DiD). cluster Cluster variable for standard errors (defaults to ``unit``). pre_window, post_window Event-time window for ``"lpdid"`` (ignored by the other estimators). never_treated_value The value of ``cohort`` that marks never-treated units (default ``0``). title Optional figure title. Returns ------- EventStudyResult ``df`` (event-time path), ``fig`` (Plotly), ``model`` (fitted pyfixest object) and ``estimator``. Examples -------- ```python import expdpy as ex from expdpy.data import load_staggered_did, load_staggered_did_data_def df = ex.set_labels( load_staggered_did(), load_staggered_did_data_def(), set_panel=True ) # Panel is declared, so unit=/time= are resolved automatically. ex.analyze_event_study(df, outcome="outcome", cohort="cohort").fig ``` ### analyze_panel_view(df[, unit, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_panel_view.html) Visualize the treatment structure of a panel (a themed ``panelview``). Provide either a binary ``treat`` column or a first-treatment ``cohort`` column (from which the indicator is derived). With ``outcome`` given, plots each unit's outcome over time instead of the treatment quilt. Parameters ---------- df Long panel data frame. unit, time Unit and time identifiers. Default to the declared panel entity and time. treat Binary (0/1) treatment-status column. cohort First-treated period column (used to derive ``treat`` when ``treat`` is omitted). outcome If given, draw an outcome-over-time line per unit instead of the treatment quilt. never_treated_value Value of ``cohort`` marking never-treated units (default ``0``). sort_by_timing Order units by their first treated period (clearest staggered-adoption picture). max_units Cap the number of units shown (an even spread is sampled when there are more). title Optional figure title. Returns ------- PanelViewResult ``df`` (the treatment quilt, or the tidy outcome frame) and ``fig``. Examples -------- Basic — the staggered-adoption treatment quilt (derived from ``cohort``): ```python import expdpy as ex from expdpy.data import load_staggered_did, load_staggered_did_data_def df = ex.set_labels( load_staggered_did(), load_staggered_did_data_def(), set_panel=True ) # Panel is declared, so unit=/time= are resolved automatically. ex.analyze_panel_view(df, cohort="cohort").fig ``` Advanced — outcome trajectories per unit instead of the treatment quilt: ```python import expdpy as ex from expdpy.data import load_staggered_did, load_staggered_did_data_def df = ex.set_labels( load_staggered_did(), load_staggered_did_data_def(), set_panel=True ) ex.analyze_panel_view(df, cohort="cohort", outcome="outcome").fig ``` ### analyze_beta_convergence(df, var[, controls, entity, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_beta_convergence.html) Unconditional and conditional β-convergence for a panel variable. Regresses each unit's annualized growth ``(y_end - y_start) / T`` on its initial level ``y_start`` over a common window. A **negative** slope β is convergence (units that start lower grow faster). With ``controls`` the conditional slope is read off a Frisch-Waugh- Lovell partial regression that holds the controls' initial-year values fixed. The slope maps to a speed ``λ = -ln(1 + β·T)/T`` and half-life ``ln 2 / λ``. The variable is used **as supplied** — no log is taken — so for the canonical income case pass *log* GDP per capita (then growth is the annualized log-difference and the x-axis is the initial log level). Parameters ---------- df Panel data frame. var Numeric variable to analyse (e.g. ``"log_gdp_pc"``). controls Optional control name(s). Each enters the conditional model as its **initial-year** value. ``None`` runs the unconditional analysis only. entity, time Panel identifiers. Default to those declared via :func:`expdpy.set_panel`. start, end First and last year used to form the growth rate. Default to the earliest and latest year in the panel; only units observed at **both** endpoints are kept. rolling When ``True`` (default), also estimate the convergence slope on every fixed-width window of ``window`` periods and return the time path. window Width of the rolling window in **periods** (sorted distinct years). Default ``max(2, n_periods // 2)``. min_obs Minimum number of units required in the cross-section and in each rolling window. vcov Standard-error type for the reported coefficients: ``"hetero"`` (HC1, the default) or ``"iid"``. Does not affect the point estimate or the plotted band. title Title for the unconditional scatter (the others get descriptive titles). Returns ------- BetaConvergenceResult The cross-section ``df``; the unconditional scatter ``fig``; the conditional FWL scatter ``fig_conditional`` and rolling plot ``fig_rolling`` (each ``None`` when not applicable); the comparison table ``gt`` / ``summary``; the ``rolling`` frame; the fitted ``models``; and the scalar fit statistics (``beta``/``speed``/``half_life`` and their ``*_cond`` counterparts). Examples -------- Basic — unconditional convergence of log GDP per capita across Bolivian provinces. Setting the labels with ``set_panel=True`` declares the panel (entity, time), so the call can omit ``entity=`` / ``time=`` and the figure/table get readable labels: ```python import expdpy as ex from expdpy.data import load_bolivia112_gdppc, load_bolivia112_gdppc_data_def df = ex.set_labels( load_bolivia112_gdppc(), load_bolivia112_gdppc_data_def(), set_panel=True ) res = ex.analyze_beta_convergence(df, "log_gdppc") res.fig res.gt res.speed, res.half_life ``` Advanced — conditional convergence, controlling for the initial GDP-per-capita level: ```python import expdpy as ex from expdpy.data import load_bolivia112_gdppc, load_bolivia112_gdppc_data_def df = ex.set_labels( load_bolivia112_gdppc(), load_bolivia112_gdppc_data_def(), set_panel=True ) ex.analyze_beta_convergence(df, "log_gdppc", controls=["gdppc"]).fig_conditional ``` ### analyze_sigma_convergence(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_sigma_convergence.html) σ-convergence: track and test the cross-sectional dispersion of a panel variable. For each period the function measures how spread out ``var`` is **across units** — the standard deviation, the Gini index and the coefficient of variation — and then asks whether that dispersion shrinks over time by regressing the **log dispersion** on time. A **negative** trend slope is σ-convergence: the cross-sectional distribution is narrowing (units are becoming more alike). This is the distributional complement to β-convergence (which compares growth to the initial level); see :func:`analyze_beta_convergence`. The variable is used **as supplied** — no log or other transform is taken — so pass it on whatever scale the analysis calls for. The panel must be **balanced** (every unit present in every period) so the dispersion is comparable across periods. Parameters ---------- df Panel data frame. var Numeric variable whose cross-sectional dispersion is tracked (e.g. ``"lifeExp"``). Used as supplied. The Gini index additionally requires non-negative values. entity, time Panel identifiers. Default to those declared via :func:`expdpy.set_panel`. start, end Optional first and last period to include. Default to the full range; the retained window must still be balanced. min_periods Minimum number of periods required to estimate a dispersion trend (at least 3). vcov Standard-error type for the trend coefficients: ``"hetero"`` (HC1, the default) or ``"iid"``. Does not change the point estimate. title Title for the dual-axis figure. Returns ------- SigmaConvergenceResult The per-period dispersion table ``df`` (``time``, ``n_units``, ``mean``, ``std``, ``gini``, ``cv``); the dual-axis ``fig`` (std on the left axis, Gini on the right); the trend table ``gt`` / ``summary`` (one row per measure: slope, SE, p-value, R²); the fitted trend ``models``; the panel dimensions ``n_periods`` / ``n_units``; and the headline trend scalars (``std_slope`` and its ``std_se`` / ``std_pvalue`` / ``std_r2``, plus the ``gini_*`` and ``cv_*`` counterparts). ``notes`` records any degraded measure (e.g. a Gini set to ``NaN`` because the variable has negative values). Notes ----- For a measure of dispersion ``D_t`` computed cross-sectionally at each time ``t``, the trend is the OLS slope ``b`` in .. math:: \ln D_t = a + b \, t + \varepsilon_t, so ``b`` is the average proportional change in dispersion per period and ``b < 0`` is σ-convergence. The standard deviation uses ``ddof = 1``; the Gini index is the relative mean absolute difference over twice the mean; the coefficient of variation is the standard deviation over the mean. If every unit's value contracts geometrically toward a common mean at rate ``rho`` per period, each dispersion measure scales as ``rho**t`` and the trend recovers ``b = ln(rho)`` exactly. See Barro & Sala-i-Martin, *Economic Growth*, ch. 11. Examples -------- Basic — σ-convergence of log GDP per capita across Bolivian provinces. The panel must be balanced; ``set_labels(..., set_panel=True)`` declares (entity, time) so the call omits ``entity=`` / ``time=`` and the figure axes are labelled: ```python import expdpy as ex from expdpy.data import load_bolivia112_gdppc, load_bolivia112_gdppc_data_def df = ex.set_labels( load_bolivia112_gdppc(), load_bolivia112_gdppc_data_def(), set_panel=True ) res = ex.analyze_sigma_convergence(df, "log_gdppc") res.fig # std (left axis) and Gini (right axis) over time res.gt # the trend table res.std_slope # < 0 indicates σ-convergence ``` ### analyze_convergence_clubs(df, var[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_convergence_clubs.html) Phillips-Sul log(t) convergence test and data-driven club clustering for a panel. Runs the full club-convergence workflow on one variable: optionally smooth each unit's series with the **Hodrick-Prescott filter** (``lambda = 400`` for annual data); form the **relative transition path** ``h_it = x_it / mean_i(x_it)``; run the **log(t) regression test** for the whole panel; and, when global convergence is rejected, apply the **clustering algorithm** to split the units into convergence **clubs**, then **merge** adjacent clubs that jointly converge. This is the descriptive question "do these units form one converging group, several catch-up clubs, or none?". The variable is used **as supplied** — no log is taken — so for the canonical income case pass *log* GDP per capita (or log labor productivity). The panel must be **balanced** (every unit present in every period) because the HP filter needs a gap-free series. Parameters ---------- df Balanced panel data frame. var Numeric variable to analyse (e.g. ``"log_gdppc"``). Used as supplied. entity, time Panel identifiers. Default to those declared via :func:`expdpy.set_panel`. filter ``"hp"`` (default) applies the Hodrick-Prescott filter per unit and analyses the **trend**; ``None`` analyses the variable as given (already detrended). hp_lambda HP smoothing parameter (``400`` for annual data, the convergence-literature default). r Initiating sample fraction for the log(t) regression: the first ``round(r*T)`` periods are discarded. Phillips-Sul recommend ``0.3`` for small/moderate ``T`` and ``0.2`` for large ``T``. method Within-club sieve: ``"adjust"`` (default) is the Schnurbus et al. (2016) refinement (add the best candidate one at a time); ``"ps"`` is the original Phillips-Sul (2007) rule that raises the inclusion threshold ``cr`` by ``incr`` until the club converges. merge Adjacent-club merging after clustering: ``"iterative"`` (default) repeats until no clubs merge, ``"single"`` does one pass, ``"none"`` reports the raw clusters. cr, incr, max_cr Sieve threshold and (for ``method="ps"``) its increment and ceiling. fr Cross-section sort key: ``0`` (default) sorts by the last period; ``fr > 0`` sorts by the mean of the last ``(1 - fr)`` fraction of periods (for noisy endpoints). tcrit One-sided convergence critical value for the t-statistic (``-1.65``, the 5% level). title Title for the headline figure. Returns ------- ConvergenceClubsResult The tidy long ``df`` (``entity``, ``time``, ``value`` = trend, ``relative`` = ``h_it``, ``club``); the within-club average figure ``fig``; the all-paths figure ``fig_paths`` and the per-club small-multiples ``fig_clubs``; the classification table ``gt`` / ``summary`` and the ``membership`` frame; the panel dimensions; the whole-panel ``global_beta`` / ``global_tstat`` and ``converged`` flag; and ``n_clubs`` / ``n_divergent``. ``.interpret()`` describes how the panel splits into clubs. Notes ----- The log(t) test regresses, for ``t = [rT] .. T``, .. math:: \log(H_1 / H_t) - 2 \log(\log t) = a + b \log t + \varepsilon_t, where ``H_t = N^{-1} \sum_i (h_{it} - 1)^2`` is the cross-sectional variance of the relative transition paths. Under the null of convergence ``b = 2\alpha \ge 0``; a one-sided ``t_b > -1.65`` fails to reject it. The standard error is the Phillips-Sul scalar long-run variance form ``\hat{\mathrm{var}}(b) = (X'X)^{-1}\hat\omega`` with ``\hat\omega`` an Andrews (1991) quadratic-spectral HAC of the residuals. The clustering sorts units by their final value, forms a core group by maximising ``t_b``, sieves in the remaining units, and recurses on the residual; adjacent clubs are then merged when they jointly converge. This is a faithful port of the Stata ``psecta`` package (Du 2017); see Phillips & Sul (2007, 2009) and Schnurbus et al. (2016). Examples -------- Basic — convergence clubs in log GDP per capita across countries. The panel must be balanced; ``set_labels(..., set_panel=True)`` declares (entity, time) so the call omits ``entity=`` / ``time=`` and the figures get readable labels: ```python import expdpy as ex from expdpy.data import load_productivity, load_productivity_data_def df = ex.set_labels( load_productivity(), load_productivity_data_def(), set_panel=True ) res = ex.analyze_convergence_clubs(df, "log_gdppc") res.fig # within-club average transition paths res.gt # club classification table res.n_clubs, res.converged print(res.interpret()) ``` ### analyze_kuznets_waves(df[, inequality, development, ...]) [Reference](https://cmg777.github.io/expdpy/reference/analyze_kuznets_waves.html) 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 ---------- df Panel data frame. inequality Numeric outcome (an inequality measure such as a Gini). Default ``"gini_regional"``. development Numeric development regressor, used as supplied (typically *log* GDP per capita); its powers ``g^2 .. g^degree`` are formed internally. Default ``"log_gdp_pc"``. controls 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). entity, time Panel identifiers. Default to those declared via :func:`expdpy.set_panel`. degree 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. vcov 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. n_sample 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. seed Seed for the point-subsampling RNG (default 0), for reproducible figures. title Title for the raw figure (the between/within figures get descriptive titles). Returns ------- 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: ```python 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()) ``` Partialling covariates out of the between and within waves, with a cubic specification: ```python 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 ``` ## Learn The teaching layer — concept sandboxes (simulated demonstrations) and concept explainers. Plain-language interpretation lives on the result objects via ``.interpret()``. ### learn_omitted_variable_bias([n, beta_x, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_omitted_variable_bias.html) Show how omitting a correlated confounder biases a regression coefficient. Simulates ``y = beta_x * x + beta_z * z + noise`` where ``x`` and the confounder ``z`` are correlated (``corr_xz``), then compares the *short* regression (omitting ``z``) with the *long* regression (controlling for ``z``). Parameters ---------- n Sample size. beta_x True effect of the focal regressor ``x``. beta_z True effect of the omitted confounder ``z``. corr_xz Correlation between ``x`` and ``z`` (drives the bias). seed Random seed. Returns ------- SandboxResult ``df`` (short vs long vs true coefficient), ``fig``, ``summary`` and ``topic``. Examples -------- These sandboxes generate their own data, so a call needs no DataFrame — turn the knobs via the keyword parameters: ```python import expdpy as ex res = ex.learn_omitted_variable_bias() res.fig ``` ### learn_pooled_vs_fixed_effects([n_units, n_periods, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_pooled_vs_fixed_effects.html) Show how pooled OLS is biased by unit effects, and fixed effects fix it. Simulates a panel where the regressor ``x`` is correlated with each unit's fixed effect. Pooled OLS confounds within- and between-unit variation (biased); the within (fixed- effects) estimator recovers the true slope. Parameters ---------- n_units, n_periods Panel dimensions. beta True within-unit slope. unit_effect_corr Correlation between ``x`` and the unit effect (drives the pooled bias). seed Random seed. Returns ------- SandboxResult ``df`` (pooled vs FE vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_pooled_vs_fixed_effects() res.fig ``` ### learn_clustering_se([n_clusters, cluster_size, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_clustering_se.html) Show that clustering changes the standard error, not the point estimate. Simulates data with cluster-correlated regressor and errors (intra-cluster correlation ``icc``), then compares classical (iid) standard errors with cluster-robust ones for the *same* coefficient. Parameters ---------- n_clusters, cluster_size Number of clusters and observations per cluster. icc Intra-cluster correlation of the errors (drives the standard-error inflation). seed Random seed. Returns ------- SandboxResult ``df`` (iid vs clustered standard error), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own clustered data, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_clustering_se() res.fig ``` ### learn_first_differences([n_units, n_periods, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_first_differences.html) Show that first differencing removes the unit effect — matching the within estimator. Simulates a balanced panel ``y_it = beta * x_it + alpha_i + e_it`` where the regressor ``x`` is correlated with each unit's fixed effect ``alpha_i`` (so pooled OLS is biased). Differencing (``Δy`` on ``Δx``) cancels ``alpha_i``; on a two-period panel the first- differences estimate equals the within (demeaning) estimate, and both recover ``beta``. Parameters ---------- n_units, n_periods Panel dimensions. With ``n_periods=2`` first differences and the within estimator coincide exactly; for more periods they differ slightly in finite samples. beta True within-unit slope. unit_effect_corr Correlation between ``x`` and the unit effect (drives the pooled bias). noise_sd Idiosyncratic noise standard deviation. seed Random seed. Returns ------- SandboxResult ``df`` (pooled vs first differences vs within vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own two-period panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_first_differences() res.fig ``` ### learn_within_vs_lsdv([n_units, n_periods, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_within_vs_lsdv.html) Show that within (demeaning) and least-squares dummy variables give the same slope. Simulates a panel with a unit fixed effect and recovers the slope two ways: the within transformation (demeaning, via absorbed fixed effects) and least-squares dummy variables (one dummy per unit in OLS). By the Frisch-Waugh-Lovell theorem the two slopes are identical for any number of periods — demeaning and unit dummies do the same job. Parameters ---------- n_units, n_periods Panel dimensions (kept modest so LSDV's one-dummy-per-unit design stays cheap). beta True within-unit slope. unit_effect_corr Correlation between ``x`` and the unit effect. noise_sd Idiosyncratic noise standard deviation. seed Random seed. Returns ------- SandboxResult ``df`` (within vs LSDV vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_within_vs_lsdv() res.fig ``` ### learn_beta_convergence([n_units, n_years, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_beta_convergence.html) Show unconditional vs conditional β-convergence on a known-parameter panel. Simulates an AR(1) panel in logs ``x_{t+1} = a + rho*x_t + gamma*z_i + e`` where ``z_i`` is a fixed steady-state determinant correlated (``corr``) with each unit's initial level. The AR(1) persistence pins the truth exactly: over a horizon ``T = n_years - 1`` the slope of growth on the initial level is ``beta = (rho**T - 1) / T`` and the structural speed of convergence is ``lambda = -ln(rho)`` (half-life ``ln 2 / lambda``). Because ``z_i`` is omitted, the **unconditional** regression is biased — units look like they barely converge (or even diverge); conditioning on ``z_i`` recovers the true convergence slope. This is the classic distinction between absolute and conditional convergence, demonstrated with :func:`expdpy.analyze_beta_convergence`. Parameters ---------- n_units, n_years Panel dimensions (units and annual periods). The horizon is ``T = n_years - 1``. rho AR(1) persistence in ``(0, 1)``; it sets the true speed ``-ln(rho)`` (closer to 1 means slower convergence). gamma Loading of the steady-state determinant ``z`` (drives the omitted-variable bias of the unconditional estimate). corr Correlation between ``z`` and the initial level (also drives the bias). noise Idiosyncratic shock standard deviation. seed Random seed. Returns ------- SandboxResult ``df`` (unconditional vs conditional vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own AR(1) panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_beta_convergence() res.fig ``` ### learn_sigma_convergence([n_units, n_years, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_sigma_convergence.html) Show σ-convergence on a panel whose dispersion narrows at a known rate. Simulates a panel in which every unit's value contracts geometrically toward a common mean ``mu``: ``x_{i,t} = mu + (x_{i,0} - mu) * rho**t``. Because the deviations from ``mu`` shrink by a constant factor ``rho`` each period while the mean stays fixed, **every** dispersion measure — the standard deviation, the Gini index and the coefficient of variation — scales as ``rho**t``. The trend of its log on time therefore equals ``ln(rho)`` exactly, the true speed of σ-convergence. Running :func:`expdpy.analyze_sigma_convergence` on the panel should recover that slope for all three measures. Parameters ---------- n_units, n_years Panel dimensions (units and annual periods). The horizon is ``T = n_years - 1``. rho Per-period contraction factor in ``(0, 1)``; the true log-dispersion trend is ``ln(rho)`` (closer to 1 means slower convergence). noise Standard deviation of an optional additive shock (``0`` gives exact recovery). seed Random seed. Returns ------- SandboxResult ``df`` (recovered trend per measure vs the true ``ln(rho)``), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own contracting panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_sigma_convergence() res.fig ``` ### learn_convergence_clubs([n_per_club, levels, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_convergence_clubs.html) Show Phillips-Sul club clustering recovering a *planted* club structure. Builds a panel with ``len(levels)`` known convergence clubs: every unit in club ``k`` starts at its long-run level ``levels[k]`` plus an idiosyncratic deviation that decays geometrically (``deviation * rho**t``), so units within a club converge to a common path while the distinct club levels keep the panel from converging globally. Running :func:`expdpy.analyze_convergence_clubs` on it should reject whole-panel convergence and recover the planted clubs. Demonstrates that the clustering is data-driven, not imposed. Parameters ---------- n_per_club Units per planted club. levels The distinct long-run (log) levels, one per club; well-separated levels give clean clubs. n_years Number of annual periods (the horizon over which deviations decay). rho Per-period decay of the within-club deviations in ``(0, 1)`` (smaller converges faster). spread Half-width of the initial within-club deviation (uniform). noise Idiosyncratic shock standard deviation (kept tiny so the clubs stay sharp). seed Random seed. Returns ------- SandboxResult ``df`` (each unit's planted vs detected club), ``fig`` (the recovered within-club average paths), ``summary`` and ``topic``. Examples -------- This sandbox simulates its own panel with a planted club structure, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_convergence_clubs() res.fig ``` ### learn_kuznets_waves([n_units, n_years, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_kuznets_waves.html) Show the three Kuznets-waves estimators recovering a *planted* polynomial wave. Simulates a panel ``y_it = sum_k betas[k-1] * g_it^k + a_i + d_t + e_it`` where the development index ``g_it = xbar_i + w_it`` mixes a cross-unit component (``between_sd``) and a within-unit component (``within_sd``), and the unit effect ``a_i`` and year effect ``d_t`` are drawn independently of ``g`` (so they bias none of the estimators). Because the planted wave is a within-unit relationship, the **within** (two-way fixed-effects) and **pooled** estimators recover the top-order coefficient, while the **between** estimator — comparing unit averages — differs, since averaging a nonlinear function is not the function of the average. Demonstrated with :func:`expdpy.analyze_kuznets_waves`. Parameters ---------- n_units, n_years Panel dimensions. betas The planted polynomial coefficients ``(b_1, ..., b_degree)`` on ``g, g^2, ...``; its length sets the degree (default the quartic ``(0.5, -0.3, 0.05, 0.04)``). between_sd, within_sd Standard deviations of the cross-unit and within-unit components of ``g``. unit_effect_sd Standard deviation of the unit and year effects in the outcome (independent of ``g``). noise Idiosyncratic shock standard deviation. seed Random seed. Returns ------- SandboxResult ``df`` (the top-order coefficient recovered by each estimator vs the true value), ``fig`` (the within partial-residual wave), ``summary`` and ``topic``. Examples -------- This sandbox simulates its own panel with a planted polynomial wave, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_kuznets_waves() res.fig ``` ### learn_hausman_test([n_units, n_periods, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_hausman_test.html) Show why the Hausman test prefers fixed effects when unit effects are correlated. Simulates a panel where the regressor ``x`` is correlated with each unit's effect. Random effects assumes no such correlation (so it is biased here) while fixed effects is consistent; the Hausman test compares the two and rejects their equality. Parameters ---------- n_units, n_periods Panel dimensions. beta True within-unit slope. unit_effect_corr Correlation between ``x`` and the unit effect (drives the random-effects bias). seed Random seed. Returns ------- SandboxResult ``df`` (FE vs RE vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_hausman_test() res.fig ``` ### learn_correlated_random_effects([n_units, n_periods, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_correlated_random_effects.html) Show the Mundlak (correlated random effects) device recovering the fixed-effects slope. The CRE model adds each unit's time-mean of the regressor to a random-effects specification. Its coefficient on the original ``x`` then equals the within (fixed-effects) estimate, and a Wald test on the added means is the Mundlak (Hausman-equivalent) test. Parameters ---------- n_units, n_periods Panel dimensions. beta True within-unit slope. unit_effect_corr Correlation between ``x`` and the unit effect. seed Random seed. Returns ------- SandboxResult ``df`` (CRE-within vs plain FE vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own panel, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_correlated_random_effects() res.fig ``` ### learn_nickell_bias([n_units, rho, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_nickell_bias.html) Show the Nickell bias in dynamic-panel fixed-effects estimates. The within estimate of a lagged dependent variable is biased downward in short panels and the bias shrinks as the panel lengthens. Simulates a dynamic panel ``y_it = rho*y_{i,t-1} + alpha_i + e`` and estimates ``rho`` by within (fixed-effects) regression at several panel lengths ``T``: for small ``T`` the estimate sits well below the truth; as ``T`` grows it converges up to ``rho``. Parameters ---------- n_units Number of units (kept large so the bias is the dominant signal). rho True autoregressive parameter. periods The panel lengths ``T`` to estimate at. seed Random seed. Returns ------- SandboxResult ``df`` (``periods_T``, ``fe_rho``, ``bias``), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own dynamic panels, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_nickell_bias() res.fig ``` ### learn_measurement_error([n, beta, ...]) [Reference](https://cmg777.github.io/expdpy/reference/learn_measurement_error.html) Show classical measurement-error attenuation: noise in the regressor biases OLS to zero. Simulates ``y = beta*x_true + e`` but regresses on a *noisy* observation ``x_obs = x_true + u``. The OLS slope is attenuated toward zero by the reliability ratio ``var(x_true) / (var(x_true) + var(u))``. Parameters ---------- n Sample size. beta True slope on the (unobserved) ``x_true``. noise_x Standard deviation of the measurement noise added to ``x_true`` (drives the attenuation). seed Random seed. Returns ------- SandboxResult ``df`` (naive vs true slope), ``fig``, ``summary`` and ``topic``. Examples -------- This sandbox simulates its own data, so the call needs no DataFrame: ```python import expdpy as ex res = ex.learn_measurement_error() res.fig ``` ## Utilities Cross-cutting helpers shared across modules — panel declaration, data-dictionary inference, outlier treatment, and the concept-explainer registry. ### set_panel(df[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/set_panel.html) Declare the panel's ``entity``, ``time`` (and optional ``entity_name``) columns on ``df``. The ids are stored under ``df.attrs["expdpy_panel"]`` so that subsequent ``explore_*`` / ``analyze_*`` calls can omit them. Explicit arguments to those functions still take precedence. Parameters ---------- df The panel data frame (modified in place — its ``attrs`` are updated and the same object is returned). entity Name of the cross-sectional (unit) identifier column, or ``None`` to leave it unset. time Name of the time identifier column, or ``None`` to leave it unset. entity_name Name of a column holding a human-readable label for each unit (e.g. ``"country"`` when ``entity`` is an ISO code). When declared, figures render units as ``Name (id)``. ``None`` leaves it unset. Returns ------- pandas.DataFrame The same ``df``, with ``df.attrs["expdpy_panel"]`` updated. Examples -------- Declare the panel once, then explore without repeating the ids: ```python import expdpy as ex from expdpy.data import load_kuznets df = ex.set_panel(load_kuznets(), entity="country", time="year") ex.explore_xtsum_table(df, var=["gini_regional", "log_gdp_pc"]).gt ex.explore_spaghetti_plot(df, var="gini_regional").fig ``` ### resolve_panel(df[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/resolve_panel.html) Resolve the ``(entity, time)`` ids for ``df``: explicit args win, else ``df.attrs``. Parameters ---------- df The panel data frame. entity, time Explicit identifiers. When ``None``, fall back to the values stored by :func:`set_panel` (if any). require_entity, require_time When ``True``, raise :class:`ValueError` if the corresponding id cannot be resolved. Returns ------- tuple of (str or None, str or None) The resolved ``(entity, time)`` column names. Raises ------ ValueError If a resolved column is not present in ``df``, or a required id is unresolved. ### set_roles(df[, outcome, covariates]) [Reference](https://cmg777.github.io/expdpy/reference/set_roles.html) Declare the main ``outcome`` and ``covariates`` on ``df`` and return it. The roles are stored under ``df.attrs["expdpy_roles"]`` so that subsequent ``explore_*`` / ``analyze_*`` calls (and the no-code apps) can default to them when their primary variable argument is omitted. Explicit arguments to those functions still take precedence. Parameters ---------- df The data frame (modified in place — its ``attrs`` are updated and the same object is returned). outcome Name of the main outcome (dependent) variable, or ``None`` to leave it unset. covariates Name(s) of the main covariate(s) — a single column or a sequence — or ``None`` to leave them unset. Returns ------- pandas.DataFrame The same ``df``, with ``df.attrs["expdpy_roles"]`` updated. Examples -------- Declare the key variables once, then explore/analyze without repeating them: ```python import expdpy as ex from expdpy.data import load_kuznets df = ex.set_panel(load_kuznets(), entity="country", time="year") df = ex.set_roles(df, outcome="gini_regional", covariates=["log_gdp_pc"]) ex.explore_scatter_plot(df).fig # x = log_gdp_pc, y = gini_regional ex.analyze_regression_table(df).etable # gini_regional ~ log_gdp_pc ``` ### build_data_def(df[, entity, time, ...]) [Reference](https://cmg777.github.io/expdpy/reference/build_data_def.html) Infer a best-guess data dictionary (``df_def``) for ``df``. Produces one row per column with an inferred ``type`` and a humanized ``label``, ready to pass to :func:`~expdpy.set_labels`. Column-name hints and dtypes drive the guess: a column is typed ``entity`` (name hints like ``country`` / ``iso`` / ``id``, or — failing that — the column that uniquely keys the rows together with the time id), ``time`` (name hints like ``year`` / ``date``, a datetime dtype, or an integer column in the calendar-year range), ``logical`` (boolean or two-valued), ``factor`` (categorical/object, or numeric with at most ``factor_cutoff`` distinct values), else ``numeric``. A best-guess ``role`` is also filled: a text column that is constant within the entity and ~1:1 with it (a readable label for the unit, e.g. a country name beside an ISO code) is tagged ``entity_name``; all other rows are left blank. The analytical roles ``outcome`` / ``covariate`` are never guessed — mark them yourself (in the dictionary or via :func:`~expdpy.set_roles`). Parameters ---------- df The data frame to describe. entity Explicit entity (unit) identifier column name(s); when given, these win over detection (and are validated against ``df``). time Explicit time identifier column name; when given, it wins over detection. factor_cutoff Numeric columns with at most this many distinct values are typed ``factor``. Returns ------- pandas.DataFrame A dictionary frame with columns ``var_name``, ``var_def``, ``label``, ``type``, ``role`` and ``can_be_na`` (one row per column of ``df``, in column order). Examples -------- Build a dictionary for any frame, then attach labels + declare the panel in one step: ```python import expdpy as ex from expdpy.data import load_kuznets ddef = ex.build_data_def(load_kuznets()) df = ex.set_labels(load_kuznets(), ddef, set_panel=True) ddef.head() ``` ### treat_outliers(x[, percentile, truncate, ...]) [Reference](https://cmg777.github.io/expdpy/reference/treat_outliers.html) Treat numerical outliers by winsorizing or truncating. For each numeric variable, values outside the ``[percentile, 1 - percentile]`` quantile range are either clipped to the boundary (winsorizing, the default) or set to ``NaN`` (truncating). Non-finite values (``inf``/``-inf``) are set to ``NaN`` first. Parameters ---------- x A :class:`pandas.Series`, :class:`pandas.DataFrame`, or :class:`numpy.ndarray`. For a DataFrame, only numeric columns are treated; other columns pass through unchanged. percentile The (symmetric) tail probability defining the cut-offs. Must satisfy ``0 < percentile < 0.5``. Defaults to ``0.01``. truncate If ``True``, out-of-bounds values are set to ``NaN``; otherwise they are clipped to the boundary value (winsorizing). Defaults to ``False``. by Optional grouping. Either an array/Series of group labels (same length as ``x``) or, for a DataFrame ``x``, a column name. Outlier cut-offs are then computed within each group. Must not contain missing values. method Quantile interpolation method passed to :func:`numpy.nanquantile`. Defaults to ``"linear"`` (equivalent to R's ``type = 7``). Use ``"averaged_inverted_cdf"`` to match Stata / R ``type = 2``. Returns ------- Same type as ``x`` The outlier-treated data. A DataFrame keeps its non-numeric columns, column order and index; a Series keeps its index and name; an ndarray keeps its shape. Examples -------- Basic — winsorize a single variable at the 1st/99th percentile: ```python import expdpy as ex from expdpy.data import load_kuznets df = load_kuznets() ex.treat_outliers(df["gdp_pc"], percentile=0.01).describe() ``` Advanced — winsorize several columns at the 5th/95th percentile, with the cut-offs computed within each continent: ```python treated = ex.treat_outliers( df[["gini_regional", "gdp_pc"]], percentile=0.05, by=df["continent"] ) treated.describe() ``` ### set_palette(mode) [Reference](https://cmg777.github.io/expdpy/reference/set_palette.html) Switch the global expdpy color palette. The palette is **process-global**: it affects every subsequent expdpy figure — grouped series colors (via :func:`color_for`), the heatmap / scatter color scales, and the registered Plotly template's colorway. The default look is unchanged until you opt in, so existing figures keep their colors unless you call this. Parameters ---------- mode ``"default"`` (the Tableau 10 palette, today's look) or ``"colorblind"`` (the Okabe-Ito colorblind-safe qualitative palette plus colorblind-safe sequential and diverging scales). Raises ------ ValueError If ``mode`` is not a known palette. Examples -------- Switch to the colorblind-safe palette, then restore the default: ```python import expdpy as ex ex.set_palette("colorblind") # every later figure uses the Okabe-Ito palette print(ex.get_palette()) ex.set_palette("default") # restore the Tableau 10 default ``` ### get_palette() [Reference](https://cmg777.github.io/expdpy/reference/get_palette.html) Return the name of the currently active palette (``"default"`` / ``"colorblind"``). ### explain(topic[, lang]) [Reference](https://cmg777.github.io/expdpy/reference/explain.html) Return the :class:`Explainer` for a method or concept. Parameters ---------- topic A topic key or alias (see :func:`list_topics`). lang Language code. Only ``"en"`` ships today; the parameter is reserved so that adding translations later is non-breaking. Returns ------- Explainer The matching explainer. Raises ------ KeyError If ``topic`` is unknown; the message lists the available topics. Examples -------- ```python import expdpy as ex ex.explain("fixed_effects").title ``` ### list_topics() [Reference](https://cmg777.github.io/expdpy/reference/list_topics.html) Return the sorted list of canonical topic keys (for app menus and docs). ## Interactive apps The three no-code ExPdPy apps (Streamlit) — one per module. ### ExploreApp([df]) [Reference](https://cmg777.github.io/expdpy/reference/ExploreApp.html) Launch the **Explore** app — exploratory analysis of panel / cross-sectional data. Same parameters as the other launchers (``df``, ``entity``, ``time``, ``df_def``, ``var_def``, ``config_list``, ``title``, ``components``, server ``run_kwargs`` …); see the module docstring. ``entity`` (the cross-section/unit id) may be a string or a list; ``time`` is the time id. Pass a :class:`pandas.DataFrame` (or ``None`` for the dataset picker / upload dialog). ### AnalyzeApp([df]) [Reference](https://cmg777.github.io/expdpy/reference/AnalyzeApp.html) Launch the **Analyze** app — panel estimators, panel models, CRE and event studies. ### LearnApp([df]) [Reference](https://cmg777.github.io/expdpy/reference/LearnApp.html) Launch the **Learn** app — concept sandboxes and the explainer index. ## Datasets ### load_kuznets() [Reference](https://cmg777.github.io/expdpy/reference/load_kuznets.html) Load the synthetic kuznets dataset (country-year, N-shaped regional Kuznets curve). ### load_kuznets_data_def() [Reference](https://cmg777.github.io/expdpy/reference/load_kuznets_data_def.html) Return variable definitions for :func:`load_kuznets`. ### load_gapminder() [Reference](https://cmg777.github.io/expdpy/reference/load_gapminder.html) Load the gapminder dataset (life expectancy, population, GDP per capita). ### load_staggered_did() [Reference](https://cmg777.github.io/expdpy/reference/load_staggered_did.html) Load the synthetic staggered difference-in-differences dataset. A balanced unit-year panel with several treatment cohorts (and a never-treated control group, ``cohort == 0``) and a known *dynamic* treatment effect — for teaching event-study and staggered-DiD methods via :func:`expdpy.analyze_event_study`. ### load_productivity() [Reference](https://cmg777.github.io/expdpy/reference/load_productivity.html) Load the cross-country labor-productivity convergence panel (PWT9.0, 1990-2014). A balanced annual panel of 108 countries over 25 years with log GDP per capita and log labor productivity (raw, before HP filtering), their levels, and region / income grouping factors. Built for :func:`expdpy.analyze_convergence_clubs` — the flagship dataset for the Phillips-Sul log(t) club-convergence workflow (mendez2020-convergence-clubs). ### load_productivity_data_def() [Reference](https://cmg777.github.io/expdpy/reference/load_productivity_data_def.html) Return variable definitions for :func:`load_productivity`. ### load_bolivia112_gdppc() [Reference](https://cmg777.github.io/expdpy/reference/load_bolivia112_gdppc.html) Load the Bolivian subnational GDP-per-capita panel (112 provinces, 1990-2024). A real-world balanced annual panel of 112 Bolivian provinces (nested within 9 departments) over 35 years with GDP per capita and its natural log. The empirical counterpart to the synthetic :func:`load_productivity` panel — built for the convergence workflows (:func:`expdpy.analyze_beta_convergence` / :func:`expdpy.analyze_sigma_convergence` / :func:`expdpy.analyze_convergence_clubs`) and for general subnational exploration (spaghetti, by-department group views, panel structure, trends). Source: Kummu, Kosonen & Masoumzadeh Sayyar, *Sci Data* 12, 178 (2025), https://doi.org/10.1038/s41597-025-04487-x. ### load_bolivia112_gdppc_data_def() [Reference](https://cmg777.github.io/expdpy/reference/load_bolivia112_gdppc_data_def.html) Return variable definitions for :func:`load_bolivia112_gdppc`. ### load_colonial_origins() [Reference](https://cmg777.github.io/expdpy/reference/load_colonial_origins.html) Load the Colonial Origins cross-section (Acemoglu, Johnson & Robinson 2001). A country-level **cross-section** (163 countries, no time dimension) carrying the variables of the famous settler-mortality IV example: log GDP per capita in 1995, average protection against expropriation risk (the endogenous regressor), and log settler mortality (the instrument), plus geography, colonizer and religion covariates and a ``base_sample`` flag for the 64-country base sample. The canonical teaching dataset for :func:`expdpy.analyze_iv_regression`. Source: Acemoglu, Johnson & Robinson (2001), "The Colonial Origins of Comparative Development", *American Economic Review* 91(5). ### load_colonial_origins_data_def() [Reference](https://cmg777.github.io/expdpy/reference/load_colonial_origins_data_def.html) Return variable definitions for :func:`load_colonial_origins`. ### load_regional_conflict() [Reference](https://cmg777.github.io/expdpy/reference/load_regional_conflict.html) Load the African regional-conflict panel (a focused teaching subset). An unbalanced region-year panel (5,689 African regions across 43 countries, 1994-2010) with conflict indicators, night-time lights (a proxy for local economic activity) and lagged rainfall and drought instruments — all region-detrended (``*_dt``). The canonical teaching dataset for :func:`expdpy.analyze_panel_iv_regression`: it instruments night-lights with lagged weather while absorbing region and year fixed effects (a panel ``xtivreg2 fe``). Source: the regional-conflict / economic-shocks replication data (night-lights, rainfall and UCDP conflict events). ### load_regional_conflict_data_def() [Reference](https://cmg777.github.io/expdpy/reference/load_regional_conflict_data_def.html) Return variable definitions for :func:`load_regional_conflict`. ### get_config(name) [Reference](https://cmg777.github.io/expdpy/reference/get_config.html) Return a startup configuration for ``ExPdPy``. Parameters ---------- name The configuration name (currently only ``"kuznets"``; see :func:`available_configs`). Returns ------- dict The configuration mapping. ## Concepts ### 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 ### Clustered standard errors **What it is.** Clustering allows the regression errors to be correlated *within* groups (e.g. repeated observations of the same country) while remaining independent *across* groups. It changes the standard errors and therefore the t-statistics and p-values — never the coefficients themselves. **When to use it.** Whenever observations are grouped and likely correlated within group: panels (cluster by unit), repeated cross-sections, or treatment assigned at a group level. The cluster should match the level at which shocks are correlated. **Watch out for.** - With few clusters (rule of thumb: fewer than ~40) the asymptotics are unreliable and a wild cluster bootstrap is safer. - Clustering at too fine a level understates uncertainty; choose the level where correlation actually lives. - Clustered standard errors are usually larger than default (iid) ones — if yours shrink, re-check the cluster choice. *See also:* fixed_effects, ols *References:* Cameron & Miller (2015), A Practitioner's Guide to Cluster-Robust Inference ### Convergence clubs (Phillips-Sul log t) **What it is.** Club convergence asks whether a panel forms **one** converging group, **several** catch-up clubs, or none. Phillips & Sul (2007) model each unit as ``X_it = delta_it * mu_t`` — a common trend ``mu_t`` scaled by a time-varying, unit-specific loading ``delta_it`` — and remove the common trend with the **relative transition path** ``h_it = X_it / mean_i(X_it)`` (its cross-sectional mean is 1 by construction). If the units converge, the cross-sectional variance ``H_t = mean_i (h_it - 1)^2`` tends to zero, and the **log(t) regression** ``log(H_1/H_t) - 2 log(log t) = a + b log t`` has a non-negative slope ``b = 2*alpha``; a one-sided ``t_b > -1.65`` fails to reject convergence. When the whole panel rejects, a **data-driven clustering algorithm** sorts units by their final level, forms a core group by maximising ``t_b``, sieves in the remaining units that keep the group converging, recurses on the residual, and finally **merges** adjacent clubs that jointly converge. The series is usually smoothed first with the **Hodrick-Prescott filter** (lambda = 400 for annual data) so the test runs on the long-run trend rather than the business cycle. **When to use it.** Use it when β- and σ-convergence give a muddy verdict — when the panel is plausibly *not* one homogeneous group but several. It is the standard tool for 'multiple equilibria' / poverty-trap questions: convergence clubs in income, labor productivity, carbon intensity, house prices or health, where a subset of units catches up to a high path while others settle on a lower one. It is data-driven (no ex-ante grouping by region or income) and robust to whether the series is trend- or difference-stationary. **Watch out for.** - Club membership is a *descriptive* clustering of transition paths, not a causal account of why a unit lands in a given club. - Results depend on the trimming fraction r (use 0.3 for moderate T, 0.2 for large T), the HP smoothing parameter, and the sorting/sieve options — report them, and check that nearby clubs are not an artefact of the merge rule. - The log(t) t-statistic is asymptotic; with few periods (small T) the test has low power and clubs can be unstable, so prefer longer panels. - Rejecting whole-panel convergence does not by itself prove distinct clubs exist; the algorithm can also return a single divergent group. *See also:* beta_convergence, sigma_convergence, correlation_vs_causation *References:* Phillips & Sul (2007), 'Transition Modeling and Econometric Convergence Tests', Econometrica 75(6): 1771-1855; Phillips & Sul (2009), 'Economic Transition and Growth', JAE 24(7): 1153-1185; Schnurbus, Haupt & Meier (2016), 'Economic Transition and Growth: A Replication', JAE; Du (2017), 'Econometric Convergence Test and Club Clustering Using Stata', Stata Journal 17(4) ### Correlated Random Effects (Mundlak device) **What it is.** The Mundlak device augments a random-effects model with the unit-level mean of each time-varying regressor. The coefficient on the original regressor then equals the fixed-effects (within) estimate, while the coefficient on the mean measures how much the between-unit association differs from the within-unit one. A joint test that the mean coefficients are zero is algebraically the Hausman test, so it makes the fixed-vs-random decision in ordinary, testable terms. **When to use it.** When you want the robustness of fixed effects but also a single, interpretable model that *includes* time-invariant regressors, or when you want the fixed-vs-random-effects decision expressed as testable coefficients rather than a separate test statistic. **Watch out for.** - It recovers the within estimate only for the time-varying regressors; truly time-invariant variables still cannot be separated from the unit effect. - Like random effects, it assumes the *idiosyncratic* error is uncorrelated with the regressors — the device only relaxes correlation that runs through the unit mean. - The mean-coefficient test is the Hausman test, so the same small-sample and power caveats apply. *See also:* fixed_effects, random_effects, hausman *References:* Mundlak (1978), On the Pooling of Time Series and Cross Section Data; Wooldridge, Econometric Analysis of Cross Section and Panel Data, ch. 10 ### Correlation is not causation **What it is.** Two variables can move together because one drives the other, because a third factor drives both (confounding), because of reverse causality, or by chance. A correlation alone cannot tell these apart. **When to use it.** Always keep this in mind when reading a correlation or a regression coefficient: describe what you find as an *association* unless a research design supports a causal claim. **Watch out for.** - A credible causal interpretation needs a design: a randomized experiment, an instrument, a difference-in-differences setup, or a regression-discontinuity. - Confounding, reverse causality and selection are the usual reasons an association is not the causal effect. *See also:* pearson, spearman, ols *References:* Angrist & Pischke, Mastering 'Metrics, ch. 1 ### Descriptive statistics **What it is.** A descriptive table summarizes each variable's central tendency (mean, median), spread (standard deviation, quartiles) and range (min, max), plus the number of non-missing observations. It is the first sanity check on any dataset. **When to use it.** At the very start of an analysis: to spot implausible values, gauge skewness (mean far from median), check units, and see how much data is missing. **Watch out for.** - The mean is sensitive to outliers; compare it with the median to judge skew. - Means computed over a panel mix within-unit and between-unit variation — they do not describe a single unit's typical value. *See also:* winsorize, time_trends *References:* Wooldridge, Introductory Econometrics, ch. 1 ### Least-squares dummy variables (LSDV) **What it is.** LSDV estimates fixed effects directly by adding one dummy variable per unit to an OLS regression. The Frisch-Waugh-Lovell theorem guarantees the slope on the regressors is identical to the within (demeaned) estimate — the dummies and the demeaning do the same job. **When to use it.** As the most transparent way to *see* what fixed effects do, and when you actually want the estimated unit intercepts. For many units the demeaning route is far cheaper for the same slopes. **Watch out for.** - With many units LSDV estimates a huge number of parameters and is slow; the within transformation gives identical slopes at a fraction of the cost. - The estimated unit intercepts are noisy when each unit has few observations (the incidental-parameters problem). *See also:* within_transformation, fixed_effects, fwl *References:* Wooldridge, Introductory Econometrics, ch. 14 ### Event study / staggered difference-in-differences **What it is.** An event study plots the outcome relative to the timing of treatment: coefficients at each *event time* (periods before and after treatment) measure how the treated and control groups diverge, with the period just before treatment (t = -1) as the baseline. Coefficients before t = 0 probe parallel trends; coefficients after t = 0 trace the dynamic treatment effect. **When to use it.** When treatment turns on at different times for different units (staggered adoption) and you want both a pre-treatment check and the path of the effect over time. Modern estimators (Gardner's did2s, Sun-Abraham, local-projections DiD) are robust to the bias that plagues a naive two-way fixed-effects event study under heterogeneous effects. **Watch out for.** - Non-zero pre-treatment coefficients warn that the parallel-trends assumption is questionable — interpret the post-treatment path cautiously. - Two-way fixed-effects (twfe) event studies are biased when effects vary across cohorts or over time; prefer did2s, saturated (Sun-Abraham) or lpdid. - The never-treated or not-yet-treated units form the control group — make sure the cohort variable encodes never-treated correctly. *See also:* parallel_trends, fixed_effects, clustered_se *References:* Callaway & Sant'Anna (2021); Sun & Abraham (2021); Gardner (2022) ### First differences **What it is.** First differencing subtracts each unit's previous-period value from its current value (Δy, Δx). Because a unit's fixed effect is the same in both periods, it cancels in the difference, so a regression of Δy on Δx removes the unit effect — another route to the within slope. **When to use it.** A natural choice with two periods (where it equals the within estimator), or when the idiosyncratic errors are highly persistent, where differencing can be more efficient than demeaning. **Watch out for.** - Differencing drops the first period of every unit, costing observations. - It coincides with the within estimator only for two periods; for more than two the two estimators differ in finite samples. - Differencing can amplify measurement error in the regressor. *See also:* within_transformation, fixed_effects, dummy_variables *References:* Wooldridge, Introductory Econometrics, ch. 13-14 ### Fixed effects **What it is.** Fixed effects add a separate intercept for every level of a grouping variable (e.g. each country, each year). This absorbs all *time-invariant* differences across groups, so the remaining coefficients are identified from variation *within* a group over time rather than from differences *between* groups. **When to use it.** In panel data, to control for stable, unobserved characteristics of units (country institutions, firm culture) and for common shocks in a period (year effects). Two-way (unit + time) fixed effects are the standard panel specification. **Watch out for.** - Fixed effects cannot estimate the effect of anything constant within the group (a country's region, a person's sex) — that variation is absorbed. - They control only for *unobserved* confounders that are constant within the group; time-varying confounders remain a threat. - Many groups with few observations each can leave little within-variation, inflating standard errors. *See also:* ols, clustered_se, fwl *References:* Wooldridge, Introductory Econometrics, ch. 13-14 ### Frisch-Waugh-Lovell (partial regression) **What it is.** The Frisch-Waugh-Lovell theorem says a multivariate coefficient can be recovered in two steps: residualize both the outcome and the focal regressor on all the *other* regressors (and fixed effects), then regress one residual on the other. The slope of that residual scatter equals the focal coefficient in the full model — which is exactly what the partial-regression plot shows. **When to use it.** To *visualize* one coefficient from a multivariate model: the plot reveals non-linearity, influential points and the strength of the partial relationship that a single number in a table hides. **Watch out for.** - If the controls and fixed effects absorb nearly all of the focal regressor's variation, the residualized slope is poorly identified. - The plotted confidence band is the simple residual-regression band; it can differ from the (clustered) standard error reported in the table. *See also:* ols, fixed_effects *References:* Lovell (1963); Frisch & Waugh (1933) ### Hausman test (fixed vs random effects) **What it is.** The Hausman test compares the fixed-effects and random-effects coefficient estimates. Under the random-effects assumption both are consistent and should agree; a large difference signals that the unit effects are correlated with the regressors, so random effects is biased. **When to use it.** To choose between fixed and random effects: reject the null (large statistic, small p-value) and prefer fixed effects; fail to reject and random effects is admissible (and more efficient). **Watch out for.** - Failing to reject is not proof that random effects is correct — it may just be low power. - The covariance difference can be non-positive-definite in finite samples; a generalized inverse is used, which makes the statistic approximate. *See also:* fixed_effects, random_effects *References:* Hausman (1978); Wooldridge, Introductory Econometrics, ch. 14 ### Instrumental variables (2SLS) **What it is.** An instrumental variable is a source of variation in an endogenous regressor that is unrelated to the outcome's error term. Two-stage least squares (2SLS) uses only the part of the regressor predicted by the instruments, isolating variation that is free of the confounding that biases ordinary least squares. The first stage regresses the endogenous regressor on the instruments; the second regresses the outcome on its fitted values. **When to use it.** When a regressor is endogenous — correlated with the error through reverse causation, omitted variables or measurement error — and you have instruments that shift the regressor but are otherwise unrelated to the outcome (e.g. settler mortality for institutions, lagged weather for local economic activity). **Watch out for.** - Instruments must be relevant: a weak first stage (rule of thumb, first-stage F below about 10) makes 2SLS badly biased and its standard errors unreliable. - Instruments must satisfy the exclusion restriction — they may influence the outcome only through the instrumented regressor — which the data cannot verify. - Under heterogeneous responses, 2SLS recovers a local estimand for the units the instruments move (the compliers), not a population-wide average. *See also:* ols, fixed_effects, correlation_vs_causation *References:* Wooldridge, Introductory Econometrics, ch. 15; Angrist & Pischke (2009), ch. 4 ### Kuznets waves (extended Kuznets curve) **What it is.** Kuznets (1955) conjectured an **inverted-U** relationship between income inequality and economic development: as an economy industrializes, inequality first rises (labour shifts from agriculture to higher-paid industry at uneven speeds), then falls (education and redistribution spread the gains). The classic test regresses an inequality measure (a Gini) on log GDP per capita and its **square**; a positive linear term with a negative quadratic term traces the inverted-U. The **Kuznets waves** hypothesis generalizes this: the relationship may be far more nonlinear, so the development term enters up to the **fourth power** — ``gini = f(g, g^2, g^3, g^4)`` with ``g = log GDP per capita``. A cubic admits an N-shape (inequality falls then rises again as a post-industrial, skill-biased phase sets in); a quartic admits a full **wave** with up to three turning points. Estimating the same polynomial under three panel estimators disentangles where the shape lives: **pooled OLS** uses all variation at once, the **between** estimator compares country averages (the cross-country curve), and the **within** (two-way fixed-effects) estimator uses only within-country movements net of common year shocks. Frisch-Waugh-Lovell partial-residual plots show the fitted wave after covariates and fixed effects are partialled out. **When to use it.** Use it to test whether inequality and development trace an inverted-U, an N-shape or a richer wave across a panel of countries or regions, and to check whether that shape is a cross-sectional phenomenon (countries at different stages differ) or a within-country dynamic (a single country's inequality rises then falls as it develops). Comparing the pooled, between and within estimators is the point: if the between curve is hump-shaped but the within curve is flat, the 'curve' is really a comparison of different countries, not a path any one country travels. Raise the polynomial degree only when theory or the data motivate the extra turning points — a higher-order term that is statistically indistinguishable from zero is just overfitting. **Watch out for.** - The Kuznets curve is a *descriptive* pattern, not a causal mechanism; omitted determinants (institutions, technology, trade, redistribution) can generate the same shape. - A significant negative quadratic term is necessary but not sufficient for an inverted-U: the implied peak must lie inside the observed range of development, otherwise the curve is effectively monotonic over the data. - High-order polynomials are unstable at the edges of the data and sensitive to a few extreme units; read the turning points only where the data are dense, and prefer the lowest degree that fits. - The between and within estimators answer different questions and need not agree; the within (fixed-effects) curve discards all cross-country variation, so it can be flat even when the between curve is strongly hump-shaped (and vice versa). - The specific inequality measure matters: a Gini, a top-income share and a Palma ratio can trace different curvature on the same data. *See also:* fwl, fixed_effects, within_between_variation, correlation_vs_causation *References:* Kuznets (1955), 'Economic Growth and Income Inequality', American Economic Review 45(1): 1-28; Gallup (2012), 'Is there a Kuznets Curve?', Portland State University; Palma (2011), 'Homogeneous middles vs. heterogeneous tails', Development and Change 42(1) ### Marginal effects & interactions **What it is.** In a model with an interaction term (``focal * moderator``), the slope of the focal regressor is not one number: it is ``b_focal + b_interaction * moderator``, a line traced across the moderator's range. The marginal effect is that partial derivative, and its confidence band (via the delta method) widens away from the centre of the data. **When to use it.** Whenever a model includes an interaction and you want to read how the focal regressor's association with the outcome changes as the moderator varies — and where, if anywhere, that association is distinguishable from zero. **Watch out for.** - The marginal effect is only meaningful over the moderator's observed range; extrapolating beyond it is unsupported. - Significance varies along the moderator — a marginal effect can be significant at some values and not others, so read the whole band, not one point. - Interactions describe associations; a causal reading still needs a research design. *See also:* ols, fwl, correlation_vs_causation *References:* Brambor, Clark & Golder (2006); Wooldridge, Introductory Econometrics, ch. 6 ### Measurement error (attenuation) **What it is.** When a regressor is observed with classical (mean-zero, independent) noise, ordinary least squares is biased toward zero. The slope is multiplied by the **reliability ratio** — the share of the observed regressor's variance that is signal rather than noise — so a noisier regressor yields a flatter estimated slope. This pull toward zero is called attenuation bias. **When to use it.** A diagnostic mindset whenever a key regressor is a proxy or a survey-reported, rounded, or otherwise mismeasured quantity: the estimated coefficient is likely an understatement of the true one. **Watch out for.** - Attenuation toward zero is the classical case (noise independent of the true value); non-classical error (e.g. correlated with the truth) can bias in either direction. - With a second independent measurement, or an instrument for the mismeasured regressor, the reliability ratio can be estimated and the slope corrected. *See also:* ols, instrumental_variables *References:* Wooldridge, Introductory Econometrics, ch. 9 ### Nickell bias (dynamic panels) **What it is.** In a dynamic panel — one whose regressors include the lagged dependent variable — the within (fixed-effects) estimator is biased in short panels. Demeaning each unit subtracts the unit's mean of the lagged outcome, which is mechanically correlated with the demeaned error, so the estimated persistence is pulled **downward**. The bias is of order 1/T and so shrinks as the number of periods grows. **When to use it.** A warning, not a method: whenever a fixed-effects model includes a lagged dependent variable on a short panel, expect the persistence coefficient to be understated. Long panels (large T) make the bias negligible; short ones call for a dynamic-panel GMM estimator (Arellano-Bond / Blundell-Bond) instead. **Watch out for.** - The bias is largest for short panels and high persistence; it does not vanish by adding more units (it is a small-T, not small-N, problem). - First-differencing has its own version of the problem; instrumenting the lagged difference (GMM) is the standard fix. *See also:* fixed_effects, first_differences *References:* Nickell (1981); Arellano & Bond (1991) ### Ordinary least squares (OLS) **What it is.** OLS fits a straight-line relationship between an outcome and one or more regressors by minimizing the sum of squared residuals. Each coefficient is the average change in the outcome associated with a one-unit increase in that regressor, holding the others fixed. **When to use it.** As the default workhorse for a continuous outcome and a first look at how variables move together. It is fast, transparent, and the baseline every other estimator is compared against. **Watch out for.** - Coefficients are associations, not causal effects, unless a research design (randomization, instrument, difference-in-differences) justifies a causal read. - Default standard errors assume independent, equal-variance errors — rarely true in panel data, where clustered standard errors are usually needed. - Sensitive to outliers and to omitted variables correlated with the regressors. *See also:* fixed_effects, clustered_se, fwl *References:* Wooldridge, Introductory Econometrics, ch. 2-4 ### Omitted-variable bias **What it is.** When a regression leaves out a variable that both belongs in the model and is correlated with an included regressor, the coefficient on that regressor absorbs part of the omitted variable's influence and is biased. The bias equals the omitted variable's effect times how strongly it co-moves with the included regressor. **When to use it.** A diagnostic mindset for any observational regression: ask what is left out and whether it correlates with your regressor of interest. **Watch out for.** - The sign of the bias follows the product of (effect of the omitted variable) and (its correlation with the included regressor). - Adding controls reduces bias only if they are the right controls — conditioning on a collider or a mediator can introduce new bias. *See also:* ols, fixed_effects, correlation_vs_causation *References:* Wooldridge, Introductory Econometrics, ch. 3 ### Panel structure (balance and gaps) **What it is.** A panel is **balanced** when every unit is observed in every period, and **unbalanced** otherwise. Gaps (missing interior periods) and ragged start/end dates change how many observations each unit contributes. **When to use it.** As a first diagnostic on any panel: to see how many units and periods you have, whether attrition or late entry is present, and how much the within estimator has to work with per unit. **Watch out for.** - Unbalanced panels are usually fine to estimate, but if the reason data is missing relates to the outcome (selection), estimates can be biased. - When the set of units changes across periods, a raw time trend mixes real change with compositional change. *See also:* within_between_variation, time_trends *References:* Wooldridge, Introductory Econometrics, ch. 13-14 ### The parallel-trends assumption **What it is.** Difference-in-differences identifies a treatment effect by assuming that, absent treatment, treated and control groups would have followed *parallel* paths. The treatment effect is the deviation of the treated group from that counterfactual parallel path. **When to use it.** It underlies every DiD and event-study estimate. The pre-treatment coefficients of an event study are the main diagnostic: if they are flat and near zero, the assumption is more credible. **Watch out for.** - Parallel trends is an assumption about an unobserved counterfactual — flat pre-trends are supportive evidence, not proof. - Anticipation effects (units reacting before treatment) violate it and show up as non-zero coefficients just before t = 0. *See also:* event_study, fixed_effects *References:* Angrist & Pischke, Mostly Harmless Econometrics, ch. 5 ### Pearson correlation **What it is.** Pearson's r measures the strength of the *linear* relationship between two numeric variables, ranging from -1 (perfect negative line) through 0 (no linear relationship) to +1 (perfect positive line). **When to use it.** For roughly linear relationships between continuous variables with no extreme outliers — a quick first read of how two variables move together. **Watch out for.** - Only captures *linear* association: a strong curved (e.g. U-shaped) relationship can have r near zero. - Highly sensitive to outliers, which can manufacture or hide a correlation. - A correlation is not a causal effect. *See also:* spearman, correlation_vs_causation *References:* Wooldridge, Introductory Econometrics, ch. 2 ### Random effects **What it is.** The random-effects estimator treats each unit's effect as a random draw uncorrelated with the regressors. It uses both within- and between-unit variation, making it more efficient than fixed effects — but only valid when that no-correlation assumption holds. **When to use it.** When unit effects are plausibly unrelated to the regressors (e.g. random sampling of units) and you want to estimate effects of time-invariant variables, which fixed effects cannot. **Watch out for.** - If the unit effects are correlated with the regressors, random effects is biased — the Hausman test checks exactly this against fixed effects. - More efficient than fixed effects only when its assumption holds. *See also:* fixed_effects, hausman *References:* Wooldridge, Introductory Econometrics, ch. 14 ### Sigma convergence **What it is.** σ-convergence asks whether the *cross-sectional dispersion* of a variable shrinks over time — whether units become more alike. At each period the dispersion is measured across units (the **standard deviation**, the **Gini index**, the **coefficient of variation**), and the test regresses the **log dispersion** on time: a **negative** slope means dispersion falls by a roughly constant proportion each period, the hallmark of σ-convergence. It is the distributional complement to β-convergence: β-convergence (poorer units growing faster) is *necessary but not sufficient* for σ-convergence, because new shocks can re-spread the distribution even while laggards catch up (Quah's critique). **When to use it.** Use it to describe whether a cross-section is compressing or fanning out over time — income or productivity across regions, test scores across schools, health across countries. Pair it with β-convergence: β answers 'do laggards grow faster?' while σ answers 'is the whole distribution narrowing?'. Report several dispersion measures, since the standard deviation is scale-dependent while the Gini and the coefficient of variation are scale-free. **Watch out for.** - σ-convergence is a *descriptive* statement about the distribution, not a causal mechanism; a narrowing spread does not say why units converged. - The standard deviation is in the variable's own units and grows with its level; the Gini and the coefficient of variation are scale-free and often tell a clearer story — compare them. - Dispersion is only comparable across periods when the set of units is fixed, so a balanced panel is required; a changing composition can masquerade as convergence or divergence. - The Gini index is only defined for non-negative values, and the coefficient of variation is unstable when the mean is near zero. *See also:* beta_convergence, fwl, correlation_vs_causation *References:* Barro & Sala-i-Martin, Economic Growth (2nd ed.), ch. 11; Sala-i-Martin (1996), 'The Classical Approach to Convergence Analysis', EJ; Quah (1993), 'Galton's Fallacy and Tests of the Convergence Hypothesis', SJE ### Spearman rank correlation **What it is.** Spearman's rho is Pearson's correlation computed on the *ranks* of the data. It measures whether two variables move together monotonically, regardless of whether the relationship is a straight line. **When to use it.** When the relationship is monotonic but not linear, when variables are skewed, or when outliers would distort Pearson's r. Comparing the two is itself informative. **Watch out for.** - Captures monotonic (always-up or always-down) association only — a non-monotonic relationship can still show a small rho. - A large gap between Pearson and Spearman is a clue: non-linearity or outliers. *See also:* pearson, correlation_vs_causation *References:* Wooldridge, Introductory Econometrics, ch. 2 ### Time trends **What it is.** A trend graph plots the average of a variable in each period, with a standard-error band, to show how it evolves over time across the panel. **When to use it.** To see the overall time pattern (growth, decline, cycles, structural breaks) before modelling, and to motivate the inclusion of year fixed effects. **Watch out for.** - A cross-sectional average over time can hide divergent paths of individual units (Simpson's paradox); compare with a by-group trend. - If the panel is unbalanced, the set of units in the average changes across periods, which can drive apparent trends. *See also:* descriptive_stats, fixed_effects *References:* Wooldridge, Introductory Econometrics, ch. 10 ### Transition matrix **What it is.** A transition matrix counts how often units move from one state to another between consecutive periods. Row-normalized, cell (i, j) is the probability of being in state j next period given state i this period; the diagonal measures persistence (staying put). Continuous variables are first binned (e.g. into quantiles) to form states. **When to use it.** To study mobility and persistence: income or rating mobility, whether countries stay in the same development tier, how sticky a categorical status is over time. **Watch out for.** - Transitions are only counted between consecutive observed periods; gaps are skipped, which can understate movement. - Quantile bins are computed on the pooled distribution, so 'moving up a bin' is relative to all units and periods, not an absolute threshold. *See also:* within_between_variation, descriptive_stats *References:* Markov chains; Quah (1993) on income distribution dynamics ### Truncating **What it is.** Truncating sets values beyond a chosen percentile to missing (NaN), effectively *dropping* the extreme observations from any analysis that needs that variable. Unlike winsorizing, it reduces the sample. **When to use it.** When extreme values are likely data errors rather than genuine observations, and removing them is more defensible than capping them. **Watch out for.** - Reduces the sample size and can introduce selection bias if the dropped points are not random. - Different variables truncated separately can leave different samples — watch the observation counts. *See also:* winsorize *References:* Wooldridge, Introductory Econometrics, ch. 9 ### Winsorizing **What it is.** Winsorizing caps extreme values at a chosen percentile (e.g. the 1st and 99th): values beyond the cutoff are *replaced* by the cutoff value rather than removed. The number of observations is preserved. **When to use it.** When a few extreme values would dominate means, regressions or correlations, but you want to keep every observation in the sample. **Watch out for.** - It is a discretionary transformation — report the percentile you used and check that results are not driven by it. - Caps distort the tails of the distribution; do not winsorize variables whose tails are the object of study. *See also:* truncate *References:* Tukey (1962), The Future of Data Analysis ### Within and between variation **What it is.** Any panel variable varies in two ways: **between** units (some units sit higher than others on average) and **within** units (a unit moves up and down over time). The xtsum decomposition reports the standard deviation of each component; the within-vs-between scatter shows how a relationship looks through each lens. **When to use it.** Before choosing an estimator: if almost all of a regressor's variation is between units, a fixed-effects (within) model has little signal to work with. Comparing the between and within slopes also reveals whether unit-level confounders are distorting a pooled relationship. **Watch out for.** - The decomposition is exact only for balanced panels; for unbalanced panels the between/within split is approximate. - A variable that is constant within units (e.g. a country's region) has zero within variation and drops out of a fixed-effects model. *See also:* fixed_effects, descriptive_stats *References:* Wooldridge, Econometric Analysis of Cross Section and Panel Data, ch. 10 ### The within transformation (demeaning) **What it is.** The within transformation subtracts each unit's own time-average from every variable, so a unit's constant characteristics — including its unobserved fixed effect — cancel out. Regressing the demeaned outcome on the demeaned regressors gives the fixed-effects estimate using only variation *within* each unit over time. **When to use it.** It *is* the fixed-effects estimator: use it whenever you want to control for stable, unobserved unit characteristics. It is the route most software (including pyfixest) takes, because it avoids estimating one dummy per unit. **Watch out for.** - Anything constant within a unit is demeaned away and cannot be estimated. - On a two-period panel it is identical to first differences; for more periods the two differ in how they weight the data, though both are consistent. - Demeaning uses a degree of freedom per unit — account for that when computing standard errors by hand. *See also:* fixed_effects, first_differences, dummy_variables *References:* Wooldridge, Introductory Econometrics, ch. 14 ## Articles ### Home [Article](https://cmg777.github.io/expdpy/index.html) --- --- ![expdpy — A Python library to explore panel data interactively](images/hero.webp){fig-align="center" fig-alt="expdpy — A Python library to explore panel data interactively"} ::: {.text-center} **Explore, analyze and learn panel data — interactively, in Python.** ::: `expdpy` pairs composable functions that return interactive [Plotly](https://plotly.com/python/) figures and publication-quality [Great Tables](https://posit-dev.github.io/great-tables/) with **fixest-style econometrics**, a built-in **teaching layer** that interprets and explains every result, and **three no-code apps**. It is built for students, teachers and applied researchers alike. ::: {.grid} ::: {.g-col-12 .g-col-md-4 .module-card} ### [🔍 Explore](explore.qmd) Describe and visualize your panel: tables, distributions, missing-value maps, time trends, group comparisons, scatter plots, **within/between variation** and **panel dynamics**. ::: ::: {.g-col-12 .g-col-md-4 .module-card} ### [🧮 Analyze](analyze.qmd) Estimate models: fixed / random / **correlated random effects**, FWL, the Hausman test, robust inference, **event-study / DiD**, **β/σ/club convergence** and the **Kuznets-waves** curve. ::: ::: {.g-col-12 .g-col-md-4 .module-card} ### [📚 Learn](learn.qmd) See the ideas behind the methods: **9 runnable concept sandboxes** where you tune a known truth, a **27-topic** explainer index, and a plain-language reading on every result. ::: ::: ## Try the apps in your browser {#try-the-apps} No install, no code — the three `ExPdPy` apps run the whole workflow in your browser: a sample pipeline, point-and-click analysis, sortable tables, and reproducible notebook export. Each is the no-code companion to a docs case study. ## What's inside **Explore** — descriptive / correlation / extreme-observation tables, histograms and bar charts, time and quantile trends, by-group bar / violin / trend views, a missing-value heatmap, scatter plots with an optional LOESS smoother, the within/between (`xtsum`) decomposition, per-unit trajectories, panel-structure diagnostics, distribution & transition dynamics, and `treat_outliers`. **Analyze** — OLS with **multi-way fixed effects** and **clustered standard errors** via [pyfixest](https://github.com/py-econometrics/pyfixest); a richer `analyze_estimation` (stepwise / multiple-outcome, Newey–West & Driscoll–Kraay SEs); **pooled / between / fixed / random effects** and the **correlated-random-effects (Mundlak)** estimator; the **Hausman test**; post-estimation (fixed-effect plots, predictions, Wald joint tests); **robust inference** (randomization inference, wild cluster bootstrap); **Frisch–Waugh–Lovell** and **coefficient** plots; modern **event-study / staggered difference-in-differences** (`did2s`, Sun–Abraham, LP-DiD, dynamic TWFE); **β-, σ- and club convergence**; and the **Kuznets-waves** curve under pooled / between / within estimators. **Learn** — every result speaks plain language: `.interpret()` gives an **associational** reading (never a causal claim unless the design supports it) and `.explain()` / `explain(topic)` / `list_topics()` browse **27** concept explainers. **Nine concept sandboxes** simulate data so you can *see* and tune a known truth — the first-differences ≈ demeaning ≈ dummies identity, fixed effects, clustering, omitted-variable bias, β/σ/club convergence, and the Kuznets wave. **Bundled datasets** — `expdpy.data` ships ready-to-explore panels: **`kuznets`** (the flagship N-shaped Kuznets-curve demo), `gapminder`, **`staggered_did`** (event-study / DiD), **`productivity`** and **`bolivia112_gdppc`** (convergence). See the [kuznets dataset](explanation/kuznets-dataset.qmd) page for the data dictionary. ## Installation Install the latest release from PyPI (random effects, CRE and the Hausman test work out of the box; the apps need the `streamlit` extra): ```bash pip install expdpy pip install "expdpy[streamlit]" # the no-code ExPdPy apps (Streamlit) ``` Using [uv](https://docs.astral.sh/uv/): ```bash uv pip install expdpy uv pip install "expdpy[streamlit]" ``` For the latest unreleased version, install straight from the `main` branch: ```bash pip install "git+https://github.com/cmg777/expdpy.git" ``` Requires Python 3.10+. ## At a glance The lead example throughout these docs is the bundled `kuznets` panel (80 countries × 2015–2025): a synthetic dataset whose regional inequality traces an **N-shaped Kuznets curve** in income — rising, falling, then rising again at very high income. ```python import expdpy as ex from expdpy.data import load_kuznets df = load_kuznets() # The N-shaped regional Kuznets curve: regional inequality vs (log) GDP per capita ex.explore_scatter_plot( df, x="log_gdp_pc", y="gini_regional", color="continent", size="population", loess=1 ).fig ``` **Run a regression and let it explain itself** — two-way fixed effects, clustered standard errors, a plain-language reading, and a coefficient plot: ```python res = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ) print(res.interpret()) # plain-language, associational reading ex.analyze_coefficient_plot(res) # themed coefficient plot with confidence intervals ``` **Learn as you go** — concept sandboxes and explainers: ```python ex.learn_first_differences() # first differences ≈ demeaning ≈ dummy variables print(ex.explain("fixed_effects")) # a concept explainer; ex.list_topics() lists all 27 ``` Head to [Explore](explore.qmd), [Analyze](analyze.qmd) and [Learn](learn.qmd) to see every function in action, or the [kuznets dataset](explanation/kuznets-dataset.qmd) page for the data dictionary. ## Built on `expdpy` stands on the modern Python data and econometrics stack: - **[Plotly](https://plotly.com/python/)** — interactive figures - **[pyfixest](https://github.com/py-econometrics/pyfixest)** — fixed-effects and difference-in-differences estimators - **[Great Tables](https://posit-dev.github.io/great-tables/)** — publication-quality tables - **[linearmodels](https://bashtage.github.io/linearmodels/)** — random / between / correlated random effects and the Hausman test - **[Streamlit](https://streamlit.io/)** — the no-code `ExPdPy` apps ## Acknowledgement expdpy began as a Python port of the excellent [ExPanDaR](https://github.com/trr266/ExPanDaR) R package by **Joachim Gassen** and the **TRR 266 Accounting for Transparency** project, and its foundations remain deeply inspired by that work. Over time, expdpy has grown well beyond the original — fixest-style estimators, event-study / difference-in-differences tools, random- and correlated-random-effects panel models, convergence analysis, and a built-in teaching layer that interprets and explains results — and it will keep evolving. We are grateful to the ExPanDaR authors. Please cite the original work when using `expdpy` in research (see [`CITATION.cff`](https://github.com/cmg777/expdpy/blob/main/CITATION.cff)). ### Explore [Article](https://cmg777.github.io/expdpy/explore.html) The **Explore** module is your first look at a dataset — before you fit a single model. This page is a **case study**: you have just been handed a country–year panel and asked whether inequality follows the famous **Kuznets curve** in income — rising, then falling, as economies develop — and what *determines* it. We will answer that the way an analyst actually works: **explore first**, model later. The lead dataset is the bundled `kuznets` panel (80 countries observed every year over 2015–2025). Its regional inequality measure (`gini_regional`) is engineered to trace an **N-shaped** Kuznets pattern in (log) GDP per capita, surrounded by a realistic set of *determinants* — trade openness, resource rents, democracy, schooling, FDI, population. Every Explore function takes a `pandas` DataFrame and returns a small **result object** carrying a tidy `.df` plus an interactive [Plotly](https://plotly.com/python/) figure (`.fig`) or a publication-quality [Great Tables](https://posit-dev.github.io/great-tables/) object (`.gt`). Many also offer a plain-language `.interpret()`. Read this page top to bottom: the functions are ordered as a **workflow** — *know the panel → describe it → split its variation → trace its trends → compare groups → study relationships → measure its dynamics*. ::: {.callout-note} This is **exploratory** analysis: every reading below describes an *association*, never a cause. The [Analyze](analyze.qmd) module turns these patterns into estimates, and [Learn](learn.qmd) explains the ideas behind them. ::: ## Stage 0 — Set up the panel A panel has two coordinates: an **entity** (here the country) and a **time** index (the year). Declaring them once means every call below can omit them, and panel-aware tables know to summarize *by period*. `set_labels` does double duty: it attaches the data dictionary's human-readable labels (so figures say "Regional inequality (Gini)" instead of `gini_regional`), and with `set_panel=True` it also reads the `entity` / `time` tags from that dictionary and declares the panel. ```python 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) df[["country", "continent", "year", "gini_regional", "gdp_pc", "log_gdp_pc"]].head() ``` You can also declare the panel directly with `set_panel`, and read back what is stored with `resolve_panel` — handy when a step (a merge, a column subset) drops the metadata and you need to re-declare it: ```python ex.set_panel(df, entity="country", time="year") ex.resolve_panel(df) # -> ('country', 'year') ``` ## Stage 1 — Know the panel's skeleton Before looking at a single value, learn the *shape* of the data. Is the panel balanced? Are there gaps? Where is information missing? A surprising amount of analysis goes wrong because this step was skipped. `explore_panel_structure` summarizes balance and coverage and draws a **presence grid** (one row per country, one column per year): solid means observed, blank means missing. ```python structure = ex.explore_panel_structure(df) structure.gt ``` ```python structure.fig ``` Some variables are missing far more often than others. `explore_missing_values_plot` maps the fraction missing by variable and year — notice the determinants (`resource_rents`, `polity2`, `school_enrollment`, `gasoline_price`) are patchier, and heavier in the early years. Knowing this *now* prevents a model later from silently dropping half your sample. ```python ex.explore_missing_values_plot(df).fig ``` `explore_value_heatmap` shows the raw outcome across the whole country-by-year grid. Standardizing **by time** (a z-score within each year) strips out the common trend so that what remains is each country's *relative* position — who is persistently more unequal than its peers. ```python ex.explore_value_heatmap(df, var="gini_regional", standardize="by_time").fig ``` ## Stage 2 — Clean and describe the variables Now meet the variables one at a time: their level, spread, and shape. Several determinants are right-skewed (GDP per capita, resource rents, aid): a handful of huge values can dominate a correlation or a scatter. `treat_outliers` **winsorizes** — it caps values at the 1st/99th percentile rather than dropping rows. We build a cleaned `analysis` frame here and reuse it for the relationship views in Stage 6. ```python cols = [ "gini_regional", "gdp_pc", "log_gdp_pc", "trade_share", "resource_rents", "school_enrollment", "polity2", ] analysis = ex.set_labels( ex.treat_outliers(df[cols], percentile=0.01), load_kuznets_data_def() ) analysis.describe().round(2) ``` `explore_descriptive_table` reports the standard statistics for every numeric variable. Because the panel is declared, it lays them out **by period** — here the first and last year — so you can read level *and* change at a glance. The note beneath records the panel's dimensions and which variables carry missing data. ```python ex.explore_descriptive_table(df, periods=[2015, 2025]).gt ``` `explore_histogram` shows the distribution of one variable. The outcome `gini_regional` is fairly symmetric; switch the variable to `gdp_pc` and you will see the long right tail that motivates the **log** transform used throughout the Kuznets literature (and provided here as `log_gdp_pc`). ```python ex.explore_histogram(df, "gini_regional", kde=True).fig ``` `explore_bar_plot` counts the levels of a categorical variable — how many observations fall in each continent. ```python ex.explore_bar_plot(df, "continent").fig ``` `explore_ext_obs_table` lists the most extreme observations — the country-years with the highest and lowest inequality. Extremes are where data errors hide, and where the substantive story is often most vivid. ```python ex.explore_ext_obs_table(df, n=5, var="gini_regional", entity=["country"], time="year").gt ``` ## Stage 3 — Two kinds of variation: within vs between This is the idea that makes panel data special. Every variable varies in **two** directions: across countries (*between*) and over time within a country (*within*). Mixing them up is the classic panel mistake, so it pays to separate them up front. `explore_xtsum_table` decomposes each variable's variation into overall, between, and within components (the Stata `xtsum` you may know). If almost all of a variable's variation is *between* countries, then over an 11-year window it is nearly a fixed trait; if much is *within*, it genuinely moves over time. ```python xt = ex.explore_xtsum_table(df, var=["gini_regional", "log_gdp_pc", "trade_share"]) xt.gt ``` ```python print(xt.interpret()) ``` `explore_spaghetti_plot` makes the within variation visible: one faint line per country plus a bold mean overlay. Here are the development trajectories — most countries drift upward together, which is exactly the kind of common time trend a panel model will later absorb. ```python ex.explore_spaghetti_plot(df, var="log_gdp_pc").fig ``` ## Stage 4 — Trends over time How does inequality evolve for the panel as a whole? `explore_trend_plot` plots the cross-country mean each year with standard-error bars — the average trajectory. ```python ex.explore_trend_plot(df, var=["gini_regional"]).fig ``` A mean can hide what happens in the tails. `explore_quantile_trend_plot` tracks several quantiles at once, so you can see whether the *spread* of inequality across countries is widening or narrowing over time. ```python ex.explore_quantile_trend_plot(df, var="gini_regional").fig ``` `explore_distribution_over_time` shows the *whole* distribution shifting year by year as a **ridgeline** — each ridge is one year's density. Is the distribution simply sliding, or is it also changing shape (skew, bimodality)? ```python ex.explore_distribution_over_time(df, var="gini_regional").fig ``` ## Stage 5 — Compare groups The panel groups countries by continent (and flags federal states). Comparing across these groups is often the first hint of *what* drives the outcome. `explore_bar_plot_by_group` reduces each group to a single statistic — here the mean level of inequality per continent. ```python ex.explore_bar_plot_by_group(df, "continent", "gini_regional").fig ``` A bar hides the spread inside each group. `explore_violin_plot_by_group` draws the full distribution per continent, so you can see overlap and outliers a mean would mask. ```python ex.explore_violin_plot_by_group(df, "continent", "gini_regional").fig ``` `explore_box_plot` gives the same comparison as boxes — and because we have a panel, passing `time=` turns it into an animation: press the **▶** button (bottom-left, beside the year slider) to watch each continent's distribution move year by year, and drag the slider to pause on a year. The value axis stays pinned so the motion is comparable. Extra arguments tune the view — here we order the continents by their mean and drop the whisker points: ```python ex.explore_box_plot( df, "continent", "gini_regional", time="year", order_by_mean=True, points=False ).fig ``` Every `explore_*` distribution result reads itself back in plain language — `.interpret()` narrates how the pooled median and the between-group gap shifted: ```python print(ex.explore_box_plot(df, "continent", "gini_regional", time="year").interpret()) ``` `explore_strip_plot` shows every region as a point instead of a summary — useful when a group has only a handful of members. **Hover a point** and its unit's name appears in the info box. It animates over time the same way (large panels are thinned to `max_units` points to stay smooth): ```python ex.explore_strip_plot(df, "continent", "gini_regional", time="year", alpha=0.5).fig ``` `explore_trend_plot_by_group` puts time back in: one trend line per continent, revealing whether the groups move together or diverge. ```python ex.explore_trend_plot_by_group(df, group_var="continent", var="gini_regional").fig ``` ### Composition — who makes up the whole, and how it shifts Group comparisons ask *how big* each group is on average; composition views ask *what share* of a total each part holds. `explore_treemap_plot` nests countries within continents, sizes each tile by population and colors it by inequality, and — with `time=` — animates the whole picture across the years (same **▶** control, bottom-left). The color scale is held fixed across frames so the shifts are comparable. ```python ex.explore_treemap_plot( df, path=["continent", "country"], size="population", color="gini_regional", time="year" ).fig ``` `explore_sunburst_plot` is the radial counterpart: the same hierarchy as concentric rings, so nested part-to-whole shares read at a glance. ```python ex.explore_sunburst_plot( df, path=["continent", "country"], size="population", color="gini_regional", time="year" ).fig ``` ## Stage 6 — Relationships and the Kuznets curve Now the central question. We work on the winsorized `analysis` frame so a few extreme values do not distort the picture. `explore_correlation_table` reports pairwise associations — Pearson above the diagonal, Spearman (rank) below, significant cells in bold. This is the quickest scan of which determinants co-move with inequality. ```python ex.explore_correlation_table(analysis).gt ``` `explore_correlation_plot` shows the same matrix as a heatmap (an `ellipse` style is also available) — easier to spot blocks of related variables at a glance. ```python ex.explore_correlation_plot(analysis).fig ``` And here it is — the **N-shaped Kuznets curve**. `explore_scatter_plot` puts inequality against (log) GDP per capita, colored by continent and sized by population, with a LOESS smoother that traces the rise–fall–rise without assuming any functional form. ```python ex.explore_scatter_plot( df, x="log_gdp_pc", y="gini_regional", color="continent", size="population", loess=1 ).fig ``` But is that curve a story about *rich versus poor countries* (between) or about *a country getting richer over time* (within)? They can even have opposite signs — Simpson's paradox for panels. `explore_scatter_plot_within_between` splits the relationship into pooled, between, and within clouds, each with its own fitted slope, so you can see which one the raw scatter was really showing. ```python wb = ex.explore_scatter_plot_within_between(df, x="log_gdp_pc", y="gini_regional") wb.fig ``` ```python print(wb.interpret()) ``` ## Stage 7 — Dynamics and persistence A panel lets you ask a question a cross-section cannot: is a country's inequality **sticky**? `explore_transition_matrix` bins inequality into states (here quartiles) and tabulates how often a country moves between them from one year to the next. A heavy diagonal means high persistence — where you start is where you stay. ```python ex.explore_transition_matrix(df, var="gini_regional", n_bins=4).fig ``` `explore_within_persistence` measures the same idea continuously: this year's value against last year's, within country, with the AR(1) serial-correlation `rho`. The closer `rho` is to 1, the more inequality this year is just a near-copy of last year's. ```python persistence = ex.explore_within_persistence(df, var="gini_regional") persistence.fig ``` ```python print(persistence.interpret()) ``` ## Where to go next You now have the full exploratory picture: a balanced panel with patchy determinants, an N-shaped curve that is mostly a *between*-country pattern, and an inequality measure that is highly persistent. The natural next step is to estimate it. - [Analyze panel data](analyze.qmd) — fit the cubic Kuznets curve with **two-way fixed effects** and clustered standard errors, then compare pooled / between / within estimators. - [Learn panel data](learn.qmd) — the ideas behind within vs between, fixed effects and demeaning, with runnable sandboxes. ### Analyze [Article](https://cmg777.github.io/expdpy/analyze.html) The **Analyze** module is where exploration becomes estimation. This page is a **case study**: having explored the country–year panel (see [Explore](explore.qmd)), you now want to *model* the **Kuznets curve** — does regional inequality rise then fall as economies develop? — and stress-test the answer the way a careful analyst would. We move through a single, intuitive workflow: **fit a first model → respect the panel structure → enrich the estimation → read the fitted model → stress-test the inference → choose the right panel estimator → assemble the flagship curve → ask a related convergence question → and finally a causal design.** Every Analyze function appears once, each with a note on *why* you reach for it at that step. The lead dataset is the bundled `kuznets` panel (80 countries observed every year over 2015–2025), whose inequality measure (`gini_regional`) traces an **N-shaped** pattern in (log) GDP per capita — a cubic — surrounded by realistic *determinants*: trade openness, resource rents, democracy, schooling, FDI, population. Every Analyze function takes a `pandas` DataFrame (or a fitted result) and returns a small **result object** carrying a tidy `.df`, plus a publication-quality table (`.etable` / `.gt`) or an interactive [Plotly](https://plotly.com/python/) figure (`.fig`). Most also offer a plain-language `.interpret()`. Estimation runs on [pyfixest](https://py-econometrics.github.io/pyfixest/) and [linearmodels](https://bashtage.github.io/linearmodels/) under the hood — you never hand-roll an OLS. ::: {.callout-note} Every reading below describes an **association**, never a cause — even the fixed-effects and event-study results. The `.interpret()` text is deliberately associational; the [Learn](learn.qmd) module explains the assumptions a causal claim would additionally require. ::: ## Stage 0 — Set up the panel A panel has two coordinates: an **entity** (here the country) and a **time** index (the year). `set_labels` attaches the data dictionary's human-readable labels (so tables and axes read "Regional inequality (Gini)" instead of `gini_regional`), and with `set_panel=True` it also reads the `entity` / `time` tags and **declares the panel** — so every estimator below can omit `entity=` / `time=` and the fixed-effects and clustering defaults just work. ```python 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) df[["country", "year", "gini_regional", "log_gdp_pc", "trade_share"]].head() ``` ## Stage 1 — A first model, and why fixed effects Start simple. `analyze_regression_table` fits OLS of inequality on a **cubic** in log GDP per capita — the functional form that can bend up, down, then up again (the N). A *pooled* regression treats every country-year as an independent draw: ```python pooled = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"] ) pooled.etable ``` But `kuznets` is a country–year panel, and pooled OLS confounds two very different comparisons: *rich versus poor countries* and *a country as it grows richer over time*. The standard fix is to absorb **two-way (country + year) fixed effects** — identifying the curve from within-country movement, net of common shocks — with standard errors **clustered by country**: ```python fe = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ) fe.etable ``` Every result can explain itself in plain, associational language: ```python print(fe.interpret()) ``` `analyze_coefficient_plot` puts the two specifications side by side, so you can *see* how absorbing fixed effects moves each coefficient and its confidence interval: ```python ex.analyze_coefficient_plot( [pooled, fe], model_labels=["Pooled OLS", "Two-way FE"] ).fig ``` The same coefficient, *seen*. `analyze_fwl_plot` uses the **Frisch–Waugh–Lovell** theorem: it residualizes both the outcome and the focal regressor (`log_gdp_pc`) on the *other* terms **and** the fixed effects, then scatters the two residuals. The fitted slope equals the focal coefficient in the table above — the multivariate estimate, reduced to a single readable picture. ```python ex.analyze_fwl_plot( df, dv="gini_regional", var="log_gdp_pc", controls=["log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ).fig ``` ## Stage 2 — Enrich the estimation `analyze_estimation` is the richer companion to the regression table: same OLS / fixed-effects core, plus **stepwise** model comparison, multiple outcomes, weights, and serial-correlation-robust standard errors (**Newey–West**, **Driscoll–Kraay**). A *cumulative-stepwise* (`csw`) comparison adds one curvature term at a time — watch the linear coefficient move as the quadratic and cubic enter, the signature of a genuinely non-linear relationship: ```python ex.analyze_estimation( df, dv="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], stepwise="csw", ).etable ``` ## Stage 3 — Read the fitted model A fitted model carries more than a coefficient table. The next three tools all take the **fitted result** from Stage 1 and interrogate it. `analyze_predictions` returns fitted values, residuals and actuals on the estimation sample — the raw material for residual diagnostics: ```python ex.analyze_predictions(fe).df.head() ``` `analyze_fixef_plot` ranks the **country intercepts** the fixed effects absorbed — which countries sit structurally high or low on inequality, before development plays any role (top 20 shown): ```python ex.analyze_fixef_plot(fe, fixef="country", top_n=20).fig ``` `analyze_joint_test` runs a Wald test that the **curvature terms are jointly zero**. Rejecting it is the formal statement that the relationship really bends — that a straight line would not do: ```python #| warning: false print(ex.analyze_joint_test(fe, ["log_gdp_pc_sq", "log_gdp_pc_cu"]).summary()) ``` ## Stage 4 — Stress-test the inference Cluster-robust standard errors lean on having *many* clusters. When that is in doubt, large-sample p-values can be over-confident. `analyze_robust_inference` offers two finite-sample alternatives: **randomization inference** (`ritest`) and the **wild cluster bootstrap** (`wildboot`). Here we test a determinant — does **trade openness** move inequality? — re-randomizing within country. ::: {.callout-note} pyfixest's randomization inference needs an **integer** cluster code, so we add a numeric `country_id` alongside the string `country`. ::: ```python #| warning: false df = df.assign(country_id=df["country"].astype("category").cat.codes) trade_model = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "trade_share"], feffects=["year"], clusters=["country_id"], ) ri = ex.analyze_robust_inference( trade_model, "trade_share", method="ritest", reps=500, cluster="country_id", seed=0 ) print( f"Trade-share coefficient {ri.estimate:.3f}: randomization-inference " f"p = {ri.p_value:.3f} over {ri.reps} permutations." ) ``` A randomization-inference p-value well above 0.05 is a useful caution: the association that an asymptotic cluster standard error might call significant does not survive a stricter, assumption-light test. ## Stage 5 — Which panel estimator? Fixed effects are one choice among several. `analyze_panel_table` lays the classics side by side — **pooled**, **between** (cross-country means), **fixed** (within), and **random** effects: ```python ex.analyze_panel_table(df, dv="gini_regional", idvs=["log_gdp_pc"]).etable ``` Fixed or random effects? Random effects is more efficient but assumes the country effect is uncorrelated with the regressors. `analyze_hausman_test` tests exactly that: ```python print(ex.analyze_hausman_test(df, dv="gini_regional", idvs=["log_gdp_pc"]).interpret()) ``` `analyze_cre_table` gives the same comparison a more readable form: the **correlated random effects** (Mundlak) device augments a random-effects model with each regressor's country mean. The coefficient on `log_gdp_pc` then equals its fixed-effects (within) estimate, while a test on the *mean* terms is the regression-form Hausman test — one table that holds the within estimate, the between signal, and the specification test together: ```python cre = ex.analyze_cre_table(df, dv="gini_regional", idvs=["log_gdp_pc"]) print(cre.interpret()) ``` ## Stage 6 — The flagship: Kuznets waves across estimators `analyze_kuznets_waves` is the synthesis. It fits the extended polynomial `gini = b_1*g + b_2*g^2 + ... + b_degree*g^degree` under **three estimators at once** — pooled OLS, between, and within two-way fixed effects — so you can read off the one question that matters: **is the N-shape a between-country pattern, a within-country pattern, or both?** Controls (here trade openness) are partialled out by Frisch–Waugh–Lovell before the component plots are drawn. ```python waves = ex.analyze_kuznets_waves(df, controls=["trade_share"]) waves.fig ``` The within (two-way FE) component plot isolates the wave that survives *inside* countries, net of common shocks — the most demanding test of the Kuznets hypothesis: ```python waves.fig_within ``` ```python print(waves.interpret()) ``` The `.gt_pooled`, `.gt_between` and `.gt_within` tables hold the full cumulative-stepwise estimates behind each curve, and `.summary` reports the implied turning points. ## Stage 7 — A related question: income convergence Inequality *between* countries depends on whether poorer economies are catching up. The convergence tools answer that for the income driver itself, `log_gdp_pc`. ::: {.callout-warning} These estimators are designed for **long** balanced panels; the `kuznets` window is only 11 years (2015–2025), so read the results as an illustration of the *tools*, not a settled finding. For a full treatment on long panels see the dedicated notebooks for [β-convergence](https://colab.research.google.com/github/cmg777/expdpy/blob/main/notebooks/analyze_beta_convergence.ipynb), [σ-convergence](https://colab.research.google.com/github/cmg777/expdpy/blob/main/notebooks/analyze_sigma_convergence.ipynb) and [convergence clubs](https://colab.research.google.com/github/cmg777/expdpy/blob/main/notebooks/analyze_convergence_clubs.ipynb). ::: `analyze_beta_convergence` asks whether initially **poorer** countries grow **faster** (β-convergence): it regresses annualized growth on the starting level and converts the slope into a *speed* and a *half-life*. With controls it reports the *conditional* version (a partial-residual plot) beside the unconditional one. ```python beta = ex.analyze_beta_convergence(df, "log_gdp_pc") beta.fig ``` `analyze_sigma_convergence` tracks whether the **dispersion** of income across countries shrinks over time — the standard deviation, Gini and coefficient of variation per year, with a trend test (a negative slope is σ-convergence): ```python ex.analyze_sigma_convergence(df, "log_gdp_pc").fig ``` `analyze_convergence_clubs` runs the **Phillips–Sul** log(t) test and, when a single converging group is rejected, clusters countries into **convergence clubs** that each approach their own path: ```python clubs = ex.analyze_convergence_clubs(df, "log_gdp_pc") clubs.fig ``` ## Stage 8 — A causal design: event study and DiD The tools so far describe associations. When a *determinant* is a datable policy or shock, a **difference-in-differences / event study** design can identify its dynamic effect. The `kuznets` panel has no such treatment, so we switch to the bundled `staggered_did` example — a panel where units adopt a treatment in different years. ```python from expdpy.data import load_staggered_did, load_staggered_did_data_def did = ex.set_labels( load_staggered_did(), load_staggered_did_data_def(), set_panel=True ) ``` `analyze_panel_view` shows the **treatment structure** — who is treated and when — as a quilt over the unit-by-year grid, the first thing to inspect in any staggered design: ```python ex.analyze_panel_view(did, cohort="cohort").fig ``` `analyze_event_study` traces the **dynamic treatment path** with a built-in pre-trend check, using a modern staggered-adoption estimator (Gardner's `did2s` here; `twfe`, Sun–Abraham `saturated` and `lpdid` are also available). Flat pre-trends and a clean post-treatment jump are what a credible design looks like: ```python es = ex.analyze_event_study(did, outcome="outcome", cohort="cohort", estimator="did2s") es.fig ``` ```python print(es.interpret()) ``` ## Where to go next You have fit the Kuznets curve, shown the N largely survives *within* countries under two-way fixed effects, stress-tested a determinant with randomization inference, chosen among panel estimators, and seen how a staggered DiD design would identify a policy effect. - [Explore panel data](explore.qmd) — the exploratory analysis that should precede every model. - [Learn panel data](learn.qmd) — the ideas behind fixed effects, demeaning, correlated random effects and convergence, with runnable sandboxes. ### Learn [Article](https://cmg777.github.io/expdpy/learn.html) The **Learn** module is the teaching layer behind the rest of the library. The [Explore](explore.qmd) and [Analyze](analyze.qmd) case studies leaned on a handful of ideas — within vs between variation, fixed effects, clustered standard errors, convergence, the Kuznets wave. This page explains *why* those moves work, **two complementary ways**: 1. **A plain-language layer on every result.** Each `explore_*` / `analyze_*` result can explain itself with `.interpret()` (an associational reading) and `.explain()` (a concept explainer). 2. **Simulated sandboxes.** Each `learn_*` function generates its *own* data from known parameters, so you can see an estimator hit — or miss — a truth you control, and turn the knobs. Read it top to bottom: we start from the real Kuznets model you fit in Analyze, browse the concept index, then isolate each idea in a sandbox — the mechanics of fixed effects, two inference classics, convergence, and the Kuznets wave itself. ::: {.callout-note} The sandboxes *simulate* data to make a teaching point; the `.interpret()` text is always **associational** (never a causal claim). The `correlation_vs_causation` explainer spells out what a causal reading would additionally require. ::: ```python 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) ``` ## Stage 1 — Read a real result in plain language Fit a model anywhere in [Analyze](analyze.qmd), then ask it to explain itself. Here is the two-way fixed-effects cubic Kuznets curve from the Analyze case study. `.interpret()` gives an **associational** reading of what was estimated: ```python res = ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ) print(res.interpret()) ``` `.explain()` returns the **concept explainer** for the method — what it is, when to use it, and its caveats — the same content as `ex.explain("fixed_effects")`: ```python print(res.explain().to_markdown()) ``` Every `explore_*` and `analyze_*` result carries these two methods. The rest of this page is the ideas they describe, isolated one at a time. ## Stage 2 — The browsable concept index `ex.list_topics()` returns every registered concept explainer (currently 27); pass any of them (or an alias) to `ex.explain(...)`: ```python ex.list_topics() ``` ```python ex.explain("fixed_effects") ``` The full catalog, grouped by theme — every entry is a key you can pass to `ex.explain(...)`: | Theme | Explainer topics | |---|---| | OLS & regression | `ols`, `fwl`, `omitted_variable_bias`, `descriptive_stats` | | Panel structure & variation | `panel_structure`, `within_between_variation`, `time_trends`, `transition_matrix` | | Fixed effects & the within transform | `fixed_effects`, `within_transformation`, `dummy_variables`, `first_differences` | | Random effects, CRE & Hausman | `random_effects`, `correlated_random_effects`, `hausman` | | Standard errors & inference | `clustered_se` | | Convergence | `beta_convergence`, `sigma_convergence`, `convergence_clubs`, `kuznets_waves` | | Causal designs / DiD | `event_study`, `parallel_trends`, `correlation_vs_causation` | | Correlation | `pearson`, `spearman` | | Outlier treatment | `winsorize`, `truncate` | ## Stage 3 — The core identity: first differences ≈ demeaning ≈ dummy variables A unit fixed effect is anything constant within a unit over time. Three transformations all **remove** it and recover the same within-unit slope: - **First differences** subtract each unit's previous-period value (Δy on Δx). - **The within transformation (demeaning)** subtracts each unit's time-average. - **Least-squares dummy variables (LSDV)** add one dummy per unit to an OLS regression. `learn_first_differences` simulates a panel where the regressor is correlated with the unit effect (so pooled OLS is biased) and recovers the slope by first differences and by demeaning. On a two-period panel the two coincide exactly, and both recover the truth: ```python fd = ex.learn_first_differences(n_periods=2) print(fd.interpret()) fd.fig ``` `learn_within_vs_lsdv` shows that demeaning and unit dummies give the **identical** slope — the Frisch–Waugh–Lovell theorem at work — for any number of periods: ```python ex.learn_within_vs_lsdv(n_periods=6).fig ``` ## Stage 4 — Why fixed effects matter `learn_pooled_vs_fixed_effects` makes the bias concrete: when the unit effect is correlated with the regressor, pooled OLS is biased, and using only within-unit variation (fixed effects) recovers the true slope. This is exactly the move the Analyze case study made on the Kuznets curve. ```python pf = ex.learn_pooled_vs_fixed_effects(unit_effect_corr=0.8) print(pf.interpret()) pf.fig ``` The **correlated random effects (Mundlak)** estimator bridges fixed and random effects — see its explainer, and `analyze_cre_table` in [Analyze](analyze.qmd): ```python ex.explain("correlated_random_effects") ``` ## Stage 5 — Two inference classics **Omitted-variable bias** — what happens to a coefficient when a correlated confounder is left out. The short regression is biased; controlling for the confounder recovers the truth: ```python ex.learn_omitted_variable_bias(corr_xz=0.6).fig ``` **Clustered standard errors** — clustering changes the *standard error*, not the point estimate. Ignoring within-cluster correlation understates uncertainty (the bars shrink too far): ```python ex.learn_clustering_se(icc=0.3).fig ``` ## Stage 6 — Convergence, simulated The Analyze case study asked whether incomes converge. These sandboxes plant a *known* answer so you can watch each tool recover it. `learn_beta_convergence` — absolute vs **conditional** convergence on a known-parameter AR(1) panel: omitting a steady-state determinant biases the unconditional slope; conditioning on it recovers the true convergence speed. (See [`analyze_beta_convergence`](analyze_beta_convergence.qmd) on real data.) ```python bc = ex.learn_beta_convergence(rho=0.9, gamma=0.6, corr=0.7) print(bc.interpret()) bc.fig ``` `learn_sigma_convergence` — a panel whose cross-sectional dispersion narrows at a **known** rate: the standard deviation, the Gini index and the coefficient of variation all shrink at the log-rate `ln(rho)`, and the function recovers it. (See [`analyze_sigma_convergence`](analyze_sigma_convergence.qmd) on real data.) ```python ex.learn_sigma_convergence(rho=0.93).fig ``` `learn_convergence_clubs` — a panel with a **planted** club structure: each unit belongs to one of several clubs converging to distinct levels, so the panel does not converge globally, yet the Phillips–Sul clustering algorithm recovers the clubs without being told they exist. (See [`analyze_convergence_clubs`](analyze_convergence_clubs.qmd) on real data.) ```python ex.learn_convergence_clubs().fig ``` ## Stage 7 — The Kuznets wave, simulated `learn_kuznets_waves` is the teaching counterpart to the flagship `analyze_kuznets_waves`. It plants a **known** polynomial wave into a panel and fits it under three estimators. The within and pooled estimators recover the true top-order coefficient; the **between** estimator differs — because the average of a nonlinear function is not the function of the average. That gap is exactly why the Analyze case study asked whether the N-shape was a within- or a between-country pattern. ```python kw = ex.learn_kuznets_waves() print(kw.interpret()) kw.fig ``` ## Where to go next - [Explore panel data](explore.qmd) — describe your data before you model it. - [Analyze panel data](analyze.qmd) — the estimators these ideas underpin, on the real Kuznets panel. - Browse every concept explainer in the [API reference](reference/index.qmd), or pass any `list_topics()` key to `ex.explain(...)`. ### For AI / LLMs [Article](https://cmg777.github.io/expdpy/use-with-llms.html) expdpy ships first-class support for large language models and AI agents: a machine-readable map of the library, function-calling schemas, an MCP server, and a portable agent skill. This page is generated from the canonical skill, so it never drifts from what an agent receives. ## Machine-readable entry points - [`/llms.txt`](https://cmg777.github.io/expdpy/llms.txt) - a curated index of the whole library (the llmstxt.org convention), with expdpy's guardrails up front. - [`/llms-full.txt`](https://cmg777.github.io/expdpy/llms-full.txt) - every function, docstring and concept explainer concatenated into one file for full-context ingestion. - [`/tools/anthropic_tools.json`](https://cmg777.github.io/expdpy/tools/anthropic_tools.json) and [`/tools/openai_tools.json`](https://cmg777.github.io/expdpy/tools/openai_tools.json) - curated function-calling tool schemas for the Anthropic and OpenAI APIs. - Every docs page has a clean Markdown twin at `.html.md`. ## MCP server Install the optional extra and run the stdio server: ```bash pip install "expdpy[mcp]" ``` Register it with an MCP client (Claude Desktop, Claude Code, ...): ```json { "mcpServers": { "expdpy": { "command": "expdpy-mcp" } } } ``` The server exposes a curated set of Explore/Analyze tools plus `explain` / `list_topics` / `learn_concept`. Each tool returns the result's plain-language `interpret()` reading, its tables, and a saved figure path. ## The use-expdpy agent skill The skill below teaches an agent to use expdpy correctly. Copy the [`.claude/skills/use-expdpy/`](https://github.com/cmg777/expdpy/tree/main/.claude/skills/use-expdpy) directory into your project (Claude Code reads `.claude/skills/`), or paste the workflow below into your agent's instructions. --- # use-expdpy Use **expdpy** - Explore / Analyze / Learn for panel and cross-sectional data - to do real analysis: declare the panel, call one module-prefixed function, and read the frozen result it returns. Every function returns a typed result object with a uniform surface (`.df`, `.fig`, `.gt`) and, on most, plain-language `.interpret()` / `.explain()`. The value of this skill is **doing the analysis the way the library intends** - panel-aware, association-only, and self-documenting - instead of reaching for raw pandas/statsmodels. expdpy is a Python port of the R package ExPanDaR. Import it as `import expdpy as xp`. The full, always-correct list of functions is in `references/function-catalog.md` (generated from the installed library, so it never lies). Read that before guessing a name. ## The four-step workflow ### Step 1 - Declare the panel once Panel data has two index columns: an **entity** (the unit - country, region, firm) and a **time** column. Declare them once and every later call inherits them: ```python import expdpy as xp from expdpy.data import load_kuznets, load_kuznets_data_def df = xp.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True) # or, without a data dictionary: df = xp.set_panel(load_kuznets(), entity="country", time="year") ``` `set_panel` writes the panel onto `df.attrs`; `resolve_panel` reads it back. You may always override per call with the keyword-only `entity=` / `time=` arguments - explicit args win. The vocabulary is **always `entity` and `time`** - never `id`, `unit`, `cs_id`, or `ts_id`. If you are unsure which columns qualify, run `python .claude/skills/use-expdpy/scripts/check_panel.py yourfile.csv` for a read-only report of candidate index columns and dtypes. Cross-sectional data (one row per entity, no time) needs no `set_panel`; pass it straight to the cross-sectional functions (e.g. `analyze_iv_regression`). ### Step 2 - Pick the module - **Explore** (`explore_*`) - describe and visualize *before* modeling: `explore_descriptive_table`, `explore_correlation_table`, `explore_missing_values_plot`, `explore_trend_plot`, `explore_scatter_plot`, `explore_spaghetti_plot`, `explore_xtsum_table`, `explore_panel_structure`. Functions ending `_plot` return a Plotly `.fig`; functions ending `_table` return a Great Tables `.gt`. - **Analyze** (`analyze_*`) - estimate: `analyze_regression_table` / `analyze_panel_table` (OLS / fixed / random effects), `analyze_iv_regression` (2SLS), `analyze_beta_convergence` / `analyze_sigma_convergence` / `analyze_convergence_clubs`, `analyze_kuznets_waves`, `analyze_event_study` (staggered DiD). - **Learn** (`learn_*`) - runnable concept sandboxes on simulated data (`learn_omitted_variable_bias`, `learn_pooled_vs_fixed_effects`, ...) for *teaching* a concept, not for estimating on the user's data. When the intent is fuzzy, open `references/choosing-a-function.md`. ### Step 3 - Read the result object (the universal contract) Every `explore_*` / `analyze_*` / `learn_*` function returns a **frozen result dataclass**, never a bare figure or frame. The surface is uniform: | Attribute | What it is | |----------------|-----------------------------------------------------------------------| | `.df` | the tidy underlying data / coefficient frame (a `pandas.DataFrame`) | | `.fig` | an interactive Plotly figure (functions ending `_plot`) | | `.gt` | a Great Tables object (functions ending `_table`) | | `.interpret()` | one paragraph of plain-language, **association-only** interpretation | | `.explain()` | the relevant concept explainer from the pedagogy registry | | `.tidy()` | one row per coefficient/term (estimation results) | | `.glance()` | one row of model-level scalars (N, R-squared, F, ...) | Not every method is meaningful on every result. Never call `.show()` on `.fig` in a script - to persist a figure, write it: `result.fig.write_html("fig.html")` (or `.write_image("fig.png")`). Full surface and idioms in `references/result-contract.md`. ### Step 4 - Interpret responsibly (READ THIS BEFORE WRITING ANY CONCLUSION) expdpy is deliberately built so the library never overclaims, and **you must match it**: - **Associations, not causation.** `.interpret()` describes how variables *move together*. It never contains the word "causes" or the phrase "effect of", and it ends with a note pointing to `xp.explain("correlation_vs_causation")`. When you summarize a result, keep that framing: write "is associated with" / "is higher when", not "causes" / "the effect of". The one place a causal claim is legitimate is instrumental variables - and even there the causal argument is *yours* (you must defend the instrument's relevance and exclusion), while `.interpret()` still reports the association. - **Lead with `.interpret()`.** Prefer quoting the library's own interpretation over inventing your own narrative; it is calibrated to the library's standards. - **Reach for `explain(topic)` when a concept needs grounding.** `xp.list_topics()` returns the concept keys (e.g. `fixed_effects`, `omitted_variable_bias`, `instrumental_variables`, `beta_convergence`). `xp.explain("fixed_effects")` returns an `Explainer` with `what` / `when_to_use` / `caveats` / `references`. Use it to justify a method or warn about a pitfall, instead of writing econometrics from memory. The full guardrail cheat-sheet is in `references/guardrails.md`. ## Recipes (runnable, end-to-end, bundled data) ### Recipe A - Descriptive + correlation overview of a panel ```python import expdpy as xp from expdpy.data import load_kuznets, load_kuznets_data_def df = xp.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True) desc = xp.explore_descriptive_table(df) # summarizes all numeric columns corr = xp.explore_correlation_table(df[["gini_regional", "gdp_pc", "trade_share"]]) print(desc.df) # the numbers desc.gt # the formatted Great Table (display in a notebook) print(corr.interpret()) # association-only reading of the correlations ``` ### Recipe B - Instrumental variables (AJR 2001 colonial origins, cross-section) ```python import expdpy as xp from expdpy.data import load_colonial_origins, load_colonial_origins_data_def ajr = xp.set_labels(load_colonial_origins(), load_colonial_origins_data_def()) base = ajr[ajr["base_sample"] == 1] # the 64-country base sample iv = xp.analyze_iv_regression( base, dv="log_gdp_pc_1995", endog="expropriation_risk", # institutions: endogenous instruments="log_settler_mortality", # the AJR instrument ) print(iv.tidy()) # 2SLS coefficients (the instrumented slope ~ 0.94) print(iv.glance()) # includes the first-stage weak-instrument F (watch for < 10) print(iv.interpret()) # association framing; the causal case is YOURS to argue ``` IV is the *one* method where a causal claim is on-topic - but only if you explicitly defend that `log_settler_mortality` is relevant (moves institutions) and excludable (affects income only through institutions). See `xp.explain("instrumental_variables")`. ### Recipe C - Kuznets waves across pooled / between / within estimators ```python import expdpy as xp from expdpy.data import load_kuznets, load_kuznets_data_def df = xp.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True) kw = xp.analyze_kuznets_waves(df, inequality="gini_regional", development="log_gdp_pc") print(kw.interpret()) # how inequality co-moves with development - associational kw.fig # pooled scatter with the fitted wave ``` ## Resources - `references/function-catalog.md` - every public function, grouped, with signature + one-line summary (generated from the installed library; always current). - `references/choosing-a-function.md` - "I want to ... -> call ..." decision guide. - `references/guardrails.md` - association-not-causation, entity/time vocabulary, reading `.interpret()`, and the `explain()` registry. - `references/result-contract.md` - the full `.df`/`.fig`/`.gt`/`.interpret()`/`.explain()`/ `.tidy()`/`.glance()` surface and the figure-saving idiom. - `references/recipes.md` - more end-to-end recipes (event-study/DiD, panel models + Hausman, robust inference, outlier treatment). - `scripts/check_panel.py` - read-only helper that reports candidate entity/time columns. ## Programmatic access (MCP server + tool schemas) To let an agent *call* expdpy directly, install the MCP server with `pip install 'expdpy[mcp]'` and run `expdpy-mcp` (stdio). Static Anthropic/OpenAI function-calling schemas are published at `https://cmg777.github.io/expdpy/tools/anthropic_tools.json` and `.../openai_tools.json`, and a machine-readable map of the whole library is at `https://cmg777.github.io/expdpy/llms.txt`. ## When to use a different skill This skill is for **using** expdpy on data. To **add or change a function in the library itself** (write `analyze_*`/`explore_*`/`learn_*`, an estimator, a result dataclass, tests, wiring), use the **write-function** skill instead - it is the developer counterpart. ### Beta convergence (analyze_beta_convergence) [Article](https://cmg777.github.io/expdpy/analyze_beta_convergence.html) 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. ```python import numpy as np import pandas as pd import expdpy as ex ``` ## 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: ```python 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.fig ``` Hover 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: ```python res_c = ex.analyze_beta_convergence( gap, "log_gdppc", controls=["lifeExp"], entity="country", time="year" ) res_c.fig_conditional ``` ### The 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: ```python res_c.gt ``` ### 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`: ```python res.fig_rolling ``` ### Everything it returns ```python 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() ``` `.interpret()` reads the result in plain language, and `.explain()` returns the concept explainer: ```python print(res_c.interpret()) ``` ## 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. ```python 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 ``` ```python # 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") ``` ### 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. ```python 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") ``` ### 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. ```python 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) ``` ## 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"): ```python print(res.interpret()) ``` 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*. ```python res_c.gt ``` ```python print(res_c.interpret()) ``` 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 (also `res.explain()`). ```python ex.explain("beta_convergence") ``` ### Sigma convergence (analyze_sigma_convergence) [Article](https://cmg777.github.io/expdpy/analyze_sigma_convergence.html) This page is two things at once: an **extended user guide** for `analyze_sigma_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 the **cross-sectional dispersion** of a variable shrinks over time — whether units become more alike. At each period $t$ we measure how spread out the variable is across units (the standard deviation $\sigma_t$, the Gini index, the coefficient of variation) and then regress the **log dispersion** on time: $$ \ln D_t \;=\; a + b\, t + \varepsilon_t . $$ The slope $b$ is the average **proportional** change in dispersion per period, so a **negative** $b$ is σ-convergence (the distribution is narrowing) and a positive $b$ is σ-divergence. The variable is used **as you supply it** — the function never transforms it. σ-convergence is the *distributional* complement to β-convergence. β-convergence (laggards growing faster) is **necessary but not sufficient** for σ-convergence: fresh shocks can re-widen the distribution even while poorer units catch up (Quah's critique). See [`analyze_beta_convergence`](analyze_beta_convergence.qmd) for the growth-vs-initial-level view. ```python import numpy as np import pandas as pd import expdpy as ex ``` ## 1. The method in one cell `analyze_sigma_convergence(df, var, ...)` only needs the variable and the panel ids. Here is the cross-country dispersion of **life expectancy** in the bundled `gapminder` panel — a bounded, non-negative level, so the standard deviation and the Gini index are both meaningful: ```python from expdpy.data import load_gapminder gap = load_gapminder() res = ex.analyze_sigma_convergence(gap, "lifeExp", entity="country", time="year") res.fig ``` The **standard deviation** is on the left axis and the **Gini index** on the right; the dashed lines are the fitted log-trends. The annotation reports each measure's per-period trend and whether it is converging. ## 2. How the function works ### Arguments | argument | what it does | when to change it | |---|---|---| | `var` | the panel variable whose cross-sectional dispersion is tracked (used **as-is**) | pass it on whatever scale you want measured; the Gini also needs non-negative values | | `entity`, `time` | the panel ids | omit if declared once via `set_panel` | | `start`, `end` | restrict to a period window (which must still be balanced) | to focus on a sub-period | | `min_periods` | minimum periods required to fit a trend (≥ 3) | rarely; the default is fine | | `vcov` | `"hetero"` (HC1, default) or `"iid"` standard errors | `"iid"` for classical SEs; never changes the slope | The panel must be **balanced** — every unit present in every period — so the dispersion is comparable across periods (more on that in §4). ### Everything it returns ```python print("scalars :", {k: round(getattr(res, k), 5) for k in ["std_slope", "gini_slope", "cv_slope", "std_pvalue"]}) print("panel :", f"{res.n_units} units x {res.n_periods} periods") print("per-period frame columns:", list(res.df.columns)) res.df.head() ``` `summary` is the per-measure trend table (slope, SE, p-value, R²) and `gt` renders it; `glance()` is the one-row headline: ```python res.summary.round(5) ``` ```python res.gt ``` `.interpret()` reads the result in plain language, and `.explain()` returns the concept explainer: ```python print(res.interpret()) ``` ## 3. Does it recover the truth? The cleanest test uses a **geometric-narrowing** panel: every unit's value contracts toward a common mean $\mu$ at rate $\rho$ per period, $x_{i,t} = \mu + (x_{i,0}-\mu)\,\rho^{t}$. Because the deviations from $\mu$ all shrink by the factor $\rho^{t}$ while the mean stays fixed, **every** dispersion measure scales as $\rho^{t}$ — so the log-dispersion trend equals $\ln\rho$ **exactly** for the standard deviation, the Gini index and the coefficient of variation alike. ```python def sigma_panel(*, n_units=60, n_years=15, rho=0.9, noise=0.0, seed=0): """Geometric-narrowing panel x_{i,t} = mu + (x_{i,0}-mu)*rho**t (mu = mean x_0).""" rng = np.random.default_rng(seed) x0 = rng.uniform(1.0, 20.0, size=n_units) mu = float(np.mean(x0)) rows = [] for i in range(n_units): for t in range(n_years): val = mu + (float(x0[i]) - mu) * rho**t if noise: val += float(rng.normal(0.0, noise)) rows.append((f"C{i:03d}", t, val)) return pd.DataFrame(rows, columns=["country", "year", "x"]) RHO = 0.9 panel = sigma_panel(rho=RHO, seed=1) fit = ex.analyze_sigma_convergence(panel, "x", entity="country", time="year") target = np.log(RHO) check = pd.DataFrame( { "measure": ["std", "gini", "cv"], "true (ln rho)": [target, target, target], "recovered": [fit.std_slope, fit.gini_slope, fit.cv_slope], } ) check["abs_error"] = (check["recovered"] - check["true (ln rho)"]).abs() check ``` ```python # Each measure recovers ln(rho) to machine precision on the noiseless DGP. for slope in (fit.std_slope, fit.gini_slope, fit.cv_slope): assert abs(slope - target) < 1e-9 assert fit.std_slope < 0 # convergence print("✅ std, Gini and CV trends all recover ln(rho) exactly") ``` A faster contraction (smaller $\rho$) means a more negative trend, and that ordering survives noise: ```python fast = ex.analyze_sigma_convergence( sigma_panel(rho=0.80, noise=0.02, seed=2), "x", entity="country", time="year" ) slow = ex.analyze_sigma_convergence( sigma_panel(rho=0.97, noise=0.02, seed=2), "x", entity="country", time="year" ) assert fast.std_slope < slow.std_slope < 0 print(f"✅ faster contraction => steeper trend ({fast.std_slope:.3f} < {slow.std_slope:.3f} < 0)") ``` ## 4. The panel must be balanced Dispersion is only comparable across periods when the **same** units are present each period, so the function refuses an unbalanced panel rather than mix a changing composition: ```python unbalanced = gap.drop(gap[(gap["country"] == "Afghanistan") & (gap["year"] < 1972)].index) try: ex.analyze_sigma_convergence(unbalanced, "lifeExp", entity="country", time="year") except ValueError as exc: print("ValueError:", exc) ``` Restrict to a balanced window with `start=`/`end=` instead: ```python res_window = ex.analyze_sigma_convergence( gap, "lifeExp", entity="country", time="year", start=1972 ) print(f"balanced window: {res_window.n_units} units x {res_window.n_periods} periods") ``` ## 5. Convergence vs divergence on real data Cross-country **life expectancy** has compressed over 1952–2007 — poorer countries caught up — so both the standard deviation and the Gini index drift down (σ-convergence): ```python print(res.interpret()) ``` The flagship `kuznets` panel makes the opposite case: the cross-country dispersion of regional inequality has **widened**, a clean example of σ-divergence (a positive trend): ```python from expdpy.data import load_kuznets kz = ex.analyze_sigma_convergence(load_kuznets(), "gini_regional", entity="country", time="year") print(kz.interpret()) ``` > Note on scale: with **no transform**, the standard deviation is in the variable's own units > and tracks its level, so the Gini and the coefficient of variation (both scale-free) often tell > a cleaner story — compare all three in `summary`. Raw GDP-per-capita *levels* diverge in > dollars even when log GDP converges, which is why the income case is usually run on `log`. ## See also - `ex.learn_sigma_convergence()` — a runnable Learn sandbox that recovers a known dispersion rate on a geometric-narrowing panel. - [`analyze_beta_convergence`](analyze_beta_convergence.qmd) — the growth-vs-initial-level view. - `ex.explain("sigma_convergence")` — the concept explainer (also `res.explain()`). ```python ex.explain("sigma_convergence") ``` ### Convergence clubs (analyze_convergence_clubs) [Article](https://cmg777.github.io/expdpy/analyze_convergence_clubs.html) This page is two things at once: an **extended user guide** for `analyze_convergence_clubs` — what it does, every argument, and everything it returns — and a **testing environment** that generates synthetic data with a *known* club structure and checks that the function recovers it. If a cell's `assert` ever fails, the function is broken. ## What is club convergence? β- and σ-convergence assume the panel is *one* group heading toward *one* path. Often it is not: some economies catch up to a high path while others settle on a lower one. **Club convergence** (Phillips & Sul, 2007/2009) tests for exactly that — *multiple* convergence equilibria — without grouping units in advance. Each unit is modelled as a common trend $\mu_t$ scaled by a time-varying, unit-specific loading, $X_{it} = \delta_{it}\,\mu_t$. Removing the common trend gives the **relative transition path** $$ h_{it} = \frac{X_{it}}{\frac1N\sum_i X_{it}}, $$ whose cross-sectional mean is 1 by construction. If the units converge, the cross-sectional variance $H_t = \frac1N\sum_i (h_{it}-1)^2 \to 0$, and the **log(t) regression** $$ \log\!\left(\frac{H_1}{H_t}\right) - 2\log(\log t) = a + b\,\log t + \varepsilon_t, \qquad t = [rT], \dots, T, $$ has a non-negative slope $b = 2\alpha$. A one-sided $t_b > -1.65$ **fails to reject** convergence. When the whole panel rejects, a **data-driven clustering algorithm** sorts units by their final level, forms a core group by maximising $t_b$, sieves in the units that keep the group converging, recurses on the residual, and finally **merges** adjacent clubs that jointly converge. The series is first smoothed with the **Hodrick–Prescott filter** ($\lambda=400$ for annual data) so the test runs on the long-run trend rather than the business cycle. This is a faithful port of the Stata `psecta` package (Du, 2017); the log(t) statistic uses the Phillips–Sul scalar long-run-variance HAC (Andrews 1991 quadratic-spectral kernel, AR(1) automatic bandwidth), reproduced in NumPy. The variable is used **as you supply it** — pass `log` GDP per capita / log labor productivity. ```python import numpy as np import pandas as pd import expdpy as ex ``` ## 1. The method in one cell `analyze_convergence_clubs(df, var, ...)` needs only the variable and the panel ids. Here is the clustering of (log) GDP per capita across countries in the bundled `productivity` panel — a balanced 108-country, 25-year Penn World Table extract: ```python from expdpy.data import load_productivity prod = load_productivity() res = ex.analyze_convergence_clubs(prod, "log_gdppc", entity="country", time="year") res.fig ``` Each line is a club's **average** relative transition path; the dashed line at 1 is the cross-sectional mean. `res.interpret()` reads the result in plain language: ```python print(res.interpret()) ``` ## 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 / log labor productivity | | `entity`, `time` | the panel ids | omit if declared once via `set_panel` | | `filter` | `"hp"` (default) HP-filters each unit and analyses the trend; `None` uses the variable as given | `None` if you pass an already-detrended series | | `hp_lambda` | HP smoothing parameter | `400` for annual data (the literature default) | | `r` | log(t) trimming fraction — the first `round(r*T)` periods are dropped | `0.3` for moderate `T`, `0.2` for large `T` | | `method` | within-club sieve: `"adjust"` (Schnurbus 2016) or `"ps"` (original Phillips–Sul) | `"ps"` to reproduce the original 2007 rule | | `merge` | adjacent-club merging: `"iterative"` / `"single"` / `"none"` | `"none"` to inspect the raw clusters | | `fr` | sort by the last period (`0`) or the mean of the last `(1-fr)` periods | `fr>0` when endpoints are noisy | ### Everything it returns ```python print("clubs :", res.n_clubs, "| divergent:", res.n_divergent, "| global t:", round(res.global_tstat, 2), "| converged:", res.converged) print("figures : fig, fig_paths, fig_clubs") print("frames : df", list(res.df.columns), "| summary", list(res.summary.columns)) res.glance() ``` The classification table `gt` (and the `summary` frame behind it) lists each club's size, its log(t) slope `b` and t-statistic, and its members: ```python res.gt ``` `membership` is the tidy `entity -> club` appendix list, and `fig_paths` / `fig_clubs` show every unit's path coloured by club and a per-club small-multiples panel: ```python res.fig_clubs ``` ## 3. Does it recover the truth? The cleanest test **plants** a known club structure: every unit in club $k$ sits at a distinct long-run level $\mu_k$ plus an idiosyncratic deviation that decays geometrically, so units within a club converge to a common path while the distinct levels keep the panel from converging globally. The algorithm should reject global convergence and recover the planted partition. ```python def club_panel(*, n_per_club=12, levels=(10.0, 9.3, 8.6), n_years=35, rho=0.9, spread=0.4, noise=0.002, seed=0): """Panel with planted clubs; returns (df, {unit: true_club}).""" rng = np.random.default_rng(seed) rows, truth = [], {} for k, mu in enumerate(levels, start=1): for j in range(n_per_club): uid = f"c{k}u{j:02d}" truth[uid] = k dev = float(rng.uniform(-spread, spread)) for t in range(1, n_years + 1): rows.append((uid, t, mu + dev * rho ** (t - 1) + float(rng.normal(0, noise)))) return pd.DataFrame(rows, columns=["unit", "year", "x"]), truth panel, truth = club_panel(seed=1) fit = ex.analyze_convergence_clubs(panel, "x", entity="unit", time="year") print(f"global log(t) t = {fit.global_tstat:.2f} -> converged = {fit.converged}") print(f"detected clubs = {fit.n_clubs} (planted 3)") fit.summary[["club", "n_members", "beta", "tstat", "converging"]] ``` ```python # Best-match accuracy: each detected club scored by its modal planted club. detected = dict(zip(fit.membership["entity"], fit.membership["club"], strict=True)) by_det = {} for uid, det in detected.items(): by_det.setdefault(int(det), []).append(truth[uid]) correct = sum( sum(1 for tc in trues if tc == max(set(trues), key=trues.count)) for det, trues in by_det.items() if det != 0 ) accuracy = correct / len(truth) assert fit.converged is False # distinct levels => no global convergence assert fit.global_tstat <= -1.65 assert fit.n_clubs == 3 # the three planted clubs assert accuracy == 1.0 # every unit in its true club print(f"recovered {fit.n_clubs} clubs, {accuracy:.0%} of units correctly placed") ``` When the panel really is one group — all units converging to a single level — the whole-panel test should *fail to reject* and return a single club: ```python one, _ = club_panel(levels=(10.0,), n_per_club=40, seed=2) solo = ex.analyze_convergence_clubs(one, "x", entity="unit", time="year") assert solo.converged is True and solo.n_clubs == 1 print(f"single converging group: converged={solo.converged}, clubs={solo.n_clubs}") ``` ## 4. Convergence clubs across countries Back to real data. Across the 108 countries, whole-panel convergence is firmly **rejected** and the algorithm splits the world into several catch-up clubs plus a divergent tail — the textbook "multiple equilibria" picture: ```python print(res.interpret()) ``` ```python # Each reported club clears the convergence threshold. clubs = res.summary[res.summary["club"] != "Divergent"] assert bool((clubs["tstat"] > -1.65).all()) res.summary[["club", "n_members", "tstat", "converging"]] ``` The same call works on log **labor productivity** (`log_lp`) — the other variable in the panel — and on any balanced panel of your own. ## See also - `ex.learn_convergence_clubs()` — a runnable Learn sandbox that plants a known club structure and shows the algorithm recover it. - `ex.explain("convergence_clubs")` — the concept explainer (also `res.explain()`). - `analyze_beta_convergence` / `analyze_sigma_convergence` — the single-group convergence views. ```python ex.explain("convergence_clubs") ``` ### Kuznets waves (analyze_kuznets_waves) [Article](https://cmg777.github.io/expdpy/analyze_kuznets_waves.html) This page is two things at once: an **extended user guide** for `analyze_kuznets_waves` — what it does, every argument, and everything it returns — and a **testing environment** that generates synthetic data with *known* coefficients and checks that the function recovers them. If a cell's `assert` ever fails, the function is broken. ## What are Kuznets waves? Kuznets (1955) conjectured an **inverted-U** between inequality and development: inequality first rises as an economy industrializes, then falls. The classic test regresses a Gini on log GDP per capita and its **square**. The **Kuznets waves** hypothesis allows the relationship to be far more nonlinear by taking the development term up to the **fourth power**: $$ \text{gini} \;=\; b_1 g + b_2 g^2 + b_3 g^3 + b_4 g^4 + \varepsilon, \qquad g = \log \text{GDP per capita}. $$ A cubic admits an N-shape; a quartic admits a full **wave** with up to three turning points. The same polynomial is estimated under three panel estimators — **pooled OLS** (all variation), the **between** estimator (country averages, the cross-country curve) and the **within** estimator (two-way country + year fixed effects) — and laid out side by side. The development variable is used **as you supply it** (pass `log` GDP per capita); the powers are formed internally. ```python import numpy as np import pandas as pd import expdpy as ex ``` ## 1. The method in one cell On the bundled `kuznets` panel the defaults already point at `gini_regional` and `log_gdp_pc`, so the call is a one-liner once the panel ids are declared: ```python from expdpy.data import load_kuznets df = ex.set_panel(load_kuznets(), entity="country", time="year") res = ex.analyze_kuznets_waves(df) res.fig ``` The raw scatter overlays the pooled quartic; the annotation reports the turning points, N and R². The **within** comparison table shows the curvature emerging as each power is added: ```python res.gt_within ``` ## 2. How the function works ### Arguments | argument | what it does | when to change it | |---|---|---| | `inequality` | the outcome (a Gini), used **as-is** | any inequality measure | | `development` | the regressor (typically `log` GDP per capita); powers `g^2..g^degree` are formed internally | pass it in logs for the income case | | `controls` | covariate name(s) partialled out of the **between** and **within** figures (FWL); they do **not** enter the tables | to net the wave of confounders | | `entity`, `time` | the panel ids | omit if declared once via `set_panel` | | `degree` | polynomial order in `[2, 6]` (default 4) | `degree=2` is the classic inverted-U; raise it only when justified | | `vcov` | `"hetero"` (HC1, default) or `"iid"` for the pooled/between tables; the within table always clusters by entity | classical SEs; never changes a point estimate | ### The three figures and three tables The figures tell a pooled → between → within story. The between and within plots are **partial-residual (component) plots**: the fitted wave drawn over inequality once the controls (and, for the within view, the two-way fixed effects) are partialled out by the Frisch–Waugh–Lovell theorem. ```python res.fig_between ``` ```python res.fig_within ``` `gt_pooled`, `gt_between` and `gt_within` are the three comparison tables; `summary` is the per-estimator curvature frame, and `.interpret()` reads it in plain language: ```python print("figures :", ["fig", "fig_between", "fig_within"]) print("tables :", ["gt_pooled", "gt_between", "gt_within"]) res.summary ``` ```python print(res.interpret()) ``` ## 3. Does it recover the truth? Because the wave is a **within-unit** relationship, the cleanest check plants a known polynomial $y = \sum_k b_k g^k$ on top of unit and year effects drawn **independently** of $g$. The within (two-way fixed-effects) and pooled estimators should recover the planted coefficients. ```python BETAS = (0.5, -0.3, 0.05, 0.04) # the planted (b1, b2, b3, b4) def wave_panel(betas=BETAS, *, n_units=80, n_years=15, seed=0): """Panel y = sum_k b_k g^k + a_i + d_t + e with g varying within and between units.""" rng = np.random.default_rng(seed) a = rng.normal(0.0, 0.4, n_units) # unit effects, independent of g d = rng.normal(0.0, 0.4, n_years) # year effects, independent of g xbar = rng.normal(0.0, 1.0, n_units) # cross-unit development level rows = [] for i in range(n_units): for t in range(n_years): g = float(xbar[i] + rng.normal(0.0, 1.0)) y = (sum(b * g ** (k + 1) for k, b in enumerate(betas)) + a[i] + d[t] + rng.normal(0.0, 0.03)) rows.append((f"u{i:03d}", t, g, y)) return pd.DataFrame(rows, columns=["unit", "year", "g", "y"]) panel = wave_panel(seed=1) fit = ex.analyze_kuznets_waves(panel, "y", "g", entity="unit", time="year", degree=4) terms = ["g", "g_p2", "g_p3", "g_p4"] within_hat = np.array([fit.models["within"][-1].coef()[t] for t in terms]) check = pd.DataFrame({"term": terms, "true": BETAS, "within_recovered": within_hat}) check["abs_error"] = (check["within_recovered"] - check["true"]).abs() check.round(4) ``` ```python # The within (two-way FE) estimator recovers the planted wave to within a tight tolerance. np.testing.assert_allclose(within_hat, BETAS, atol=0.03) print("✅ within (two-way FE) recovered the planted quartic") ``` ### Between is not the same estimator The between estimator compares **unit averages**, and the average of a nonlinear curve is not the curve of the average — so it need not match the planted within-unit wave. That divergence is exactly what the three-estimator layout is for: ```python pooled_hat = np.array([fit.models["pooled"][-1].coef()[t] for t in terms]) between_hat = np.array([fit.models["between"][-1].coef()[t] for t in terms]) pd.DataFrame( {"term": terms, "true": BETAS, "pooled": pooled_hat, "between": between_hat, "within": within_hat} ).round(4) ``` ```python # Pooled also recovers the within-unit wave (its effects are orthogonal to g)... np.testing.assert_allclose(pooled_hat, BETAS, atol=0.05) # ...while the between (cross-unit) curve is a genuinely different object. print("✅ pooled recovers the wave; between is the cross-country curve") ``` ## 4. The Kuznets curve on the bundled panel The bundled `kuznets` panel was designed to show an **N-shaped** regional Kuznets curve. Read the three estimators together — the shape and where it lives: ```python res.summary ``` ```python print(res.interpret()) ``` ## See also - `ex.learn_kuznets_waves()` — a runnable Learn sandbox that plants a wave and shows the three estimators recovering (or not) the top-order coefficient. - `ex.explain("kuznets_waves")` — the concept explainer (also `res.explain()`). ```python ex.explain("kuznets_waves") ``` ### Instrumental variables (analyze_iv_regression) [Article](https://cmg777.github.io/expdpy/analyze_iv_regression.html) This page is two things at once: an **extended user guide** for the two instrumental-variables entry points — `analyze_iv_regression` (any cross-section or custom-fixed-effects design) and `analyze_panel_iv_regression` (the panel `xtivreg2 … fe` analogue) — and a **testing environment** that builds synthetic data with a *known* slope and checks that two-stage least squares recovers it while ordinary least squares does not. If a cell's `assert` ever fails, the function is broken. IV is the one method in expdpy where the *prose below* legitimately talks about **causal identification** — that is the entire point of an instrument. The library's own `.interpret()`, by deliberate design, still reports **associations**; the causal claim is the argument *you* make when you defend the instrument's relevance and exclusion. ## What is instrumental-variables estimation? A regressor is **endogenous** when it is correlated with the error term — through reverse causation, an omitted confounder, or measurement error — and then the ordinary-least-squares slope is biased. An **instrument** $z$ rescues identification when it is - **relevant** — it genuinely moves the endogenous regressor $x$, and - **excludable** — it affects the outcome $y$ *only* through $x$, never directly. Two-stage least squares (2SLS) uses only the part of $x$ predicted by the instruments: $$ \text{first stage:}\quad x = \pi\, z + \text{controls} + v, \qquad \text{second stage:}\quad y = \beta\, \hat{x} + \text{controls} + e . $$ The slope $\beta$ is read off the *fitted* $\hat{x}$, so it is identified from instrument-driven variation that is free of the confounding in $x$. The price is precision and a relevance requirement: if the first stage is weak, 2SLS is badly biased and its standard errors are unreliable. The conventional screen is the **first-stage F statistic** — below about **10** (the Staiger–Stock rule of thumb) is the warning sign. `analyze_iv_regression` reports it for you. For panel data, `analyze_panel_iv_regression` is the same 2SLS core wrapped to absorb **entity** (and, by default, **time**) fixed effects and to cluster by entity. ```python import numpy as np import pandas as pd import expdpy as ex ``` ## 1. The method in one cell The canonical Acemoglu–Johnson–Robinson (2001) example: does the quality of economic **institutions** raise income? Institutions are endogenous, so we instrument average protection against expropriation risk (a proxy for institutional quality) with **log settler mortality** — early-colonial mortality shaped the institutions Europeans built, but plausibly affects today's income only through them. We use the 64-country base sample. ```python from expdpy.data import load_colonial_origins, load_colonial_origins_data_def # A pure cross-section (no time dimension): label it, but do NOT set a panel. df = ex.set_labels(load_colonial_origins(), load_colonial_origins_data_def()) base = df[df["base_sample"] == 1] res = ex.analyze_iv_regression( base, dv="log_gdp_pc_1995", endog="expropriation_risk", instruments="log_settler_mortality", ) res.etable ``` The instrumented slope on `expropriation_risk` is the famous **≈ 0.94**. The weak-instrument screen comes straight off the result: ```python print(f"first-stage F = {res.first_stage_f:.2f} (rule of thumb: > 10 is OK)") ``` ≈ 23, comfortably above 10 — log settler mortality is a strong instrument here. ## 2. How the function works ### Arguments | argument | what it does | when to change it | |---|---|---| | `dv` | the dependent (outcome) variable | always set it | | `endog` | endogenous regressor name(s) to be instrumented | the variable you cannot treat as exogenous | | `instruments` | excluded instrument name(s); the **order condition** needs at least as many instruments as endogenous regressors | add more to over-identify (and enable an overid check) | | `exog` | included exogenous controls that enter directly | to hold confounders fixed (e.g. latitude in AJR) | | `feffects` | fixed effects absorbed by pyfixest | for arbitrary FE designs; for panels prefer `analyze_panel_iv_regression` | | `clusters` | cluster variable(s) for cluster-robust standard errors | when errors are correlated within groups | | `format` | `"gt"` (default), `"tex"`, `"md"`, `"df"`, `"html"` for the rendered `etable` | `"df"` to pull numbers; `"tex"` for a paper | ### What it returns `glance()` is the one-row summary — outcome, N, the design counts, the first-stage F, and whether fixed effects were absorbed: ```python print("first-stage F :", round(res.first_stage_f, 2), " p :", round(res.first_stage_p, 6)) print("design : endog =", res.endog, "| instruments =", res.instruments, "| exog =", res.exog) res.glance() ``` `tidy()` is the coefficient frame behind the table (`Estimate`, `Std. Error`, `t value`, `Pr(>|t|)` and the confidence bounds): ```python res.tidy() ``` `.interpret()` reads it in plain language — note it states the *association* and the weak-instrument verdict, and ends by reminding you that relevance and exclusion are *your* argument to make: ```python print(res.interpret()) ``` `.explain()` returns the concept explainer (the same as `ex.explain("instrumental_variables")`): ```python res.explain() ``` ### The panel analogue `analyze_panel_iv_regression` shares the 2SLS core but manages the panel fixed effects for you — it is Stata's `xtivreg2 … fe`: | argument | what it does | default | |---|---|---| | `entity`, `time` | panel ids (resolved from `set_panel` when omitted) | required `entity`; `time` needed for two-way | | `twoway` | absorb both entity **and** time fixed effects | `True` (set `False` for entity-only) | | `cluster_entity` | cluster the standard errors by entity | `True` | ## 3. Does it recover the truth? The cleanest test plants a **known** slope. Let an unobserved confounder $u$ enter both $x$ and $y$, so OLS of $y$ on $x$ is biased; a valid instrument $z$ (excluded from the $y$ equation) lets 2SLS recover the truth: $$ z, u \sim N(0,1)\ \text{independent},\quad x = \pi z + \delta u + \nu,\quad y = \alpha + \beta_{\text{true}}\, x + \gamma u + \varepsilon . $$ Because $\operatorname{cov}(x,u) > 0$ and $\gamma > 0$, the omitted $u$ biases OLS **upward**; $z$ is correlated with $x$ (relevant) but absent from the $y$ equation (excluded). ```python def iv_dgp(*, n=4000, pi=1.0, delta=1.0, gamma=2.0, beta_true=0.8, alpha=1.0, seed=0): """Cross-section where a hidden confounder u biases OLS but z is a valid instrument. z, u ~ N(0,1) independent; x = pi*z + delta*u + noise; y = alpha + beta_true*x + gamma*u + noise. z is excluded from y (exclusion) and u is omitted (so OLS of y on x is biased upward). """ rng = np.random.default_rng(seed) z = rng.normal(size=n) u = rng.normal(size=n) x = pi * z + delta * u + rng.normal(0.0, 0.5, n) y = alpha + beta_true * x + gamma * u + rng.normal(0.0, 0.5, n) return pd.DataFrame({"y": y, "x": x, "z": z}) BETA_TRUE = 0.8 syn = iv_dgp(beta_true=BETA_TRUE, seed=0) iv = ex.analyze_iv_regression(syn, dv="y", endog="x", instruments="z", format="df") ols = ex.analyze_estimation(syn, "y", "x", format="df") iv_b = float(iv.df.set_index("term").loc["x", "Estimate"]) ols_b = float(ols.df.set_index("term").loc["x", "Estimate"]) check = pd.DataFrame({ "estimator": ["true beta", "OLS (biased)", "2SLS (z)"], "slope": [BETA_TRUE, ols_b, iv_b], }) check["abs_error_vs_true"] = (check["slope"] - BETA_TRUE).abs() check.round(4) ``` OLS lands far above the truth; 2SLS sits right on it. The asserts make that precise — and check the instrument is strong: ```python # 2SLS recovers the planted slope; OLS is biased away from it; the instrument is strong. assert abs(iv_b - BETA_TRUE) < 0.05 # 2SLS ~ truth assert ols_b > BETA_TRUE + 0.10 # OLS biased UP (cov(x,u)>0, gamma>0) assert abs(ols_b - BETA_TRUE) > abs(iv_b - BETA_TRUE) # OLS strictly worse assert iv.first_stage_f > 50 # strong instrument (pi=1, n=4000) print("✅ 2SLS recovered the planted slope; OLS is biased; instrument is strong") ``` ## 4. Real data examples ### Cross-section: institutions and income (AJR 2001) The OLS-versus-IV contrast is the whole point. Estimate the naive OLS slope, then the instrumented one: ```python ols_ajr = ex.analyze_estimation( base, "log_gdp_pc_1995", "expropriation_risk", format="df" ) ols_slope = float(ols_ajr.df.set_index("term").loc["expropriation_risk", "Estimate"]) iv_slope = float(res.df.set_index("term").loc["expropriation_risk", "Estimate"]) print(f"naive OLS slope : {ols_slope:.3f}") print(f"2SLS slope : {iv_slope:.3f}") print(f"first-stage F : {res.first_stage_f:.2f}") ``` Instrumenting **raises** the slope from ≈ 0.52 to ≈ 0.94: the classic AJR result that OLS, blurred by measurement error in institutional quality, *understates* the association once we isolate the settler-mortality-driven variation. ```python print(res.interpret()) ``` ### Panel: night-lights and conflict (xtivreg2 fe) A panel IV. Night-time lights proxy local economic activity but are themselves endogenous to conflict; instrument them with **lagged rainfall and drought** (weather shocks that move local activity but are plausibly excludable from conflict given region and year effects). We absorb region and year fixed effects and cluster by region — a panel `xtivreg2 fe`. ```python from expdpy.data import load_regional_conflict, load_regional_conflict_data_def panel = ex.set_labels( load_regional_conflict(), load_regional_conflict_data_def(), set_panel=True ) pres = ex.analyze_panel_iv_regression( panel, dv="conflict", endog="log_lights_lag1", instruments=["rain_lag2", "drought_lag2"], ) pres.etable ``` ```python lights = float(pres.df.set_index("term").loc["log_lights_lag1", "Estimate"]) print(f"lights coefficient : {lights:.3f}") print(f"first-stage F : {pres.first_stage_f:.2f} (two instruments)") print(pres.interpret()) ``` The slope is **negative** (≈ -0.30): more instrument-driven economic activity goes with **less** conflict, with the two weather instruments jointly strong (F ≈ 25, well above 10), region and year effects absorbed, and standard errors clustered by region. ## See also - The `analyze_iv_regression` and `analyze_panel_iv_regression` reference pages — the full argument and return documentation. - `ex.explain("instrumental_variables")` — the concept explainer (also `res.explain()`). - The bundled datasets: `load_colonial_origins` (the AJR cross-section) and `load_regional_conflict` (the African conflict panel), each with a `*_data_def` dictionary. ```python ex.explain("instrumental_variables") ``` ### Marginal effects (analyze_marginal_effects_plot) [Article](https://cmg777.github.io/expdpy/analyze_marginal_effects_plot.html) This page is two things at once: an **extended user guide** for `analyze_marginal_effects_plot` — what it does, every argument, and everything it returns — and a **testing environment** that plants a *known* interaction and checks that the function traces it exactly. If a cell's `assert` ever fails, the function is broken. Everything here describes **associations**; an interaction is not a causal claim. ## What is a marginal effect in an interaction model? When a model includes an interaction `focal * moderator`, the slope of the focal regressor is **not one number**. It is a line that slides as the moderator moves: $$ ME(z) = b_{\text{focal}} + b_{\text{interaction}}\, z . $$ The marginal effect is that partial derivative $\partial y / \partial x$ evaluated at moderator value $z$. Its uncertainty comes from the **delta method**, which combines the variances and covariance of the two coefficients: $$ \operatorname{Var}\!\big(ME(z)\big) = V_{ff} + z^{2} V_{ii} + 2 z\, V_{fi} . $$ Because the variance is **quadratic** in $z$, the confidence band is narrowest near the centre of the data and **widens** toward the edges. A linear marginal effect crosses zero at most once, at the **sign-flip point** $$ z^{\star} = -\,\frac{b_{\text{focal}}}{b_{\text{interaction}}}, $$ below and above which the focal regressor relates to the outcome in *opposite* directions. `analyze_marginal_effects_plot` fits the interaction model, traces $ME(z)$ across the moderator's observed range with the delta-method band, marks the zero line, and reports the **average marginal effect** (the slope evaluated at the sample-mean moderator). ```python import numpy as np import pandas as pd import expdpy as ex ``` ## 1. The method in one cell On the bundled `kuznets` panel: does the **income–inequality gradient** depend on a country's **trade openness**? Trace the marginal effect of log GDP per capita across the range of the trade share. ```python 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_marginal_effects_plot( df, dv="gini_regional", focal="log_gdp_pc", moderator="trade_share" ) res.fig ``` The curve crosses zero inside the data: at low trade openness the gradient is **positive**, at high openness it turns **negative**. The average marginal effect summarises it in one number: ```python print(f"average marginal effect = {res.ame:.5f} (se {res.ame_se:.5f})") ``` ≈ -0.009, distinguishable from zero — but, as the figure shows, that single average hides a sign change. ## 2. How the function works ### Arguments | argument | what it does | when to change it | |---|---|---| | `dv` | the dependent (outcome) variable | always set it | | `focal` | the regressor whose marginal effect is traced | the variable of interest | | `moderator` | the variable it is interacted with | the conditioning variable | | `controls` | additional exogenous controls | to hold other covariates fixed | | `feffects` | fixed effects absorbed by pyfixest | panel / group designs | | `clusters` | cluster variable(s) → also widens the band | when errors are correlated within groups | | `at` | explicit moderator values to evaluate | to read the effect at specific points | | `n_points` | grid size when `at` is not given | smoother / coarser curve (default 50) | | `alpha` | band significance level | `0.05` → 95% (default) | | `title` | figure title | for presentation | ### What it returns The result names the design and carries the grid behind the figure: ```python print("focal :", res.focal) print("moderator :", res.moderator) print("AME :", round(res.ame, 5), " AME se :", round(res.ame_se, 5)) res.df.head() ``` `res.df` has one row per grid point: the moderator value, the marginal effect `me`, its standard error `se`, and the band bounds `ci_lower` / `ci_upper`. `.interpret()` reads it in plain language — it names the sign-flip point and the average effect, and keeps the language associational: ```python print(res.interpret()) ``` `.explain()` returns the concept explainer (the same as `ex.explain("marginal_effects")`): ```python res.explain() ``` ## 3. Does it recover the truth? Plant a **known** interaction and check two things: that the traced `me` grid is *exactly* $\hat{b}_{\text{focal}} + \hat{b}_{\text{interaction}}\, z$, and that the recovered sign-flip matches $-b_{\text{focal}}/b_{\text{interaction}}$. $$ y = a + b_f\, x + b_m\, z + b_{fz}\,(x z) + \varepsilon, \qquad z^{\star} = -\,b_f / b_{fz} . $$ With $b_f = 0.5$ and $b_{fz} = 0.4$ the true flip is at $z^{\star} = -1.25$, inside the data. ```python B_F, B_M, B_FZ, A = 0.5, 0.3, 0.4, 1.0 # planted; flip at -B_F/B_FZ = -1.25 def me_dgp(*, n=3000, a=A, b_f=B_F, b_m=B_M, b_fz=B_FZ, seed=0): """Interaction DGP: y = a + b_f*x + b_m*z + b_fz*(x*z) + noise; x, z ~ N(0,1).""" rng = np.random.default_rng(seed) x = rng.normal(size=n) z = rng.normal(size=n) y = a + b_f * x + b_m * z + b_fz * (x * z) + rng.normal(0.0, 0.5, n) return pd.DataFrame({"y": y, "x": x, "z": z}) syn = me_dgp(seed=0) mres = ex.analyze_marginal_effects_plot(syn, dv="y", focal="x", moderator="z") coef = {str(k): float(v) for k, v in mres.model.coef().items()} b_f_hat, b_fz_hat = coef["x"], coef["x:z"] grid = mres.df["z"].to_numpy() me_closed_form = b_f_hat + b_fz_hat * grid # ME(z) by hand flip_recovered = -b_f_hat / b_fz_hat # zero-crossing check = pd.DataFrame({ "quantity": ["b_focal", "b_interaction", "sign-flip z*"], "true": [B_F, B_FZ, -B_F / B_FZ], "recovered": [b_f_hat, b_fz_hat, flip_recovered], }) check["abs_error"] = (check["recovered"] - check["true"]).abs() check.round(4) ``` ```python # Coefficients recovered to within sampling error. assert abs(b_f_hat - B_F) < 0.05 assert abs(b_fz_hat - B_FZ) < 0.05 # The plotted me grid IS b_focal + b_interaction*z, exactly (pure algebra, no estimation error). np.testing.assert_allclose(mres.df["me"].to_numpy(), me_closed_form, atol=1e-9) # The recovered sign-flip matches -b_focal/b_interaction (up to sampling error). assert abs(flip_recovered - (-B_F / B_FZ)) < 0.1 # And the AME sits near b_focal, since mean z ~ 0. assert abs(mres.ame - B_F) < 0.06 print("✅ me grid = b_focal + b_interaction*z; coefficients and sign-flip recovered") ``` ### Why the band widens away from the centre The delta-method variance is quadratic in the moderator, so the standard error is U-shaped — smallest near the middle of the data, largest at the edges: ```python narrow = mres.df["se"].idxmin() print("narrowest band near z =", round(float(mres.df["z"].iloc[narrow]), 3), "-> se =", round(float(mres.df["se"].iloc[narrow]), 4)) print("widest at the edges :", round(float(mres.df["se"].iloc[[0, -1]].max()), 4)) assert mres.df["se"].iloc[[0, -1]].max() > mres.df["se"].iloc[narrow] print("✅ the band is narrowest in the centre and widens toward the edges") ``` ## 4. The income–inequality gradient across trade openness (kuznets) Back to real data. The marginal effect of development on regional inequality is positive at low trade openness and turns negative at high openness — the sign change the figure shows: ```python res.fig ``` ```python print(res.interpret()) ``` The `interpret()` text states the average effect (≈ -0.009, distinguishable from zero) and the sign-flip point itself. Read the whole band, not the single average: this is an **association** conditional on the model, not a causal effect. ## See also - The `analyze_marginal_effects_plot` reference page — the full argument and return documentation. - `ex.explain("marginal_effects")` — the concept explainer (also `res.explain()`). - The bundled `load_kuznets` dataset and its `load_kuznets_data_def` dictionary. ```python ex.explain("marginal_effects") ``` ### The kuznets dataset [Article](https://cmg777.github.io/expdpy/explanation/kuznets-dataset.html) `kuznets` is the package's lead showcase dataset: a **synthetic** country–year panel of **80 countries over 2015–2025** (880 observations). It is modelled on Table 4 of the [Lessmann & Seidel (2017) regional-inequality replication](https://carlos-mendez.org/post/python_kuznets_dmsp/data/) — the same variables (renamed to clean `snake_case`) plus a synthetic `continent` grouping — but the data is generated so that regional inequality traces an **N-shaped Kuznets curve** in income: inequality *rises*, *falls*, then *rises again* at very high income. Country names are generic (`country 1` … `country 80`), so the panel illustrates the package without standing in for any real economy. It is deliberately rich in control variables and in realistic features (skewed distributions, scattered missing values, a multi-level factor and a two-level dummy) so a single dataset can exercise **every** `expdpy` feature. ```python import expdpy as ex from expdpy.data import load_kuznets, load_kuznets_data_def df = load_kuznets() df.head() ``` ## Variable dictionary The bundled definition table marks the panel identifiers (`entity` / `time`) and the variable types the app uses to populate its selectors. Each description also records the original Table-4 column name. ```python load_kuznets_data_def() ``` Highlights: - **`gini_regional`** — the outcome that follows the N-shape (regional inequality). - **`gdp_pc`** and its derived `log_gdp_pc`, `log_gdp_pc_sq`, `log_gdp_pc_cu` — income and the polynomial terms that make the cubic curve turnkey. - **`country`** (`entity`) and **`year`** (`time`) — the panel identifiers, and the natural two-way fixed effects for any regression on this dataset. - **`continent`** (5 levels) and **`federal`** (0/1) — extra grouping factors for by-group views (and alternative fixed effects). - **`gasoline_price`, `school_enrollment`, `gini_lights`, `polity2`** carry realistic missing values (heavier in the early years), so the missing-value heatmap has something to show. ## The N-shaped Kuznets curve A scatter of regional inequality against (log) GDP per capita, with a LOESS smoother, reveals the rise–fall–rise directly: ```python ex.explore_scatter_plot( df, x="log_gdp_pc", y="gini_regional", color="continent", size="population", loess=1 ).fig ``` ## Statistical evidence Because `kuznets` is a country–year panel, the regression absorbs **two-way (country + year) fixed effects** — the natural specification for panel data, controlling for every time-invariant country trait and every common annual shock. A cubic in (log) GDP per capita still recovers the N *within* country — the cubic term is **positive and significant** — with standard errors clustered by country: ```python ex.analyze_regression_table( df, dvs="gini_regional", idvs=["log_gdp_pc", "log_gdp_pc_sq", "log_gdp_pc_cu"], feffects=["country", "year"], clusters=["country"], ).etable ``` ## Launching the app on kuznets The dataset ships a preset configuration that opens an app directly on the curve: ```python from expdpy.streamlit_app import ExploreApp from expdpy.data import load_kuznets, load_kuznets_data_def, get_config ExploreApp(load_kuznets(), df_def=load_kuznets_data_def(), config_list=get_config("kuznets")) ``` When launched without data, the bundled Streamlit apps default to this dataset in their picker. ### Data model [Article](https://cmg777.github.io/expdpy/explanation/data-model.html) `expdpy` follows ExPanDaR's panel-data model. This page describes how to structure both the **data** and its **data dictionary** (`df_def`) so that every figure and table renders clearly — in particular, so axes, legends and headers carry **full, human-readable labels** rather than bare column names. ## The data: a tidy panel Your data frame should be **tidy (long)**: - **one row per `entity` × `time` observation** (e.g. one row per country-year); - **one column per variable** — the entity id(s) and the time id are ordinary columns, sitting alongside the variables you analyse; - **balanced or unbalanced** panels both work (units may enter, exit or have interior gaps), and a pure **cross-section** simply omits the `time` column. A few rows of the bundled `kuznets` panel illustrate the shape — each `(country, year)` pair is one row, every variable its own column: | country | year | gini_regional | gdp_pc | log_gdp_pc | |-----------|------|---------------|--------|------------| | country 1 | 2015 | 0.31 | 812 | 6.70 | | country 1 | 2016 | 0.30 | 845 | 6.74 | | country 2 | 2015 | 0.12 | 41 980 | 10.65 | Do **not** pre-aggregate or pivot to wide form — the Explore and Analyze functions reshape internally (melting, grouping, demeaning) and need the long layout. ## Identifiers - **`entity`** — one or more columns identifying the cross-section / unit (e.g. country + ISO code). - **`time`** — a single column identifying the time dimension, coercible to an ordered factor (e.g. fiscal year). Declare them once with `set_panel(df, entity=..., time=...)`, pass them directly to a function, or describe them via a **`df_def`** table (below) and let `set_labels(df, df_def, set_panel=True)` declare them for you. ## The data dictionary (`df_def`) `df_def` is a small DataFrame with **one row per variable** you want to expose. At a minimum include the **entity** row(s) and the **time** row (so panel views know the identifiers), plus the numeric/factor variables you analyse. Variables absent from `df_def` simply fall back to default type inference and their bare column name. Its columns: | column | required? | meaning | |-------------|----------------------|-----------------------------------------------------------------------------------------------------------| | `var_name` | **required** | the column name in the data (must match exactly) | | `type` | **required** | one of `entity`, `time`, `factor`, `logical`, `numeric` (made **authoritative** for the app's variable menus) | | `label` | recommended | a **concise, human-readable label** used for axis titles, legends and table headers (with units where helpful, e.g. `"GDP per capita (USD)"`) | | `var_def` | optional | a longer description (or, for `var_def` tables, an expression). Used as the label only when `label` is absent | | `role` | optional | marks the **key variables**: one `outcome`, any number of `covariate`, and one `entity_name` (the column holding readable unit names). Blank for everything else | | `can_be_na` | optional | if `False`, rows missing this variable are **dropped** from the analysis sample (only the entity/time ids are required by default) | ```python from expdpy.data import load_kuznets_data_def # columns: var_name, var_def, label, type, role, can_be_na load_kuznets_data_def() # entity (country, iso), time (year), factors and numerics ``` The bundled `*_data_def` tables (`kuznets`, `gapminder`, `firms`, `staggered_did`) ship a curated `label` for every variable. ## Key variables: main outcome & covariates Mark the variable you most care about as the **main outcome** and the explanatory variable(s) of interest as **covariates** — in the dictionary's `role` column, or directly with `set_roles(df, outcome=..., covariates=[...])`. Once declared, the figures, tables and apps **default to these key variables** instead of the first numeric column: - a scatter plots the covariate against the outcome; a regression uses the outcome as the dependent variable and the covariates as the regressors; - single-variable views (histogram, trend, extreme observations, spaghetti, …) default to the outcome, and the descriptive / correlation tables lead with the key variables; - in the apps, the key variables are **pre-selected** and **floated to the top** of every dropdown. The bundled `*_data_def` dictionaries already mark their key variables, so the defaults work the moment you attach them — and `set_roles` declares the same for your own data: ```python import expdpy as ex from expdpy.data import load_kuznets, load_kuznets_data_def # kuznets ships role=outcome on gini_regional and role=covariate on the log-GDP terms df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True) ex.explore_scatter_plot(df).fig # x = log_gdp_pc, y = gini_regional ex.analyze_regression_table(df).etable # the cubic Kuznets curve, by default # declare the same on a frame of your own df = ex.set_roles(df, outcome="gini_regional", covariates=["log_gdp_pc"]) ``` ## Entity names vs entity ids A panel keys on an `entity` **id** (e.g. an ISO code or a numeric province id), but a separate column often holds a readable **name** for each unit (e.g. the country or province name). Tag that column `role = entity_name` (or let `build_data_def` auto-detect it — any text column that is constant within, and ~1:1 with, the entity id), and every figure that labels individual units shows **`Name (id)`** — e.g. `Bolivia (BOL)` in a spaghetti legend or a hover tooltip — by reading `df.attrs`. No per-figure arguments are needed; the `.df` you get back keeps the raw ids. ## Readable labels in figures and tables Readability is a key component of any figure or table, so the best practice is to **label with the data dictionary**. Attach the labels once and every downstream figure and table picks them up automatically: ```python 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.explore_trend_plot(df, var="gini_regional").fig # y-axis: "Regional inequality (Gini)" ex.explore_descriptive_table(df).gt # rows labelled, not "gini_regional" ``` `set_labels` stores the labels on `df.attrs` (just as `set_panel` stores the panel ids and `set_roles` stores the key variables), so they survive across calls — though, like the panel ids, some pandas operations drop `attrs`; re-apply `set_labels` / `set_panel` / `set_roles` after such steps, or pass the values explicitly. One call does it all: `set_labels(df, df_def, set_panel=True)` reads the dictionary's labels **and** declares the panel, the `entity_name`, and the `outcome` / `covariate` roles it carries. The label for each variable is resolved in order of preference: 1. an explicit per-call `label=` / `labels=` argument (always wins), 2. the `label` stored on `df.attrs` (from `set_labels`), 3. the variable's `label` column in `df_def`, then its `var_def`, 4. finally the bare **`var_name`** when no readable label exists. So a dictionary without a `label` column still works (it falls back to `var_def`, then the name), and a data frame with no labels at all simply shows column names — labelling is a readability upgrade, never a requirement. The returned `.df` / `.df_corr` of every result keeps the **raw** variable names; only the displayed axes and headers use the labels. ## Variable categories Inside the app, columns are classified for the selectors: a **factor** is categorical/object or a numeric column with at most `factor_cutoff` (default 10) distinct values; a **two-level** column has exactly two values; the rest are **numeric** or **logical**. When the dictionary declares a `type`, that declaration is **authoritative** — a numeric column you tag `factor` becomes a grouping variable, for instance, regardless of its dtype. The marked outcome and covariates are also floated to the top of the matching selectors. ## Time-axis conversion The time-trend functions coerce the `time` id for nicer ticks: full date strings become dates, numeric-looking values (including factor years like `"2013"`) become numbers, and anything else becomes an ordered categorical — matching ExPanDaR's `try_convert_ts_id`. ### Changelog [Article](https://cmg777.github.io/expdpy/changelog.html) ## 0.5.2 (2026-07-01) **Calmer animation controls + a richer Explore app.** - The animated views (scatter, distribution-over-time, box, strip, treemap, sunburst) now carry a single unobtrusive **▶** button in the **bottom-left corner, beside the time slider** (pause by dragging the slider) instead of a Play/Pause pair above the plot. - `explore_strip_plot` names each point's **entity in the hover box** ("Name (id)" when a readable name is declared). - The **Explore Streamlit app** gains full controls for the box/strip/composition charts (log scale, ordering, orientation, box points, strip opacity & sampling, treemap/sunburst leaf sampling), copy-paste **"Export & reproduce"** code snippets for each, and an *Animated views* callout; the **Composition** page now sits right after **By group**. - The Explore docs page & notebook demonstrate the new parameters and the box/strip `.interpret()`. ## 0.5.1 (2026-07-01) **Richer animated exploration for panel data.** Four new time-animated Explore views make "watch this evolve year by year" a one-call operation, each degrading gracefully to a static figure when no time id is available: - `explore_treemap_plot` / `explore_sunburst_plot` — hierarchical composition (a flexible `path=[...]`, defaulting to the panel entity) sized by a value and colored by another, animated over time with a fixed color scale. - `explore_box_plot` / `explore_strip_plot` — distribution-by-group box and strip plots that animate over time, with a pinned value axis, an optional log scale, and (for strip) point sampling on large panels. Both carry a plain-language `.interpret()`. **Consistency + polish.** All animated figures now share one control surface via a new internal `_animate` helper (unified "▶ Play" / "⏸ Pause" buttons, frame speed, and a time-labelled slider), and `explore_animated_scatter_plot` gains `log_x` / `log_y` toggles (the canonical Gapminder log-income axis). ## 0.5.0 (2026-06-29) **expdpy is now first-class for LLMs and AI agents.** A new agent-facing surface lets you point an LLM at the library and have it work correctly — carrying expdpy's econometric guardrails (associations, not causation; `entity`/`time` panel vocabulary; the `.interpret()` / `explain()` contract). Everything derives from one source of truth (`expdpy._meta`), so the agent surface cannot silently drift from the API. ### Added - **`llms.txt` + `llms-full.txt`** ([llmstxt.org](https://llmstxt.org) convention) at the site root: a curated, guardrailed index of every function, article, dataset and concept, and a full-text concatenation for one-shot context. Every docs page also gets a clean Markdown twin at `.html.md`. - **MCP server** (`pip install "expdpy[mcp]"`, console script `expdpy-mcp`): a stdio Model Context Protocol server exposing a curated set of Explore/Analyze tools plus `explain` / `list_topics` / `learn_concept`. Each tool returns the result's `.interpret()` reading, its tables, and a saved figure path. Pass data by bundled-dataset name, file path, or inline records. - **Static tool schemas**: committed [Anthropic](tools/anthropic_tools.json) and [OpenAI](tools/openai_tools.json) function-calling JSON (published under `/tools/`), generated from the same registry the MCP server uses. - **`use-expdpy` agent skill** (`.claude/skills/use-expdpy/`) and the [For AI / LLMs](use-with-llms.qmd) hub page: an end-user skill that teaches an agent to declare the panel, pick the right function, read the result contract, and interpret responsibly. All artifacts are regenerated in the docs build and guarded by a CI freshness check. The change is purely additive — no existing API changes. ## 0.4.23 (2026-06-29) The data dictionary (`df_def`) becomes a first-class driver of labels, key variables and entity names — across both the library and the no-code apps. Additive and backward-compatible: a dictionary without the new column behaves exactly as before. ### Data dictionary — a new `role` column - `df_def` gains a sixth column, **`role`**, with values `outcome` / `covariate` / `entity_name` (blank otherwise). Mark the **main outcome** and **main covariate(s)** once, and the figures, tables and apps default to them; mark an **`entity_name`** column (a readable label for each unit) and figures render units as **`Name (id)`** (e.g. `Bolivia (BOL)`). - **`build_data_def`** now emits the `role` column and **auto-detects** the entity-name column (a text column that is constant within, and ~1:1 with, the entity id). - Every **bundled `*_data_def`** ships curated roles, so the flagship datasets demonstrate the defaults out of the box — loading `kuznets` defaults a regression to the cubic Kuznets curve, `bolivia112_gdppc` labels provinces as `Name (id)`, and so on. - New **`set_roles(df, outcome=, covariates=)`** utility (stored on `df.attrs`, like `set_panel` / `set_labels`); `set_panel` gains an `entity_name=` argument; and `set_labels(df, df_def, set_panel=True)` now also declares the roles + entity-name a dictionary carries. ### Library — figures and entry functions use the metadata - Spaghetti, panel-structure / value heatmaps, within-persistence, within/between scatter, animated scatter, β-convergence, convergence clubs, Kuznets waves and the extreme-observations table now show **`Name (id)`** when an entity-name column is declared. - When their primary variable is omitted, `explore_scatter_plot` (x = first covariate, y = outcome), `analyze_regression_table` (dv = outcome, idvs = covariates), `explore_histogram` and `explore_trend_plot` default to the declared roles; the descriptive and correlation tables lead with the key variables. ### Apps — labels, defaults and the dictionary editor - Variable menus now display **`Label (var_name)`** when the dictionary provides labels; the selected value stays the raw column name. - The marked outcome / covariate(s) are **pre-selected** as defaults and **floated to the top** of each dropdown. - The data-dictionary editor gains a **Role** selector, the declared **`type`** is now authoritative for the variable categories, and **`can_be_na = False`** is enforced (rows missing a required variable are dropped from the analysis sample). ## 0.4.22 (2026-06-27) A teaching + internals release: four new concept sandboxes, an internal helper refactor, and Learn-app polish. Additive and backward-compatible. ### Learn — four new concept sandboxes - **`learn_hausman_test`** — a panel whose regressor is correlated with the unit effects, so random effects is biased and the Hausman test rejects it in favour of fixed effects. - **`learn_correlated_random_effects`** — the Mundlak device, whose slope on the original regressor equals the fixed-effects estimate, with the Mundlak (regression-form Hausman) test. - **`learn_nickell_bias`** — the within estimate of a lagged dependent variable is biased downward in short panels and the bias shrinks as `T` grows. - **`learn_measurement_error`** — classical attenuation: noise in a regressor pulls its OLS coefficient toward zero by the reliability ratio. Each ships an explainer (`nickell_bias` and `measurement_error` are new), a `.interpret()` reading, and now a **`.data`** field — the raw simulated frame (every sandbox carries it). ### Internals — shared helpers in `_common.py` - The genuinely cross-module private helpers (numeric-then-lexical level sorting, the sample-size default opacity, and the time-axis / standard-error / x-axis builders) moved from the feature modules (`scatter.py`, `trends.py`) into a new `expdpy._common`. No public API change. ### Learn app - The concept sandboxes are now a **lazy picker** (one selectbox) instead of nine eager tabs — only the chosen sandbox simulates and renders. - The explainer index is **searchable** (filter by name, title or description). - Each sandbox offers a **CSV download of its simulated dataset**. ## 0.4.21 (2026-06-27) A low-risk **polish** release: a broader teaching layer, consistent missing-data reporting, and opt-in accessible theming. All additive and backward-compatible. ### Learn — `.interpret()` on nine more result types - Plain-language `.interpret()` now ships on the result types that lacked it: **`analyze_coefficient_plot`**, **`analyze_fixef_plot`**, **`analyze_predictions`**, **`analyze_joint_test`**, **`analyze_robust_inference`** and **`analyze_panel_view`** (Analyze), plus **`explore_scatter_plot`**, **`explore_missing_values_plot`** and **`explore_value_heatmap`** (Explore). Each reads the result in associational, non-causal language (the existing `analyze_joint_test().summary()` is unchanged). ### Explore — consistent missing-data reporting + clearer errors - Functions that drop rows with missing values now emit a single advisory **`ExpdpyWarning`** ("dropped N of M rows … with missing values in […]") instead of dropping silently, across the Explore and Analyze surfaces. Silence one category-wide with `warnings.filterwarnings("ignore", category=expdpy.ExpdpyWarning)`. - Several vague panel/validation errors now name the offending column(s) and disambiguate "entirely missing" from "no overlapping observations". ### Theming — colorblind-safe palette + unified title API - **`set_palette("colorblind")`** / **`get_palette()`** — an opt-in, process-global toggle to the **Okabe-Ito** colorblind-safe qualitative palette plus colorblind-safe sequential and diverging scales. The default look is unchanged until you opt in. - Every Explore plotting function and table now accepts **`title=`** / **`subtitle=`**; tables keep **`caption=`** working as a backward-compatible alias. *Known limitation:* a few decorative fixed-color lines do not follow the palette toggle. ## 0.4.20 (2026-06-27) ### Analyze — instrumental variables, panel IV, and marginal effects - **`analyze_iv_regression`** — instrumental-variables / two-stage least squares (2SLS) via pyfixest's native IV formula, with fixed effects, clustered standard errors, and the **first-stage weak-instrument F**. **`analyze_panel_iv_regression`** is the panel-aware companion (the `entity`/`time` vocabulary, two-way fixed effects and cluster-by-entity by default — the equivalent of Stata's `xtivreg2 ... fe`). Both carry `.interpret()` and an `instrumental_variables` concept explainer. - **`analyze_marginal_effects_plot`** — for a model with an interaction `focal * moderator`, traces the marginal effect of the focal regressor across the moderator's range with a delta-method confidence band, plus the average marginal effect. ### Explore — an animated bubble scatter - **`explore_animated_scatter_plot`** — a Gapminder-style time-slider bubble chart: each unit is a bubble whose position, size and color move across periods, with a play button and pinned axes so motion is comparable over time. ### Data — two canonical IV teaching datasets - **`load_colonial_origins`** — the Acemoglu-Johnson-Robinson (2001) "Colonial Origins" 163-country cross-section. The 64-country `base_sample` reproduces the famous instrumented expropriation-risk slope of ≈ 0.94. - **`load_regional_conflict`** — a focused subset of the African region-year conflict panel (night-lights instrumented by lagged rainfall and drought, region + year fixed effects); the first-stage F reproduces the published 24–40 range. ### Apps — exports, copy-code, and a results-comparison panel - **Every chart and table now has an "⬇ Export & reproduce" expander** — download the figure as a self-contained interactive HTML file (or the table as CSV) and copy the exact expdpy code that reproduces the view. - **Save & compare regression specifications.** On the Regression page, "➕ Save spec for comparison" stashes the current model; saved specs render side by side as one table and a dodged coefficient plot. ## 0.4.19 (2026-06-26) ### Apps — a richer, more robust sample selector - **The Sample menu now filters by category, by value range, and by period.** Pick one or more **factor variables** and the categories to keep; one or more **continuous variables** and a min–max range; and a dedicated **Period** slider over the time id. All filters combine (AND), and an always-visible **Active sample** summary (dataset · period · filters · "rows kept = n / N") with a **Reset filters** button keeps you oriented on every page. - **Single-year → cross-section.** Collapse the Period slider to one year and the app switches to **cross-sectional mode** — the panel / over-time views (within-between, trends, dynamics, convergence, event-study, panel-models, missing-by-time) hide with a notice, and the cross-sectional views keep working. - **Fixed the disappearing-selection bug.** Filter options are now computed from the *pre-subset* frame, so choosing `continent = X` no longer collapses the factor and resets the selection — selections persist across pages, and every analysis updates as you filter. - **Missing-values view no longer looks blank.** When a sample has no missing values (e.g. the unbalanced **firms** panel), it now says so clearly and points to **Panel structure** for the structural gaps, instead of drawing an all-zero heatmap. (A mixed-type balance-summary table also renders cleanly now.) - **Export reproduces the filters as code.** "Export notebook + data" now ships the *unfiltered* working frame plus a runnable **Subset the sample** cell (`df = df[… isin/between …]`) and the outlier-treatment step, so the notebook rebuilds the exact analysis sample from the full data. ## 0.4.18 (2026-06-26) ### Apps — the two-file (data + dictionary) workflow - **The ExPdPy apps now revolve around two files: a data file *and* a data dictionary (`df_def`).** Upload your own data and (optionally) its dictionary, and every figure and table updates with the right labels and panel structure. Upload **data only** and the app auto-builds an **editable** dictionary in the sidebar — set the `entity` and `time` rows to unlock the panel views, tidy the labels, then **Apply**. A new public helper, [`build_data_def`](reference/build_data_def.html), infers that dictionary (types + entity/time) from any frame, so the same workflow is available in notebooks: `ex.set_labels(df, ex.build_data_def(df), set_panel=True)`. - **The data dictionary is applied throughout** — labelled axes/legends/headers now render for the bundled datasets too (previously the labels were never attached in the app), and switching datasets resets stale variable selections cleanly. This fixes views that could silently disappear on an upload (e.g. the missing-values map) because the panel was never declared. - **The dataset picker now offers *every* bundled dataset** — `Productivity` and `Bolivia (provinces)` join Kuznets, Gapminder, Staggered DiD and Firms. - **Export now ships the dictionary, and the notebook is Colab-ready.** "Export notebook + data" writes `expdpy_data_def.csv` alongside the sample, and the generated notebook reloads both with `ex.set_labels(df, data_def, set_panel=True)`. The `.ipynb` is a **Google Colab** notebook: a pinned install cell (`expdpy==`, NumPy/Numba upgrade) restarts the runtime once so NumPy loads cleanly, with markdown explaining the setup. The `.py` script stays a plain local-run script. ## 0.4.17 (2026-06-25) ### Learn - **The [Learn page](learn.html) is now a single-pass tutorial — "the ideas behind the case study."** A complete redesign opens by interpreting a *real* two-way fixed-effects Kuznets model (`.interpret()` / `.explain()`), browses the full concept index (`list_topics()` — 27 topics — grouped by theme), then isolates each idea in a simulated sandbox where the truth is known: the within-transformation identity (`learn_first_differences`, `learn_within_vs_lsdv`), why fixed effects matter (`learn_pooled_vs_fixed_effects`), two inference classics (`learn_omitted_variable_bias`, `learn_clustering_se`), convergence (`learn_beta_convergence`, `learn_sigma_convergence`, `learn_convergence_clubs`), and the Kuznets wave (`learn_kuznets_waves`). Every `learn_*` sandbox now appears once each, removing the old tour/gallery duplication and adding the previously-undemonstrated `learn_kuznets_waves`. - **The Learn Streamlit app mirrors the tutorial.** The Concept sandboxes page grows from seven to **nine** tabs — adding `learn_sigma_convergence` and `learn_convergence_clubs` — reordered to the case-study sequence. The Concept explainers page browses all 27 topics. - **The [Learn Colab notebook](https://colab.research.google.com/github/cmg777/expdpy/blob/main/notebooks/learn.ipynb) is regenerated** so the website, notebook and app present the same sequence. ## 0.4.16 (2026-06-25) ### Analyze - **The [Analyze page](analyze.html) is now a single-pass Kuznets case study.** A complete redesign walks newcomers through *all 17* `analyze_*` functions once each, in the order an analyst actually works: fit a first model and add fixed effects (`analyze_regression_table`, `analyze_coefficient_plot`, `analyze_fwl_plot`) → enrich the estimation (`analyze_estimation`) → read the fitted model (`analyze_predictions`, `analyze_fixef_plot`, `analyze_joint_test`) → stress-test the inference (`analyze_robust_inference`) → choose the panel estimator (`analyze_panel_table`, `analyze_hausman_test`, `analyze_cre_table`) → the flagship Kuznets-waves curve (`analyze_kuznets_waves`) → a related income-convergence question (`analyze_beta_convergence`, `analyze_sigma_convergence`, `analyze_convergence_clubs`) → a causal design (`analyze_panel_view`, `analyze_event_study`). The four previously-undemonstrated functions (the convergence trio and Kuznets waves) now appear in the narrative. - **The Analyze Streamlit app mirrors the case study.** Its pages are reorganized to the same workflow — Regression, Post-estimation (new), Panel models, Kuznets waves, Convergence (β / σ / clubs consolidated), Event study & DiD — surfacing the seven functions the app did not previously render (`analyze_estimation`, `analyze_predictions`, `analyze_fixef_plot`, `analyze_joint_test`, `analyze_robust_inference`, `analyze_beta_convergence`, `analyze_panel_view`). Results only; plain-language readings sit behind a collapsed expander. - **The [Analyze Colab notebook](https://colab.research.google.com/github/cmg777/expdpy/blob/main/notebooks/analyze.ipynb) is regenerated** from the redesigned page, so the website, notebook and app present the same sequence of tools. ## 0.4.15 (2026-06-25) ### Explore - **The [Explore page](explore.html) is now a single-pass Kuznets case study.** A complete redesign walks newcomers through *all 21* `explore_*` functions (and the `set_panel` / `resolve_panel` / `treat_outliers` utilities) once each, in the order an analyst actually works: know the panel's skeleton (`explore_panel_structure`, `explore_missing_values_plot`, `explore_value_heatmap`) → describe variables → split within vs between variation (`explore_xtsum_table`, `explore_spaghetti_plot`) → trends (incl. `explore_distribution_over_time`) → compare groups → relationships and the N-shaped curve (incl. `explore_scatter_plot_within_between`) → dynamics (`explore_transition_matrix`, `explore_within_persistence`). The eight previously-undemonstrated panel-aware functions now appear in the narrative. - **The Explore Streamlit app mirrors the case study.** Its pages are reorganized to the same workflow — Overview & Data, Describe variables, Within & between, Trends, By group, Relationships, Dynamics — replacing the single catch-all "Panel structure" page. The Colab notebook is regenerated from the redesigned page. ## 0.4.14 (2026-06-24) ### Explore - **`explore_scatter_plot` no longer draws a LOESS confidence band.** The shaded band was an *unweighted* bootstrap while the LOESS line is *size-weighted* under `loess=2`, so the line could fall outside its own band; the smoother now shows just the line. - **`explore_spaghetti_plot` no longer shows a legend.** It was a single "mean (…)" entry that ate horizontal plot space; highlighted units still render in saturated colour. ## 0.4.13 (2026-06-24) ### Explore - **`explore_descriptive_table` is now panel-aware.** When a `time` column is known (declared via `set_panel` / `set_labels`, or passed explicitly) each statistic is shown **by period** — by default at the first and last period — under a spanning column header (e.g. `Mean` over `2015` and `2025`); without a time dimension it falls back to one column per statistic. The default statistics are now **Mean, Std. dev., Median, Min., Max.**, rows are labelled from the data dictionary, and the notes report the number of observations and any variable with missing data. **Breaking:** the old length-8 `digits` vector is replaced by a `stats=` selection (any of the eight statistics), a scalar-or-mapping `digits=`, and a new `periods=` argument; the result gains a tidy `.by_period` frame (`.df` still carries all eight pooled statistics). - **`explore_histogram` gains opt-in density overlays.** New `kde=` and `normal=` flags draw a Gaussian kernel-density estimate and/or a normal curve on the Density scale (off by default; the Count/Density toggle hides them in Count view). - **The trend / time-series plots no longer show a draggable range slider** (`explore_trend_plot`, `explore_quantile_trend_plot`, `explore_spaghetti_plot`). ### Data dictionary (`df_def`) everywhere - **Every function now leans on the data dictionary for readable output, while still working without it.** Regression / estimation / CRE tables relabel their coefficient and dependent-variable rows from the dictionary (the tidy `.df` keeps raw term names), and the panel estimators (`analyze_panel_table`, `analyze_hausman_test`, `analyze_cre_table`) and DiD views (`analyze_event_study`, `analyze_panel_view`) now resolve `entity` / `time` / `unit` from the declared panel, so those arguments can be omitted after `set_panel` / `set_labels`. - **Examples across the library now illustrate the dictionary**, opening with `df = ex.set_labels(load_kuznets(), load_kuznets_data_def(), set_panel=True)`. ## 0.4.12 (2026-06-24) ### Docs - **The API reference now shows live output and the source code of every function.** Each reference page renders its docstring examples as *executed* cells — interactive Plotly figures, Great Tables and data frames appear directly below the code — and carries a **source** link to a new splot-style source page (`reference/modules/`) that lists the full, syntax-highlighted module source, anchored at the function (with a back-link to the docs). Two build steps (`tools/build_source_pages.py`, `tools/build_reference_enrichment.py`) run inside `docs-build` between `quartodoc build` and `quarto render`. - **The API reference index is easier to scan.** Each function now shows a brief, splot-style argument signature beside its name (e.g. `analyze_beta_convergence(df, var[, controls, …])`), and the distracting link underline is dropped from the listing. ## 0.4.11 (2026-06-23) ### Fixed - **Convergence clubs** (`analyze_convergence_clubs`) — three correctness fixes to the Phillips-Sul clustering, plus a reporting fix: - The default `method="adjust"` (Schnurbus et al. 2016) club refinement scored each candidate against the *core* group rather than the *growing* club and had no final joint-test fallback, so it could emit a group labelled a convergence "club" whose own log(t) t-statistic was below the threshold. It now scores against the accumulating club and falls back to the core when the refined club still fails its joint test (matching the `method="ps"` branch). - A variable whose per-period cross-sectional mean is at or near zero (e.g. a demeaned, centered or growth series) made the relative transition `h_it = x_it / mean_i(x_it)` blow up to `inf` and silently corrupt every returned frame and figure. It now raises a clear error. - A constant / already-identical panel produced a non-finite global log(t) statistic that was silently reported as "divergent" (printing a literal `NaN`). It now raises a clear "not estimable" error. - The user-supplied `tcrit` threshold is now threaded into the summary table's `converging` column, the table source-note and the plain-language `.interpret()` (all previously hardcoded to `-1.65`), and is exposed on `ConvergenceClubsResult.tcrit`. ## 0.4.10 (2026-06-23) ### Added - **Kuznets-waves analysis** (`analyze_kuznets_waves`). Tests the extended Kuznets curve — the inequality-development relationship taken up to a quartic, `gini = b_1 g + b_2 g^2 + b_3 g^3 + b_4 g^4` with `g = log` GDP per capita — under three panel estimators laid out side by side: **pooled OLS**, the **between** estimator (the cross-country curve, a polynomial in the entity means) and the **within** estimator (two-way country + year fixed effects). Each is a cumulative-stepwise (`csw`) comparison table — the linear model, then the quadratic, up to the full `degree`-order polynomial — and three figures tell the pooled → between → within story: a raw scatter with the pooled wave overlaid, and **between** and **within** Frisch–Waugh–Lovell **partial-residual (component) plots** that draw the fitted wave once optional `controls` (and, for the within view, the two-way fixed effects) are partialled out. The result exposes `gt_pooled` / `gt_between` / `gt_within`, the three figures, a per-estimator curvature `summary` (turning points, peak, top-order term), the fitted `models`, and `.interpret()` / `.explain()`. A paired `learn_kuznets_waves()` sandbox, a `kuznets_waves` concept explainer, a Streamlit **Kuznets waves** tab, and a Quarto → Colab notebook ship with it. ## 0.4.9 (2026-06-23) ### Added - **Data:** a new bundled **`bolivia112_gdppc`** dataset — a real-world balanced panel of 112 Bolivian provinces (nested within 9 departments) over 1990-2024 with GDP per capita and its natural log; the empirical counterpart to the synthetic `productivity` panel, for the convergence workflows (`analyze_beta_convergence` / `analyze_sigma_convergence` / `analyze_convergence_clubs`) and general subnational exploration. Load with `load_bolivia112_gdppc()` / `load_bolivia112_gdppc_data_def()`. Source: Kummu, Kosonen & Masoumzadeh Sayyar, "Downscaled gridded global dataset for GDP per capita PPP over 1990-2022," Sci Data 12, 178 (2025), https://doi.org/10.1038/s41597-025-04487-x. ## 0.4.8 (2026-06-22) ### Added - **Club-convergence analysis** (`analyze_convergence_clubs`). A faithful Python port of the Phillips & Sul (2007/2009) log(t) test and data-driven clustering algorithm (the Stata `psecta` package). It runs the full workflow on one variable: a per-unit **Hodrick-Prescott** filter (`lambda = 400` for annual data), the **relative transition path** `h_it = x_it / mean_i(x_it)`, the **log(t) regression test** for the whole panel and — when global convergence is rejected — the **clustering algorithm** that splits units into convergence **clubs**, followed by **merging** of adjacent clubs that jointly converge. The log(t) statistic uses the Phillips-Sul scalar long-run-variance HAC (Andrews 1991 quadratic-spectral kernel with an AR(1) automatic bandwidth), hand-coded in NumPy to match the reference since standard OLS engines do not provide it. The result exposes a tidy long frame (with `relative` and `club`), three figures (within-club averages, all paths by club, and per-club small multiples), a classification table and an `entity -> club` membership frame. Configurable trimming `r`, sieve `method` (Schnurbus-adjusted or original PS), `merge` mode and HP `filter`. - **Learn:** a runnable `learn_convergence_clubs` sandbox plants a known club structure and shows the algorithm recover it, and an `explain("convergence_clubs")` concept explainer covers the log(t) test and clustering. A new "Convergence clubs" page in the Analyze app exposes the analysis on your data. - **Data:** a new bundled **`productivity`** dataset — a balanced 108-country × 25-year Penn World Table panel of (raw) log GDP per capita and log labor productivity with region / income grouping factors — for the club-convergence workflow (`load_productivity`). ### Fixed - **`analyze_beta_convergence`:** a conditional control that is constant or collinear with the initial level was silently dropped by the estimator, so the reported "conditional" fit was identical to the unconditional one with no warning. The conditional model is now **skipped with an explicit note** naming the offending control(s), rather than mislabelling the unconditional slope as conditional. - **`analyze_beta_convergence`:** duplicate `(entity, time)` rows were de-duplicated with an order-sensitive, NaN-blind rule, so a missing-valued duplicate ordered first could silently evict a valid observation. De-duplication now keeps the first **non-missing** value per cell (matching `analyze_sigma_convergence` / `analyze_convergence_clubs`). ## 0.4.7 (2026-06-22) ### Added - **Sigma-convergence analysis** (`analyze_sigma_convergence`). It tracks the cross-sectional **dispersion** of a panel variable over time — the standard deviation, the Gini index and the coefficient of variation — and tests whether that dispersion shrinks by regressing the **log dispersion** on time (a negative trend is σ-convergence). The headline figure is a **dual-axis** time series with the standard deviation on the left axis and the Gini index on the right. The variable is used as supplied and the panel must be balanced; the Gini and coefficient of variation degrade gracefully (to `NaN` with a note) on negative values or a near-zero mean. - **Learn:** a runnable `learn_sigma_convergence` sandbox and an `explain("sigma_convergence")` concept explainer demonstrate σ-convergence on a geometric-narrowing panel whose dispersion shrinks at a known log-rate `ln(rho)`, which the function recovers exactly. A new "Sigma convergence" page in the Analyze app exposes the same analysis on your data. ## 0.4.6 (2026-06-22) ### Added - **Beta-convergence analysis** (`analyze_beta_convergence`). From a single panel variable it runs the standard cross-country β-convergence workflow: the unconditional growth-vs-initial-level scatter (with the country on hover and a regression annotation), the **conditional** version that partials out steady-state controls via the Frisch–Waugh–Lovell theorem, a comparison table reporting the slope, the **speed of convergence** λ and the **half-life**, and a **rolling** fixed-width-window view of how the convergence slope evolves. The variable is used as supplied (pass *log* GDP per capita for the canonical case), so it works for income, schooling, health and any other catch-up question. - **Learn:** a runnable `learn_beta_convergence` sandbox and an `explain("beta_convergence")` concept explainer demonstrate absolute vs. conditional convergence on a known-parameter panel — the unconditional slope is biased by an omitted steady-state determinant; conditioning on it recovers the truth. A new "Beta convergence" tab in the Learn app exposes the same demo. ### Fixed - **Colab notebooks install cleanly on a fresh runtime.** Because expdpy needs `numpy>=2.1` but Google Colab pre-installs an older `numba` that caps `numpy<2.1`, the notebooks' install cell hit a numpy/numba dependency conflict. The cell now co-resolves `numba>=0.61` in the same pip pass, so pip upgrades both to a compatible pair (numpy 2.4.x + numba 0.65.x). Restart the runtime if Colab prompts after the install. ## 0.4.5 (2026-06-21) ### Fixed - **Saturated / Sun–Abraham event study crashed on older NumPy** (e.g. Google Colab's default NumPy 2.0.x): `analyze_event_study(..., estimator="saturated")` raised `TypeError: reshape() got an unexpected keyword argument 'shape'`. The underlying `pyfixest` estimator uses NumPy 2.1's `np.reshape(..., shape=...)`, so expdpy now requires `numpy>=2.1` (was `>=1.24`). In Colab, re-run the install cell and restart the runtime when prompted. ## 0.4.4 (2026-06-21) ### Added - **Human-readable labels on every figure and table.** Variables can now carry a concise `label` (a new column in the data dictionary, `df_def`); declare them once with `set_labels(df, df_def, set_panel=True)` and every Explore/Analyze axis title, legend title and table header reads with the full label (e.g. "Regional inequality (Gini)") instead of the bare column name, falling back to `var_def` and then the name when no label exists. The four bundled datasets (`kuznets`, `gapminder`, `firms`, `staggered_did`) ship curated labels. The returned `.df` / `.df_corr` keep the raw names. See the [data model](explanation/data-model.qmd) page for how to structure the data and dictionary. - **"Run in Google Colab" call-out on every module page.** `explore`, `analyze` and `learn` now show the official Colab badge under the title, opening the matching `notebooks/.ipynb` straight from GitHub — no install required. A Colab badge was also added to the README badge row and to each module card on the docs landing page. ### Fixed - **`explore_trend_plot` now labels its y-axis** (it was blank): a single variable shows that variable's label, several variables show "Value". - **Notebook title timestamp.** The generated Colab notebooks showed the literal `{BUILD_STAMP}` instead of the build time; the title cell now reads `built `, so each notebook states when it was last regenerated. ### Changed - **CI now guarantees the committed notebooks never drift from their `.qmd` sources.** A new docs-workflow job rebuilds the notebooks and fails if they differ (ignoring the per-build timestamp line). ## 0.4.3 (2026-06-21) ### Changed - **Trend range sliders are now a near-invisible sliver.** The shared `blank_rangeslider` helper renders a much thinner strip (`thickness` 0.06 → 0.02) in a near-white `#FAFAFA` fill, so the slider beneath `explore_trend_plot`, `explore_quantile_trend_plot`, and `explore_spaghetti_plot` stays draggable but no longer competes with the chart. ## 0.4.2 (2026-06-21) **API-wide function rename.** Every analysis function is now prefixed with its module, so the name tells you which workflow it belongs to and reads consistently across the library. **This is a breaking change with no backward-compatible aliases** — update call sites to the new names using the map below. ### Changed (breaking) - **`prepare_*` → `explore_*` / `analyze_*`**, and **`sandbox_*` → `learn_*`.** Plotly-figure functions now end in `_plot` and Great-Tables functions in `_table`, and scope qualifiers move to the end of the name (e.g. `prepare_by_group_violin_graph` → `explore_violin_plot_by_group`). - **Utilities keep their current names** and are grouped under a new *Utilities* heading in the API: `set_panel`, `resolve_panel`, `treat_outliers`, `explain`, `list_topics` are unchanged. - Result dataclasses, the `ExploreApp` / `AnalyzeApp` / `LearnApp` classes, and all function parameters are unchanged. #### Rename map **Explore** | Old | New | | --- | --- | | `prepare_descriptive_table` | `explore_descriptive_table` | | `prepare_correlation_table` | `explore_correlation_table` | | `prepare_ext_obs_table` | `explore_ext_obs_table` | | `prepare_xtsum_table` | `explore_xtsum_table` | | `prepare_histogram` | `explore_histogram` | | `prepare_bar_chart` | `explore_bar_plot` | | `prepare_correlation_graph` | `explore_correlation_plot` | | `prepare_trend_graph` | `explore_trend_plot` | | `prepare_quantile_trend_graph` | `explore_quantile_trend_plot` | | `prepare_by_group_bar_graph` | `explore_bar_plot_by_group` | | `prepare_by_group_trend_graph` | `explore_trend_plot_by_group` | | `prepare_by_group_violin_graph` | `explore_violin_plot_by_group` | | `prepare_missing_values_graph` | `explore_missing_values_plot` | | `prepare_scatter_plot` | `explore_scatter_plot` | | `prepare_within_between_scatter` | `explore_scatter_plot_within_between` | | `prepare_spaghetti_graph` | `explore_spaghetti_plot` | | `prepare_value_heatmap` | `explore_value_heatmap` | | `prepare_panel_structure` | `explore_panel_structure` | | `prepare_distribution_over_time` | `explore_distribution_over_time` | | `prepare_transition_matrix` | `explore_transition_matrix` | | `prepare_within_persistence` | `explore_within_persistence` | **Analyze** | Old | New | | --- | --- | | `prepare_regression_table` | `analyze_regression_table` | | `prepare_estimation` | `analyze_estimation` | | `prepare_fwl_plot` | `analyze_fwl_plot` | | `prepare_coefficient_plot` | `analyze_coefficient_plot` | | `prepare_panel_table` | `analyze_panel_table` | | `prepare_cre_table` | `analyze_cre_table` | | `prepare_hausman_test` | `analyze_hausman_test` | | `prepare_fixef_plot` | `analyze_fixef_plot` | | `prepare_predictions` | `analyze_predictions` | | `prepare_joint_test` | `analyze_joint_test` | | `prepare_robust_inference` | `analyze_robust_inference` | | `prepare_event_study` | `analyze_event_study` | | `prepare_panel_view` | `analyze_panel_view` | **Learn** | Old | New | | --- | --- | | `sandbox_omitted_variable_bias` | `learn_omitted_variable_bias` | | `sandbox_pooled_vs_fixed_effects` | `learn_pooled_vs_fixed_effects` | | `sandbox_clustering_se` | `learn_clustering_se` | | `sandbox_first_differences` | `learn_first_differences` | | `sandbox_within_vs_lsdv` | `learn_within_vs_lsdv` | ## 0.4.1 (2026-06-21) A panel-aware **Explore** release: the exploratory functions now make the across-units-vs-over-time structure of panel data explicit, and the identifier vocabulary is standardized. **This includes breaking changes to the Explore API.** ### Added - **Within / between variation** — `explore_xtsum_table` (Stata `xtsum`-style decomposition of each variable into overall / between / within standard deviations) and `explore_scatter_plot_within_between` (the pooled, between and within slopes of an `x`–`y` relationship, side by side). - **Per-unit trajectories** — `explore_spaghetti_plot` draws every unit's path over time with a bold central-tendency overlay, optional highlighting, sampling for large panels and small-multiple faceting. - **Panel-structure diagnostics** — `explore_panel_structure` (balance/gaps summary + unit-by-period presence grid) and `explore_value_heatmap` (a unit-by-time value heatmap, optionally standardized within period or unit). - **Distribution & transition dynamics** — `explore_distribution_over_time` (ridgeline or animated), `explore_transition_matrix` (period-to-period state transitions, binning numeric variables) and `explore_within_persistence` (within-unit serial correlation). - **`set_panel(df, entity=, time=)`** stores the panel identifiers on the frame so Explore functions can omit them; explicit per-call arguments always win. - New explainer topics (`within_between_variation`, `panel_structure`, `transition_matrix`) and `.interpret()` on every new result, plus a new **`firms`** bundled dataset — a small *unbalanced* panel (staggered entry/exit, interior gaps, a discrete size class, persistent revenue) for the structure / transition / persistence views. - A **Panel structure** page in the Explore app surfacing all of the above. ### Changed (breaking) - **Identifiers standardized to `entity` (unit) and `time`.** The Explore functions that took `ts_id` now take `time` (keyword-only): `explore_trend_plot`, `explore_quantile_trend_plot`, `explore_trend_plot_by_group`, `explore_missing_values_plot`. `explore_ext_obs_table`'s `cs_id` / `ts_id` become `entity` / `time`. `explore_trend_plot_by_group` now takes `group_var`, `var` positionally with `time` keyword-only. - **Every Explore function returns a wrapped result.** `explore_scatter_plot`, `explore_missing_values_plot` and `explore_violin_plot_by_group` previously returned a bare Plotly figure; they now return a result object — access the figure via `.fig`. - **The app launchers, dataset metadata and bundle format use `entity`/`time` too.** The `ExploreApp` / `AnalyzeApp` / `LearnApp` keyword arguments `cs_id` / `ts_id` are renamed to `entity` / `time` (hard rename — the old names are no longer accepted). The bundled `df_def` `type` values `cs_id` / `ts_id` (returned by `load_*_data_def()`) become `entity` / `time`, and the launch bundle manifest keys change to match. So the whole package now speaks one panel vocabulary. ### Fixed - `explore_trend_plot` no longer drops a row from *all* series when *any* one variable is missing (it now drops missing values per series), removing a multi-variable mean bias. - `explore_missing_values_plot` orders periods by their true (numeric/date) value rather than lexically; `explore_trend_plot_by_group` orders group levels numerically when possible. ### Visual - A refreshed Plotly theme (modern font stack with Arial fallback for stable static exports, softer gridlines, theme-aware hover cards, unified heatmap colorbars) and richer hover detail, range sliders and toggles across the figures. ## 0.4.0 (2026-06-20) A reorganization release. expdpy is now structured around three conceptual modules — **Explore**, **Analyze** and **Learn** — across the public API, the documentation site, and the apps. The estimator surface is focused on linear panel methods. **This is a breaking change.** ### Removed (breaking) - **IV / 2SLS, Poisson, and logit / probit estimators are gone.** `analyze_estimation` no longer accepts `model="iv"` / `"poisson"` / `"logit"` / `"probit"` (nor the `endog` / `instruments` arguments); it now covers **OLS** with stepwise / multiple-outcome comparison, serial-correlation-robust standard errors (Newey–West, Driscoll–Kraay) and weights. Migrate IV / GLM code to OLS-based or other panel methods. - The `iv` and `glm` concept-explainer topics were removed from `explain()` / `list_topics()`. - **The single combined `ExPdPy` app and the `expdpy-streamlit` console script are gone**, replaced by three module apps (see below). ### Added - `analyze_cre_table` — a **correlated-random-effects (Mundlak)** estimator that brings FE-consistent within estimates into a random-effects frame, alongside `analyze_panel_table` and `analyze_hausman_test`. A joint test on its unit-mean terms is the regression-form Hausman test. - Two **Learn** sandboxes — `learn_first_differences` and `learn_within_vs_lsdv` (first differences ≈ demeaning ≈ least-squares dummy variables) — plus matching explainer topics (`first_differences`, `within_transformation` / `demeaning`, `dummy_variables` / `lsdv`, `correlated_random_effects` / `mundlak`). - **Three no-code apps** — `ExploreApp`, `AnalyzeApp`, `LearnApp` (console scripts `expdpy-explore`, `expdpy-analyze`, `expdpy-learn`; deploy scripts `app_explore.py`, `app_analyze.py`, `app_learn.py`). They share the common shell and differ only in which pages they expose; the Learn app adds an in-app concept-explainer browser. ### Changed - **`linearmodels` is now a required dependency** (previously the optional `panel` extra). Pooled / between / fixed / random effects, CRE and the Hausman test work out of the box; `pip install "expdpy[panel]"` is kept as a no-op alias for compatibility. - **Reorganized into three modules.** The public `__all__`, the README, the documentation reference, and the app navigation are grouped under **Explore** / **Analyze** / **Learn**. The docs site now has one page per module (folding in the old Quickstart and Examples gallery), and the home page leads with three module cards. No import paths change for retained functions. ## 0.3.0 (2026-06-19) ### Removed - **The Shiny app has been removed.** Streamlit is now the sole interactive `ExPdPy` app. The `expdpy[app]` install extra and the `expdpy.app` module are gone; launch the app with `from expdpy.streamlit_app import ExPdPy` (or `pip install "expdpy[streamlit]"`). The framework-agnostic app core (sample pipeline, the safe `var_def` evaluator, config save/load, component renderers, notebook export) now lives under `expdpy.streamlit_app`. ## 0.2.0 (2026-06-19) A major feature release: modern **fixest / pyfixest** econometrics, a **panel-data** toolkit, and a **pedagogy** layer for teaching — all surfaced through the interactive apps. Requires `pyfixest>=0.60`; adds an optional `panel` extra (`linearmodels`). ### New estimators - `analyze_estimation` — a unified estimator adding **IV / 2SLS**, **Poisson** (`fepois`) and **logit / probit** (`feglm`) to OLS; a wider choice of standard errors (heteroskedastic HC1–HC3, cluster-robust CRV1 / CRV3, and the serial-correlation-robust Newey–West and Driscoll–Kraay); and **stepwise / multiple-outcome model comparison** (`sw()` / `csw()`, multiple left-hand sides) for watching estimates move as the specification changes. - Post-estimation helpers: `analyze_fixef_plot` (visualize the estimated fixed effects), `analyze_predictions`, and `analyze_joint_test` (a Wald joint-significance test). - `analyze_robust_inference` — randomization inference (and the wild cluster bootstrap when the optional `wildboottest` package is installed), for credible inference with few clusters. ### Event study & staggered difference-in-differences - `analyze_event_study` wrapping the modern estimators — Gardner's two-stage `did2s`, Sun–Abraham (`saturated`), local-projections DiD (`lpdid`) and dynamic two-way fixed effects (`twfe`) — returning a themed Plotly event-study plot with a pre-trend diagnostic. - `analyze_panel_view` — a Plotly reimplementation of `panelview` that derives the binary treatment indicator from a first-treatment **cohort** column for you. - A bundled `load_staggered_did()` teaching dataset. ### Panel models (optional `panel` extra, via `linearmodels`) - `analyze_panel_table` — pooled OLS, between, fixed-effects and random-effects estimates side by side (the fixed-effects estimate matches the pyfixest path). - `analyze_hausman_test` — the classic fixed-vs-random-effects specification test. ### Visualization - `analyze_coefficient_plot` — coefficient plots with confidence intervals for one or several models (a beginner-friendly alternative to reading a coefficient table). - A dark Plotly theme variant (`apply_default_layout(fig, dark=True)`). ### Pedagogy - A concept-explainer registry — `explain(topic)` and `list_topics()` — covering fixed effects, clustering, IV, GLMs, event studies, parallel trends, omitted-variable bias, winsorizing vs truncating, Pearson vs Spearman, and more. - Result objects gain `.interpret()` (plain-language, strictly *associational* — never causal unless an FE/IV design is present), `.explain()`, and broom-style `.tidy()` / `.glance()`. - Concept sandboxes that simulate data so a concept can be seen and tuned: `learn_omitted_variable_bias`, `learn_pooled_vs_fixed_effects`, `learn_clustering_se`. ### Interactive apps - Both the Streamlit and Shiny apps now show a plain-language **interpretation** and a **method explainer** on the regression card, plus a **coefficient plot**. - New pages / cards in both apps: **Event study & DiD** and **Panel models** (shown for panel data); the Streamlit app also gains a data-free **Concept sandboxes** page. The **Staggered DiD** dataset is available in the picker. ## 0.1.0 (unreleased) Initial release — a Python port of ExPanDaR. - Analytical functions returning Plotly figures and Great Tables / pyfixest output: `explore_descriptive_table`, `explore_correlation_table`, `explore_correlation_plot`, `explore_ext_obs_table`, `explore_trend_plot`, `explore_quantile_trend_plot`, `explore_bar_plot_by_group`, `explore_trend_plot_by_group`, `explore_violin_plot_by_group`, `explore_histogram`, `explore_bar_plot`, `explore_missing_values_plot`, `explore_scatter_plot`, `analyze_regression_table`, and `treat_outliers`. - The interactive `ExPdPy` app (Shiny for Python) with in-app upload, save/load of configurations, and reproducible notebook/script export. - A second interactive `ExPdPy` app (Streamlit): the same no-code exploration UI in a multipage layout with native tables, the subset / outlier / user-defined-variable pipeline, config save/load (interchangeable with the Shiny app's configs) and reproducible notebook export — runnable locally and on Streamlit Community Cloud (`streamlit run streamlit_app.py`). - Bundled datasets: `kuznets` (a synthetic panel whose regional inequality traces an N-shaped Kuznets curve — the default showcase across the docs and the app picker) and `gapminder` (plus their definition tables and configurations).