Exporting regression results¶
A one-page map of how StatsPAI turns a fitted model into a publication-quality table — LaTeX, Word, Excel, HTML, Markdown, Quarto — or an agent-native JSON payload. Everything below is reachable from a single import:
The design goal is symmetry: any result object exports the same way, and
a single canonical builder (sp.regtable) drives every format so the numbers
and styling never drift between LaTeX and Word.
1. The 30-second version¶
r = sp.regress("y ~ x + z", data=df) # an EconometricResults
r.to_latex(caption="Main results", label="tab:main") # -> LaTeX string
r.to_markdown() # -> GitHub-flavoured Markdown
r.to_html(path="table.html") # -> writes + returns the HTML
r.to_excel("table.xlsx") # -> styled .xlsx
r.to_word("table.docx") # -> AER/QJE-styled .docx
For two or more models side by side, use sp.regtable:
m_ols = sp.regress("wage ~ educ + exper", data=df)
m_iv = sp.iv("wage ~ (educ ~ dist) + exper", data=df)
tbl = sp.regtable(m_ols, m_iv,
model_labels=["OLS", "IV"],
template="aer")
print(tbl) # text in the terminal
tbl.to_latex() # LaTeX source
tbl.save("table1.docx") # format inferred from the extension
2. Single-model exports¶
Both result families expose the same export surface, so estimator choice never changes how you write the table out:
| Method | Returns | Notes |
|---|---|---|
.to_latex() |
str |
booktabs \begin{table} float; caption=, label= |
.to_html() |
str |
inline-styled <table> |
.to_markdown() |
str |
GFM; quarto=True for Quarto cross-refs |
.to_excel(path) |
str |
styled .xlsx (needs openpyxl) |
.to_word(path) |
str |
AER/QJE .docx (needs python-docx) |
.to_dict() |
dict |
JSON-safe agent payload |
.to_json() |
str |
json.dumps of to_dict() |
The string-returning methods (to_latex / to_html / to_markdown) take an
optional path= and write the file and return the string. to_excel /
to_word take the path as their first argument and return it.
Passing table options through¶
Single-model exports on EconometricResults are thin wrappers over
sp.regtable, so every regtable keyword passes straight through:
r.to_latex(
coef_labels={"educ": "Years of schooling", "exper": "Experience"},
keep=["educ", "exper"], # hide the intercept and controls
order=["exper", "educ"], # reorder rows
se_type="t", # t-stats instead of SE in parentheses
stars=True,
fmt="%.4f",
template="qje",
notes=["Robust standard errors."],
)
CausalResult(DiD / RD / IV-effect / synth …) exports are tuned to the design —to_latex()on asp.callaway_santanna(...)result renders the event-study / group-time grid rather than a plain coefficient column. The method names are identical; the layout is design-aware.
3. Multi-model tables: sp.regtable¶
sp.regtable(*models, ...) is the canonical builder. It accepts any mix of
EconometricResults, CausalResult, and duck-typed objects exposing
params / std_errors.
Models may be passed as separate arguments or as a single list — the
latter is what R's modelsummary takes, so it works here too:
sp.regtable(m1, m2) # varargs
sp.regtable([m1, m2]) # a list, same table
sp.etable([m1, m2]) # and on the sibling functions
A nested list still means something different to sp.regtable: each
inner list is a panel. Anything that cannot be tabulated raises
MethodIncompatibility naming the offending position — these functions
never return a silently empty table.
tbl = sp.regtable(
m1, m2, m3,
model_labels=["(1)", "(2)", "(3)"],
dep_var_labels=["Wage", "Wage", "Hours"],
coef_labels={"educ": "Schooling"},
keep=["educ", "exper"],
stats=["N", "R2", "adj_R2", "F"],
se_type="se", # "se" | "t" | "p" | "ci"
star_levels=(0.10, 0.05, 0.01),
template="aer",
title="Returns to schooling",
notes=["Standard errors in parentheses."],
)
Highlights (see sp.describe_function("regtable") for the full list):
- Journal presets —
template=one of"aer","qje","econometrica","restat","jf","aeja","jpe","restud". Each sets star levels, the SE-row label, default stats, and notes. - Auto-diagnostic rows — fixed-effect / cluster indicators, IV first-stage
F, DiD pre-trend p, RD bandwidth/kernel/poly are extracted automatically
(
diagnostics="auto"). - Fixed-effect level counts —
fixef_sizes=Trueemits# Firm,# Yearrows. - Robust SE at print time —
vcov="HC1"("HC0"/"HC2"/"HC3"/"robust") recomputes SE/t/p/CI for OLS without re-fitting. - Stacked SE specs —
multi_se={"Bootstrap SE": [se1, se2]}prints extra bracketed SE rows beneath the primary one. - Column spanners —
column_spanners=[("OLS", 2), ("IV", 2)]renders a grouping header (\multicolumn/colspan/cmidrule). - eform —
eform=Truereportsexp(b)(odds ratio / IRR / HR) with delta-method SE; pass a per-model list to mix columns.
Decimal places¶
Economics journals do not mandate a decimal count. What they do in practice is
scale precision to the estimate, and print a coefficient and its standard error
at the same decimal place. That is what fmt="auto" — the default — does:
sp.regtable(m) # adaptive precision (default)
sp.regtable(m, fmt="auto") # the same thing, spelled out
Each coefficient row gets one decimal count, chosen so that neither the estimate nor its standard error loses a significant digit, and both halves print at that count. A single table can then carry rows on completely different scales without either being rounded into meaninglessness:
A fixed fmt="%.3f" would have written the treatment effect as
2108.412 (471.938) — seven digits of false precision on a dollar
amount — and fmt="%.0f" would have flattened re75 to 0***.
To pin precision yourself:
| call | effect | borrowed from |
|---|---|---|
digits=3 |
3 decimal places | R modelsummary, stargazer |
fmt=3 |
identical to digits=3 |
— |
fmt="%.4f" |
any printf template | Stata esttab's b(%9.3f) |
fmt="r3" |
round to 3 decimals | R fixest::etable |
fmt="s3" |
3 significant digits | R fixest::etable |
fmt="auto" |
pair each estimate with its own SE (default) | StatsPAI |
se_fmt="%.2f" |
standard-error row only, deliberately unpaired | — |
stats_fmt="%.4f" |
R² / adj. R² / F rows and tests= footer statistics |
— |
The same vocabulary works everywhere. sp.regtable, sp.esttab,
sp.modelsummary, sp.sumstats, sp.mean_comparison, sp.outreg2,
sp.etable, sp.fast.etable, and every result-object surface —
.to_markdown(), .to_html(), .to_word(), .to_latex() and
.summary() — take precision the same way, so you never have to remember
which parameter a given exporter wanted:
sp.regtable(m1, m2, digits=3)
sp.sumstats(df, digits=3)
result.to_markdown(digits=3)
result.to_latex(digits=3)
result.summary(digits=3) # the console agrees with the exports
What you read on screen is what you get in the paper. .summary() used
to be pinned at six decimals while every export used adaptive precision,
so the same ATT could read 0.332635 in the terminal and 0.333 in the
table you pasted into LaTeX.
Fit statistics get their own rule. stats_fmt defaults to "stat":
three decimals, surrendered one at a time as the integer part grows past
six significant figures. R² stays 0.090 and a Wald F you pass in as
10.5 stays 10.500, but an F statistic of 538582.398 prints as
538,582 rather than at nine significant figures. Pass an explicit
stats_fmt="%.3f" for uniform decimals.
One deliberate exception: .to_excel() keeps numeric cells rounded to
six decimals rather than display strings. A spreadsheet gets sorted, charted
and recomputed — it is a data-interchange target, not a presentation surface.
fmt and digits are the same knob — passing both raises. Summary statistics
never follow fmt, because they sit on their own scale; N is always a
thousands-separated integer.
Two edges worth knowing:
siunitxdecimal-aligns a whole LaTeX column, so it cannot use per-row precision.to_latex(siunitx=True)collapses adaptive precision to the finest count any row asked for and drops thousands separators.- Values below the decimal ceiling escape to scientific notation rather
than printing
0.000000, so a nonzero estimate is never displayed as an exact zero.
Saving and the format matrix¶
tbl.to_text() tbl.to_latex() tbl.to_html()
tbl.to_markdown() tbl.to_quarto() tbl.to_dataframe()
tbl.to_excel("t.xlsx") tbl.to_word("t.docx")
tbl.save("t.tex") # extension dispatch (below)
save(path) and regtable(..., filename=path) pick the writer from the
extension:
| Extension | Writer |
|---|---|
.tex |
to_latex |
.html |
to_html |
.md |
to_markdown |
.qmd |
to_quarto |
.docx |
to_word |
.xlsx |
to_excel |
.csv |
to_dataframe().to_csv |
.json |
to_json (agent payload, below) |
4. Agent-native serialization¶
The package is agent-native: a rendered table is a first-class artifact an LLM tool loop can serialise, cache, and reason over without re-rendering.
to_dict() carries three layers:
- metadata —
model_labels,dep_var_labels,panel_labels,title,notes,template,se_type,stars/star_levels,requested_stats,coef_labels. table— the rendered cell grid (the formatted"2.067***"/"(0.074)"strings), as a list of{"term": ..., "<model label>": "<cell>"}records, withcolumns.models— the numeric truth per model: coefficientestimate,std_error,t_statistic,p_value,conf_low,conf_high, plus summarystatsanddepvar.
Use the models layer for machine reasoning, the table layer for faithful
re-display:
beta = payload["models"][0]["coefficients"]["educ"]["estimate"] # a float
tbl.to_json(indent=2) # str, round-trips through json
tbl.to_dict(renders=["latex"]) # also embed rendered strings
tbl.to_dict(renders=True) # latex + html + markdown + text
NaN / Inf are coerced to null, so json.dumps(tbl.to_dict()) is always
strict-valid JSON.
The payload is a faithful cache, not just a snapshot — from_dict rebuilds a
table that re-renders byte-identically (for tables built without multi_se /
eform / column_spanners / tests):
from statspai.output.regression_table import RegtableResult
cached = tbl.to_json() # store anywhere
again = RegtableResult.from_dict(json.loads(cached))
assert again.to_latex() == tbl.to_latex() # exact round-trip
Single results carry the same idea: sp.regress(...).to_dict(detail="agent")
adds violations / next_steps / suggested_functions for tool loops.
5. Multi-panel tables and document containers¶
For a stacked multi-panel layout (Main / Heterogeneity / Robustness / Placebo) — each argument is a list of models:
pt = sp.paper_tables(
main=[m1, m2],
robustness=[m3, m4],
template="aer",
)
pt.to_latex("paper_tables.tex")
To assemble several tables (and free text) into one document and export it as
a single .docx / .xlsx / .tex / .md / .html:
c = sp.collect()
c.add_regression(m1, m2, title="Table 1")
c.add_summary(df, title="Table 2: Descriptives")
c.save("appendix.docx")
See also the replication-pack guide for bundling
tables with provenance, Quarto cross-refs, great_tables, and CSL
bibliographies.
6. Coming from Stata or R¶
| Stata | R | StatsPAI |
|---|---|---|
esttab m1 m2 using t.tex |
modelsummary(list(m1, m2)) |
sp.regtable(m1, m2).save("t.tex") |
estout, cells(b se) |
stargazer(m1, m2) |
sp.regtable(m1, m2, se_type="se") |
outreg2 using t.xls |
texreg(list(m1, m2)) |
sp.regtable(m1, m2).to_excel("t.xlsx") |
eststo / esttab |
etable(m1, m2) (fixest) |
sp.regtable(m1, m2) |
esttab, star(* .1 ** .05) |
modelsummary(stars = c(...)) |
sp.regtable(..., star_levels=(0.1, 0.05, 0.01)) |
esttab, label |
coef_map = c(...) |
sp.regtable(..., coef_labels={...}) |
esttab, keep(x) |
coef_omit / coef_map |
sp.regtable(..., keep=["x"]) |
esttab, indicate("FE = ...") |
gof_map |
sp.regtable(..., fixef_sizes=True) |
eststo store + esttab |
gt() / kableExtra |
sp.regtable(...) + .to_dict() for agents |
Stata-flavoured (sp.eststo / sp.esttab) and R-flavoured
(sp.modelsummary) surfaces exist for muscle memory, but they now forward to
sp.regtable and emit a DeprecationWarning. New code should call
sp.regtable directly for full control over labels, templates, and SE
formats.
7. Coefficient plots¶
sp.coefplot(*models) returns a Matplotlib (fig, ax) forest plot — PNG / PDF
come free via fig.savefig:
fig, ax = sp.coefplot(m1, m2, model_names=["OLS", "IV"])
fig.savefig("coefplot.pdf") # or .png, .svg — any Matplotlib backend
For a LaTeX-native, vector, editable plot, sp.coefplot_tikz(*models) emits
pgfplots source (the same forest layout — one series per model, horizontal CI
error bars, zero reference line):
tikz = sp.coefplot_tikz(m1, m2, coef_labels={"x": "Treatment"},
level=0.95, standalone=True)
open("coefplot.tex", "w").write(tikz) # standalone=True → compiles as-is
8. What exports — and what doesn't¶
sp.regtable(result) is the universal exporter: it duck-types any result
exposing params / std_errors (multi-coefficient) or estimate / se
(single effect), so the same call works regardless of estimator. Verified
across the result families:
| Result class | Returned by (e.g.) | sp.regtable(r) |
|---|---|---|
EconometricResults |
regress / ols / iv / feols / logit / glm |
✅ |
CausalResult |
did / rd / dml / tmle / qreg |
✅ |
PanelResults |
panel |
✅ |
FrontierResult |
frontier |
✅ |
So you never learn a per-estimator export API — fit anything, then
sp.regtable(r).to_latex() / .to_dict() / .save(...).
Out of scope (by design). Some results are not coefficient tables and
carry their own visualisations or summaries instead: causal-forest
heterogeneous effects (.plot() of the CATE distribution), partial-
identification bounds (intervals, not point ± SE), and density / dose-
response curves. Forcing these into a coefficient column would misrepresent
them, so they are intentionally excluded from the table exporters. The
tests/test_export_surface_contract.py suite pins the universality claim for
the table-shaped results above.
9. LaTeX special characters¶
Cells, column headers and table notes are escaped for you. This matters
more than it sounds: a matching result's detail table has a
propensity_score row and mean_treated headers, and an unescaped _
stops pdflatex with ! Missing $ inserted. The star legend is escaped
too — a bare < in p<0.01 typesets as an inverted exclamation mark
under the default OT1 font encoding.
A caption= you supply is not escaped, because a caption is where
deliberate markup belongs:
The rule is that data is escaped and markup is not, and the two are told
apart by where they came from rather than by a flag — nothing in the
string beta_1 says whether it is a column name or the inside of
$\beta_1$. If you need markup in a cell, set escape=False on
sp.regtable and escape what you meant to yourself.
See also¶
sp.describe_function("regtable")— every parameter, inline.- Replication workflow — provenance + Quarto + CSL.
- Migrating from R — the full
fixest/didmap.