第03讲 按标签取数 字典与结构化数组

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

🎬 开场:位置是会变的

本讲的样本是 2024-01-02 的真实收盘数据(取自本地 tushare pro_bar),只取四五只股票是为了每个值都看得清。

上一讲用位置取数,close[0] 取第一只股票的价格。看这段代码:

codes = ["600519", "000858", "601318", "000001"]
close = np.array([1685.01, 136.00, 39.47, 9.21])

close[0]        # 1685.01  —— 贵州茅台

现在你要按价格排个序(研究里再常见不过的动作):

order  = np.argsort(close)          # 升序
codes  = [codes[i] for i in order]
close  = close[order]

close[0]        # 9.21   ← 变成平安银行了

代码一个字没改,close[0] 指向的股票变了。

位置只是数据当前排列顺序的一个副产品。排序、插入、删除、合并、筛选——这些动作都会重排位置。而你真正想指的那个东西(贵州茅台)从来没变过,变的只是它排第几。

这就是标签取数存在的理由:你说的是”茅台”,不是”第 0 个”

在量化里,这个问题格外要命,因为你几乎总在做重排数据的动作:

你做的事 位置发生了什么
按市值排序选股 全部重排
剔除 ST 股 后面所有股票的位置前移
两张表按代码合并 顺序取决于合并方式
按日期筛选 行位置全变
新股上市 插入新行,后面全移位

只要你的代码里写着”第 3 列”,任何一次重排都可能让它悄悄指向别的东西。而且不报错。


一、🧰 字典:最基本的标签取数

Python 里最常用的标签容器就是字典——用名字存,用名字取

close = {"600519": 1685.01, "000858": 136.00, "601318": 39.47}

close["600519"]      # 1685.01

不管字典里的键是什么顺序,close["600519"] 永远给你茅台的价格。

取不到怎么办

直接用 [] 取一个不存在的键会报 KeyError。多数时候你不希望程序就这么停掉:

close["999999"]                 # ❌ KeyError
close.get("999999")             # None      —— 取不到给 None
close.get("999999", 0.0)        # 0.0       —— 取不到给你指定的默认值

.get() 在处理真实数据时很有用:一批股票里有几只当天停牌、数据源没给,用 .get(code, np.nan) 可以让流程继续走下去,同时把缺失标记成 NaN,而不是让整个脚本崩掉。

WARNING: ⚠️ 但”不崩掉”不等于”没问题”
.get(code, 0.0) 把缺失填成 0,代码不报错了——可 0 元的股价是个荒谬的数字,它会一路流进你的收益率计算。默认值填什么,是个需要想清楚的决定:填 np.nan 通常比填 0 安全,因为 NaN 会在后续计算里保持”缺失”这个状态,而 0 会伪装成一个正常数字。

常用操作

"600519" in close          # True    —— 判断有没有
close.keys()               # 所有标签
close.values()             # 所有值
close.items()              # 成对拿出来,常用来遍历
for code, px in close.items():
    print(code, px)

用推导式批量取

给定一个股票池,把它们的价格都取出来:

pool = ["600519", "601318"]
{code: close[code] for code in pool}              # 假设都存在
{code: close.get(code, np.nan) for code in pool}  # 更稳妥

🎮 随堂快练

QUESTION: 你有一个字典 mktcap(代码 → 市值),和一个股票池列表 pool。写代码取出池子里所有股票的市值,池子里可能有几只不在字典中。
TIP: 👉 答案

{c: mktcap.get(c, np.nan) for c in pool}

.get(c, np.nan) 而不是 mktcap[c]——后者遇到不存在的代码会直接 KeyError 中断。填 np.nan 而不是 0,因为市值为 0 的股票在后续排序、分组里会被当成”最小市值”,而 NaN 会被正确识别为缺失。


二、🧰 结构化数组:给数组的列起名字

字典解决了”按名字取”,但它有个短板:装不了二维表格

行情数据是这样的:每一行是一条记录,每条记录有代码、收盘价、成交量几个字段。用字典表示会很别扭。

numpy 的结构化数组正好补这个位置——给数组的每一列起个名字

dt = np.dtype([("code", "U6"), ("close", "f8"), ("vol", "i8")])
arr = np.array([("600519", 1685.01, 32156),
                ("000858",  136.00, 215269)], dtype=dt)

dtype 在这里描述了一条记录的结构:code 是 6 个字符的字符串、close 是 8 字节浮点、vol 是 8 字节整数。

取数就有了两个方向:

arr["close"]     # 按字段名取一整列  → [1685.01  136.  ]
arr["code"]      # → ['600519' '000858']
arr[0]           # 按位置取一整行    → ('600519', 1685.01, 32156)
arr[0]["close"]  # 先取行再取字段    → 1685.01

这已经很接近 DataFrame 了:按名字取列、按位置取行。区别在于 DataFrame 的行也有标签,而且功能多得多。

按字段取是视图

np.shares_memory(arr, arr["close"])    # True

取一整列不复制数据,改它会改到原数组。这和上一讲的规律一致:字段在内存里是等间隔排列的(每条记录占固定字节数,同一字段每次跳过一条记录的长度),所以能用窗口表达。

🎮 随堂快练

QUESTION: 用上面的 arr,写出:① 所有股票的成交量 ② 第二条记录的代码 ③ 判断 arr["close"] 改动后会不会影响 arr
TIP: 👉 答案
arr["vol"] ② arr[1]["code"](或 arr["code"][1]) ③ 会影响,因为按字段取返回的是视图,np.shares_memory 返回 True。要独立副本得写 arr["close"].copy()


三、🤔 位置还是标签:怎么选

两种方式都能取到同一个数,但适用场景不同。

按位置 按标签
你说的话 “第 3 个” “叫 close 的那个”
数据重排后 失效(指向别的东西) 仍然正确
需要知道 它排第几 它叫什么
适合 数据顺序本身有意义(时间序列的”最近 20 天”) 数据顺序无意义(哪只股票、哪个字段)
出错方式 静默指错,不报错 取不到会 KeyError报错

最后一行很关键:标签取数错了会报错,位置取数错了不会

close["600520"](代码写错一位)立刻 KeyError,你马上知道。而 close[3](本该是 2)安静地返回一个数字,你可能到最后都发现不了。

什么时候该用哪个

用位置:维度本身就是有序的,且顺序有含义。

  • 时间序列的”最近 250 天”:px[-250:]
  • 训练集/测试集切分:px[:split] / px[split:]

用标签:这个维度只是”一堆东西”,顺序是任意的。

  • 哪只股票:d["600519"]
  • 哪个字段:arr["close"]

行情矩阵正好是这两者的组合——行(时间)用位置,列(股票)用标签。这正是 pandas 的 DataFrame 要做的事:给行和列都配上标签,同时保留按位置取的能力。

IMPORTANT: 🔑 pandas 的设计动机
到第 05–07 讲你会看到,pandas 把这两套方式做成了两个明确的入口:

  • df.iloc[0] —— 按位置(i = integer)
  • df.loc["600519"] —— 按标签

分成两个名字,就是为了不让你搞混。numpy 只有一套 [],你得自己记住方括号里的东西是位置还是别的什么;pandas 强迫你说清楚。

🎮 随堂快练

QUESTION: 下面两个需求,各该用位置还是标签?
① 取某只股票最近 60 个交易日的收盘价
② 从一张有 30 个字段的行情表里取出”收盘价”和”成交量”两列
TIP: 👉 答案
两者都用:股票用标签(哪只)、时间用位置(最近 60 天)。
标签。字段的排列顺序是任意的——数据源换个版本、多加一个字段,位置就变了。写 arr"close","vol" 而不是 arr[:, [3, 7]]


🏋️ 训练营

QUESTION: 🟢 训练 1:你有 close = {"600519": 1685.01, "000858": 136.00}。写出:① 取茅台的价格 ② 取一个可能不存在的代码,取不到时返回 NaN ③ 判断 “601318” 在不在里面
TIP: 👉 参考
close["600519"] ② close.get(code, np.nan) ③ "601318" in close

QUESTION: 🟡 训练 2:下面这段代码想”取出市值最大的 3 只股票的代码”,但有个隐患。指出来。

order = np.argsort(mktcap)[::-1]     # 按市值降序
top3  = codes[:3]                     # 取前 3 个代码

TIP: 👉 参考
order 算出来了,但根本没用上codes[:3] 取的还是原始顺序的前三只,和市值毫无关系。
正确写法要用 order 去重排 codes

top3 = [codes[i] for i in order[:3]]

这个 bug 不报错,还会给你三个看起来很正常的股票代码——这正是位置取数最典型的出错方式:你以为位置反映了某种顺序,其实没有。

QUESTION: 🔴 训练 3:你要把两个数据源的价格对上——A 源是字典 {代码: 价格},B 源是两个平行的列表 codes_bprices_b(第 i 个代码对应第 i 个价格)。写代码找出两边价格差异超过 1% 的股票。
TIP: 👉 参考

b = dict(zip(codes_b, prices_b))        # 先把 B 源也变成字典
diff = {c: abs(a_price[c] - b[c]) / b[c]
        for c in a_price.keys() & b.keys()    # 只比较两边都有的
        if abs(a_price[c] - b[c]) / b[c] > 0.01}

关键有两点:
先把平行列表转成字典dict(zip(...)))。两个平行列表靠位置对应,非常脆弱——任何一方排序变了,对应关系就全错,而且不报错。
keys() & keys() 取交集,只比较两边都有的股票。直接遍历 A 的键去查 B 会 KeyError


🐛 常见坑

  • ⚠️ 算了排序却没用上np.argsort 返回的是位置数组,你得拿它去重排数据,光算不用等于没做。
  • ⚠️ 两个平行列表靠位置对应:任何一方重排,对应关系全错且不报错。及早转成字典或结构化数组。
  • ⚠️ .get() 的默认值填 0:0 会伪装成正常数字流进后续计算。填 np.nan 更安全。
  • ⚠️ 字段位置写死arr[:, 3] 在数据源加一个字段后就指向别处。用字段名。
  • ⚠️ 结构化数组按字段取是视图:改它会改原数组,要独立副本得 .copy()

✍️ 作业

  1. 把开场那段代码跑一遍:建 codesclose,打印 close[0];然后按价格升序重排,再打印 close[0],确认它变了。
  2. dict(zip(codes, close)) 把它们转成字典,重复上面的排序操作,验证 d["600519"] 不受影响。
  3. 造一个有 code / close / vol 三个字段的结构化数组,分别按字段名和按行位置取数,并用 np.shares_memory 验证按字段取是不是视图。
  4. 思考题:既然标签取数更安全,为什么不干脆全部用标签、彻底抛弃位置?(提示:想想”最近 20 个交易日”这个需求——你知道那 20 天分别是哪天吗?如果知道,写起来方便吗?)

🔮 下讲预告:第 04 讲——按条件取数。前两讲你都得先知道”要第几个”或”叫什么名字”。但研究里更多的情况是:你不知道是哪些,只知道它们满足什么条件——涨幅超过 5% 的、市值前 20% 的、剔除掉 ST 的。下一讲讲布尔掩码,以及一个在 pandas 里会反复出现的陷阱。


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