第09讲 缺失数据与排序

📎 配套代码第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.02.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 个

ffillbfill 的方向差别,在回测里是致命的

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_changeshiftdiffrollingcumsum 这些依赖顺序的操作,全都建立在这个顺序之上。

数据源给的顺序不一定是按时间排的——尤其是从数据库查出来、多个文件拼起来、或者做过 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_codetrade_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 参与比较一律 Falsedf[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。

✍️ 作业

  1. 读一份本地行情或估值数据,打印 df.isna().mean().sort_values(ascending=False),找出缺失最多的三列。对每一列判断缺失原因属于本讲第一节的哪一类,并写下你会怎么处理、为什么。
  2. 构造一个有停牌缺失的价格序列,分别用 ffill()bfill()interpolate() 填充,把三个结果并排打印。指出哪些填法用到了未来信息。
  3. 造一个 pd.Series([1,2,3]),做一次 reindex 让它产生缺失,打印 dtype 和值;再用 dtype="Int64" 重做一遍对比。说明为什么第二种能保住整数。
  4. [10, 20, 20, 30] 跑一遍 rank 的五种 method,把结果记下来。然后设想一个因子只有 1~5 五种取值、共 5000 只股票,说明 average 会产生什么问题。
  5. 复现本讲第七节:读一份行情数据,先 sample(frac=1) 打乱行顺序,然后分别”不排序直接 pct_change“和”先排序再 pct_change“,比较两者的收益率标准差和”日涨幅超过 10% 的比例”。用 A 股涨跌停制度解释为什么第一个结果一定是错的。
  6. 思考题:本讲说 groupby 保持组内原始行顺序。那么如果你的数据本来就是按时间排好的,是不是就可以省掉 sort_values?(提示:想想 merge 之后、concat 多个文件之后、从数据库 SELECT 之后,行顺序还保证吗?以及——你怎么验证它确实是排好的?)

🔮 下讲预告:第 10 讲——数据转换与字符串处理。这一讲处理的是”值缺了”,下一讲处理的是”值有问题”:重复行怎么找出来、怎么按映射表批量替换、怎么把连续值切成分档,以及一整套 str 方法——股票代码要加交易所后缀、公司名要去掉 ST 前缀、从公告文本里抠出数字。


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