前阵子有个做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(1+R_p,t) - ln(1+R_b,t)] / (R_p,t - R_b,t)
整体系数公式:
K = [ln(1+R_p) - ln(1+R_b)] / (R_p - R_b)
当组合收益等于基准收益时,用极限值1/(1+R)代替,避免除零。
得到系数后,第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]], index=periods, columns=sectors ) # 组合各行业区间收益率(小数形式) p_return = pd.DataFrame( [[0.05, 0.02, -0.01], [0.08, 0.03, 0.01]], index=periods, columns=sectors ) # 基准各行业期初权重 b_weight = pd.DataFrame( [[0.40, 0.30, 0.30], [0.45, 0.25, 0.30]], index=periods, columns=sectors ) # 基准各行业区间收益率 b_return = pd.DataFrame( [[0.04, 0.03, -0.02], [0.05, 0.02, 0.00]], index=periods, columns=sectors )我用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(axis=1) r_b_series = (b_weight * b_return).sum(axis=1) # 区间累计收益 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(level=1).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(level=1)是对多级索引的第二层做分组求和。因为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() # 注意这个写法等价于(1+r_p连乘-1)-(1+r_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.0060 | 0.0040 | 0.0000 | 0.0100 |
| 2023Q2 原始 | 0.0065 | 0.0190 | 0.0030 | 0.0285 |
| 2023Q1 调整后 | 0.0063 | 0.0042 | 0.0000 | 约0.0104 |
| 2023Q2 调整后 | 0.0067 | 0.0195 | 0.0031 | 约0.0292 |
| 多期合计 | 0.0129 | 0.0236 | 0.0031 | 0.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里plot+bar就够。
Brinson多期归因实操一遍之后的直接收益是:当有人再丢给你一只基金让你评价“这经理到底行不行”时,你不用再盯着净值曲线猜了。拉数据、跑代码、读三项效应的结构,心里基本就有数了。代码看着不复杂,但每一步口径、验证、边界处理都是实际项目里会反复踩的坑,建议你自己亲手把模拟数据跑通一遍,再上真实持仓。