"""Results container for the ChangesInChanges (CiC) and QDiD estimators."""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import pandas as pd
_ESTIMATOR_TITLES = {
"cic": "Changes-in-Changes (Athey & Imbens 2006) Results",
"qdid": "Quantile Difference-in-Differences (QDiD) Results",
}
[docs]
@dataclass
class ChangesInChangesResults:
"""Results for :class:`~diff_diff.changes_in_changes.ChangesInChanges` and
:class:`~diff_diff.changes_in_changes.QDiD`.
The headline ``att``/``se``/``t_stat``/``p_value``/``conf_int`` fields carry
the mean effect; ``quantile_effects`` is a DataFrame with one row per
requested quantile (columns ``quantile``, ``qte``, ``se``, ``t_stat``,
``p_value``, ``conf_low``, ``conf_high``). All inference derives from the
bootstrap (replicate-SD standard errors, symmetric normal-approximation
intervals at level ``alpha``); with ``n_bootstrap=0`` every inference field
is NaN.
``q_lower``/``q_upper`` bound the point-identified interior quantile range
for unconditional CiC fits (Athey-Imbens eq. 17; NaN for QDiD and for
covariate fits, where the unconditional bounds are not the relevant
objects). ``sup_t_crit`` is the qte package's sup-t critical value for
uniform bands - computed at a FIXED 95% level regardless of ``alpha`` (qte
parity); see :meth:`uniform_bands`. ``covariates`` records the covariate
columns used by the conditional (quantile-regression) fit, or ``None``
for unconditional fits.
"""
att: float
se: float
t_stat: float
p_value: float
conf_int: Tuple[float, float]
quantile_effects: pd.DataFrame
q_lower: float
q_upper: float
sup_t_crit: float
n_obs: int
cell_sizes: Dict[str, int]
n_bootstrap: int
n_bootstrap_valid: int
panel: bool
estimator: str
quantiles: np.ndarray = field(repr=False)
alpha: float = 0.05
covariates: Optional[List[str]] = None
# ------------------------------------------------------------------
# convenience properties
# ------------------------------------------------------------------
@property
def is_significant(self) -> bool:
"""Whether the headline ATT is significant at level ``alpha`` (False on NaN)."""
return bool(np.isfinite(self.p_value) and self.p_value < self.alpha)
@property
def significance_stars(self) -> str:
"""Significance stars for the headline ATT p-value ('' when NaN)."""
from diff_diff.results import _get_significance_stars
return "" if not np.isfinite(self.p_value) else _get_significance_stars(self.p_value)
# ------------------------------------------------------------------
# uniform bands
# ------------------------------------------------------------------
# ------------------------------------------------------------------
# serialization
# ------------------------------------------------------------------
[docs]
def to_dict(self) -> Dict[str, Any]:
"""Flat headline dictionary (ATT inference, ranges, sizes, bootstrap metadata)."""
return {
"att": self.att,
"se": self.se,
"t_stat": self.t_stat,
"p_value": self.p_value,
"conf_int_lower": self.conf_int[0],
"conf_int_upper": self.conf_int[1],
"q_lower": self.q_lower,
"q_upper": self.q_upper,
"sup_t_crit": self.sup_t_crit,
"n_obs": self.n_obs,
"cell_sizes": dict(self.cell_sizes),
"n_bootstrap": self.n_bootstrap,
"n_bootstrap_valid": self.n_bootstrap_valid,
"panel": self.panel,
"estimator": self.estimator,
"alpha": self.alpha,
"covariates": list(self.covariates) if self.covariates else None,
"inference_method": "bootstrap" if self.n_bootstrap > 0 else "none",
}
[docs]
def to_dataframe(self, level: str = "quantiles") -> pd.DataFrame:
"""Return the quantile-effects table or the one-row ATT summary.
Parameters
----------
level : {"quantiles", "att"}
``"quantiles"`` (default) returns a copy of ``quantile_effects``;
``"att"`` returns a single-row frame with the headline fields.
"""
if level == "quantiles":
return self.quantile_effects.copy()
if level == "att":
return pd.DataFrame(
[
{
"att": self.att,
"se": self.se,
"t_stat": self.t_stat,
"p_value": self.p_value,
"conf_low": self.conf_int[0],
"conf_high": self.conf_int[1],
"n_obs": self.n_obs,
}
]
)
raise ValueError(f"level must be 'quantiles' or 'att', got '{level}'")
# ------------------------------------------------------------------
# text summary
# ------------------------------------------------------------------
[docs]
def summary(self) -> str:
"""Fixed-width text summary: headline ATT block plus the quantile-effects table."""
from diff_diff.results import _get_significance_stars
ci_pct = int(round((1 - self.alpha) * 100))
width = 88
bar = "=" * width
dash = "-" * width
def _fmt(x: Any, nd: int = 4) -> str:
try:
xf = float(x)
except (TypeError, ValueError):
return ""
return "" if np.isnan(xf) else f"{xf:.{nd}f}"
mode = "panel (unit block bootstrap)" if self.panel else "repeated cross-section"
cs = self.cell_sizes
lines = [
bar,
_ESTIMATOR_TITLES.get(self.estimator, "Distributional DiD Results").center(width),
bar,
f"Observations: {self.n_obs} Mode: {mode}",
(
f"Cells: control pre={cs.get('control_pre')}, "
f"control post={cs.get('control_post')}, "
f"treated pre={cs.get('treated_pre')}, "
f"treated post={cs.get('treated_post')}"
),
]
if self.covariates:
lines.append(
f"Covariates: {', '.join(self.covariates)} (conditional ranks via "
"per-cell linear quantile regression, 99-tau grid; qte xformla parity)"
)
if self.n_bootstrap > 0:
lines.append(
f"Inference: bootstrap ({self.n_bootstrap_valid}/{self.n_bootstrap} "
"valid replicates), replicate-SD SEs, normal-approximation intervals"
)
else:
lines.append("Inference: disabled (n_bootstrap=0); all inference fields are NaN")
if self.estimator == "cic" and np.isfinite(self.q_lower) and np.isfinite(self.q_upper):
lines.append(
f"Point-identified interior quantile range (eq. 17): "
f"({self.q_lower:.4f}, {self.q_upper:.4f})"
)
stars = "" if np.isnan(self.p_value) else _get_significance_stars(self.p_value)
lines.extend(
[
dash,
f"{'':>10} {'Estimate':>10} {'Std.Err':>10} {'t':>8} {'P>|t|':>8}"
f" [{ci_pct}% Conf. Int.]",
dash,
f"{'ATT':>10} {_fmt(self.att):>10} {_fmt(self.se):>10}"
f" {_fmt(self.t_stat, 2):>8} {_fmt(self.p_value, 3):>8}"
f" [{_fmt(self.conf_int[0]):>9}, {_fmt(self.conf_int[1]):>9}] {stars}",
"",
"Quantile treatment effects:",
dash,
]
)
for _, r in self.quantile_effects.iterrows():
p = r["p_value"]
row_stars = "" if pd.isna(p) else _get_significance_stars(float(p))
lines.append(
f"{r['quantile']:>10.2f} {_fmt(r['qte']):>10} {_fmt(r['se']):>10}"
f" {_fmt(r['t_stat'], 2):>8} {_fmt(r['p_value'], 3):>8}"
f" [{_fmt(r['conf_low']):>9}, {_fmt(r['conf_high']):>9}] {row_stars}"
)
lines.append(bar)
lines.append("Signif. codes: *** p<0.001, ** p<0.01, * p<0.05")
return "\n".join(lines)
[docs]
def print_summary(self) -> None:
"""Print :meth:`summary` to stdout."""
print(self.summary())
def __repr__(self) -> str:
att_s = "nan" if np.isnan(self.att) else f"{self.att:.4f}"
se_s = "nan" if np.isnan(self.se) else f"{self.se:.4f}"
return (
"ChangesInChangesResults("
f"estimator={self.estimator!r}, ATT={att_s}, SE={se_s}, "
f"n_quantiles={len(self.quantile_effects)}, "
f"panel={self.panel}, n_bootstrap={self.n_bootstrap})"
)
# QDiD shares the container; the ``estimator`` field distinguishes the two.
QDiDResults = ChangesInChangesResults