Python 64.6%
TeX 23.3%
HTML 11.6%
Makefile 0.5%
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