TidyPy4DS:给 pandas 补上 tidyverse 风格的数据清洗体验

为什么写 TidyPy4DS

做数据分析的人,大概率都在 pandas 和 tidyverse 之间摇摆过。

如果你长期写 R,dplyr / tidyr / stringr 那套东西会很顺手:选列有 selector,批量变换有 across(),宽长表转换、字符串处理、缺失值处理都有比较统一的入口。

但一旦切到 Python,虽然 pandas 功能很强,日常清洗时还是经常会冒出几个问题:

  • 想按前缀、类型、条件批量选列,不够统一
  • 想对一批列做同样的处理,assign(...) 经常要自己手搓字典
  • 想把清洗步骤写成稳定、可复用、可读的链,容易堆很多一次性 lambda
  • 从 tidyverse 迁移过来时,肌肉记忆没地方放

所以我写了 TidyPy4DS

它不是想重写 pandas,也不是想做另一个复杂框架,而是只补一小块真正不够顺手的地方:列选择、批量变换、字符串处理、宽长表转换,以及更自然的 .pipe() 链式体验。

项目地址:https://github.com/lenardar/TidyPy4DS

这个项目想解决什么

一句话说,函数名尽量跟 tidyverse,接口行为坚持 Python / pandas。

也就是说:

  • 不做 SQL DSL
  • 不做惰性执行引擎
  • 不替代 groupbymergeassign 这些已经很成熟的 pandas 原生能力
  • 只把最容易重复、最容易写散的那部分动作收成统一入口

目前项目里最核心的几组能力是:

  • selector 系统:starts_withcontainsnumericwhere
  • 批量变换:mutate_across
  • 批量改名:rename_with
  • 汇总:summarize
  • 结构查看:glimpse
  • 条件映射:case_whenif_elserecode
  • 宽长表转换:pivot_longerpivot_wider
  • 缺失值处理:drop_nafill_nareplace_na
  • 表头清理:clean_namesremove_emptyrow_to_names

一个简单对比:纯 pandas vs tidypy

先看一份很常见的数据:

1
2
3
4
5
6
7
8
9
import pandas as pd

df = pd.DataFrame({
"employee_id": [101, 102, 103, 104, 105],
"dept": ["Sales", "Sales", "Tech", "Tech", "HR"],
"score_math": [92.0, None, 88.0, 79.0, None],
"score_eng": [85.0, 90.0, None, 82.0, 87.0],
"name": [" Alice ", "Bob ", " Carol", "David", " Frank "],
})

目标很普通:

  • 只保留 ID、分组、分数字段和名字
  • 用每列中位数填补缺失值
  • 去掉 score_ 前缀
  • 去掉名字前后空格
  • 按数学成绩做一个等级
  • 最后按分组汇总

纯 pandas 写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pandas_result = (
df.loc[:, ["employee_id", "dept", "score_math", "score_eng", "name"]]
.assign(
score_math=lambda x: x["score_math"].fillna(x["score_math"].median()),
score_eng=lambda x: x["score_eng"].fillna(x["score_eng"].median()),
)
.rename(columns={"score_math": "math", "score_eng": "eng"})
.assign(
name=lambda x: x["name"].str.strip(),
level=lambda x: pd.Series(
["A" if v >= 90 else "B" if v >= 80 else "C" for v in x["math"]],
index=x.index,
),
)
)

这段代码当然没问题,而且完全是标准 pandas

但你会发现几个特点:

  • 分数字段被显式写了两遍
  • 改名前后的列名都要手动维护
  • 当类似列变多时,改动面会越来越大

tidypy 写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
from tidypy.tidy import (
case_when,
clean_names,
mutate_across,
rename_with,
select,
starts_with,
)

tidypy_result = (
clean_names(df)
.pipe(
select,
"employee_id",
"dept",
starts_with("score_"),
"name",
)
.pipe(
mutate_across,
starts_with("score_"),
lambda s: s.fillna(s.median()),
)
.pipe(
rename_with,
lambda c: c.replace("score_", ""),
starts_with("score_"),
)
.assign(
name=lambda x: x["name"].str.strip(),
level=lambda x: case_when(
(x["math"] >= 90, "A"),
(x["math"] >= 80, "B"),
default="C",
),
)
)

它并不是神奇地比 pandas 少很多代码。

真正的区别在于:你开始把“哪些列属于同一类”和“这批列要做什么”分开表达了。

这个分离在小数据上可能只是“看着顺一点”,但一旦你多加一个 score_logic 列,差别就出来了:

  • 纯 pandas 版本通常要改多处显式列名
  • selector 版本通常只要让新列符合 score_ 规则,主流程基本不用改

如果再往真实一点的数据走,差异会更明显。比如很多 Excel 导出来的原始表根本不是“干净 DataFrame”,而是:

  • 列名里有空格、括号和标点
  • 第一行才是真正的表头
  • 后面还夹着全空行、全空列

这时候我最近补进去的一批 janitor 风格函数就比较顺手了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from tidypy.tidy import clean_names, remove_empty, row_to_names

raw = pd.DataFrame(
[
["Patient ID", "Score Math", None],
[1, 90, None],
[2, 88, None],
[None, None, None],
]
)

result = (
raw
.pipe(row_to_names, row=0)
.pipe(remove_empty, axis="both")
.pipe(clean_names)
)

这类东西单看都不复杂,但它们经常正好卡在“pandas 原生当然能做,只是每次都要重写一遍”的位置上。

核心设计:先把列选择系统做对

这个项目里最重要的抽象其实不是 mutate_across(),而是 ColSelector

所有 helper 都返回一个 selector 对象,真正操作时再结合 df 解析出列名。

比如:

1
2
3
select(df, numeric() | starts_with("id"))
select(df, everything() - contains("tmp"))
mutate_across(df, where(lambda s: s.isnull().any()), lambda s: s.fillna(0))

这样做有几个明显好处:

  • helper 可以访问列名,也可以访问 dtype 或数据本身
  • selector 可以组合
  • 列选择逻辑可以复用,而不是散落在各处

我最后定下来的规则也比较简单:

  • | 表示并集
  • - 表示排除
  • 结果去重且保序
  • 解析不到列时直接报错,不静默忽略

这套东西一旦稳定下来,后面的 selectmutate_acrossrename_withpivot_longer 都会自然顺很多。

一个我刻意没有做的东西:裸列名 NSE

如果熟悉 tidyverse,可能第一反应会问:

能不能做成 mutate(df, total=a + b) 这种效果?

答案是:理论上可以硬做,实际上不值得。

在 R 里,mutate() 这种体验背后是 tidy evaluation;Python 没有这一层语言机制。真要做,只能走 eval、AST 改写、代理对象这些路线。

这种东西最大的风险不是“写不出来”,而是:

  • 报错不直观
  • 调试很差
  • pandas 生态不一致
  • 一旦边界复杂起来,很容易变成一套脆弱的语法魔法

所以我最后的取舍是:

  • 不做裸列名 NSE
  • 保留 assign(...) 处理少量显式新列
  • mutate_across(...) 解决批量变换
  • case_when(...)if_else(...)recode(...) 解决条件映射和重编码

这套方案不花哨,但更稳。

glimpse() 也是我很想要的一个小东西

在 notebook 里,head() 看值,info() 看结构,但两者之间总觉得差一口气。

所以我顺手加了一个 glimpse()

1
2
3
from tidypy.tidy import glimpse

glimpse(df)

它会给出:

  • 行数、列数
  • 每列 dtype
  • 非空数、缺失数
  • 唯一值数量
  • 前几条样本值预览

而且支持:

1
2
glimpse(df, cols=starts_with("score_"))
print(glimpse(df, as_text=True, display=False))

这玩意不大,但在实际 notebook 里非常顺手。

现在这个项目是什么状态

目前 TidyPy4DS 已经有一版可以直接用的实现,仓库里也把几件基础设施补上了:

  • 双语 README
  • 双语函数文档
  • 双语 notebook 示例
  • unittest 测试
  • GitHub Actions CI
  • 一批偏 janitor 风格的小函数

示例 notebook 分成了三类:

  • Why tidypy:直接对比纯 pandas 和 tidypy
  • Core APIs:看 selector、mutate_acrosssummarize 这些核心能力
  • Reshape and missing values:看 pivot_longerpivot_widerseparateunite、缺失值处理

项目整体还在早期阶段,但核心方向已经比较清楚:

  • selector 系统要稳定
  • API 要尽量少而清晰
  • 不做多余包装
  • 不和 Python 自身的直觉打架
  • 单文件实现先保持,但内部已经按模块整理,后续可拆

最后

这个项目本质上不是在和 pandas 对抗,而是在承认一件事:

pandas 很强,但确实有一些高频清洗动作,写起来就是不够顺。

如果能用一层很薄的 helper,把这些动作收成更统一、可复用、可读的入口,那它就值得存在。

如果你也经常在 pandas 和 tidyverse 的思路之间切换,TidyPy4DS 也许会正好补上你手感里缺的那一块。

最近我自己的感受也更明确了:这个项目最有价值的地方,不是“把 tidyverse 完整搬到 Python”,而是把那些每天都会写、但又总写得零碎的数据清洗动作,收成一套尽量干净的小工具。

项目地址:https://github.com/lenardar/TidyPy4DS