第02讲 按位置取数 元组列表与数组

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

🎬 开场:为什么先讲取数

做菜的时候,真正”炒”的动作可能只要三分钟,前面摘菜、洗菜、切配却要半小时。菜市场买回来的一把青菜,得掐掉黄叶、去根、按用途切段或切丝——不是因为切菜这件事本身多高明,而是不切好,后面根本没法下锅。

量化研究里,取数就是这道工序。

你手上的原始数据永远是一大坨:几千只股票、十几年的日线、几十个字段全堆在一起。而你每次要算的东西,几乎从来不需要这一整坨。真正的研究动作——算收益率、算相关性、做回归——往往就是几行;前面绝大部分代码,都在做”从这一大坨里,把我这次要的那一小块取出来”。

几个具体的例子:

你要做的事 需要先取出什么
算某只股票的年化波动率 这只股票的那一列,且只要最近 250 个交易日
比较沪深300成分股的表现 全市场里挑出这 300 只对应的那些列
检验某个因子在小盘股上是否更有效 按市值分组,取出市值最小的那 20%
剔除涨跌停的样本 按条件筛掉一部分行
做样本外检验 前 70% 的时间段训练,后 30% 测试——按时间切两段
排除新股上市初期的异常 每只股票掐掉上市后的前 60 天

这些都不是”高级操作”,但它们出现在每一个研究脚本里,而且往往出现好几次。取数写得顺,整个分析流程才顺。

还有一层更要紧的:切菜切错了,最多是卖相不好;取数取错了,后面所有的数字都是错的,而且不会有人提醒你。取成了别的股票、日期错位了一天、本来想剔除的样本没剔干净——这些都不报错,只会安静地让你的结论跑偏。


取数的三种方式

接下来三讲,把取数拆成三种方式讲:

方式 你说的话 典型场景
02(本讲) 按位置 “第 3 个””前 10 个””最后 5 天” 数据本身有顺序,你知道要第几个
03 按标签 “叫 close 的那一列””代码是 600519 的那只” 你知道名字,但不知道(也不该关心)它排第几
04 按条件 “所有收盘价大于 100 的””涨幅前 10% 的” 你不知道是哪些,只知道它们满足什么

这三种方式在 pandas 里会分别对应 ilocloc、布尔索引。但它们不是 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,2a[0:3] 结果一样、性质不同。

✍️ 作业

  1. 造一个 px = np.random.randn(250, 300).cumsum(axis=0) + 100,完成训练营 1 的四个取数,把每个结果的 .shape 打出来核对。
  2. 亲手踩一次坑:把训练 3 那段有 bug 的代码原样跑一遍,先确认它不报错,再打印 px[:20] 看原数据是不是被改了。
  3. 对同一份数据分别用列表和数组做切片,各改一个元素,验证”列表切片是副本、数组切片是窗口”。
  4. 思考题:为什么 numpy 要把花式索引设计成副本,而不是想办法做成窗口?(提示:窗口的读法只能用”从哪开始、每次跳多远”描述。任意位置列表 [0, 7, 19, 3] 能用这种方式描述吗?)

🔮 下讲预告:第 03 讲——按标签取数。位置取数有个大问题:数据一排序、一插入,位置全变了,你的 px[:, 3] 就指向了另一只股票。下一讲讲怎么用名字取数——字典、结构化数组,以及 pandas 为什么要给每一行每一列都配一个标签。


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