📎 配套代码:
第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: 你有
codes和weights两个等长的列表,想把它们合成一个”代码→权重”的 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
结果有四个标签——是两边标签的并集。只在一边出现的(z 和 w)算不出来,给 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: 👉 答案
最常见的原因是标签对不上:代码格式不同(600519vs600519.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]——池子里有因子没有的代码时会报KeyError。reindex会安静地补 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 > 0是False,所以停牌日被算进了”不上涨”。如果你想表达的是”在有交易的日子里上涨的比例”,得写(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"]包含c,s.iloc[0:3]不包含第 3 个。 - ⚠️ 相运算后一堆 NaN:多半是标签对不上(格式、类型、空格)。用
index.difference()排查。 - ⚠️ 统计函数默认跳过 NaN:
mean()不会因为有缺失就返回 NaN,方便但也可能让你忽略了数据缺了多少。先看isna().sum()。 - ⚠️
NaN > 0是False:布尔统计时缺失值会被归到”不满足”那一边。 - ⚠️ 能用内置就别用
map:s * 2比s.map(lambda x: x*2)更快也更好读。
✍️ 作业
- 用三种方式各建一个 Series(列表+index、字典、标量广播),打印它们的
index、values、dtype。 - 造一个
index=[2,0,1]的 Series,分别跑s[0]、s.loc[0]、s.iloc[0],确认前两个相同、和第三个不同。想清楚为什么。 - 造两个标签部分重叠的 Series 相加,观察结果的标签是不是并集、不重叠的地方是不是 NaN。再用
index.difference()查出差异。 - 拿一列收益率数据,练习:
describe()、nlargest(5)、idxmax()、value_counts()(先用pd.cut分档)、rank()。 - 思考题:既然 Series 会自动对齐,为什么还要有
reindex这种手动对齐的方法?(提示:想想”我要让这个 Series 严格按照某个给定的股票池排列,缺的补 NaN”这个需求。)
🔮 下讲预告:第 06 讲——DataFrame。Series 是一列,DataFrame 是很多列拼在一起,行和列都有标签。你会看到本讲的取数、对齐、统计在二维上怎么用,以及一个新问题:取一行和取一列,为什么行为不太一样。