statspai.qte¶
qte ¶
Quantile Treatment Effects (QTE) module for StatsPAI.
Provides estimators for: - Quantile DID (Athey & Imbens 2006) — DID at each quantile - QTE via Quantile Regression (Firpo 2007) — conditional QTE with controls - QTE via Distribution — propensity-score reweighting approach
References
Athey, S. & Imbens, G. W. (2006). Identification and Inference in Nonlinear Difference-in-Differences Models. Econometrica, 74(2), 431-497. [@athey2006identification]
Firpo, S. (2007). Efficient Semiparametric Estimation of Quantile Treatment Effects. Econometrica, 75(1), 259-276. [@firpo2007efficient]
BeyondAverageResult
dataclass
¶
Bases: ResultProtocolMixin
Distributional LATE on compliers.
Returned by :func:beyond_average_late; holds the per-quantile complier
LATE, its bootstrap SE / CI, and the estimated complier share.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 600
>>> z = rng.integers(0, 2, n)
>>> d = ((0.2 + 0.6 * z + rng.normal(0, 0.3, n)) > 0.5).astype(int)
>>> y = 1.0 + 1.0 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d, "z": z})
>>> res = sp.beyond_average_late(
... df, y="y", treat="d", instrument="z",
... quantiles=np.array([0.25, 0.5, 0.75]), n_boot=50)
>>> isinstance(res, sp.BeyondAverageResult)
True
>>> res.late_q.round(2).tolist()
[0.98, 1.01, 1.14]
>>> round(res.complier_share, 2)
0.69
plot ¶
Plot the complier LATE curve with its CI band. Returns (fig, ax).
DistIVResult
dataclass
¶
Bases: ResultProtocolMixin
Distributional IV: complier LATE per quantile.
Returned by :func:dist_iv. Holds the complier quantile treatment effect
at each requested quantile with standard errors and confidence intervals,
the estimated complier share, and which SE method produced them.
Attributes:
| Name | Type | Description |
|---|---|---|
quantiles |
ndarray
|
Probability levels. |
late_q |
ndarray
|
Complier QTE at each level. |
se_q, ci_low, ci_high |
ndarray
|
Standard errors and confidence bounds. |
complier_share |
float
|
|
se_method |
str
|
|
n_obs |
int
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 4000
>>> z = rng.integers(0, 2, n)
>>> d = ((0.3 + 0.5 * z + rng.normal(0, 0.3, n)) > 0.5).astype(int)
>>> y = 1.0 + 1.0 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d, "z": z})
>>> res = sp.dist_iv(df, y="y", treat="d", instrument="z",
... quantiles=np.array([0.25, 0.5, 0.75]))
>>> isinstance(res, sp.DistIVResult)
True
>>> bool(np.all(np.abs(res.late_q - 1.0) < 0.3)) # true LATE = 1.0
True
DTEResult ¶
Bases: ResultProtocolMixin
Container for distributional treatment effect estimates.
Returned by :func:distributional_te. Carries the DTE curve over a
grid, quantile treatment effects, treated/counterfactual CDFs, and a
Kolmogorov-Smirnov statistic for the null of no distributional effect.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 500
>>> d = rng.integers(0, 2, n)
>>> y = 1.0 + 1.5 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d})
>>> res = sp.distributional_te(
... df, y="y", treatment="d", method="ipw",
... quantiles=[0.25, 0.5, 0.75], n_boot=50, seed=42)
>>> isinstance(res, sp.DTEResult)
True
>>> bool(np.all(np.abs(res.qte_effects - 1.5) < 0.3)) # true effect 1.5
True
>>> bool(0.0 <= res.ks_pvalue <= 1.0 and 0.0 <= res.cvm_pvalue <= 1.0)
True
HDPanelQTEResult
dataclass
¶
Bases: ResultProtocolMixin
Panel QTE at multiple quantiles with high-dimensional control selection.
Returned by :func:qte_hd_panel.
Attributes:
| Name | Type | Description |
|---|---|---|
quantiles, qte, se, ci_low, ci_high |
ndarray
|
|
selected_controls |
list of str
|
Union of the |
n_obs, n_units, n_periods |
int
|
|
method, se_method |
str
|
|
diagnostics |
dict
|
Assumption flags, notably Canay's location-shift requirement and the panel length driving its bias. |
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> rows = []
>>> for u in range(80):
... ui = rng.normal(0, 0.5)
... treated = u >= 40
... for t in range(10):
... d = 1.0 if (treated and t >= 5) else 0.0
... x1, x2, x3 = rng.normal(0, 1, 3)
... y = 1.0 + 1.2 * d + 0.5 * x1 + ui + rng.normal(0, 1)
... rows.append((u, t, y, d, x1, x2, x3))
>>> df = pd.DataFrame(
... rows, columns=["unit", "time", "y", "d", "x1", "x2", "x3"])
>>> res = sp.qte_hd_panel(
... df, y="y", treat="d", unit="unit", time="time",
... covariates=["x1", "x2", "x3"],
... quantiles=np.array([0.25, 0.5, 0.75]), se="none")
>>> isinstance(res, sp.HDPanelQTEResult)
True
>>> bool(np.all(np.abs(res.qte - 1.2) < 0.4)) # true effect 1.2
True
QTEResult ¶
Bases: ResultProtocolMixin
Container for quantile treatment effect estimates.
Attributes:
| Name | Type | Description |
|---|---|---|
quantiles |
ndarray
|
Quantile grid. |
effects |
ndarray
|
QTE point estimates. |
se |
ndarray
|
Bootstrap / analytical standard errors. |
ci_lower, ci_upper |
ndarray
|
Confidence interval bounds. |
ate |
float
|
Average treatment effect (for comparison). |
method |
str
|
Estimation method label. |
n_obs |
int
|
Sample size. |
alpha |
float
|
Significance level. |
Examples:
A :class:QTEResult is produced by estimators such as :func:qte and
:func:qdid; inspect the quantile grid, per-quantile effects and the
mean ATE:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(0)
>>> n = 300
>>> d = rng.integers(0, 2, n)
>>> y = 1.0 + 1.5 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d})
>>> res = sp.qte(df, y="y", treatment="d",
... quantiles=[0.25, 0.5, 0.75], n_boot=50, seed=0)
>>> isinstance(res, sp.QTEResult)
True
>>> res.quantiles.tolist()
[0.25, 0.5, 0.75]
>>> res.effects.shape
(3,)
to_frame ¶
Tidy per-quantile table, including the uniform band when present.
test_no_effect ¶
Test QTE(tau) = 0 at EVERY tau against "somewhere non-zero".
Not what a row of pointwise p-values tests: with 19 quantiles at the
5% level, roughly one spurious rejection is expected under the null.
Requires se='analytic'.
test_constant_effect ¶
Test QTE(tau) is the same at every tau against "it varies".
Rejecting means treatment does something an average effect cannot express. Failing to reject means the ATE is an adequate summary.
plot ¶
QTE plot with CI bands and ATE reference line.
Returns (fig, ax).
beyond_average_late ¶
beyond_average_late(data: DataFrame, y: str, treat: str, instrument: str, quantiles: Optional[ndarray] = None, alpha: float = 0.05, n_boot: int = 200, seed: int = 0) -> BeyondAverageResult
Distributional LATE on compliers under imperfect compliance.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
|
required |
treat
|
str
|
|
required |
instrument
|
str
|
|
required |
quantiles
|
array - like
|
|
None
|
alpha
|
float
|
|
0.05
|
n_boot
|
int
|
|
200
|
seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
BeyondAverageResult
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 600
>>> z = rng.integers(0, 2, n)
>>> d = ((0.2 + 0.6 * z + rng.normal(0, 0.3, n)) > 0.5)
>>> d = d.astype(int)
>>> y = 1.0 + 1.0 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d, "z": z})
>>> res = sp.beyond_average_late(
... df, y="y", treat="d", instrument="z",
... quantiles=np.array([0.25, 0.5, 0.75]), n_boot=50)
>>> res.late_q.round(2).tolist() # complier LATE per quantile
[0.98, 1.01, 1.14]
>>> round(res.complier_share, 2)
0.69
kan_dlate ¶
kan_dlate(data: DataFrame, y: str, treat: str, instrument: str, covariates: Optional[List[str]] = None, quantiles: Optional[ndarray] = None, alpha: float = 0.05, n_boot: int = 200, seed: int = 0) -> DistIVResult
Deprecated alias for :func:dist_iv.
.. deprecated:: 1.21.0
This function never implemented a distinct estimator — it has always
forwarded to :func:dist_iv — and its docstring attributed the method
to two different authors ("Kennedy 2025" in the module header, "Shaw
2025" here) for the same arXiv ID. Verification against arXiv and the
DataCite DOI registry shows arXiv:2506.12765 is Model Risk in
Machine-Learning Distributional IV Estimation by Charles Shaw
alone, and neither its title nor its v1 abstract mentions a
Kolmogorov-Arnold network. Rather than keep a function that claims a
method it does not implement, use :func:dist_iv directly. Scheduled
for removal in 1.23.0; see MIGRATION.md.
Returns:
| Type | Description |
|---|---|
DistIVResult
|
Exactly what :func: |
Examples:
>>> import warnings
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 4000
>>> z = rng.integers(0, 2, n)
>>> d = ((0.3 + 0.5 * z + rng.normal(0, 0.3, n)) > 0.5).astype(int)
>>> y = 1.0 + 1.0 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d, "z": z})
>>> qs = np.array([0.25, 0.5, 0.75])
>>> with warnings.catch_warnings(): # deprecated: forwards to dist_iv
... warnings.simplefilter("ignore", DeprecationWarning)
... old = sp.kan_dlate(df, y="y", treat="d", instrument="z", quantiles=qs)
>>> new = sp.dist_iv(df, y="y", treat="d", instrument="z", quantiles=qs)
>>> bool(np.allclose(old.late_q, new.late_q)) # identical to dist_iv
True
distributional_te ¶
distributional_te(data: DataFrame, y: str, treatment: str, x: Optional[List[str]] = None, method: str = 'ipw', n_grid: int = 100, quantiles: Optional[List[float]] = None, n_boot: int = 500, alpha: float = 0.05, seed: Optional[int] = None) -> DTEResult
Estimate distributional treatment effects.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str — outcome column.
|
|
required |
treatment
|
str — treatment column (binary 0/1 for IPW/DR;
|
0-3 group encoding for CiC). |
required |
x
|
list[str], optional — covariates (required for DR).
|
|
None
|
method
|
('ipw', 'dr', 'cic')
|
|
'ipw'
|
n_grid
|
int — grid points for CDF evaluation.
|
|
100
|
quantiles
|
list[float] — QTE quantile indices.
|
|
None
|
n_boot
|
int — bootstrap replications.
|
|
500
|
alpha
|
float — significance level.
|
|
0.05
|
seed
|
int, optional — random seed.
|
|
None
|
Returns:
| Type | Description |
|---|---|
DTEResult
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 500
>>> d = rng.integers(0, 2, n)
>>> y = 1.0 + 1.5 * d + rng.normal(0, 1, n)
>>> df = pd.DataFrame({"y": y, "d": d})
>>> res = sp.distributional_te(
... df, y="y", treatment="d", method="ipw",
... quantiles=[0.25, 0.5, 0.75], n_boot=50, seed=42)
>>> bool(np.all(np.abs(res.qte_effects - 1.5) < 0.3)) # true effect 1.5
True
>>> bool(res.ks_stat > 0) # treated and counterfactual CDFs differ
True
qte_hd_panel ¶
qte_hd_panel(data: DataFrame, y: str, treat: str, unit: str, time: str, covariates: List[str], quantiles: Optional[ndarray] = None, alpha: float = 0.05, method: str = 'canay', lasso_alpha: Optional[float] = None, se: str = 'bootstrap', n_boot: int = 200, seed: int = 0) -> HDPanelQTEResult
Panel quantile treatment effects with high-dimensional controls.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Long-format panel. |
required |
y
|
str
|
|
required |
treat
|
str
|
|
required |
unit
|
str
|
|
required |
time
|
str
|
|
required |
covariates
|
list of str
|
Candidate control set; narrowed by double-selection LASSO. |
required |
quantiles
|
array - like
|
Defaults to |
None
|
alpha
|
float
|
|
0.05
|
method
|
('canay', 'dummy_fe', 'pooled')
|
See the module docstring. |
'canay'
|
lasso_alpha
|
float
|
Penalty on standardised covariates. |
None
|
se
|
('bootstrap', 'none')
|
|
'bootstrap'
|
n_boot
|
int
|
|
200
|
seed
|
int
|
|
0
|
Returns:
| Type | Description |
|---|---|
HDPanelQTEResult
|
|
Notes
.. versionchanged:: 1.21.0
Rebuilt. The previous implementation within-demeaned before running
quantile regression, selected controls from the outcome equation
only, and fabricated se = 0.1 on two fallback paths.
See MIGRATION.md.
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> rows = []
>>> for u in range(80):
... ui = rng.normal(0, 0.5)
... treated = u >= 40
... for t in range(10):
... d = 1.0 if (treated and t >= 5) else 0.0
... x1, x2, x3 = rng.normal(0, 1, 3)
... y = 1.0 + 1.2 * d + 0.5 * x1 + ui + rng.normal(0, 1)
... rows.append((u, t, y, d, x1, x2, x3))
>>> df = pd.DataFrame(
... rows, columns=["unit", "time", "y", "d", "x1", "x2", "x3"])
>>> res = sp.qte_hd_panel(
... df, y="y", treat="d", unit="unit", time="time",
... covariates=["x1", "x2", "x3"],
... quantiles=np.array([0.25, 0.5, 0.75]), se="none")
>>> bool(np.all(np.abs(res.qte - 1.2) < 0.4)) # true effect 1.2
True
References
canay2011simple, belloni2014inference, xu2025quantile
qdid ¶
qdid(data: DataFrame, y: str, group: str, time: str, quantiles: Optional[List[float]] = None, n_boot: int = 500, alpha: float = 0.05, seed: int = 42, method: str = 'qdid') -> Any
Quantile Difference-in-Differences (QDiD) and its alternatives.
.. warning::
**This is not Changes-in-Changes.** Versions <= 1.20.0 described and
labelled this function as Athey & Imbens (2006) CiC. It is not:
Athey & Imbens propose CiC *instead of* QDiD and criticise QDiD
directly, because differencing quantiles presumes the untreated
outcome distribution shifts by the same amount at every rank. R's
``qte`` package keeps ``QDiD()`` and ``CiC()`` separate for the same
reason. The numbers never changed -- only the attribution. Use
``method='cic'`` for changes-in-changes.
method='cic' delegates to :func:statspai.cic and returns its
CausalResult; qte::MDiD and ddid2 are not implemented.
QTE_DID(τ) = F_{11}^{-1}(τ) - F_{10}^{-1}(τ) - [F_{01}^{-1}(τ) - F_{00}^{-1}(τ)]
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
|
required |
y
|
str
|
Outcome variable. |
required |
group
|
str
|
Binary group indicator (0 = control, 1 = treated). |
required |
time
|
str
|
Binary time indicator (0 = pre, 1 = post). |
required |
quantiles
|
list of float
|
Defaults to |
None
|
n_boot
|
int
|
Bootstrap replications. |
500
|
alpha
|
float
|
Significance level. |
0.05
|
seed
|
int
|
Random seed. |
42
|
Returns:
| Type | Description |
|---|---|
QTEResult
|
|
Examples:
>>> import numpy as np
>>> import pandas as pd
>>> import statspai as sp
>>> rng = np.random.default_rng(42)
>>> n = 400
>>> g = rng.integers(0, 2, n)
>>> t = rng.integers(0, 2, n)
>>> y = (1.0 + 0.5 * g + 0.3 * t + 2.0 * g * t
... + rng.normal(0, 1, n))
>>> df = pd.DataFrame({"y": y, "g": g, "t": t})
>>> res = sp.qdid(df, y="y", group="g", time="t",
... quantiles=[0.25, 0.5, 0.75], n_boot=50)
>>> round(res.ate, 2) # true effect = 2.0
2.14
>>> np.round(res.effects, 2) # QTE at each quantile
array([1.96, 1.94, 2.26])