ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

量化价值投资中的Pandas排序策略:从sort_values到rank的工程实践

量化价值投资中的Pandas排序策略:从sort_values到rank的工程实践 每年年报季前后我都要把全市场股票的估值因子重新算一遍然后按综合得分排序、挑出前十分之一构建候选池。这套流程我用了很多年最初用 Excel 手工拉表后来彻底切到了 Pandas。表面上看Pandas 的排序无非就是sort_values加一个ascending参数的事但真正放到量化价值投资的选股流水线里你会发现排序的细节远比想象中多缺失值要先处理还是先排序市盈率动辄上千的极端值怎么消化多列排序的优先级怎么设计不同行业的估值基准完全不同直接全市场排序会不会把持仓长期锁定在银行、地产这种低估值板块里这篇文章就围绕这些问题展开适合两种读者一种是想用 Python 做价值投资分析的投资者另一种是已经把 Pandas 用得很熟、但打算把排序因子真正落地到策略里的量化研究员。我会从业务需求讲到具体函数再给出一套我在生产环境里验证过的排序流水线最后把这几年踩过的坑一并交代清楚。1. 价值因子到组合构建排序在选股流水线中的位置1.1 从估值指标到选股结果的一条完整链路量化价值投资的核心逻辑并不复杂找到基本面扎实、价格又低于内在价值的公司。落实到数据层面就是先拿到一批估值指标——市盈率PE、市净率PB、市销率PS、股息率、现金流折现后的安全边际然后把这些指标加工成一个综合得分最后对全市场股票按得分排序得分越高代表越值得买。这里的关键是排序不是一个孤立的操作而是连接因子数据和组合构建的枢纽。在我平时维护的策略流水线里整条链路是这样走的定时从财务数据源拉取最新财报指标计算 PE、PB、PS、PCF 等原始因子。做数据清洗剔除 ST、次新股、停牌股处理缺失值和异常值。因子标准化让不同量纲的指标可以相加比如 PE 是几十倍、PB 是个位数直接加没有意义。加权合成综合得分价值因子多的可以更激进一点比如 PE 占 30%、PB 占 30%、股息率占 20%、PCF 占 20%。排序选股按综合得分降序排列取前 10% 或前 50 只进入组合。回测验证按排名分组验证排名靠前的组是否真的跑赢排名靠后的组。第 3 步到第 5 步每一步都在跟排序打交道。如果你只是想在 DataFrame 里按照某列排个序那df.sort_values(pe)一行就结束了。但你在做投资决策时排序背后的语义必须想清楚你是想找估值最低的股票还是估值相对自己历史最便宜的股票这两种目标对应完全不同的排序逻辑。1.2 排序在因子研究中的三层用途同一个sort_values和rank在策略研究的不同阶段承担的任务完全不同。我把它们归纳成三层截面选股在某个时间截面上对全市场股票按因子排序选出最好的那一批。这是最直观的用途。因子检验把股票按因子值从小到大分成十组算每组后续一段时间的平均收益。如果第 1 组收益最低、第 10 组收益最高说明这个因子有效而且组与组之间的收益通常还要有单调性。这一步本质上也是在排序只是排序后不是直接买而是做统计验证。组合加权选完股之后怎么分配仓位一种常见做法是等权另一种是按排名赋予权重排名越靠前权重大排名靠后的权重小。这也依赖排序结果。所以不要小看排序这个动作。它既决定你看待数据的方式也直接决定组合的持仓结构。后面几章我会逐个拆解这些场景里的排序细节先从排序前的数据清洗讲起。2. 排序前的估值因子清洗缺失值、极端市盈率与标准化2.1 缺失值会怎么影响排序结果在开始排序之前第一件事是搞清楚你的因子列里有没有 NaN。Pandas 排序时遇到缺失值默认行为是排在最后na_positionlast听起来很省心但这会在两个地方给你埋雷。第一个雷是如果你的策略逻辑是估值越低越好PE 缺失的股票排在最后正好被你排除看起来没问题。但如果有一个因子的 NaN 恰好代表该股票不盈利所以没有合理 PE那这些股票里面可能藏着反转股你盲目排到末尾会漏掉它们。更稳妥的做法是主动决策而不是让 Pandas 的默认参数替你决策。第二个雷是多个因子加权合成得分时NaN 不会因为加权就消失。df[综合得分] 0.3 * df[pe_rank] 0.3 * df[pb_rank] 0.4 * df[pcf_rank]这样的表达式只要pb_rank里有一个 NaN整个综合得分就变成 NaN最终排序时这只股票被甩到最后。我的习惯是排序前先做一轮明确处理# 先剔除核心数据缺失的股票 df df.dropna(subset[pe, pb, ps]) # 对于某些有业务含义的缺失用行业均值填充 df[pcf] df.groupby(industry)[pcf].transform(lambda x: x.fillna(x.median()))这里特别说明一下dropna和fillna哪个更好取决于你的策略容忍度。我做的是偏保守的价值投资一般只保留核心指标全部齐全的股票宁可少选也不能拿缺失数据硬凑。2.2 极端市盈率怎么处理价值投资里最头疼的数据问题是极端值。A 股里 PE 低于 0 的亏损股一大片PE 超过 1000 的微利股也不少。如果你直接用原始 PE 排序极端值会彻底扭曲整个排序结果PE 为 -5 的亏损股排最前面PE 为 800 的股票排在中间PE 为 20 的优质价值股反而排到了后面。这完全不符合价值投资的直觉。处理极端值业界没有统一答案但主流做法是先把 PE 限制在合理区间再做排序。我自己常用的方式有三种分位数截断Winsorize把小于 5% 分位数和大于 95% 分位数的值强制替换为对应的分位数值。MAD 法基于中位数和绝对中位差识别离群值比简单的分位数截断更稳健。直接剔除对负 PE 直接剔除因为亏损股不在传统价值投资的筛选范围内。我倾向于用 MAD 法因为它对数据分布的假设更少。具体代码大概是def mad_winsorize(s: pd.Series, n5): median s.median() mad (s - median).abs().median() upper median n * 1.4826 * mad lower median - n * 1.4826 * mad return s.clip(lower, upper)为什么用 1.4826因为对于正态分布MAD 约等于 0.6745 倍标准差取倒数就是 1.4826这样把 MAD 换成了标准差的量纲。这不是我发明的是统计里常用的稳健尺度估计量。2.3 标准化为什么不能直接吃原始值把缺失值和极端值处理完之后你面对的是量纲完全不同的几个指标PE 可能是 10 到 30PB 可能是 0.8 到 3股息率可能是 0 到 6%。如果你把0.4 * pe 0.3 * pb 0.3 * 股息率当成综合得分那股息率那项的贡献几乎可以忽略排序结果基本被 PE 绑架。标准化有两条路线。一条是 z-score(x - mean) / std把数据变成均值 0、标准差 1 的分布。另一条是百分位排名把每个值转换成它在全样本中的百分位值域固定在 0 到 1 之间。做量化价值投资我强烈推荐百分位排名作为标准化的首选原因有两个第一它天然把异常值压到两端的 0 和 1即使前面极端值没清理干净影响也被限制住了第二百分位排名本质上就是rank(pctTrue)操作简单性能也远好于先排序再映射回原始值的复杂流程。df[pe_pct] df.groupby(date)[pe].rank(pctTrue) df[pb_pct] df.groupby(date)[pb].rank(pctTrue) # 股息率越高越好所以取反 df[div_pct] 1 - df.groupby(date)[dividend_yield].rank(pctTrue)注意这里有一个价值的正负方向估值越低越好股息率越高越好。所以股息率的百分位得分要取 1 减去排名百分位保证所有因子都是得分越高越好后面合成综合得分才不会乱套。3. sort_values 的细节与大规模面板数据的性能瓶颈3.1 核心参数逐个拆解sort_values的常用参数我都列成了一张表方便你对照着看参数作用我的使用习惯by指定排序列可以是单个列名或列名列表多因子排序列成 list越靠前的越优先ascending升降序可以是布尔值或布尔值列表单列用 True / False多列用[True, False]na_positionNaN 排最前还是最后明确设置不让默认值代替策略决策kind排序算法quicksort / mergesort / heapsort / stable需要可复现时用mergesortignore_index排完序后是否重置索引保存结果时设 True避免索引混乱inplace是否原地修改基本不用保持管道式写法多列排序时by[score, pe]的含义是先按score排序score相同的再按pe排序。这个后者只是前者的 tie-breaker的特性在选股场景里非常实用。比如综合得分相同的情况下我希望 PE 更低的那只股票排在前面写成df.sort_values([score, pe], ascending[False, True])即可。3.2 大面板数据下的性能瓶颈做全市场回测时你手里的数据通常是按月快照的面板每个月有 5000 只股票10 年就是 60 万行。这种量级下groupby之后再逐组sort_values是一个常见但非常慢的写法# 非常慢别这样写 df.groupby(date).apply(lambda x: x.sort_values(score, ascendingFalse))原因很简单apply会对每个分组产生一个临时 DataFrame然后groupby再把这些 DataFrame 合并回来中间有大量对象创建和拼接开销。正确的做法是直接对全表排序用sort_values的多列顺序保证组内有序# 快得多先按日期、再按得分排序 df df.sort_values([date, score], ascending[True, False])这样得到的结果中同一个 date 内的行是连续且按 score 降序排列的。接下来你如果需要给每个日期内加一个组内排名序号直接用groupby(date).cumcount()就可以完全不需要逐组排序。这是我做月度截面排序最常用的组合。另外一个容易被忽视的性能因素如果排序列是字符串类型排序速度会明显慢于数值类型。我的建议是给股票代码加一个专门的数值列比如把 600519.SH 这类代码映射成整数 ID排序时用 ID 而不是字符串。3.3 key 参数和自定义排序边界sort_values还有一个key参数允许你对排序键做一次函数变换后再排序。简单说df.sort_values(pe, keylambda x: np.log(x))会先取 PE 的对数再排序。这个参数能否用在我们这个场景我试过几次实际价值很有限。因为价值因子的清洗本来就应该在排序前完成把转化逻辑塞进key会让代码的可读性变差。但有一个例外排序前想对某个列做临时的大小写统一或取绝对值用key会非常省事属于那种偶尔用到一次就能救你一命的参数。4. rank 排名因子得分合成与行业中性化的正确实现4.1 sort_values 和 rank 的区别以及五种排名方法很多刚接触 Pandas 的人会混淆sort_values和rank。其实两者的关系很简单sort_values是给 DataFrame 的行重新排序rank是给每行计算一个排名值原数据的顺序保持不变。rank的method参数有五种用一组 PE 数据 [12, 15, 9, 15, 22] 来演示最直白排序键methodaverageminmaxfirstdense9111111222222153.53433153.534432255554在价值投资的多因子合成里average是最常用的因为两个 PE 完全相同的公司不必因为顺序分个高下取平均名次符合直觉。dense适合你只关心分几个档次的场景比如分十组。first会按出现顺序分配名次虽然可复现但排序结果依赖数据顺序——如果数据源顺序变了排名就变了我不推荐在策略里用。有一个很关键的细节rank在ascendingFalse时值最大的排名最小1值最小反而排名最大。如果你想要的语义是PE 越小排名越靠前记得把ascendingTrue加上# PE 最小的排名第 1 df[pe_rank] df[pe].rank(methodaverage, ascendingTrue)4.2 行业中性化全市场排序的一个大坑全市场直接排序价值因子天然会偏向行业。银行股 PE 常年 5 倍到 8 倍科技股 PE 30 倍起步很正常。如果你对全市场股票按 PE 排名结果就是排名靠前的全是银行、地产、钢铁组合永远长成高股息低估值风格的模样。解决这个问题最常用的方法是行业中性化也就是在行业内部做排名再把排名合成得分df[pe_rank_in_ind] df.groupby([date, industry])[pe].rank(pctTrue)这个做法等于是问一个问题在这家公司的同行里它的估值算高还是算低跨行业比较 PE 没有意义同行里的相对估值才有意义。做完行业中性化之后综合得分变成了在各自行业里都相对便宜的公司集合组合的行业分布会更均匀也更符合价值投资分散风险的原则。但这里要注意一个副作用行业中性化会削弱因子本身的信息量。如果整个银行业都被低估行业中性化之后银行股内部的排名差异不会让它获得比其他行业的股票更高的得分。所以实际落地时我通常做半中性化——行业内部排名和全市场排名各占一部分权重这样既保持行业相对低估的信号又不被行业内部的噪声完全带走。4.3 排名转得分百分位排名的实际意义为什么在合成的环节推荐用rank(pctTrue)而不是rank()因为rank()给的是 1 到 5000 的绝对数字不同月份的股票数量不一样排名不能跨时间比较。比如 1 月份有 5000 只股票它排名第 5004 月份有 4800 只股票它还是排名第 500但两个 500 的含金量完全不同。百分位排名把名次除以样本数量得到 0 到 1 之间的比例这样不同时期的排名就可以直接比较了。市盈率排名第 10% 的股票不管样本量是 3000 还是 8000代表的意义都是比 90% 的公司便宜。用量化的话说这种处理叫截面标准化去掉的是时间轴上的样本量波动保留的是横截面的相对估值信息。做完这一步再对多个因子做简单加权平均得到的综合得分就是一个含义清晰、可以历史回溯的指标。5. 排序结果的下游应用分层回测与权重配置5.1 用 qcut 做因子分层验证排序是否有效排序结果的第一类下游应用是因子检验。拿到每个时间截面的因子排名后按排名把股票分成 10 组算各组在下一周期的组合收益df[group] df.groupby(date)[composite_score].transform( lambda x: pd.qcut(x.rank(methodfirst), 10, labelsFalse) )这里我用了qcutquantile cut它的作用是把数据按分位数切成几段。为什么不用cut因为cut是按数值区间切分比如得分 0 到 0.1 是一组、0.1 到 0.2 是第二组但得分分布不是均匀的某些分数区间会挤进大量股票另一些区间可能一只都没有。qcut保证每组的股票数量接近相等这在做因子检验时才是合理的。qcut的一个经典报错是Bin edges must be unique意思是某个分位点对应的值重复了导致切割边界重叠。解决办法有两个一是先对得分取排名再用qcut切排名比如上面代码里的x.rank(methodfirst)二是给qcut加duplicatesdrop参数但这样分出来的组数量就不一定是 10 组了。我的习惯是先 rank 再 qcut可复现性最好。5.2 从排名到持仓权重如果因子检验通过排名就要真正用于组合构建了。这里我分享一个比较顺手的权重配置方案对入选的股票按排名做线性权重递减。假设你选出了 20 只股票综合得分排名第 1 的权重最大。最简单的线性权重是selected df[df[percentile] 0.1].sort_values(composite_score, ascendingFalse) selected[weight] (selected[composite_score] - selected[composite_score].min()) / \ (selected[composite_score].max() - selected[composite_score].min()) # 归一化 selected[weight] selected[weight] / selected[weight].sum()要不要做这个权重分配我个人的经验是如果组合股票数量在 20 只以上权重差异对最终收益的贡献非常有限等权反而更稳因为它避免了排名第一的股票出幺蛾子拖累整个组合。如果组合只有 5 到 10 只那线性加权就有意义了值得做。5.3 排序后的索引对齐一个隐蔽的故障点排完序之后DataFrame 的索引还是原来的顺序。如果你在这个状态下直接做df1 df2或者df1.join(df2)Pandas 会根据索引对齐数据而不是根据行的位置对齐。这就会导致一个隐蔽的 bug你以为拿到的第 1 行是排名第 1 的股票但索引还是原始数据里的行号合并时 Pandas 自动按索引匹配排序结果完全被忽略。解决方案很简单排序后重置索引或者养成显式归并的习惯sorted_df df.sort_values(composite_score, ascendingFalse).reset_index(dropTrue) # 或者保留原代码的情况下用 merge 而非 join merged pd.merge(sorted_df, price_df, on[date, code], howleft)我建议把排完序的表存成标准化结构一列日期、一列股票代码、一列综合得分、一列百分位排名后面不管做回测还是画图都不容易踩到索引对齐的坑。6. 我在生产环境中踩过的五个排序相关坑6.1 排序稳定性同一个策略两次结果不一样sort_values默认使用 quicksort这个算法是不稳定的意思是对两个相同的关键字它们在排序后的相对顺序不能保证。对选股策略来说最致命的结果是同样一组数据你在两台机器上跑或者换一个 Pandas 版本跑排名第 49 名和第 50 名的股票顺序可能不一样然后你恰好取前 50 只组合就变了。更糟的是rank(methodfirst)本身就依赖数据的原始顺序如果上游数据源的顺序有变化排名就变了。我的解决办法是排名时增加一个稳定的 tie-breaker 列比如股票代码df[sort_key1] df[composite_score] df[sort_key2] df[code].map(code_to_int) # 用整数代码打破并列 df df.sort_values([sort_key1, sort_key2], ascending[False, True], kindmergesort)mergesort是稳定排序配合唯一的二次排序键可以保证结果在多次运行中百分百一致。这对我做策略回测非常重要——回测结果不可复现后面所有分析都不可信。6.2 百分位排名和 top 10% 的关系很多人以为df[percentile] 0.1就代表排名前 10%。对rank(pctTrue)来说确实如此。但注意rank(pctTrue)的语义有版本上的细节早期版本里排名第 1 的百分位可能是1/n而不是 0我习惯先看一眼describe()再使用df[rank_pct] df.groupby(date)[composite_score].rank(pctTrue, ascendingFalse) print(df[rank_pct].describe())确认最大值是不是等于 1、最小值是不是接近 0。如果语义和预期不符选股结果会整体偏移几个百分点这在实盘里不是小事。6.3 不要把 sort_values 和 rank 混在同一个管道里我在代码评审时经常看到一种写法先用sort_values排序然后reset_index再用range(len(df))生成一个排名列。这种做法跟rank的结果在大部分情况下等价但它有两个问题一是没处理并列值二是没考虑样本量变化三是多写了好几行无用代码。我建议的原则很明确排序和排名各司其职。sort_values负责把 DataFrame 变成方便阅读和切片的形式rank负责生成可以参加计算和合成的数值。不要在排序后的 DataFrame 上手工算序号直接用rank一次得到排名列名里写清楚是用的哪种排名方法。6.4 空值在 rank 中的传播跟直觉是反的rank有一个na_option参数默认值是keep也就是说 NaN 在排名结果里仍然是 NaN。如果你的后续计算是rank(pctTrue)直接参与加权那 NaN 的传播会导致综合得分为 NaN最终排序时掉到末尾。我有一段时间在实盘脚本里出现过为什么漏掉了一批股票的问题最后定位到——是这些股票的某个因子没有数据排名传播了 NaN。处理方案就是在计算排名之前把缺失值显式处理掉比如先fillna(df.groupby(industry)[factor].transform(median))或者直接剔除。一句话不要让 NaN 一路渗透到综合得分里。6.5 因子排序方向的一致性最后一个坑不是技术问题是策略语义问题。一个组合里你可能有市盈率越低越好的因子也有股本回报率越高越好的因子。合成综合得分前所有因子都需要统一方向。我的做法是统一为分数越高越好具体操作是对越低越好的因子用rank(pctTrue)之后不反转对越高越好的因子用1 - rank(pctTrue)。这里我建议不要依赖记忆直接在代码里写好注释因为四个因子叠在一起方向真的容易搞反。搞反一次回测曲线可能刚好变成反的策略就从低估值选股变成了追高选股。我自己在策略脚本里会专门留一段 assert 来检测方向# 验证方向综合得分最高的股票其 PE 分位应该明显低于全市场均值 assert selected[pe_pct].mean() df[pe_pct].mean() * 0.5, 因子方向可能搞反了这个断言不贵但在每次跑完选股后都能帮你提前发现方向性错误。以上就是我在量化价值投资里使用 Pandas 排序策略的全部经验。核心总结起来就一句话排序是整个选股流水线的枢纽但真正决定策略质量的不是排序本身而是排序之前的数据清洗、排序时的语义选择以及排序后你怎么使用排名结果。希望这篇文章里基于我实际操作的细节能让你少走几步弯路。最后再分享一个小建议所有排序和排名相关的代码都尽量写成管道式每一步都明确传入参数、不依赖默认值这样不管是回测还是实盘你都能盯住每一步在干什么。
返回列表