SPB Git forge

spb/wp4_uqo

Public
3commits 1branches 1releases
5.4 MBsize
maindefault branch
1 h agolast push
Python 64.6% TeX 23.3% HTML 11.6% Makefile 0.5%
25.8 KB · 892 lines python
Raw Blame History
1"""2tables.py - Publication-quality table generation for hedonic pricing analysis.34Generates LaTeX (booktabs), CSV, and HTML tables suitable for academic5publication. All tables follow APA / econometrics journal conventions:6three-line tables, significance stars, proper formatting of coefficients7and standard errors.89Author: Simon-Pierre Boucher10Date: 2026-05-2011"""1213from __future__ import annotations1415import logging16import os17from pathlib import Path18from typing import Any, Dict, List, Optional, Sequence, Tuple, Union1920import numpy as np21import pandas as pd2223logger = logging.getLogger(__name__)2425# ---------------------------------------------------------------------------26# Significance helpers27# ---------------------------------------------------------------------------2829_STAR_THRESHOLDS: List[Tuple[float, str]] = [30    (0.001, "***"),31    (0.01, "**"),32    (0.05, "*"),33]343536def _stars(pvalue: float) -> str:37    """Return significance stars for a p-value."""38    for threshold, symbol in _STAR_THRESHOLDS:39        if pvalue <= threshold:40            return symbol41    return ""424344def _fmt_coef(value: float, se: Optional[float] = None,45              pvalue: Optional[float] = None, decimals: int = 3) -> str:46    """Format a coefficient with optional standard error and stars."""47    s = f"{value:.{decimals}f}"48    if pvalue is not None:49        s += _stars(pvalue)50    if se is not None:51        s += f"\n({se:.{decimals}f})"52    return s535455# ---------------------------------------------------------------------------56# Table 1 - Summary statistics57# ---------------------------------------------------------------------------5859def summary_statistics_table(60    df: pd.DataFrame,61    cols: List[str],62) -> pd.DataFrame:63    """64    Table 1 -- Descriptive statistics.6566    Columns: Variable, Mean, Std. Dev., Min, P25, Median, P75, Max, N.6768    Parameters69    ----------70    df : pd.DataFrame71        Analysis dataset.72    cols : list[str]73        Numeric columns to summarise.7475    Returns76    -------77    pd.DataFrame78        Summary statistics table.79    """80    logger.info("Building summary statistics table for %d variables.", len(cols))8182    records: List[Dict[str, Any]] = []83    for col in cols:84        s = df[col].dropna()85        records.append({86            "Variable": col,87            "Mean": round(s.mean(), 3),88            "Std. Dev.": round(s.std(), 3),89            "Min": round(s.min(), 3),90            "P25": round(s.quantile(0.25), 3),91            "Median": round(s.median(), 3),92            "P75": round(s.quantile(0.75), 3),93            "Max": round(s.max(), 3),94            "N": int(s.count()),95        })9697    return pd.DataFrame(records).set_index("Variable")9899100# ---------------------------------------------------------------------------101# Table 2 - Price distribution by room / property type102# ---------------------------------------------------------------------------103104def price_by_room_type_table(105    df: pd.DataFrame,106    price_col: str = "price_numeric",107    room_col: str = "property_type",108    type_col: Optional[str] = None,109) -> pd.DataFrame:110    """111    Table 2 -- Price distribution by room type (and optionally property type).112113    Parameters114    ----------115    df : pd.DataFrame116        Must contain *price_col* and *room_col*.117    price_col : str118        Numeric price column.119    room_col : str120        Room / property type column.121    type_col : str, optional122        Additional grouping column (e.g., property_type).123124    Returns125    -------126    pd.DataFrame127        Grouped statistics.128    """129    logger.info("Building price-by-room-type table.")130131    group_cols = [room_col]132    if type_col is not None:133        group_cols.append(type_col)134135    agg = (136        df.dropna(subset=[price_col])137        .groupby(group_cols, observed=True)[price_col]138        .agg(139            Mean="mean",140            Median="median",141            Std="std",142            Min="min",143            Max="max",144            N="count",145        )146        .round(2)147        .reset_index()148    )149    return agg150151152# ---------------------------------------------------------------------------153# Table 3 - Correlation matrix154# ---------------------------------------------------------------------------155156def correlation_matrix_table(157    df: pd.DataFrame,158    cols: List[str],159    method: str = "pearson",160) -> pd.DataFrame:161    """162    Table 3 -- Pairwise correlation matrix.163164    Lower triangle only, with significance stars computed via t-test165    approximation.166167    Parameters168    ----------169    df : pd.DataFrame170    cols : list[str]171    method : str172        'pearson' or 'spearman'.173174    Returns175    -------176    pd.DataFrame177        Formatted correlation matrix (lower triangle with stars).178    """179    from scipy import stats as sp_stats180181    logger.info("Building correlation matrix table (%s).", method)182183    sub = df[cols].dropna()184    n = len(sub)185    corr = sub.corr(method=method)186187    formatted = corr.copy().astype(str)188    for i, row_var in enumerate(cols):189        for j, col_var in enumerate(cols):190            if j > i:191                formatted.iloc[i, j] = ""192            elif i == j:193                formatted.iloc[i, j] = "1.000"194            else:195                r = corr.iloc[i, j]196                # t-test for correlation significance197                if abs(r) < 1.0:198                    t_stat = r * np.sqrt((n - 2) / (1 - r ** 2))199                    p = 2 * sp_stats.t.sf(abs(t_stat), df=n - 2)200                else:201                    p = 0.0202                formatted.iloc[i, j] = f"{r:.3f}{_stars(p)}"203204    return formatted205206207# ---------------------------------------------------------------------------208# Table 4/5 - Regression comparison209# ---------------------------------------------------------------------------210211def regression_comparison_table(212    results_dict: Dict[str, Any],213    stat_keys: Optional[List[str]] = None,214) -> pd.DataFrame:215    """216    Table 4/5 -- Side-by-side regression results with significance stars.217218    Parameters219    ----------220    results_dict : dict221        ``{model_label: result}`` where each *result* is either:222        - a ``statsmodels`` results object (with ``.params``, ``.bse``,223          ``.pvalues``, ``.rsquared``, ``.nobs``), OR224        - a plain dict ``{variable: {'coef': float, 'se': float, 'pvalue': float}, ...}``225          with optional top-level keys ``'R2'``, ``'Adj_R2'``, ``'N'``, ``'AIC'``, ``'BIC'``.226227    stat_keys : list[str], optional228        Extra bottom-panel statistics to include.229230    Returns231    -------232    pd.DataFrame233        Variables in rows, models in columns.234    """235    logger.info("Building regression comparison table (%d models).", len(results_dict))236237    all_vars: List[str] = []238    parsed: Dict[str, Dict[str, Dict[str, float]]] = {}239    model_stats: Dict[str, Dict[str, Any]] = {}240241    for label, res in results_dict.items():242        if isinstance(res, dict):243            # Plain dict input244            coefs = {245                k: v for k, v in res.items()246                if isinstance(v, dict) and "coef" in v247            }248            parsed[label] = coefs249            model_stats[label] = {250                k: v for k, v in res.items() if not isinstance(v, dict)251            }252            for v in coefs:253                if v not in all_vars:254                    all_vars.append(v)255        else:256            # statsmodels result object257            coefs = {}258            for var in res.params.index:259                coefs[var] = {260                    "coef": res.params[var],261                    "se": res.bse[var],262                    "pvalue": res.pvalues[var],263                }264                if var not in all_vars:265                    all_vars.append(var)266            parsed[label] = coefs267            ms: Dict[str, Any] = {"N": int(getattr(res, "nobs", 0))}268            if hasattr(res, "rsquared"):269                ms["R2"] = round(res.rsquared, 4)270            if hasattr(res, "rsquared_adj"):271                ms["Adj_R2"] = round(res.rsquared_adj, 4)272            if hasattr(res, "aic"):273                ms["AIC"] = round(res.aic, 1)274            if hasattr(res, "bic"):275                ms["BIC"] = round(res.bic, 1)276            model_stats[label] = ms277278    # Build coefficient rows (coef + se rows interleaved)279    rows: List[Dict[str, str]] = []280    for var in all_vars:281        coef_row: Dict[str, str] = {"Variable": var}282        se_row: Dict[str, str] = {"Variable": ""}283        for label in results_dict:284            if var in parsed[label]:285                c = parsed[label][var]286                coef_row[label] = f"{c['coef']:.3f}{_stars(c.get('pvalue', 1.0))}"287                se_row[label] = f"({c.get('se', 0.0):.3f})"288            else:289                coef_row[label] = ""290                se_row[label] = ""291        rows.append(coef_row)292        rows.append(se_row)293294    # Bottom panel: model statistics295    default_stat_keys = ["N", "R2", "Adj_R2", "AIC", "BIC"]296    use_keys = stat_keys if stat_keys is not None else default_stat_keys297    for key in use_keys:298        stat_row: Dict[str, str] = {"Variable": key}299        for label in results_dict:300            val = model_stats.get(label, {}).get(key, "")301            if isinstance(val, float):302                stat_row[label] = f"{val:.4f}" if val < 10 else f"{val:.1f}"303            else:304                stat_row[label] = str(val) if val != "" else ""305        rows.append(stat_row)306307    out = pd.DataFrame(rows)308    out = out.set_index("Variable")309    return out310311312# ---------------------------------------------------------------------------313# Table 6 - Amenity premiums314# ---------------------------------------------------------------------------315316def amenity_premiums_table(317    results: Any,318    amenity_vars: Optional[List[str]] = None,319) -> pd.DataFrame:320    """321    Table 6 -- Amenity coefficients converted to percentage premiums.322323    For a semi-log model ln(price) = Xb, the percentage premium of a324    dummy variable is (exp(b) - 1) * 100.325326    Parameters327    ----------328    results : statsmodels result or dict329        Regression results.330    amenity_vars : list[str], optional331        If None, all variables containing 'amenity' or common amenity332        keywords are selected.333334    Returns335    -------336    pd.DataFrame337        Amenity, Coefficient, Std. Error, % Premium, Significance.338    """339    logger.info("Building amenity premiums table.")340341    if isinstance(results, dict):342        data = results343    else:344        params = results.params345        bse = results.bse346        pvals = results.pvalues347        data = {348            v: {"coef": params[v], "se": bse[v], "pvalue": pvals[v]}349            for v in params.index350        }351352    _amenity_keywords = [353        "amenity", "pool", "wifi", "parking", "kitchen", "gym",354        "air_conditioning", "ac", "washer", "dryer", "tv",355        "hot_tub", "fireplace", "elevator", "pet", "breakfast",356    ]357358    if amenity_vars is None:359        amenity_vars = [360            v for v in data361            if any(kw in v.lower() for kw in _amenity_keywords)362        ]363364    records = []365    for var in amenity_vars:366        info = data[var]367        coef = info["coef"]368        se = info.get("se", np.nan)369        pval = info.get("pvalue", np.nan)370        pct_premium = (np.exp(coef) - 1) * 100371        records.append({372            "Amenity": var,373            "Coefficient": round(coef, 4),374            "Std. Error": round(se, 4),375            "% Premium": round(pct_premium, 2),376            "Significance": _stars(pval) if not np.isnan(pval) else "",377        })378379    out = pd.DataFrame(records).sort_values("% Premium", ascending=False)380    return out.reset_index(drop=True)381382383# ---------------------------------------------------------------------------384# Table 7 - Host reputation385# ---------------------------------------------------------------------------386387def host_reputation_table(388    results: Any,389    host_vars: Optional[List[str]] = None,390) -> pd.DataFrame:391    """392    Table 7 -- Host and review coefficients.393394    Parameters395    ----------396    results : statsmodels result or dict397    host_vars : list[str], optional398399    Returns400    -------401    pd.DataFrame402    """403    logger.info("Building host reputation table.")404405    _host_keywords = [406        "host", "superhost", "review", "rating", "response",407        "experience", "verified", "identity", "listing_count",408        "quality_score", "is_superhost", "num_reviews",409        "is_guest_favorite",410    ]411412    if isinstance(results, dict):413        data = results414    else:415        params = results.params416        bse = results.bse417        pvals = results.pvalues418        data = {419            v: {"coef": params[v], "se": bse[v], "pvalue": pvals[v]}420            for v in params.index421        }422423    if host_vars is None:424        host_vars = [425            v for v in data426            if any(kw in v.lower() for kw in _host_keywords)427        ]428429    records = []430    for var in host_vars:431        info = data[var]432        coef = info["coef"]433        se = info.get("se", np.nan)434        pval = info.get("pvalue", np.nan)435        records.append({436            "Variable": var,437            "Coefficient": round(coef, 4),438            "Std. Error": round(se, 4),439            "p-value": round(pval, 4) if not np.isnan(pval) else "",440            "Significance": _stars(pval) if not np.isnan(pval) else "",441            "% Effect": round((np.exp(coef) - 1) * 100, 2),442        })443444    return pd.DataFrame(records)445446447# ---------------------------------------------------------------------------448# Table 8 - Location gradient449# ---------------------------------------------------------------------------450451def location_gradient_table(452    results: Any,453    location_vars: Optional[List[str]] = None,454) -> pd.DataFrame:455    """456    Table 8 -- Distance-to-centre effects.457458    Parameters459    ----------460    results : statsmodels result or dict461    location_vars : list[str], optional462463    Returns464    -------465    pd.DataFrame466    """467    logger.info("Building location gradient table.")468469    _loc_keywords = [470        "distance", "dist_", "km", "center", "centre", "cbd",471        "downtown", "lat", "long", "longitude", "latitude",472        "neighbourhood", "neighborhood", "city",473    ]474475    if isinstance(results, dict):476        data = results477    else:478        params = results.params479        bse = results.bse480        pvals = results.pvalues481        data = {482            v: {"coef": params[v], "se": bse[v], "pvalue": pvals[v]}483            for v in params.index484        }485486    if location_vars is None:487        location_vars = [488            v for v in data489            if any(kw in v.lower() for kw in _loc_keywords)490        ]491492    records = []493    for var in location_vars:494        info = data[var]495        coef = info["coef"]496        se = info.get("se", np.nan)497        pval = info.get("pvalue", np.nan)498        records.append({499            "Variable": var,500            "Coefficient": round(coef, 4),501            "Std. Error": round(se, 4),502            "p-value": round(pval, 4) if not np.isnan(pval) else "",503            "Significance": _stars(pval) if not np.isnan(pval) else "",504            "% Effect per unit": round((np.exp(coef) - 1) * 100, 2),505        })506507    return pd.DataFrame(records)508509510# ---------------------------------------------------------------------------511# Table 9 - Quantile regression512# ---------------------------------------------------------------------------513514def quantile_table(515    qreg_results: Dict[float, Any],516    variables: Optional[List[str]] = None,517) -> pd.DataFrame:518    """519    Table 9 -- Quantile regression coefficients side by side.520521    Parameters522    ----------523    qreg_results : dict524        ``{quantile: result}`` where *quantile* is e.g. 0.10, 0.25, ...525        Each *result* is a statsmodels ``QuantRegResults`` or a dict526        ``{variable: {'coef': float, 'se': float, 'pvalue': float}}``.527528    variables : list[str], optional529        Variables to include; if None, use all from the first result.530531    Returns532    -------533    pd.DataFrame534        Variables as rows, quantiles as columns.535    """536    logger.info("Building quantile regression table (%d quantiles).",537                len(qreg_results))538539    parsed: Dict[float, Dict[str, Dict[str, float]]] = {}540    for q, res in qreg_results.items():541        if isinstance(res, dict):542            parsed[q] = res543        else:544            parsed[q] = {545                v: {"coef": res.params[v], "se": res.bse[v],546                    "pvalue": res.pvalues[v]}547                for v in res.params.index548            }549550    if variables is None:551        first_key = next(iter(parsed))552        variables = list(parsed[first_key].keys())553554    quantiles_sorted = sorted(parsed.keys())555556    rows = []557    for var in variables:558        coef_row: Dict[str, str] = {"Variable": var}559        se_row: Dict[str, str] = {"Variable": ""}560        for q in quantiles_sorted:561            col_label = f"Q{int(q * 100)}"562            if var in parsed[q]:563                info = parsed[q][var]564                coef_row[col_label] = (565                    f"{info['coef']:.3f}{_stars(info.get('pvalue', 1.0))}"566                )567                se_row[col_label] = f"({info.get('se', 0.0):.3f})"568            else:569                coef_row[col_label] = ""570                se_row[col_label] = ""571        rows.append(coef_row)572        rows.append(se_row)573574    return pd.DataFrame(rows).set_index("Variable")575576577# ---------------------------------------------------------------------------578# Table 10 - ML model comparison579# ---------------------------------------------------------------------------580581def ml_comparison_table(582    ml_results: Dict[str, Dict[str, float]],583) -> pd.DataFrame:584    """585    Table 10 -- Machine-learning model performance comparison.586587    Parameters588    ----------589    ml_results : dict590        ``{model_name: {'RMSE': float, 'MAE': float, 'R2': float,591        'MAPE': float, ...}}``.592593    Returns594    -------595    pd.DataFrame596    """597    logger.info("Building ML comparison table (%d models).", len(ml_results))598599    records = []600    for model, metrics in ml_results.items():601        row: Dict[str, Any] = {"Model": model}602        row.update({k: round(v, 4) if isinstance(v, float) else v603                     for k, v in metrics.items()})604        records.append(row)605606    out = pd.DataFrame(records).set_index("Model")607608    # Sort by R2 descending if present609    if "R2" in out.columns:610        out = out.sort_values("R2", ascending=False)611612    return out613614615# ---------------------------------------------------------------------------616# Table 11 - SHAP importance617# ---------------------------------------------------------------------------618619def shap_importance_table(620    shap_df: pd.DataFrame,621    top_n: int = 20,622) -> pd.DataFrame:623    """624    Table 11 -- SHAP top variables.625626    Parameters627    ----------628    shap_df : pd.DataFrame629        Must contain 'Variable' and 'Mean_SHAP' (or 'mean_abs_shap')630        columns.631    top_n : int632        Number of top variables to display.633634    Returns635    -------636    pd.DataFrame637    """638    logger.info("Building SHAP importance table (top %d).", top_n)639640    df = shap_df.copy()641642    # Normalise column names643    col_map = {}644    for c in df.columns:645        cl = c.lower()646        if "variable" in cl or "feature" in cl:647            col_map[c] = "Variable"648        elif "shap" in cl:649            col_map[c] = "Mean |SHAP|"650    df = df.rename(columns=col_map)651652    if "Mean |SHAP|" not in df.columns:653        raise ValueError("shap_df must contain a column with 'shap' in its name.")654655    df = df.sort_values("Mean |SHAP|", ascending=False).head(top_n).reset_index(drop=True)656    df["Rank"] = range(1, len(df) + 1)657658    # Compute cumulative share if possible659    total = df["Mean |SHAP|"].sum()660    if total > 0:661        df["Share (%)"] = (df["Mean |SHAP|"] / total * 100).round(2)662        df["Cumul. (%)"] = df["Share (%)"].cumsum().round(2)663664    return df[["Rank", "Variable", "Mean |SHAP|", "Share (%)", "Cumul. (%)"]]665666667# ---------------------------------------------------------------------------668# Table 12 - Spatial model comparison669# ---------------------------------------------------------------------------670671def spatial_results_table(672    spatial_results: Dict[str, Dict[str, Any]],673) -> pd.DataFrame:674    """675    Table 12 -- Spatial model comparison (OLS, SAR, SEM, SDM, GWR, etc.).676677    Parameters678    ----------679    spatial_results : dict680        ``{model_name: {'R2': float, 'AIC': float, 'Log-Lik': float,681        'Lambda': float, 'Rho': float, 'Moran_I': float, ...}}``.682683    Returns684    -------685    pd.DataFrame686    """687    logger.info("Building spatial results table (%d models).",688                len(spatial_results))689690    records = []691    for model, stats in spatial_results.items():692        row: Dict[str, Any] = {"Model": model}693        for k, v in stats.items():694            if isinstance(v, float):695                row[k] = round(v, 4)696            else:697                row[k] = v698        records.append(row)699700    return pd.DataFrame(records).set_index("Model")701702703# ---------------------------------------------------------------------------704# Table 13 - Robustness checks summary705# ---------------------------------------------------------------------------706707def robustness_table(708    robustness_results: Dict[str, Dict[str, Any]],709) -> pd.DataFrame:710    """711    Table 13 -- Robustness checks summary.712713    Parameters714    ----------715    robustness_results : dict716        ``{check_name: {variable: {'coef': float, 'se': float, ...}, ...717        'R2': float, 'N': int}}``.718719    Returns720    -------721    pd.DataFrame722    """723    logger.info("Building robustness table (%d checks).",724                len(robustness_results))725726    records = []727    for check_name, content in robustness_results.items():728        row: Dict[str, Any] = {"Check": check_name}729730        # Extract model-level stats731        for key in ("R2", "Adj_R2", "N", "AIC", "BIC", "RMSE"):732            if key in content:733                val = content[key]734                row[key] = round(val, 4) if isinstance(val, float) else val735736        # Count significant variables737        var_entries = {738            k: v for k, v in content.items()739            if isinstance(v, dict) and "coef" in v740        }741        n_sig = sum(742            1 for v in var_entries.values()743            if v.get("pvalue", 1.0) < 0.05744        )745        row["Sig. vars (p<.05)"] = n_sig746        row["Total vars"] = len(var_entries)747        records.append(row)748749    return pd.DataFrame(records).set_index("Check")750751752# ---------------------------------------------------------------------------753# Export utilities754# ---------------------------------------------------------------------------755756def export_table(757    df: pd.DataFrame,758    name: str,759    formats: Optional[List[str]] = None,760    output_dir: Optional[str] = None,761    caption: Optional[str] = None,762    label: Optional[str] = None,763) -> Dict[str, str]:764    """765    Export a DataFrame to results/tables/ in multiple formats.766767    Parameters768    ----------769    df : pd.DataFrame770    name : str771        Base filename (without extension).772    formats : list[str]773        Subset of ``['csv', 'tex', 'html']``.774    output_dir : str, optional775        Override for the output directory.776    caption : str, optional777        LaTeX caption.778    label : str, optional779        LaTeX label.780781    Returns782    -------783    dict784        ``{format: filepath}``785    """786    if formats is None:787        formats = ["csv", "tex", "html"]788789    if output_dir is None:790        output_dir = os.path.join(791            os.path.dirname(os.path.dirname(os.path.abspath(__file__))),792            "results", "tables",793        )794795    os.makedirs(output_dir, exist_ok=True)796    paths: Dict[str, str] = {}797798    for fmt in formats:799        fpath = os.path.join(output_dir, f"{name}.{fmt}")800        if fmt == "csv":801            df.to_csv(fpath)802        elif fmt == "tex":803            latex_str = format_latex_table(804                df,805                caption=caption or name.replace("_", " ").title(),806                label=label or f"tab:{name}",807            )808            with open(fpath, "w", encoding="utf-8") as fh:809                fh.write(latex_str)810        elif fmt == "html":811            df.to_html(fpath, border=0, classes="table table-sm")812        else:813            logger.warning("Unknown export format: %s", fmt)814            continue815        paths[fmt] = fpath816        logger.info("Exported %s -> %s", name, fpath)817818    return paths819820821def format_latex_table(822    df: pd.DataFrame,823    caption: str,824    label: str,825    note: Optional[str] = None,826) -> str:827    """828    Format a DataFrame as a publication-ready LaTeX table with booktabs.829830    Parameters831    ----------832    df : pd.DataFrame833    caption : str834    label : str835    note : str, optional836        Table note placed below the table.837838    Returns839    -------840    str841        LaTeX source code.842    """843    ncols = len(df.columns) + (1 if df.index.name else 0)844    col_spec = "l" + "c" * (ncols - 1)845846    lines: List[str] = [847        r"\begin{table}[htbp]",848        r"\centering",849        r"\caption{" + caption + "}",850        r"\label{" + label + "}",851        rf"\begin{{tabular}}{{{col_spec}}}",852        r"\toprule",853    ]854855    # Header856    header_parts: List[str] = []857    if df.index.name:858        header_parts.append(df.index.name)859    else:860        header_parts.append("")861    header_parts.extend(str(c) for c in df.columns)862    lines.append(" & ".join(header_parts) + r" \\")863    lines.append(r"\midrule")864865    # Body866    for idx, row in df.iterrows():867        row_parts = [str(idx)]868        for val in row:869            if isinstance(val, float):870                row_parts.append(f"{val:.4f}" if abs(val) < 100 else f"{val:.1f}")871            else:872                row_parts.append(str(val))873        lines.append(" & ".join(row_parts) + r" \\")874875    lines.append(r"\bottomrule")876    lines.append(r"\end{tabular}")877878    if note:879        lines.append(r"\begin{tablenotes}")880        lines.append(r"\small")881        lines.append(rf"\item \textit{{Note:}} {note}")882        lines.append(r"\end{tablenotes}")883884    # Default significance note885    lines.append(r"\par\smallskip\footnotesize")886    lines.append(887        r"\textit{Note:} $^{***}p<0.001$, $^{**}p<0.01$, $^{*}p<0.05$."888    )889890    lines.append(r"\end{table}")891    return "\n".join(lines)892