statspai.decomposition¶
decomposition ¶
Decomposition Analysis module for StatsPAI.
Decomposition toolkit covering mean, distributional, inequality,
demographic, and causal decomposition methods under a
unified API: sp.decompose(method=...).
Methods (19 in total — Yu-Elwert added in v1.15)
Mean decomposition
- oaxaca — Blinder-Oaxaca (Blinder 1973; Oaxaca 1973) with 5
reference-coefficient choices (A, B, pooled/Neumark, Cotton, Reimers)
- gelbach — Gelbach (2016) sequential orthogonal decomposition of
omitted-variable bias
- fairlie — Fairlie (2005) nonlinear decomposition for logit/probit
- bauer_sinning / yun_nonlinear — Bauer-Sinning (2008) + Yun
(2005) detailed nonlinear decomposition
Distributional decomposition
- rif — Recentered Influence Function regression + OB decomposition
(Firpo-Fortin-Lemieux 2009)
- ffl — Firpo-Fortin-Lemieux (2018) two-step detailed decomposition
- dfl — DiNardo-Fortin-Lemieux (1996) reweighting
- machado_mata — Machado-Mata (2005) quantile decomposition
- melly — Melly (2005) analytical quantile decomposition
- cfm — Chernozhukov-Fernández-Val-Melly (2013) counterfactual
distributions via distribution regression
Inequality decomposition
- subgroup — between/within decomposition (Theil T/L, GE, Gini,
Atkinson, CV²)
- shapley_inequality — Shorrocks-Shapley (2013) allocation of
inequality to covariates
- gini_source — Lerman-Yitzhaki (1985) Gini source decomposition
Demographic / standardisation
- kitagawa — Kitagawa (1955) two-factor rate decomposition
- das_gupta — Das Gupta (1993) multi-factor decomposition
Causal decomposition
- gap_closing — Lundberg (2022) gap-closing estimator
(regression / IPW / AIPW)
- mediation — VanderWeele (2014) natural direct/indirect effects
- disparity / causal_jvw — Jackson-VanderWeele (2018) causal
disparity decomposition
- yu_elwert — Yu & Elwert (2025) nonparametric causal decomposition
of group disparities into baseline, prevalence, effect, and selection
components (efficient-influence-function-based; ML-friendly)
Unified Entry
sp.decompose(method=..., **kwargs) dispatches to any of the above.
Polish (v1.15)
Every result class now inherits DecompResultMixin, exposing a
common .confint(), .cite(), .to_dict(), .to_json(),
.to_excel(), and .to_word() surface in addition to each
method's bespoke .summary() / .plot() / .to_latex().
Plots share a common palette and minimalist style via
:mod:statspai.decomposition.plots (forest plots, mediation forest,
Yu-Elwert mechanism plot, RIF heatmap, …).
CFMResult
dataclass
¶
Bases: DecompResultMixin
Chernozhukov-Fernández-Val-Melly counterfactual distribution result.
DFLResult
dataclass
¶
Bases: DecompResultMixin
Container for DFL reweighting decomposition results.
FFLResult
dataclass
¶
Bases: DecompResultMixin
Container for Firpo-Fortin-Lemieux two-step decomposition.
MachadoMataResult
dataclass
¶
Bases: DecompResultMixin
Container for Machado-Mata decomposition.
MellyResult
dataclass
¶
Bases: DecompResultMixin
Container for Melly quantile decomposition.
GelbachResult ¶
Bases: DecompResultMixin
Result container for Gelbach (2016) decomposition.
Attributes:
| Name | Type | Description |
|---|---|---|
total_change |
float
|
Total change in the base coefficient when added controls are included: beta_base - beta_full. |
decomposition |
DataFrame
|
Per-variable contributions with columns |
base_coef |
float
|
Coefficient of interest from the base (short) regression. |
full_coef |
float
|
Coefficient of interest from the full (long) regression. |
base_var |
str
|
Name of the variable of interest. |
Examples:
>>> import statspai as sp
>>> res = sp.gelbach(
... data=sp.cps_wage(), y="log_wage",
... base_x=["education"],
... added_x=["experience", "tenure", "union"])
>>> isinstance(res, sp.GelbachResult)
True
>>> res.base_var
'education'
>>> bool(abs((res.base_coef - res.full_coef) - res.total_change) < 1e-6)
True
>>> print(res.summary())
OaxacaResult ¶
Bases: DecompResultMixin
Result container for Oaxaca-Blinder decomposition.
Attributes:
| Name | Type | Description |
|---|---|---|
overall |
dict
|
Keys: |
detailed |
DataFrame
|
Variable-level decomposition with columns |
group_stats |
dict
|
Per-group means, coefficients, standard errors, sample sizes. |
reference |
str or int
|
Reference weight specification used. |
Examples:
>>> import statspai as sp
>>> res = sp.oaxaca(
... data=sp.cps_wage(), y="log_wage", group="female",
... x=["education", "experience", "tenure"], reference=0)
>>> isinstance(res, sp.OaxacaResult)
True
>>> bool({"gap", "explained", "unexplained"}.issubset(res.overall))
True
>>> "contribution" in res.detailed.columns
True
>>> print(res.summary())
plot ¶
Bar / forest chart of per-variable explained contributions.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
('waterfall', 'forest')
|
|
"waterfall"
|
YuElwertResult
dataclass
¶
Bases: DecompResultMixin
Yu-Elwert (2025) causal decomposition of a group disparity.
Attributes:
| Name | Type | Description |
|---|---|---|
disparity |
float
|
Observed gap |
baseline |
float
|
Counterfactual disparity if no one were treated. |
prevalence |
float
|
Contribution of differential treatment uptake (group A vs. B), scaled by the reference group's average treatment effect. |
effect |
float
|
Contribution of group heterogeneity in average treatment effects, scaled by the advantaged group's treatment prevalence. |
selection |
float
|
Group-specific covariance between treatment assignment and individual-level effect heterogeneity — the signature mechanism of Yu-Elwert. |
se |
dict[str, float] | None
|
Standard errors keyed by component ( |
ci |
dict[str, (float, float)] | None
|
Two-sided 95% confidence intervals (matching |
detailed |
DataFrame
|
Tidy table of (component, value, se, ci_low, ci_high). |
nuisance |
dict
|
Diagnostic snapshot — group sizes, fitted per-cell means,
plus |
method |
str
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> r = rng.integers(0, 2, size=n)
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> p = 1 / (1 + np.exp(-(0.5 * r + 0.3 * x1)))
>>> t = (rng.uniform(size=n) < p).astype(int)
>>> y = (1.0 + 0.5 * x1 + 0.3 * x2 + (0.8 + 0.4 * r) * t
... + rng.normal(size=n))
>>> df = pd.DataFrame({"y": y, "t": t, "r": r, "x1": x1, "x2": x2})
>>> res = sp.yu_elwert_decompose(
... df, y="y", treatment="t", group="r", x=["x1", "x2"],
... inference="none",
... )
>>> type(res).__name__
'YuElwertResult'
>>> res.method
'plugin'
disparity_decompose ¶
disparity_decompose(data: DataFrame, y: str, group: str, mediator: str, covariates: Optional[Sequence[str]] = None, target_level: Optional[float] = None) -> DisparityDecompResult
Jackson & VanderWeele (2018) causal disparity decomposition.
Decomposes an observed group disparity in Y into: - initial disparity: what would remain if mediator M were set to a reference level (e.g. Group A's M distribution). - mediator-attributable: the complementary share.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str — outcome
|
|
required |
group
|
str — binary group (0/1, where 1 = disadvantaged)
|
|
required |
mediator
|
str — mediator
|
|
required |
covariates
|
list or None
|
|
None
|
target_level
|
float or None
|
Value at which to fix mediator for the "initial" counterfactual. Default: mean of M in reference group (group=0). |
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 600
>>> g = rng.integers(0, 2, n) # 1 = disadvantaged
>>> m = rng.normal(2.0 - 0.8 * g, 1.0) # mediator (e.g. education)
>>> y = 50.0 + 5.0 * m + 3.0 * g + rng.normal(0, 2.0, n)
>>> df = pd.DataFrame({'y': y, 'group': g, 'med': m})
>>> res = sp.disparity_decompose(df, y='y', group='group', mediator='med')
>>> # Identity: total = initial + mediator-attributable
>>> round(res.total_disparity - (res.initial_disparity
... + res.mediator_attributable), 8)
0.0
References
jackson2018decomposition
gap_closing ¶
gap_closing(data: DataFrame, y: str, group: str, x: Sequence[str], method: str = 'aipw', target_dist: int = 1, trim: float = 0.001, inference: str = 'analytical', n_boot: int = 299, alpha: float = 0.05, seed: Optional[int] = 12345) -> GapClosingResult
Counterfactual gap after equalising covariate distributions.
Computes the mean gap that would remain if one group's covariate
distribution were shifted to match the other's, in the spirit of
Lundberg's gap-closing estimand with the covariates as the intervened
variables. method="ipw" is DiNardo-Fortin-Lemieux reweighting and
matches ddecompose::dfl_decompose; "regression" is the
Oaxaca-Blinder counterfactual and matches ddecompose::ob_decompose;
"aipw" combines the two and is consistent if either model is right.
For interventions on a treatment variable, see Lundberg's gapclosing
package.
.. versionchanged:: 1.28.0
method="ipw" and the reweighting term of "aipw" used the
reciprocal of the density ratio, reweighting each group away from
the target distribution: with Y = 2X and no group effect the IPW
counterfactual gap was twice the observed gap instead of zero, and
"aipw" was consistent only when its outcome model was right.
"regression" is unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
column names
|
|
required |
group
|
column names
|
|
required |
x
|
column names
|
|
required |
method
|
('regression', 'ipw', 'aipw')
|
AIPW is doubly robust (recommended). |
'regression'
|
target_dist
|
(0, 1)
|
|
0
|
trim
|
float — propensity trim
|
|
0.001
|
inference
|
('analytical', 'bootstrap', 'none')
|
|
'analytical'
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 600
>>> g = rng.integers(0, 2, n)
>>> x1 = rng.normal(g * 0.6, 1.0) # covariate differs by group
>>> x2 = rng.normal(0, 1.0, n)
>>> y = 1.0 + 0.8 * x1 + 0.5 * x2 + 0.4 * g + rng.normal(0, 1.0, n)
>>> df = pd.DataFrame({'y': y, 'group': g, 'x1': x1, 'x2': x2})
>>> res = sp.gap_closing(df, y='y', group='group', x=['x1', 'x2'],
... method='aipw', inference='none')
>>> # Identity: closed gap = observed gap - counterfactual gap
>>> round(res.closed_gap - (res.observed_gap
... - res.counterfactual_gap), 10)
0.0
References
lundberg2024gap
mediation_decompose ¶
mediation_decompose(data: DataFrame, y: str, treatment: str, mediator: str, covariates: Optional[Sequence[str]] = None, inference: str = 'analytical', n_boot: int = 299, alpha: float = 0.05, seed: Optional[int] = 12345) -> MediationDecompResult
Linear nested-models mediation decomposition (VanderWeele 2014 four-way simplified to natural direct / indirect under linearity).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str — continuous outcome
|
|
required |
treatment
|
str — binary exposure
|
|
required |
mediator
|
str — mediator
|
|
required |
covariates
|
list of str or None
|
|
None
|
inference
|
('analytical', 'bootstrap', 'none')
|
|
'analytical'
|
Returns:
| Type | Description |
|---|---|
MediationDecompResult with NDE, NIE, CDE, proportion mediated.
|
|
Notes
Mediator model M ~ A + C, outcome model Y ~ A + M + A*M + C.
With a* = 0, a = 1 and covariates held at their sample means
c̄ (Valeri & VanderWeele's conditional effects at the mean, as in
paramed and CMAverse):
nde(pure NDE)= θ1 + θ3 (β0 + β_c'c̄)nie(total NIE)= β1 (θ2 + θ3)cde= θ1 + θ3 m*atm* = E[M | A = 0](the sample mean)
Up to 1.28.0 nde used E[M | A = 0] = β0 + β_c'c̄_{A=0}, the
covariate mean of the unexposed, so with confounded exposure it was
the direct effect at a covariate profile nobody asked for (and it
disagreed with sp.four_way_decomposition on the same data).
Without covariates the two coincide, and cde == nde.
Examples:
Simulate A -> M -> Y with a direct A -> Y path (true
NDE = 0.5, NIE = 0.8):
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(3)
>>> n = 500
>>> a = rng.binomial(1, 0.5, size=n) # binary exposure
>>> m = 0.8 * a + rng.normal(size=n) # A -> M
>>> y = 0.5 * a + 1.0 * m + rng.normal(size=n) # direct + mediated
>>> df = pd.DataFrame({'y': y, 'a': a, 'm': m})
>>> result = sp.mediation_decompose(df, y='y', treatment='a',
... mediator='m')
>>> round(result.nde, 2), round(result.nie, 2)
(0.39, 0.78)
>>> round(result.propn_mediated, 2) # share of total effect via M
0.66
Bootstrap CIs for the decomposition components:
cfm_decompose ¶
cfm_decompose(data: DataFrame, y: str, group: str, x: Sequence[str], tau_grid: Optional[Sequence[float]] = None, reference: int = 0, n_thresh: int = 40, ks_test: bool = True, thresholds: Optional[Sequence[float]] = None, inversion: str = 'interpolate') -> CFMResult
Chernozhukov-Fernández-Val-Melly (2013) counterfactual decomposition.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
column names
|
|
required |
group
|
column names
|
|
required |
x
|
column names
|
|
required |
tau_grid
|
Sequence[float] or None
|
|
None
|
reference
|
(0, 1)
|
Same convention as |
0
|
n_thresh
|
int — number of thresholds for distribution regression
|
(ignored when |
40
|
ks_test
|
bool — whether to compute Kolmogorov-Smirnov gap test
|
|
True
|
thresholds
|
sequence of float
|
Explicit distribution-regression thresholds, shared by both groups.
Default: |
None
|
inversion
|
('interpolate', 'step')
|
How the estimated CDF, known only at the thresholds, is inverted.
|
'interpolate'
|
Returns:
| Type | Description |
|---|---|
CFMResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.decomposition.datasets.cps_wage()
>>> r = sp.cfm_decompose(df, y="log_wage", group="female",
... x=["education", "experience"],
... tau_grid=[0.25, 0.5, 0.75], n_thresh=15)
>>> type(r).__name__
'CFMResult'
>>> sorted(r.overall)
['mean_composition', 'mean_gap', 'mean_structure']
chilean_households ¶
Chilean-style household income with urban/rural gap.
Simulated from a fixed-seed design; not survey microdata.
df.attrs['simulated'] is True.
Columns: - rural : int {0, 1} - head_education : years - head_age : years - household_size : int - log_income : float
Examples:
cps_wage ¶
CPS-style wage data with a gender gap.
Simulated from a fixed-seed Mincer equation; not Current Population
Survey microdata. df.attrs['simulated'] is True.
Columns: - female : int {0, 1} - education : years - experience : years - tenure : years - union : int {0, 1} - married : int {0, 1} - log_wage : float
Examples:
disparity_panel ¶
Synthetic disparity panel with treatment, mediator, outcome.
Columns: - group : int {0, 1} (disadvantaged = 1) - education : years (mediator) - parent_income : float (confounder) - age : years (confounder) - income : float (outcome)
Examples:
mincer_wage_panel ¶
Two-period Mincer wage distribution with a structural shift.
Simulated from a fixed-seed design. df.attrs['simulated'] is
True.
Useful for DFL / FFL examples where Group 0 is early period and Group 1 is late period.
Columns: - period : int {0, 1} - education : years - experience : years - union : int - occupation_high_skill : int - log_wage : float
Examples:
dfl_decompose ¶
dfl_decompose(data: DataFrame, y: str, group: str, x: Sequence[str], stat: str = 'mean', tau: float = 0.5, reference: int = 0, weights: Optional[Union[str, ndarray]] = None, trim: float = 0.001, inference: str = 'analytical', n_boot: int = 299, alpha: float = 0.05, quantile_grid: Optional[Sequence[float]] = None, seed: Optional[int] = 12345, stat_convention: str = 'statspai') -> DFLResult
DFL (1996) reweighting decomposition at a chosen distributional statistic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str — outcome variable name
|
|
required |
group
|
str — binary (0/1) group indicator
|
|
required |
x
|
Sequence[str] — covariates used for propensity model
|
|
required |
stat
|
('mean', 'variance', 'std', 'quantile', 'iqr', 'gini', 'log_var')
|
|
'mean'
|
tau
|
float — quantile level (when stat='quantile')
|
|
0.5
|
reference
|
(0, 1)
|
.. warning::
|
0
|
weights
|
str, array or None — sample weights
|
|
None
|
trim
|
float — clip propensity scores to [trim, 1-trim]
|
|
0.001
|
inference
|
('none', 'bootstrap', 'analytical')
|
|
'none'
|
n_boot
|
int — bootstrap replications
|
|
299
|
alpha
|
float — CI level
|
|
0.05
|
quantile_grid
|
sequence of τ ∈ (0, 1) or None
|
If provided, also compute quantile-process decomposition on this grid. |
None
|
seed
|
int or None
|
|
12345
|
stat_convention
|
('statspai', 'hmisc')
|
Weighted variance / quantile definition for the reweighted
counterfactual (see |
'statspai'
|
Returns:
| Type | Description |
|---|---|
DFLResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> r = sp.dfl_decompose(df, y='log_wage', group='female',
... x=['education', 'experience', 'tenure'],
... stat='quantile', tau=0.5)
>>> r.summary()
>>> r.gap, r.composition, r.structure
>>> # Variance decomposition with bootstrap inference
>>> r = sp.dfl_decompose(df, y='log_wage', group='female',
... x=['education', 'experience', 'tenure'],
... stat='variance', inference='bootstrap',
... n_boot=49, seed=12345)
>>> r.se['gap']
See also :func:sp.decompose — the unified dispatcher — via
sp.decompose('dfl', data=df, ...).
available_methods ¶
decompose ¶
Unified entry point for all decomposition methods.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
method
|
str
|
One of the methods listed in |
required |
**kwargs
|
method-specific keyword arguments (see individual
|
function signatures for details). |
{}
|
Returns:
| Type | Description |
|---|---|
method-specific result class with ``.summary()``, ``.plot()``,
|
|
``.to_latex()``, ``._repr_html_()``.
|
|
Examples:
>>> import statspai as sp
>>> df = sp.decomposition.datasets.cps_wage()
>>> r = sp.decompose('oaxaca', data=df, y='log_wage', group='female',
... x=['education', 'experience', 'tenure'])
>>> r.summary()
>>> r = sp.decompose('ffl', data=df, y='log_wage', group='female',
... x=['education', 'experience', 'tenure'],
... stat='quantile', tau=0.5)
>>> r.summary()
>>> # NOTE: ``method='aipw'`` below is passed through to
>>> # ``gap_closing``'s own ``method`` parameter; the dispatcher's
>>> # own method arg is positional-only so there is no collision.
>>> r = sp.decompose('gap_closing', data=df, y='log_wage',
... group='female',
... x=['education', 'experience', 'tenure'],
... method='aipw')
Convention warning for dfl vs machado_mata / melly /
cfm: reference=0 has different semantics across method
families (reweighting vs coefficient-swap). See the per-method
docstrings before comparing composition/structure estimates across
methods.
diversity_index ¶
diversity_index(data: Union[DataFrame, ndarray], species: Optional[str] = None, count: Optional[str] = None, by: Optional[Union[str, Sequence[str]]] = None, index: Union[str, Sequence[str]] = 'shannon', q: float = 1.0, base: Optional[float] = None, min_records: int = 0) -> Union[float, Series, DataFrame]
Species-diversity indices from record-level or count data.
Computes Shannon, richness, Pielou evenness, the Simpson family and Hill numbers, grouped onto a panel index.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame or ndarray
|
Either long-format records (one row per sighting, or one row per
species-site with a |
required |
species
|
str
|
Species-identifier column. Required for long-format input. |
None
|
count
|
str
|
Abundance column. Omit when each row is one individual/record, in which case rows are counted. |
None
|
by
|
str or list of str
|
Grouping keys — typically the panel index, e.g.
|
None
|
index
|
str or list of str
|
Any of
|
``"shannon"``
|
q
|
float
|
Order of the Hill number. Ignored by the other indices. |
1.0
|
base
|
float
|
Logarithm base for Shannon. |
None
|
min_records
|
int
|
Groups with fewer than this many records return |
0
|
Returns:
| Type | Description |
|---|---|
float, Series, or DataFrame
|
A scalar for one index and no grouping; a Series indexed by group
for one index with |
Examples:
>>> import pandas as pd
>>> import statspai as sp
>>> records = pd.DataFrame({
... "county": ["A", "A", "A", "A", "B", "B", "B", "B"],
... "species": ["s1", "s1", "s2", "s3", "s1", "s1", "s1", "s2"],
... })
>>> out = sp.diversity_index(
... records, species="species", by="county", index=["shannon", "richness"]
... )
>>> bool(out.loc["A", "shannon"] > out.loc["B", "shannon"])
True
>>> int(out.loc["A", "richness"])
3
Notes
Every index here is a sample quantity: it describes the individuals actually recorded, not the community they were drawn from. With citizen-science data, observation effort therefore enters the outcome directly, which is why effort controls (records per observer-hour, say) belong in the regression even after the index is computed.
References
[@shannon1948mathematical], [@simpson1949measurement], [@pielou1966measurement], [@hill1973diversity]
ffl_decompose ¶
ffl_decompose(data: DataFrame, y: str, group: str, x: Sequence[str], stat: str = 'quantile', tau: float = 0.5, reference: int = 0, weights: Optional[Union[str, ndarray]] = None, trim: float = 0.001, inference: str = 'analytical', n_boot: int = 299, alpha: float = 0.05, seed: Optional[int] = 12345, quantile_convention: str = 'statspai') -> FFLResult
Firpo-Fortin-Lemieux two-step detailed distributional decomposition.
The gap nu_A - nu_B (RIF means, i.e. the plug-in statistics) splits
exactly into the pure composition effect, the specification error
X_C (b_C - b_ref) (non-zero when the RIF is not linear in X), the
pure wage-structure effect, and the reweighting error
(X_target - X_C) b_C (zero when the reweighting is consistent), as
in Firpo, Fortin & Lemieux (2018) and ddecompose::ob_decompose(
reweighting = TRUE), which this matches.
.. versionchanged:: 1.28.0
Three corrections, found against ddecompose: spec_error and
reweight_error held each other's values; with reference=1
the specification-error term had the wrong sign, so the components
did not add up to the gap (0.1184 against 0.1216 for the variance
on cps_wage); and gap was the difference of
n / (n - 1)-corrected statistics while the components add up to
the difference of RIF means, so the identity held only up to that
correction. gap is now the RIF-mean difference (the plug-in
statistics; stat_a / stat_b are unchanged).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
|
required |
group
|
str — binary {0, 1}
|
|
required |
x
|
Sequence[str]
|
|
required |
stat
|
{'quantile', 'mean', 'variance', 'std', 'iqr', 'gini',
|
|
'quantile'
|
tau
|
float (for quantile)
|
|
0.5
|
reference
|
int {0, 1}
|
0: B reweighted to look like A's X (composition = effect of A's X on B's outcomes relative to observed B) |
0
|
weights
|
(str, array or None)
|
|
None
|
trim
|
float — propensity trim
|
|
0.001
|
inference
|
('analytical', 'bootstrap', 'none')
|
|
'analytical'
|
n_boot
|
int
|
|
299
|
alpha
|
float
|
|
0.05
|
seed
|
int or None
|
|
12345
|
quantile_convention
|
('statspai', 'dineq', 'rifreg')
|
Quantile RIF convention ( |
'statspai'
|
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> r = sp.ffl_decompose(df, y='log_wage', group='female',
... x=['education', 'experience', 'tenure'],
... stat='quantile', tau=0.5)
>>> import contextlib, io
>>> with contextlib.redirect_stdout(io.StringIO()):
... text = r.summary()
>>> "Firpo-Fortin-Lemieux Two-Step Decomposition" in text
True
>>> bool(abs(r.gap - (r.composition + r.structure
... + r.spec_error + r.reweight_error)) < 1e-6)
True
References
[@firpo2018decomposing]
inequality_index ¶
inequality_index(y: ndarray, index: str = 'theil_t', weights: Optional[ndarray] = None, eps: float = 1.0, alpha: Optional[float] = None) -> float
Compute a single inequality index.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y
|
np.ndarray — outcome (e.g. income)
|
|
required |
index
|
str — one of theil_t, theil_l, mld, ge0, ge1, ge2,
|
atkinson, gini, cv2 |
'theil_t'
|
weights
|
ndarray or None
|
|
None
|
eps
|
float — Atkinson inequality-aversion parameter
|
|
1.0
|
alpha
|
float or None — GE parameter override
|
|
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> income = np.random.default_rng(0).lognormal(10.0, 0.5, size=500)
>>> g = sp.inequality_index(income, index="gini")
>>> bool(0 < g < 1)
True
>>> bool(sp.inequality_index(income, index="theil_t") > 0)
True
References
[@shorrocks1984inequality]
shapley_inequality ¶
shapley_inequality(data: DataFrame, y: str, x: Sequence[str], index: str = 'theil_t', weights: Optional[Union[str, ndarray]] = None) -> ShapleyInequalityResult
Shorrocks-Shapley decomposition of an inequality index across covariates.
For each subset S ⊆ covariates, compute the predicted outcome ŷ_S = X_S · β_S (OLS) and evaluate index I(ŷ_S). The marginal contribution of variable j to I is averaged over all orderings yielding its Shapley value φ_j.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
as usual
|
|
required |
y
|
as usual
|
|
required |
x
|
as usual
|
|
required |
index
|
str
|
|
'theil_t'
|
weights
|
(str, array or None)
|
|
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> df = sp.cps_wage(n=600, seed=2)
>>> df["wage"] = np.exp(df["log_wage"])
>>> res = sp.shapley_inequality(
... df, y="wage", x=["education", "experience"], index="theil_t")
>>> bool(res.total > 0)
True
>>> len(res.shapley)
2
Notes
Combinatorial cost: O(2^|x|). For |x| ≤ 10 this is fine; for larger x the function warns and uses a random permutation sampler.
References
[@shorrocks2013decomposition]
source_decompose ¶
source_decompose(data: DataFrame, sources: Sequence[str], weights: Optional[Union[str, ndarray]] = None, gini: str = 'corrected') -> SourceDecompResult
Lerman-Yitzhaki (1985) Gini source decomposition.
Total income = Σ sources. Each source's contribution is S_k · R_k · G_k / G_total where S_k is its share of total mean, R_k the Gini correlation with total rank, G_k its own Gini.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
sources
|
sequence of str
|
Income-source columns; total income is their row sum. |
required |
weights
|
(str, array or None)
|
|
None
|
gini
|
('corrected', 'population')
|
|
"corrected"
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> df = pd.DataFrame({
... "labor": rng.lognormal(9.5, 0.4, 400),
... "capital": rng.lognormal(8.0, 0.7, 400),
... })
>>> res = sp.source_decompose(df, sources=["labor", "capital"])
>>> bool(0 < res.total_gini < 1)
True
>>> len(res.sources)
2
References
[@lerman1985income]
subgroup_decompose ¶
subgroup_decompose(data: DataFrame, y: str, by: str, index: str = 'theil_t', weights: Optional[Union[str, ndarray]] = None, eps: float = 1.0, alpha: Optional[float] = None) -> SubgroupDecompResult
Subgroup decomposition (between / within) of an inequality index.
Supported for additive GE family (theil_t, theil_l, mld, ge0, ge1, ge2, cv2, atkinson(ε=1)). Gini returns Dagum (1997) Gini_B / Gini_W / Gini_overlap.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str — outcome
|
|
required |
by
|
str — grouping variable
|
|
required |
index
|
str — inequality index name
|
|
'theil_t'
|
weights
|
(str, array or None)
|
|
None
|
eps
|
float — Atkinson parameter
|
|
1.0
|
alpha
|
float or None — GE parameter override
|
|
None
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> df = sp.cps_wage(n=800, seed=1)
>>> df["wage"] = np.exp(df["log_wage"])
>>> res = sp.subgroup_decompose(df, y="wage", by="female", index="theil_t")
>>> bool(res.total > 0)
True
>>> bool(abs(res.between + res.within - res.total) < 1e-6)
True
References
[@shorrocks1984inequality], [@dagum1997approach]
das_gupta ¶
das_gupta(data_a: DataFrame, data_b: DataFrame, factor_names: Sequence[str], by: Optional[Union[str, Sequence[str]]] = None) -> DasGuptaResult
Das Gupta (1993) multi-factor decomposition.
Decomposes the difference in a product-form aggregate into each factor's contribution using symmetric averaging across all possible orderings.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data_a
|
pd.DataFrame with the same factor columns.
|
One row per stratum (e.g. age group). The aggregate for each
population is |
required |
data_b
|
pd.DataFrame with the same factor columns.
|
One row per stratum (e.g. age group). The aggregate for each
population is |
required |
factor_names
|
list of factor column names.
|
|
required |
by
|
str or list of str
|
Column(s) identifying the stratum of each row, used to pair the
rows of |
None
|
Notes
Assumes the aggregate is a sum over strata of products of factors,
R = sum_i f_{1,i} f_{2,i} ... f_{m,i}. Each stratum's product is
decomposed by averaging over all orderings of the factors -- Das
Gupta's symmetric formula -- and the per-stratum effects are summed,
which is what DasGuptR::dgnpop does with
ratefunction = "sum(f1*f2*...)". For additive forms use
kitagawa_decompose.
.. versionchanged:: 1.28.0
With more than one row per population the aggregate was computed as
the product of the factor means rather than the sum over strata of
the factor products the docstring stated, so the decomposition of
stratified data was of a different quantity. On Das Gupta's
Table 6.5 data (four factors, six age groups; 1963 vs 1968) factor
A's share of the gap was 0% where DasGuptR gives 36.8%, and
factor D's +333% where it gives -52.5%. Single-row inputs are
unchanged. Rows are now paired by by or by position, and
unequal lengths raise.
Examples:
>>> import statspai as sp
>>> import pandas as pd
>>> # Crude birth rate = fertility x share_women (product form).
>>> data_a = pd.DataFrame({'fertility': [0.10], 'share_women': [0.50]})
>>> data_b = pd.DataFrame({'fertility': [0.08], 'share_women': [0.55]})
>>> res = sp.das_gupta(data_a, data_b,
... factor_names=['fertility', 'share_women'])
>>> # Factor effects sum to the total gap (exact identity)
>>> float(round(res.gap - res.factor_effects['effect'].sum(), 12))
0.0
References
dasgupta1993standardization
kitagawa_decompose ¶
kitagawa_decompose(data: DataFrame, rate: str, group: str, by: Union[str, Sequence[str]], weights: Optional[str] = None, normalize: str = 'symmetric') -> KitagawaResult
Kitagawa (1955) two-factor rate decomposition.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Tidy data. Either individual-level (aggregated internally) or
pre-aggregated cell-level with columns: |
required |
rate
|
str
|
Column holding the category-specific rate (or 0/1 outcome at the individual level). |
required |
group
|
str
|
Binary group indicator. |
required |
by
|
str or list of str
|
Category variable(s) defining cells. |
required |
weights
|
str or None
|
Cell population weights. If None, each row treated as individual-level data (weight = 1). |
None
|
normalize
|
('symmetric', 'a', 'b')
|
|
'symmetric'
|
Examples:
>>> import statspai as sp
>>> import numpy as np
>>> import pandas as pd
>>> rng = np.random.default_rng(0)
>>> rows = []
>>> for grp, base in [(0, 0.2), (1, 0.3)]:
... for age, share in [('young', 0.5), ('mid', 0.3), ('old', 0.2)]:
... n = int(400 * share) + (10 if grp == 1 else 0)
... rate = base + {'young': 0.0, 'mid': 0.05, 'old': 0.1}[age]
... for _ in range(n):
... rows.append({'group': grp, 'age': age,
... 'y': int(rng.random() < rate)})
>>> df = pd.DataFrame(rows)
>>> res = sp.kitagawa_decompose(df, rate='y', group='group', by='age')
>>> # Gap = rate effect + composition effect + interaction (exact identity)
>>> float(round(res.gap - (res.rate_effect + res.composition_effect
... + res.interaction), 10))
0.0
References
kitagawa1955components
melly_decompose ¶
melly_decompose(data: DataFrame, y: str, group: str, x: Sequence[str], tau_grid: Optional[Sequence[float]] = None, reference: int = 0, n_tau_qr: int = 100) -> MellyResult
Melly (2005) quantile decomposition.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
column names
|
|
required |
group
|
column names
|
|
required |
x
|
column names
|
|
required |
tau_grid
|
Sequence[float] or None — reporting τ grid
|
|
None
|
reference
|
(0, 1)
|
Same convention as |
0
|
n_tau_qr
|
int
|
Number |
100
|
Returns:
| Type | Description |
|---|---|
MellyResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> r = sp.melly_decompose(df, y='log_wage', group='female',
... x=['education', 'experience', 'tenure'],
... tau_grid=[0.25, 0.5, 0.75])
>>> import contextlib, io
>>> with contextlib.redirect_stdout(io.StringIO()):
... text = r.summary()
>>> "Melly (2005) Quantile Decomposition" in text
True
>>> list(r.quantile_grid['tau'])
[0.25, 0.5, 0.75]
>>> set(['gap', 'composition', 'structure']) <= set(r.quantile_grid.columns)
True
References
[@melly2005decomposition]
bauer_sinning ¶
bauer_sinning(data: DataFrame, y: str, group: str, x: Sequence[str], model: str = 'logit', reference: int = 0, variant: str = 'yun') -> NonlinearDecompResult
Bauer-Sinning (2008) nonlinear Oaxaca-Blinder decomposition with Yun (2004, 2005) weights for detailed contributions.
Implements the three-fold equivalent: gap = [E(p_a(X_a)) - E(p_r(X_a))] # not used here but uses Yun's weight decomposition: explained_j = w_j · (E(p_r(X_a)) - E(p_r(X_b))) where w_j = (Δx̄_j · β_r_j) / Σ_k (Δx̄_k · β_r_k)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
model
|
('logit', 'probit')
|
|
'logit'
|
reference
|
(0, 1)
|
|
0
|
variant
|
'yun'
|
|
'yun'
|
Examples:
fairlie ¶
fairlie(data: DataFrame, y: str, group: str, x: Sequence[str], model: str = 'logit', reference: int = 0, n_sim: int = 500, seed: Optional[int] = 12345) -> NonlinearDecompResult
Fairlie (2005) nonlinear decomposition for binary outcomes.
Procedure (as in Jann's Stata fairlie): fit the binary model on the
reference group; sort each group by its predicted probability; if the
groups differ in size, draw a random subsample of the larger one the
size of the smaller (kept in rank order); pair the two groups rank to
rank; then switch the covariates from group A's (group == 0) values
to group B's one variable at a time, in the order given. Variable
j's contribution is the change in the mean predicted probability at
its switch, averaged over n_sim subsamples. With equal group sizes
there is no subsample and the decomposition is deterministic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str — binary {0, 1}
|
|
required |
group
|
str — binary; ``0`` is group A, ``1`` group B (gap = A - B)
|
|
required |
x
|
Sequence[str]
|
|
required |
model
|
('logit', 'probit')
|
|
'logit'
|
reference
|
(0, 1)
|
|
0
|
n_sim
|
int — number of random subsamples to average over (``reps()``)
|
|
500
|
seed
|
int or None
|
|
12345
|
Returns:
| Type | Description |
|---|---|
NonlinearDecompResult
|
|
Examples:
gelbach ¶
gelbach(data: DataFrame, y: str, base_x: Sequence[str], added_x: Sequence[str], var_of_interest: Optional[str] = None, alpha: float = 0.05, vce: str = 'robust', absorb: Optional[Union[str, Sequence[str]]] = None, cluster: Optional[str] = None, shapley: bool = False) -> GelbachResult
Gelbach (2016) decomposition of omitted variable bias.
When controls are added to a regression, the coefficient on a variable of interest may change. This function decomposes that change into contributions from each added variable, answering: "Which added controls explain the change, and by how much?"
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input dataset. |
required |
y
|
str
|
Outcome variable name. |
required |
base_x
|
list of str
|
Variables in the base (short) specification. |
required |
added_x
|
list of str
|
Variables added to obtain the full (long) specification. |
required |
var_of_interest
|
str
|
Which base variable's coefficient change to decompose.
Defaults to the first element of |
None
|
alpha
|
float
|
Significance level. |
0.05
|
vce
|
('robust', 'nonrobust')
|
Heteroskedasticity-robust or homoskedastic covariance, as
|
"robust"
|
absorb
|
str or list of str
|
Fixed effects kept in both the base and the full regression, as
|
None
|
cluster
|
str
|
Cluster-robust covariance, as |
None
|
shapley
|
bool
|
Also report, in column |
False
|
Notes on inference
The standard errors are those of Gelbach's own Stata command b1x2
(each added variable its own group): the covariance of the stacked
long regression and auxiliary regressions, plus the part due to the
long-regression coefficients on the added variables and both cross
terms. The result also carries the joint vcov of the per-variable
contributions and total_se.
.. versionchanged:: 1.28.0
Standard errors were a two-term delta method that treated the
auxiliary and long-regression estimates as independent; b1x2
keeps their covariance. On cps_wage that moved the SEs by
0.03%, but the old formula has no guarantee of being that close.
Point estimates are unchanged.
Returns:
| Type | Description |
|---|---|
GelbachResult
|
Result object with |
Notes
The Gelbach identity:
.. math::
\hat\beta^{\text{base}}_k - \hat\beta^{\text{full}}_k
= \sum_{j \in \text{added}} \tilde\gamma_{kj} \hat\beta^{\text{full}}_j
where :math:\tilde\gamma_{kj} is the coefficient from regressing
added variable j on all base variables (including constant).
Examples:
>>> import statspai as sp
>>> result = sp.gelbach(
... data=sp.cps_wage(), y="log_wage",
... base_x=["education"],
... added_x=["experience", "tenure", "union"],
... )
>>> type(result).__name__
'GelbachResult'
>>> result.base_var
'education'
>>> bool(abs((result.base_coef - result.full_coef) - result.total_change) < 1e-6)
True
References
gelbach2016covariates
rif_decomposition ¶
rif_decomposition(formula: str, data: DataFrame, group: str, statistic: StatisticKind = 'quantile', tau: float = 0.5, reference: int = 0, quantile_convention: Literal['statspai', 'dineq', 'rifreg'] = 'statspai') -> RIFDecompositionResult
RIF Oaxaca-Blinder decomposition (FFL 2009, Section 5).
Decomposes the between-group difference in a distributional statistic into explained (covariate endowment) and unexplained (coefficient) parts at the chosen statistic.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
group
|
str
|
Binary (0/1) group indicator column. |
required |
reference
|
int
|
Which group's coefficients to use as the reference (0 or 1). |
0
|
quantile_convention
|
('statspai', 'dineq', 'rifreg')
|
Quantile RIF convention for |
"statspai"
|
Examples:
rif_values ¶
rif_values(y: ndarray, statistic: StatisticKind = 'quantile', tau: float = 0.5, quantile_convention: Literal['statspai', 'dineq', 'rifreg'] = 'statspai') -> ndarray
Compute the RIF of each observation.
Delegates to :func:statspai.decomposition._common.influence_function,
which is the canonical implementation shared with the FFL two-step
decomposition. Supported statistics (expanded in this release):
``quantile`` (with ``tau``), ``mean``, ``variance``, ``std``,
``log_var``, ``iqr``, ``gini``, ``theil_t``, ``theil_l``,
``atkinson`` (ε = 1).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
y
|
(n,) array
|
|
required |
statistic
|
str
|
|
'quantile'
|
tau
|
float
|
Quantile level (only used when |
0.5
|
quantile_convention
|
('statspai', 'dineq', 'rifreg')
|
Quantile RIF convention. |
"statspai"
|
rifreg ¶
rifreg(formula: str, data: DataFrame, statistic: StatisticKind = 'quantile', tau: float = 0.5, quantile_convention: Literal['statspai', 'dineq', 'rifreg'] = 'statspai') -> RIFResult
RIF regression (Firpo, Fortin & Lemieux 2009).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
formula
|
str
|
|
required |
data
|
DataFrame
|
|
required |
statistic
|
('quantile', 'variance', 'gini')
|
|
"quantile"
|
tau
|
float
|
Quantile level (default 0.5 = median UQPE). |
0.5
|
quantile_convention
|
('statspai', 'dineq', 'rifreg')
|
Quantile RIF convention for |
"statspai"
|
Examples:
yu_elwert_decompose ¶
yu_elwert_decompose(data: DataFrame, y: str, treatment: str, group: str, x: Sequence[str], *, method: str = 'plugin', inference: str = 'bootstrap', n_boot: int = 499, alpha: float = 0.05, trim: float = 0.005, cluster: Optional[str] = None, seed: Optional[int] = 12345) -> YuElwertResult
Nonparametric causal decomposition of a group disparity.
.. versionchanged:: 1.28.0
method="efficient" computed selection as a covariance of
doubly robust scores, so the four components did not add up to the
disparity (0.566 against 0.572 on a 2,000-row example), and used
unnormalised inverse-probability weights. Given the same nuisance
predictions it now matches cdgd::cdgd0_manual to machine
precision. inference="analytic" is new. "plugin" is
unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel with one row per observation. |
required |
y
|
str
|
Name of the (continuous) outcome column. |
required |
treatment
|
str
|
Binary treatment indicator (0/1). |
required |
group
|
str
|
Binary group indicator (0/1) — |
required |
x
|
sequence of str
|
Adjustment covariates (used to identify within-group CATEs). |
required |
method
|
('plugin', 'efficient')
|
|
"plugin"
|
inference
|
('bootstrap', 'analytic', 'none')
|
|
"bootstrap"
|
n_boot
|
int
|
|
499
|
alpha
|
float
|
Two-sided coverage level. |
0.05
|
trim
|
float
|
Lower/upper clip for fitted propensities (only used in
|
0.005
|
cluster
|
str or None
|
Column name to use for cluster bootstrap. |
None
|
seed
|
int or None
|
|
12345
|
Returns:
| Type | Description |
|---|---|
YuElwertResult
|
|
Notes
Identification requires conditional ignorability of treatment given
(R, X) (no unmeasured confounders within group). The framework
does not require R itself to be unconfounded, distinguishing
it from causal-mediation approaches.
The "selection" component is zero whenever individuals are randomly assigned to treatment (no selection on individual gain) or whenever the CATE is constant within group (no heterogeneity to select on). A non-zero selection term — particularly one of opposite sign in the two groups — flags that targeting differs systematically across groups, often the lever a designer can pull.
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> r = rng.integers(0, 2, size=n)
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> p = 1 / (1 + np.exp(-(0.5 * r + 0.3 * x1)))
>>> t = (rng.uniform(size=n) < p).astype(int)
>>> y = (1.0 + 0.5 * x1 + 0.3 * x2 + (0.8 + 0.4 * r) * t
... + rng.normal(size=n))
>>> df = pd.DataFrame({"y": y, "t": t, "r": r, "x1": x1, "x2": x2})
>>> res = sp.decompose(
... "yu_elwert", data=df, y="y", treatment="t", group="r",
... x=["x1", "x2"], inference="none",
... )
>>> type(res).__name__
'YuElwertResult'
>>> res.method
'plugin'
References
yu2025nonparametric