Skip to content

2.5 线性模型与正则化

概念详解

线性模型在量化中的核心地位

在深度学习大行其道的时代,一个反直觉的事实是:在量化因子合成中,线性模型仍然是最常用、最可靠的工具。原因很简单——金融数据的信噪比极低,复杂模型的额外灵活性更可能捕获噪声而非信号,而线性模型的简洁性天然具有正则化效果。

线性回归的核心方程:

Y = X\beta + \varepsilon, \quad \varepsilon \sim N(0, \sigma^2 I)

在量化中,Y 通常是资产的未来收益率(或超额收益),X 是各种因子暴露、特征或信号,β 是它们对收益的边际贡献。

名词解释:Lasso回归

在损失函数中加入L1惩罚项,能将不重要特征的系数压缩为0,实现特征选择。

名词解释:Ridge回归

在损失函数中加入L2惩罚项,使所有系数向0收缩但不为0,解决多重共线性问题,提高数值稳定性。

名词解释:弹性网络 (Elastic Net)

同时结合L1和L2惩罚项:λ1β1+λ2β22。兼顾Lasso的特征选择能力和Ridge的稳定性,是实践中常用的折衷方案。

OLS的局限与正则化的必要

普通最小二乘法(OLS)虽然是最基础的线性方法,但在量化因子建模中面临三大挑战:

  1. 多重共线性:因子之间高度相关(如PE和PB,动量1月和动量3月),使得 β 估计不稳定,系数的符号甚至可能反转
  2. 高维问题:当因子数量接近或超过样本量时(pNp>N),OLS不可识别
  3. 过拟合:最小化样本内误差会导致模型捕捉噪声,样本外预测力下降

正则化(Regularization)通过向损失函数中添加惩罚项来解决这些问题:

\hat{\beta} = \arg\min_{\beta} \left\{ \frac{1}{2N}\|Y - X\beta\|_2^2 + \lambda P(\beta) \right\}

其中 λ 是正则化强度,P(β) 是惩罚函数。

数学原理

三种正则化对比

方法惩罚项特征选择系数特征适用场景
OLS无偏p << N, 无多重共线性
Ridgeλ|β|22收缩但不为0多重共线性
Lassoλ|β|1部分精确为0稀疏性假设成立
Elastic Netαλ|β|1+(1α)λ|β|22兼顾两者一般情况首选

Lasso的变量选择机制

Lasso能将系数压缩到精确的0,这来自于L1惩罚的几何性质:在参数空间中,L1约束区域(菱形)的尖角落在坐标轴上,使得解更容易落在坐标轴上(系数为0)。

最优 λ 的选择通常通过交叉验证。λ 越大,保留的变量越少。典型的选择策略:

  • lambda_min:交叉验证误差最小的 λ
  • lambda_1se:误差在最小值1个标准误以内的最大 λ(更稀疏、更稳健)

在量化中,通常推荐 lambda_1se——牺牲一点样本内拟合度,换取更稀疏的模型和更好的样本外泛化能力。

回归诊断的关键指标

每一次回归后都应检查以下诊断指标:

  • VIF (Variance Inflation Factor)VIFj=1/(1Rj2),其中 Rj2 是将特征j对其他特征回归的决定系数。VIF > 10 提示严重的多重共线性
  • 条件数 (Condition Number)XTX 最大特征值与最小特征值之比,>30提示共线性问题
  • 残差自相关 (Durbin-Watson):检验残差是否独立。在时间序列回归中,残差自相关会导致系数标准误的低估
  • 异方差性 (Breusch-Pagan检验):如果残差的方差不恒定(金融中很常见),需要使用HAC (Heteroskedasticity and Autocorrelation Consistent) 标准误

Python实战

案例1:Lasso选因子

PYTHON14 行 · 738 B
📄此处有展示代码14 行 · 738 B展开 ▼
python
from sklearn.linear_model import LassoCV
# 一键运行:demo 数据已自动注入 factors (4 因子) 与 df['returns'] (目标变量)
_target = df['returns'].shift(-1).dropna()       # 未来一期收益(最后一行 NaN, 丢掉)
_factor_set = factors.loc[_target.index]            # 对齐因子
X = _factor_set.values
y = _target.values

model = LassoCV(cv=5).fit(X, y)
selected = np.where(model.coef_ != 0)[0]
print(f"✅ 总候选因子数: {len(_factor_set.columns)}")
print(f"✅ Lasso 选中因子个数: {len(selected)}")
print(f"✅ 最优正则化参数 α: {model.alpha_:.6f}")
selected_names = [_factor_set.columns[i] for i in selected]
print(f"✅ 选中因子: {selected_names if selected_names else '(全部被剔除)'}")
点击展开可浏览运行结果
✅ 总候选因子数: 5
✅ Lasso 选中因子个数: 2
✅ 最优正则化参数 α: 0.000011
✅ 选中因子: ['momentum', 'size']

案例2:完整的正则化因子选择流程

PYTHON144 行 · 6.1 KB
📄此处有展示代码144 行 · 6.1 KB展开 ▼
python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LassoCV, RidgeCV, ElasticNetCV
from sklearn.preprocessing import StandardScaler
from sklearn.model_selection import TimeSeriesSplit
import statsmodels.api as sm
from statsmodels.stats.outliers_influence import variance_inflation_factor

def regularized_factor_selection(factors, returns, method='lasso', cv_splits=5):
    """
    完整的正则化因子选择流程
    
    factors: DataFrame, 每列是一个候选因子
    returns: Series, 未来一期收益率(目标变量)
    method: 'lasso', 'ridge', 'elastic_net'
    """
    # 数据清洗和对齐
    data = pd.concat([returns.rename('target'), factors], axis=1).dropna()
    y = data['target'].values
    X = data[factors.columns].values
    feature_names = factors.columns.tolist()
    
    # 标准化(正则化前必须标准化特征)
    scaler = StandardScaler()
    X_scaled = scaler.fit_transform(X)
    
    # 时序交叉验证(避免未来信息泄露)
    tscv = TimeSeriesSplit(n_splits=cv_splits)
    
    # 选择正则化方法
    if method == 'lasso':
        model = LassoCV(cv=tscv, max_iter=5000, random_state=42,
                        alphas=np.logspace(-4, 1, 50))
    elif method == 'ridge':
        model = RidgeCV(cv=tscv, alphas=np.logspace(-3, 3, 50))
    elif method == 'elastic_net':
        model = ElasticNetCV(cv=tscv, max_iter=5000, random_state=42,
                            l1_ratio=[0.1, 0.3, 0.5, 0.7, 0.9],
                            alphas=np.logspace(-4, 1, 20))
    
    model.fit(X_scaled, y)
    
    # 提取系数
    coef_df = pd.DataFrame({
        'factor': feature_names,
        'coefficient': model.coef_,
        'abs_coef': np.abs(model.coef_)
    }).sort_values('abs_coef', ascending=False)
    
    # 被选中的因子(Lasso/ElasticNet下系数不为0)
    if hasattr(model, 'l1_ratio_'):  # ElasticNet
        selected = coef_df[coef_df['coefficient'] != 0]
    elif method == 'lasso':
        selected = coef_df[coef_df['coefficient'] != 0]
    else:  # Ridge: 所有系数都非零,选绝对值最大的
        selected = coef_df[coef_df['abs_coef'] > coef_df['abs_coef'].quantile(0.5)]
    
    # 可视化
    fig, axes = plt.subplots(1, 3, figsize=(18, 5))
    
    # 系数路径(仅Lasso/ElasticNet;sklearn ≥1.4 不再保证 coef_path_,用 hasattr 兜底)
    if hasattr(model, 'mse_path_') and hasattr(model, 'coef_path_'):
        alphas = model.alphas_
        coef_path = model.coef_path_ if method == 'lasso' else None
        if coef_path is not None:
            for i in range(min(15, coef_path.shape[0])):
                axes[0].plot(alphas, coef_path[i], alpha=0.5, linewidth=0.8)
            axes[0].axvline(x=model.alpha_, color='red', linestyle='--',
                           label=f'最优α={model.alpha_:.4f}')
            axes[0].set_xscale('log')
            axes[0].set_xlabel('Alpha (正则化强度)')
            axes[0].set_ylabel('系数值')
            axes[0].set_title('Lasso系数路径')
            axes[0].legend()
    
    # 因子系数条形图
    top_n = min(20, len(coef_df))
    top_factors = coef_df.head(top_n)
    colors = ['steelblue' if c > 0 else 'coral' for c in top_factors['coefficient']]
    axes[1].barh(range(top_n), top_factors['coefficient'].values, color=colors)
    axes[1].set_yticks(range(top_n))
    axes[1].set_yticklabels(top_factors['factor'].values, fontsize=9)
    axes[1].axvline(x=0, color='black', linewidth=0.5)
    axes[1].set_xlabel('系数')
    axes[1].set_title(f'因子系数排名 (Top {top_n}, {method})')
    axes[1].invert_yaxis()
    
    # OLS诊断(用选中的因子做OLS,分析统计显著性)
    if len(selected) > 0 and len(selected) <= 15:
        X_selected = sm.add_constant(X_scaled[:, [feature_names.index(f) for f in selected['factor']]])
        ols_model = sm.OLS(y, X_selected).fit()
        
        coef_with_p = pd.DataFrame({
            '因子': ['Intercept'] + selected['factor'].tolist(),
            '系数': ols_model.params,
            't值': ols_model.tvalues,
            'p值': ols_model.pvalues
        })
        
        # 显著性可视化
        significant = coef_with_p['p值'] < 0.05
        axes[2].barh(range(len(coef_with_p)-1, -1, -1), coef_with_p['t值'].values,
                    color=['gray' if s else 'lightgray' for s in significant[::-1]])
        axes[2].axvline(x=0, color='black', linewidth=0.5)
        axes[2].axvline(x=1.96, color='red', linestyle='--', alpha=0.5, label='±1.96 (p=0.05)')
        axes[2].axvline(x=-1.96, color='red', linestyle='--', alpha=0.5)
        axes[2].set_yticks(range(len(coef_with_p)-1, -1, -1))
        axes[2].set_yticklabels(coef_with_p['因子'].values, fontsize=9)
        axes[2].set_xlabel('t统计量')
        axes[2].set_title('因子显著性 (OLS with selected factors)')
        axes[2].legend()
    else:
        axes[2].text(0.5, 0.5, '因子数量不适用OLS诊断', 
                    ha='center', va='center', transform=axes[2].transAxes)
    
    plt.tight_layout()
    plt.show()
    
    # 输出结果
    print("=" * 60)
    print(f"正则化因子选择结果 ({method.upper()})")
    print("=" * 60)
    print(f"总候选因子数: {len(feature_names)}")
    print(f"选中因子数: {len(selected)}")
    if hasattr(model, 'alpha_'):
        print(f"最优正则化参数: {model.alpha_:.6f}")
    print(f"\n选中的因子及其系数:")
    for _, row in selected.iterrows():
        print(f"  {row['factor']:<25} {row['coefficient']:+.6f}")
    
    return {
        'model': model,
        'selected_factors': selected,
        'all_coefficients': coef_df,
        'scaler': scaler
    }

# 一键运行(用 demo 数据 factors(4列) + df['returns'] 作为预测目标)
_target = df['returns'].shift(-1).dropna()
_factor_set = factors.loc[_target.index]
results = regularized_factor_selection(_factor_set, _target, method='lasso', cv_splits=5)
print(f"\n✅ LASSO 选中 {len(results['selected_factors'])} 个因子 / 共 {len(_factor_set.columns)} 个候选")
print(f"✅ 有效因子列表: {results['selected_factors']['factor'].tolist()}")
点击展开可浏览运行结果
运行结果图 1
📘 本段代码定义了 1 个函数/类:函数 `regularized_factor_selection`(完整的正则化因子选择流程)。该片段为教学展示(未包含独立运行的输入数据),可在实战练习中结合真实数据调用。

案例3:VIF多重共线性诊断

PYTHON122 行 · 4.8 KB
📄此处有展示代码122 行 · 4.8 KB展开 ▼
python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from statsmodels.stats.outliers_influence import variance_inflation_factor
# 注:本案例纯 matplotlib + statsmodels, 不依赖 seaborn(浏览器沙箱不支持)。
import statsmodels.api as sm

def multicollinearity_diagnosis(factors_df, vif_threshold=10):
    """
    因子共线性诊断:计算VIF并可视化
    
    factors_df: 每列是一个因子
    """
    # 处理缺失值
    data = factors_df.dropna()
    feature_names = data.columns.tolist()
    
    # 添加常数项用于VIF计算
    X = sm.add_constant(data)
    
    # 计算VIF
    vif_data = []
    for i in range(X.shape[1]):
        try:
            vif = variance_inflation_factor(X.values, i)
            vif_data.append({'feature': X.columns[i], 'VIF': vif})
        except:
            vif_data.append({'feature': X.columns[i], 'VIF': np.inf})
    
    vif_df = pd.DataFrame(vif_data)
    
    # 排除常数项的VIF(通常很大,不相关)
    vif_features = vif_df[vif_df['feature'] != 'const'].sort_values('VIF', ascending=False)
    
    # 标记高VIF因子
    high_vif = vif_features[vif_features['VIF'] > vif_threshold]
    ok_vif = vif_features[vif_features['VIF'] <= vif_threshold]
    
    # 可视化
    fig, axes = plt.subplots(1, 2, figsize=(16, 6))
    
    # VIF条形图
    all_vif = pd.concat([high_vif, ok_vif])
    colors = ['coral' if v > vif_threshold else 'steelblue' for v in all_vif['VIF']]
    axes[0].barh(range(len(all_vif)), all_vif['VIF'].values, color=colors)
    axes[0].set_yticks(range(len(all_vif)))
    axes[0].set_yticklabels(all_vif['feature'].values, fontsize=9)
    axes[0].axvline(x=vif_threshold, color='red', linestyle='--', linewidth=2,
                   label=f'阈值={vif_threshold}')
    axes[0].set_xlabel('VIF值')
    axes[0].set_title(f'因子VIF诊断 (高VIF={len(high_vif)}个)')
    axes[0].legend()
    axes[0].invert_yaxis()
    
    # 相关性热力图(纯 matplotlib, 无需 seaborn)
    # 先计算高VIF因子之间的相关性
    if len(high_vif) > 1:
        high_vif_names = high_vif['feature'].tolist()
        corr_high_vif = data[high_vif_names].corr()
        ax_h = axes[1]
        arr = corr_high_vif.values
        im = ax_h.imshow(arr, cmap='RdBu_r', vmin=-1, vmax=1, aspect='equal')
        ax_h.set_xticks(range(len(high_vif_names)))
        ax_h.set_xticklabels(high_vif_names, rotation=45, ha='right', fontsize=9)
        ax_h.set_yticks(range(len(high_vif_names)))
        ax_h.set_yticklabels(high_vif_names, fontsize=9)
        for i in range(arr.shape[0]):
            for j in range(arr.shape[1]):
                v = arr[i, j]
                color = 'white' if abs(v) > 0.6 else 'black'
                ax_h.text(j, i, f'{v:.2f}', ha='center', va='center',
                          color=color, fontsize=9)
        cbar = plt.colorbar(im, ax=ax_h, fraction=0.046, pad=0.04)
        cbar.set_label('相关系数')
        ax_h.set_title('高VIF因子间的相关性')
    else:
        axes[1].text(0.5, 0.5, '没有足够的高VIF因子\n用于展示相关性', 
                    ha='center', va='center', transform=axes[1].transAxes)
    
    plt.tight_layout()
    plt.show()
    
    # 打印诊断结果
    print("=" * 50)
    print("多重共线性诊断报告")
    print("=" * 50)
    print(f"因子总数: {len(feature_names)}")
    print(f"高VIF因子(VIF>{vif_threshold}): {len(high_vif)}个")
    
    if len(high_vif) > 0:
        print(f"\n高VIF因子列表:")
        for _, row in high_vif.iterrows():
            print(f"  {row['feature']:<25} VIF={row['VIF']:.1f}")
        
        print(f"\n建议:考虑删除以下因子或使用Ridge/Lasso回归")
        # 按VIF从高到低,迭代删除高VIF中最高的一个
        remaining = feature_names.copy()
        simplified = []
        while True:
            X_check = sm.add_constant(data[remaining])
            vifs = [variance_inflation_factor(X_check.values, i) 
                   for i in range(1, X_check.shape[1])]
            max_vif_idx = np.argmax(vifs)
            if vifs[max_vif_idx] > vif_threshold:
                removed = remaining.pop(max_vif_idx)
                simplified.append(removed)
            else:
                break
        print(f" 简化后因子集合 ({len(remaining)}个): {remaining}")
    
    return {
        'vif_df': vif_features,
        'high_vif_factors': high_vif,
        'correlation_matrix': data.corr()
    }


# 一键运行(用 demo 数据 factors)
vif_results = multicollinearity_diagnosis(factors, vif_threshold=10)
print(f"\n✅ 总因子数: {len(vif_results['vif_df'])}")
print(f"⚠️  高VIF因子数: {len(vif_results['high_vif_factors'])}")
print(f"✅ 高VIF因子: {vif_results['high_vif_factors']['feature'].tolist() if len(vif_results['high_vif_factors']) else '(无)'}")
点击展开可浏览运行结果
==================================================
多重共线性诊断报告
==================================================
因子总数: 5
高VIF因子(VIF>10): 0个

✅ 总因子数: 5
⚠️  高VIF因子数: 0
✅ 高VIF因子: (无)
运行结果图 1运行结果图 2

因子暴露正交化(在实际量化中非常常用)

PYTHON39 行 · 1.5 KB
📄此处有展示代码39 行 · 1.5 KB展开 ▼
python
import numpy as np
import pandas as pd

def orthogonalize_factors(factors_df, order='descending_variance'):
    """
    对因子进行正交化处理,消除因子间的线性重叠
    
    order: 正交化顺序
        - 'descending_variance':按方差从大到小(常见做法)
    """
    data = factors_df.dropna()
    feature_names = data.columns.tolist()
    
    if order == 'descending_variance':
        variances = data.var()
        ordered_features = variances.sort_values(ascending=False).index.tolist()
    else:
        ordered_features = feature_names
    
    orthogonalized = pd.DataFrame(index=data.index)
    
    for i, feature in enumerate(ordered_features):
        y = data[feature].values
        if i == 0:
            orthogonalized[feature] = y
        else:
            # 将当前因子对前面的正交化因子回归,取残差
            X = orthogonalized[ordered_features[:i]].values
            beta = np.linalg.lstsq(X, y, rcond=None)[0]
            residuals = y - X @ beta
            orthogonalized[feature] = residuals
    
    return orthogonalized[feature_names]  # 按原始顺序返回

# 一键运行(用 demo 数据 factors)
ortho_factors = orthogonalize_factors(factors, order='descending_variance')
print(f"✅ 正交化前 因子总方差: {factors.var().sum():.4f}")
print(f"✅ 正交化后 因子总方差: {ortho_factors.var().sum():.4f}")
print(f"✅ 正交化后 列间最大相关系数: {np.abs(np.corrcoef(ortho_factors.T)).max():.4f} (理想值 ≈ 1.0,因为保留了每个因子的最大方差)")
点击展开可浏览运行结果
✅ 正交化前 因子总方差: 0.0020
✅ 正交化后 因子总方差: 0.0019
✅ 正交化后 列间最大相关系数: 1.0000 (理想值 ≈ 1.0,因为保留了每个因子的最大方差)

常见误区

误区一:Lasso总能选对变量

事实:Lasso在以下情况下表现不佳:(a) 变量之间高度相关时,Lasso倾向于随机选一个而丢弃其他的,(b) 真实模型中的系数很小但不为零时,Lasso可能将其压缩为零。弹性网络(Elastic Net)在这些场景下通常更好。

误区二:正则化参数可以随意选择

事实λ 的选择是正则化中最关键的决策。太小≈OLS(过拟合),太大≈零模型(欠拟合)。在金融时间序列数据上做交叉验证时,必须使用时序交叉验证(TimeSeriesSplit)而非普通的K-Fold,因为金融数据有时间依赖性,随机打乱会引入未来信息泄露。

误区三:因子标准化是可选的

事实:正则化方法(尤其是Lasso和Ridge)对特征的尺度极其敏感。如果因子A的量级是10^6而因子B的量级是10^{-3},不标准化的正则化会把所有权重都给因子A。永远在正则化之前标准化特征(归一化到均值0,方差1)

误区四:统计显著=经济显著

事实:在大样本下,几乎任何微小的效应都能变得"统计显著"。一个每天产生0.0001%额外收益的因子,在5年日度数据上可能t值超过3,但扣除交易成本后可能毫无经济意义。始终结合经济显著性(系数的大小、策略的换手率、容量等)来判断一个因子是否有价值。

实战练习

  1. 正则化方法比较:使用相同的候选因子集(至少15个因子),分别用OLS、Ridge、Lasso和ElasticNet进行拟合。比较四种方法:(a) 样本内R方,(b) 样本外R方,(c) 选中的因子数量,(d) 各因子的系数稳定性(通过Bootstrap采样观察系数分布)。

  2. 共线性处理实战:构造一个包含高度相关因子的数据集(如PE和PB,或不同窗口的动量),先用VIF诊断识别共线性问题,然后分别尝试(a)直接删除高VIF因子,(b)使用正交化,(c)使用Ridge回归,比较三种方法处理后的OLS回归结果。

  3. 因子时序衰减分析:将历史数据按年度分成若干个子区间,在每个子区间上分别用Lasso选择因子。观察:(a) 不同年份选中的因子是否一致?(b) 因子系数的符号是否稳定?(c) 因子系数的大小是否随时间系统性地衰减?

延伸阅读

  • 《The Elements of Statistical Learning》—— Hastie, Tibshirani, Friedman,统计学习的全面参考
  • 《An Introduction to Statistical Learning》—— James et al.,ISL的经典入门
  • 《Machine Learning in Asset Pricing》—— Stefan Nagel,机器学习在资产定价中的应用
  • Zou, H. and Hastie, T. (2005): "Regularization and Variable Selection via the Elastic Net"
  • Gu, Kelly, Xiu (2020): "Empirical Asset Pricing via Machine Learning" —— 系统性比较各类ML方法在因子建模中的表现

本章要点

  • 线性模型是量化因子合成的首选工具,简单透明,在小样本和低信噪比环境下通常优于复杂模型
  • 正则化是处理高维因子空间和多重共线性的核心方法:Lasso选变量,Ridge稳系数,ElasticNet取折衷
  • 时序交叉验证(TimeSeriesSplit)是金融数据中参数选择的必要条件
  • VIF诊断帮助识别共线性,正交化处理可消除因子间的信息重叠
  • 统计显著不等于经济显著,因子选择最终需结合交易成本、换手率和容量进行综合评估