21. 交叉验证:K折交叉验证选择最优平滑参数

做波动率曲面平滑的时候,最头疼的问题是什么?

我个人觉得,不是模型不够复杂,而是过拟合。你想想看,把训练数据拟合得完美无缺,结果一到实盘就崩了。这种情况我在项目里遇到过好几次,尤其是早期做期权做市的时候,用了一个高阶样条,样本内误差小得惊人,结果实盘一跑,曲面形状完全扭曲。

所以,怎么选平滑参数?光靠肉眼判断或者拍脑袋肯定不行。我们需要一个系统性的方法——K折交叉验证

为什么需要交叉验证?

说白了,平滑参数λ(或者叫惩罚系数)控制着模型的复杂度。λ太小,模型太灵活,容易把噪声也学进去;λ太大,模型太僵硬,连真实的波动率结构都抹平了。

你可能会问:那用测试集不就行了?嗯,这里要注意:测试集只能用来评估最终模型,不能用来调参。否则你就是在「偷看」答案,模型会过拟合到测试集上。

交叉验证的妙处在于:它把数据分成K份,轮流用K-1份训练、1份验证,这样每一份数据都当过「考官」。最后取K次验证误差的平均值,作为模型泛化能力的估计。

核心思想:用「未见过的数据」来评估模型,避免过拟合。

K折交叉验证的流程

我习惯用5折或10折。数据量小的时候用5折,数据量大用10折。下面这张图帮你理清思路:

K折交叉验证选择平滑参数流程图 原始波动率数据 随机打乱,分成K等份(K=5) 循环K次,每次选不同的验证集 训练集(Fold 2-5) 验证集:Fold 1 训练集(Fold 1,3-5) 验证集:Fold 2 训练集(Fold 1-2,4-5) 验证集:Fold 3 训练集(Fold 1-3,5) 验证集:Fold 4 训练集(Fold 1-4) 验证集:Fold 5 选择平均误差最小的λ

具体怎么操作?

假设我们有一组波动率观测数据,用样条平滑。我们需要尝试不同的λ值,比如从0.001到1000,对数均匀采样20个点。

对于每个λ,做以下事情:

  1. 数据分割:把N个观测点随机分成K份。注意要按时间顺序打乱吗?不一定。如果数据有强时间序列特性,我建议用时间序列交叉验证(后面会讲)。但波动率曲面数据通常可以随机打乱。
  2. 训练与验证:对每一折,用K-1份数据拟合样条(给定λ),然后在剩下1份上计算误差。误差指标我常用均方根误差(RMSE)或者平均绝对百分比误差(MAPE)
  3. 汇总:计算K次验证误差的平均值和标准差。
我的小技巧:不要只看平均值,也要看标准差。如果某个λ的平均误差很低但标准差很大,说明它不稳定——换一批数据可能就翻车了。我一般选「平均值+1倍标准差」最小的那个λ,兼顾精度和稳定性。

代码实现

下面是一个完整的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)则欠拟合,误差也大。

曾经踩过的坑:有一次我忘了设置random_state,每次跑出来的最优λ都不一样。后来才发现是数据分割的随机性导致的。建议固定随机种子,或者多次重复交叉验证取平均。

进阶技巧:网格搜索+交叉验证

如果平滑参数不止一个(比如样条阶数+惩罚系数),可以用网格搜索。我习惯用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折交叉验证帮你科学地选平滑参数,但要注意数据特性。随机数据用K折,时间序列用滚动窗口。

好了,这一章就到这里。代码可以直接拿去用,但记得根据你的数据量调整K值和λ的搜索范围。如果数据点少于50个,我建议用留一法(LOO-CV)代替K折,效果更稳定。

公众号:蓝海数据掘金营,微信deep3321