📎 配套代码:
第09讲_缺失与排序.py
📊 配套数据:data/quote/—— 交易数据(沪深300 × 2024 年以来,仓库自带,开箱即跑)
🎬 开场:两个互补的条件,加起来对不上
用样本里的 daily_basic 数据(沪深 300 × 2024 年以来,17 万行),按市盈率分个类:
(db["pe"] > 0).sum() # 盈利的 (db["pe"] <= 0).sum() # 亏损的
真跑:
pe > 0 的行数 161,887 pe <= 0 的行数 0 两者相加 161,887 总行数 170,148 ← 少了 8,261 行
两个条件在逻辑上是互补的,一个数不是大于 0 就是小于等于 0。但它们加起来没覆盖全部数据。
少掉的 8,261 行(4.86%)是 pe 为 NaN 的行——亏损股的市盈率不是负数,是没有。分母是负的,市盈率这个指标本身就失去意义,数据源直接给了空值。
而 NaN <= 0 的结果是 False,不是 True,也不报错。所以这 8 千行既没进”盈利”,也没进”亏损”,静静地从两个桶里漏了出去。
💡 4.86% 这个比例和股票池有关。沪深 300 是大盘蓝筹,亏损的少;换成全市场,PE 缺失率会到 20% 以上。比例会变,机制不变。
如果你正在按这个分类做统计——比如”亏损股的平均换手率”——结果会是空的,而你可能到最后都不知道为什么。
缺失数据的麻烦从来不是”有缺失”,而是它不吭声。这一讲讲怎么让它吭声,以及和它高度相关的另一件事:排序。
🎯 这一讲结束时,你能
- 先看清缺失的规模和分布,再决定怎么处理,而不是上来就
fillna(0) - 说清 NaN 的两个静默伤害:参与比较一律
False、让整型列升成float64 - 在删、填、留三条路里按场景选,并知道
bfill在回测里为什么是未来函数 - 用
sort_values的多列排序、na_position、稳定排序,以及rank五种并列处理方式 - 解释为什么
groupby之前不排序,pct_change会给出完全错误但看起来正常的结果
一、🧰 先看清楚,再动手
处理缺失之前先问三个问题:有多少、在哪些列、为什么缺。
db.isna().sum() # 每列缺多少 db.isna().mean() # 每列缺失率 db.isna().sum(axis=1) # 每行缺多少 db[db.isna().any(axis=1)] # 有缺失的行
那份真实数据的缺失分布:
ts_code 0 0.00% trade_date 0 0.00% close 0 0.00% turnover_rate 0 0.00% pe 8,261 4.86% pe_ttm 10,340 6.08% pb 0 0.00% total_mv 0 0.00%
分野很清楚:行情类字段一个不缺(价格、成交、市值是交易所直接给的);估值类字段有缺失(pe 4.86%、pe_ttm 6.08%);pb 一个不缺(沪深 300 里没有净资产为负的公司)。
为什么缺,决定了怎么处理
量化数据里的缺失大致三种来源,处理方式完全不同:
| 缺失原因 | 例子 | 该怎么处理 |
|---|---|---|
| 业务上就不存在 | 亏损股没有 PE、未分红没有股息率 | 保持缺失。填 0 会让它变成”市盈率极低”的便宜股 |
| 当天没有数据 | 停牌、未上市、已退市 | 看用途:算收益率要保持缺失;画图可以 ffill
|
| 数据源漏了 | 采集失败、字段偶发为空 | 补数据源,或按邻近值插值 |
WARNING: ⚠️
fillna(0)是最危险的默认动作
把亏损股的 PE 填成 0,它在”按 PE 从低到高选便宜股”里会排在最前面——你选出的”最便宜的股票”全是亏损股。
0 不是”没有”,0 是一个具体的数值,它会正常参与所有排序和计算。填之前先问:这个 0 在业务上说得通吗。
🎮 随堂快练
QUESTION: 一份行情表里
close列有缺失。怎么判断是”停牌”还是”数据源漏了”?
TIP: 👉 答案
看同一天的vol(成交量)。停牌当天成交量是 0 或缺失,其它字段也一并缺;数据源漏了则往往只缺这一个字段,成交量正常。
更一般的判断方法:db.isna().sum(axis=1).value_counts()看每行缺几个字段——整行大面积缺通常是”这天本来就没数据”,零星单字段缺才是采集问题。
二、🐛 缺失的两个静默伤害
伤害一:NaN 参与比较,结果一律是 False
这是开场那个例子的根源:
np.nan > 0 # False np.nan <= 0 # False np.nan == 0 # False np.nan == np.nan # False —— 连自己都不等于
后果是布尔筛选会静默地把缺失丢进”否”的那一边。写 df[df.pe > 30] 时你以为在筛高估值股,实际上同时把那 4.86% 的亏损股排除了——这个排除你没有意识到,也没人提醒你。
判断缺失只能用 isna(),不能用 == np.nan:
db["pe"].isna() # ✅ db["pe"] == np.nan # ❌ 全是 False
IMPORTANT: 🔑 条件分支要显式处理缺失
# ❌ 缺失被算进 "低估值" df["组"] = np.where(df["pe"] < 20, "低估值", "高估值") # ✅ 三分,缺失单独一档 df["组"] = np.select([df["pe"].isna(), df["pe"] < 20], ["无PE", "低估值"], default="高估值")
np.where只有两个出口,缺失一定会掉进else。只要数据可能有缺失,二分法就是错的。
伤害二:整型列一遇缺失,整列升成 float64
第 08 讲读 CSV 时见过一次,这里是它的一般形式:
s = pd.Series([1, 2, 3]) # int64 s.reindex([0, 1, 2, 3]) # float64,值变成 [1.0, 2.0, 3.0, nan]
原因是 NaN 本身是一个 float 类型的特殊值,int64 里没有位置放它。整列只好升级成 float64 来容纳。
这在量化里的实际后果:股票代码、行业编码、成交手数这类整数字段,只要经过一次 reindex/merge/resample 产生了缺失,就会变成 1.0、2.0 这样带小数点的浮点数。再拿去和别的表按整数键合并,就对不上了。
三、🧰 pd.NA 与可空类型
上面那个问题,pandas 后来给了正解:可空类型(nullable dtype)。
pd.Series([1, 2, 3], dtype="Int64").reindex([0, 1, 2, 3])
[1, 2, 3, <NA>] dtype: Int64 ← 还是整数,缺失是 <NA>
注意大写的 Int64——小写 int64 是 numpy 的传统整型,大写是 pandas 的可空整型。它把”值”和”是不是缺失”分开存:一列整数放值,另一列布尔标记哪些位置是缺失。于是整数还是整数,缺失也能表达。
同类的还有 "boolean"(可空布尔)和 "string"(真正的字符串类型,不是 object)。缺失值统一显示成 pd.NA。
什么时候值得切过去:
- 整数语义重要,且数据会产生缺失——股票代码、行业编码、份额、笔数
- 布尔列可能有缺失——
is_st、是否停牌。传统 bool 装不下缺失,只能退成 object - 文本列内存吃紧——
"string"配 pyarrow 后端比 object 省很多
什么时候不用管:价格、收益率、财务指标这些本来就是浮点数的字段,NaN 工作得很好,没必要切。
NOTE: 💡 两套体系目前是并存的
np.nan(float 哨兵)是老体系,pd.NA是新体系。混用时要注意pd.NA的比较结果是pd.NA而不是False——它表达的是”不知道”,比NaN更严谨,但也意味着if判断会直接报错而不是静默走else。
从教学角度说这是好事:报错比静默出错强。
四、🧰 处理缺失:删、填、留
删:dropna
df.dropna() # 有任何缺失的行全删 df.dropna(how="all") # 整行全缺才删 df.dropna(subset=["close", "vol"]) # 只看这两列有没有缺 df.dropna(thresh=5) # 至少有 5 个非缺失值才留 df.dropna(axis=1) # 删列不是删行
subset 是最常用的——大部分时候你只关心几个关键字段,其它字段缺不缺无所谓。
WARNING: ⚠️ 裸
dropna()在宽表上很危险
一张 18 列的表,只要任何一列缺失整行就被删掉。前面那份数据pe缺 22%、pe_ttm缺 26%,裸dropna()会删掉近三成的行——而你可能根本没用到pe这一列。
先想清楚哪几列是必需的,写进subset。
填:fillna
df.fillna(0) # 填固定值 df.fillna({"pe": 0, "pb": 1}) # 逐列填不同的值 df["close"].fillna(df["close"].mean()) # 填均值 df.ffill() # 用前一个非缺失值填(向前填充) df.bfill() # 用后一个非缺失值填 df.ffill(limit=3) # 最多连续填 3 个
ffill 和 bfill 的方向差别,在回测里是致命的
s = pd.Series([10.0, np.nan, np.nan, 13.0], index=["d1","d2","d3","d4"])
原始 [10.0, nan, nan, 13.0] ffill [10.0, 10.0, 10.0, 13.0] ← 用 d1 的价格补 d2、d3 bfill [10.0, 13.0, 13.0, 13.0] ← 用 d4 的价格补 d2、d3
ffill 用过去的值补,bfill 用未来的值补。
在回测里,bfill 意味着你在 d2 那天用上了 d4 才会发生的价格——这就是未来函数。回测结果会好得离谱,实盘一分钱赚不到。
IMPORTANT: 🔑 时间序列数据默认只用
ffill
停牌期间用停牌前最后一个价格填充,是符合现实的(你确实卖不掉,账面价值就是停牌前那个价)。
bfill只在非时序场景用,比如填补静态的公司属性表。任何按时间排列的数据,用bfill之前都要停下来想一遍。
还有一个常被忽略的:limit参数。停牌三年的股票,ffill会一路把三年前的价格填到今天。加limit=5让它最多填一周,超过就保持缺失。
插值:interpolate
df["close"].interpolate() # 线性插值 df["close"].interpolate(method="time") # 按时间间隔加权(需要时间索引)
插值也是用两端的值去推中间的,同样用到了未来信息。它适合处理”本该连续但采集漏了”的数据(比如分钟线漏了几个 tick),不适合处理停牌。
留:什么都不做
很多时候最正确的处理就是不处理。pandas 的聚合函数默认跳过缺失:
db["pe"].mean() # 自动跳过 NaN,用 238 万个有效值算 db["pe"].count() # 只数非缺失的个数 db["pe"].sum() # 跳过 NaN
算平均 PE 时,把亏损股保持为 NaN 让它自动被排除,比填任何值都更接近你想要的答案。
🎮 随堂快练
QUESTION: 你要算每只股票的 20 日均线。数据里有停牌导致的缺失。三种做法:①
dropna()后算 ②ffill()后算 ③ 直接算。选哪个?
TIP: 👉 答案
②。理由是”20 日”这个窗口是按行数算的:
①dropna()删掉停牌日,窗口里的 20 行会跨越更长的真实时间,均线含义变了。
③ 直接算,rolling默认遇到缺失就输出 NaN,停牌后连续 20 天都没有均线。
②ffill()让停牌日保持停牌前的价格,行数和日历天数对齐,均线含义正确。这也符合现实——停牌期间你的持仓估值就是按停牌前的价格算的。
附带条件:要加limit,长期停牌不能无限填下去。
五、🧰 排序
排序看起来和缺失是两件事,但它们在同一个地方交汇:排序就是一连串比较,而 NaN 参与比较一律是 False。
sort_values
df.sort_values("pe") # 按一列升序 df.sort_values("pe", ascending=False) # 降序 df.sort_values(["industry", "pe"]) # 先按行业,组内再按 PE df.sort_values(["industry", "pe"], ascending=[True, False]) # 升降混合 df.sort_index() # 按行标签排 df.sort_index(axis=1) # 按列名排列
多列排序时 ascending 可以传一个列表,逐列指定方向——选股里常用”行业升序、因子值降序”这种组合。
NaN 排在哪里
db.sort_values("pe") # 末尾 3 个: [nan, nan, nan] db.sort_values("pe", ascending=False) # 末尾 3 个: [nan, nan, nan] db.sort_values("pe", na_position="first") # 开头 3 个: [nan, nan, nan]
NaN 默认永远排在最后,不管升序还是降序。 这个行为很合理——缺失值没有大小,放在哪里都是人为规定,pandas 选了”总是靠后”。
它带来一个容易忽略的后果:
df.sort_values("pe").head(100) # 选 PE 最低的 100 只
这行代码自动排除了所有亏损股,因为它们的 PE 是 NaN,被排到了最后。结果可能正是你想要的,但这是一次隐式的样本筛选——你没写任何筛选条件,样本却已经变了。
TIP: 🚀 想明确表达意图,就显式写出来
df[df["pe"].notna()].sort_values("pe").head(100)多写一个条件,读代码的人(包括三个月后的你)就知道”亏损股是被有意排除的”,而不是”碰巧被排序规则挤掉了”。
稳定排序
排序时如果有并列值,它们的相对顺序会不会变?
df.sort_values("industry", kind="stable") # 并列的保持原有相对顺序
kind 默认是 "quicksort",不保证稳定。多数时候无所谓,但有一种情况必须用 stable:你想通过连续两次排序实现多级排序。
df.sort_values("date").sort_values("code", kind="stable")
第二次排序如果不稳定,第一次按日期排好的顺序就被打乱了。不过更直接的写法是一次排完:df.sort_values(["code", "date"])——能一次排完就别分两次。
nlargest / nsmallest
只要最大或最小的几个时,不必排完整个表:
df.nlargest(10, "total_mv") # 市值最大的 10 只 df.nsmallest(10, "pe") # PE 最低的 10 只
比 sort_values(...).head(10) 更直接,也更快——它不需要把全部数据排好序。
六、🧰 排名 rank
排序改变行的位置,排名保持位置不变,给出每行排第几。因子研究里排名比排序用得更多。
df["pe"].rank() # 升序排名,1 是最小的 df["pe"].rank(ascending=False) # 1 是最大的 df["pe"].rank(pct=True) # 百分位,0~1 之间
并列怎么办:五种 method
用 [10, 20, 20, 30] 这四个值看差别,真跑:
method |
结果 | 含义 |
|---|---|---|
average(默认) |
[1.0, 2.5, 2.5, 4.0] |
并列的取平均名次 |
min |
[1.0, 2.0, 2.0, 4.0] |
并列的都取最小名次(体育比赛的并列第二) |
max |
[1.0, 3.0, 3.0, 4.0] |
并列的都取最大名次 |
first |
[1.0, 2.0, 3.0, 4.0] |
按出现先后强行分出高下 |
dense |
[1.0, 2.0, 2.0, 3.0] |
并列算一个名次,下一名不跳号 |
因子研究里默认的 average 通常是对的。但离散取值的因子要小心——比如”分析师评级”只有 1~5 五个值,几千只股票挤在五个名次上,average 会让同一档的股票全部拿到相同的排名值,后续分组会出问题。这种情况用 dense 或先分箱。
缺失不参与排名
pd.Series([10, np.nan, 20]).rank() # [1.0, nan, 2.0]
NaN 的排名还是 NaN,且不占名次。想改变这个行为用 na_option:"keep"(默认)、"top"(缺失排最前)、"bottom"(缺失排最后)。
百分位排名在因子研究里
df["pe_pct"] = df.groupby("trade_date")["pe"].rank(pct=True)
这一行做的是:在每个交易日的横截面上,把 PE 转成 0~1 的百分位。
这么做的理由是不同因子量纲完全不同——PE 是几十的量级,换手率是百分之几,市值是上亿。转成百分位之后它们就可比了,也能直接相加做多因子合成。
注意 groupby("trade_date")——排名必须在同一天的横截面内做。跨越不同日期排名是没有意义的,市场整体估值水平本身就在变。
🎮 随堂快练
QUESTION:
df.groupby("industry")["mktcap"].rank(ascending=False)和df["mktcap"].rank(ascending=False)差在哪?
TIP: 👉 答案
前者是行业内排名(每个行业各自从 1 开始),后者是全市场排名。
选股里想做”每个行业选最大的 3 只”就必须用前者。用后者的话,银行股会占满前几十名,中小行业一只都选不出来。
七、🐛 排序没做对,后面全错
第 01 讲开场那个不报错的 bug,现在可以完整解释了。
groupby 保持组内的原始行顺序
d = pd.DataFrame({"c": ["A","A","A"], "d": ["0103","0101","0102"], # 日期是乱的 "p": [3.0, 1.0, 2.0]}) d.groupby("c")["p"].pct_change()
原表顺序 ['0103', '0101', '0102'] pct_change [nan, -0.667, 1.0] ← 按原顺序算的,不是按日期
groupby 不会按你的意愿把组内数据排好序,它保持原表里的行顺序。pct_change、shift、diff、rolling、cumsum 这些依赖顺序的操作,全都建立在这个顺序之上。
数据源给的顺序不一定是按时间排的——尤其是从数据库查出来、多个文件拼起来、或者做过 merge 之后。
在真实数据上的后果
拿那 17 万行行情,故意打乱行顺序(模拟”顺序不可靠”的数据源),两种写法对比:
bad = sh.groupby("ts_code")["close"].pct_change() good = sh.sort_values(["ts_code","trade_date"]).groupby("ts_code")["close"].pct_change()
真跑:
| 不排序 | 先排序 | |
|---|---|---|
| 收益率均值 | 0.060801 | 0.000705 |
| 标准差 | 0.4755 | 0.0246 |
| 最大值 | 17.9 | 0.2 |
| 日涨幅超 10% 的比例 | 58.5% | 0.7% |
58.5% 的交易日涨幅超过 10%。 A 股有涨跌停制度,主板日内涨跌幅限制 10%,这个数字在物理上就不可能。
但代码没有报错,没有警告,describe() 输出的每一个数字都是合法的浮点数。你只有在知道 A 股有涨停板这个业务常识的前提下,才会觉得 58% 这个数不对劲。
IMPORTANT: 🔑 依赖顺序的操作前面,必须有 sort_values
df = df.sort_values(["ts_code", "trade_date"]) # 先排 df["ret"] = df.groupby("ts_code")["close"].pct_change()涉及的操作:
shift/diff/pct_change/cumsum/cumprod/rolling/expanding/ffill/bfill。
判断标准很简单:这个操作的结果是否取决于”上一行是谁”。是的话,就得先保证”上一行”确实是你以为的那一行。
排序键要写全:先ts_code再trade_date。只按trade_date排,不同股票的行会交错在一起。
TIP: 🚀 加一道防线
排完序之后,用业务常识做一次校验:assert (df["ret"].abs() > 0.11).mean() < 0.05, "涨跌幅异常,检查排序"这类静默 bug 唯一可靠的抓手就是用领域知识给数据设边界。涨跌停、换手率不超过 100%、市盈率不会是负数——每一条都能变成一行断言。
🏋️ 训练营
QUESTION: 🟢 训练 1:
df有一列pe,22% 是缺失。写代码:① 统计缺失率 ② 分成”低估值/高估值/无PE”三档,不让缺失掉进任何一档 ③ 算有效样本的平均 PE
TIP: 👉 参考df["pe"].isna().mean() # ① df["组"] = np.select([df["pe"].isna(), df["pe"] < 20], # ② ["无PE", "低估值"], default="高估值") df["pe"].mean() # ③ 自动跳过 NaN② 的关键是缺失条件放在第一个——
np.select按顺序匹配,先判缺失才能拦住它掉进后面的档。用np.where做不到,它只有两个出口。
QUESTION: 🟡 训练 2:下面这段代码算每只股票的 5 日动量,有两处问题。找出来。
df = pd.read_parquet("pro_bar.parquet") df["mom5"] = df.groupby("ts_code")["close"].pct_change(5) top = df.sort_values("mom5")[:20]TIP: 👉 参考
问题①:groupby前没有sort_values(["ts_code","trade_date"])。pct_change按原表行顺序算,数据源顺序不保证按时间,结果全错且不报错。
问题②:sort_values默认升序,[:20]取到的是动量最低的 20 只,不是最高的。想要最高的用nlargest(20, "mom5")。
改对:df = df.sort_values(["ts_code", "trade_date"]) df["mom5"] = df.groupby("ts_code")["close"].pct_change(5) top = df.nlargest(20, "mom5")两处都是”不报错”的错误——这类问题不会在运行时暴露,只会让你的因子悄悄失效。
QUESTION: 🔴 训练 3:你要构造一个”行业中性的低估值因子”:在每个交易日、每个行业内部,把 PE 转成百分位,越低越好。亏损股(PE 缺失)要单独处理,不能当成”估值最低”。写出来,并说明每一步为什么这么写。
TIP: 👉 参考df["pe_pct"] = (df.groupby(["trade_date", "industry"])["pe"] .rank(pct=True, ascending=True)) df["factor"] = 1 - df["pe_pct"] # PE 越低分越高 df.loc[df["pe"].isna(), "factor"] = np.nan # 亏损股保持缺失,不给分逐条理由:
groupby(["trade_date","industry"])——必须同时按日期和行业分组。只按行业会跨期比较(不同时期市场估值水平不同),只按日期就不是行业中性。rank(pct=True)——转百分位,让 PE 和其它量纲不同的因子可比。1 - pe_pct——把”低 PE 好”翻译成”分数高”,方便和其它因子相加。- 最后一行显式保持缺失:
rank本来就会给 NaN 返回 NaN,这一行是防御性的,防止后续有人fillna(0)把亏损股变成 factor=0(中位水平),或者更糟,被1 - NaN之外的逻辑意外赋值。
加分点:行业内股票数太少时百分位不稳定(3 只股票的百分位只有 0.33/0.67/1.0)。可以加一个transform("count")过滤掉样本不足的行业。
🐛 常见坑
- ⚠️
NaN参与比较一律 False:df[df.pe > 30]静默排除了所有缺失行。二分逻辑遇到可能缺失的列一定要改三分。 - ⚠️
== np.nan判断不出缺失:只能用isna()。 - ⚠️
fillna(0)改变业务含义:亏损股 PE 填 0 会变成”最便宜的股票”。填之前问这个 0 说不说得通。 - ⚠️ 裸
dropna()删太多:宽表上任一列缺失整行就没了。用subset指定关键列。 - ⚠️
bfill是未来函数:时间序列只用ffill,而且要加limit防止长期停牌被一路填下去。 - ⚠️ 整型列一遇缺失升 float64:股票代码、行业编码变成
1.0后再 merge 就对不上。需要整数语义就用Int64。 - ⚠️
sort_values默认升序:想要”最大的几个”用ascending=False或直接nlargest。 - ⚠️ 排序时 NaN 总在最后:
sort_values("pe").head(100)隐式排除了缺失行。想排除就显式写notna()。 - ⚠️
groupby保持组内原序:pct_change/shift/rolling之前必须先sort_values(["code","date"]),否则结果全错且不报错。 - ⚠️
rank跨期做没有意义:横截面因子要groupby("trade_date")之后再 rank。
✍️ 作业
- 读一份本地行情或估值数据,打印
df.isna().mean().sort_values(ascending=False),找出缺失最多的三列。对每一列判断缺失原因属于本讲第一节的哪一类,并写下你会怎么处理、为什么。 - 构造一个有停牌缺失的价格序列,分别用
ffill()、bfill()、interpolate()填充,把三个结果并排打印。指出哪些填法用到了未来信息。 - 造一个
pd.Series([1,2,3]),做一次reindex让它产生缺失,打印 dtype 和值;再用dtype="Int64"重做一遍对比。说明为什么第二种能保住整数。 - 用
[10, 20, 20, 30]跑一遍rank的五种method,把结果记下来。然后设想一个因子只有 1~5 五种取值、共 5000 只股票,说明average会产生什么问题。 - 复现本讲第七节:读一份行情数据,先
sample(frac=1)打乱行顺序,然后分别”不排序直接pct_change“和”先排序再pct_change“,比较两者的收益率标准差和”日涨幅超过 10% 的比例”。用 A 股涨跌停制度解释为什么第一个结果一定是错的。 - 思考题:本讲说
groupby保持组内原始行顺序。那么如果你的数据本来就是按时间排好的,是不是就可以省掉sort_values?(提示:想想merge之后、concat多个文件之后、从数据库SELECT之后,行顺序还保证吗?以及——你怎么验证它确实是排好的?)
🔮 下讲预告:第 10 讲——数据转换与字符串处理。这一讲处理的是”值缺了”,下一讲处理的是”值有问题”:重复行怎么找出来、怎么按映射表批量替换、怎么把连续值切成分档,以及一整套
str方法——股票代码要加交易所后缀、公司名要去掉ST前缀、从公告文本里抠出数字。