阅读提示

这篇讲解因子参数优化的高级工具——betalens.factor.mining 模块。它能帮你自动扫描多个参数组合,找出 ICIR 最高或分组收益差距最大的参数配置。适合在做正式回测之前做参数敏感性分析。

导言

任何因子都有可调参数:滚动窗口长度、去极值方法、分组数、调仓频率。这些参数没有”标准答案”,需要结合数据来搜索。

Betalens 的 mining 模块提供两种搜索策略:

  1. 参数扫描ParameterSweepConfig):给定参数范围网格,全量组合搜索。
  2. 滚动窗口RollingMiningConfig):给定窗口长度序列,模拟 Walk-Forward 检验。

参数搜索的产出不是”最优参数”,而是参数敏感性和稳健性报告——帮你判断:这个因子的收益是来自真实规律,还是来自对某组特定参数的过拟合?

参数扫描:run_parameter_sweep

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from betalens.factor.mining import (
ParameterSweepConfig,
run_parameter_sweep,
)

config = ParameterSweepConfig(
factor_key="股息率(报告期)",
param_grid={
"winsorize_method": ["mad", "percentile", "std"],
"n_mad": [3.0, 4.0, 5.0],
"standardize_method": ["zscore", "rank"],
"n_quantiles": [5, 10, 20],
},
engine="vector", # "vector" 速度快(推荐用于粗筛)
max_memory_ratio=0.5, # 最多用 50% 内存
cache_dir="_sweep/_cache",
)

results = run_parameter_sweep(
config,
days,
date_ranges,
code_ranges,
start_date="2020-01-01",
end_date="2024-12-31",
n_jobs=4, # 并行任务数
)

engine 参数:

engine 速度 精度 适用场景
vector 快(推荐) 近似 粗筛(扫描几十到上百组参数)
exact 精确 精选(对 top 参数做精细验证)

输出结果

1
2
3
4
5
6
7
8
# results 是 DataFrame,每行一组参数组合
print(results.columns)
# ['param_hash', 'winsorize_method', 'n_mad', 'standardize_method',
# 'n_quantiles', 'IC_mean', 'ICIR', 'long_short_return', 'turnover']

# 找 ICIR 最高的参数组合
best = results.loc[results["ICIR"].idxmax()]
print(f"最优参数:{best.to_dict()}")

结果可视化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import matplotlib.pyplot as plt

# 绘制参数敏感性的热力图(固定其他参数,只变化一个)
pivot = results.pivot_table(
values="ICIR",
index="n_mad",
columns="standardize_method",
)
plt.figure(figsize=(8, 6))
plt.imshow(pivot.values, cmap="RdYlGn", aspect="auto")
plt.xticks(range(len(pivot.columns)), pivot.columns)
plt.yticks(range(len(pivot.index)), pivot.index)
plt.colorbar(label="ICIR")
plt.xlabel("standardize_method")
plt.ylabel("n_mad")
plt.title("ICIR 参数敏感性热力图")
plt.tight_layout()
plt.savefig("sensitivity_heatmap.png")

滚动窗口:run_walk_forward

参数扫描的隐含假设是”最优参数在历史上一致有效”。但金融市场结构会变化——2015 年股灾前后的最优参数可能完全不同。滚动窗口(Walk-Forward)通过分段检验来验证参数的稳健性。

基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from betalens.factor.mining import (
RollingMiningConfig,
run_walk_forward,
)

rolling_config = RollingMiningConfig(
window_lengths=[252, 504], # 训练窗口:1年、2年
steps=[21, 63], # 再平衡步长:1个月、3个月
out_of_sample_ratio=0.3, # 留 30% 做样本外检验
param_grid={
"winsorize_method": ["mad"],
"n_mad": [3.0, 4.0],
"n_quantiles": [5, 10],
},
engine="exact", # 滚动窗口用精确引擎(计算量更小)
)

rolling_results = run_walk_forward(
rolling_config,
days,
date_ranges,
code_ranges,
start_date="2015-01-01",
end_date="2024-12-31",
)

窗口长度的生成逻辑window_lengths × steps 笛卡尔积生成窗口序列,并过滤 step <= window_length(确保步长不超过窗口长度)。

输出结果解读

1
2
3
4
5
6
7
8
9
10
# 滚动窗口结果 DataFrame
print(rolling_results.columns)
# ['window', 'train_start', 'train_end', 'test_start', 'test_end',
# 'best_params', 'train_ICIR', 'test_ICIR', 'train_return', 'test_return']

# 样本内 vs 样本外对比
for _, row in rolling_results.iterrows():
print(f"窗口 {row.train_start.date()}~{row.train_end.date()} "
f"→ 测试 {row.test_start.date()}~{row.test_end.date()}: "
f"训练 ICIR={row.train_ICIR:.3f}, 测试 ICIR={row.test_ICIR:.3f}")

稳健性判断标准

  • 测试期 ICIR > 训练期 ICIR × 0.7:参数稳健性良好。
  • 测试期 ICIR > 0.2:即使样本外有所衰减,仍有实际预测价值。
  • 测试期 ICIR ≈ 0:参数严重过拟合,训练期 ICIR 不可信。

因子侧的 Hook 机制

如果你要在参数扫描的每个任务里执行自定义逻辑(比如在跑完 IC 之后自动生成报告),可以用 hook 机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from betalens.factor.mining import make_mining_spec, mining_valid_report

def make_mining_spec(params):
"""把参数字典转成 mining spec。"""
return {
"winsorize_method": params["winsorize_method"],
"n_mad": params["n_mad"],
"n_quantiles": params["n_quantiles"],
}

def mining_valid_report(params, rank, output_dir, start_date, end_date):
"""每次有效运行后自动生成报告。"""
# rank: 本轮参数组合的 ICIR 排名
if rank <= 5: # 只对 top 5 参数组合生成详细报告
from betalens.analyst import Analyst
# ... 生成 report.xlsx ...
pass

config = ParameterSweepConfig(
...
make_mining_spec_hook=make_mining_spec,
mining_valid_report_hook=mining_valid_report,
)

所有 hook 函数都是可选的,不传就使用默认行为。

开发者侧:内存保护与缓存

参数扫描可能产生大量中间数据(每个参数组合都要跑一次完整流水线)。Betalens 提供了两个保护机制:

1. 内存保护

1
2
3
4
config = ParameterSweepConfig(
...
max_memory_ratio=0.5, # 超过 50% 内存使用率时自动暂停/分批
)

2. 磁盘缓存

1
2
3
4
config = ParameterSweepConfig(
...
cache_dir="_sweep/_cache/", # 中间结果缓存目录
)

第二次运行同样的参数组合时,直接从缓存读取,不重复计算。

常见错误

1. 参数网格太大导致计算爆炸

1
2
3
4
5
6
7
8
9
10
11
# 错:3 × 3 × 3 × 3 × 3 = 243 组,每组跑多年数据 → 可能需要几小时
param_grid={
"winsorize_method": ["mad", "percentile", "std"],
"n_mad": [2.0, 3.0, 4.0],
"standardize_method": ["zscore", "rank", "minmax"],
"n_quantiles": [5, 10, 20],
"rebal_freq": ["W", "M", "Q"],
}

# 对:先用 vector engine 粗筛,top 10 参数用 exact engine 精筛
config = ParameterSweepConfig(param_grid={...}, engine="vector")

2. 滚动窗口步长大于窗口长度

1
2
3
4
5
6
7
8
9
10
11
# 错:step=504 > window_length=252,会被过滤掉
rolling_config = RollingMiningConfig(
window_lengths=[252],
steps=[504], # 再平衡步长超过窗口
)

# 对:step <= window_length
rolling_config = RollingMiningConfig(
window_lengths=[252],
steps=[63], # 约3个月
)

3. 用训练期最优参数做样本外测试

滚动窗口结果里,test_ICIR 是用测试期数据计算的,和训练期参数无关。如果你用 rolling_results["best_params"] 手动重新跑测试期,就会产生”用训练数据选参数再验证训练数据”的逻辑错误。

延伸阅读