Skip to content

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(ax: Any = None) -> Any

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

E[kappa], the estimated share of compliers.

se_method str

'analytic' (influence function) or 'bootstrap'.

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

plot

plot(ax: Any = None) -> Any

Plot the complier QTE curve with its CI band. Returns (fig, ax).

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

summary

summary() -> str

Print and return a formatted summary.

plot

plot(ax: Any = None) -> Any

Plot the DTE curve with CI band. Returns (fig, ax).

plot_cdf

plot_cdf(ax: Any = None) -> Any

Plot treated vs. counterfactual CDFs. Returns (fig, ax).

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 Y ~ X and D ~ X LASSO selections.

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

plot

plot(ax: Any = None) -> Any

Plot the QTE curve with its CI band. Returns (fig, ax).

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

to_frame() -> DataFrame

Tidy per-quantile table, including the uniform band when present.

test_no_effect

test_no_effect(kind: str = 'ks', n_boot: int = 1000, seed: int = 0)

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_constant_effect(kind: str = 'ks', n_boot: int = 1000, seed: int = 0)

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

plot(ax: Any = None) -> Any

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:dist_iv returns.

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 (0.1, 0.25, 0.5, 0.75, 0.9).

None
alpha float
0.05
method ('canay', 'dummy_fe', 'pooled')

See the module docstring. 'canay' assumes the individual effect is a pure location shift and needs a reasonably long panel.

'canay'
lasso_alpha float

Penalty on standardised covariates. None uses the Belloni-Chernozhukov-Hansen plug-in penalty.

None
se ('bootstrap', 'none')

'bootstrap' resamples units, preserving within-unit dependence. There is no analytic option: Canay's two-step variance depends on the first step, and a naive quantile-regression SE would understate it.

'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 [0.1, 0.25, 0.5, 0.75, 0.9].

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])