Skip to content

Commit cf07cde

Browse files
Polish neural causal workflow
Add validation diagnostics, plotting, and report exports for neural causal estimators. Signed-off-by: Codex <codex@openai.com>
1 parent b4ae593 commit cf07cde

11 files changed

Lines changed: 1376 additions & 70 deletions

File tree

src/statspai/__init__.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -927,6 +927,13 @@
927927
"TARNet",
928928
"CFRNet",
929929
"DragonNet",
930+
"neural_effects_frame",
931+
"neural_summary_frame",
932+
"neural_training_frame",
933+
"neural_causal_to_markdown",
934+
"neural_causal_to_html",
935+
"neural_causal_to_excel",
936+
"neural_causal_plot",
930937
# Causal Discovery
931938
"notears",
932939
"NOTEARS",
@@ -1502,10 +1509,20 @@ def _register_lazy(modname, *names):
15021509
"policy_weight_prte", "policy_weight_marginal",
15031510
"policy_weight_observed_prte",
15041511
)
1505-
_register_lazy("neural_causal",
1512+
_register_lazy("neural_causal.models",
15061513
"tarnet", "cfrnet", "dragonnet", "TARNet", "CFRNet", "DragonNet",
1514+
)
1515+
_register_lazy("neural_causal.gnn_causal",
15071516
"gnn_causal", "GNNCausalResult",
15081517
)
1518+
_register_lazy("neural_causal.exports",
1519+
"neural_effects_frame", "neural_summary_frame", "neural_training_frame",
1520+
"neural_causal_to_markdown", "neural_causal_to_html",
1521+
"neural_causal_to_excel",
1522+
)
1523+
_register_lazy("neural_causal.plots",
1524+
"neural_causal_plot",
1525+
)
15091526
_register_lazy("neural_causal.cevae",
15101527
"cevae", "CEVAE", "CEVAEResult",
15111528
)

src/statspai/__init__.pyi

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,6 +522,13 @@ from .neural_causal.models import TARNet as TARNet
522522
from .neural_causal.models import cfrnet as cfrnet
523523
from .neural_causal.models import dragonnet as dragonnet
524524
from .neural_causal.models import tarnet as tarnet
525+
from .neural_causal.exports import neural_causal_to_excel as neural_causal_to_excel
526+
from .neural_causal.exports import neural_causal_to_html as neural_causal_to_html
527+
from .neural_causal.exports import neural_causal_to_markdown as neural_causal_to_markdown
528+
from .neural_causal.exports import neural_effects_frame as neural_effects_frame
529+
from .neural_causal.exports import neural_summary_frame as neural_summary_frame
530+
from .neural_causal.exports import neural_training_frame as neural_training_frame
531+
from .neural_causal.plots import neural_causal_plot as neural_causal_plot
525532
from .nonparametric.kdensity import KDensityResult as KDensityResult
526533
from .nonparametric.kdensity import kdensity as kdensity
527534
from .nonparametric.lpoly import LPolyResult as LPolyResult

src/statspai/core/results.py

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1449,6 +1449,7 @@ def plot(self, type: str = 'auto', **kwargs):
14491449
- Staggered SCM → cohort-level ATT comparison
14501450
- Causal Impact → 3-panel (original + pointwise + cumulative)
14511451
- Matching → Love plot (covariate balance)
1452+
- Neural causal estimators → CATE distribution
14521453
- RD → coefplot (use ``rdplot()`` for binned scatter)
14531454
- Other → coefplot
14541455
"""
@@ -1488,6 +1489,10 @@ def plot(self, type: str = 'auto', **kwargs):
14881489
from ..matching.match import balanceplot
14891490
return balanceplot(self, **kwargs)
14901491

1492+
if self.model_info.get('neural_causal'):
1493+
from ..neural_causal.plots import neural_causal_plot
1494+
return neural_causal_plot(self, type='cate', **kwargs)
1495+
14911496
return self._coefplot(**kwargs)
14921497

14931498
# Explicit type overrides
@@ -1502,6 +1507,9 @@ def plot(self, type: str = 'auto', **kwargs):
15021507
if type == 'balance':
15031508
from ..matching.match import balanceplot
15041509
return balanceplot(self, **kwargs)
1510+
if type in ('cate', 'ite', 'effects', 'propensity', 'overlap', 'loss'):
1511+
from ..neural_causal.plots import neural_causal_plot
1512+
return neural_causal_plot(self, type=type, **kwargs)
15051513
return self._coefplot(**kwargs)
15061514

15071515
def _is_synth_result(self) -> bool:
@@ -1560,6 +1568,193 @@ def _coefplot(self, ax=None, figsize=(8, 5), **kwargs):
15601568
# Export
15611569
# ------------------------------------------------------------------
15621570

1571+
def to_markdown(self, path: Optional[str] = None,
1572+
digits: int = 4) -> str:
1573+
"""Render the causal result as Markdown.
1574+
1575+
Neural causal results delegate to the richer neural exporter,
1576+
which includes unit-level effects and training diagnostics.
1577+
Other causal results render the broom-style tidy table plus
1578+
scalar diagnostics.
1579+
"""
1580+
if self.model_info.get('neural_causal'):
1581+
from ..neural_causal.exports import neural_causal_to_markdown
1582+
return neural_causal_to_markdown(self, path=path, digits=digits)
1583+
1584+
tidy = self.tidy().round(digits)
1585+
glance = self.glance().round(digits)
1586+
parts = [
1587+
f"# {self.method}",
1588+
"",
1589+
"## Estimates",
1590+
tidy.to_markdown(index=False),
1591+
"",
1592+
"## Summary",
1593+
glance.to_markdown(index=False),
1594+
]
1595+
if self.detail is not None and len(self.detail) > 0:
1596+
parts.extend([
1597+
"",
1598+
"## Detail",
1599+
self.detail.round(digits).to_markdown(index=False),
1600+
])
1601+
text = "\n".join(parts) + "\n"
1602+
if path is not None:
1603+
from pathlib import Path
1604+
Path(path).write_text(text, encoding="utf-8")
1605+
return text
1606+
1607+
def to_html(self, path: Optional[str] = None,
1608+
digits: int = 4) -> str:
1609+
"""Render the causal result as an HTML report."""
1610+
if self.model_info.get('neural_causal'):
1611+
from ..neural_causal.exports import neural_causal_to_html
1612+
return neural_causal_to_html(self, path=path, digits=digits)
1613+
1614+
tidy = self.tidy().round(digits)
1615+
glance = self.glance().round(digits)
1616+
blocks = [
1617+
"<html><body>",
1618+
f"<h1>{_html_escape(self.method)}</h1>",
1619+
"<h2>Estimates</h2>",
1620+
tidy.to_html(index=False),
1621+
"<h2>Summary</h2>",
1622+
glance.to_html(index=False),
1623+
]
1624+
if self.detail is not None and len(self.detail) > 0:
1625+
blocks.extend([
1626+
"<h2>Detail</h2>",
1627+
self.detail.round(digits).to_html(index=False),
1628+
])
1629+
blocks.append("</body></html>")
1630+
html = "\n".join(blocks)
1631+
if path is not None:
1632+
from pathlib import Path
1633+
Path(path).write_text(html, encoding="utf-8")
1634+
return html
1635+
1636+
def to_excel(self, path: str, digits: int = 6) -> str:
1637+
"""Write a multi-sheet Excel workbook for the causal result."""
1638+
if self.model_info.get('neural_causal'):
1639+
from ..neural_causal.exports import neural_causal_to_excel
1640+
return neural_causal_to_excel(self, path, digits=digits)
1641+
1642+
with pd.ExcelWriter(path) as writer:
1643+
self.tidy().round(digits).to_excel(
1644+
writer, sheet_name="Estimates", index=False
1645+
)
1646+
self.glance().round(digits).to_excel(
1647+
writer, sheet_name="Summary", index=False
1648+
)
1649+
if self.detail is not None and len(self.detail) > 0:
1650+
self.detail.round(digits).to_excel(
1651+
writer, sheet_name="Detail", index=False
1652+
)
1653+
diagnostics = _filter_jsonable_scalars(self.model_info)
1654+
if diagnostics:
1655+
pd.DataFrame([diagnostics]).to_excel(
1656+
writer, sheet_name="Diagnostics", index=False
1657+
)
1658+
return path
1659+
1660+
def to_word(
1661+
self,
1662+
path: str,
1663+
digits: int = 4,
1664+
caption: Optional[str] = None,
1665+
) -> str:
1666+
"""Write a Word (``.docx``) report for the causal result.
1667+
1668+
Produces a publication-style three-block document:
1669+
1670+
1. Title (``caption`` or ``"<method> Results"``).
1671+
2. Estimates table (variables × coefficient/SE/t/p/CI), formatted
1672+
with stars (``* p<0.1, ** p<0.05, *** p<0.01``) and Times New
1673+
Roman 9-10pt typography matching :file:`output/_aer_style.py`.
1674+
3. Detail table (group/time/ATT or estimator-specific) if
1675+
``self.detail`` is non-empty.
1676+
4. Trailing notes paragraph: ``"Standard errors in parentheses.
1677+
Observations: N. Method: <method>."``.
1678+
1679+
Requires ``python-docx``.
1680+
"""
1681+
try:
1682+
from docx import Document
1683+
from docx.shared import Pt
1684+
from docx.enum.text import WD_ALIGN_PARAGRAPH
1685+
from docx.enum.table import WD_TABLE_ALIGNMENT
1686+
except ImportError as e: # pragma: no cover
1687+
raise ImportError(
1688+
"python-docx required for to_word(). "
1689+
"Install: pip install python-docx"
1690+
) from e
1691+
from ..output._aer_style import (
1692+
apply_word_booktab_rules,
1693+
style_word_table_typography,
1694+
add_word_notes_paragraph,
1695+
)
1696+
1697+
doc = Document()
1698+
title = caption or f"{self.method}{self.estimand} Estimates"
1699+
p = doc.add_paragraph()
1700+
run = p.add_run(title)
1701+
run.bold = True
1702+
run.font.size = Pt(12)
1703+
run.font.name = "Times New Roman"
1704+
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
1705+
1706+
def _write_df_to_table(df: pd.DataFrame) -> None:
1707+
n_rows = len(df) + 1
1708+
n_cols = len(df.columns) + 1
1709+
table = doc.add_table(rows=n_rows, cols=n_cols)
1710+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
1711+
table.autofit = True
1712+
table.rows[0].cells[0].text = ""
1713+
for j, col in enumerate(df.columns, 1):
1714+
table.rows[0].cells[j].text = str(col)
1715+
for i, (idx, row) in enumerate(df.iterrows()):
1716+
table.rows[i + 1].cells[0].text = str(idx)
1717+
for j, val in enumerate(row, 1):
1718+
if isinstance(val, float):
1719+
cell_text = f"{val:.{digits}f}" if np.isfinite(val) else ""
1720+
elif pd.isna(val):
1721+
cell_text = ""
1722+
else:
1723+
cell_text = str(val)
1724+
table.rows[i + 1].cells[j].text = cell_text
1725+
style_word_table_typography(
1726+
table, header_rows=(0,),
1727+
header_pt=10, body_pt=9,
1728+
align_first_col="left", align_data_cols="center",
1729+
)
1730+
apply_word_booktab_rules(table, header_top_idx=0, header_bot_idx=0)
1731+
1732+
# Estimates block — always present
1733+
tidy = self.tidy().round(digits).set_index(
1734+
self.tidy().columns[0]
1735+
) if "term" in self.tidy().columns or "estimand" in self.tidy().columns else \
1736+
self.tidy().round(digits)
1737+
_write_df_to_table(tidy)
1738+
1739+
# Detail block — group/time ATTs, etc.
1740+
if self.detail is not None and len(self.detail) > 0:
1741+
doc.add_paragraph().add_run("Detail").bold = True
1742+
detail_view = self.detail.round(digits)
1743+
if not detail_view.index.is_unique or detail_view.index.equals(
1744+
pd.RangeIndex(len(detail_view))
1745+
):
1746+
detail_view = detail_view.reset_index(drop=True)
1747+
_write_df_to_table(detail_view)
1748+
1749+
notes = (
1750+
"Standard errors in parentheses. "
1751+
"* p<0.1, ** p<0.05, *** p<0.01. "
1752+
f"Observations: {self.n_obs:,}. Method: {self.method}."
1753+
)
1754+
add_word_notes_paragraph(doc, notes)
1755+
doc.save(path)
1756+
return path
1757+
15631758
def to_latex(self, caption: Optional[str] = None,
15641759
label: Optional[str] = None) -> str:
15651760
"""Generate a LaTeX table of the results."""

src/statspai/neural_causal/__init__.py

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- **TARNet** : Treatment-Agnostic Representation Network (Shalit et al. 2017)
1111
- **CFRNet** : Counterfactual Regression with IPM regularisation (Shalit et al. 2017)
1212
- **DragonNet** : Targeted regularisation for causal estimation (Shi et al. 2019)
13+
- **CEVAE** : Causal effect variational autoencoder for latent confounding
1314
1415
All models require PyTorch:
1516
pip install statspai[neural] # or: pip install torch
@@ -25,22 +26,55 @@
2526
Advances in Neural Information Processing Systems (NeurIPS), 32. [@shi2019adapting]
2627
"""
2728

28-
from .models import (
29-
tarnet,
30-
cfrnet,
31-
dragonnet,
32-
TARNet,
33-
CFRNet,
34-
DragonNet,
35-
)
36-
from .gnn_causal import gnn_causal, GNNCausalResult
37-
3829
__all__ = [
3930
'tarnet',
4031
'cfrnet',
4132
'dragonnet',
4233
'TARNet',
4334
'CFRNet',
4435
'DragonNet',
36+
'cevae', 'CEVAE', 'CEVAEResult',
4537
'gnn_causal', 'GNNCausalResult',
38+
'neural_effects_frame',
39+
'neural_summary_frame',
40+
'neural_training_frame',
41+
'neural_causal_to_markdown',
42+
'neural_causal_to_html',
43+
'neural_causal_to_excel',
44+
'neural_causal_plot',
4645
]
46+
47+
_LAZY_ATTRS = {
48+
'tarnet': ('models', 'tarnet'),
49+
'cfrnet': ('models', 'cfrnet'),
50+
'dragonnet': ('models', 'dragonnet'),
51+
'TARNet': ('models', 'TARNet'),
52+
'CFRNet': ('models', 'CFRNet'),
53+
'DragonNet': ('models', 'DragonNet'),
54+
'cevae': ('cevae', 'cevae'),
55+
'CEVAE': ('cevae', 'CEVAE'),
56+
'CEVAEResult': ('cevae', 'CEVAEResult'),
57+
'gnn_causal': ('gnn_causal', 'gnn_causal'),
58+
'GNNCausalResult': ('gnn_causal', 'GNNCausalResult'),
59+
'neural_effects_frame': ('exports', 'neural_effects_frame'),
60+
'neural_summary_frame': ('exports', 'neural_summary_frame'),
61+
'neural_training_frame': ('exports', 'neural_training_frame'),
62+
'neural_causal_to_markdown': ('exports', 'neural_causal_to_markdown'),
63+
'neural_causal_to_html': ('exports', 'neural_causal_to_html'),
64+
'neural_causal_to_excel': ('exports', 'neural_causal_to_excel'),
65+
'neural_causal_plot': ('plots', 'neural_causal_plot'),
66+
}
67+
68+
69+
def __getattr__(name):
70+
"""Resolve optional PyTorch/sklearn-backed neural exports lazily."""
71+
if name in _LAZY_ATTRS:
72+
import importlib
73+
module_name, attr = _LAZY_ATTRS[name]
74+
mod = importlib.import_module(f'.{module_name}', package=__name__)
75+
obj = getattr(mod, attr)
76+
globals()[name] = obj
77+
return obj
78+
raise AttributeError(
79+
f"module 'statspai.neural_causal' has no attribute {name!r}"
80+
)

0 commit comments

Comments
 (0)