Brinson多期归因模型Python实现:从单期分解到Carino平滑
发布时间:2026/10/4 9:01:48 锦皓数字建站

前阵子有个做FOF的朋友拿一份基金经理的业绩曲线来找我问得很直接“这人去年赚了35%到底是他行业押得准还是个股选得好我该怎么判断”我说光看净值没用净值只能告诉你结果不能告诉你钱是从哪个环节赚来的。你得把他的持仓拉出来跟基准指数逐行业对一遍算清楚每一分超额收益的出处——这就是Brinson归因模型干的事。而落到实际工作里你得用Python处理几十个季度的持仓快照解决多期收益跨期累加的问题这就是这次要聊的Brinson多期归因模型的完整Python实现。这篇文章适合谁一类是做FOF和基金研究的需要把基金经理的收益来源拆开看能力另一类是管自己组合的想搞明白自己相对基准的超额到底来自仓位还是选股。我会把从单期Brinson到多期Carino平滑的完整逻辑、代码、验证过程都写出来你可以直接照着跑一遍。1. 归因到底在回答什么问题从“赚了多少”到“凭什么赚”先往后退一步想清楚归因这个动作的本质。任何基金收益都可以拆成两部分一部分来自市场本身涨跌另一部分来自相对基准的偏离。买沪深300指数基金赚的是市场的beta买主动权益基金基金经理的每一个决策本质上都是在做“配置偏离”和“选股偏离”——比如比基准多配了两个点的电子行业或者在电子行业里挑了跟基准不一样的股票。Brinson归因就是把“相对基准的超额收益”拆成这两块贡献的数学工具。为什么说这个问题很关键因为同样一年赚35%A经理可能是行业配置压对了电子、医药、新能源都超配但行业内选股很一般B经理可能行业配置跟基准差不多纯粹靠行业内挖出了几只翻倍股。这两种能力的可持续性完全不同前者依赖宏观判断和行业轮动节奏后者依赖深度研究和个股挖掘。如果你在做基金池筛选不把这两件事拆开你根本不知道该给谁加权重。Brinson模型在基金量化研究里属于最经典的持仓归因框架跟Barra模型这类因子归因是两条路线。Brinson是从持仓权重出发自上而下按分类维度行业、板块、资产类别拆解Barra是从因子暴露出发自下而上把收益映射到风格因子和行业因子上。实际工作中两者经常配合使用但Brinson胜在直观、可解释性强、对数据要求相对低——你只需要组合权重、基准权重、分类收益三张表就够了不需要估计因子暴露。这个系列的文章我就从Brinson开始讲因为它是一切的基石。你先把配置和选股这两条腿拆明白再去做风格暴露、交易归因逻辑就顺了。2. 单期Brinson的分解逻辑先搞清楚一笔账怎么记Brinson单期模型的核心是一组恒等式任何一个时间段内组合收益减基准收益必然等于配置效应、选股效应、交互效应三者之和。理解这三项各自的含义是所有后续步骤的地基。2.1 三大效应的数学定义假设某个分类维度下有N个分项比如申万一级行业的31个行业用w_p和w_b分别表示组合和基准在某个行业上的期初权重用r_p和r_b表示该行业区间收益率。那么单个行业的贡献可以拆成配置效应 (w_p - w_b) × r_b组合这个行业的权重比基准重还是轻如果这个行业基准本身涨了超配就赚钱。选股效应 w_b × (r_p - r_b)在权重跟基准一样的假设下组合在这个行业里挑的股票比基准的行业指数多赚了多少。交互效应 (w_p - w_b) × (r_p - r_b)权重偏离和选股偏离同时存在时产生的交叉项。把N个行业的三个效应分别相加就得到总配置、总选股、总交互。而且数学上必然有组合收益 - 基准收益 总配置效应 总选股效应 总交互效应这个等式不是近似是恒等式只要权重之和与收益口径对得上多一分钱都对不上。2.2 生活化理解开饭店的仓位和手艺我经常用开饭店来类比。配置效应好比你把店面开在哪个商圈——你比同行多开了两家在核心商圈的店整条街都在涨租金你就赚到地段溢价选股效应好比同一商圈里你的菜品比别人好吃客流更旺交互效应则是“好地段好菜品”的放大效果——你不仅店开得好菜也比别人强两者叠加产生额外收益。这三个效应不是平均分配的。有的基金经理强在行业轮动配置效应常年为正选股效应平平有的基金经理行业配置基本跟基准一致但个股挖掘能力强选股效应占大头。你拿Brinson跑完一期数据先看三项的绝对值占比基本就能判断这个人的风格标签。2.3 为什么叫“期初权重”和“静态暴露”Brinson原始公式里用的是期初权重也就是区间开始时点组合和基准在各行业上的实际配置比例。这意味着模型隐含假设期间内权重不变所有收益都来自期初暴露。这个假设当然不符合实际——基金经理会调仓但Brinson设计成静态暴露是有理由的它可以干净地衡量“期初决策”带来的收益把调仓带来的增量贡献留给更复杂的多期归因去解决。如果你用的是日度换手极频繁的组合静态Brinson的误差会变大这时候就需要把区间拆短用多期归因去平滑。另外注意组合和基准的权重都要归一化到100%。实际操作里组合通常有现金、打新底仓等非行业资产要么单独设一个“现金及其他”分类要么先剔除再对剩余权重做归一化否则权重差额会虚增配置效应。3. 多期归因为什么麻烦算术相加与复利对不上账如果你只想做一个季度的归因前面单期模型已经够用。但实际做基金研究拿到的持仓往往是过去两三年、八个十个季度的快照你需要把各期的配置贡献和选股贡献累计起来得到“过去三年总共有多少超额来自配置、多少来自选股”。这时候问题就来了直接求和会交叉失衡。3.1 复利链路导致的路径依赖问题举个直观的例子。组合第一期跑赢基准1%第二期跑赢基准2%算术加总超额是3%。但两期累计的几何超额不是3%因为组合和基准各自的净值在复利增长第二期的超额收益是在第一期末的净值基础上继续累积的。Brinson各期贡献相加是算术口径而区间总超额是复利口径。两者天然对不上账。Carino在1999年那篇经典论文里指出的是同一个问题多期Brinson归因如果简单把各期贡献相加会留下一个不小的残差而且残差大小跟收益波动率正相关——市场波动越大残差越大。你不能把这个残差简单丢进“其他项”因为它是系统性偏差不是数据噪声。3.2 三种常见多期平滑方案业界处理这个问题有三条主流路线。第一种是Brinson-Fachler的变体处理本质上不解决复利问题只是通过对交互效应的归属约定来减少残差。它把交互效应并入选股效应表达式简化为配置 (w_p - w_b) × r_b选股 w_p × (r_p - r_b)。好处是分解结果更干净坏处是残差依然存在跨期口径依然不对。第二种是Carino对数调整也是我这次实现用的方案。每期算出原始贡献后乘一个调整系数把每期贡献换算到统一的对数收益口径上最终让所有期的调整贡献相加等于区间总超额。第三种是Menchero和GRAP的迭代最优化方法通过寻找一组系数使残差平方和最小数学上更严谨但实现复杂常规投研场景里不太需要Carino已经足够。3.3 Carino调整系数的数学直觉Carino的关键在于给第t期的三个贡献乘同一个缩放系数。系数的计算方式是先用每期组合收益和基准收益算出对数活跃度再与整体对数活跃度比较得到一个比例。单期系数公式k_t [ln(1R_p,t) - ln(1R_b,t)] / (R_p,t - R_b,t)整体系数公式K [ln(1R_p) - ln(1R_b)] / (R_p - R_b)当组合收益等于基准收益时用极限值1/(1R)代替避免除零。得到系数后第t期的所有Brinson贡献项都乘以k_t/K。这样做的效果是收益波动大的时期贡献被放大或缩小最终所有期加总必定等于区间算术总超额。用一句话概括Carino本质上是在给各期的“算术超额”按对数活跃度重新加权把复利链路里的非线性偏差摊回到每一期去。4. Python实现从数据表到归因结果的全流程代码铺垫了这么多终于到代码部分。我用一组模拟数据走完整条链路。这个例子里有两个期间、三个行业数据量小到你可以手工验算比直接上真实数据更容易确认自己理解正确。4.1 构造模拟数据假设一个组合和基准都只投三个行业信息技术、医药生物、金融地产。组合两期的行业权重和行业收益分别如下基准对应停用。import pandas as pd import numpy as np periods [2023Q1, 2023Q2] sectors [信息技术, 医药生物, 金融地产] # 组合各行业期初权重每行求和为1 p_weight pd.DataFrame( [[0.50, 0.30, 0.20], [0.60, 0.20, 0.20]], indexperiods, columnssectors ) # 组合各行业区间收益率小数形式 p_return pd.DataFrame( [[0.05, 0.02, -0.01], [0.08, 0.03, 0.01]], indexperiods, columnssectors ) # 基准各行业期初权重 b_weight pd.DataFrame( [[0.40, 0.30, 0.30], [0.45, 0.25, 0.30]], indexperiods, columnssectors ) # 基准各行业区间收益率 b_return pd.DataFrame( [[0.04, 0.03, -0.02], [0.05, 0.02, 0.00]], indexperiods, columnssectors )我用DataFrame而不是字典嵌套是为了后面所有运算都能按行、按列向量化代码更短也更快。真实数据量如果比较大pandas的处理效率也完全够用。4.2 单期归因核心函数def brinson_single(p_w, p_r, b_w, b_r): allocation (p_w - b_w) * b_r selection b_w * (p_r - b_r) interaction (p_w - b_w) * (p_r - b_r) return allocation, selection, interaction这里有一个容易忽略的点pandas的Series和DataFrame之间做减法时会自动按索引对齐。你的行业分类列名必须完全一致否则结果里会冒出NaN后面求和就全错了。真实数据里行业名称经常有“信息技术”和“信息服务”这种相似但不一致的命名清洗时一定要先做分类映射。4.3 Carino多期平滑与聚合def carino_factor(r_p, r_b): if abs(r_p - r_b) 1e-8: return 1.0 / (1.0 r_p) return (np.log1p(r_p) - np.log1p(r_b)) / (r_p - r_b) def brinson_multi_period(p_weight, p_return, b_weight, b_return): single_results {} # 逐期计算原始Brinson贡献 for t in p_weight.index: allocation, selection, interaction brinson_single( p_weight.loc[t], p_return.loc[t], b_weight.loc[t], b_return.loc[t] ) single_results[t] pd.DataFrame({ 配置效应: allocation, 选股效应: selection, 交互效应: interaction }) # 逐期组合收益与基准收益 r_p_series (p_weight * p_return).sum(axis1) r_b_series (b_weight * b_return).sum(axis1) # 区间累计收益 r_p_total np.prod(1 r_p_series) - 1 r_b_total np.prod(1 r_b_series) - 1 k_total carino_factor(r_p_total, r_b_total) # Carino调整 adjusted_results {} for t in p_weight.index: k_t carino_factor(r_p_series[t], r_b_series[t]) factor k_t / k_total adjusted_results[t] single_results[t] * factor adj_df pd.concat(adjusted_results, names[期间]) total_df adj_df.groupby(level1).sum() return single_results, adj_df, total_df, r_p_series, r_b_series这段代码里有几个经验点值得说。第一用np.log1p而不是np.log(1 x)是因为当收益率是小数比如0.01时1 x会先做浮点加法再取对数精度会损失log1p是高精度实现专门处理这种接近于零的自变量。第二carino_factor里的相等判断很关键。当某期组合收益恰好等于基准收益计算机会直接除零返回无穷大。实际回测中遇到完全相等的情况很少但遇到极其接近的情况时浮点误差也会让分母变得很小所以判断阈值不能写得太大1e-8比较合适。第三groupby(level1)是对多级索引的第二层做分组求和。因为pd.concat生成的MultiIndex第一层是期间第二层是效应类型groupby第二层后正好得到三类效应各自的多期合计。4.4 运行结果与恒等式验证执行上面的函数把结果打印出来single_results, adj_df, total_df, r_p_series, r_b_series brinson_multi_period( p_weight, p_return, b_weight, b_return ) print(逐期原始Brinson贡献) for t, df in single_results.items(): print(f\n{t}:) print(df.round(6)) print(\nCarino调整后各期贡献四舍五入到四位) print(adj_df.round(4)) print(\n多期聚合贡献) print(total_df.round(4)) active_total r_p_series.prod() - r_b_series.prod() # 注意这个写法等价于(1r_p连乘-1)-(1r_b连乘-1) print(\n区间算术超额收益:, round(active_total, 6)) print(调整后归因贡献合计:, round(total_df.sum().sum(), 6)) print(验证通过:, np.isclose(active_total, total_df.sum().sum()))我用数据手工验算过结果是这样期间配置效应选股效应交互效应单期超额2023Q1 原始0.00600.00400.00000.01002023Q2 原始0.00650.01900.00300.02852023Q1 调整后0.00630.00420.0000约0.01042023Q2 调整后0.00670.01950.0031约0.0292多期合计0.01290.02360.00310.0396这里有个必须强调的点第二期的交互效应是正的0.0030说明这个组合在信息技术上不仅超配了而且行业内选到了比基准指数涨得更多的股票配置和选股形成了共振。这种共振正是市场好时主动基金超额收益被放大的来源——它让数字更大但也让业绩更难归因到单一能力上。最后那行验证输出会打印True因为调整后的贡献加总正好等于区间算术超额收益0.0396。到这里整个多期归因的闭环就打通了。5. 归因结果的解读边界别把数字当圣旨代码跑通之后真正考验功底的是结果解读。Brinson输出三个数但要读懂这三个数背后的业务含义还需要注意几个边界条件。5.1 从数字反推组合的真实行为回到模拟数据。第一期的超额主要来自配置0.0060选股0.0040比例相当交互为0第二期选股0.0190明显压过配置0.0065交互也有0.0030。这些数字放在一起可以初步判断这个组合在2023年Q1像做行业偏配的风格到Q2更像行业内选股发力整体风格偏积极。实际分析一只公募基金时我会把季度的边际变化单独列出来看。如果某个季度的配置效应突然放大通常意味着基金经理在期初做了明显的行业切换这时配合前十大重仓变动一起看能还原出当时的决策逻辑。如果你只拿一年总数据看这些中间的转折点就被抹平了。5.2 交互效应的正负含义交互效应很多人会忽略但它信息量很大。交互为正说明基金经理在超配的行业里恰好也选到了好股票权重偏离和选股偏离方向一致交互为负说明虽然超配了某个行业但行业内的选股拖了后腿或者低配的行业里反而选到了牛股。实操中不少机构会把交互效应并入选股效应理由是“基金经理主动选股时本来就会同时考虑配置和标的”。但我不建议在内部展示时直接合并因为交互为正的基金往往体现出更强的攻防能力合并后会掩盖这个特征。我一般在报告中把交互单列正文解释时才统一归入选股逻辑。5.3 静态Brinson回答不了的问题Brinson的静态暴露假设决定了它解释不了“调仓时点”的价值。假设一个基金经理在季中准确地把半导体仓位从10%加到30%吃到了主升浪静态Brinson可能只看到期末权重跟基准差不多从而漏掉大部分交易产生的超额。这不是模型错了而是它设计上就只衡量期初暴露的贡献。同样的道理Brinson也不直接回答“这个基金经理的交易能力强不强”——交易能力需要单独的交易归因模型去衡量。它只回答一个相对窄的问题你相对基准多赚的钱主要来自把仓位压在了哪些资产上还是来自在这些资产里选了哪些标的。6. 从单期到多期的工程化落地数据、口径与验证最后聊点工程化的东西。模型原理搞清楚了真正上真实数据时你会遇到一堆“文档里没有但一定会碰到”的问题我按出现频率排个序。6.1 权重口径和收益口径的对齐真实持仓数据里权重口径至少有三种期初市值占比、期末市值占比、区间平均市值占比。Brinson模型严格说应该用期初权重因为暴露衡量的是决策时点的状态。但很多数据商只提供季度末的持仓快照你用期末权重算归因本质上是在用“事后暴露”解释“事前收益”逻辑上是有瑕疵的。我的习惯是如果只有季度末快照就把收益区间切到快照日比如持仓日期是3月31日那这一期就归因1月1日到3月31日的收益。市场上有不少工具会把持仓快照往前推一天以避免future information bias但推不推取决于你是做绩效归因还是做业绩预测前者要求不那么严后者必须严格。收益口径也要对齐。组合的行业收益最好用持仓股票的区间收益率按持仓市值加权计算出组合在各行业内的收益率基准的行业收益直接用基准指数在各行业上的收益率。这两套收益的估值点必须一致否则归因结果会凭空多出“择时残差”。6.2 行业分类映射看似简单实则最耗时真实数据里行业分类的脏数据问题极其常见。同一家公司在不同时间可能从申万一级的“通信”被调到了“计算机”你的行业映射表没更新就会产生前一期归因到通信、后一期归因到计算机的“行业跳跃”这种跳跃会同时扭曲配置效应和选股效应。我建议维护一张单独的行业映射表以股票代码为键以最新分类为准但在每个归因区间的起始日做一次快照。如果公司区间内发生行业调整保持期初分类不变这样至少保证单个区间内口径一致。6.3 现金、新股、停牌等特殊样本的处理组合里通常有几千块零头现金行业权重加起来可能是99.98%。如果不处理Brinson会把这部分差额算进配置效应产生虚假的超额。标准做法是单独设一个“现金”类别基准在现金上的收益为0组合现金收益也为0权重差额仍然可以反映仓位选择。新股上市初期往往有连续涨停如果你直接用上市首日收益率参与加权这个行业的选股效应会被瞬间拉爆掩盖正常持仓的真实贡献。我的做法是把持有期不足5个交易日的打新收益单独拆成“打新及其他”类别不参与行业归因。6.4 验证步骤跑通数据后必须做一轮自查拿到归因结果后建议按下面这组顺序自查一次逐期检查权重是否归一化到1组合收益是否等于各行业权重乘以各行业收益之和。检查恒等式每期组合收益减基准收益是否等于该期三个效应之和。检查多期恒等式Carino调整后各期贡献加总是否等于区间算术总超额。用一两个行业做手工抽查确认结果不是纯粹的巧合。只要第四步的np.isclose验证通过说明代码逻辑和数据处理都对齐了可以放心使用结果。我在实际项目里最后还会加一步把各期的三效应画成堆积柱状图外加一条累计超额曲线。图形化的价值在于能一眼看出这个组合的超额是稳定分布在多数季度还是依赖某几个爆发季度——这两种形态对“要不要把这只基金放进核心池”的判断完全不同。你不需要用很复杂的工具Matplotlib里plotbar就够。Brinson多期归因实操一遍之后的直接收益是当有人再丢给你一只基金让你评价“这经理到底行不行”时你不用再盯着净值曲线猜了。拉数据、跑代码、读三项效应的结构心里基本就有数了。代码看着不复杂但每一步口径、验证、边界处理都是实际项目里会反复踩的坑建议你自己亲手把模拟数据跑通一遍再上真实持仓。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。