21. 交叉验证:K折交叉验证选择最优平滑参数
做波动率曲面平滑的时候,最头疼的问题是什么?
我个人觉得,不是模型不够复杂,而是过拟合。你想想看,把训练数据拟合得完美无缺,结果一到实盘就崩了。这种情况我在项目里遇到过好几次,尤其是早期做期权做市的时候,用了一个高阶样条,样本内误差小得惊人,结果实盘一跑,曲面形状完全扭曲。
所以,怎么选平滑参数?光靠肉眼判断或者拍脑袋肯定不行。我们需要一个系统性的方法——K折交叉验证。
为什么需要交叉验证?
说白了,平滑参数λ(或者叫惩罚系数)控制着模型的复杂度。λ太小,模型太灵活,容易把噪声也学进去;λ太大,模型太僵硬,连真实的波动率结构都抹平了。
你可能会问:那用测试集不就行了?嗯,这里要注意:测试集只能用来评估最终模型,不能用来调参。否则你就是在「偷看」答案,模型会过拟合到测试集上。
交叉验证的妙处在于:它把数据分成K份,轮流用K-1份训练、1份验证,这样每一份数据都当过「考官」。最后取K次验证误差的平均值,作为模型泛化能力的估计。
K折交叉验证的流程
我习惯用5折或10折。数据量小的时候用5折,数据量大用10折。下面这张图帮你理清思路:
具体怎么操作?
假设我们有一组波动率观测数据,用样条平滑。我们需要尝试不同的λ值,比如从0.001到1000,对数均匀采样20个点。
对于每个λ,做以下事情:
- 数据分割:把N个观测点随机分成K份。注意要按时间顺序打乱吗?不一定。如果数据有强时间序列特性,我建议用时间序列交叉验证(后面会讲)。但波动率曲面数据通常可以随机打乱。
- 训练与验证:对每一折,用K-1份数据拟合样条(给定λ),然后在剩下1份上计算误差。误差指标我常用均方根误差(RMSE)或者平均绝对百分比误差(MAPE)。
- 汇总:计算K次验证误差的平均值和标准差。
代码实现
下面是一个完整的Python实现,用scikit-learn的KFold加上自定义的样条平滑器:
import numpy as np
from sklearn.model_selection import KFold
from scipy.interpolate import UnivariateSpline
def cross_validate_smoothing(x, y, lambdas, k=5):
"""
K折交叉验证选择最优平滑参数
参数:
x: 自变量(比如期限或执行价)
y: 因变量(波动率)
lambdas: 待测试的平滑参数列表
k: 折数,默认5
返回:
best_lambda: 最优平滑参数
results: 每个lambda的验证结果
"""
kf = KFold(n_splits=k, shuffle=True, random_state=42)
results = []
for lam in lambdas:
fold_errors = []
for train_idx, val_idx in kf.split(x):
# 分割数据
x_train, x_val = x[train_idx], x[val_idx]
y_train, y_val = y[train_idx], y[val_idx]
# 拟合样条(s就是平滑参数)
spline = UnivariateSpline(x_train, y_train, s=lam)
# 在验证集上预测
y_pred = spline(x_val)
# 计算RMSE
rmse = np.sqrt(np.mean((y_val - y_pred) ** 2))
fold_errors.append(rmse)
# 记录该lambda的平均误差和标准差
mean_error = np.mean(fold_errors)
std_error = np.std(fold_errors)
results.append((lam, mean_error, std_error))
# 选择最优lambda(平均误差+1倍标准差最小)
best_idx = np.argmin([r[1] + r[2] for r in results])
best_lambda = results[best_idx][0]
return best_lambda, results
# 使用示例
np.random.seed(42)
x = np.linspace(0.1, 2.0, 100)
y_true = 0.2 + 0.1 * np.sin(2 * np.pi * x) # 真实波动率结构
y_noise = y_true + 0.02 * np.random.randn(100) # 加入噪声
# 测试一系列平滑参数
lambdas = np.logspace(-3, 3, 20)
best_lam, results = cross_validate_smoothing(x, y_noise, lambdas, k=5)
print(f"最优平滑参数: {best_lam:.4f}")
print("\n各参数验证结果:")
print(f"{'Lambda':>10} {'平均RMSE':>10} {'标准差':>10}")
for lam, mean_err, std_err in results:
print(f"{lam:10.4f} {mean_err:10.4f} {std_err:10.4f}")
输出结果解读
运行上面的代码,你会看到类似这样的输出:
| Lambda | 平均RMSE | 标准差 |
|---|---|---|
| 0.0010 | 0.0198 | 0.0031 |
| 0.0046 | 0.0185 | 0.0028 |
| 0.0215 | 0.0172 | 0.0025 |
| 0.1000 | 0.0189 | 0.0035 |
| ... | ... | ... |
看到没?λ=0.0215的时候,平均RMSE最低,标准差也小。这就是我们想要的「甜点区」。λ太小(0.001)虽然训练误差低,但验证误差反而高了——典型的过拟合。λ太大(0.1)则欠拟合,误差也大。
进阶技巧:网格搜索+交叉验证
如果平滑参数不止一个(比如样条阶数+惩罚系数),可以用网格搜索。我习惯用GridSearchCV配合自定义评分函数:
from sklearn.model_selection import GridSearchCV
from sklearn.base import BaseEstimator, RegressorMixin
class SplineSmoother(BaseEstimator, RegressorMixin):
def __init__(self, s=1.0, k=3):
self.s = s
self.k = k
self.spline_ = None
def fit(self, x, y):
self.spline_ = UnivariateSpline(x, y, s=self.s, k=self.k)
return self
def predict(self, x):
return self.spline_(x)
# 参数网格
param_grid = {
's': np.logspace(-2, 2, 10),
'k': [2, 3, 4]
}
# 网格搜索
grid = GridSearchCV(
SplineSmoother(),
param_grid,
cv=5,
scoring='neg_mean_squared_error'
)
grid.fit(x.reshape(-1, 1), y_noise)
print(f"最优参数: {grid.best_params_}")
print(f"最优得分: {-grid.best_score_:.4f}")
这样一次就把所有参数组合都试完了,省心省力。
什么时候不能用K折?
嗯,这里要提醒一下。如果你的波动率数据有明显的时间序列特性(比如日度数据,今天和明天高度相关),随机打乱K折会泄漏未来信息。我曾经在回测里吃过这个亏——用未来数据训练模型,回测曲线漂亮得不行,实盘直接打脸。
解决办法:用时间序列交叉验证,也叫「滚动窗口验证」。简单说就是训练集永远在验证集之前,不偷看未来。
好了,这一章就到这里。代码可以直接拿去用,但记得根据你的数据量调整K值和λ的搜索范围。如果数据点少于50个,我建议用留一法(LOO-CV)代替K折,效果更稳定。