07 超越线性关系

为什么要超越线性?

线性模型简洁易用,但现实世界的关系往往是非线性的。

  • 股价走势呈现周期性波动,而非简单线性趋势
  • 收益率与风险之间可能存在倒U型关系
  • 经济增长与投资之间的边际效应递减

本章介绍六种突破线性假设的方法,在保持可解释性的同时捕捉复杂模式。

非线性建模要先处理单变量曲线再扩展到多变量结构

学习非线性建模,逻辑上通常要分成两个层次:

  • 先处理单变量曲线:看清一条曲线如何偏离直线
  • 再扩展到多变量结构:理解多条非线性关系怎样组合进同一个模型

这两个层次分别对应两类核心任务:

  • 在单变量情形下比较全局方法与局部方法谁更合适
  • 在多变量情形下同时保留灵活性与可解释性

因此,本章真正要建立的是一种判断框架:

先判断非线性发生在哪里,再判断应该用多光滑、多局部、多可解释的方式去拟合它。

线性假设何时失效?来自A股的证据

案例:市盈率(PE)与未来收益率

  • 传统金融理论假设PE与收益率呈线性负相关
  • 但实际数据显示:PE过低(价值陷阱)和PE过高(成长股溢价)时,关系复杂

线性假设失效的三种模式

模式 描述 金融案例
阈值效应 低于/高于某值时关系改变 市盈率 < 10 时,可能是价值陷阱
饱和效应 边际效应递减 研发投入对利润的影响
周期效应 存在周期性波动 消费支出的季节性模式

解决思路

  • 不抛弃线性模型的可解释性
  • 而是扩展线性模型的框架来容纳非线性

非线性方法的统一框架

核心思想:所有方法都可以写成基函数展开的形式

\[ \large{ f(X) = \sum_{m=1}^{M} \beta_m h_m(X) } \tag{1}\]

其中 \(h_m(X)\)基函数(Basis Function),\(\beta_m\) 是系数。

方法 基函数 \(h_m(X)\) 特点
多项式回归 \(X^m\) 全局
阶梯函数 \(I(c_m \leq X < c_{m+1})\) 局部常数
回归样条 \((X - \xi_m)_+^3\) 局部多项式
平滑样条 非参数 自动选节点

关键洞见:一旦确定了基函数,模型仍然是线性回归——对 \(\beta_m\) 是线性的!

两类非线性来源:曲率、阈值与局部结构

在实务中,非线性通常来自三类不同机制:

  • 曲率:变量效应持续变化,但变化速度本身也在变
  • 阈值:越过某个临界点之后,关系突然切换
  • 局部结构:不同区间有不同规律,无法用一条全局曲线概括

这三类来源分别对应本章不同方法的优势场景。

六种非线性方法概览

方法 核心思想 灵活性
多项式回归 添加 \(x^2, x^3, \ldots\)
阶梯函数 \(x\) 分段为常数区间
回归样条 分段多项式 + 节点处平滑连接
平滑样条 带粗糙度惩罚的样条
局部回归 在每个点附近拟合局部模型
GAMs 多变量的可加非线性模型

方法选择的实用指南

如何在六种方法中做选择?

  • 数据量 < 100:优先考虑多项式回归(参数少)
  • 数据量 100-1000:回归样条或自然样条
  • 数据量 > 1000:平滑样条或GAM
  • 需要高度可解释性:多项式回归或阶梯函数
  • 多个预测变量:GAM(可以对每个变量施加不同的非线性)

实操决策树

  1. 先试线性回归,检查残差图
  2. 如果残差有明显模式 → 尝试3-5次多项式回归
  3. 如果仍不理想 → 尝试自然样条(自由度3-5)
  4. 如果多个变量 → 直接用GAM

判断非线性的第一步:残差图怎么看?

真正的建模流程不是一上来就上样条,而是先看残差图:

  • 残差随机散开:线性假设可能已经够用
  • 残差呈 U 型或 S 型:说明存在系统性曲率
  • 残差在某些区间突然偏高或偏低:可能存在阈值或分段结构

所以,残差图是从线性走向非线性的第一个信号灯。

实证案例:海康威视股价数据

我们使用海康威视(002415.XSHE)2010-2025年的日度股价数据作为贯穿全章的实证案例。

Code
import pandas as pd  # 导入pandas用于数据框操作
import numpy as np  # 导入numpy用于数值计算
import matplotlib.pyplot as plt  # 导入matplotlib用于绑图
import os  # 导入os模块用于跨平台路径处理
import warnings  # 导入warnings模块用于控制警告信息
warnings.filterwarnings('ignore')  # 忽略所有警告信息,保持输出整洁

# 设置中文字体为思源宋体
plt.rcParams['font.family'] = ['Source Han Serif SC', 'Noto Serif CJK SC', 'SimSun']  # 设置中文显示字体优先级
plt.rcParams['axes.unicode_minus'] = False  # 解决负号显示问题

# 根据操作系统设置数据根目录
DATA_DIR = 'C:/qiufei/data' if os.name == 'nt' else '/home/ubuntu/r2_data_mount/data'  # 跨平台数据路径
stock_price_path = os.path.join(DATA_DIR, 'stock/stock_price_pre_adjusted.h5')  # 拼接前复权股价文件路径
all_stock_data = pd.read_hdf(stock_price_path)  # 从本地h5文件读取全部股价数据
Figure 1

数据预处理

Code
# 若 order_book_id 在索引中则重置为普通列
if 'order_book_id' in all_stock_data.index.names:  # 检查是否为多级索引
    all_stock_data = all_stock_data.reset_index()  # 重置索引为普通列
# 统一日期列名为 trade_date
if 'date' in all_stock_data.columns and 'trade_date' not in all_stock_data.columns:  # 兼容不同数据源
    all_stock_data = all_stock_data.rename(columns={'date': 'trade_date'})  # 重命名日期列

# 筛选海康威视并按日期排序
hikvision_data = all_stock_data[all_stock_data['order_book_id'] == '002415.XSHE'].sort_values('trade_date').copy()  # 提取海康威视数据

# 构造时间序号与收盘价
time_index = np.arange(len(hikvision_data))  # 生成0到N-1的时间序号作为自变量
close_price = hikvision_data['close'].values  # 提取收盘价数组作为因变量

# 绘制股价走势
plt.figure(figsize=(10, 4))  # 创建10x4英寸画布
plt.plot(time_index, close_price, linewidth=0.8, color='steelblue')  # 绑制收盘价折线
plt.xlabel('交易日序号')  # 设置横轴标签
plt.ylabel('收盘价(元)')  # 设置纵轴标签
plt.title(f'海康威视收盘价(共{len(hikvision_data)}个交易日)')  # 设置标题
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 2: 海康威视日度收盘价时序图

多项式回归的基本思想

多项式回归通过添加高次项来扩展线性模型:

\[ y_i = \beta_0 + \beta_1 x_i + \beta_2 x_i^2 + \cdots + \beta_d x_i^d + \epsilon_i \]

  • 本质上仍是线性模型——对参数 \(\beta\) 线性
  • 阶数 \(d\) 控制灵活性:\(d\) 越大越灵活,但也越容易过拟合
  • Runge 现象:高阶多项式在端点处可能剧烈振荡

多项式回归的几何直觉:高次项如何制造弯曲?

从几何上看,高次项是在给直线增加新的’弯曲自由度’:

  • 二次项允许曲线出现一个稳定的弧度
  • 三次项允许曲线出现拐点
  • 更高次项会带来更多局部弯折,但也更容易失控

所以,多项式回归的灵活性,本质上来自对曲线弯曲能力的逐步开放。

多项式回归的数学本质

d阶多项式回归模型

\[ \large{ y_i = \beta_0 + \beta_1 x_i + \beta_2 x_i^2 + \cdots + \beta_d x_i^d + \varepsilon_i } \tag{2}\]

等价表述:创建新变量 \(x_i^2, x_i^3, \ldots, x_i^d\),然后做普通多元线性回归

为什么d通常 ≤ 5?

  • 高阶多项式在数据边界处剧烈振荡(龙格现象)
  • 多项式系数随阶数增大变得极度不稳定
  • 阶数过高 → 严重过拟合

补充说明:共线性问题

\(x, x^2, x^3\) 之间往往高度相关,导致系数估计不稳定。 解决方案:使用正交多项式(Orthogonal Polynomials):

from numpy.polynomial import polynomial as P
# 正交多项式避免了共线性问题

不同阶数多项式拟合对比

Code
from sklearn.pipeline import Pipeline  # 导入Pipeline用于构建建模流水线
from sklearn.preprocessing import PolynomialFeatures  # 导入多项式特征变换器
from sklearn.linear_model import LinearRegression  # 导入线性回归模型

degree_candidates = [1, 3, 5, 10, 15, 20]  # 候选多项式阶数列表
fig, axes = plt.subplots(2, 3, figsize=(12, 6))  # 创建2行3列子图网格
axes = axes.ravel()  # 将二维子图数组展平为一维便于迭代

time_features = time_index.reshape(-1, 1)  # 将时间序号变为列向量(sklearn要求二维输入)

for idx, degree in enumerate(degree_candidates):  # 遍历每个候选阶数
    # 构建多项式回归流水线:先做特征变换再拟合线性回归
    poly_pipeline = Pipeline([
        ('poly', PolynomialFeatures(degree=degree)),  # 生成1到d次多项式特征
        ('lr', LinearRegression())  # 对多项式特征做最小二乘拟合
    ])
    poly_pipeline.fit(time_features, close_price)  # 在全量数据上拟合模型
    predicted_price = poly_pipeline.predict(time_features)  # 预测拟合值

    axes[idx].scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 绑制原始数据散点
    axes[idx].plot(time_index, predicted_price, color='red', linewidth=2)  # 绑制拟合曲线
    axes[idx].set_title(f'阶数 d={degree}')  # 设置子图标题

plt.tight_layout()  # 自动调整子图间距
plt.show()  # 显示图表
Figure 3: 不同阶数多项式拟合海康威视股价

交叉验证选择最优阶数

Code
from sklearn.model_selection import TimeSeriesSplit, cross_val_score  # 导入时间序列CV工具

degree_list = list(range(1, 16))  # 候选阶数1到15
cv_mean_mse = []  # 存储各阶数的平均交叉验证MSE

tscv = TimeSeriesSplit(n_splits=5)  # 5折时间序列交叉验证(保持时间顺序)

for degree in degree_list:  # 遍历每个候选阶数
    poly_pipeline = Pipeline([
        ('poly', PolynomialFeatures(degree=degree)),  # 多项式特征变换
        ('lr', LinearRegression())  # 线性回归
    ])
    # scoring='neg_mean_squared_error':返回负MSE(sklearn约定越大越好)
    cv_scores = cross_val_score(poly_pipeline, time_features, close_price, cv=tscv, scoring='neg_mean_squared_error')
    cv_mean_mse.append(-cv_scores.mean())  # 取负号还原为正MSE并求平均

optimal_degree = degree_list[np.argmin(cv_mean_mse)]  # 取MSE最小的阶数为最优

plt.figure(figsize=(8, 4))  # 创建画布
plt.plot(degree_list, cv_mean_mse, 'o-', color='steelblue')  # 绑制各阶数的CV MSE折线
plt.xlabel('多项式阶数')  # 横轴标签
plt.ylabel('交叉验证 MSE')  # 纵轴标签
plt.title(f'最优阶数 d = {optimal_degree}')  # 标题显示最优阶数
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 4: 时间序列交叉验证选择最优多项式阶数

为什么训练误差不能决定阶数?

如果只看训练误差,你几乎总会偏向更高阶模型:

  • 阶数越高,模型越容易贴住训练样本
  • 但贴住样本不等于学到规律,也可能只是记住噪声
  • 真正重要的是样本外误差,这正是交叉验证的意义

所以,选阶数不是比谁更会贴数据,而是比谁更能推广到未来。

多项式回归的预测置信区间

点预测区间预测的区别:

  • 点预测\(\hat{f}(x_0)\)——单个数值
  • 区间预测\([\hat{f}(x_0) - z_{\alpha/2}\cdot\text{SE}, \hat{f}(x_0) + z_{\alpha/2}\cdot\text{SE}]\)

两类不确定性

  1. 均值的置信区间(Confidence Interval for Mean):
    • 反映 \(f(x_0)\) 估计的不确定性
    • 宽度随样本量增大而缩小
  2. 个体预测区间(Prediction Interval):
    • 还包含随机误差 \(\varepsilon\) 的影响
    • 宽度不会随样本量趋于零(存在不可约误差)

A股实例

  • 均值CI:“海康威视明天的期望收益率在 \([-0.5\%, 0.8\%]\) 之间”
  • 预测PI:“海康威视明天的实际收益率有95%概率在 \([-3.2\%, 3.5\%]\) 之间”

ANOVA逐步检验多项式阶数

除了交叉验证,可以用F检验逐步检验是否需要更高阶项:

import statsmodels.api as sm  # 导入statsmodels用于统计检验
from scipy.stats import f as f_dist  # 导入F分布用于计算p值

anova_results = []  # 存储各对嵌套模型的F检验结果
for degree in range(1, 6):  # 比较1阶vs2阶、2阶vs3阶...4阶vs5阶
    # 拟合当前阶数模型
    poly_current = PolynomialFeatures(degree=degree)  # 当前阶数特征变换
    features_current = poly_current.fit_transform(time_features)  # 生成当前阶特征矩阵
    model_current = sm.OLS(close_price, features_current).fit()  # 拟合OLS模型

    # 拟合下一阶数模型
    poly_next = PolynomialFeatures(degree=degree + 1)  # 下一阶特征变换
    features_next = poly_next.fit_transform(time_features)  # 生成下一阶特征矩阵
    model_next = sm.OLS(close_price, features_next).fit()  # 拟合OLS模型

    # 计算F统计量
    rss_current = model_current.ssr  # 当前模型的残差平方和
    rss_next = model_next.ssr  # 下一阶模型的残差平方和
    df_diff = model_next.df_model - model_current.df_model  # 自由度差异
    f_stat = ((rss_current - rss_next) / df_diff) / (rss_next / model_next.df_resid)  # F统计量
    p_val = 1 - f_dist.cdf(f_stat, df_diff, model_next.df_resid)  # 右尾p值

    anova_results.append({'比较': f'{degree}阶 vs {degree+1}阶', 'F统计量': f'{f_stat:.2f}', 'p值': f'{p_val:.4f}'})

pd.DataFrame(anova_results)  # 输出ANOVA比较结果表
Table 1: 多项式回归嵌套模型ANOVA检验
比较 F统计量 p值
0 1阶 vs 2阶 825.18 0.0000
1 2阶 vs 3阶 2344.04 0.0000
2 3阶 vs 4阶 6.56 0.0105
3 4阶 vs 5阶 53.66 nan
4 5阶 vs 6阶 inf nan

ANOVA与F检验的直觉理解

ANOVA的核心问题:增加模型复杂度后,拟合改善是否显著?

\[ \large{ F = \frac{(\text{RSS}_1 - \text{RSS}_2) / (p_2 - p_1)}{\text{RSS}_2 / (n - p_2)} } \tag{3}\]

  • \(\text{RSS}_1\):简单模型(如1阶)的残差平方和
  • \(\text{RSS}_2\):复杂模型(如2阶)的残差平方和
  • 分子:增加的复杂度带来多少拟合改善
  • 分母:复杂模型的残差方差(基准噪声水平)

决策逻辑

  • \(F\) 值大 → 拟合改善显著 → 值得增加复杂度
  • \(F\) 值小 → 改善可能只是偶然 → 保持简单模型
  • 逐步检验:1阶 vs 2阶 → 2阶 vs 3阶 → … → 第一次p值不显著时停止

多项式回归的局限性

为什么需要更灵活的方法?

问题一:全局影响

  • 修改一处数据 → 影响整条拟合曲线
  • 高杠杆点可能导致曲线在远离该点处也发生扭曲

问题二:边界振荡

  • 多项式在数据范围边界处容易剧烈振荡
  • 龙格现象(Runge’s Phenomenon):阶数越高,边界振荡越严重

问题三:不适合非光滑关系

  • 如果真实关系有”尖角”或”阶梯”,多项式无法精确拟合
  • 需要分段(Piecewise)的方法

过渡:这些局限引出了本章后续的方法——阶梯函数和样条,它们用局部拟合替代全局多项式。

当全局多项式顾此失彼时就该转向局部方法

多项式回归的问题,不是它不能弯,而是它的弯曲方式往往全局联动

  • 一端的数据变化会牵动整条曲线
  • 边界位置最容易出现不可信的外推
  • 当不同区间的变化节奏不同,全局多项式往往顾此失彼

一旦出现这些现象,就说明问题已经不再是’阶数够不够高’,而是该不该把整条曲线交给同一个全局函数。

这时,局部方法的价值就会立刻上升。

阶梯函数:将连续变量离散化

阶梯函数将连续变量 \(X\) 分成 \(K\) 个区间,在每个区间内拟合常数:

\[ y_i = \beta_0 + \beta_1 \cdot \mathbf{1}(c_1 \le x_i < c_2) + \cdots + \beta_K \cdot \mathbf{1}(c_K \le x_i < c_{K+1}) + \epsilon_i \]

  • 切点 \(c_1, \ldots, c_K\)\(x\) 的取值范围分为 \(K+1\)
  • 每段内的预测值是一个常数(该段的均值)
  • 优点:简单直观,易于解释
  • 缺点:对切点位置敏感,不连续的”阶梯”可能不自然

阶梯函数的数学表述

将连续变量 \(X\) 划分为 \(K+1\) 个区间

\[ \large{ y_i = \beta_0 + \beta_1 C_1(x_i) + \beta_2 C_2(x_i) + \cdots + \beta_K C_K(x_i) + \varepsilon_i } \tag{4}\]

其中 \(C_k(x) = I(c_k \leq x < c_{k+1})\)指示函数

系数解读

  • \(\beta_0\):基准区间(\(x < c_1\))的均值
  • \(\beta_k\):第 \(k\) 个区间相对于基准的平均差异
  • 等价于:对分组变量做单因子ANOVA

金融应用

  • 将日成交量分为”低/中/高”三档,分析各档下的收益率特征
  • 将市盈率按分位数分组,比较各组的未来表现
  • 本质上就是”分组比较”——在量化投资中非常常见

阶梯函数最适合哪类商业问题?

阶梯函数虽然简单,但在商业与金融里有很稳定的应用位置:

  • 信用评分分箱:把年龄、收入、负债率分成若干档
  • 分位数组合分析:把股票按因子值分组比较未来收益
  • 政策阈值研究:某指标高于监管线之后,企业行为是否改变

只要问题本身天然具有’分档’思维,阶梯函数就非常合适。

阶梯函数拟合与交叉验证

Code
# 将时间序号等频分为8个区间(每个区间约含相同数量的观测值)
hikvision_data['Time'] = time_index  # 添加时间序号列
hikvision_data['Time_bin'] = pd.qcut(time_index, q=8, labels=False)  # 等频分箱为0-7共8组

# 为每个分箱生成哑变量并拟合OLS模型
step_dummies = pd.get_dummies(hikvision_data['Time_bin'], prefix='bin', drop_first=True, dtype=float)  # 生成虚拟变量
step_features = sm.add_constant(step_dummies)  # 添加截距项
step_model = sm.OLS(close_price, step_features).fit()  # 拟合阶梯函数OLS模型

plt.figure(figsize=(10, 4))  # 创建画布
plt.scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 绑制原始数据
plt.plot(time_index, step_model.fittedvalues, color='red', linewidth=2)  # 绑制阶梯函数拟合值
plt.xlabel('交易日序号')  # 横轴标签
plt.ylabel('收盘价(元)')  # 纵轴标签
plt.title('阶梯函数拟合(8个等频分箱)')  # 图表标题
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 5: 阶梯函数拟合海康威视股价(8个等频分箱)

从分段多项式到样条

回归样条在每段区间拟合低阶多项式,并在节点处施加连续性约束:

  • 分段多项式:在 \(K\) 个节点处分段,每段独立拟合多项式
  • 连续性约束:要求函数值、一阶导数、二阶导数在节点处连续
  • 三次样条:每添加一个节点增加 1 个自由度(共 \(K + 4\) 个参数)
  • 截断幂基\(h(x, \xi) = (x - \xi)^3_+\) 是节点 \(\xi\) 处的基函数

为什么节点处必须平滑?

如果节点处不平滑,会产生两个问题:

  • 曲线会在连接点出现肉眼可见的折痕
  • 导数不连续会让边际效应解释失真

因此,样条并不是简单地把多项式拼起来,而是在拼接时强制它们保留连续的经济含义。

为什么三次样条最常用?

样条理论上可以是一阶、二阶、三阶甚至更高阶,但三次样条最常见,因为它在三件事上平衡最好:

  • 足够灵活,能表达常见的弯曲关系
  • 足够平滑,函数值、一阶和二阶导数都能连续
  • 计算稳定,不像更高阶样条那样容易带来额外波动

所以,三次并不是偶然,而是经验与理论共同筛出来的默认选项。

样条的直觉:光滑的分段多项式

分段多项式的问题:在节点处可能不连续不光滑

样条的解决方案:在每个节点处施加连续性约束

三次样条的约束条件(在每个节点 \(\xi_j\) 处):

  1. 函数值连续\(f(\xi_j^-) = f(\xi_j^+)\)
  2. 一阶导数连续\(f'(\xi_j^-) = f'(\xi_j^+)\)
  3. 二阶导数连续\(f''(\xi_j^-) = f''(\xi_j^+)\)

自由度计算

  • \(K\) 个节点的三次样条有 \(K + 4\) 个参数
  • 减去 \(3K\) 个约束 → 自由度 = \(K + 4\)
  • 但以截断幂基函数表示:直接 \(K + 4\) 个基函数

\[ \large{ h_j(X) = (X - \xi_j)_+^3 = \begin{cases} (X - \xi_j)^3, & X > \xi_j \\ 0, & X \leq \xi_j \end{cases} } \tag{5}\]

节点数量与位置的选择

节点选择策略

策略 描述 优点 缺点
等距节点 在数据范围内均匀分布 简单 忽略数据密度
分位数节点 放在数据分位数处 适应数据密度 推荐
CV选择 用交叉验证选节点数 最优 计算量大

经验法则

  • 通常 3-5 个节点已经足够
  • 节点放在数据密度高的区域(分位数方法)
  • 通过CV选择最优节点数量

过度节点的风险

  • 节点太多 → 过拟合 → CV误差上升
  • 极端情况:\(K = n\)(每个数据点一个节点)→ 完全插值 → 严重过拟合

自然样条:边界行为更稳健

自然样条在边界区域(最外侧两个节点之外)强制为线性,有效减少端点振荡:

  • 边界约束额外减少 4 个参数 → 自由度 = \(K\)
  • 直觉:边界外数据稀少,线性外推比多项式外推更稳健
  • Python 实现:patsy.cr() 生成自然样条基函数

自然样条的边界约束

三次样条的边界问题:数据范围之外的外推非常不可靠。

自然样条的额外约束

在边界节点之外强制函数为线性(而非三次)。

  • 等价于:\(f''(\xi_1) = f'''(\xi_1) = 0\)\(f''(\xi_K) = f'''(\xi_K) = 0\)
  • 每个边界多2个约束 → 自由度减少4个
  • 自然样条自由度 = \(K\)\(K\) 个节点)

对比

类型 边界行为 自由度
三次样条 三次多项式(振荡) \(K + 4\)
自然样条 线性(稳定) \(K\)

金融应用的重要性

  • 预测未来股价时,边界外推不可避免
  • 自然样条的线性外推比三次外推更保守、更安全

单变量非线性方法的选择框架

走到这里,单变量方法已经可以压缩成一张选择表:

  • 多项式回归:适合简单、连续的曲率
  • 阶梯函数:适合阈值、分箱、规则型问题
  • 自然样条:适合大多数需要平滑且稳健的实务场景

这也是实际分析中最常见的三种起手式。

自然样条拟合与CV选择自由度

Code
from patsy import dmatrix  # 导入patsy用于根据公式生成设计矩阵

df_candidates = list(range(3, 16))  # 候选自由度3到15
cv_mse_spline = []  # 存储各自由度的交叉验证MSE

for df_val in df_candidates:  # 遍历每个候选自由度
    fold_mse_list = []  # 当前自由度下各折的MSE
    for train_idx, test_idx in tscv.split(time_features):  # 时间序列5折交叉验证
        x_train_fold = time_index[train_idx]  # 当前折的训练集时间序号
        x_test_fold = time_index[test_idx]  # 当前折的测试集时间序号
        y_train_fold = close_price[train_idx]  # 训练集目标值
        y_test_fold = close_price[test_idx]  # 测试集目标值

        # 用patsy cr()生成自然样条基矩阵
        basis_train = dmatrix(f'cr(x, df={df_val})', {'x': x_train_fold}, return_type='dataframe')  # 训练集基函数矩阵
        basis_test = dmatrix(f'cr(x, df={df_val})', {'x': x_test_fold}, return_type='dataframe')  # 测试集基函数矩阵

        spline_fold_model = sm.OLS(y_train_fold, basis_train).fit()  # 拟合当前折的样条回归
        y_pred_fold = spline_fold_model.predict(basis_test)  # 在测试折上预测
        fold_mse_list.append(np.mean((y_test_fold - y_pred_fold) ** 2))  # 计算并记录MSE

    cv_mse_spline.append(np.mean(fold_mse_list))  # 求5折平均MSE

optimal_df = df_candidates[np.argmin(cv_mse_spline)]  # 选取MSE最小的自由度

plt.figure(figsize=(8, 4))  # 创建画布
plt.plot(df_candidates, cv_mse_spline, 'o-', color='steelblue')  # 绑制CV MSE随自由度变化的折线
plt.xlabel('自然样条自由度')  # 横轴标签
plt.ylabel('交叉验证 MSE')  # 纵轴标签
plt.title(f'最优自由度 df = {optimal_df}(CV MSE ≈ {min(cv_mse_spline):.2f})')  # 显示最优结果
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 6: 交叉验证选择自然样条最优自由度

样条 vs 多项式:CV MSE 对比

Code
# 拟合15阶多项式
poly15 = Pipeline([
    ('poly', PolynomialFeatures(degree=15)),  # 15阶多项式特征
    ('lr', LinearRegression())  # 线性回归
])
poly15.fit(time_features, close_price)  # 拟合模型
pred_poly15 = poly15.predict(time_features)  # 预测

# 拟合df=15的自然样条
basis_full = dmatrix('cr(x, df=15)', {'x': time_index}, return_type='dataframe')  # 生成全量样条基
spline15_model = sm.OLS(close_price, basis_full).fit()  # 拟合样条模型
pred_spline15 = spline15_model.fittedvalues  # 获取拟合值

fig, axes = plt.subplots(1, 2, figsize=(12, 4))  # 创建1行2列子图

axes[0].scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 左图:原始数据
axes[0].plot(time_index, pred_poly15, color='red', linewidth=2)  # 15阶多项式拟合线
axes[0].set_title('15阶多项式(边界剧烈振荡)')  # 子图标题
axes[0].set_ylim(-50, 100)  # 限制纵轴范围避免极端值

axes[1].scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 右图:原始数据
axes[1].plot(time_index, pred_spline15, color='blue', linewidth=2)  # 自然样条拟合线
axes[1].set_title(f'自然样条 df=15(CV MSE ≈ {min(cv_mse_spline):.2f})')  # 子图标题

plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 7: 自然样条 vs 多项式回归(同自由度 df=15)对比

样条方法的四大优势

维度 多项式回归 回归样条
局部性 全局基函数,牵一发动全身 局部基函数,修改节点仅影响附近
边界稳定性 高阶时边界振荡严重 自然样条边界线性化,稳定
自由度效率 需要高阶才能灵活 低自由度即可高灵活度
自适应性 均匀全局 可在变化剧烈区域放更多节点

样条选择的实操总结

回归样条 vs. 自然样条 vs. 平滑样条

特征 回归样条 自然样条 平滑样条
节点选择 手动或CV 手动或CV 自动(每个点)
边界行为 不稳定 线性(稳健) 取决于 \(\lambda\)
调节参数 节点数 \(K\) 自由度 df 平滑参数 \(\lambda\)
计算复杂度 中等
推荐场景 教学演示 实际应用首选 需要自动化时

实操建议

  1. 首选自然样条:自由度3-5,用CV选最优
  2. 如果需要全自动 → 用平滑样条
  3. GAM中默认使用平滑样条/P样条

自动平滑解决手动节点选择的局限

当样条方法开始真正用于建模时,新的问题会立刻出现:

  • 如果我不知道节点该放哪里,能不能让算法自动决定
  • 如果我不想显式设定节点个数,能不能直接约束曲线粗糙度
  • 如果未来要扩展到多变量,哪一种单变量平滑方法最容易拼接成更大的可解释模型

这说明非线性建模的难点已经从’能不能画弯’,转向’如何自动而稳定地控制弯曲程度’。

平滑样条与 GAM 正是在这里发挥作用。

平滑样条:拟合与光滑性的权衡

平滑样条通过最小化带惩罚的目标函数来拟合数据:

\[ \min_g \sum_{i=1}^{n} (y_i - g(x_i))^2 + \lambda \int g''(t)^2 \, dt \]

  • 第一项:拟合误差(越小越贴合数据)
  • 第二项:粗糙度惩罚(\(g''(t)^2\) 衡量曲率,越小越光滑)
  • \(\lambda\) 控制两者的权衡:
    • \(\lambda = 0\):完美插值(过拟合)
    • \(\lambda \to \infty\):退化为直线(欠拟合)

平滑样条的优化问题

平滑样条求解的是一个带惩罚的最优化问题

\[ \large{ \min_f \sum_{i=1}^n (y_i - f(x_i))^2 + \lambda \int [f''(t)]^2 dt } \tag{6}\]

两项的权衡

  • 第一项(拟合度):残差平方和 → 希望尽量小 → 鼓励复杂模型
  • 第二项(光滑度):二阶导数的积分 → 惩罚曲率 → 鼓励简单模型
  • \(\lambda \geq 0\)调节旋钮,平衡两者

\(\lambda\) 的极端情况

\(\lambda\) 效果 类比
\(\lambda = 0\) 完全插值 每个数据点精确通过
\(\lambda \to \infty\) 最小二乘直线 最光滑的可能
最优 \(\lambda\) 偏差-方差平衡 通过CV选择

读这个优化问题的顺序

  1. 先看残差平方和,它在回答’模型愿不愿意贴着数据走’
  2. 再看曲率惩罚,它在回答’模型要为每一次弯折付出多少代价’
  3. 最后看 \(\lambda\),它不是新信息,而是决定前两项谁更有话语权的旋钮

惩罚参数 \(\lambda\) 与光滑度是什么关系?

\(\lambda\) 可以被理解为’不允许曲线乱弯’的强度旋钮:

  • \(\lambda\) 小:模型更愿意追随数据细节
  • \(\lambda\) 大:模型更强调整体平滑
  • 最优值通常不由肉眼决定,而由交叉验证或 GCV 决定

所以,平滑样条真正要学会的不是记公式,而是读懂这个旋钮在控制什么。

有效自由度与LOOCV

  • 平滑样条的解是在所有 \(n\) 个数据点处的自然样条,但通过惩罚实现收缩
  • 有效自由度(Effective Degrees of Freedom):\(\text{df}_\lambda = \text{tr}(S_\lambda)\)
    • \(S_\lambda\) 是平滑矩阵,\(\hat{y} = S_\lambda y\)
    • \(\text{df}_\lambda\)\(\lambda\) 增大而减小
  • LOOCV 快捷公式(无需反复拟合):

\[ \text{RSS}_{cv}(\lambda) = \sum_{i=1}^{n} \left(\frac{y_i - \hat{g}_\lambda(x_i)}{1 - \{S_\lambda\}_{ii}}\right)^2 \]

有效自由度为什么比节点个数更重要?

在平滑样条里,真正控制复杂度的不是’放了多少个节点’,而是有效自由度

  • 节点几乎可以放在每个观测点上
  • 但惩罚项会把很多自由度收回来
  • 因此,模型真正的灵活性由 df\(_{eff}\) 决定,而不是由节点表面数量决定

这也是平滑样条与回归样条最容易混淆、但最本质的区别之一。

pygam实现平滑样条

Code
from pygam import LinearGAM  # 导入pygam线性GAM模型
from pygam import s as s_gam  # 导入平滑项函数(避免与pygam.s冲突)

if not hasattr(np, 'int'):  # 兼容新版NumPy中移除np.int别名而旧版pygam仍会引用该名称的情况
    np.int = int  # 将np.int临时指向Python内置int以保证pygam内部样条基函数构造正常执行

lambda_values = [0.1, 10, 1000]  # 三个代表性的平滑参数值
fig, axes = plt.subplots(1, 3, figsize=(14, 4))  # 创建1行3列子图

for idx, lam in enumerate(lambda_values):  # 遍历三个λ值
    # s_gam(0, lam=lam):对第0个特征施加平滑样条,指定λ
    gam_model = LinearGAM(s_gam(0, lam=lam)).fit(time_features, close_price)  # 拟合平滑样条GAM
    x_grid = np.linspace(0, len(time_index) - 1, 500).reshape(-1, 1)  # 生成500个均匀网格点用于绘图
    y_pred_grid = gam_model.predict(x_grid)  # 在网格上预测

    axes[idx].scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 绑制原始数据散点
    axes[idx].plot(x_grid, y_pred_grid, color='red', linewidth=2)  # 绑制平滑样条拟合曲线
    axes[idx].set_title(f'λ = {lam}')  # 子图标题

plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 8: 不同平滑参数λ下的平滑样条拟合

网格搜索最优平滑参数

Code
# 使用pygam内置的网格搜索功能自动选择最优λ
optimal_gam = LinearGAM(s_gam(0)).gridsearch(  # 对第0个特征施加平滑样条
    time_features, close_price,  # 输入时间特征和目标股价
    lam=np.logspace(-3, 4, 30)  # 在[0.001, 10000]范围内搜索30个候选λ值
)

optimal_lambda = optimal_gam.lam[0][0]  # 提取最优λ值
effective_dof = optimal_gam.statistics_['edof']  # 提取有效自由度

x_grid = np.linspace(0, len(time_index) - 1, 500).reshape(-1, 1)  # 生成绘图用网格
y_pred_optimal = optimal_gam.predict(x_grid)  # 用最优模型在网格上预测

plt.figure(figsize=(10, 4))  # 创建画布
plt.scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 原始数据散点
plt.plot(x_grid, y_pred_optimal, color='red', linewidth=2)  # 绑制最优平滑样条
plt.title(f'最优平滑样条(λ ≈ {optimal_lambda:.4f},有效自由度 ≈ {effective_dof:.2f})')  # 显示选中参数
plt.xlabel('交易日序号')  # 横轴标签
plt.ylabel('收盘价(元)')  # 纵轴标签
plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 9: 网格搜索最优平滑参数λ的拟合结果

选择平滑参数 \(\lambda\) 的实际执行顺序

把“选最优 \(\lambda\)”翻成可执行步骤,可以按下面的顺序理解:

  1. 先定候选范围:给出一组从小到大的 \(\lambda\) 候选值,通常按对数刻度排开
  2. 逐个候选重复拟合:对每个 \(\lambda\) 都用同一套数据切分或同一个 GCV 准则重新拟合一次平滑样条
  3. 记录两类输出:一边记验证误差或 GCV 值,一边记对应的有效自由度,判断模型到底有多灵活
  4. 找最低误差点:把误差最小的 \(\lambda\) 作为默认选择,而不是凭肉眼挑一条“看起来顺眼”的曲线
  5. 再回到全样本重拟合:用选中的 \(\lambda\) 在完整数据上重新拟合,得到最终曲线

所以,\(\lambda\) 的选择本质上不是“调图形美观”,而是“重复拟合很多次,再比较哪一个样本外表现最好”。

平滑样条 vs 回归样条

维度 回归样条 平滑样条
节点选择 用户指定节点位置 每个唯一 \(x\) 都是节点
灵活度控制 节点数量和位置 惩罚参数 \(\lambda\)
适用场景 对变量分布有先验知识 不确定节点位置时
计算效率 低参数、快速 高参数但有高效算法

局部回归(LOWESS)的核心思想

局部回归在每个目标点附近拟合一个加权最小二乘模型:

  1. 选定目标点 \(x_0\) 和邻域比例 frac
  2. 计算所有数据点到 \(x_0\) 的距离,离得近的权重大
  3. 在邻域内做加权线性回归
  4. \(x_0\) 处的拟合值作为预测
  • frac 控制平滑程度:越大越光滑,越小越局部
  • 优点:完全非参数,无需假设函数形式
  • 缺点:计算量大(每个点都要重新拟合),难以推断

LOWESS 的带宽到底在控制什么?

LOWESS 里的 frac 并不是一个随便调的经验参数,它控制的是:

  • 每次局部拟合会看多大的邻域
  • 模型愿意把多少短期波动当成’信号’
  • 局部曲线是更像平滑趋势,还是更像逐点跟随

所以,带宽本质上是在决定你对’局部性’的定义。

LOWESS的算法步骤

对于目标点 \(x_0\) 处的估计值

  1. 确定邻域:选择距离 \(x_0\) 最近的 \(k = \lfloor f \cdot n \rfloor\) 个点
  2. 计算权重\(w_i = W(|x_i - x_0| / \Delta(x_0))\)
    • \(\Delta(x_0)\) 是第 \(k\) 个最近点到 \(x_0\) 的距离
    • \(W\) 是核函数(如三次权重函数)
  3. 加权回归:在邻域内做加权最小二乘
  4. 取预测值\(\hat{f}(x_0) = \hat{\beta}_0 + \hat{\beta}_1 x_0\)

带宽 \(f\)(span)的影响

带宽 效果 特点
\(f\) 小(如0.1) 高度灵活 容易过拟合
\(f\) 大(如0.9) 非常光滑 可能欠拟合
\(f = 1.0\) 全局回归 等价线性回归

LOWESS的优势与局限

优势

  • 完全非参数:不假设任何函数形式
  • 局部自适应:不同区域可以有不同的趋势
  • 对离群值稳健(使用稳健权重迭代时)
  • ✓ 是探索性数据分析的利器

局限

  • 计算量大:每个预测点都需要做一次回归 → \(O(n^2)\)
  • 维度诅咒:高维空间中”邻域”概念失效
  • ✗ 无法给出参数估计系数检验
  • ✗ 不易推广到多变量场景

在金融中的典型用途

  • 探索性分析:绘制”非参数趋势线”
  • 与参数模型对比:LOWESS曲线偏离线性 → 证据表明非线性
  • 不适合正式建模,更多用于数据可视化

LOWESS 为什么难以直接推广到高维?

LOWESS 在一维很好理解,但一到高维就立刻遇到困难:

  • 高维空间里’附近’的概念变得模糊
  • 每个局部邻域都需要更多样本才能稳定拟合
  • 计算成本会随着维度迅速上升

这也是为什么 LOWESS 更适合作为单变量或低维探索工具,而不是高维正式模型。

LOWESS实现与带宽选择

Code
from statsmodels.nonparametric.smoothers_lowess import lowess  # 导入LOWESS平滑函数

frac_values = [0.01, 0.05, 0.2]  # 三个候选带宽(邻域比例)
fig, axes = plt.subplots(1, 3, figsize=(14, 4))  # 创建1行3列子图

for idx, frac_val in enumerate(frac_values):  # 遍历每个带宽
    # lowess返回(x排序, y平滑)的二维数组
    smoothed_result = lowess(close_price, time_index, frac=frac_val, return_sorted=True)  # LOWESS平滑

    axes[idx].scatter(time_index, close_price, s=1, alpha=0.3, color='gray')  # 原始数据
    axes[idx].plot(smoothed_result[:, 0], smoothed_result[:, 1], color='red', linewidth=2)  # 平滑曲线
    axes[idx].set_title(f'frac = {frac_val}')  # 设置子图标题

plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 10: 不同带宽参数下的LOWESS拟合对比

多变量非线性为什么比单变量难得多?

单变量非线性只需要回答一条曲线怎么弯,多变量非线性则多了两层复杂性:

  • 每个变量都可能各自非线性
  • 变量之间还可能存在交互

如果完全不加限制,模型会非常灵活,但也会迅速失去可解释性与可计算性。

GAM的基本思想

广义加性模型(GAM)将线性模型中的每个线性项替换为灵活的非线性函数

\[ y_i = \beta_0 + f_1(x_{i1}) + f_2(x_{i2}) + \cdots + f_p(x_{ip}) + \epsilon_i \]

  • 每个 \(f_j\) 可以是样条、局部回归等非线性函数
  • 可加性假设:各特征的效应独立相加(不含交互项)
  • 这是GAM最大的优势也是限制:保持了可解释性,但可能遗漏交互效应

GAM的数学表述

广义可加模型(Generalized Additive Model):

\[ \large{ y_i = \beta_0 + f_1(x_{i1}) + f_2(x_{i2}) + \cdots + f_p(x_{ip}) + \varepsilon_i } \tag{7}\]

每个 \(f_j\)单变量非参数函数(通常用平滑样条实现)。

与普通线性回归的对比

特征 线性回归 GAM
单变量效应 \(\beta_j x_j\)(线性) \(f_j(x_j)\)(非线性)
可加性
交互效应 需手动添加 需手动添加
可解释性 系数 \(\beta_j\) 偏依赖图

关键假设——可加性

  • 各变量的效应独立相加,没有交互
  • 这是一个强假设,但保证了可解释性
  • 如需交互效应:可添加 \(f_{jk}(x_j, x_k)\) 项(二维平滑面)

GAM 与多元线性回归到底差在哪?

GAM 并不是把线性回归推翻,而是在保留’可加’结构的前提下,把直线替换成平滑曲线:

  • 线性回归:每个变量对应一个斜率
  • GAM:每个变量对应一条可学习的效应曲线
  • 两者都能拆解变量贡献,但 GAM 允许贡献随变量水平改变

因此,GAM 可以被看成多元线性回归的非线性升级版,而不是完全陌生的新物种。

拟合GAM的算法:反向拟合

反向拟合算法(Backfitting Algorithm):

  1. 初始化:\(\hat{f}_j \equiv 0\)\(j = 1, \ldots, p\)
  2. 循环直到收敛:
    • 对每个 \(j = 1, \ldots, p\)
      • 计算偏残差\(r_j = y - \hat{\beta}_0 - \sum_{k \neq j} \hat{f}_k(x_k)\)
      • 用平滑方法拟合 \(\hat{f}_j\)\(r_j \sim f_j(x_j)\)

直觉

  • 每次只关注一个变量
  • 把其他变量的效应”剥离”后,看剩余的模式
  • 反复迭代直到所有 \(\hat{f}_j\) 稳定

计算优势

  • \(p\) 维问题分解为 \(p\)一维问题
  • 每个一维问题用平滑样条求解 → 计算高效
  • 对于正态分布的响应变量,保证全局最优

反向拟合一轮到底先做什么再做什么?

把反向拟合压成“一轮操作清单”,顺序如下:

  1. 拿着上一轮的全部函数出发:先把当前的 \(\hat{f}_1, \ldots, \hat{f}_p\) 当作暂时有效的答案
  2. 挑一个变量 \(j\):暂时只更新这一条函数,其他函数先不动
  3. 先减掉别人解释掉的部分:计算偏残差 \(r_j = y - \hat{\beta}_0 - \sum_{k \neq j} \hat{f}_k(x_k)\)
  4. 只用 \(x_j\) 去拟合剩余部分:把 \(r_j\)\(x_j\) 做平滑,得到新的 \(\hat{f}_j\)
  5. 做中心化再继续:通常把新的 \(\hat{f}_j\) 调整到均值为 0,让截距吸收整体水平,避免“截距和函数项重复记账”
  6. 换下一个变量重复:把所有变量轮一遍算作一轮;若变化还明显,就继续下一轮,直到更新幅度很小为止

学生真正要记住的是:GAM 不是一次把所有曲线同时硬算出来,而是靠“减别人、拟自己、再轮换”慢慢收敛出来的。

反向拟合为什么有效?

反向拟合之所以漂亮,是因为它把一个复杂的多变量问题拆成了多个容易处理的一维更新:

  • 每次只修正一个变量的函数形状
  • 其他变量的贡献先临时固定
  • 多轮迭代后,各个函数逐渐协调到一起

这是一种’分而治之’的思想,也是 GAM 可计算的关键。

GAM的优势与局限

优势

  • 自动捕捉每个变量的非线性效应
  • 偏依赖图使每个变量的效应可视化
  • 可作为建模的第一步——快速探索数据模式
  • 计算效率高(Backfitting算法)

局限

  • 可加性假设限制了捕捉交互效应的能力
  • 变量间的交互需要手动指定(如张量积平滑)
  • 当交互效应很强时,GAM可能表现不佳
  • 高维情况下参数选择复杂

GAM在A股多因子选股中的应用

传统线性多因子模型

\[ r_i = \alpha + \beta_1 \cdot \text{PE}_i + \beta_2 \cdot \text{ROE}_i + \beta_3 \cdot \text{市值}_i + \varepsilon_i \]

GAM增强版

\[ r_i = \alpha + f_1(\text{PE}_i) + f_2(\text{ROE}_i) + f_3(\text{市值}_i) + \varepsilon_i \]

为什么GAM更适合?

  • PE效应非线性:PE极低可能是价值陷阱,PE适中有价值溢价
  • 规模效应非线性:小盘股和超大盘股的收益特征不同
  • ROE效应非线性:ROE过高可能不可持续

偏依赖图的威力

  • 可视化每个因子的非线性效应
  • 发现数据中的真实模式,而非强加线性假设
  • 是因子研究的重要探索性工具

sklearn实现GAM:海康威视多因子模型

构建三因子模型预测海康威视未来5日收益率:

  • 动量因子:过去20日累计收益率
  • 波动率因子:过去20日收益率标准差
  • 月份因子:捕捉A股日历效应
Listing 1: GAM多因子模型特征构造
from sklearn.compose import ColumnTransformer  # 导入列变换器用于混合特征处理
from sklearn.preprocessing import SplineTransformer, OneHotEncoder  # 样条变换和独热编码
from sklearn.linear_model import Ridge  # 岭回归模型
from sklearn.pipeline import Pipeline  # 模型管道
from sklearn.inspection import PartialDependenceDisplay  # 偏依赖图工具

# 构造因子特征
hikvision_data['Momentum'] = hikvision_data['close'].pct_change(20)  # 20日动量(累计收益率)
hikvision_data['Volatility'] = hikvision_data['close'].pct_change().rolling(20).std()  # 20日波动率
hikvision_data['Month'] = hikvision_data['trade_date'].dt.month  # 交易月份
hikvision_data['Future_Return'] = hikvision_data['close'].pct_change(5).shift(-5)  # 未来5日收益率(目标变量)

# 删除缺失值(滚动窗口和前瞻产生的NA)
gam_analysis_data = hikvision_data[['Momentum', 'Volatility', 'Month', 'Future_Return']].dropna()  # 提取并清洗数据

GAM模型拟合与评估

Listing 2: GAM模型拟合:SplineTransformer + Ridge
# 准备特征和目标
gam_feature_matrix = gam_analysis_data[['Momentum', 'Volatility', 'Month']]  # 三因子特征矩阵
gam_target = gam_analysis_data['Future_Return']  # 目标变量

# 定义混合特征处理器:连续变量用样条,类别变量用独热编码
preprocessor = ColumnTransformer([
    ('spline', SplineTransformer(n_knots=5, degree=3), ['Momentum', 'Volatility']),  # 对动量和波动率做三次样条(5个节点)
    ('onehot', OneHotEncoder(drop='first', sparse_output=False), ['Month'])  # 月份独热编码(去掉第一个避免多重共线性)
])

# 构建GAM管道:预处理 → 岭回归
gam_pipeline = Pipeline([
    ('preprocess', preprocessor),  # 特征预处理步骤
    ('ridge', Ridge(alpha=1.0))  # 带L2正则化的线性回归
])

gam_pipeline.fit(gam_feature_matrix, gam_target)  # 在全量数据上拟合GAM模型
r2_score = gam_pipeline.score(gam_feature_matrix, gam_target)  # 计算R²拟合优度
print(f'GAM模型 R² ≈ {r2_score:.4f}')  # 打印模型评估指标
GAM模型 R² ≈ 0.0290

偏依赖图:可视化非线性效应

Code
fig, axes = plt.subplots(1, 3, figsize=(14, 4))  # 创建1行3列子图

# 偏依赖展示:保持其他变量在训练集分布上平均化,展示单变量的边际效应
PartialDependenceDisplay.from_estimator(
    gam_pipeline,  # 训练好的GAM管道模型
    gam_feature_matrix,  # 训练数据
    features=['Momentum', 'Volatility', 'Month'],  # 要展示偏依赖的三个特征
    ax=axes,  # 绑制到预设的子图轴上
    subsample=50,  # 随机下采样50个样本以加速计算(注:生产环境建议≥200)
    grid_resolution=20  # 网格精度为20个点
)

plt.tight_layout()  # 自动调整布局
plt.show()  # 显示图表
Figure 11: GAM偏依赖图——三因子对未来收益率的边际效应

如何解读偏依赖图?

偏依赖图的三个关键元素

  1. 曲线形状
    • 单调递增 → 正效应(如ROE越高收益率越高)
    • U型 → 非线性效应(如波动率中等时最优)
    • 近似水平 → 该变量影响不大
  2. 置信带宽度
    • 宽 → 估计不确定性大(数据少或噪声大)
    • 窄 → 估计可靠
  3. y轴刻度
    • 反映该变量的经济显著性
    • y轴范围大 → 影响力强
    • y轴范围小 → 影响力弱

常见陷阱

  • 偏依赖图假设可加性 → 忽略了交互效应
  • 如果变量之间高度相关,偏依赖图可能误导
  • 需要配合交互效应分析使用

偏依赖图最容易被误读的三件事

偏依赖图很好用,但最常见的误读也集中在三点:

  1. 把相关性很强的变量当成彼此独立来解读
  2. 只看曲线形状,不看置信带和样本密度
  3. 把局部波动当成稳健的经济规律

所以,偏依赖图是解释工具,不是自动生成结论的机器。

逻辑回归GAM:预测涨跌方向

Listing 3: pygam逻辑回归GAM:预测涨跌方向
from pygam import LogisticGAM, s as s_gam, f  # 导入逻辑回归GAM、平滑项和因子项
from sklearn.metrics import accuracy_score  # 导入准确率评估函数

# 构造方向变量:未来5日收益率>0为上涨(1),否则为下跌(0)
gam_analysis_data = gam_analysis_data.copy()  # 创建副本避免SettingWithCopyWarning
gam_analysis_data['Direction'] = (gam_analysis_data['Future_Return'] > 0).astype(int)  # 构造二分类标签

# 准备特征矩阵(pygam需要numpy数组)
pygam_features = gam_analysis_data[['Momentum', 'Volatility', 'Month']].values  # 提取为numpy数组
direction_target = gam_analysis_data['Direction'].values  # 目标标签数组

# 拟合逻辑回归GAM
# s_gam(0): 对动量施加非线性平滑项
# s_gam(1): 对波动率施加非线性平滑项
# f(2): 对月份施加因子项(离散类别变量)
logistic_gam = LogisticGAM(s_gam(0) + s_gam(1) + f(2))  # 定义模型结构
logistic_gam.gridsearch(pygam_features, direction_target)  # 网格搜索最优平滑参数

# 评估模型
predicted_direction = logistic_gam.predict(pygam_features)  # 预测涨跌方向
accuracy = accuracy_score(direction_target, predicted_direction)  # 计算预测准确率
print(f'逻辑回归GAM准确率 ≈ {accuracy:.4f}')  # 打印准确率指标
print(logistic_gam.summary())  # 输出模型摘要(包含Pseudo R²、各项显著性)
逻辑回归GAM准确率 ≈ 0.5760
LogisticGAM                                                                                               
=============================================== ==========================================================
Distribution:                      BinomialDist Effective DoF:                                     35.6438
Link Function:                        LogitLink Log Likelihood:                                 -2526.1802
Number of Samples:                         3764 AIC:                                              5123.648
                                                AICc:                                            5124.3883
                                                UBRE:                                               3.3688
                                                Scale:                                                 1.0
                                                Pseudo R-Squared:                                   0.0316
==========================================================================================================
Feature Function                  Lambda               Rank         EDoF         P > x        Sig. Code   
================================= ==================== ============ ============ ============ ============
s(0)                              [0.2512]             20           12.7         1.57e-04     ***         
s(1)                              [0.2512]             20           12.1         2.27e-08     ***         
f(2)                              [0.2512]             12           10.9         3.97e-07     ***         
intercept                                              1            0.0          6.59e-03     **          
==========================================================================================================
Significance codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

WARNING: Fitting splines and a linear function to a feature introduces a model identifiability problem
         which can cause p-values to appear significant when they are not.

WARNING: p-values calculated in this manner behave correctly for un-penalized models or models with
         known smoothing parameters, but when smoothing parameters have been estimated, the p-values
         are typically lower than they should be, meaning that the tests reject the null too readily.
None

分类GAM与回归GAM的共同框架

无论是回归 GAM 还是分类 GAM,底层结构其实一致:

  • 都保留了可加的函数结构
  • 都把单变量效应写成可学习的平滑项
  • 区别主要在于响应变量分布与链接函数不同

也就是说,分类 GAM 不是一套新哲学,而是在同一框架里换了观测模型。

ANOVA检验:月份变量的最佳形式

月份变量应以哪种形式入模?我们通过三个嵌套模型的偏差分析来判断:

from pygam import l  # 导入线性项函数

# 模型1: 月份作为因子项 f(2)
gam_factor = LogisticGAM(s_gam(0) + s_gam(1) + f(2)).fit(pygam_features, direction_target)  # 拟合因子项模型
# 模型2: 月份作为线性项 l(2)
gam_linear = LogisticGAM(s_gam(0) + s_gam(1) + l(2)).fit(pygam_features, direction_target)  # 拟合线性项模型
# 模型3: 月份作为平滑项 s_gam(2)
gam_smooth = LogisticGAM(s_gam(0) + s_gam(1) + s_gam(2)).fit(pygam_features, direction_target)  # 拟合平滑项模型

# 提取各模型的Deviance和有效自由度
dev_factor = float(np.ravel(gam_factor.statistics_['deviance'])[0])  # 因子项模型偏差
dev_linear = float(np.ravel(gam_linear.statistics_['deviance'])[0])  # 线性项模型偏差
dev_smooth = float(np.ravel(gam_smooth.statistics_['deviance'])[0])  # 平滑项模型偏差

comparison_table = pd.DataFrame({
    '模型': ['f(Month) - 因子项', 'l(Month) - 线性项', 's(Month) - 平滑项'],
    'Deviance': [f'{dev_factor:.2f}', f'{dev_linear:.2f}', f'{dev_smooth:.2f}'],
})
comparison_table  # 输出比较表
Table 2: GAM模型的ANOVA偏差分析:月份变量的三种形式
模型 Deviance
0 f(Month) - 因子项 5059.64
1 l(Month) - 线性项 5111.74
2 s(Month) - 平滑项 5059.89

什么时候应该给 GAM 加交互项?

当你发现下面这些信号时,就应考虑在 GAM 中加入交互:

  • 单个变量的偏依赖图解释力很弱,但组合关系明显
  • 经济学上明确存在协同效应或替代效应
  • 残差中仍保留与变量组合有关的系统模式

不过,加交互会迅速提高模型复杂度,因此必须有明确理由。

非线性建模的主线是从单变量曲线到多变量结构

把整章压缩后,可以看到一条非常清晰的主线:

  • 先解决单变量下曲线怎样画得合理
  • 再解决多变量下曲线怎样组合后仍然可解释

从多项式到自然样条,再到 LOWESS 与 GAM,方法虽然不断变化,但始终围绕同一个核心张力:

模型需要足够灵活去贴近真实关系,同时又不能灵活到失去解释与泛化能力。

六种方法比较总结

方法 灵活性 可解释性 计算成本 最佳场景
多项式回归 简单非线性、短序列
阶梯函数 分段常数、分箱场景
回归样条 有节点先验知识
平滑样条 中-高 自动平滑、中等规模数据
局部回归 探索性分析、低维数据
GAMs 中-高 多变量非线性、需可解释性

课堂版结论:LOWESS 更适合先看形状,不适合作为正式可复用模型;一旦要进入稳定建模阶段,单变量优先自然样条,多变量优先 GAM。

默认落地顺序

  1. 先用 LOWESS 看形状,确认到底有没有明显非线性
  2. 单变量正式建模优先自然样条,少数存在硬阈值时再考虑阶梯函数
  3. 多变量且还要解释时优先 GAM,而不是直接跳到更黑箱的方法
  4. 只有在有明确节点先验或教学上要展示结构差异时,才专门回到回归样条或高阶多项式

方法选择的决策矩阵

根据具体需求选择最合适的方法

需求 推荐方法 理由
快速探索 LOWESS 零假设、纯数据驱动
单变量非线性 自然样条 灵活且稳健
多变量非线性 GAM 可加性 + 可解释
需要F检验 多项式 + ANOVA 有解析检验
阈值/分组效应 阶梯函数 直观易解释
全自动建模 平滑样条 / GAM 自动选参数

A股量化实践中的常见搭配

  1. 因子研究:先LOWESS探索 → GAM确认非线性 → 自然样条建模
  2. 高频预测:LOWESS做自适应趋势估计
  3. 信用风控:GAM做评分模型(可解释性要求高)

快速决策顺序:先问是单变量还是多变量,再问是否必须保留解释性,最后才问要不要追求更高灵活度。

最小路线图:如果你现在完全没把握,就先按’LOWESS 看形状 → 自然样条或 GAM 建基线 → 再比较是否值得升级复杂度’这三步走。

实践建议

  • 从简单开始:先尝试低阶多项式或基础样条

  • 交叉验证:始终用 CV 选择模型复杂度(阶数、节点数、\(\lambda\)

  • 检查边界:特别关注拟合曲线在数据边界处的行为

  • 多变量问题:优先考虑 GAMs,必要时加入交互项

  • 与线性基线比较:非线性模型不一定优于线性——用 CV 验证

  • 中国市场特殊性:A股日历效应和政策驱动使得非线性建模尤为重要

  • 不要过度解读曲线:偏依赖图和平滑曲线首先是描述关系形状,不自动等于因果结论

  • 正式汇报要留基线:无论最后选哪种非线性模型,都保留一个线性或低复杂度样条版本作对照

本章核心公式速查表

方法 模型公式 关键参数 先回答什么问题
多项式回归 \(y = \sum_{m=0}^d \beta_m x^m\) 阶数 \(d \leq 5\) 我只需要一条全局弯曲曲线吗?
阶梯函数 \(y = \sum_{k=0}^K \beta_k C_k(x)\) 切分点数 \(K\) 关系更像阈值跳变而不是连续弯曲吗?
回归样条 \(y = \sum_{j} \beta_j B_j(x)\) 节点 \(K\),阶数 我是否知道哪些位置值得放节点?
自然样条 边界线性的样条 自由度 df 我想要稳健的单变量正式模型吗?
平滑样条 \(\min \text{RSS} + \lambda\int f''^2\) \(\lambda\) 或 df\(_{\text{eff}}\) 我想把’弯多少’交给惩罚项自动控制吗?
LOWESS 加权局部回归 带宽 span 我现在是在探索形状,而不是定稿模型吗?
GAM \(y = \beta_0 + \sum_j f_j(x_j)\) 每个 \(f_j\) 的 df 我有多个变量,还希望逐项解释非线性吗?

选择优先级:自然样条 > GAM > 平滑样条 > 多项式 > 阶梯 > LOWESS(用于建模)

把这张表当目录用:先问自己想解决的是’全局弯曲、局部弯曲、阈值跳变,还是多变量可解释非线性’,再回来看对应公式和参数。