Matching and balancing¶
statspai.matching covers classical matching, balancing weights, diagnostics,
and Love plots behind a unified sp.match(...) dispatcher plus standalone
estimator functions for power users.
See also the decision guide: Choosing a matching estimator, and the exhaustive auto-generated listing under Full API reference -> matching.
Choosing an entry point¶
import statspai as sp
# Default nearest-neighbour propensity-score matching.
r = sp.match(
df,
y="earnings",
treat="training",
covariates=["age", "education", "earnings_pre"],
)
# Balancing-weight estimators are available through method=...
r_ebal = sp.match(df, y="earnings", treat="training",
covariates=["age", "education"], method="ebalance")
# ...or as standalone functions with estimator-specific options.
w = sp.overlap_weights(df, treat="training",
covariates=["age", "education", "earnings_pre"])
diag = sp.balance_diagnostics(df, treatment="training",
covariates=["age", "education"],
weights=w.weights)
Estimator families¶
| Family | Functions | Typical use |
|---|---|---|
| Classical matching | sp.match(method="nearest" | "psm" | "mahalanobis" | "cem" | "stratify") |
Matched samples or subclassification with transparent design choices. |
Stata psmatch2 |
sp.psmatch2 |
Stata-faithful supported PSM paths with matched-sample variables (_weight/_support; plus _n1/_pdif for nearest-neighbour), post-matching balance, common-support plot, and frequency-weighted PSM-DID. See the PSM-DID guide. |
| Entropy / CBPS / SBW | sp.ebalance, sp.cbps, sp.sbw |
Direct covariate balance by reweighting. |
| Genetic matching | sp.genmatch |
Automated balance search over covariate weights. |
| Overlap weights | sp.overlap_weights |
ATE-style overlap-population estimands with stable weights. |
| Diagnostics | sp.balance_diagnostics, sp.love_plot |
Standardised mean differences, variance ratios, and Love plots. |
Method-level API¶
sp.match(...)¶
match ¶
Matching estimators for observational causal inference.
Unified interface supporting orthogonal design choices:
- distance: how to measure unit similarity
'propensity'— logit propensity score (Rosenbaum & Rubin 1983)'mahalanobis'— Mahalanobis distance (Rubin 1980)'euclidean'— normalized Euclidean distance-
'exact'— exact covariate values (no approximation) -
method: how to use those distances
'nearest'— k-nearest-neighbor matching'stratify'— subclassification / stratification-
'cem'— coarsened exact matching (Iacus, King & Porro 2012) -
bias_correction: Abadie-Imbens (2011) regression adjustment for matching discrepancies in nearest-neighbor matching.
Backward compatible: method='psm', method='mahalanobis', and
method='cem' still work and map to the new parameter space.
References
Rosenbaum, P.R. and Rubin, D.B. (1983). Biometrika, 70(1), 41-55. Abadie, A. and Imbens, G.W. (2006). Econometrica, 74(1), 235-267. Abadie, A. and Imbens, G.W. (2011). JBES, 29(1), 1-11. Iacus, S.M., King, G., and Porro, G. (2012). Political Analysis, 20(1), 1-24. King, G. and Nielsen, R. (2019). Political Analysis, 27(4), 435-454. Cunningham, S. (2021). Causal Inference: The Mixtape. Yale University Press. Ch. 5: Matching and Subclassification. https://mixtape.scunning.com/ [@rosenbaum1983central]
MatchEstimator ¶
Unified matching estimator supporting multiple distance × method combinations.
This is the object-oriented backend behind :func:match. Most users
should call :func:sp.match; construct MatchEstimator directly only
when you want to hold the configured estimator and call .fit()
yourself. .fit() returns a CausalResult.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input data. |
required |
y
|
str
|
Outcome column. |
required |
treat
|
str
|
Binary (0/1) treatment column. |
required |
covariates
|
list of str
|
Variables to match on. |
required |
distance
|
str
|
|
None
|
method
|
str
|
|
'nearest'
|
estimand
|
str
|
|
'ATT'
|
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> age = rng.normal(40, 8, n)
>>> edu = rng.normal(12, 2, n)
>>> ps = 1 / (1 + np.exp(-(0.05 * (age - 40) + 0.1 * (edu - 12))))
>>> training = rng.binomial(1, ps)
>>> wage = 20 + 0.3 * age + 0.5 * edu + 4.0 * training + rng.normal(0, 3, n)
>>> df = pd.DataFrame({"wage": wage, "training": training,
... "age": age, "edu": edu})
>>> est = sp.MatchEstimator(df, y="wage", treat="training",
... covariates=["age", "edu"], distance="propensity")
>>> result = est.fit()
>>> type(result).__name__
'CausalResult'
match ¶
match(data: DataFrame, y: str, treat: str, covariates: List[str], *, distance: Optional[str] = None, method: str = 'nearest', estimand: str = 'ATT', n_matches: int = 1, caliper: Optional[float] = None, caliper_scale: str = 'raw', replace: bool = True, ties: str = 'all', tie_tolerance: float = 0.0, m_order: str = 'smallest_min_dist', mahalanobis_cov: str = 'pooled', exact: Optional[Union[str, Sequence[str]]] = None, covariate_calipers: Optional[Mapping[str, float]] = None, mahvars: Optional[Union[str, Sequence[str]]] = None, bias_correction: bool = False, ps_poly: int = 1, ps_model: str = 'logit', pscore: Optional[str] = None, strata: Optional[str] = None, common_support: str = 'none', kernel: str = 'epan', bwidth: float = 0.06, se_method: str = 'auto', ai_matches: int = 1, bootstrap_reps: int = 200, bootstrap_seed: Optional[int] = None, llr_stata_compat: bool = False, n_strata: int = 5, n_bins: Optional[Union[int, Sequence[int], Mapping[str, Any]]] = None, alpha: float = 0.05, weights: Optional[str] = None, cluster: Optional[str] = None, sampling_weights: Optional[str] = None, k2k: bool = False, design: Any = None) -> CausalResult
Estimate treatment effect using matching.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input data. |
required |
y
|
str
|
Outcome variable. |
required |
treat
|
str
|
Binary treatment variable (0/1). |
required |
covariates
|
list of str
|
Variables to match on. |
required |
distance
|
str
|
Distance metric: 'propensity', 'mahalanobis', 'euclidean', 'exact'. Default is 'propensity' for method='nearest'/'stratify'. |
None
|
method
|
str
|
Matching algorithm: 'nearest', 'stratify', 'cem', 'kernel', 'radius', 'llr'. Legacy values 'psm', 'mahalanobis' also accepted.
|
'nearest'
|
estimand
|
str
|
Target estimand: 'ATT' or 'ATE'. The ATE is available for
nearest-neighbour matching (both arms are matched), for
|
'ATT'
|
n_matches
|
int
|
Number of nearest-neighbor matches per unit. |
1
|
caliper
|
float
|
Maximum distance for a valid match. Units left without a match are
excluded with a warning; if no unit at all finds one the call
raises |
None
|
caliper_scale
|
(raw, sd)
|
|
'raw'
|
replace
|
bool
|
Match with replacement (nearest-neighbor only). |
True
|
ties
|
(all, first)
|
What to do, under matching with replacement, when more units are
exactly as close as the |
'all'
|
tie_tolerance
|
float
|
With |
0
|
exact
|
str or list of str
|
Nearest-neighbour matching only: a unit may be matched only to units
with the same values of these columns (R |
None
|
covariate_calipers
|
dict
|
Nearest-neighbour matching only: |
None
|
mahvars
|
str or list of str
|
Nearest-neighbour propensity-score matching only: match on the
Mahalanobis distance of these columns (pooled within-arm
covariance), and use the propensity score fitted on With any of these three options the distance matrix is searched row by row instead of through the sorted-score or k-d tree search, which is slower on large samples but gives the same matches as the unconstrained search would on the allowed pairs. |
None
|
bias_correction
|
bool
|
Apply Abadie-Imbens (2011) bias correction via regression
adjustment on the matching discrepancy. The regression of the
outcome on |
False
|
ps_model
|
(logit, probit)
|
The binary model for the propensity score. |
'logit'
|
pscore
|
str
|
A column that already holds the propensity score (or any index the
matching should run on, such as the linear predictor): Stata
|
None
|
strata
|
str
|
With |
None
|
ps_poly
|
int
|
Polynomial degree for the propensity score model.
|
1
|
common_support
|
(none, minmax)
|
Common-support trimming for nearest-neighbor matching.
|
'none'
|
kernel
|
str
|
Kernel type for |
'epan'
|
bwidth
|
float
|
Kernel bandwidth on the propensity score for |
0.06
|
se_method
|
str
|
Standard-error estimator: one of ======================== =========== ==================
option SE ratio coverage (nom. .95)
======================== =========== ==================
With
A standard error that cannot be estimated (a single matched unit,
a stratum with one unit in an arm) is Through 1.39.3 the standard error of the ATE was the matched-pair
SE of the treated-to-control leg alone (the ATT's The default treats the matching variable as given. When that
variable is a propensity score estimated on the same data, this is
conservative for the ATE: in one simulated design the default
standard error averaged 1.3 times the sampling standard deviation
(95% intervals covered 99%), while The default estimates each unit's conditional variance from its
.. versionchanged:: 1.39.7
Through 1.39.6 the same-arm units were always taken in row
order, even under .. versionchanged:: 1.22
|
'auto'
|
ai_matches
|
int
|
Number of within-arm matches |
1
|
bootstrap_reps
|
int
|
Number of bootstrap replications for |
200
|
bootstrap_seed
|
int
|
Seed for the bootstrap resampler. Pass one for reproducible standard errors. |
None
|
n_strata
|
int
|
Number of strata for method='stratify'. |
5
|
n_bins
|
(int, sequence or dict)
|
Coarsening for method='cem'. An integer is the number of
equal-width bins used for every covariate. The default follows
the |
None
|
alpha
|
float
|
Significance level. |
0.05
|
weights
|
str
|
Frequency weights (Stata |
None
|
cluster
|
str
|
Not supported; raises with pointers to estimators that are. |
None
|
sampling_weights
|
str
|
Sampling (survey) weights, R |
None
|
design
|
SurveyDesign
|
A complex survey design from :func: .. versionadded:: 1.40.0 |
None
|
k2k
|
bool
|
|
False
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> df = pd.DataFrame({'age': rng.integers(20, 60, n),
... 'edu': rng.integers(8, 18, n),
... 'exp': rng.normal(10, 5, n)})
>>> logit = -3 + 0.03 * df['age'] + 0.1 * df['edu']
>>> df['training'] = rng.binomial(1, 1 / (1 + np.exp(-logit)))
>>> df['wage'] = (5 + 1.5 * df['training'] + 0.05 * df['age']
... + 0.3 * df['edu'] + 0.1 * df['exp'] + rng.normal(size=n))
>>> # Propensity score matching (default)
>>> result = sp.match(df, y='wage', treat='training',
... covariates=['age', 'edu', 'exp'])
>>> type(result).__name__
'CausalResult'
>>> # Mahalanobis distance + bias correction
>>> result = sp.match(df, y='wage', treat='training',
... covariates=['age', 'edu', 'exp'],
... distance='mahalanobis', bias_correction=True)
>>> # Exact matching
>>> result = sp.match(df, y='wage', treat='training',
... covariates=['age', 'edu'],
... distance='exact')
>>> # Propensity score stratification (5 strata)
>>> result = sp.match(df, y='wage', treat='training',
... covariates=['age', 'edu', 'exp'],
... method='stratify', n_strata=5)
>>> # CEM
>>> result = sp.match(df, y='wage', treat='training',
... covariates=['age', 'edu'],
... method='cem')
>>> # Quadratic PS model (Cunningham 2021, Ch. 5 style)
>>> result = sp.match(df, y='wage', treat='training',
... covariates=['age', 'edu', 'exp'],
... ps_poly=2)
balanceplot ¶
balanceplot(result: CausalResult, threshold: float = 0.1, ax: Any = None, figsize: tuple = (8, None), title: Optional[str] = None) -> Tuple[Any, Any]
Love plot: covariate balance visualization (SMD dot plot).
Displays standardized mean differences (SMD) for each covariate. The standard threshold for good balance is |SMD| < 0.1.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
result
|
CausalResult
|
Result from |
required |
threshold
|
float
|
SMD threshold lines. |
0.1
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
Height auto-scales with number of covariates if None. |
(8, None)
|
title
|
str
|
|
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 200
>>> age = rng.normal(40, 8, n)
>>> edu = rng.normal(12, 2, n)
>>> ps = 1 / (1 + np.exp(-(0.05 * (age - 40) + 0.1 * (edu - 12))))
>>> training = rng.binomial(1, ps)
>>> wage = 20 + 0.3 * age + 0.5 * edu + 4.0 * training + rng.normal(0, 3, n)
>>> df = pd.DataFrame({"wage": wage, "training": training,
... "age": age, "edu": edu})
>>> result = sp.match(df, y="wage", treat="training",
... covariates=["age", "edu"])
>>> fig, ax = sp.balanceplot(result)
>>> fig.savefig("balance.png")
>>> type(ax).__name__
'Axes'
psplot ¶
psplot(data: DataFrame, treat: str, covariates: List[str], *, n_bins: int = 40, ax: Any = None, figsize: tuple = (8, 5), title: Optional[str] = None, labels: tuple = ('Control', 'Treated'), colors: tuple = ('#3498DB', '#E74C3C'), trim: Optional[float] = None) -> Tuple[Any, Any]
Propensity score distribution plot (common support diagnostic).
Overlays histograms of the estimated propensity score for treated and control groups, so the user can visually assess whether the common support (overlap) assumption holds.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
treat
|
str
|
Binary treatment column. |
required |
covariates
|
list of str
|
Covariates used to estimate the propensity score. |
required |
n_bins
|
int
|
Number of histogram bins. |
40
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
|
(8, 5)
|
title
|
str
|
|
None
|
labels
|
tuple of str
|
Labels for (control, treated). |
('Control', 'Treated')
|
colors
|
tuple of str
|
Colors for (control, treated). |
('#3498DB', '#E74C3C')
|
trim
|
float
|
If set, draw vertical lines at (trim, 1-trim) to show the recommended trimming region. |
None
|
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
Examples:
>>> import statspai as sp, numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 400
>>> x1, x2 = rng.normal(size=n), rng.normal(size=n)
>>> D = rng.binomial(1, 1 / (1 + np.exp(-(x1 + 0.5 * x2))))
>>> df = pd.DataFrame({"D": D, "x1": x1, "x2": x2})
>>> fig, ax = sp.psplot(df, treat="D", covariates=["x1", "x2"])
sp.psmatch2(...)¶
psmatch2 ¶
Stata psmatch2-faithful propensity-score matching with a full
post-matching toolkit (balance, common-support plot, PSM-DID).
sp.psmatch2 is the migration-friendly front door for analysts coming
from Stata's psmatch2 (Leuven & Sianesi 2003). It wraps the supported
nearest-neighbour, kernel, and radius propensity-score matching paths in a
:class:PSMatch2Result that exposes the matched-sample variables
(_pscore, _treated, _support, _weight, _y; plus
_n1 … _nk, _nn, _pdif for nearest-neighbour matching) and the three
operations that sp.match alone could not previously support:
.balance()— covariate balance on the matched, weighted sample (the post-matching analogue of Statapstest)..psplot()— propensity-score density before/after matching, with the controls reweighted by_weight(the common-support diagnostic)..psm_did()— weighted PSM-DID: merge_weightinto a panel, keep the matched sample, and run the weightedy ~ treat * postregression (Stata'sreg y i.treat##i.post [aweight=_weight], withweight='fweight'available for the[fweight=]degrees of freedom).
For the pinned Stata 18 psmatch2 paths (nearest-neighbour,
Epanechnikov kernel, and radius matching), the point estimate, analytic
SE, and emitted matched-frame columns match the reference fixtures — see
tests/reference_parity.
References
Leuven, E. and Sianesi, B. (2003). PSMATCH2: Stata module to perform full Mahalanobis and propensity score matching, common support graphing, and covariate imbalance testing. Statistical Software Components S432001. Rosenbaum, P.R. and Rubin, D.B. (1983). Biometrika, 70(1), 41-55. Heckman, J.J., Ichimura, H. and Todd, P.E. (1997). Review of Economic Studies, 64(4), 605-654.
PSTestResult ¶
Bases: ResultProtocolMixin
Stata pstest-shaped balance table (see :meth:PSMatch2Result.pstest).
Attributes:
| Name | Type | Description |
|---|---|---|
table |
DataFrame
|
One row per covariate: unmatched / matched arm means, |
summary_stats |
dict
|
|
PSMatch2Result ¶
Bases: ResultProtocolMixin
Container for a sp.psmatch2 run.
Attributes:
| Name | Type | Description |
|---|---|---|
matched_data |
DataFrame
|
The input data plus the psmatch2 columns ( |
att, se, pvalue, ci |
float / tuple
|
Average treatment effect on the treated and its inference. |
estimand |
str
|
Always |
result |
CausalResult
|
The underlying :func: |
Methods:
| Name | Description |
|---|---|
matched_sample |
Rows that entered the matched sample ( |
balance |
Post-matching covariate balance on the weighted matched sample. |
psplot |
Propensity-score density before/after matching. |
psm_did |
Frequency-weighted PSM-DID regression. |
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> m = sp.psmatch2(df, outcome='log_wage', treat='union',
... covariates=['education', 'experience', 'tenure'])
>>> '_weight' in m.matched_data.columns
True
>>> bal = m.balance() # post-matching balance
>>> fig, ax = m.psplot()
matched_sample ¶
Return the rows that make up the matched sample.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
on_support
|
bool
|
Keep only rows with |
True
|
drop_unmatched
|
bool
|
Drop rows with a missing |
True
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
|
balance ¶
balance(covariates: Optional[Sequence[str]] = None, *, threshold: float = 0.1) -> BalanceDiagnosticsResult
Covariate balance before vs after matching (StatsPAI conventions).
Standardized mean differences are reported two ways:
smd_raw— before matching: unweighted SMD over the full treated vs control sample.smd_weighted— after matching: SMD with the_weightfrequency weights, so a control used twice counts twice and unmatched / off-support units drop out (weight 0).
.. warning::
This is not Stata's pstest table, despite the family
resemblance. pstest keeps the unmatched pooled standard
deviation in the denominator of the post-matching bias; this
method uses the matched-sample standard deviation, the
convention most non-Stata packages follow. The two disagree by
several percent on real data. Use :meth:pstest when you need
to reproduce a printed Stata table.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
covariates
|
list of str
|
Variables to assess. Defaults to the matching covariates. |
None
|
threshold
|
float
|
|SMD| balance threshold. |
0.1
|
Returns:
| Type | Description |
|---|---|
BalanceDiagnosticsResult
|
|
pstest ¶
Stata pstest <covariates>, both — the printed balance table.
:meth:balance reports StatsPAI's own diagnostics. This reports
Stata's, digit for digit, so a ported result can be checked against
a pstest table without translating conventions. The two differ
deliberately: pstest keeps the unmatched pooled standard
deviation in the denominator of the post-matching bias (so the two
rows are comparable), computes matched moments with Stata importance
weights (variance divides by Σw - 1), and forms Rubin's B and R
on the linear index rather than the propensity score.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
covariates
|
sequence of str
|
Variables to test. Defaults to the matching covariates. |
None
|
Returns:
| Type | Description |
|---|---|
PSTestResult
|
|
Examples:
psplot ¶
psplot(*, before: bool = True, n_grid: int = 300, ax: Any = None, figsize: tuple[float, float] = (8.0, 4.5), title: Optional[str] = None) -> tuple[Any, Any]
Propensity-score density by treatment group, after matching.
Controls are reweighted by _weight so the plotted control
density reflects the matched sample, not the raw pool. With
before=True the raw (unweighted) densities are overlaid as
dashed lines so the user can see how matching tightened overlap.
Returns:
| Type | Description |
|---|---|
(fig, ax)
|
|
psm_did ¶
psm_did(panel: DataFrame, *, id: str, y: str, time: Optional[str] = None, post: Optional[str] = None, treat: Optional[str] = None, treat_time: Optional[Any] = None, covariates: Optional[Sequence[str]] = None, fixed_effects: Optional[Sequence[str]] = None, cluster: Optional[Union[str, List[str]]] = None, on_support: bool = True, weight: str = 'aweight', alpha: float = 0.05) -> CausalResult
Weighted PSM-DID on a panel.
Implements the Stata workflow
.. code-block:: stata
psmatch2 d x1 x2, out(y) ... // produces _weight
// merge _weight back onto the panel by id, then
reg y i.treat##i.post [aweight=_weight] if _support==1
The matching _weight (and _support) are merged onto panel
by id, the matched sample is selected, and the weighted
difference-in-differences regression
y ~ treat + post + treat:post (+ covariates | fixed_effects)
is fitted with :func:sp.feols. The treat:post coefficient is
the PSM-DID treatment effect.
.. note::
Which Stata weight regime? aweight and fweight give
the same coefficient but different standard errors, because
fweight treats a control reused w times as w
independent observations and so uses df = sum(w) - k instead
of df = n_rows - k. On the reference fixture the DiD SE is
0.250051 under aweight and 0.214797 under fweight — the
fweight interval is ~14% narrower purely from the degrees of
freedom.
Choosing between them is mostly a reproducibility question:
match the Stata line you are reconciling against. 'aweight'
is the default because it is the more conservative of the two and
the only one defined when k > 1 makes _weight fractional.
'fweight' is implemented by physically replicating rows —
which is what a frequency weight means — and reproduces Stata
bit-for-bit (see
tests/reference_parity/test_psmdid_weight_parity.py).
Neither regime gives correct inference under matching with
replacement. Both condition on the realised match structure and
treat the matched sample as independently drawn, while a control
serving several treated units induces dependence across those
comparisons that no degrees-of-freedom convention captures.
'aweight' is the less optimistic of the two, not the correct
one. When the standard error is load-bearing, cluster on the
matched control's identity or bootstrap the pipeline.
.. versionchanged:: 1.22
weight='fweight' now computes genuine Stata fweight
degrees of freedom. Before 1.22 it silently computed
aweight numbers. The default changed from
'fweight' to 'aweight', so results from the default
call are unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
panel
|
DataFrame
|
Long panel (one row per unit-period). |
required |
id
|
str
|
Unit identifier. Must also exist in the matching data so the
per-unit |
required |
y
|
str
|
Outcome in the panel. |
required |
time
|
str
|
Time variable. Used with |
None
|
post
|
str
|
Binary post-period indicator. Provide this or |
None
|
treat
|
str
|
Time-invariant treated-group indicator in the panel. Defaults to the matching treatment variable. |
None
|
treat_time
|
scalar
|
First treated period; |
None
|
covariates
|
list of str
|
Additional time-varying controls. |
None
|
fixed_effects
|
list of str
|
Columns absorbed as fixed effects (e.g. |
None
|
cluster
|
str or list
|
Cluster variable(s) for the standard errors. |
None
|
on_support
|
bool
|
Keep only matched units on common support. |
True
|
weight
|
('aweight', 'fweight', 'none')
|
Weight regime for the DiD regression, following Stata:
|
'aweight'
|
alpha
|
float
|
Significance level for the returned CI. |
0.05
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
cite ¶
Citation for the matching estimator (delegates to the result).
psmatch2 ¶
psmatch2(data: DataFrame, *, treat: Optional[str] = None, covariates: Optional[Union[Sequence[str], str]] = None, outcome: Optional[str] = None, y: Optional[str] = None, neighbor: int = 1, n_matches: Optional[int] = None, caliper: Optional[float] = None, common_support: str = 'none', method: str = 'neighbor', kernel: str = 'epan', bwidth: float = 0.06, se: str = 'psmatch2', ai: int = 0, replace: bool = True, ps_poly: int = 1, ps_model: str = 'logit', pscore: Optional[str] = None, radius_weights: str = 'treated', distance: Optional[str] = None, bootstrap_reps: int = 200, bootstrap_seed: Optional[int] = None, llr_stata_compat: bool = False, alpha: float = 0.05, ties: bool = False, ate: bool = False) -> PSMatch2Result
Stata psmatch2-faithful supported propensity-score matching.
Runs nearest-neighbour propensity-score matching and returns a
:class:PSMatch2Result carrying the psmatch2 matched-sample variables
(_pscore _treated _support _weight _y; plus
_n1 … _nn _pdif for nearest-neighbour matching) plus
post-matching balance, common-support plotting, and PSM-DID helpers.
This is the Stata-migration front door over :func:sp.match; the pinned
Stata 18 nearest-neighbour, kernel, and radius paths are covered by
reference fixtures for the point estimate, analytic SE, and emitted
matched-frame columns (Leuven & Sianesi 2003).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Cross-section with one row per unit (the matching sample). |
required |
treat
|
str
|
Binary treatment indicator (0/1). Stata's |
None
|
covariates
|
list of str
|
Pre-treatment covariates entering the propensity-score model.
Optional when |
None
|
pscore
|
str
|
A column that already holds the propensity score: Stata
|
None
|
outcome
|
str
|
Outcome variable ( |
None
|
y
|
str
|
Outcome variable ( |
None
|
neighbor
|
int
|
Number of nearest neighbours |
1
|
caliper
|
float
|
Maximum propensity-score distance for a valid match
(Stata |
None
|
common_support
|
('none', 'minmax')
|
|
'none'
|
method
|
('neighbor', 'kernel', 'radius')
|
Matching algorithm. |
'neighbor'
|
radius_weights
|
('treated', 'pairs')
|
How radius matching weighs the controls. |
'treated'
|
kernel
|
('epan', 'normal', 'biweight', 'uniform', 'tricube')
|
Kernel type for |
'epan'
|
bwidth
|
float
|
Kernel bandwidth on the propensity score for |
0.06
|
se
|
('psmatch2', 'ai', 'abadie_imbens')
|
Standard-error estimator. |
'psmatch2'
|
llr_stata_compat
|
bool
|
Reproduce Stata's |
False
|
ai
|
int
|
Shorthand for the Abadie-Imbens (2006) robust SE with |
0
|
replace
|
bool
|
Match with replacement (psmatch2 default). |
True
|
ps_poly
|
int
|
Polynomial degree of the propensity-score model. |
1
|
ps_model
|
('logit', 'probit')
|
The binary model for the propensity score. Stata |
'logit'
|
distance
|
str
|
Matching metric; |
'propensity'
|
alpha
|
float
|
Significance level for the ATT confidence interval. |
0.05
|
ties
|
bool
|
Stata |
False
|
ate
|
bool
|
Stata |
False
|
Returns:
| Type | Description |
|---|---|
PSMatch2Result
|
|
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> m = sp.psmatch2(df, outcome='log_wage', treat='union',
... covariates=['education', 'experience', 'tenure'])
>>> round(float(m.att), 4) == round(float(m.result.estimate), 4)
True
>>> sorted(c for c in m.matched_data.columns if c.startswith('_'))[:4]
['_id', '_n1', '_nn', '_pdif']
Post-matching balance (the analogue of Stata pstest — smd_raw is
before matching, smd_weighted is the matched, _weight-weighted
sample):
sp.ebalance(...)¶
ebalance ¶
Entropy Balancing (Hainmueller 2012).
Reweights the control group so that weighted covariate moments (mean, variance, skewness) exactly match the treated group, without dropping observations or relying on propensity score models.
More robust than PSM because it directly targets balance rather than modeling the selection process.
References
Hainmueller, J. (2012). "Entropy Balancing for Causal Effects: A Multivariate Reweighting Method to Produce Balanced Samples in Observational Studies." Political Analysis, 20(1), 25-46. [@hainmueller2012entropy]
ebalance ¶
ebalance(data: DataFrame, y: str, treat: str, covariates: List[str], moments: Union[int, Sequence[int]] = 1, alpha: float = 0.05, vce: str = 'mestimation', dof_adjust: bool = False, estimand: str = 'ATT', base_weights: Optional[str] = None, weights: Optional[str] = None) -> CausalResult
Entropy Balancing treatment effect estimator.
Reweights control units to exactly match treated covariate moments,
then estimates ATT via weighted difference in means. estimand='ATE'
reweights both arms to the moments of the whole sample and
'ATC' the treated to the moments of the controls (R
WeightIt::weightit(method = "ebal", estimand = ...)).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
Outcome variable. |
required |
treat
|
str
|
Binary treatment indicator (0/1). |
required |
covariates
|
list of str
|
Covariates to balance on. |
required |
moments
|
int or sequence of int
|
Number of moments to balance: - 1: means only - 2: means and variances - 3: means, variances, and skewness A sequence gives the order covariate by covariate, in the order of
|
1
|
alpha
|
float
|
|
0.05
|
vce
|
(mestimation, naive)
|
Standard error of the ATT.
|
'mestimation'
|
dof_adjust
|
bool
|
How a second or third moment is matched. By default the raw
moments of the controls are set to those of the treated, which is
what R |
False
|
estimand
|
(ATT, ATE, ATC)
|
|
'ATT'
|
base_weights
|
str
|
Column of positive base weights |
None
|
weights
|
str
|
Column of sampling (survey) weights |
None
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
ATT estimate with entropy-balanced weights and balance table. |
Examples:
>>> import statspai as sp
>>> import numpy as np, pandas as pd
>>> rng = np.random.default_rng(0)
>>> n = 300
>>> age = rng.normal(40, 10, n)
>>> income = rng.normal(50, 15, n)
>>> education = rng.integers(8, 20, n).astype(float)
>>> ps = 1 / (1 + np.exp(-(0.03 * (age - 40) + 0.02 * (income - 50))))
>>> treated = (rng.uniform(size=n) < ps).astype(int)
>>> outcome = (2.0 * treated + 0.1 * age + 0.05 * income
... + 0.2 * education + rng.normal(0, 1, n))
>>> df = pd.DataFrame({'outcome': outcome, 'treated': treated,
... 'age': age, 'income': income,
... 'education': education})
>>> result = sp.ebalance(df, y='outcome', treat='treated',
... covariates=['age', 'income', 'education'])
>>> bool(np.isfinite(result.estimate))
True
>>> 'balance' in result.model_info # post-weighting balance table
True
Notes
Entropy balancing solves:
.. math:: \min_w \sum_i w_i \log(w_i / q_i)
subject to balance constraints (weighted moments match) and normalization (weights sum to 1).
Unlike PSM, this guarantees exact balance on specified moments without iteration or caliper tuning.
If the dual optimizer raises, uniform control weights are used as a
fallback; a ConvergenceWarning is emitted and
model_info['weights_fallback'] is set to True.
See Hainmueller (2012, Political Analysis).
ebalance_weights ¶
ebalance_weights(data: DataFrame, treat: str, covariates: List[str], moments: Union[int, Sequence[int]] = 1, dof_adjust: bool = False) -> ndarray
The entropy-balancing weights alone, one per row of data.
Treated units carry weight 1 and the control weights sum to the number
of treated units (the layout of model_info['weights_full'] of
:func:ebalance, and of the variable Stata ebalance, generate()
creates). data must be free of missing values in the columns used.
Raises NumericalInstability when no balancing weights exist.
sp.cbps(...)¶
cbps ¶
Covariate-balancing propensity score (CBPS) for a binary treatment.
The propensity score is a logistic model pi(X; beta) = expit(X'beta)
whose coefficients are chosen so that the inverse-probability weights it
implies balance the covariate means, optionally together with the logit
score equation [@imai2014covariate]. With n units, n1 of them
treated, the sample moments of Imai and Ratkovic (2014) are
score (eq. 7): s(beta) = X'(T - pi) / n
balance (eq. 10): b(beta) = X'w / n, w = T/pi - (1 - T)/(1 - pi)
balance (eq. 11): b(beta) = X'w / n, w = (n/n1) (T - pi)/(1 - pi)
(eq. 10 for the ATE, eq. 11 for the ATT). variant='exact' is the
just-identified CBPS, b(beta) = 0; variant='over' stacks
g = [s; b] and minimises the GMM criterion g' Sigma^{-1} g with the
model-implied moment covariance Sigma of eq. (16) (ATE) or (17) (ATT).
Two observations organise the computation:
- The just-identified moments are the gradient of a strictly concave
function of
beta(for the ATEsum_T (eta - e^{-eta}) - sum_C (eta + e^{eta}), for the ATTsum_T eta - sum_C e^{eta}, witheta = X'beta), so the solution is unique when it exists and Newton's method with a line search on that function finds it. - The over-identified fit is the two-step estimator (
CBPS(twostep = TRUE), the reference's default):Sigmais evaluated once, ata * beta_MLEwithathe minimiser over[0.8, 1.1]of the continuously-updated criterion along that ray, and then held fixed. This convention was established from the reference's outputs (its reportedJandmle.Jare reproduced by exactly thisSigma, and the scale sits on 0.8 or 1.1 when the unconstrained minimiser lies outside); seedocs/dev/cleanroom/cbps.md.
Both criteria are invariant to a non-singular linear reparametrisation of the covariates, so they are solved on centred and scaled covariates and the coefficients mapped back.
The effect is the Hajek (self-normalised) inverse-probability-weighted contrast; its standard error is the standard deviation of the estimate over nonparametric bootstrap refits.
Clean-room implementation written from Imai and Ratkovic (2014); the
reference (R CBPS 0.24, GPL >= 2) was used only as a black box.
References
[@imai2014covariate]
cbps ¶
cbps(data: DataFrame, y: str, treat: str, covariates: List[str], estimand: Literal['ATE', 'ATT'] = 'ATE', variant: Literal['exact', 'over'] = 'over', n_bootstrap: int = 500, alpha: float = 0.05, seed: Optional[int] = None, add_intercept: bool = True, trim: float = 0.0) -> CausalResult
Covariate-Balancing Propensity Score estimator (Imai-Ratkovic 2014).
Fits a logistic propensity score whose inverse-probability weights balance the covariate means and reports the Hajek-weighted treatment effect.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Analysis data. Rows with a missing outcome, treatment or covariate are dropped. |
required |
y
|
str
|
Outcome column. |
required |
treat
|
str
|
Treatment column, coded 0/1. |
required |
covariates
|
list of str
|
Covariates of the propensity model; they are also the balanced moments. Categorical columns are expanded to indicators. |
required |
estimand
|
('ATE', 'ATT')
|
|
'ATE'
|
variant
|
('exact', 'over')
|
|
'exact'
|
n_bootstrap
|
int
|
Nonparametric bootstrap replications for the standard error (the whole CBPS fit is repeated on each resample). With fewer than two replications the standard error, p-value and interval are NaN. |
500
|
alpha
|
float
|
Level of the normal-approximation confidence interval. |
0.05
|
seed
|
int
|
Seed of the bootstrap resampling. |
None
|
add_intercept
|
bool
|
Add a constant to the propensity model. With |
True
|
trim
|
float
|
When positive, propensity scores are clipped to
|
0.0
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Raises:
| Type | Description |
|---|---|
MethodIncompatibility
|
Unknown |
DataInsufficient
|
One treatment arm is empty after dropping missing rows. |
Examples:
>>> import numpy as np, pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> x = rng.normal(size=400)
>>> d = rng.binomial(1, 1 / (1 + np.exp(-0.5 * x)))
>>> df = pd.DataFrame({"y": 1 + 2 * d + x + rng.normal(size=400),
... "d": d, "x": x})
>>> res = sp.cbps(df, y="y", treat="d", covariates=["x"],
... variant="exact", n_bootstrap=50, seed=1)
>>> abs(res.model_info["std_mean_diff_after"]["x"]) < 1e-8
True
References
[@imai2014covariate]
Clean-room implementation; the reference (R CBPS 0.24, GPL >= 2)
was used only as a black box.
sp.genmatch(...)¶
genmatch ¶
Genetic Matching (Diamond & Sekhon 2013).
The generalised distance is
.. math::
d_W(x_i, x_j) = (x_i - x_j)^\top S_d^{-1/2}\, W\, S_d^{-1/2} (x_i - x_j),
where :math:S_d is the diagonal matrix of full-sample covariate
variances and :math:W is a diagonal weight matrix found by a genetic
(evolutionary) search that maximises the minimum across-covariate
balance p-value (Kolmogorov-Smirnov + t-tests, following the Matching
R package).
:math:S_d is diagonal, not the full covariance: that is the metric
Matching::Match(Weight = 3, Weight.matrix = W) implements, and it is
verified pair-for-pair against that function in
tests/reference_parity/test_matching_r_parity.py. The genetic search
itself is stochastic and is not reproducible across languages, so what is
pinned is the deterministic matching kernel given a supplied W.
Outputs
- the optimal weight vector,
- matched treated-control pair indices,
- a
balancetable of standardised mean differences pre/post match, - the ATT estimate + its matched-pair standard error (see the warning
under :class:
GenMatchResult— it is not a bootstrap).
References
Diamond, A. & Sekhon, J. S. (2013). "Genetic matching for estimating causal effects." Review of Economics and Statistics, 95(3), 932-945. [@diamond2013genetic]
GenMatchResult
dataclass
¶
Bases: ResultProtocolMixin
Output of :func:sp.genmatch (Diamond-Sekhon genetic matching).
Holds the ATT estimate and its standard error, the optimal covariate
weight vector, the matched control indices, and a pre/post balance
table. Call .summary() for a formatted report.
.. warning::
att_se is the matched-pair standard error
sd(Y_t - Y_c) / sqrt(n_pairs), not a bootstrap — earlier
versions of this docstring said "bootstrap SE", which was never what
the code computed. It conditions on the realised match structure and
on the fitted covariate weights, and genetic matching matches with
replacement (on a typical run one control serves a dozen treated
units), so it ignores exactly the dependence that makes this
estimator's naive SE too small.
The same formula, measured on sp.match over 36 designs x 1000
replications (benchmarks/matching_se_coverage.py), runs
0.56-0.91x the true sampling SD and never reaches nominal coverage
(0.71-0.92 against a nominal 0.95). Treat att_se as a lower
bound; for inference that covers, bootstrap the whole pipeline or
use sp.match(se_method='abadie_imbens').
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 300
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> p = 1.0 / (1.0 + np.exp(-(0.5 * x1 - 0.5 * x2 - 0.5)))
>>> d = rng.binomial(1, p)
>>> y = 1.0 + 2.0 * d + x1 + x2 + rng.normal(size=n)
>>> df = pd.DataFrame({'y': y, 'd': d, 'x1': x1, 'x2': x2})
>>> res = sp.genmatch(df, y='y', treat='d', covariates=['x1', 'x2'],
... population_size=10, generations=5)
>>> isinstance(res, sp.GenMatchResult)
True
>>> res.n_treated
111
genmatch ¶
genmatch(data: DataFrame, y: str, treat: str, covariates: Sequence[str], k: int = 1, population_size: int = 40, generations: int = 20, mutation_rate: float = 0.2, alpha: float = 0.05, random_state: int = 42) -> GenMatchResult
Genetic Matching for ATT estimation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
|
required |
treat
|
str
|
Binary treatment indicator. |
required |
covariates
|
sequence of str
|
|
required |
k
|
int
|
Number of matches per treated unit. |
1
|
population_size
|
int
|
|
40
|
generations
|
int
|
|
20
|
mutation_rate
|
float
|
|
0.2
|
alpha
|
float
|
|
0.05
|
random_state
|
int
|
|
42
|
Returns:
| Type | Description |
|---|---|
GenMatchResult
|
|
Notes
Degenerate Kolmogorov-Smirnov balance tests fall back to p=1.0 with
a StatsPAIWarning; failures in the final balance table are
counted in result.detail['ks_test_failures'].
Examples:
Simulated observational data with two confounders (true ATT = 2):
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 300
>>> x1 = rng.normal(size=n)
>>> x2 = rng.normal(size=n)
>>> p = 1.0 / (1.0 + np.exp(-(0.5 * x1 - 0.5 * x2 - 0.5)))
>>> d = rng.binomial(1, p)
>>> y = 1.0 + 2.0 * d + x1 + x2 + rng.normal(size=n)
>>> df = pd.DataFrame({'y': y, 'd': d, 'x1': x1, 'x2': x2})
Small genetic-search settings keep the example fast; prefer the
defaults (population_size=40, generations=20) in practice:
sp.sbw(...)¶
sbw ¶
Stable Balancing Weights (Zubizarreta 2015, JASA).
Finds weights that minimise dispersion (e.g. variance, or KL divergence
from the uniform distribution) while imposing user-specified covariate
balance tolerances. Unlike entropy balancing, SBW allows approximate
balance via per-covariate tolerance δ_j, which is essential when
exact balance is infeasible or would blow up variance.
Formulation
For ATT estimation with treated group :math:\mathcal{T} and control
group :math:\mathcal{C}, solve
.. math::
\min_{w} \; \frac{1}{|\mathcal{C}|}\sum_{i \in \mathcal{C}} w_i^2
\text{s.t.} \quad
\left| \frac{1}{|\mathcal{T}|}\sum_{i \in \mathcal{T}} X_{ij}
- \sum_{i \in \mathcal{C}} w_i X_{ij} \right|
\;\le\; \delta_j \sigma_j \; \forall j,
\sum_{i \in \mathcal{C}} w_i = 1, \; w_i \ge 0.
The variance-minimising objective is equivalent to maximising effective
sample size :math:\mathrm{ESS}(w) = (\sum w_i)^2 / \sum w_i^2.
This complements :func:ebalance (exact balance, KL objective) and
:func:cbps (covariate-balancing propensity score) — together forming
the 2026 triumvirate of modern weighting estimators.
References
Zubizarreta, J.R. (2015). "Stable Weights that Balance Covariates for Estimation with Incomplete Outcome Data." Journal of the American Statistical Association, 110(511), 910-922. [@zubizarreta2015stable]
Wang, Y. and Zubizarreta, J.R. (2020). "Minimal dispersion approximately balancing weights: asymptotic properties and practical considerations." Biometrika, 107(1), 93-105. [@wang2020minimal]
SBWResult ¶
Bases: CausalResult
Stable balancing weights with a diagnostic panel.
Thin subclass of :class:CausalResult that attaches the weight
vector, effective sample size, and covariate balance table. Returned
by :func:sbw.
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage().iloc[:400].copy()
>>> res = sp.sbw(df, treat="union",
... covariates=["education", "experience", "tenure"],
... y="log_wage", delta=0.05)
>>> isinstance(res, sp.SBWResult)
True
>>> res.estimand
'ATT'
>>> list(res.balance.columns)
['mean_treated', 'mean_control', 'SMD_before', 'SMD_after']
sbw ¶
sbw(data: DataFrame, treat: str, covariates: List[str], y: Optional[str] = None, *, estimand: str = 'att', delta: Union[float, Sequence[float]] = 0.02, objective: str = 'variance', tolerance_scale: str = 'sd', include_squares: bool = False, alpha: float = 0.05, solver_options: Optional[dict] = None) -> SBWResult
Stable Balancing Weights (Zubizarreta 2015) with optional ATT/ATE treatment-effect estimation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
treat
|
str
|
Binary 0/1 treatment indicator column. |
required |
covariates
|
list of str
|
Columns whose means must be balanced. |
required |
y
|
str
|
Outcome column. If provided, a weighted ATT/ATE estimate with
HC-robust SE is attached to the returned :class: |
None
|
estimand
|
('att', 'ate', 'atc')
|
|
'att'
|
delta
|
float or sequence
|
Balance tolerance. With |
0.02
|
objective
|
('variance', 'entropy')
|
Dispersion objective. |
'variance'
|
tolerance_scale
|
('sd', 'raw', 'target', 'group')
|
Standard deviation |
'sd'
|
include_squares
|
bool
|
Also balance second-moments (w_j² columns). |
False
|
alpha
|
float
|
Significance level for inference on the outcome. |
0.05
|
solver_options
|
dict
|
Passed to |
None
|
Returns:
| Type | Description |
|---|---|
SBWResult
|
|
Examples:
sp.overlap_weights(...)¶
overlap_weights ¶
Overlap weights (Li, Morgan, Zaslavsky 2018).
Overlap weights target the "average treatment effect among the overlap population" (ATO) — those observations with genuine equipoise. The weight function is
w_i = 1 - e(X_i) for treated i (T_i=1)
w_i = e(X_i) for control i (T_i=0)
which is proportional to the "tilting" that minimises the variance of
the resulting weighted treatment-effect estimator subject to exact
covariate balance on the moments used to fit e(·) (Li et al. 2018,
Theorem 1 & 3). Overlap weights:
- are bounded in [0, 1] — no extreme weights from small propensity scores, so results are stable even with poor overlap;
- exactly balance the log-odds covariates when
e(X)is a logit fit; - target ATO, not ATE, and should be interpreted accordingly.
References
Li, F., Morgan, K.L., Zaslavsky, A.M. (2018). "Balancing Covariates via Propensity Score Weighting." JASA, 113(521), 390-400.
Li, F., Thomas, L.E., Li, F. (2019). "Addressing Extreme Propensity Scores via the Overlap Weights." American Journal of Epidemiology, 188(1), 250-257.
overlap_weights ¶
overlap_weights(data: DataFrame, y: str, treat: str, covariates: List[str], estimand: str = 'ATO', n_bootstrap: int = 500, alpha: float = 0.05, seed: Optional[int] = None, trim: float = 0.0) -> CausalResult
Overlap-weight (ATO) treatment effect estimator.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
Outcome column. |
required |
treat
|
str
|
Binary 0/1 treatment column. |
required |
covariates
|
list of str
|
Covariates for the logistic propensity-score model. |
required |
estimand
|
('ATO', 'ATE', 'ATT', 'ATC', 'matching', 'entropy')
|
Which generalized-weight scheme to use. All follow Li-Li-Li
(2019) Table 1; 'ATO' uses the overlap weights; 'matching'
uses the |
'ATO'
|
n_bootstrap
|
int
|
Paired-sample bootstrap replications for SE. |
500
|
alpha
|
float
|
|
0.05
|
seed
|
int
|
|
None
|
trim
|
float
|
Optional clip of pscore to |
0.0
|
Returns:
| Type | Description |
|---|---|
CausalResult
|
|
Examples:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> result = sp.overlap_weights(df, y='log_wage', treat='union',
... covariates=['education', 'experience',
... 'tenure'],
... n_bootstrap=100, seed=42)
>>> result.estimand, round(result.estimate, 3)
('ATO', 0.188)
>>> report = result.summary()
>>> ess = result.model_info['effective_sample_size']
>>> bool(0 < ess <= len(df))
True
sp.balance_diagnostics(...)¶
balance_diagnostics ¶
balance_diagnostics(data: DataFrame, treatment: str, covariates: List[str], weights: Optional[Union[ndarray, Series, str]] = None, ps: Optional[Union[ndarray, Series, str]] = None, method: str = 'logit', threshold: float = 0.1, sd_denom: str = 'weighted') -> BalanceDiagnosticsResult
Unified balance diagnostics for matching and weighting estimators.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Analysis frame. |
required |
treatment
|
str
|
Binary treatment indicator. |
required |
covariates
|
list of str
|
Covariates to audit. |
required |
weights
|
array - like or str
|
Observation weights after matching/weighting. If omitted, ATE
inverse-propensity weights are computed from |
None
|
ps
|
array - like or str
|
Propensity scores. If omitted, estimated with |
None
|
method
|
(logit, probit, gbm)
|
Propensity-score model when |
'logit'
|
threshold
|
float
|
Balance threshold for absolute standardized mean differences. |
0.1
|
sd_denom
|
(weighted, unweighted)
|
Standard deviation under the weighted mean difference.
|
'weighted'
|
Returns:
| Type | Description |
|---|---|
BalanceDiagnosticsResult
|
|
Notes
Weighted variances use sum(w (x - m)^2) / (sum(w) - sum(w^2) /
sum(w)), which does not depend on the scale of the weights and
reduces to the n - 1 variance for equal weights, so
weights=1 reproduces the unweighted columns exactly.
Examples:
With no weights, ATE inverse-propensity weights are computed from
the estimated propensity scores:
>>> import statspai as sp
>>> df = sp.cps_wage()
>>> bal = sp.balance_diagnostics(
... df, treatment='union',
... covariates=['education', 'experience', 'tenure'])
>>> bal.summary_stats['n_obs']
3000
>>> bool(bal.summary_stats['n_imbalanced_weighted']
... <= bal.summary_stats['n_imbalanced_raw'])
True
Typical post-estimation flow — audit your own weights and scores:
>>> import numpy as np
>>> ps = sp.propensity_score(
... df, 'union', ['education', 'experience', 'tenure'])
>>> w = np.where(df['union'] == 1, 1 / ps, 1 / (1 - ps))
>>> bal = sp.balance_diagnostics(
... df, treatment='union',
... covariates=['education', 'experience', 'tenure'],
... weights=w, ps=ps)
>>> bool(bal.summary_stats['effective_sample_size'] > 0)
True
sp.love_plot(...)¶
love_plot ¶
love_plot(data: Any, treatment: Optional[str] = None, covariates: Optional[List[str]] = None, weights: Optional[Union[ndarray, Series]] = None, threshold: float = 0.1, ps_method: str = 'logit', ax: Any = None, figsize: Tuple[float, Optional[float]] = (7, None), title: str = 'Covariate Balance (Love Plot)') -> Tuple[Any, Any]
Love plot: dot plot of standardized mean differences before/after.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Input data. |
required |
treatment
|
str
|
Binary treatment column. |
None
|
covariates
|
list of str
|
Covariate columns. |
None
|
weights
|
array - like
|
IPW or matching weights. If None, inverse-PS weights are computed. A missing weight counts as zero (an unmatched row). |
None
|
threshold
|
float
|
SMD threshold for the vertical dashed line (default 0.1). |
0.1
|
ps_method
|
str
|
PS estimation method for balance computation. |
'logit'
|
ax
|
matplotlib Axes
|
|
None
|
figsize
|
tuple
|
(width, height). Height defaults to 0.4 * n_covariates + 1. |
(7, None)
|
title
|
str
|
Plot title. |
'Covariate Balance (Love Plot)'
|
Returns:
| Type | Description |
|---|---|
(fig, ax) : tuple
|
|
Examples: