周二上午 9 点半,上证刚开盘。你坐在一家中型私募的研究台,手边是一段从 3.2.1 留下的 NumPy 代码:一个 (T, N) 的日对数收益矩阵,T = 244,N = 3,列依次是 510300.SH、600519.SH、000001.SZ。你想把 600519.SH 在 2024-02-08(春节休市前最后一个交易日)这天的收益单独捞出来——但你眼前只有一个 np.ndarray 加两根 list[str],一根记录日期、一根记录票号。你得先去 dates.index('2024-02-08') 拿到行号,再去 tickers.index('600519.SH') 拿到列号,然后下标进去。两周后做行业归并时,那两根 list 没跟着重排一次,于是 600519.SH 那列的收益就被静默贴到了 000001.SZ 上,你的 PM 在月度复盘时才看出银行板块的归因里混进了白酒。
Pandas 存在的全部理由,就是把这两根 list 升格为一等公民:行有 index,列有 columns,下标用名字而不是位置;对齐和容错由库来做。这一课讲数据模型本身——把名字和形状摸熟,后面四节才有地方挂动词。
序列:带标签的一维数组
最底层的对象是 序列(Series)——一根定长的、有 数据类型(dtype)的一维数组,再贴上一根等长的 标签(label)数组 index,可选地带一个名字 name。把 510300.SH 五个交易日的日对数收益写成 Series:
import pandas as pd
s = pd.Series([0.01, -0.02, 0.005, 0.012, -0.004], index=pd.to_datetime(['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05', '2024-01-08']), name='log_return')
print(s.dtype)
print(s.index)
print(s.shape)
print(s.name)
四行输出依次给出 float64(一列同构数值)、一个 DatetimeIndex(注意 2024-01-06 与 2024-01-07 是周末,上证不交易;2024-01-08 是下一个交易日,index 自动留出这道周末缺口)、(5,)(一维长度 5)、'log_return'。除了上面的「值 + index + name」三件套,构造 Series 还有两条路:从 dict 来(键直接变成 index),或者从 np.ndarray 来(不传 index 时默认是 RangeIndex(0, n))。
对齐:Pandas 相对 NumPy 最值钱的属性
Series 与标量做算术是逐元素的,s * 100 把每一项乘 100。Series 与 Series 做算术则按 index 自动对齐(alignment)——同一个标签下的值相加,标签不重合的位置补 NaN。这正是 3.2.1 那两根并行 list 会丢的那条信息:
a = pd.Series([1.0, 2.0, 3.0], index=['x', 'y', 'z'])
b = pd.Series([10.0, 20.0, 40.0], index=['x', 'y', 'w'])
a + b
# Index: ['w', 'x', 'y', 'z']
# Values: [NaN, 11.0, 22.0, NaN]
a + b 的 index 是两边标签的并集 ['w', 'x', 'y', 'z'](按字典序排好),'x' 处 1 + 10 = 11、'y' 处 2 + 20 = 22;'w' 只在 b 里出现、'z' 只在 a 里出现,两处的结果都是 NaN。两条 NumPy 数组按位置相加只会算长度对不齐然后抛错,不会按 label 对齐——这条差异是后面跨表合并、跨频率行情拼接的根。
数据框:共享 index 的一组 Series
数据框(DataFrame)的心智模型不是「二维 ndarray」,而是「一组共享 index 的 Series 字典」。三种构造方式都常用:
import pandas as pd
dates = pd.to_datetime(['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05', '2024-01-08'])
df = pd.DataFrame({
'510300.SH': [0.01, -0.02, 0.005, 0.012, -0.004],
'600519.SH': [0.003, 0.011, -0.007, 0.002, 0.015],
'000001.SZ': [-0.005, 0.008, 0.001, -0.003, 0.020],
}, index=dates)
df.shape 是 (5, 3),df.index 是上证 5 个交易日的 DatetimeIndex,df.columns 是三只票号,df.dtypes 给每一列各自一个 dtype。最后这一点很关键:DataFrame 不是同构数组,同一张表里 float64 列和 object 列共存是正常的,因为底层就是个字典,不是一块连续的二维 buffer。df.head() / df.tail() 看头尾几行,df.size 是 shape 的乘积。
三种索引:列、标签、位置
落到取数,把以下三种 access 分开记。
A:列访问 df['col'] 返回那一列的 Series;df[['col1', 'col2']] 返回两列的 DataFrame。它不是 NumPy 里 arr[:, i] 的同款——这里下标的对象是列名,不是行号。
B:标签索引 .loc 走名字。df.loc['2024-01-03'] 返回那一行的 Series(以列名为 index);df.loc['2024-01-03', '510300.SH'] 返回单个标量;切片如下:
df.loc['2024-01-03':'2024-01-05'] # 3 行:包含 1-03、1-04、1-05
记一条规则:.loc 切片对两端都是 inclusive(含两端),.iloc 切片像 Python 切片一样是 half-open(左闭右开)。df.loc['2024-01-03':'2024-01-05'] 返回 3 行(含 1-05),df.iloc[0:3] 返回 3 行(不含位置 3)。两端含与不含的语义差一个边界,写 backtest 的入场出场日时一次写错就是少跑一天。
C:位置索引 .iloc 走整数下标,左闭右开。同一个标量两种取法都得到当日 510300.SH 的收益:
df.loc['2024-01-03', df.columns[0]] # 标签索引
df.iloc[1, 0] # 位置索引
规则一句话:有标签用 .loc,按位置用 .iloc,永远不要用裸 df[...] 取行——裸 df['x'] 是列访问,裸 df[0:3] 又退化为行切片,两种含义共享同一种语法是 Pandas API 的历史伤疤。布尔掩码(boolean mask)也走 .loc:df.loc[df['510300.SH'] > 0] 给出 510300.SH 当日为正的那些行。
两个高频陷阱
陷阱 1:链式赋值(chained assignment)。想把 510300.SH 当天为正的收益清零,下面这种写法看着自然,其实是个坑:
# 错误写法:触发 SettingWithCopyWarning,不保证写回 df
df[df[df.columns[0]] > 0][df.columns[0]] = 0.0
# 正确写法:单次 .loc,写入保证生效
df.loc[df[df.columns[0]] > 0, df.columns[0]] = 0.0
df[df[df.columns[0]] > 0] 先生成一个临时副本(temporary copy,对应 Pandas 的 view-vs-copy 语义),后面那次列赋值赋到那个匿名副本上,原 df 文风不动;Pandas 会抛 SettingWithCopyWarning 提醒,但 warning 不是 error,跑批脚本一不留神就静默错下去。两步 [ ][ ] 拍成一次 .loc[mask, col] = value,含义明确且写入保证生效。
陷阱 2:Series vs 单列 DataFrame。df[col] 与 df[[col]] 长得很像,形状完全不同:
series_form = df[df.columns[0]] # Series,形状 (5,)
frame_form = df[[df.columns[0]]] # DataFrame,形状 (5, 1)
type(series_form).__name__ # 'Series'
type(frame_form).__name__ # 'DataFrame'
series_form.mean() 给一个标量;frame_form.mean() 给一个长度 1 的 Series。下游若是 sklearn 一类要求二维特征矩阵的接口,前者要 .to_frame() 升维、后者直接能喂;多一对方括号决定一段调用栈的形状,写之前先想清楚要 1D 还是 2D。
收尾:把日期贴回 3.2.1 的对数收益
把 3.2.1 末尾那一行 np.log(prices[1:] / prices[:-1]) 贴回来,外面套一层 pd.Series 配上日期:
import numpy as np
import pandas as pd
prices = np.array([3.85, 3.89, 3.87, 3.93, 3.91])
dates = pd.to_datetime(['2024-01-02', '2024-01-03', '2024-01-04', '2024-01-05', '2024-01-08'])
log_return = pd.Series(np.log(prices[1:] / prices[:-1]), index=dates[1:], name='log_return')
log_return.loc['2024-01-03'] # 按日期取,而不是按位置
同一段 ndarray 贴上 DatetimeIndex 之后,「拿 1-03 的收益」就从「先去 list 查下标再回数组」简化成 log_return.loc['2024-01-03']。.to_numpy() 是反向通道——需要把 Series 喂给只认 ndarray 的 NumPy / SciPy 例程时,调用 s.to_numpy() 给出底层数组(视图还是副本由 dtype 是否需要转换决定)。新代码统一用 .to_numpy(),老代码里出现的 .values 还能工作但不再是首选。
练习
Exercise
给定一个 pd.DataFrame df,其 index 是交易日的 DatetimeIndex,每一列是一只票号、值是当日的收盘价。请写出一个单一表达式,返回一个新的 DataFrame:行是同一组日期,值是按列计算的日 log returns(即 prices / prices.shift(1) 取自然对数),并且丢掉第一行(因为没有前一日价格)。要求使用 .shift(1) 与逐元素算术;不允许使用 Python for loop 或 .apply。
提示
df.shift(1):它把所有值整体下移一行,使「昨天的价」与「今天的 index」对齐,第一行自动是 NaN。提示
prices / prices.shift(1) 取自然对数,外层用 dropna() 去掉首行 NaN:np.log(df / df.shift(1)).dropna()。衔接下一课
你现在能从字典、列表、ndarray 三种来源构造 Series 与 DataFrame,能读 dtypes / index / columns / shape,能用 .loc 按标签、.iloc 按位置取,能区分 Series 与单列 DataFrame,也能避开链式赋值。但这一课所有数据都是你手敲的 Python 字面量——真正的研究台上,数据是从 CSV、Parquet 落到内存的,永远带着缺失值、类型混乱和重复行。下一课讲 read_csv / read_parquet 的 90% 用法,以及 dropna / fillna / pd.to_datetime / pd.to_numeric(errors='coerce') 这套清洗工具——把字符串里的 'N/A' 和单元格里的 '' 一并升格为 NaN,再决定是丢弃还是 前向填充(forward fill)。