周一早盘前,你接手了一笔策略回测:沪深300 ETF(510300.SH)一年的日线,要算每日对数收益率(daily log return)。上一课你已经能把 CSV 流过来、用生成器逐行解析、再用 dataclass 装好。可一旦真要算数,你写下的还是那段熟悉的循环:
log_returns = []
for i in range(1, len(prices)):
log_returns.append(math.log(prices[i] / prices[i - 1]))
十二行能写完,对一年 252 个交易日尚可。可同样一段逻辑会出现在每一份回测脚本里——一旦换成 5 分钟线、A 股全市场 5000 只票同时算,Python 字节码逐元素跑的代价就开始压人:每元素 ~2 微秒,5000×252 个点要十多秒,绝大多数时间花在解释器开销上。NumPy 把这一切重写成:
log_returns = np.log(prices[1:] / prices[:-1])
一行、一遍 C 级循环(C-level pass)、对一年日线快 60–100 倍。这一课要做的就是把这一跳变得自然——从 ndarray 的数据模型一路讲到这条单行。
ndarray 数据模型
ndarray 是一块带 (dtype, shape, strides) 头的、定步长(fixed-stride)、连续内存的同质数组。同质(homogeneous)意味着所有元素共享一个数据类型(dtype),索引一格只需一次乘法加偏移;这是它能比 Python list 快两个数量级的根本原因。
import numpy as np
arr = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
print(arr.dtype) # float64
print(arr.shape) # (5,)
print(arr.ndim) # 1
print(arr.size) # 5
dtype 是元素类型:浮点字面量默认 float64,整数字面量在 64 位 Linux 上默认 int64。shape 是各维长度的元组——一维 252 天日线写作 (252,),三只票一年日收益矩阵写作 (252, 3)。ndim 等于 len(shape),size 等于各维之积,别和 len(arr) 混淆,后者只给第一维。
构造数组的几种方式
np.array(python_list) 接受任意嵌套可迭代,dtype 由内容推断;显式传 dtype=np.float32 可省一半内存——这一手等到 profiling 真的指认瓶颈在内存时再用。np.arange(start, stop, step) 像 range,允许浮点步长,但浮点端点漂移是已知坑;需要「恰好 N 等分」时改用 np.linspace(start, stop, num),端点闭区间、元素数严格等于 num。np.zeros(shape)、np.ones(shape)、np.full(shape, fill_value) 直接给你填好的形状;np.empty(shape) 速度最快但内容是未初始化的脏内存,新手代码里几乎只会变 footgun,绕开。
索引三层:基本切片、花式索引、布尔索引
NumPy 索引分三层,每层「是否共享内存」直接决定你能否就地改写。
第一层——基本切片(basic slicing)返回视图(view):
arr = np.arange(10)
view = arr[2:5]
view[0] = 99
print(arr) # [0 1 99 3 4 5 6 7 8 9]
view 和 arr 共用同一块底层内存(view.base is arr),对 view 的就地写穿透到 arr。二维同理:arr[:, 0] 取首列,arr[1:, ::2] 取从第 1 行起、每隔一列,全是视图。
第二层——花式索引(fancy / integer-array indexing)返回副本(copy):用整数序列选行,可以乱序。沪深300 ETF 一年日线里挑五个季末日:
quarter_ends = arr[[0, 62, 125, 188, 250]]
quarter_ends.base is None,对它写入不再回传到 arr。
第三层——布尔索引(boolean indexing)也返回副本:
mask = returns > 0
positive = returns[mask]
mask 是与 returns 同形的 bool 数组,returns[mask] 把所有 True 处的元素压成一维。日收益筛正天数、月度筛掉涨跌停标签外的样本都是这个模式。把这三层背熟,回头排「我改了切片为什么原数组也变了」之类的故障会快很多。
广播规则
NumPy 允许形状不完全相同的数组做逐元素运算,规则一句话:从最右维(rightmost dim)对齐,每一对要么相等、要么有一个为 1;前面缺的维当作 1。把 (T, N) 日收益矩阵按列减去每只票均值正是这个套路:
returns = np.array([[0.01, -0.02, 0.005], [0.02, 0.01, -0.01], [-0.005, 0.015, 0.02]])
col_mean = returns.mean(axis=0)
demeaned = returns - col_mean
returns 形状 (3, 3),col_mean 形状 (3,);从右对齐 3 ↔ 3 相等,左侧缺位补 1 得 (1, 3),沿时间轴重复——按列减均值。axis=0 沿哪一维归约属于下一课的主题,这里只借它构造一个能广播的 (N,) 向量。
反向操作(按行减日内均值)则不行:(T, N) - (T,) does not broadcast; reshape the second operand to (T, 1) (e.g. via [:, None]) first。形状 (T,) 从右对齐会落到 N 那一位上,对不齐时直接抛 ValueError——np.zeros((3, 4)) + np.zeros((3,)) 就是这条规则的反面教材。标量也走广播:prices * 1.01 把整组价乘 1%,标量被视作 (),缺位全部补 1。
通用函数与逐元素算术
+、-、*、/、** 作用在 ndarray 上时全部逐元素,np.log、np.exp、np.sqrt、np.abs、np.sin 等通用函数(universal function, ufunc)同理。这些算子最终都掉进一段 C 实现的紧循环(tight loop),对连续内存一遍扫到底,每元素的解释器开销摊到零——这就是开篇那条单行能比 Python for 快两个数量级的硬道理。
收官:把开篇的循环砸成一行
# prices: 沪深300 ETF (510300.SH) 一年 252 个交易日的日收盘价,dtype float64
prices = np.loadtxt("510300_close.csv")
log_returns = np.log(prices[1:] / prices[:-1])
prices[1:] 是「第 2 天到最后一天」的视图,prices[:-1] 是「首日到倒数第二天」的视图,逐元素相除得到每日的 P_t / P_{t-1},外层一次 np.log ufunc 同样逐元素跑过。结果 log_returns 长度 len(prices) - 1,dtype float64,没有任何 Python 字节码在内层循环里出现。
附带两条提示:A 股一年实际为 244–252 个交易日(节假日波动),所以真实数据集长度未必恰是 252,写测试时别把长度硬编码。结构化数组(np.dtype([('symbol', 'U10'), ('price', 'f8')]))这种东西存在,但凡你要把字符串列和数值列放一起,下一节 3.2.2 的 Pandas DataFrame 才是正确答案;这里就不展开了。
练习
Exercise
给定长度为 N 的一维 NumPy 数组 prices,表示某只股票的日收盘价。请写出一个单一的 NumPy 表达式(不允许任何 Python for、不允许列表推导式),返回长度为 N-1 的一维数组,元素为日简单收益率 prices[t] / prices[t-1] - 1;再写一个表达式返回日对数收益率 log(prices[t] / prices[t-1])。两个表达式都只能用切片和逐元素算术。
提示
prices[1:],分母是「到倒数第二天」prices[:-1]。两个切片都是视图,长度都是 N-1,恰好对齐。提示
prices[1:] / prices[:-1] - 1,标量 1 自动广播;对数收益再用 np.log 包一层:np.log(prices[1:] / prices[:-1])。衔接下一课
到这里你能按 dtype、shape、维度看清楚任意一维或二维数组,能区分视图与副本的写入语义,能按广播规则把 (T, N) 矩阵与 (N,) 均值对上。下一课要换一个动词——mean / std / var / cov 等聚合的逐元素版本你刚才已经见过,但真正每天用的是沿哪条轴归约。沿 axis=0 收掉时间,得到每只票的均值与年化波动;沿 axis=1 收掉票号,得到每日组合的等权回报;不带 axis 才退化成全数组标量。同一只 np.std 加不加 ddof 差一个自由度,落到协方差矩阵还是 t 统计量上就此分家。下一课就把这把刀磨利。