Python技术迷

别再写np.where嵌套地狱了!Pandas条件逻辑的向量化写法让效率翻倍

昨晚十一点多我还在公司,灯都关得差不多了,就我和我们组那个小李,还杵在工位前面对着一坨 np.where 发呆。 风扇呼呼转,他跟我说:“哥,这段逻辑你帮我看看,我感觉没啥问题就是有点丑。”我一看,差点原地去楼下再点根烟压压惊。

他代码大概长这样:

import numpy as np

df["level"] = np.where(df["score"] >= 90, "A",
                np.where(df["score"] >= 80, "B",
                    np.where(df["score"] >= 60, "C", "D")))

你们是不是也写过这种的,老老实实哈,别装。 三层嵌套,勉强还能看,真业务一上来,条件一多、字段一多,直接就变成那种:

df["tag"] = np.where(
    (df["city"] == "北京") & (df["amount"] > 1000) & (df["vip"] == 1), "北京大客",
    np.where(
        (df["city"] == "上海") & (df["amount"] > 500), "上海重点",
        np.where(
            (df["amount"] <= 0) | df["is_refund"].eq(1), "异常",
"普通"
        )
    )
)

看着就头大对吧? 问题其实不止“丑”,还有几个更要命的:

  • 条件和结果黏在一起,改一个条件要从头顺着括号数。
  • 多人协作的时候,别人根本不敢乱动,怕动一行炸一片。
  • 真到几百万行数据的时候,性能上去了你也不好排查是逻辑错还是算发太慢。

我当时就跟小李说一句:“以后别再写这种 np.where 嵌套地狱了,咱们用 pandas 自己那套向量化玩法,速度快还好维护。”

在公司楼下抽烟的时候聊得最多的,其实就是“写得越像 if-else 的 DataFrame 代码,其实越难维护”。pandas 给你的不是 if,而是“筛选 +赋值”。

像刚才那个简单分等级的例子,其实完全可以拆开放,写得像人脑里的思路一样。比如你先造个小例子玩一下:

import pandas as pd

df = pd.DataFrame({
"name": ["张三", "李四", "王五", "赵六"],
"score": [95, 83, 61, 40]
})

正常人脑子里想的是: 大于等于 90 是 A; 大于等于 80 是 B; 大于等于 60 是 C; 剩下的就是 D。

那就照这个顺序写:

df["level"] = "D"# 先给个默认值

df.loc[df["score"] >= 60, "level"] = "C"
df.loc[df["score"] >= 80, "level"] = "B"
df.loc[df["score"] >= 90, "level"] = "A"

注意一下顺序,我是从低到高覆盖的,后面的会把前面的“刷”掉。 这种写法的好处是:一行一个规则,谁看谁明白,你半夜爬起来看日志也能懂在干嘛。

而且它也是完全向量化的,没有 for,没有 apply,底层还是 C 那套,速度该有的都有。

早上开会的时候,有人问我:“那要是条件又多又复杂呢?十几种标签那种?” 这种情况如果你继续 .loc 一行一行写,虽然还能接受,但文件会变得很长嘛,滑轮滑得手都酸。

这个时候,我一般让大家用 np.select,注意是 select 不是 where。 思路就是:把条件先拆成一个个布尔数组,再配一个结果数组,最后一把梭。

比如说有这样一张表:

df = pd.DataFrame({
"city": ["北京", "北京", "上海", "深圳", "北京"],
"amount": [1200, 50, 800, -10, 0],
"vip": [1, 0, 1, 0, 0]
})

业务想要的是:

  • 北京 + 金额 > 1000 + vip,是“北京大客”
  • 上海 + 金额 > 500,是“上海重点”
  • 金额 <= 0 或退款,是“异常”
  • 其他都是“普通”

那你可以这么写(我那天就直接给小李改成这样):

import numpy as np

cond_beijing_vip = (df["city"].eq("北京")) & (df["amount"] > 1000) & (df["vip"] == 1)
cond_shanghai = (df["city"].eq("上海")) & (df["amount"] > 500)
cond_bad = (df["amount"] <= 0)  # 简化一下,先不管退款字段

conds = [cond_beijing_vip, cond_shanghai, cond_bad]
choices = ["北京大客", "上海重点", "异常"]

df["tag"] = np.select(conds, choices, default="普通")

你看,这里几个点:

  • 复杂条件单独起名,不用再挤在一行 np.where 里。
  • np.select 把“条件 → 结果”的映射集中放在两个列表里,顺序就是优先级。
  • 想加新规则就插一条,删规则就删一条,完全不用重新数括号。

那天我跟小李说,你把这些 cond_xxx 变量往上挪,整块逻辑就是“规则区域”,体验会好很多,后面别人想复用某个条件也比复制粘贴舒服。

还有一种特别常见的:按数值区间打标签,比如“差/中/良/优”之类的。 很多同学还是忍不住往 np.where 上堆,其实 pandas 早就帮你准备好了一个小工具,叫 pd.cut。

比如刚才那个分等级,完全可以一刀切成区间:

df = pd.DataFrame({
"name": ["张三", "李四", "王五", "赵六"],
"score": [95, 83, 61, 40]
})

bins = [0, 60, 80, 90, 100]  # 区间边界
labels = ["D", "C", "B", "A"]

df["level"] = pd.cut(df["score"], bins=bins, labels=labels, right=False)

这里 right=False 的意思是左闭右开,比如 [80, 90)。 你要调整边界,只改 bins 就行,不用到处找 >= 和 <。

这个在风控、推荐、运营那种一堆“分档”的地方特别爽, 昨天还和做数据库压测的同事对着几百万行数据玩了一把分档,pd.cut 一跑就完事,肉眼可见比自己乱写条件快不少。

还有一类需求也特别多: 比如订单状态字段,你要根据状态映射成中文说明;或者根据一个枚举类把值映射成标签。 这种千万别上 np.where,直接字典 map 一把梭。

举个特别简单的,别嫌幼稚哈:

status_map = {
0: "待支付",
1: "已支付",
2: "已发货",
3: "已关闭",
}

df["status_text"] = df["status"].map(status_map).fillna("未知状态")

逻辑一眼就懂: 数字 → 文本,就一层映射。 你再用 np.where 写成那种:

df["status_text"] = np.where(df["status"] == 0, "待支付",
                      np.where(df["status"] == 1, "已支付",
                        ...))

纯属折磨自己,维护的人要记你两句。

稍微高级一点的玩法是,你有两三个字段一起决定一个结果,不想写 if-else,又觉得 np.select 列一堆条件有点乱。 这时候可以直接把“规则表”也做成一个 DataFrame,再 merge 一下,用“连接”代替“条件判断”,也是向量化的。

比如说按 city + vip 组合决定标签:

rules = pd.DataFrame({
"city": ["北京", "北京", "上海", "上海"],
"vip": [0, 1, 0, 1],
"tag": ["北京普通", "北京大客", "上海普通", "上海大客"]
})

df = df.merge(rules, on=["city", "vip"], how="left")
df["tag"] = df["tag"].fillna("其他")

这种写法在规则很多的时候特别香,你业务同学甚至能直接改 Excel,导进来就变成新规则了,你连代码都不用动。

你看,写着写着就从 if-else 风格变成“数据驱动”的风格了。

说了这么多“写法更优雅”,总得聊两句性能,不然感觉像在玩花活。 那天我在工位上随手敲了段小脚本,给小李演示了一下为啥不要随便 .apply,也不要玩嵌套 np.where:

import pandas as pd
import numpy as np
import time

N = 1_000_000
df = pd.DataFrame({
"score": np.random.randint(0, 100, size=N)
})

defwith_nested_where(df):
return np.where(df["score"] >= 90, "A",
           np.where(df["score"] >= 80, "B",
           np.where(df["score"] >= 60, "C", "D")))

defwith_loc(df):
    s = pd.Series("D", index=df.index)
    s.loc[df["score"] >= 60] = "C"
    s.loc[df["score"] >= 80] = "B"
    s.loc[df["score"] >= 90] = "A"
return s

start = time.time()
for _ in range(5):
    lvl1 = with_nested_where(df)
print("nested where:", time.time() - start)

start = time.time()
for _ in range(5):
    lvl2 = with_loc(df)
print("loc:", time.time() - start)

你可以自己跑跑看,具体数字每台机器不一样,但感觉大概是:

  • 两个都是向量化,其实都比 apply 快太多。
  • 但真正麻烦的是:嵌套 np.where 一旦逻辑改复杂了,你很难按块去重用条件,只能一坨一坨重算。
  • loc + 预先算好的布尔条件,可以把重复的条件缓存起来,代码改动少很多。

极端一点的情况是很多人喜欢把复杂逻辑丢到:

df.apply(lambda row: some_if_else(row), axis=1)

这个真的是性能杀手。 你可以随手改一下上面那个脚本,用 apply 去算等级,和向量化比一比,差距非常直观。

再说两个我自己常用的小习惯,顺手就把可读性提上去了。

第一个,小心地把条件拆出来起名。 比如不是写:

df.loc[(df["score"] >= 60) & (df["score"] < 80) & (~df["is_makeup"]), "level"] = "C"

而是写:

pass_score = df["score"] >= 60
good_score = df["score"] >= 80
has_makeup = df["is_makeup"]

df["level"] = "D"
df.loc[pass_score & ~has_makeup, "level"] = "C"
df.loc[good_score & ~has_makeup, "level"] = "B"

第二个,利用 assign 把一串逻辑串在一起,保持链式:

df = (
    df
    .assign(
        is_big = lambda d: d["amount"] > 1000,
        is_error = lambda d: d["amount"] <= 0
    )
)

df["tag"] = np.select(
    [df["is_big"], df["is_error"]],
    ["大单", "异常"],
    default="正常"
)

这样做的好处是: 你中间那些 is_big、is_error 字段可以在别的地方复用,调试的时候还能直接看中间状态。

扯了这么久,回到那个最开始的问题: 为什么我老是劝你们“别再写 np.where 嵌套地狱了”?

其实很简单:

  • pandas 已经给了你一堆更贴近思维方式的向量化工具:loc、np.select、cut、map、merge…
  • 这些写法不是花里胡哨,是真能减少 bug、提升协作效率的。
  • 真要算性能,它们跟你嵌套 np.where 一样快,甚至更容易做优化。

下次你再准备写第二层 np.where 的时候,先停一下,想想能不能把逻辑拆成:

  • 一批有名字的布尔条件
  • 一个清清楚楚的“规则列表”或“规则表”

写完你再回头看代码,心里那个舒坦劲儿,你懂的。

行,我先去泡杯咖啡,今晚看谁再给我提个十层 np.where 的 MR,我直接在评审上给他拉个红线😄