0


10个内置在 Pandas 中却常被忽略的向量化操作

Pandas 代码写得越多,越容易陷入一种惯性:用

apply()

逐行处理,用循环拼接结果,用

groupby

merge

绕一大圈完成本可以一行解决的操作。代码能跑结果正确,但行数膨胀、性能也大打折扣,审查时也让人读得费力。

Pandas 本身内置了大量面向列操作的方法,覆盖条件赋值、数据分箱、格式转换、字符串处理等常见场景,只是在日常使用中很容易被忽略。翻阅 Kaggle 高分方案和生产级数据管道的源码后会发现,那些看起来简洁的一行代码并非技巧,而是对库本身设计意图的理解。

本文整理了10个这样的写法,每个都附带常见的冗长版本作为对照。

1、 用 np.select() 替代嵌套的 if/else来创建多条件列

创建条件列最常见的写法是

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倍并不罕见。可读性更好只是附带的好处。

2、 链式调用 .assign()构建列,不打断代码流

写过一堆

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,同一次调用里后面的列可以引用前面的列。特征工程管道中一次性构建五六个派生列时尤其实用。

3、 用 pd.cut() 和 pd.qcut()对连续数据分箱

对连续数据做分箱,过去的做法是手写条件判断。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() 按观测值数量均分,在机器学习或统计分析中构建均衡分组时正好对应需求。

4、 用 .melt() 和 .pivot_table()一步完成数据重塑

宽格式与长格式之间的转换,花了远比愿意承认的更久才摸清楚。

宽转长:

 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 喂数据或搭报表仪表板时,熟练掌握它们能省下不少时间。

5、 用增强版 .describe() 一行完成 DataFrame 概要分析

.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 报告。

6、 用 .query() 做可读的类 SQL 筛选

过去写筛选条件一直是下面这种风格:

 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

可用时会在底层调用它,对大数据集上的复杂条件有加速效果。不过真正让人坚持用它的原因是,三个月后回来读代码时条件表达一目了然。

7、 用 .transform() 替代 groupby + merge

生产代码里经常出现下面的模式,写法总是比必要的复杂:

 # 三行代码加一次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 都适用。

8、 用 pd.to_datetime()配合 errors='coerce' 自动解析混乱日期

现实数据里日期格式几乎不会是干净的——同一列混着

"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

或手写解析逻辑,但那种情况远比想象中少见。

9、 用 .explode() 展开单元格中的列表

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)

即可。

10、 用向量化字符串方法替代字符串 apply()

字符串操作是 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

“10个内置在 Pandas 中却常被忽略的向量化操作”的评论:

还没有评论