📎 配套代码:
第02讲_按位置取数.py
📊 配套数据:本讲用课件内的小样本(2024-01-02 真实收盘快照),无需外部数据
🎬 开场:为什么先讲取数
做菜的时候,真正”炒”的动作可能只要三分钟,前面摘菜、洗菜、切配却要半小时。菜市场买回来的一把青菜,得掐掉黄叶、去根、按用途切段或切丝——不是因为切菜这件事本身多高明,而是不切好,后面根本没法下锅。
量化研究里,取数就是这道工序。
你手上的原始数据永远是一大坨:几千只股票、十几年的日线、几十个字段全堆在一起。而你每次要算的东西,几乎从来不需要这一整坨。真正的研究动作——算收益率、算相关性、做回归——往往就是几行;前面绝大部分代码,都在做”从这一大坨里,把我这次要的那一小块取出来”。
几个具体的例子:
| 你要做的事 | 需要先取出什么 |
|---|---|
| 算某只股票的年化波动率 | 这只股票的那一列,且只要最近 250 个交易日 |
| 比较沪深300成分股的表现 | 全市场里挑出这 300 只对应的那些列 |
| 检验某个因子在小盘股上是否更有效 | 按市值分组,取出市值最小的那 20% |
| 剔除涨跌停的样本 | 按条件筛掉一部分行 |
| 做样本外检验 | 前 70% 的时间段训练,后 30% 测试——按时间切两段 |
| 排除新股上市初期的异常 | 每只股票掐掉上市后的前 60 天 |
这些都不是”高级操作”,但它们出现在每一个研究脚本里,而且往往出现好几次。取数写得顺,整个分析流程才顺。
还有一层更要紧的:切菜切错了,最多是卖相不好;取数取错了,后面所有的数字都是错的,而且不会有人提醒你。取成了别的股票、日期错位了一天、本来想剔除的样本没剔干净——这些都不报错,只会安静地让你的结论跑偏。
取数的三种方式
接下来三讲,把取数拆成三种方式讲:
| 讲 | 方式 | 你说的话 | 典型场景 |
|---|---|---|---|
| 02(本讲) | 按位置 | “第 3 个””前 10 个””最后 5 天” | 数据本身有顺序,你知道要第几个 |
| 03 | 按标签 | “叫 close 的那一列””代码是 600519 的那只” | 你知道名字,但不知道(也不该关心)它排第几 |
| 04 | 按条件 | “所有收盘价大于 100 的””涨幅前 10% 的” | 你不知道是哪些,只知道它们满足什么 |
这三种方式在 pandas 里会分别对应 iloc、loc、布尔索引。但它们不是 pandas 发明的——Python 原生的元组、列表,以及 numpy 数组,早就有这三种取法。先在这些基础容器上把手感练出来,后面上 pandas 就是换个外壳。
本讲讲第一种:按位置。
一、🧰 三种容器,同一套位置语法
元组、列表、numpy 数组,按位置取数的语法几乎一样:
t = (10, 11, 12, 13, 14) # 元组 l = [10, 11, 12, 13, 14] # 列表 a = np.array([10, 11, 12, 13, 14]) # 数组
| 操作 | 写法 | 元组 | 列表 | 数组 |
|---|---|---|---|---|
| 取单个 | x[2] |
12 |
12 |
12 |
| 倒数第一个 | x[-1] |
14 |
14 |
14 |
| 切一段 | x[1:4] |
(11,12,13) |
[11,12,13] |
[11 12 13] |
| 隔一个取 | x[::2] |
✅ | ✅ | ✅ |
| 反向 | x[::-1] |
✅ | ✅ | ✅ |
位置从 0 开始,切片 [a:b] 是含头不含尾——取到的是第 a 个到第 b−1 个,长度正好 b−a。
负数从末尾数起,-1 是最后一个。这在时间序列里很常用:px[-250:] 就是”最近 250 个交易日”,不用先去数总共有多少行。
完整的切片形式:[开始 : 结束 : 步长]
三个部分都可以省略:
a[2:] # 从第 2 个到末尾 a[:3] # 从头到第 2 个 a[:] # 全部 a[::2] # 全部,但隔一个取一个 a[::-1] # 全部,倒过来 a[1:4:2] # 第 1 到第 3 个之间,隔一个取
TIP: 💡 切片不会越界报错
a[1:100]不会报错,只会给你从第 1 个到末尾的所有元素。而a[100](取单个)会报IndexError。
这个差别在写循环边界时经常救命,但也可能掩盖问题——你以为取到了 100 个,其实只有 5 个。
🎮 随堂快练
QUESTION: 一个列表
prices装着某只股票 300 天的收盘价。写出:① 最近 20 天 ② 最早 60 天之外的全部 ③ 每 5 天取一个(做周频抽样)
TIP: 👉 答案
①prices[-20:]②prices[60:]③prices[::5]
⚠️ ③ 严格说是”每 5 个取一个”,不等于真正的周频重采样——遇到停牌、节假日就会错位。真正的周频要用日期来对齐(第 18–19 讲)。这里只是位置抽样。
二、🧰 二维数据:数组能做,列表做不了
上面三种容器看着差不多。但一到二维,差距就出来了。
行情数据天然是二维的:行 = 交易日,列 = 股票。
px = np.random.randn(30, 500).cumsum(axis=0) + 100 # 30 天 × 500 只
numpy 数组用逗号分隔两个维度,每个维度独立切片:
px[0] # 第 0 天,所有股票 → (500,) px[:, 3] # 第 3 只股票,所有天 → (30,) px[-5:] # 最后 5 天,所有股票 → (5, 500) px[5:10, 2:6] # 第 5-9 天 × 第 2-5 只 → (5, 4) px[:, ::-1] # 股票顺序反过来 → (30, 500)
px[:, 3] 里的 : 表示”这个维度全要”。先行后列,和数学上写矩阵的习惯一致。
而嵌套列表做不到这一点:
m = [[1,2,3], [4,5,6]] m[:, 0] # ❌ TypeError:列表不认识"逗号分隔的维度" [row[0] for row in m] # ✅ 只能这样,用推导式逐行取
这是从列表转向数组的第一个实际理由:多维数据的取数,数组能一行写完,列表得写循环。
🎮 随堂快练
QUESTION:
px是 30 天 × 500 只的行情矩阵。写出:① 第 100 只股票最近 10 天的价格 ② 前 5 只股票的全部历史
TIP: 👉 答案
①px[-10:, 100]②px[:, :5]
⚠️ 注意 ① 的顺序:先写行(时间)再写列(股票)。写成px[100, -10:]就变成”第 100 天的最后 10 只股票”——不报错,但完全是另一回事。这类错误全靠自己看出来。
三、🧰 按任意位置取:花式索引
切片只能表达”等间隔”的位置。但你常常要的是任意几个——比如”第 0、7、19 这三只股票”。
a0, 3, 4 # 数组:直接给一个位置列表 px[:, [0, 7, 19]] # 二维:取这三只股票的全部历史
这叫花式索引。列表做不到,只能用推导式:
[l[i] for i in [0, 3, 4]] # 列表只能这样
在量化里这个操作极其常见——你有一个股票池(比如沪深300的成分股在全市场里的位置),要从大矩阵里把它们挑出来,就是花式索引。
WARNING: ⚠️ 花式索引和切片,语法像,性质完全不同
a[0:3]和a0,1,2结果一模一样,但一个是切片、一个是花式索引。它们在内存上的行为相反——这是第四节的内容,也是这一讲最容易出事的地方。
🎮 随堂快练
QUESTION: 你有一个
idx = [3, 17, 42],是三只目标股票在矩阵里的列号。写出取它们最近 60 天数据的代码。
TIP: 👉 答案
px[-60:, idx]
行用切片(最近 60 天,等间隔),列用花式索引(任意三个位置)。两种方式可以混用,各维度互不干扰。
四、🔬 取出来的东西,是原数据还是副本
这一节解释一个容易踩的坑:你取出来的那一块,改动它的时候,会不会连原始数据一起改掉。
同样是切片,列表和数组行为相反
l = [10, 11, 12, 13, 14] l2 = l[1:4] # 列表切片 l2[0] = 999 print(l) # [10, 11, 12, 13, 14] ← 原列表没变 a = np.array([10, 11, 12, 13, 14]) a2 = a[1:4] # 数组切片 a2[0] = 999 print(a) # [10, 999, 12, 13, 14] ← 原数组变了!
列表切片给你一份新的,数组切片给你的是原数据上的一个窗口。同样的语法,相反的结果。
numpy 这么设计是有道理的:行情矩阵动辄几百万个数,每次切片都复制一份太浪费。所以它默认让你”透过窗口看原数据”,需要独立副本时你自己说。
怎么判断
np.shares_memory(a, a[1:4]) # True 切片 → 窗口 np.shares_memory(a, a0,3,4) # False 花式索引 → 副本
规律是:能用”等间隔跳步”描述的(切片),是窗口;要挑不等间隔位置的(花式索引),是副本。
因为窗口的本质是”换一种读法去读同一块内存”——只要位置是等间隔的,读法就能用几个数字描述清楚(从哪开始、每次跳多远)。而任意位置列表描述不了,只能真的把数据挑出来复制一份。
这决定了你该怎么写
sub = px[:10] # 窗口:改 sub 会改到 px sub = px[:10].copy() # 副本:改 sub 与 px 无关
只读就用切片(零成本),要改就加 .copy()。别为了省一次复制而在要改动的地方用切片——省下的那点内存,远不如”污染了原始数据还找不到原因”的代价大。
🎮 随堂快练
QUESTION: 下面哪几行会改到
px?a = px[:5]; a[0] = 0 # ① b = px[:5]; b = b * 2 # ② c = px0,1; c[0] = 0 # ③TIP: 👉 答案
只有①会改。
② 不会——b = b * 2是让变量名b指向了一个新数组,px没被碰过。要改原数据得用b *= 2(原地乘)。
③ 不会——px0,1是花式索引,拿到的已经是副本了。
③ 最坑:它和 ① 长得几乎一样,结果却相反。
🏋️ 训练营
QUESTION: 🟢 训练 1:
px是 250 天 × 300 只的行情矩阵。写出下面四个取数。
① 最后一个交易日全部股票的价格
② 第 50 只股票的完整历史
③ 前 60 天、后 100 只股票
④ 每 5 天抽一次的全部数据
TIP: 👉 参考
①px[-1]②px[:, 50]③px[:60, -100:]④px[::5]
③ 注意”后 100 只”是-100:,不是200:——用负数就不必知道总共多少列,列数变了代码也不用改。
QUESTION: 🟡 训练 2:你有
pool = [12, 45, 78, 103],是四只目标股票的列号。要取它们最近 20 天的数据,并且后面要对这份数据做标准化(会修改它),原始px不能受影响。写出完整代码。
TIP: 👉 参考sub = px[-20:, pool].copy() sub = (sub - sub.mean(axis=0)) / sub.std(axis=0)这里
.copy()其实可以省——花式索引本来就返回副本。但显式写出来更好:读代码的人一眼看出你的意图是”我要一份独立的”,不用去回忆”花式索引是不是副本来着”。
QUESTION: 🔴 训练 3:下面这段清洗代码有两个 bug,都不报错。找出来并改对。
clean = px[:20] # 取前 20 天做清洗 clean[clean < 0] = 0 # 负价格清零 first = clean[0] first = first / first.mean() # 第 0 天做归一化TIP: 👉 参考
Bug①:px[:20]是切片,拿到的是窗口。第 2 行的清零改到了原始数据px。后面还要用原始数据的话,它已经被污染了。
→ 改:clean = px[:20].copy()
Bug②:first = first / first.mean()是变量重绑定,clean[0]根本没变。
→ 改:clean[0] /= clean[0].mean()(原地除)
改对后:clean = px[:20].copy() clean[clean < 0] = 0 clean[0] /= clean[0].mean()两个 bug 是一对镜像:一个”以为没改却改了原数据”,一个”以为改了却没改”。都不报错。
🐛 常见坑
- ⚠️ 二维取数写反了行列:
px[100, -10:]和px[-10:, 100]都合法,含义完全不同。先行后列。 - ⚠️ 切片越界不报错:
a[1:100]静默返回能取到的部分。你以为有 100 个,其实只有 5 个。 - ⚠️ 数组切片是窗口,改它会连坐原数组:要独立副本必须显式
.copy()。 - ⚠️ 列表切片和数组切片行为相反:列表给副本,数组给窗口。
- ⚠️
b = b * 2改不到原数据:变量重绑定不是原地修改。要改用b *= 2。 - ⚠️ 花式索引即使位置连续也是副本:
a0,1,2和a[0:3]结果一样、性质不同。
✍️ 作业
- 造一个
px = np.random.randn(250, 300).cumsum(axis=0) + 100,完成训练营 1 的四个取数,把每个结果的.shape打出来核对。 -
亲手踩一次坑:把训练 3 那段有 bug 的代码原样跑一遍,先确认它不报错,再打印
px[:20]看原数据是不是被改了。 - 对同一份数据分别用列表和数组做切片,各改一个元素,验证”列表切片是副本、数组切片是窗口”。
- 思考题:为什么 numpy 要把花式索引设计成副本,而不是想办法做成窗口?(提示:窗口的读法只能用”从哪开始、每次跳多远”描述。任意位置列表
[0, 7, 19, 3]能用这种方式描述吗?)
🔮 下讲预告:第 03 讲——按标签取数。位置取数有个大问题:数据一排序、一插入,位置全变了,你的
px[:, 3]就指向了另一只股票。下一讲讲怎么用名字取数——字典、结构化数组,以及 pandas 为什么要给每一行每一列都配一个标签。