"""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
from diff_diff.results_base import BaseResults
_ESTIMATOR_TITLES = {
"cic": "Changes-in-Changes (Athey & Imbens 2006) Results",
"qdid": "Quantile Difference-in-Differences (QDiD) Results",
}
[docs]
@dataclass
class ChangesInChangesResults(BaseResults):
"""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