📎 配套代码:
第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 的股票代码”。已有
mktcap、is_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% 且当日未涨停”的股票。已有
mktcap、ret、codes。写出代码。
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 > 0是False,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 参与比较一律是 False:
np.nan > 0是False,缺失值会被静默归到”不满足”那一类。统计前先看np.isnan(x).sum()。 - ⚠️ 边界值不留余量:判断涨停写
== 0.1几乎必然漏掉真实数据里的0.0999。
✍️ 作业
- 造一组 100 只股票的
close、ret、mktcap、codes,完成训练营 1 的三个取数。 -
亲手验证优先级坑:分别跑
close[(close>50) & (close<200)]和close[close>50 & close<200],看第二个报什么错,想清楚为什么。 - 验证”条件取数是副本”:
sub = close[close>100],改sub[0],确认close没变;再用close[close>100] = 0,确认这次变了。 - 在数组里塞几个
np.nan,然后统计(x > 0).sum(),再统计np.isnan(x).sum(),确认 NaN 确实被算进了”不满足”那一边。 - 思考题:为什么 numpy 不能像 Python 那样支持
and?(提示:a and b要求解释器先判断a整体的真假,才决定要不要看b。对一个有 100 个元素的布尔数组,”整体的真假”该怎么定义?全 True 才算真?还是有一个 True 就算真?)
🔮 下讲预告:三种取数方式讲完了。第 05 讲开始进入 pandas——Series:给一维数组配上标签。你会看到本讲的布尔掩码、上一讲的标签取数、第 02 讲的位置取数,在 pandas 里怎么被统一成
loc和iloc两个入口,以及为什么这个统一是必要的。