第05讲 Series 带标签的一维数据

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

🎬 开场:把前三讲的东西合成一个

本讲样本是 2024-01-02 的真实收盘数据(取自本地 tushare pro_bar):茅台 1685.01、五粮液 136.00、中国平安 39.47、平安银行 9.21、招商银行 27.58。只取五只是为了每个值都看得清。

前三讲讲了取数的三种方式,但它们各自有短板:

close = np.array([1685.01, 136.00, 39.47])      # 数组:能算,但不知道谁是谁
codes = ["600519", "000858", "601318"]        # 得另开一个列表存代码

d = {"600519": 1685.01, "000858": 136.00}       # 字典:知道谁是谁,但不能直接算
d * 2                                          # ❌ 报错,字典不支持数学运算

数组会算不会认,字典会认不会算。你只能两个一起用,还得自己保证它们顺序对应——第 03 讲说过,这个”自己保证”非常脆弱。

Series 就是把这两样合成一个:

close = pd.Series([1685.01, 136.00, 39.47],
                  index=["600519", "000858", "601318"],
                  name="close")
600519    1685.01
000858     136.00
601318      39.47
Name: close, dtype: float64

左边一列是标签index),右边是。它既能像数组一样整列运算,又能像字典一样按名字取。

从这一讲开始进入 pandas。Series 是最简单的 pandas 结构,把它用熟,下一讲的 DataFrame 就是”很多个 Series 拼在一起”。


一、🧰 建 Series

从列表建,自己指定标签

close = pd.Series([1685.01, 136.00, 39.47, 9.21, 27.58],
                  index=["600519", "000858", "601318", "000001", "600036"],
                  name="close")

index 是标签,name 是这一列的名字(后面并进 DataFrame 时会变成列名)。

不给 index 会怎样

pd.Series([1, 2, 3])       # 标签自动变成 0, 1, 2

自动生成的是整数标签 0,1,2,...,看起来和”位置”一样——但它是标签。这个区别在后面会造成一个经典的坑,第三节讲。

从字典建,键自动变成标签

pd.Series({"600519": 1685.01, "000858": 136.00})

这是最自然的一种,字典本来就是”名字→值”。

从标量建,广播到所有位置

pd.Series(0.0, index=["600519", "000858"])    # 两个位置都是 0.0

初始化一个空仓位、给所有股票一个默认权重,常这么写。

几个属性

close.values      # 底层的 numpy 数组
close.index       # 标签
close.name        # 'close'
close.dtype       # float64
close.shape       # (5,)

.values 拿到的就是一个普通 numpy 数组——前三讲学的东西在这里都还能用。

🎮 随堂快练

QUESTION: 你有 codesweights 两个等长的列表,想把它们合成一个”代码→权重”的 Series。写出两种写法。
TIP: 👉 答案

pd.Series(weights, index=codes)          # 直接给 index
pd.Series(dict(zip(codes, weights)))     # 先合成字典再建

两种都对。第二种在你已经有字典时更自然;第一种在两个列表分别来自不同地方时更直接。


二、🧰 取数:三种方式在这里统一了

第 02–04 讲的三种取数方式,在 Series 上全都能用:

close["600519"]           # 按标签
close.iloc[0]             # 按位置
close[close > 100]        # 按条件

按标签取:[].loc

close["600519"]                    # 1685.01
close.loc["600519"]                # 同上,写法更明确
close"600519", "601318"        # 取多个,返回一个 Series

按位置取:.iloc

close.iloc[0]           # 第一个
close.iloc[-1]          # 最后一个
close.iloc[0:3]         # 前三个

按条件取:布尔掩码

和第 04 讲完全一样,只是现在结果带着标签:

close[close > 100]
# 600519    1685.01
# 000858     136.00

好处是筛完还知道是哪几只股票——用 numpy 数组的话,你得自己拿掩码再去筛一次代码数组。

⚠️ 一个容易搞混的地方:切片的边界

close.loc["600519":"601318"]     # 按标签切片 → 3 个,含尾
close.iloc[0:3]                  # 按位置切片 → 3 个,不含尾

标签切片包含结束位置,位置切片不包含。

这不是 pandas 故意别扭。位置切片沿用 Python 的惯例(list[0:3] 给你 3 个);而标签切片如果不含尾,你就得知道”下一个标签是谁”才能表达”取到 601318 为止”——那太反直觉了。

🎮 随堂快练

QUESTION: close 的标签是股票代码。下面两句各返回几个元素?

close.loc["600519":"601318"]
close.iloc[0:3]

TIP: 👉 答案
都是 3 个,但原因不同:loc 含尾(600519、000858、601318 三个),iloc 不含尾(第 0、1、2 个)。这里恰好数量相同,纯属巧合——换个切法就不一样了。


三、🐛 整数标签的陷阱

上面说过,不指定 index 时标签是 0,1,2,...。这时候 [] 里放一个整数,pandas 该理解成标签还是位置?

s = pd.Series([10, 20, 30])       # 标签是 0,1,2
s[0]      # 10  —— 这是"标签为 0 的元素",恰好也是第 0 个

看着没问题。但换个标签就露馅了:

s = pd.Series([10, 20, 30], index=[2, 0, 1])    # 标签故意打乱
s[0]      # 20  ← 是"标签为 0"的那个,不是第 0 个!

s[0] 给的是 20(标签 0 对应的值),而不是 10(第 0 个位置的值)。

这类混淆是 pandas 最常见的困惑来源之一。解决办法很简单:不要用裸的 [] 取单个元素,明确写 .loc.iloc

s.loc[0]     # 20   我要标签是 0 的
s.iloc[0]    # 10   我要第 0 个

写出来多几个字符,但读代码的人(包括三个月后的你)一眼就知道你要的是什么。

WARNING: ⚠️ 这个坑在真实数据里更隐蔽
从 CSV 读进来的表,默认标签就是 0,1,2,...。你一路 df[0]s[5] 写得很顺。直到某天你做了一次筛选或排序——标签跟着数据走,不再是连续的 0,1,2,你的代码就开始取到别的东西。而且不报错。

🎮 随堂快练

QUESTION: s = pd.Series([10,20,30], index=[2,0,1])。写出”取第一个元素”和”取标签为 1 的元素”。
TIP: 👉 答案
第一个:s.iloc[0] → 10
标签为 1:s.loc[1] → 30
别写 s[0]s[1]——它们都会按标签理解,容易出错。


四、🧰 运算与自动对齐

Series 支持整列运算,和 numpy 一样:

close * 100          # 每个元素都乘 100
close.pct_change()   # 相邻元素的变化率
np.log(close)        # numpy 函数也能直接用

两个 Series 相运算时,pandas 会按标签对齐,这是它和 numpy 最大的区别:

a = pd.Series([1.0, 2, 3], index=["x", "y", "z"])
b = pd.Series([10.0, 20, 40], index=["x", "y", "w"])
a + b
w     NaN
x    11.0
y    22.0
z     NaN

结果有四个标签——是两边标签的并集。只在一边出现的(zw)算不出来,给 NaN

这个行为在量化里非常有用:两张表的股票不完全一样、日期有缺口,直接相加就行,pandas 会自动把对得上的对上,对不上的标成缺失。用 numpy 你得自己写一堆对齐逻辑。

WARNING: ⚠️ 但也要小心
自动对齐意味着标签对不上的时候不会报错,只会给你一堆 NaN。如果你的两个 Series 本该完全对应,结果算出来半数是 NaN,那说明标签有问题(比如一边是 "600519"、另一边是 "600519.SH")。
算完之后看一眼 result.isna().sum(),是个好习惯。

缺失值的处理

close.isna()             # 哪些是缺失
close.isna().sum()       # 有几个缺失
close.dropna()           # 丢掉缺失
close.fillna(0)          # 填成 0
close.ffill()            # 用前一个值填(时间序列常用)

统计函数默认会跳过 NaN:

pd.Series([1, np.nan, 3]).mean()     # 2.0,不是 NaN

这和 numpy 相反(np.mean 遇到 NaN 会返回 NaN)。pandas 的默认行为是”当缺失不存在”,方便,但也意味着你可能没注意到数据缺了一半。

🎮 随堂快练

QUESTION: 两个 Series 相加后大量出现 NaN,可能是什么原因?怎么排查?
TIP: 👉 答案
最常见的原因是标签对不上:代码格式不同(600519 vs 600519.SH)、日期类型不同(字符串 vs 时间戳)、有空格。
排查:

a.index.difference(b.index)      # a 有 b 没有的标签
b.index.difference(a.index)      # b 有 a 没有的

看看差异是全部还是个别。如果几乎全对不上,多半是格式问题。


五、🧰 常用操作

统计

close.mean()      close.std()      close.sum()
close.min()       close.max()      close.median()
close.describe()                    # 一次给全套

找位置

close.idxmax()       # 最大值对应的标签 → '600519'
close.idxmin()       # 最小值对应的标签

注意 idxmax 返回的是标签不是位置——这正是 Series 比 numpy 方便的地方,np.argmax 只能给你位置,还得自己去查是哪只股票。

排序与排名

close.sort_values()                    # 按值排序(升序)
close.sort_values(ascending=False)     # 降序
close.sort_index()                     # 按标签排序
close.rank(ascending=False)            # 排名
close.nlargest(3)                      # 最大的 3 个(比排序再切片更直接)
close.nsmallest(3)

nlargest 在选股里很常用——”市值前 10″直接一句话。

去重与计数

industry.unique()          # 有哪些不同的值
industry.nunique()         # 有几种
industry.value_counts()    # 每种出现几次(按次数降序)
industry.isin(["银行", "白酒"])    # 是否属于某个集合

value_counts() 是探索数据时最常用的函数之一——拿到一列不熟悉的数据,先 value_counts() 看看都有什么值、分布如何。

逐元素变换

close.map(lambda x: x * 1.1)          # 对每个元素应用函数
close.map({"600519": "茅台"})          # 也可以传字典做查表替换
close.astype(int)                      # 转类型
close.round(2)                         # 保留两位小数
close.clip(lower=0)                    # 截断到 0 以上

TIP: 💡 有内置的就别用 map
close.map(lambda x: x*2)close * 2 结果一样,但后者更快也更好读。map 留给”确实没有内置写法”的情况,比如查表替换。

🎮 随堂快练

QUESTION: 你有一个市值 Series,要选出市值最大的 20 只股票的代码。写出两种写法,说明哪个更好。
TIP: 👉 答案

mktcap.nlargest(20).index                      # ✅ 更直接
mktcap.sort_values(ascending=False)[:20].index  # 也对,但多绕一步

nlargest 把意图(要最大的 N 个)直接说出来了,而且不需要对整个 Series 排序。


六、🧰 Index:标签本身也是个对象

close.index 拿到的不是普通列表,而是一个 Index 对象。它有自己的一套操作,用熟了能省很多事。

close.index
# Index(['600519', '000858', '601318', '000001', '600036'], dtype='object')

当集合用

Index 支持集合运算,这在对比两批股票时特别方便:

a.index.intersection(b.index)     # 两边都有的(交集)
a.index.union(b.index)            # 合起来(并集)
a.index.difference(b.index)       # a 有 b 没有的(差集)

上一节排查”相加全是 NaN”用的就是 difference

判断与查找

"600519" in close.index          # 在不在
close.index.get_loc("601318")    # 这个标签排第几 → 2
close.index.is_unique            # 有没有重复标签
close.index.has_duplicates       # 同上,反过来问

is_unique 值得养成习惯去查。标签重复会让取数返回多个结果,这往往不是你想要的:

s = pd.Series([1, 2, 3], index=["a", "b", "a"])
s["a"]        # 返回两个元素的 Series,不是一个数

改标签

close.index = [c + ".SH" for c in close.index]     # 整体替换
close.rename({"600519": "贵州茅台"})                # 按映射改(返回新 Series)
close.reset_index(drop=True)                        # 丢掉标签,退回 0,1,2

str 访问器对字符串标签也能用:

close.index.str.startswith("60")     # 布尔数组,可以拿去筛
close.index.str.split(".").str[0]    # 批量去掉后缀

reindex:按给定的标签重新排列

这是 Index 最有用的方法之一,也是作业里那道思考题的答案:

pool = ["600519", "601318", "999999"]     # 我要的股票池
close.reindex(pool)
600519    1685.01
601318      39.47
999999       NaN     ← 原来没有,补 NaN

它做三件事:按给定顺序排列、原来有的取过来、原来没有的补 NaN。同时原 Series 里不在 pool 中的会被丢掉。

这和自动对齐的区别在于:自动对齐是”两边取并集”,而 reindex 是”以我给的这份名单为准”。做多因子研究时,你常常需要把几十个因子都强行对齐到同一个股票池上,用的就是它。

WARNING: ⚠️ Index 是不可变的

close.index[0] = "新代码"      # ❌ TypeError

要改只能整体替换(close.index = [...])或用 rename
这个限制是有意的:多个 Series 可以共用同一个 Index 对象,如果允许改其中一个,别的会跟着变。

🎮 随堂快练

QUESTION: 你有一个因子 Series,和一个目标股票池 pool(列表)。要让因子严格按 pool 的顺序排列,缺的补 NaN。写出代码。
TIP: 👉 答案

factor.reindex(pool)

别用 factor[pool]——池子里有因子没有的代码时会报 KeyErrorreindex 会安静地补 NaN,这正是对齐股票池时想要的行为。


🏋️ 训练营

QUESTION: 🟢 训练 1:给定 close(代码→收盘价)和 shares(代码→总股本),算出市值并找出市值最大的三只股票代码。
TIP: 👉 参考

mktcap = close * shares          # 自动按代码对齐
mktcap.nlargest(3).index

关键在第一行:两个 Series 相乘会按标签对齐,你不用管它们的顺序是否一致。但要检查一下 mktcap.isna().sum()——如果标签对不上,结果会是一堆 NaN。

QUESTION: 🟡 训练 2:一个收益率 Series ret 里混有 NaN(停牌日)。写代码统计:① 有多少个交易日 ② 有多少天缺失 ③ 剔除缺失后的平均收益率 ④ 上涨天数
TIP: 👉 参考

len(ret)                    # ① 总天数
ret.isna().sum()            # ② 缺失天数
ret.mean()                  # ③ pandas 默认跳过 NaN
(ret > 0).sum()             # ④ 上涨天数

③ 不用先 dropna()mean() 本来就跳过 NaN。
④ 要注意:NaN > 0False,所以停牌日被算进了”不上涨”。如果你想表达的是”在有交易的日子里上涨的比例”,得写 (ret > 0).sum() / ret.notna().sum()

QUESTION: 🔴 训练 3:下面这段代码想算两个数据源的价格偏离度,结果全是 NaN。找出原因。

a = pd.Series([1685.01, 136.00], index=["600519", "000858"])
b = pd.Series([1681.0, 152.5], index=["600519.SH", "000858.SZ"])
diff = (a - b) / b
print(diff)

TIP: 👉 参考
两边标签格式不同:一边是纯代码 600519,另一边带交易所后缀 600519.SH。pandas 按标签对齐时一个都对不上,于是并集有 4 个标签,每个都缺一半,全是 NaN。
排查方法:

a.index.difference(b.index)     # 全部都在差集里 → 说明格式不一致

改法是先统一格式:

b.index = b.index.str.split(".").str[0]      # 去掉后缀
diff = (a - b) / b

这类问题在对接多个数据源时非常常见,而且不报错——它只是安静地给你一列 NaN。


🐛 常见坑

  • ⚠️ 用裸 [] 取单个元素:标签是整数时会分不清位置还是标签。明确写 .loc.iloc
  • ⚠️ loc 切片含尾,iloc 切片不含尾s.loc["a":"c"] 包含 cs.iloc[0:3] 不包含第 3 个。
  • ⚠️ 相运算后一堆 NaN:多半是标签对不上(格式、类型、空格)。用 index.difference() 排查。
  • ⚠️ 统计函数默认跳过 NaNmean() 不会因为有缺失就返回 NaN,方便但也可能让你忽略了数据缺了多少。先看 isna().sum()
  • ⚠️ NaN > 0False:布尔统计时缺失值会被归到”不满足”那一边。
  • ⚠️ 能用内置就别用 maps * 2s.map(lambda x: x*2) 更快也更好读。

✍️ 作业

  1. 用三种方式各建一个 Series(列表+index、字典、标量广播),打印它们的 indexvaluesdtype
  2. 造一个 index=[2,0,1] 的 Series,分别跑 s[0]s.loc[0]s.iloc[0],确认前两个相同、和第三个不同。想清楚为什么。
  3. 造两个标签部分重叠的 Series 相加,观察结果的标签是不是并集、不重叠的地方是不是 NaN。再用 index.difference() 查出差异。
  4. 拿一列收益率数据,练习:describe()nlargest(5)idxmax()value_counts()(先用 pd.cut 分档)、rank()
  5. 思考题:既然 Series 会自动对齐,为什么还要有 reindex 这种手动对齐的方法?(提示:想想”我要让这个 Series 严格按照某个给定的股票池排列,缺的补 NaN”这个需求。)

🔮 下讲预告:第 06 讲——DataFrame。Series 是一列,DataFrame 是很多列拼在一起,行和列都有标签。你会看到本讲的取数、对齐、统计在二维上怎么用,以及一个新问题:取一行和取一列,为什么行为不太一样


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