跳到正文

Python 表格生成:pandas Styler 与格式导出

报告里放一张表,先要判断它值多少工夫。控制台上扫一眼就能得到的信息,不值得写排版代码;要跟着报告反复重跑、被正文引用的表,值得;交给期刊或不做分析的合作者、他们会直接在 Word 里改的表,最值得。这一篇按这个顺序讲三档做法:to_markdown() 起步,Styler 补细节,to_excel()to_latex() 做交付。

排版之前,先把要展示的内容算成一张规整的汇总表。原始数据几乎从来不是能直接印出来的样子。

import pandas as pd
from sklearn.datasets import load_iris
iris = load_iris(as_frame=True).frame
iris.columns = ["sepal_len", "sepal_wid", "petal_len", "petal_wid", "species"]
iris["species"] = iris["species"].map({0: "setosa", 1: "versicolor", 2: "virginica"})
agg = (
iris.groupby("species")
.agg(
样本量=("petal_len", "count"),
花瓣长均值=("petal_len", "mean"),
花瓣长标准差=("petal_len", "std"),
)
.round(3)
)
print(agg)
样本量 花瓣长均值 花瓣长标准差
species
setosa 50 1.462 0.174
versicolor 50 4.260 0.470
virginica 50 5.552 0.552

命名聚合(新列名=("原列名", "函数名"))让结果直接带上中文列名,省掉一轮 rename()。分组键落在索引上,to_markdown()to_latex() 都会把它当作行标签输出,这一层不用额外处理;只有要继续参与计算时才需要 reset_index()。细节见 Pandas分组聚合

小数位不要设得比数据本身的精度还高。仪器读数到小数点后两位,表里写三位就是虚假精度;样本量这类整数也不该被补成 50.0。经验做法是均值保留的位数与原始测量一致,标准差可以多留一位。

to_markdown() 把 DataFrame 变成 Markdown 管道表,粘进任何支持 Markdown 的文档都是张像样的表。

print(agg.to_markdown())
| species | 样本量 | 花瓣长均值 | 花瓣长标准差 |
|:-----------|------:|--------:|---------:|
| setosa | 50 | 1.462 | 0.174 |
| versicolor | 50 | 4.26 | 0.47 |
| virginica | 50 | 5.552 | 0.552 |

对齐方式按列的类型自动决定:文本列左对齐,数值列右对齐,分隔行里的 ---: 就是这个含义。宽度交给渲染端算,数字不会被补零。

floatfmt 统一小数位。上面那张表里 4.26 和 1.462 位数不一致,是各自保留三位有效数字的结果,正式输出时通常要拉齐:

print(agg.to_markdown(floatfmt=".2f"))
| species | 样本量 | 花瓣长均值 | 花瓣长标准差 |
|:-----------|------:|--------:|---------:|
| setosa | 50.00 | 1.46 | 0.17 |
| versicolor | 50.00 | 4.26 | 0.47 |
| virginica | 50.00 | 5.55 | 0.55 |

代价是样本量也被写成了 50.00。计数列和测量列的小数位要求不同,一张表里混着两类列时,floatfmt 只能二选一。要分开控制,得改用 Styler

to_markdown() 依赖第三方包 tabulate,没装会直接抛 ImportError。pandas 不自带它,换一台机器跑脚本时容易在这里断掉。

Styler 是 pandas 的表格样式对象,通过 .style 访问器拿到。它不改变数据,只挂上一层展示规则。

st = agg.style.format(precision=2).set_caption("三种鸢尾的花瓣长度(单位:cm)")

format() 控制数字位数,set_caption() 加表标题。用 .style 有一个前提:必须装 jinja2,否则访问 .style 会直接报错:

AttributeError: The '.style' accessor requires jinja2

条件着色用 map()。它接受一个函数,返回 CSS 字符串,逐格决定样式:

def highlight_large(v):
return "background-color: #ffe0b2" if v > 5 else ""
st2 = agg.style.map(highlight_large, subset=["花瓣长均值"])

subset 限定作用范围,不写就作用于整张表。函数对每个单元格调用一次,返回空字符串表示不加样式。

早期版本的 pandas 里这个函数叫 applymap()。2.1 起改名为 map()pandas 3.0 已彻底移除旧名,调用 st.applymap(...) 会得到 AttributeError: 'Styler' object has no attribute 'applymap'。网上大量教程还在用旧写法,照抄会直接失败。

按数值大小自动映射颜色,用 background_gradient(),不必自己写判断函数:

agg.style.background_gradient(subset=["花瓣长均值"], cmap="Blues")

cmap 接受任何 Matplotlib 的色图名。深色背景会让黑字看不清,配 BluesReds 这类浅色端颜色更稳妥。另外两个常用方法:highlight_max() 高亮每列最大值,bar() 在单元格里画一根长度正比于数值的数据条——后者适合展示占比或得分,不适合已经是均值的列。

表头样式归 set_table_styles() 管,它接受一组 CSS 选择器:

styled = agg.style.set_table_styles(
[
{"selector": "th", "props": [("background-color", "#f5f5f5"), ("text-align", "center")]},
{"selector": "caption", "props": [("caption-side", "top"), ("font-size", "14px")]},
]
).set_caption("表 1 三种鸢尾的花瓣长度")

Styler 能改的样式止步于 CSS。单元格合并、跨行表头这类结构改动它做不了,那属于 to_excel() 配合 openpyxl 直接操作工作表,或者干脆换用专门的表格库。

在终端里 print(st) 只会得到普通的 DataFrame 文本,样式完全不体现:

样本量 花瓣长均值 花瓣长标准差
species
setosa 50 1.46 0.17
versicolor 50 4.26 0.47
virginica 50 5.55 0.55

Styler 的样式只在 Jupyter Notebook、导出的 HTML 和支持样式的 Excel 里生效。用 st.to_html() 可以把带样式的表格存成独立 HTML 片段,嵌进网页或邮件。

Styler.to_excel() 会把样式一并写进 xlsx,合作者打开看到的就是着色后的表:

agg.style.background_gradient(subset=["花瓣长均值"], cmap="Blues").to_excel(
"花瓣长度汇总.xlsx", engine="openpyxl"
)

写进去的是真实的单元格填充色:读回文件可以看到三行的填充分别是 00F7FBFF003282BE0008306B,正是 Blues 色图由浅到深的三档。这条路要求装 openpyxl,engine 参数不写也能自动识别,显式写出来是为了在同事的机器上报错更清楚。

论文正文要的是 LaTeX 三线表。to_latex() 的默认输出就已经是三线表:

print(agg.to_latex(caption="三种鸢尾的花瓣长度", label="tbl:iris"))
\begin{table}
\caption{三种鸢尾的花瓣长度}
\label{tbl:iris}
\begin{tabular}{lrrr}
\toprule
& 样本量 & 花瓣长均值 & 花瓣长标准差 \\
species & & & \\
\midrule
setosa & 50 & 1.462000 & 0.174000 \\
versicolor & 50 & 4.260000 & 0.470000 \\
virginica & 50 & 5.552000 & 0.552000 \\
\bottomrule
\end{tabular}
\end{table}

\toprule\midrule\bottomrule 来自 LaTeX 的 booktabs 宏包,需要在导言区写 \usepackage{booktabs}

注意默认的数字格式:1.462 被写成了 1.462000。这是 to_latex() 沿用 DataFrame 内部浮点表示的结果,正式输出必须显式指定 float_format

print(
agg.to_latex(
column_format="lccc",
escape=False,
float_format="%.2f",
caption="花瓣长度汇总",
label="tbl:iris2",
)
)
\label{tbl:iris2}
\begin{tabular}{lccc}
\toprule
& 样本量 & 花瓣长均值 & 花瓣长标准差 \\
species & & & \\
\midrule
setosa & 50 & 1.46 & 0.17 \\
versicolor & 50 & 4.26 & 0.47 \\
virginica & 50 & 5.55 & 0.55 \\
\bottomrule
\end{tabular}
\end{table}

三个参数各管一件事。column_format 指定每列对齐,lccc 是首列左对齐、其余居中。escape=False 关掉特殊字符转义——列名里有中文时保留原样,但如果列名含 _&% 这些 LaTeX 保留字符,转义就必须打开,否则编译报错。float_format 统一小数位。

label 的值会成为 \label{...} 的内容,正文里用 \ref{tbl:iris2} 引用,编号由 LaTeX 自动处理。

需要说明一处版本差异:pandas 3.0 移除了 to_latex()booktabs 参数,因为三线表现在是默认输出。旧代码里常见的 to_latex(booktabs=True) 在这个版本会抛 TypeError: NDFrame.to_latex() got an unexpected keyword argument 'booktabs'。2.x 及更早的版本则需要显式传 booktabs=True 才能得到上面的效果。

和 R 语言做同一件事的对照:

任务 R Python
基础表格 knitr::kable() df.to_markdown()
手工调样式 kableExtra Styler.set_table_styles()
管道式排版 gt() Styler 的链式方法
导出 Word gtsave(".docx") 无直接对应,走 to_excel()
导出 LaTeX kable(format = "latex") df.to_latex()

R 的 gt 在导出 Word 原生表格上明显更顺手,gtsave() 一行就能交付;Python 这边要么交 Excel、要么交 LaTeX,中间地带没有等价的工具。R 版的完整做法见 /r/reporting/tables/

  • 只有两三个数字:写进句子里,读者一眼扫过,比让他在表里对行对列快得多。
  • 列数超过十列:拆成两张表,或者把次要列挪进附录,别用缩小字号硬塞。
  • 表格只给同事看一眼:print() 就够,不要为它写 Styler。
  • 交付给期刊:先确认对方要 Word 表格还是 LaTeX 源码。要 LaTeX 就交 .tex 片段,要 Word 就用 to_excel() 或手工排。

表格的数字从哪来、半年后还能不能重跑出同样一张表,是比排版更靠前的问题,接着看 /python/reporting/reproducible/