
Pandas 代码写得越多,越容易陷入一种惯性:用 apply() 逐行处理,用循环拼接结果,用 groupby 加 merge 绕一大圈完成本可以一行解决的操作。代码能跑结果正确,但行数膨胀、性能也大打折扣,审查时也让人读得费力。
Pandas 本身内置了大量面向列操作的方法,覆盖条件赋值、数据分箱、格式转换、字符串处理等常见场景,只是在日常使用中很容易被忽略。翻阅 Kaggle 高分方案和生产级数据管道的源码后会发现,那些看起来简洁的一行代码并非技巧,而是对库本身设计意图的理解。

本文整理了10个这样的写法,每个都附带常见的冗长版本作为对照。
创建条件列最常见的写法是 apply() 加自定义函数。能跑,但在大型 DataFrame 上慢得肉眼可见。
常见写法:
defcategorize(row):
ifrow['score'] >=90:
return'A'
elifrow['score'] >=80:
return'B'
elifrow['score'] >=70:
return'C'
else:
return'F'
df['grade'] =df.apply(categorize, axis=1)换一种方式:
importnumpyasnp
conditions= [df['score'] >=90, df['score'] >=80, df['score'] >=70]
df['grade'] =np.select(conditions, ['A', 'B', 'C'], default='F')np.select() 是向量化操作,一次性处理整个数组而非逐行循环。在百万行的 DataFrame 上,比 apply() 快50到100倍并不罕见。可读性更好只是附带的好处。
写过一堆 df['new_col'] = ... 赋值语句堆在一起的人,换用 .assign() 之后代码结构会清晰很多。
# 用assign替代三行独立的赋值语句...
df= (
df.assign(
full_name=lambdax: x['first'] +' '+x['last'],
email_domain=lambdax: x['email'].str.split('@').str[1],
is_active=lambdax: x['last_login'] >'2025-01-01'
)
).assign() 返回新的 DataFrame,天然适合方法链。每个 lambda 接收的是链中当前状态的 DataFrame,同一次调用里后面的列可以引用前面的列。特征工程管道中一次性构建五六个派生列时尤其实用。
对连续数据做分箱,过去的做法是手写条件判断。Pandas 内置了两个专用函数:
# 等宽分箱(固定区间)
df['age_group'] =pd.cut(df['age'], bins=[0, 18, 35, 50, 65, 100],
labels=['Teen', 'Young Adult', 'Mid', 'Senior', 'Elder'])
# 等频分箱(每个箱中的记录数相同)
df['income_quartile'] =pd.qcut(df['income'], q=4, labels=['Q1', 'Q2', 'Q3', 'Q4'])两者的差异比多数人意识到的更关键。pd.cut() 按固定宽度划分区间,适合年龄段这类有明确范围的场景;pd.qcut() 按观测值数量均分,在机器学习或统计分析中构建均衡分组时正好对应需求。
宽格式与长格式之间的转换,花了远比愿意承认的更久才摸清楚。
宽转长:
df_long=df.melt(
id_vars=['student'],
value_vars=['math', 'science', 'english'],
var_name='subject',
value_name='score'
)长转宽:
df_wide=df_long.pivot_table(
index='student',
columns='subject',
values='score',
aggfunc='mean'
)两者组合大约覆盖了80%的数据重塑场景。.melt() 将宽 DataFrame 拆成长格式,.pivot_table() 反向操作的同时允许在透视过程中做聚合。给 Seaborn 喂数据或搭报表仪表板时,熟练掌握它们能省下不少时间。
.describe() 人人都知道,但通过自定义百分位数可以将覆盖范围扩展到所有列类型:
df.describe(include='all', percentiles=[.01, .05, .25, .5, .75, .95, .99])几乎每个新数据集上还会运行另一个操作——一次性获取所有字符串列中频率最高的前3个值:
df.select_dtypes(include='object').apply(lambdacol: col.value_counts().head(3))初始数据探索时在 .info() 和 .describe() 之后紧接着跑一次,异常的分类值很快就会暴露出来。需要更系统的分析工作流时,ydata-profiling(原 pandas-profiling)一个函数调用就能生成完整的 HTML 报告。
过去写筛选条件一直是下面这种风格:
result=df[(df['age'] >25) & (df['city'] =='Delhi') & (df['salary'] >50000)]满屏的括号和 &,容易写错,code review 时读起来也累。.query() 的写法接近原生 SQL:
result=df.query("age > 25 and city == 'Delhi' and salary > 50000")外部变量用 @ 前缀引用:
min_salary=50000
result=df.query("salary > @min_salary").query() 在 numexpr 可用时会在底层调用它,对大数据集上的复杂条件有加速效果。不过真正让人坚持用它的原因是,三个月后回来读代码时条件表达一目了然。
生产代码里经常出现下面的模式,写法总是比必要的复杂:
# 三行代码加一次merge来获取部门平均值
avg_salary=df.groupby('department')['salary'].mean().reset_index()
avg_salary.columns= ['department', 'dept_avg_salary']
df=df.merge(avg_salary, on='department').transform() 一行就够:
df['dept_avg_salary'] =df.groupby('department')['salary'].transform('mean').transform() 把组级别的计算结果广播回原始索引,每行直接拿到所属组的聚合值,不需要任何 merge。sum、std、count、rank、自定义 lambda 都适用。
现实数据里日期格式几乎不会是干净的——同一列混着 "March 5, 2024"、"2024-03-05" 和 "05/03/24" 是常态。手动逐条处理并不现实。
df['date'] =pd.to_datetime(df['date_string'], errors='coerce', infer_datetime_format=True)errors='coerce' 把无法解析的日期转为 NaT(Not a Time)而不是抛出异常,随后可以检查失败的记录:
failed=df[df['date'].isna() &df['date_string'].notna()]
print(f"{len(failed)} dates couldn't be parsed")拿到干净的 datetime 列之后,用 .dt 访问器批量提取时间特征:
df=df.assign(
year=df['date'].dt.year,
month=df['date'].dt.month,
day_of_week=df['date'].dt.day_name(),
is_weekend=df['date'].dt.dayofweek.ge(5)
)pd.to_datetime() 覆盖了90%的日期清洗场景。格式极端混乱时可能需要 dateutil 或手写解析逻辑,但那种情况远比想象中少见。
JSON API 或 NoSQL 导出的数据中,列表值嵌在 DataFrame 单元格里是常见现象。以前要把它们拆开,得写一段不短的循环。
df=pd.DataFrame({
'user': ['Alice', 'Bob'],
'skills': [['Python', 'SQL', 'Spark'], ['Java', 'Scala']]
})
df_exploded=df.explode('skills').explode() 从 Pandas 0.25 开始引入,列表中的每个元素单独成行,其余列自动复制。反向操作——将行折叠回列表——用 .groupby().agg(list) 即可。
字符串操作是 Pandas 中最容易踩的性能坑。遇到字符串处理就条件反射地写 apply(),但 .str 下有一整套向量化方法,执行速度快得多。
慢速写法:
df['clean_name'] =df['name'].apply(lambdax: x.strip().lower().replace(' ', '_'))快速写法:
df['clean_name'] =df['name'].str.strip().str.lower().str.replace(' ', '_', regex=False).str 访问器覆盖了 .contains()、.extract()、.findall()、.split()、.pad()、.zfill() 等数十种方法。百万行数据集上全面弃用 apply() 可以换来5到10倍的速度差距。
以上10个写法的共同点在于:放弃逐行处理的思路,转向列级别的向量化操作。这不只是代码风格的偏好,而是 Pandas 底层基于 NumPy 数组设计的必然结果——顺着库的设计方向写,代码自然会更短、更快、更易维护。
如果在数据规模上遇到 Pandas 的性能天花板,Polars 是目前最值得评估的替代方案——基于 Rust 实现,在不少工作负载下有明显的速度优势。
by Aashish Kumar
本文分享自 DeepHub IMBA 微信公众号,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文参与 腾讯云自媒体同步曝光计划 ,欢迎热爱写作的你一起参与!