← 返回量化与 AI 投研概念库

幸存者偏差:你的股票池里,输掉的那些早就不见了

最后更新 2026-08-18
R2 · 注意风险

你大概见过这类说法:「A 股这些年整体是涨的」「长期持有优质公司,十年下来收益很可观」「我筛了一批标的,过去五年年化两位数」。

这些结论多半不是编的,算的时候数据也没错。问题出在更早的一步——用来算这些结论的那份名单,是今天的名单。名单上的每一家公司,都有一个共同点:它们活到了今天。而那些没活到今天的——退市摘牌的、被吸收合并的、连年亏损被换了主业的——根本没进入这场统计。

这不是小数目,也不是可以「大致忽略」的边角料。它是量化里最古老、最便宜也最致命的一类错误:你根本没做错什么运算,你只是问错了对象。

先说人话:它到底是件什么事

二战时候有个流传很广的故事:工程师检查返航的轰炸机,发现机翼上的弹孔最密,于是提议加固机翼。有人反对——机翼中弹还能飞回来,说明机翼不致命;真正该加固的是那些没能飞回来的飞机被打中的地方,比如发动机。返航的飞机是幸存者,它们身上的弹孔恰恰标出了「哪里挨打没关系」。

换个每天都在发生的版本。你想知道「开餐馆赚不赚钱」,于是去商业街走一圈,问了十家店,八家说还行。你得出结论:开餐馆挺赚。可这条街三年里换了三十家招牌,那二十二家倒掉的老板,现在在别的城市打工,你问不到他们。

幸存者偏差说的就是这件事:你能采集到的样本,是被某种筛选机制留下来的,而这个筛选恰恰和你要研究的问题相关。 它和撒谎无关,和算错无关,甚至和数据质量都无关——数据可以每一条都准,只要少了那些没活下来的,结论就是歪的。

它的杀伤方向和 前视偏差 一样刻薄:只往好处错。样本里被抹掉的,永远是表现最差的那批。所以它带来的从来不是「结论有噪音」,而是「结论系统性偏乐观」。

它在真实投资里长什么样

退市:整条历史可能从数据库里消失

一家公司摘牌之后,会发生一件很多人没想过的事——它的历史行情可能一起从数据源里下架了。你今天去拉「过去十年全部 A 股日线」,拉回来的往往是「今天仍在交易的这些代码,过去十年的日线」。这两句话差得很远。

差在哪?摘牌前的那段走势,通常是这只票一生中最难看的部分:连续跌停、成交枯竭、退市整理期的断崖。这段最惨的收益,恰恰就是被删掉的那段。qlib 的众包数据说明文件在开头就点了这件事——公开数据源可能缺失已退市股票的数据,而这会把幸存者偏差引进训练过程。它给出的应对是把多个数据源合并、互相交叉校验,好拿到一份更完整的历史记录。

A 股的退市有好几条路径:营收与利润不达标的财务类、股价或市值持续低于标准的交易类(也就是常说的面值类)、信息披露与规范运作出问题的规范类,还有重大违法类。具体的判定口径由现行监管规则决定,也在变,本文不给任何数值——你需要知道的是机制:退市不是随机事件,它高度集中在「表现最差」这一端。 一个只按「随机丢失一些数据」来估计影响的人,会大幅低估这件事。

这里还藏着一个很多人踩过的坑:面值类退市看的是不复权的价格。Vibe-Trading 的 A 股风险预警技能文档特意强调了这一点。如果你的数据管道里所有价格都做了前复权,那你算出来的「离退市线还有多远」根本不是监管看的那个数。同一份数据,复权与否是两个世界。

ST:你剔掉的是今天的 ST,不是当时的 ST

回测的时候把 ST 股剔掉」——这几乎是每个 A 股策略的标配一行。ST 是「风险警示」,交易所给持续经营存在风险的公司挂的牌子,前面再加个星号(*ST)表示情况更严重。剔掉它们听起来天经地义。

问题是:你手上有历史的 ST 名单吗?

Vibe-Trading 的 A 股技能文档在数据源清单里写得很直白:常用接口只返回当前的 ST 与退市整理板列表,历史不可查。换句话说,你能拿到的是「今天谁是 ST」,不是「2021 年 3 月那天谁是 ST」。

于是你用今天的名单去清洗历史,会同时犯两个方向相反的错:

  • 误剔:一家公司是去年才被 ST 的,你把它过去五年正常经营时的数据也一起删了。删掉的是一段真实存在、当时完全合格的样本。
  • 漏剔:一家公司三年前是 *ST,后来摘帽恢复正常。今天的名单里没有它,于是它当年最狼狈的那段全额留在了你的样本里——而且是以「正常股票」的身份留着。

更别扭的是第三个方向:这份名单本身就携带了未来信息。你在 2021 年的那一格上剔除了一家 2025 年才被 ST 的公司,等于替当年的自己做了一次预知。这时候幸存者偏差和前视偏差是同一个动作的两面,只是前者说的是「样本里剩下谁」,后者说的是「你什么时候知道的」。

借壳与并购:代码还在,公司已经换人了

A 股还有一种更隐蔽的形态:股票代码从头到尾没断过,价格序列连续、完整、复权因子齐全——看起来是一份质量极高的数据。但中间某个时点,这家公司被注入了完全不同的资产,主业从制造换成了别的,管理层、业务、收入来源全变了。序列还是那条序列,公司已经不是那家公司。

如果你在做「长期持有优质公司」这类研究,这条连续曲线会让你把两段毫不相干的历史当成一家企业的成长轨迹。反过来,被吸收合并而消失的那一方,代码注销,历史断在合并那天。

这里要小心一个过度修正:「消失」不等于「归零」。被吸收合并、被私有化的股东是拿到了对价的,把这类标的一律按亏光处理,会从高估直接跳到低估。补正幸存者偏差的正确姿势不是「把消失的都当死掉」,而是「查清它是怎么消失的,按当时股东实际拿到的结果记账」。这件事很费力,也正是绝大多数人跳过它的原因。

基金榜单:清盘的那些不在榜上

同一个机制在基金上更明显。Vibe-Trading 的基金分析技能文档把它列为第一条注意事项:基金数据库中已 清盘 的基金可能被排除,导致历史平均业绩偏高。

想想这个筛选有多狠。一只基金业绩差 → 持有人赎回 → 规模缩到红线以下 → 清盘退出 → 从数据库消失。筛选变量和业绩几乎完全相关。 于是「主动基金平均年化多少」这个数,天然就是一份幸存者名单的平均值。你在任何平台上看到的「同类排名前 10%」,分母也是活到今天的那些。

指数:涨了几十年的曲线,是一套换人机制

这一条最容易被当成常识而放过。宽基指数 的成分是定期调整的,规则大体是把不再符合条件的换出去、把符合条件的换进来。这套机制本身没有任何问题,它就是指数该干的事。

但它意味着:指数的长期曲线,是一条内置了汰换机制的曲线,不是「买定一篮子股票放着不动」的结果。 你拿指数的长期年化去推断「随便挑一堆股票放二十年大概能有多少」,中间隔着一整套换人规则。这不是说指数不好,恰恰相反——指数把这件事做得很规矩、也公开写在编制方案里。歪掉的是你的类比。

系统层:四种系统,四种态度

概念说到这就差不多了。接下来看它在真实系统里长什么样——同一个问题,四套代码给出了四种完全不同的态度,把它们摆在一起,你会比读十遍定义更明白它是什么。

来源 做法 一句话说清
qlib 数据层 每个标的存「起止时间区间」而非一个名字 股票池是时间的函数,不是一张名单
qlib 指数收集器 从今天的名单倒着回放进出事件,重建历史成分 名单可以被复原,只要你肯记账
Vibe-Trading 用「曾经的成分」并集 + 逐日成员掩码;做不到就打标记 修不了的偏差,至少要写在报告里
freqtrade 默认只下载仍在交易的品种,历史名单需显式索取 默认值本身就是有偏的

做法一 · 把股票池写成时间的函数

qlib 存放股票池的文件格式简单到有点朴素:制表符分隔的三列,标的代码、起始日期、结束日期。关键在于一个标的可以有多行——它可以进指数、出指数、再进来,每一段都是独立的一行区间。

取用的时候,它不是「返回一张名单」,而是拿你给的时间范围去和每个标的的区间求交集:

_instruments_filtered = {
    inst: list(
        filter(
            lambda x: x[0] <= x[1],
            [(max(start_time, pd.Timestamp(x[0])), min(end_time, pd.Timestamp(x[1]))) for x in spans],
        )
    )
    for inst, spans in _instruments.items()
}
_instruments_filtered = {key: value for key, value in _instruments_filtered.items() if value}

交集为空的标的直接被丢掉——也就是说,一只 2020 年才上市的股票,你查 2018 年时它压根不会出现;一只 2019 年摘牌的股票,你查 2018 年时它必须出现。这个数据结构最值钱的地方在于:「今天的名单」这个概念在这里根本不存在,你连问都问不出来。

做法二 · 从今天倒着放电影,把历史名单还原出来

有了格式还不够,历史区间从哪来?qlib 的指数收集器给了一套很像会计对账的办法。

第一步,把能拿到的历史成分快照按时间倒序扫一遍,相邻两个快照一比对,就能反推出这中间发生了什么:上一期有、这一期没有的,是被调进来的(add);这一期有、上一期没有的,是被调出去的(remove)。一串名单快照,就这样被翻译成了一串带日期的进出事件。

第二步更巧。它从今天的成分名单出发,倒着回放这些事件:

for _row in tqdm(changers_df.sort_values(self.DATE_FIELD_NAME, ascending=False).itertuples(index=False)):
    if _row.type == self.ADD:
        # 遇到「调入」事件:把这只票的起始日期,前推到它真正进来的那天
        ...
    else:
        # 遇到「调出」事件:把这只早已离场的票重新补进名单,
        # 区间是「基准起始日 → 它被调出去的那天」
        _tmp_df = pd.DataFrame([[_row.symbol, self.bench_start_date, _row.date]], columns=instruments_columns)
        new_df = pd.concat([new_df, _tmp_df], sort=False)

看懂这一段,你就看懂了幸存者偏差的解法长什么样:每遇到一个「曾经被踢出去」的事件,就把那家公司请回名单里,并给它标上离场日期。 电影倒着放完,你手上的就不再是今天的三百家,而是这段历史里前前后后进出过的所有家,每一家都带着自己的在场时间。

同一份代码里还有一个细节:它另外保存了一份只含当前成分、没有任何历史区间的文件,文件名里带着 only_new。两份文件放在一起看,是理解这件事最直观的方式——一份是名单,一份是历史。

做法三 · 修不了的时候,把偏差写进报告

Vibe-Trading 的因子评测工具走的是同一条路,但它多做了一件事,而这件事在工程上更值得学。

它构建股票池的方式是:把窗口内任何时点曾经是成分股的代码全部取并集下载下来,然后再用一张逐日的成员矩阵去掩码,让每一天的横截面只保留当天真正在册的名字。源码注释把不这么做的后果写得很清楚——否则整个面板会把今天的名册一路带回窗口起点,一只票之所以在样本里,只是因为它撑到了最后,那么每一个统计量都是在一个用后见之明挑出来的集合上算的。

关键在于它对「做不到」的处理。当拿不到时点成分数据、只能退回一份静态名单时,它不假装没事,而是在结果的元数据里挂上一个标记:

panel["_meta"] = {
    "universe": "csi300",
    "survivorship_bias": membership is None,
    "pit_membership": membership is not None,
    ...
}

另一个用公开百科的当前成分表拼出来的股票池,这个字段直接被硬编码成 True,并在日志里明说这份池子是幸存者偏差的。这个标记会一路传到评测汇总的元信息里,让看报告的人知道自己在看什么。

「消除偏差」和「声明偏差」是两种不同的诚实,后者的成本低得多,价值却常被低估。 一份写着「本结果基于幸存者名单」的报告,和一份什么都不说的报告,读者的解读会完全不同。

同一份代码里还有一处同源的讲究:拿不到复权因子的标的会被丢弃而不是用未复权价硬算,而丢弃的数量被单独记进元数据。注释里那句话值得抄下来——丢弃这个动作本身必须可见,否则它就变成了另一种无声的偏差。

做法四 · 承认默认值是有偏的

freqtrade 在加密货币这一侧给了一个更朴素的提醒:它的历史数据下载命令,当你用通配符展开某个计价货币下的「全部交易对」时,展开出来的是当前仍在交易的那些;要连同已经下架的一起下,需要显式加上包含非活跃交易对的开关。(这是当前版本的默认行为,可配置,也可能随版本变化。)

它的回测文档里还有一条警告:动态品种筛选器在回测里是可以用的(并非所有筛选器都允许),但它依赖的是当前的市场状况,反映不出这个名单在历史上的样子;而且一旦用了非静态名单,回测结果的可复现性也没法保证。

顺带一提,交易所是否即将下架某个品种这类信息,只在实时数据接口里才有(而且文档写明并非所有交易所都提供)——历史文件里没有这一列。能不能拿到「谁将要消失」,和能不能拿到「谁曾经消失」,是两个完全不同的问题。

做法零 · 你的日常

把上面四种摆完,再看你每天在做的事:

翻一份「近五年收益最好的基金」榜单,翻一个连续盈利多年的账户晒单,看一篇「十年十倍股复盘」,听朋友讲他表哥当年怎么All in 一只票翻了几倍。

这些样本有一个共同点——它们是被结果筛选出来才出现在你面前的。 亏光离场的人不写复盘,清盘的基金不上榜,赌错方向的表哥不被提起。你以为自己在观察一个分布,其实你只被允许看到它的右尾。

对付它的办法和 qlib 那套是一个道理:每看到一个成功样本,先问一句「和它同期做同样事情的人,一共有多少个,现在还剩几个」。这个问题的答案通常查不到,但知道自己在看一份被筛过的名单,本身就已经改变了你该给它多少权重

六项自查,不动代码也能做

  1. 数一数池子随时间的大小。 从头到尾都是同一批代码、数量一动不动,几乎可以判定是今天的名单。真实的池子应该有进有出。
  2. 样本期内有没有任何一个标的「消失」过? 一个都没有 = 红灯。一个都没有还横跨了好几年 = 双红灯。
  3. 去问数据的来源怎么处理摘牌标的:整条历史一起下架,还是保留到最后一个交易日?这件事问供应商比读代码快,而且答案往往写在文档的角落里。
  4. 查清你的 ST 名单是哪一天的。 如果只有当前名单,那就明确写下来:本次清洗使用的是当前名单,历史口径无法还原。
  5. 把静默丢弃的数量打印出来。 复权因子缺失、停牌太久、数据字段不全——每一次「跳过这只」都是一次隐形筛选。看不见的剔除最危险。
  6. 做一次极端对照。 尽力把池子换成「窗口内曾经存在过的全部标的」,同一套规则再跑一遍,看结果掉多少。这个差额是个粗糙的估计而不是精确测量,但它能给你一个量级感——而量级感往往比精确值更能改变你的决策。

这六条逐项过一遍容易漏。幸存者偏差自查清单把样本泄漏的常见入口铺成了一张更全的勾选表,手上有现成的池子和名单时对着勾一遍,没勾上的那几条会汇成一个风险分级。

🚧 避坑

「我加了退市股,问题就解决了」——通常没有。 退市标的的数据质量恰恰是最差的:长期停牌、复权因子缺失或不再更新、最后一段行情稀疏甚至空缺。你把它们加回来之后,很可能会因为「数据不干净」在下一步清洗里又把它们过滤掉一遍,绕一圈回到原地。补齐样本和补齐数据质量是两件事,第一件不难,第二件很贵。

失效边界与常见误用

和前视偏差混为一谈。 幸存者偏差是样本选择问题——名单里剩下谁;前视偏差是时间信息问题——你什么时候知道的。ST 名单那个例子里两者同时出现,但它们的检查方法完全不同:查前视要看每个数字的可得时间,查幸存要看池子的进出记录。用查前视的工具查幸存,一无所获。三类回测偏差的完整分界见 回测偏差总览

过拟合混为一谈。 过拟合 是参数被历史噪音磨得太合身,换一段数据就失灵;幸存者偏差是数据本身就选错了对象,参数再朴素也救不回来。过拟合可以靠样本外检验发现,幸存者偏差不行——因为你的样本外用的还是同一份幸存者名单。

以为「样本量大」就能对冲 恰恰相反。样本量越大,你越容易相信这个偏乐观的均值,从而下更重的注。系统性偏差不会被大样本稀释,只会被大样本坐实。

只在做量化时才想起它。 你不写代码,一样每天在吃这个亏:基金排行、大 V 战绩、公众号里的复盘、别人告诉你的成功案例,全是幸存者样本。这一层比代码那层难修得多,因为没有任何工具会提醒你。

过度修正成另一种错。 把所有消失的标的一律按归零处理、把所有 ST 一律当作必然退市,会从系统性高估直接翻到系统性低估。补正的目标是还原当时的真实结果,不是往悲观方向再拍一次脑袋。

小结

  • 幸存者偏差 = 你能看到的样本,是被一套和你的问题高度相关的机制筛选过的;它只会让历史成绩变好看
  • A 股的四种具体形态:退市标的的历史可能整条下架、ST 名单往往只有当前版本、借壳让代码连续但公司换人、被合并的标的「消失」不等于「归零」。
  • 系统层四种态度:把股票池写成带起止时间的区间(时间的函数,问不出「今天的名单」)、从当前名单倒着回放进出事件重建历史成分、用曾经成员的并集加逐日掩码并在做不到时打上偏差标记、承认下载工具的默认值只给你活着的品种。
  • 「消除偏差」之外还有一种更便宜的诚实叫「声明偏差」:修不了就写进报告的元信息里,让读结果的人知道自己在看什么。
  • 六项自查里最有用的两项:池子有没有进出、静默丢弃了多少。看不见的剔除最危险。

一句话记住这篇:只统计活下来的那些人,你得到的从来不是「这条路好不好走」,而是「走通的人长什么样」。

本文所引源码与文档均来自上述快照版本,是阅读代码得出的机制说明,不是运行结果;具体行为请以你手上那个版本为准。回 量化与 AI 投研概念库 看其它概念各自在说什么;本站内容为投资教育,不构成任何投资建议,边界见 免责声明

内容有错、看不懂、或想看下一篇?告诉我们 →

本文为投资教育整理,不构成任何投资建议;不荐个股/基金、不预测点位、不承诺收益。关键数据与结论请结合权威来源自行验证,并见风险免责声明