计算出ICIR权重矩阵后,通常需要将权重矩阵标准化,加权合成综合因子,用于构建投资组合。
这里通过示例函数,尝试分析和说明如何用时间变化的权重矩阵(例如滚动 ICIR),
将多个标准化后的因子值线性组合成一个综合因子得分。
1 权重矩阵标准化
这里给出combine_factors_rolling_weight,函数利用时间变化的权重矩阵(例如滚动 ICIR),
将多个标准化后的因子值线性组合成一个综合因子得分。
其输出可作为选股信号(如多因子打分),用于后续的回测或实盘交易。
1.1 代码示例
combine_factors_rolling_weight将多个因子normalized_factors,
按照时间维度动态变化的权重weight_matrix合成为一个复合因子Composite Factor。
代码示例如下
def combine_factors_rolling_weight(
normalized_factors: Dict[str, pd.DataFrame],
weight_matrix: pd.DataFrame,
) -> pd.DataFrame:
"""
Time-varying weighted composite (datetime × stock).
At each date T the factor weights are row T of `weight_matrix` (e.g. the
rolling ICIRs from compute_rolling_icir_weights), sign-preserved and
normalized by the row's sum of absolute weights --- the same convention as
combine_factors_icir_weight, but re-estimated every day from past-only
information.
Dates where every weight is NaN (rolling warm-up) produce a NaN composite
row; callers should trim the warm-up period from the backtest window.
Per-stock NaN factor values are skipped in the weighted sum (min_count=1),
matching the static combiners.
"""
names = [n for n in normalized_factors if n in weight_matrix.columns]
if not names:
raise ValueError("weight_matrix columns do not match normalized_factors keys")
w = weight_matrix[names].astype(float)
row_abs = w.abs().sum(axis=1)
# Rows with ~zero total |weight| become NaN (no signal) rather than
# silently falling back to equal weight mid-sample.
wn = w.div(row_abs.where(row_abs > 1e-12), axis=0)
weighted_dfs = [normalized_factors[n].mul(wn[n], axis=0) for n in names]
stacked = pd.concat(weighted_dfs, axis=1)
result = stacked.T.groupby(level=0).sum(min_count=1).T
return result
1.2 代码分析
1)应用场景
该计算常用于量化多因子选股。
例如,每个交易日使用过去一段时间的ICIR(IC 均值/IC 标准差)作为权重,
且权重随交易日滚动更新(避免未来信息)。
2) 输入参数
normalized_factors:
Dictstr, pd.DataFrame,键为因子名称,值为DataFrame,索引为日期,列为股票代码,
值为已标准化(通常为截面 Z-score 或排序分位数)的因子暴露。
各因子需对齐到相同日期和股票范围。
weight_matrix:
pd.DataFrame,索引为日期,列为因子名称,值为该日期的因子权重,
例如来自滚动ICIR权重的输出,其列名应与normalized_factors的键匹配。
3)输出形式
pd.DataFrame,是日期×股票的综合得分,索引为日期datetime对应权重矩阵的行索引,
列为股票代码,值为加权求和后的综合得分。
2 核心计算分析
2.1 核心算法
这里通过步骤分解示例
1)因子名称对齐
只取同时存在于weight_matrix列和normalized_factors键中的因子,避免键不匹配。
若没有共同因子则报错。
names = [n for n in normalized_factors if n in weight_matrix.columns]
if not names:
raise ValueError("weight_matrix columns do not match normalized_factors keys")
2)权重归一化(绝对值)
w = weight_matrix[names].astype(float)
row_abs = w.abs().sum(axis=1)
wn = w.div(row_abs.where(row_abs > 1e-12), axis=0)
w = weight_matrixnames截取对应列。
计算每行(每个日期)的权重绝对值之和 `row_abs = w.abs().sum(axis=1)`。
将每一行的权重除以该行的绝对值总和,得到归一化后的权重wn。
对于row_abs接近 0 的行(≤ 1e-12),
用 where替换为 NaN,使得 wn` 整行为 NaN,表示该日无有效权重。
为什么用绝对值归一化?
1 这保留了权重的正负号,且使得正负权重的相对比例保持不变。
例如,权重为2, -1,绝对值之和为 3,归一化为2/3, -1/3,总和为 1/3(非零),但符号比例保留。这允许因子具有正负贡献,且绝对值总和反映总杠杆强度。
2 若权重全部为正,则归一化后和为 1;若混合正负,和可能接近 0,此时不应退化为等权,故采用绝对值归一化。
3 直接求和对冲(正负抵消)会丢失总信号强度信息,而 L1 归一化保留了权重的方向和相对大小,确保组合得分不会因为正负抵消而异常缩小。
3)加权组合
weighted_dfs = [normalized_factors[n].mul(wn[n], axis=0) for n in names]
stacked = pd.concat(weighted_dfs, axis=1)
result = stacked.T.groupby(level=0).sum(min_count=1).T
return result
对每个因子,将normalized_factorsn(日期×股票)逐元素乘以对应的权重列wnn(日期向量),得到该因子的加权贡献weighted_dfs。
将所有加权后的DataFrame横向拼接(pd.concat(axis=1)),形成一个多列的大DataFrame,其中列是因子-日期组合,实际上,由于每个weighted_dfs的列名是股票代码,多个因子会存在重复列名(股票代码),所以concat后会有重复的列名。
然后使用stacked.T.groupby(level=0).sum(min_count=1).T
stacked.T.groupby(level=0).sum(min_count=1).T
计算详情过程如下,其实就是股票进行聚合。
stacked.T将原 DataFrame 转置,使得行变为股票,列变为因子
weighted_dfs中的每个 DataFrame 的列都是相同的股票代码,concat(axis=1)会生成一个具有重复列名的 DataFrame(列名是股票代码,但每个因子一个副本)。此时 stacked.T 转置后,行是股票代码(重复),列是日期
weighted_dfs列表中的每个 DataFrame 形状为 (n_dates, n_stocks)。
pd.concat(weighted_dfs, axis=1)将按列拼接,如果所有 DataFrame 的列索引(股票)相同,那么拼接后列索引将是重复的(例如股票A出现多次)。此时stacked的列索引不是 MultiIndex,而是普通的 Index 包含重复值。
然后stacked.T转置,行索引变为原来的列(股票,包含重复),列索引变为原来的行索引(日期)。
接着.groupby(level=0).sum(min_count=1):groupby(level=0)意味着按行索引的第一级分组(行索引是转置前的列名,即股票代码)。由于重复的股票名会被分为一组,sum会对每个股票下所有因子(对应不同列)的加权值求和,min_count=1表示若该股票在该日期至少有一个非 NaN 的加权值,则求和,否则结果为 NaN。这实际上是对每个日期和每个股票,将所有因子的加权贡献相加。
最后 .T转置回来,恢复为 (日期 × 股票) 的格式。
4)注意事项
需要注意的是,
groupby(level=0).sum(min_count=1)中min_count=1表示在分组求和时,
若该组内所有值均为 NaN,则结果为 NaN;否则忽略 NaN 并求和(只对非 NaN 加总)。
这也是合理的,对于某股票某日期,只要至少有一个因子有值,就计算加权和;
若全部缺失,则结果为 NaN。
2.2 设计细节
1)权重来源&归一化
没有用全样本区间固定计算ICIR(可能引入未来信息) ,而是仅使用过去信息滚动计算(无未来偏差)
采用绝对值归一化,确保正负因子方向的保留,且使得权重总和可正可负。这与常见的"等权"或"市值加权"不同,更强调因子贡献的绝对值比例。
2)处理缺失值
在求和时使用 `min_count=1`,避免因少数因子缺失导致结果全为 NaN,但同时要求至少有一个有效因子。
3)时间对齐
weight_matrix和normalized_factors的日期索引必须匹配(或至少normalized_factors包含 weight_matrix的日期)。若某日期权重全 NaN,则结果全为 NaN(因为wn整行 NaN,加权后所有因子贡献全 NaN,最终求和也 NaN)。
4)计算场景
不是一次计算全局不变,而是每个交易日重新计算权重矩阵的行。
回测中避免前视,更符合真实交易场景,回测结果可信度更高。
5)潜在优化
对于大数据集,concat+groupby可能效率较低,可改用循环累加或使用pd.concat(..., keys=...)` 后按第一级分组求和。
2.3 边缘情况
1)所有权重为 0 或 NaN(热身期)
row_abs为 0 或NaN,wn全部变为NaN。加权因子全为 NaN,最终结果行全为NaN。调用方需自行裁剪(文档中已提示)。 |
2)某只股票在某个因子中缺失(NaN)|
该因子对该股票的贡献被忽略,但仍使用其他因子求和(因为 `min_count=1)。
3)某只股票在所有因子中均缺失
该股票当日最终结果为NaN。
4)权重矩阵列名与因子字典键不完全匹配
仅匹配的部分参与合成,其余忽略,不报错,除非一个都没匹配到,则抛出 ValueError。 |
5)日期索引未对齐
mul(axis=0)会按索引对齐,若日期不一致,会出现大量NaN或数据错位。这是致命隐患,调用者必须提前确保日期完全一致。
reference
滚动ICIR权重分析的探索和代码示例