第04讲 按条件取数 布尔掩码

📎 配套代码第04讲_按条件取数.py
📊 配套数据:本讲用课件内的小样本(2024-01-02 真实收盘快照),无需外部数据

🎬 开场:你并不知道要第几个

本讲样本是 2024-01-02 的真实收盘数据(取自本地 tushare pro_bar):茅台 1685.01、五粮液 136.00、中国平安 39.47、平安银行 9.21、招商银行 27.58。只取五只是为了每个值都看得清。

前两讲的取数,都有个前提:你事先知道要什么。

  • 按位置:你知道要第 3 个、最近 20 天
  • 按标签:你知道要 600519、要 close 这一列

但研究里更常见的情况是——你不知道是哪些,只知道它们满足什么条件

  • 今天涨幅超过 5% 的股票,是哪些?
  • 市值排在前 20% 的,有哪几只?
  • 把 ST 股、停牌股、上市不满 60 天的剔掉,还剩什么?
  • 收益率是空值的那些行,在哪儿?

这类需求没法用位置或标签表达,因为答案取决于数据本身,而且每天都不一样。今天涨超 5% 的是这 37 只,明天就是另外 52 只。

条件取数解决的就是这个问题:你描述条件,让数据自己告诉你是哪些


一、🧰 布尔掩码:条件先变成一串 True/False

条件取数分两步。第一步是把条件变成一个布尔数组

close = np.array([1685.01, 136.00, 39.47, 9.21, 27.58])

close > 100
# [ True  True False False False]

注意 close > 100 的结果不是一个 True 或 False,而是和原数组等长的一串布尔值——每个位置一个,回答”这个位置满不满足”。

第二步,用这串布尔值去取数:

close[close > 100]     # [1685.01  136.  ]

方括号里放布尔数组,numpy 就把 True 位置的元素挑出来。这叫布尔索引,那串布尔值叫掩码(mask)。

掩码可以复用到别的数组上

这是布尔掩码最有用的性质:

codes = np.array(["600519", "000858", "601318", "000001", "600036"])
mask  = close > 100

close[mask]     # [1685.01  136.  ]
codes[mask]     # ['600519' '000858']   ← 同一个掩码,用在代码数组上

条件是在价格上算的,但掩码可以用来筛代码——只要两个数组等长、顺序对应。

在量化里这个用法极其常见:你在一个指标上算出条件(市值前 20%),然后用这个掩码去筛收益率、筛行业、筛任何对应的数据。

WARNING: ⚠️ 前提是”顺序对应”
掩码复用依赖两个数组的第 i 个元素说的是同一只股票。一旦其中一个被重排过(第 03 讲的问题),掩码就会张冠李戴——而且不报错
这也是 pandas 要给数据配标签的原因之一:有了标签,对齐是自动的,不用你自己保证顺序。

🎮 随堂快练

QUESTION: ret 是一组收益率,codes 是对应的股票代码。写出”取出所有下跌股票的代码”。
TIP: 👉 答案

codes[ret < 0]

条件在 ret 上算,掩码用在 codes 上。前提是两个数组等长且顺序对应。


二、🧰 组合条件:& | ~,而且必须加括号

多个条件组合时,numpy 用的符号和 Python 不一样:

含义 Python 里 numpy 数组上
and &
or \|
not ~
close[(close > 50) & (close < 200)]     # [136.]

两个坑,都会报错,但报的错让人摸不着头脑。

坑一:用了 and

close[(close > 50) and (close < 200)]
# ValueError: The truth value of an array with more than one element is ambiguous

因为 and 要求两边是单个真假值,而这里两边都是含 5 个元素的布尔数组。Python 不知道”一个数组整体算真还是算假”,只好报错。

& 则是逐元素做与运算,正好是我们要的。

坑二:忘了加括号

close[close > 50 & close < 200]
# TypeError

因为 & 的运算优先级> 还高,所以这句实际被解释成 close > (50 & close) < 200,完全不是你的本意。

只要用了 & | ~,就把每个条件用括号包起来。这不是风格问题,是必须。

🎮 随堂快练

QUESTION: 写出”取出市值大于 100 亿、且不属于 ST 的股票代码”。已有 mktcapis_st(布尔数组)、codes
TIP: 👉 答案

codes[(mktcap > 100) & (~is_st)]

is_st 本身已经是布尔数组,取反用 ~。两个条件各自加括号。


三、🧰 三个常用工具

np.where:按条件二选一

不是筛选,而是逐个位置做选择——满足条件取一个值,否则取另一个:

np.where(close > 100, "大", "小")
# ['大' '大' '小' '小' '小']

长度不变,只是每个位置被替换成了对应的值。常用来打标签、分档、做条件替换:

np.where(ret > 0, 1, -1)              # 涨跌方向
np.where(close < 0, np.nan, close)    # 把异常的负价格标成缺失

np.isin:判断在不在一个集合里

pool = ["600519", "600036"]
np.isin(codes, pool)          # [True False False False True]
close[np.isin(codes, pool)]   # [1685.01   27.58]

这是”从全市场里取出我关心的那个股票池”的标准写法。比写一串 (codes<mark>"A") | (codes</mark>"B") | ... 清楚得多。

按条件赋值:一次改一批

close[close < 50] = 0        # 所有小于 50 的,改成 0

不用循环,一句话改掉所有满足条件的元素。清洗数据时常用。

🎮 随堂快练

QUESTION: 把一组收益率 ret 里超过 ±10% 的极端值截断到 ±10%(做去极值)。写出两种写法。
TIP: 👉 答案

ret = np.where(ret > 0.1, 0.1, np.where(ret < -0.1, -0.1, ret))   # 嵌套 where
ret = np.clip(ret, -0.1, 0.1)                                      # ✅ 更好

np.clip 把意图(截断到某个区间)直接说出来了,嵌套 where 要读的人在脑子里绕一圈。能用专门的函数就别用通用的。


四、🔬 条件取数拿到的一定是副本

第 02 讲讲过:切片给你窗口,花式索引给你副本。布尔索引和花式索引一样,一定是副本

np.shares_memory(close, close[close > 100])    # False

sub = close[close > 100]
sub[0] = -1
print(close)     # 原数组没变

原因和花式索引一样:满足条件的元素在内存里位置不规则——可能是第 0、1、4 个——没法用”从哪开始、每次跳多远”描述,只能真的挑出来复制一份。

但”一步赋值”会改到原数组

close[close < 50] = 0        # 原数组变了

这看起来矛盾:既然布尔索引返回副本,为什么赋值能改到原数组?

因为 close[mask] = 0一条完整的赋值语句,numpy 认得这个形式,会直接把值写回原内存,中间不产生副本。

而下面这样写就不行了:

sub = close[close < 50]      # 第一步:产生副本
sub[:] = 0                   # 第二步:改的是副本
# close 没变

分成两步,中间那份副本就把你和原数据隔开了。

IMPORTANT: 🔑 这个区别在 pandas 里会变成一个著名的警告
第 21 讲你会遇到 SettingWithCopyWarning。它的成因和这里完全一样:你以为在改原数据,其实改的是一个临时副本
区别在于 pandas 会(在能察觉时)警告你一句,而 numpy 连警告都没有。

🎮 随堂快练

QUESTION: 下面哪一句能把 px 里的负价格改成 0?

px[px < 0] = 0                    # ①
bad = px[px < 0]; bad[:] = 0      # ②

TIP: 👉 答案
只有①有效。② 的 bad 是副本,改它和 px 无关。
判断方法:赋值语句的左边如果直接是 px[...],就是写回原数据;如果先赋给一个变量再改那个变量,就是在改副本。


🏋️ 训练营

QUESTION: 🟢 训练 1:close 是收盘价数组,codes 是对应代码。写出:① 价格在 50 到 200 之间的代码 ② 不在股票池 pool 里的代码 ③ 把价格为负的改成 NaN
TIP: 👉 参考
codes[(close > 50) & (close < 200)]
codes[~np.isin(codes, pool)]
close[close < 0] = np.nan

QUESTION: 🟡 训练 2:你要选出”市值前 20% 且当日未涨停”的股票。已有 mktcapretcodes。写出代码。
TIP: 👉 参考

threshold = np.percentile(mktcap, 80)          # 市值的 80 分位
mask = (mktcap >= threshold) & (ret < 0.099)   # 涨停约 10%,留点余量
codes[mask]

两点值得注意:
“前 20%”要先算出阈值,不能直接写死一个市值数字——不同时期市值分布差很多。
② 涨停判断写 < 0.099 而不是 < 0.1,因为浮点计算和实际涨停价的四舍五入会让”恰好 10%”变成 0.09999...0.10001这类边界值在真实数据里几乎总要留余量。

QUESTION: 🔴 训练 3:下面这段代码想”把停牌日(成交量为 0)的收益率标记为 NaN,然后统计有多少个交易日是上涨的”。有两个问题,找出来。

ret_clean = ret[vol > 0]              # ①
ret_clean[ret_clean == 0] = np.nan
up = ret > 0                           # ②
print(f"上涨天数: {up.sum()}")

TIP: 👉 参考
问题①ret[vol > 0]筛掉了停牌日,不是”标记为 NaN”。结果数组变短了,和原来的日期对不上了。而且 ret_clean 是副本,第 2 行改它对原数据毫无影响。
→ 要标记而不是删除,应该写 ret[vol == 0] = np.nan(一步赋值,改原数组)。
问题②:统计用的是 ret 而不是清洗后的数据。就算前面清洗对了,这里也白清洗了。而且 np.nan > 0False,NaN 会被静默算进”不上涨”。
→ 改:up = ret > 0,但要先确认 NaN 的处理方式——如果想排除停牌日,得用 np.nansum 或先筛掉 NaN。
改对后:

ret[vol == 0] = np.nan                 # 标记,不删除
up = ret > 0                            # NaN 会算作 False
print(f"上涨天数: {up.sum()},其中停牌 {np.isnan(ret).sum()} 天未计入")

这两个问题合起来是同一类错误:分不清”筛选”和”标记”,以及改的到底是原数据还是副本。


🐛 常见坑

  • ⚠️ and / or 组合数组条件:会报 ValueError。数组上要用 & | ~
  • ⚠️ 忘了给每个条件加括号& 优先级比 > 高,a > 1 & a < 5 会被解释成完全不同的意思。
  • ⚠️ bad = px[mask] 后改 bad 没用:布尔索引返回副本。要改原数据必须写成一步 px[mask] = v
  • ⚠️ 筛选和标记搞混px[mask] 是取出满足条件的(数组变短),px[mask] = v 是就地改(长度不变)。前者会让数据和日期对不上。
  • ⚠️ NaN 参与比较一律是 Falsenp.nan > 0False,缺失值会被静默归到”不满足”那一类。统计前先看 np.isnan(x).sum()
  • ⚠️ 边界值不留余量:判断涨停写 == 0.1 几乎必然漏掉真实数据里的 0.0999

✍️ 作业

  1. 造一组 100 只股票的 closeretmktcapcodes,完成训练营 1 的三个取数。
  2. 亲手验证优先级坑:分别跑 close[(close>50) & (close<200)]close[close>50 & close<200],看第二个报什么错,想清楚为什么。
  3. 验证”条件取数是副本”:sub = close[close>100],改 sub[0],确认 close 没变;再用 close[close>100] = 0,确认这次变了。
  4. 在数组里塞几个 np.nan,然后统计 (x > 0).sum(),再统计 np.isnan(x).sum(),确认 NaN 确实被算进了”不满足”那一边。
  5. 思考题:为什么 numpy 不能像 Python 那样支持 and?(提示:a and b 要求解释器先判断 a 整体的真假,才决定要不要看 b。对一个有 100 个元素的布尔数组,”整体的真假”该怎么定义?全 True 才算真?还是有一个 True 就算真?)

🔮 下讲预告:三种取数方式讲完了。第 05 讲开始进入 pandas——Series:给一维数组配上标签。你会看到本讲的布尔掩码、上一讲的标签取数、第 02 讲的位置取数,在 pandas 里怎么被统一成 lociloc 两个入口,以及为什么这个统一是必要的。


← 上一讲  ·  返回课程  ·  下一讲 →