nb8.3 — Publication-Quality Tables & Figures: Turning Maya’s Frozen Results into a Paper

Week 8, Chapter 8.3. Companion notebook.

In Chapter 8.3 you learned the single sentence the whole craft turns on: a paper is not a transcript of what you did; it is an argument for why a reader should believe what you found. A referee reads the tables first — finds the headline number, checks the standard errors, scans the fixed-effects rows — and only then, skeptically, reads your prose. So the tables and figures are not decoration you add at the end. They are the load-bearing object, and they must stand alone: a reader who sees only the table should be able to recover the sample, the estimator, what the coefficient means, what the stars mean, and what fixed effects and clustering were used, without leaving the table.

This notebook does exactly what §8 of the chapter promised: it takes Maya’s frozen difference-in-differences result — the one she ran once after filing her pre-analysis plan (nb7.5), the one she stress-tested in nb8.2 — and generates publication-quality output from it. We will:

  1. Rebuild Maya’s synthetic county-year fair-lending panel (the same DGP as nb7.5, so the numbers match).
  2. Estimate a sequence of specifications, building up controls and fixed effects across columns.
  3. Render a publication-style regression table to LaTeX (via pyfixest’s etable) and to a readable text version — coefficients with clustered SEs in parentheses, significance stars, “Yes/No” fixed-effects rows, N and R^2 at the bottom, and a complete table note.
  4. Build an event-study figure (coefficients with CIs against event time, reference line at k=-1) and a coefficient plot, saved as files.
  5. Make the “table stands alone” principle concrete, and point at the binding standard in Appendix D.

You will not hand-format a single table. The notebook does it, reproducibly, from the frozen results — which is the whole point. A retyped table can silently drift from the numbers it claims to report; a generated table cannot.

import matplotlib
matplotlib.use("Agg")  # headless: render to buffers/files, never to a screen

import os
import tempfile
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

import pyfixest as pf

rng = np.random.default_rng(42)  # one generator, pinned, drives the whole notebook

plt.rcParams["figure.figsize"] = (9, 5)
plt.rcParams["axes.grid"] = True
plt.rcParams["grid.alpha"] = 0.3
plt.rcParams["font.size"] = 11

# All .tex / .png artifacts go to a throwaway directory: the notebook OWNS its outputs,
# so re-running is clean and nothing leaks into the repo.
OUTDIR = tempfile.mkdtemp(prefix="nb83_")
print("scratch output dir:", OUTDIR)
print("numpy   ", np.__version__)
print("pandas  ", pd.__version__)
print("pyfixest", pf.__version__)

1. Rebuild Maya’s panel (same DGP as nb7.5)

We regenerate the exact synthetic panel Maya froze in nb7.5, so this notebook’s headline number reproduces hers: a planted true ATT of \tau = -1.80 percentage points (the exam programs narrow the minority–white denial gap). The structure is a staggered difference-in-differences:

  • Unit is a county, observed once per year, 2014–2023 (a balanced panel).
  • 30 states are the clusters — treatment varies at the state level (a state adopts an examination program, and all its counties turn on together), so the honest standard error clusters by state.
  • Adoption is staggered: 8 states adopt in 2018, 7 in 2020, and 15 never adopt (clean controls).
  • Each county carries a permanent level \alpha_i (its baseline gap); each year carries a common shock \lambda_t (national mortgage conditions). Two named controls — standardized applicant income and ltv (loan-to-value) — shift the raw gap and stand in for applicant composition.

The data-generating process, exactly as the chapter’s identifying-equation form prescribes (outcome, treatment, controls, two-way fixed effects):

\text{gap}_{ct} = \alpha_c + \lambda_t + \tau\,\text{post}_{ct} + \gamma_{\text{inc}}\,\text{income}_{ct} + \gamma_{\text{ltv}}\,\text{ltv}_{ct} + \varepsilon_{ct}.

# --- Planted truth and panel dimensions (verbatim from nb7.5) ----------------
TAU_TRUE   = -1.80          # programs NARROW the gap by 1.8 pp -- the effect the table must recover
YEARS      = list(range(2014, 2024))   # 10 years
N_STATES   = 30             # 30 states -> 30 clusters (treatment varies at the STATE level)
COUNTIES_PER_STATE = 8      # 240 counties, 2,400 county-year rows
GAMMA_INCOME = -0.9         # higher applicant income -> smaller raw gap
GAMMA_LTV    =  1.2         # higher loan-to-value     -> larger  raw gap
SIGMA        =  0.8         # idiosyncratic county-year noise (sd)

# Half the states adopt; adoption YEAR is staggered across two cohorts.
adopt_cohort = {}                       # state -> year program takes effect (np.nan = never)
for s in range(N_STATES):
    if   s < 8:   adopt_cohort[s] = 2018
    elif s < 15:  adopt_cohort[s] = 2020
    else:         adopt_cohort[s] = np.nan      # never-adopters = clean controls

state_level = {s: rng.normal(0.0, 1.0) for s in range(N_STATES)}        # state mean shift in baseline gap
year_shock  = {t: 0.10 * (t - 2014) + rng.normal(0.0, 0.3) for t in YEARS}  # common national shocks

rows = []
for s in range(N_STATES):
    G = adopt_cohort[s]
    for c in range(COUNTIES_PER_STATE):
        county_id = s * COUNTIES_PER_STATE + c
        alpha_i   = state_level[s] + rng.normal(0.0, 0.7)   # county permanent level
        for t in YEARS:
            income = rng.normal(0.0, 1.0)                   # standardized log applicant income
            ltv    = rng.normal(0.0, 1.0)                   # standardized loan-to-value
            post_treat = 1 if (not np.isnan(G) and t >= G) else 0
            gap = (alpha_i
                   + year_shock[t]
                   + TAU_TRUE * post_treat
                   + GAMMA_INCOME * income
                   + GAMMA_LTV * ltv
                   + rng.normal(0.0, SIGMA))
            rows.append({
                "county": county_id, "state": s, "year": t,
                "cohort_G": G, "post_treat": post_treat,
                "income": income, "ltv": ltv, "gap": gap,   # gap = minority-white denial gap, pp
            })

panel = pd.DataFrame(rows)
panel["adopter"] = panel["cohort_G"].notna().astype(int)

print(f"county-year rows : {len(panel):,}")
print(f"states (clusters): {panel['state'].nunique()}")
print(f"adopting states  : {panel.loc[panel['adopter'] == 1, 'state'].nunique()}"
      f"   never-adopters: {panel.loc[panel['adopter'] == 0, 'state'].nunique()}")
print(f"PLANTED true ATT : {TAU_TRUE:+.2f} pp")
panel.head()

2. A sequence of specifications: building up the columns

A publication table tells a story across its columns. The reader watches the coefficient as you add structure, and the story you want is: the estimate is not an artifact of one lucky specification. We build four columns, each adding one layer, with the cluster-robust standard error held to the level treatment varies (state) throughout:

Col Specification What it adds
(1) gap ~ post_treat raw DiD-ish comparison, no structure
(2) + year FE strips out common national shocks \lambda_t
(3) + county + year FE the two-way fixed-effects DiD; absorbs every fixed county level \alpha_c
(4) + income + ltv controls the frozen primary spec from Maya’s PAP (nb7.5)

Column (4) is the pre-registered specification — the one Maya filed before looking. The other columns exist to show the reader how the estimate behaves as you build toward it, not to let her pick the prettiest one after the fact (that would be the garden of forking paths from nb8.1). The headline lives in column (4); the rest is supporting evidence for its stability.

# Build the four columns. CLUSTER BY STATE everywhere (the level treatment varies).
m1 = pf.feols("gap ~ post_treat",                      data=panel, vcov={"CRV1": "state"})
m2 = pf.feols("gap ~ post_treat | year",               data=panel, vcov={"CRV1": "state"})
m3 = pf.feols("gap ~ post_treat | county + year",      data=panel, vcov={"CRV1": "state"})
m4 = pf.feols("gap ~ post_treat + income + ltv | county + year",
              data=panel, vcov={"CRV1": "state"})   # <- the frozen primary spec (nb7.5)

models = [m1, m2, m3, m4]

# Quick look at the headline coefficient as we build up the columns.
print(f"{'column':>8}  {'beta(post)':>11}  {'cluster SE':>11}")
for j, m in enumerate(models, start=1):
    b  = float(m.coef()["post_treat"])
    se = float(m.se()["post_treat"])
    print(f"{j:>8}  {b:>11.3f}  {se:>11.3f}")

print(f"\nPlanted truth: {TAU_TRUE:+.2f} pp"
      f"   |   Column (4) = frozen primary spec from nb7.5 (expect beta ~ -1.886, SE ~ 0.065)")

The coefficient sits near -1.8 in every column and tightens as fixed effects soak up the noise that inflated the early standard errors. Column (4) recovers \hat\beta \approx -1.886 with a clustered SE of about 0.065 — exactly the frozen number from Maya’s nb7.5 run. That stability across columns is itself a piece of evidence: the headline is not an artifact of which controls happened to be in the regression.

Now we turn these four fitted objects into the artifact a referee actually reads.

3. The publication regression table → LaTeX

pyfixest.etable takes a list of fitted models and builds a multi-column table. We ask it for the things Appendix D requires a table to carry:

  • coefficients with their clustered SE in parentheses directly beneath (coef_fmt="b \n (se)");
  • significance stars at the conventional thresholds, defined in the note (signif_code=[0.01, 0.05, 0.10]);
  • a fixed-effects block that shows which FE each column uses (show_fe=True) — the “Yes/No” rows;
  • N and R^2 in dedicated bottom rows (etable adds these automatically);
  • human-readable variable labels instead of raw column names;
  • column headers that name what each specification adds;
  • a complete table note stating the dependent variable, the units, the estimator, the flavor of standard error (clustered by state), and what the stars mean.

type="tex" returns a LaTeX string (it uses booktabs rules and a threeparttable note block — exactly the layout Appendix D asks for). We discipline the digits to three decimals: a coefficient is not more precise for carrying six.

LABELS = {
    "post_treat": "Exam program (post)",
    "income":     "Applicant income (std.)",
    "ltv":        "Loan-to-value (std.)",
}
HEADS = ["(1) OLS", "(2) +Year FE", "(3) +County&Year FE", "(4) Primary"]

TABLE_NOTE = (
    "Dependent variable: county-year minority-white mortgage-denial gap, in percentage points. "
    "Sample: 2,400 county-year observations, 240 counties in 30 states, 2014-2023. "
    "Estimator: two-way fixed-effects difference-in-differences (treatment switches on when a county's "
    "state adopts a fair-lending examination program). Standard errors in parentheses are clustered by "
    "state (30 clusters). Stars: * p<0.10, ** p<0.05, *** p<0.01. "
    "Column (4) is the pre-registered primary specification."
)

tex_table = pf.etable(
    models,
    type="tex",
    labels=LABELS,
    model_heads=HEADS,
    show_fe=True,
    coef_fmt="b \n (se)",          # estimate on top, SE in parentheses beneath
    signif_code=[0.01, 0.05, 0.10],  # *** ** * thresholds (defined in the note)
    notes=TABLE_NOTE,
)

# Confirm we really produced a LaTeX tabular with booktabs rules.
assert "tabular" in tex_table, "etable did not emit a LaTeX tabular environment"
assert ("toprule" in tex_table) or ("booktabs" in tex_table), "no booktabs rules in the LaTeX table"

tex_path = os.path.join(OUTDIR, "table1_main.tex")
with open(tex_path, "w") as fh:
    fh.write(tex_table)

print("LaTeX regression table written to:", tex_path)
print("=" * 72)
print(tex_table)

That string is the table — generated, never retyped. In a real manuscript you would \input{table1_main.tex} in your LaTeX source, so the number in the paper is mechanically the number from the regression. There is no opportunity for a transcription error, and no way for the table to drift from the analysis when you re-run with one more year of data.

A few things to notice in the LaTeX above, because they are the Appendix D requirements made concrete: the \begin{threeparttable} wrapper attaches the note to the table (so it travels with it); the \toprule / \midrule / \bottomrule are booktabs rules (no vertical lines, no double horizontals — the clean style every finance journal uses); the FE rows sit in their own block between rules; and N and R^2 are in dedicated bottom rows. The note carries the four things a stand-alone table must declare: the dependent variable and its units, the estimator, the standard-error flavor (clustered by state), and the star thresholds.

A readable text/markdown version

LaTeX is for the manuscript; while you are working, you want to read the table at a glance. The same call with type="df" returns a pandas DataFrame (great for the screen and for a Markdown export). It is the identical content, just rendered for human eyes rather than for pdflatex.

df_table = pf.etable(
    models,
    type="df",
    labels=LABELS,
    model_heads=HEADS,
    show_fe=True,
    coef_fmt="b (se)",
    signif_code=[0.01, 0.05, 0.10],
)

# Save a Markdown copy alongside the .tex (same content, human-readable rendering).
md_path = os.path.join(OUTDIR, "table1_main.md")
with open(md_path, "w") as fh:
    fh.write("Table 1. Effect of fair-lending exam programs on the denial gap\n\n")
    fh.write(df_table.to_markdown(index=False))
    fh.write("\n\nNote: " + TABLE_NOTE + "\n")

print("readable table written to:", md_path, "\n")
with pd.option_context("display.max_colwidth", 40, "display.width", 120):
    print(df_table.to_string(index=False))

Same numbers, two renderings, one source. The text version is what you paste into a Slack message to your advisor; the LaTeX version is what goes into the paper. Crucially, neither was typed by hand, so they cannot disagree with each other or with the regression.

4. The event-study figure: coefficients vs event time

The regression table reports a single ATT. But the credibility of a DiD lives in the dynamics: are the pre-treatment leads flat (consistent with parallel trends), and does the effect turn on at adoption? That is what an event-study plot shows — one coefficient per event time k = t - G_s, measured relative to the omitted reference period k = -1.

We build the relative-event-time variable, park the never-adopters at the reference period (so they form the comparison and drop out of the displayed coefficients), and bin the endpoints at \pm 4 so the far leads and lags do not split into sparse, noisy cells. Then we estimate

\text{gap}_{ct} = \sum_{k \neq -1} \beta_k \,\mathbb{1}[t - G_s = k] + \gamma_{\text{inc}}\,\text{income}_{ct} + \gamma_{\text{ltv}}\,\text{ltv}_{ct} + \alpha_c + \lambda_t + \varepsilon_{ct},

with pyfixest’s i(reltime, ref=-1) syntax, clustering by state.

# Relative event time; never-adopters parked at the reference period (-1) so they are the comparison.
panel_es = panel.copy()
panel_es["reltime"] = np.where(
    panel_es["adopter"] == 1,
    panel_es["year"] - panel_es["cohort_G"],
    -1,                                   # controls sit at the reference -> drop out of displayed betas
)
# Bin the endpoints so far leads/lags do not fragment into sparse cells.
panel_es["reltime"] = panel_es["reltime"].clip(-4, 4).astype(int)

event = pf.feols(
    "gap ~ i(reltime, ref=-1) + income + ltv | county + year",
    data=panel_es, vcov={"CRV1": "state"},
)

# Pull the event-time coefficients into a tidy frame (drop the named controls).
es = event.tidy().reset_index().rename(columns={"Coefficient": "term"})
es = es[es["term"].str.startswith("reltime::")].copy()
es["k"] = es["term"].str.replace("reltime::", "", regex=False).astype(int)
es = es.sort_values("k").reset_index(drop=True)
es = es.rename(columns={"Estimate": "beta", "2.5%": "lo", "97.5%": "hi"})
print(es[["k", "beta", "Std. Error", "lo", "hi"]].round(3).to_string(index=False))

Read those numbers before plotting them: the pre-treatment leads (k = -4, -3, -2) are all small and their confidence intervals comfortably straddle zero — the visual signature of parallel pre-trends. At k = 0 the coefficient drops to roughly -1.9 pp and stays there through k = +4. That is a textbook clean event study, and the figure should make a referee see it in two seconds.

# Hand-built event-study figure: point estimates + 95% CIs vs event time, reference line at k = -1.
fig, ax = plt.subplots(figsize=(8.5, 5))

ax.errorbar(
    es["k"], es["beta"],
    yerr=[es["beta"] - es["lo"], es["hi"] - es["beta"]],
    fmt="o", color="black", ecolor="0.35", elinewidth=1.4,
    capsize=3, markersize=5, label="Point estimate (95% CI)",
)
ax.axhline(0.0, color="0.5", linewidth=1.0)                 # zero line
ax.axvline(-1.0, color="0.5", linestyle="--", linewidth=1.0)  # reference period k = -1
ax.annotate("reference\nperiod (k=-1)", xy=(-1, 0.2), xytext=(-3.6, 0.55),
            fontsize=9, color="0.3",
            arrowprops=dict(arrowstyle="->", color="0.4", lw=1))

ax.set_xlabel("Event time  $k = t - G_s$  (years relative to program adoption)")
ax.set_ylabel("Effect on minority-white denial gap (pp)")
ax.set_title("Event study: denial-gap effect by years since fair-lending exam adoption")
ax.set_xticks(sorted(es["k"].tolist()))
ax.legend(loc="lower left", framealpha=0.9)
fig.tight_layout()

es_path = os.path.join(OUTDIR, "fig_event_study.png")
fig.savefig(es_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print("event-study figure saved to:", es_path)
print("pre-trend leads near zero; sharp drop to ~-1.9 pp at k=0, stable thereafter.")

Notice the figure-craft choices, which are Appendix D’s figure rules made concrete: it shows point estimates and confidence intervals (a dot with no whisker hides the precision); it labels both axes in real units (years; percentage points), not “x” and “y”; it marks the reference period explicitly so the reader knows where the normalization sits; and it is drawn in black-and-gray so it survives being printed in grayscale or photocopied — color is never load-bearing. A reader who sees only this figure, with no caption, can still read the design off it.

pyfixest also ships a one-liner, iplot, that produces a serviceable version of the same plot directly from the fitted model. It is convenient for a quick look; the hand-built version above gives you full control over the reference line, annotations, and grayscale styling that a publication figure needs.

# The library one-liner, for comparison. Returns a matplotlib Figure we can save.
fig_iplot = pf.iplot(
    [event],
    figsize=(8.5, 4.8),
    title="Event study via pyfixest.iplot (quick-look version)",
    drop=["income", "ltv"],   # show only the event-time coefficients, not the controls
)
# iplot labels its axes generically; set publication labels/units after the fact.
ax_ip = fig_iplot.axes[0]
ax_ip.set_xlabel("Event time  k = t - G  (years relative to adoption)")
ax_ip.set_ylabel("Effect on denial gap (pp)")
iplot_path = os.path.join(OUTDIR, "fig_event_study_iplot.png")
fig_iplot.savefig(iplot_path, dpi=150, bbox_inches="tight")
print("pyfixest iplot figure saved to:", iplot_path)

5. The coefficient plot: the headline across specifications

A second figure that earns its place in a paper is the coefficient plot — the single key coefficient (post_treat) shown with its confidence interval across the four specifications. It is the visual twin of the regression table’s top row: it lets a reader see the stability of the estimate as controls and fixed effects are added, without parsing four columns of numbers. Where the table proves the point to a careful reader, the coefficient plot proves it to a skimming one.

# Collect the post_treat coefficient + 95% CI from each of the four models.
rows = []
for head, m in zip(HEADS, models):
    ci = m.confint().loc["post_treat"]
    rows.append({
        "spec": head,
        "beta": float(m.coef()["post_treat"]),
        "lo":   float(ci.iloc[0]),
        "hi":   float(ci.iloc[1]),
    })
coef_df = pd.DataFrame(rows)
print(coef_df.round(3).to_string(index=False))

fig, ax = plt.subplots(figsize=(8.5, 4.5))
ypos = np.arange(len(coef_df))[::-1]   # first spec at top
ax.errorbar(
    coef_df["beta"], ypos,
    xerr=[coef_df["beta"] - coef_df["lo"], coef_df["hi"] - coef_df["beta"]],
    fmt="o", color="black", ecolor="0.35", elinewidth=1.4, capsize=3, markersize=6,
)
ax.axvline(TAU_TRUE, color="0.5", linestyle="--", linewidth=1.0,
           label=f"planted truth ({TAU_TRUE:+.1f} pp)")
ax.set_yticks(ypos)
ax.set_yticklabels(coef_df["spec"])
ax.set_xlabel("Coefficient on 'Exam program (post)'  (pp), with 95% CI")
ax.set_title("Coefficient stability across specifications")
ax.legend(loc="lower right", framealpha=0.9)
fig.tight_layout()

coef_path = os.path.join(OUTDIR, "fig_coef_plot.png")
fig.savefig(coef_path, dpi=150, bbox_inches="tight")
plt.close(fig)
print("\ncoefficient plot saved to:", coef_path)

All four intervals sit on the same side of zero and cluster around the dashed line at the planted truth. The story the figure tells in one glance — the estimate is stable and negative no matter which structure you impose — is the same story the table tells in four columns of numbers. That redundancy is deliberate: the table convinces the referee who reads carefully, the figure convinces the one who only skims.

6. The “table stands alone” test

Chapter 8.3 §8 gave you the acid test, and it is the third reflection prompt of your Your-Turn: cover up the surrounding prose and ask whether a reader can recover everything from the table and its note alone. Let us run that test on Table 1, mechanically, by listing each question a referee asks and pointing at where the table answers it. If any answer were “you have to read the paragraph,” the table would have failed.

checklist = [
    ("What is the dependent variable, and in what units?",
     "Note: 'minority-white mortgage-denial gap, in percentage points.'"),
    ("What is the sample (units, span, count)?",
     "Note: '2,400 county-year obs, 240 counties in 30 states, 2014-2023' + Observations row."),
    ("What is the estimator / research design?",
     "Note: 'two-way fixed-effects difference-in-differences ...'."),
    ("What does the key coefficient mean?",
     "Row label 'Exam program (post)' + units in the note: pp change in the gap when a program is active."),
    ("What do the numbers in parentheses represent?",
     "Note: 'Standard errors in parentheses are clustered by state (30 clusters).'"),
    ("What do the stars mean?",
     "Note: '* p<0.10, ** p<0.05, *** p<0.01.'"),
    ("Which fixed effects does each column use?",
     "The FE block rows (county / year) show which column includes which FE."),
    ("Which column is the pre-registered headline?",
     "Note: 'Column (4) is the pre-registered primary specification.'"),
]

print("STAND-ALONE AUDIT OF TABLE 1")
print("=" * 78)
for q, where in checklist:
    print(f"Q: {q}\n   -> {where}\n")

# Mechanical proof that the note actually carries the load-bearing phrases.
for needed in ["percentage points", "clustered by state", "p<0.05",
               "difference-in-differences", "pre-registered"]:
    assert needed in TABLE_NOTE, f"table note is missing required disclosure: {needed!r}"
print("PASS: every stand-alone question is answered by the table + note (no prose required).")

Every question is answered without leaving the table. That is what “stands alone” means in practice, and it is the difference between a table a referee trusts on first read and one that sends them hunting through your prose — already a little annoyed with you.

Disciplined significant digits

One more craft point worth making explicit. Throughout, coefficients and SEs carry three decimal places and never more. A denial-gap effect reported as -1.886 pp is honest; the same number written as -1.88634 pp is false precision — it implies a measurement accuracy the data cannot support, and a careful reader reads those trailing digits as a tell that the author does not understand their own standard errors. Pick a sensible number of significant figures for the kind of quantity (a few decimals for a coefficient, whole numbers for N, two or three for R^2) and hold it across the whole table.

7. Where the binding standard lives — and your Turn

This notebook showed the mechanics: generate the table and figures from the frozen results, render to LaTeX, and make the artifacts stand alone. The exact, binding standard — precise table layout, what belongs in the note versus the main text, how to report R^2 versus within-R^2, how many digits for which kind of number, the precise fixed-effects-and-cluster disclosure lines — lives in Appendix D (book/appendices/D-style-guide/): D.1 covers table layout and notes; D.2 covers which coefficients to report and the FE/cluster/sample disclosure; D.3 covers robustness sections; D.4 the replication packet; D.5 prose style and causal-language discipline. Treat Appendix D as the spec for your manuscript, and let the notebook — not a text editor — build every table you put in the paper.

A discipline to carry forward: the tables in your paper are generated from the analysis, never retyped. Re-run with a corrected sample or one more year of data, and the LaTeX regenerates; the number in the paper can never silently drift from the number in the regression. That single habit prevents the most embarrassing and most common error in empirical work — a table that no longer matches the code that supposedly produced it.


Your Turn

  1. Add a fifth column. Re-estimate column (4) but cluster by county instead of state, add it as column (5) to etable, and update the note to disclose the new clustering. Does the SE change in the direction nb8.2 led you to expect? Is your note still complete for the new column?
  2. Report within-R^2 vs R^2. A two-way FE model’s overall R^2 is dominated by the fixed effects. Look up how pyfixest reports goodness-of-fit, decide which R^2 honestly describes the explanatory power of your regressors in a FE model, and adjust the table (and note) so the reader is not misled by a huge but uninformative R^2. (Appendix D.2 has the rule.)
  3. Break, then fix, the stand-alone test. Delete the standard-error-flavor sentence from TABLE_NOTE, regenerate the LaTeX, and hand the bare table to a classmate. Ask them: clustered on what? Watch them fail to answer. Then put the sentence back. That failure is exactly the one Appendix D’s note rules exist to prevent.