本章会用到的数据
公开下载:沪深 300 指数行情。代码会在首次运行时下载并保存到 data/course/。
这些数据能做什么:用指数价格、成交额和波动信息比较神经网络与简单基准。
分析时注意:按日期先后划分样本,标准化参数只根据较早数据计算。
判断模型:如果神经网络没有胜过简单模型,就保留简单模型。
【课堂核心】3 学时学习安排(1/2)
| 动机与先修 |
20 |
| 模型部件 |
25 |
| 前向、损失与反向 |
25 |
【课堂核心】3 学时学习安排(2/2)
| 示例数据说明 |
20 |
| 候选与最终测试 |
25 |
| 独立任务与反馈 |
40 |
| 最终测试检查 |
15 |
| 本章小结 |
10 |
【可选拓展】:万能近似定理细节、PyTorch 架构变体与额外手算题。
本章学习路线图
核心问题
- 传统的线性模型(如 OLS)在金融世界中是否足够?
- 当变量间的关系变得复杂、非线性时,我们如何构建更强大的预测模型?
- 如何让模型自动从数据中“学习”到复杂的模式,而不仅仅是执行我们预设的公式?
学习目标
通过本章学习,你将能够:
- 理论层面: 掌握人工神经网络 (ANN) 的核心概念,理解其如何通过层级结构和非线性变换来学习复杂模式。
- 模型层面: 区分回归与分类任务在神经网络中的不同实现,并理解反向传播的直觉含义。
- 实践层面: 熟练使用
scikit-learn 和 PyTorch 两大主流框架,从零开始构建、训练并评估一个用于解决金融预测问题的神经网络。
为何关注神经网络?
计量模型与神经网络都需要明确任务、信息集和验证设计;不少经典规格会预先限定函数形式(例如线性关系)。
神经网络是一类可学习表示的候选模型,能够在给定架构、损失与数据下拟合复杂的非线性预测关系。
这种灵活性也提高了过拟合和计算风险;是否产生增量预测价值,必须在目标任务上相对事先确定基线验证。
催化剂 1: 数据爆炸 (Big Data)
金融市场产生了海量的高频交易数据、财报文本、新闻情感、卫星图像等另类数据。
这些数据扩展了候选信息集,但有效样本量仍受时间依赖、标签稀缺、授权和数据质量限制;数据量本身不保证复杂模型胜出。
催化剂 2: 算力革命 (Computational Power)
神经网络训练的计算量随架构、批量、精度、数据流程与硬件而变;不能脱离具体任务给出统一历史耗时。
今天,GPU (图形处理器) 可加速适合并行的工作负载;完整流程耗时仍须在目标硬件、数据流程与模型配置上实测。
神经网络的金融应用
下列领域可以把神经网络列为候选模型;每项都须与任务匹配的线性、朴素或结构化基线同窗比较,不能预设普遍优越:
- 信用评分与违约预测: 检验申请人信息中的非线性预测关系,并检查校准与群体误差。
- 资产定价: 检验特征与未来收益之间的条件性预测关联;不把关联写成因果“驱动”。
- 算法交易: 在计入延迟、成本和市场冲击后检验高频模式是否仍有样本外增量。
- 波动率预测: 比较神经网络与时间序列基线对指定期限波动率目标的预测误差。
- 金融文本分析: 从有授权的新闻和财报构造候选文本信号,并在时间外窗口验证。
灵感源泉:生物神经元
神经网络的最初灵感来源于人脑的结构——一个由数十亿个相互连接的神经元组成的复杂网络。
映射:从生物到人工
这个简单的“接收-处理-传递”机制是构建复杂智能的基础。我们可以将它抽象成一个数学模型。
| 树突 (Dendrite) |
输入 (Inputs) \(x_i\) |
接收信息 |
| 细胞核 (Nucleus) |
处理单元 |
整合信息并决策 |
| 轴突 (Axon) |
输出 (Output) |
传递结果 |
通用人工神经元先计算得分,再选择输出规则
一个通用人工神经元执行两个核心步骤:
- 加权求和: 将所有输入特征 \(x_i\) 与其对应的权重 \(w_i\) 相乘,并加上一个偏置项 \(b\)。
- 激活: 将加权和的结果传入一个激活函数 \(g(\cdot)\),得到最终输出。
通用人工神经元的数学表达
\[
\large{
\underbrace{z = (w_1 x_1 + w_2 x_2 + \dots + w_n x_n) + b}_{\text{1. 加权求和}} = \mathbf{w}^T \mathbf{x} + b
}
\]
\[
\large{
\underbrace{\text{output} = g(z)}_{\text{2. 激活}} = g(\mathbf{w}^T \mathbf{x} + b)
}
\]
- 共同模板:先计算线性得分,再用指定输出规则。
- 逻辑单元/逻辑回归:Sigmoid 输出 + Bernoulli 对数似然。
- 经典感知器:阈值符号决策 + 感知器更新规则。
- 边界:二者共享线性得分,但不是同一模型。
经典感知器、逻辑单元与通用神经元不能混称
| 通用人工神经元 |
\(g(\mathbf{w}^T\mathbf{x}+b)\),\(g\) 与损失待指定 |
不能 |
| 经典感知器 |
阶跃或符号输出;错分样本驱动感知器更新 |
不能 |
| 逻辑单元 |
Sigmoid 输出;Bernoulli 对数似然或交叉熵 |
是模型概率分数;仍需留出校准检查 |
感知器的局限
一个单独的感知器只能解决线性可分的问题。
要解决更复杂的问题,我们需要将多个神经元组合起来,并引入非线性。
激活函数:赋予网络非线性
激活函数 (Activation Function) 是神经网络能够学习复杂模式的关键。它在加权求和之后引入了非线性变换。
它决定了一个神经元的输出,是神经网络的“节拍器”。
思想实验:没有非线性会怎样?
如果激活函数是线性的(例如,\(g(z)=z\)),那么无论你堆叠多少层神经网络,最终的结果都等同于一个单一的线性变换。
\[
\large{
\begin{aligned}
\text{Layer}_2(\text{Layer}_1(\mathbf{x})) &= (\mathbf{W}_2 (\mathbf{W}_1 \mathbf{x} + \mathbf{b}_1) + \mathbf{b}_2) \\
&= (\mathbf{W}_2 \mathbf{W}_1) \mathbf{x} + (\mathbf{W}_2 \mathbf{b}_1 + \mathbf{b}_2) \\
&= \mathbf{W}' \mathbf{x} + \mathbf{b}'
\end{aligned}
}
\]
一个复杂的、多层的线性模型仍然只是一个线性模型,无法捕捉现实世界中普遍存在的非线性关系。
常用激活函数(1): Sigmoid
Sigmoid 函数将任意实数输入压缩到 \((0, 1)\) 区间内。
\[
\large{
g(z) = \frac{1}{1 + e^{-z}}
}
\]
- 应用: 常用于二元分类输出层;在指定 Bernoulli 条件模型后可解释为模型概率分数,实际概率质量仍须在留出数据上检查校准。
- 缺点: 在隐藏层中已不常用。
Sigmoid的缺点:梯度消失
- 饱和区:输入绝对值很大时,Sigmoid 导数趋近 0。
- 链式累积:深度网络的多层小梯度相乘,可造成梯度消失(Vanishing Gradients)。
- 训练后果:较早层参数更新很小,训练可能极其缓慢。
常用激活函数(2): ReLU
ReLU (Rectified Linear Unit) 是目前深度学习中最常用的激活函数,形式极其简单。
\[
\large{
g(z) = \max(0, z)
}
\]
- 优点:
- 计算速度极快。
- 有效缓解了梯度消失问题(当 \(z > 0\) 时,梯度恒为1)。
ReLU的优点
当输入 \(z>0\) 时,ReLU是一个线性函数;当 \(z \le 0\) 时,输出为0。
这种分段线性的特性,使得多个ReLU神经元组合起来可以拟合出任意复杂的非线性函数。
可以把它想象成一个简单的“开关”:只有当输入信号足够强(大于0)时,它才允许信号通过。
激活函数可视化比较
从图中可以清晰地看到两种函数的区别。ReLU 的单侧不饱和特性是其在现代深度学习中占据主导地位的关键。
代码
import numpy as np # 生成激活函数网格并计算对应数值
# 生成 Sigmoid 与 ReLU 的同尺度 SVG 曲线,供本章比较饱和区与分段线性响应。
def generate_activations_svg():
# 建立 `z` 的有序取值网格,用于展示“Sigmoid 与 ReLU 激活函数的可视化比较”随参数变化的比较结果。
z = np.linspace(-6, 6, 300)
# 把线性得分映射为 `sigmoid` 概率曲线,展示 Sigmoid 的区间压缩作用。
sigmoid = 1 / (1 + np.exp(-z))
# 将负输入截断为零得到 ReLU 曲线,与平滑 Sigmoid 并排比较。
relu = np.maximum(0, z)
# 将数据点坐标转换为 SVG 路径字符串
# 把连续坐标编码为 SVG 路径 `sigmoid_path`,用于绘制“Sigmoid 与 ReLU 激活函数的可视化比较”的曲线。
sigmoid_path = "M " + " ".join([f"{80+(x+6)/12*280},{250-y*180}" for x, y in zip(z, sigmoid)])
# 把连续坐标编码为 SVG 路径 `relu_path`,用于绘制“Sigmoid 与 ReLU 激活函数的可视化比较”的曲线。
relu_path = "M " + " ".join([f"{440+(x+6)/12*280},{250-np.clip(y,0,6)/6*180}" for x, y in zip(z, relu)])
# 把两条激活函数路径嵌入完整 SVG 字符串,交给浏览器渲染。
svg = f'''
<svg viewBox="0 0 800 320" style="max-width: 100%; height: auto;">
<title>Sigmoid 与 ReLU 激活函数</title>
<desc>Sigmoid函数呈S形,将值压缩到0和1之间。ReLU函数在输入小于0时为0,大于0时呈线性增长。</desc>
<g id="bf10-activations">
<style>
#bf10-activations .axis {{ stroke: #3c4043; stroke-width: 2; }}
#bf10-activations .grid {{ stroke: #e0e0e0; stroke-width: 1; stroke-dasharray: 4,4; }}
#bf10-activations .label {{ font-family: "Source Han Serif SC", "Noto Serif CJK SC", serif; font-size: 20px; text-anchor: middle; fill: #3c4043; }}
#bf10-activations .title {{ font-size: 20px; font-weight: bold; }}
#bf10-activations .sigmoid-line {{ stroke: #4285F4; stroke-width: 3.5; fill: none; }}
#bf10-activations .relu-line {{ stroke: #EA4335; stroke-width: 3.5; fill: none; }}
</style>
<text x="220" y="38" class="label title">Sigmoid:两端饱和</text><text x="580" y="38" class="label title">ReLU:正半轴不饱和</text>
<line x1="80" y1="250" x2="360" y2="250" class="axis"/><line x1="220" y1="70" x2="220" y2="270" class="axis"/>
<line x1="440" y1="250" x2="720" y2="250" class="axis"/><line x1="580" y1="70" x2="580" y2="270" class="axis"/>
<text x="365" y="270" class="label">z</text><text x="725" y="270" class="label">z</text>
<!-- Curves -->
<path d="{sigmoid_path}" class="sigmoid-line"/>
<path d="{relu_path}" class="relu-line"/>
</g>
</svg>
'''
# 返回 `svg`,把“激活函数可视化比较”的计算结果交给调用方。
return svg
from IPython.display import HTML, display # 让 Jupyter 将字符串解析为内联 HTML/SVG DOM
display(HTML(generate_activations_svg())) # 在成品中显示真实 SVG DOM 而非原始 HTML 文本
组建网络:神经元层
一个单独的神经元能力有限。神经网络的强大之处在于将许多神经元组织成层 (Layers)。
一个标准的前馈神经网络 (Feedforward Neural Network) 通常由三部分组成。
隐藏层 (Hidden Layers)
- 功能: 模型的核心,负责从输入数据中提取和转换特征。
- 结构: 可以有一个或多个隐藏层。层数越多,网络越“深”(Deep)。
- 计算: 每一层的神经元接收前一层所有神经元的输出,进行加权求和与激活,然后将结果传递给下一层。
输出层 (Output Layer)
- 功能: 产生最终的预测结果。
- 结构: 输出节点的数量和激活函数取决于具体任务。
- 回归问题: 通常是1个节点,使用线性激活函数(即不激活)。
- 二元分类: 通常是1个节点,使用 Sigmoid 激活函数。
- 多元分类: 通常是 N 个节点(N为类别数),使用 Softmax 激活函数。
神经网络结构图
一个包含4个输入特征、2个隐藏层(每层5个神经元)和1个输出的神经网络。
前向传播:信息在网络中的流动
前向传播 (Forward Propagation) 是指信息从输入层开始,逐层向前流动,直到计算出输出层结果的过程。
它回答了这个问题:“对于给定的输入 \(\mathbf{x}\) 和当前的网络参数 \((\mathbf{W}, \mathbf{b})\),模型的预测结果是什么?”
前向传播的数学表达
符号约定:
- \(\mathbf{x}\): 输入特征向量。
- \(\mathbf{a}^{(l)}\): 第 \(l\) 层的激活输出向量(\(\mathbf{a}^{(0)} = \mathbf{x}\))。
- \(\mathbf{W}^{(l)}\): 从第 \(l-1\) 层到第 \(l\) 层的权重矩阵。
- \(\mathbf{b}^{(l)}\): 第 \(l\) 层的偏置向量。
从层 \(l-1\) 到层 \(l\) 的计算:
- 加权和: \(\large{ \mathbf{z}^{(l)} = \mathbf{W}^{(l)} \mathbf{a}^{(l-1)} + \mathbf{b}^{(l)} }\)
- 激活: \(\large{ \mathbf{a}^{(l)} = g(\mathbf{z}^{(l)}) }\)
这个过程从第一层开始,逐层重复,直到计算出最后一层(输出层)的结果 \(\hat{y} = \mathbf{a}^{(L)}\)。
万能近似定理只保证表示能力
定理的表示性结论
- 目标:紧致集 \(K\subset\mathbb{R}^d\) 上的连续函数 \(f\in C(K)\)。
- 常用充分条件:激活函数连续且非多项式。
- 存在性:对任意 \(\varepsilon>0\),存在有限宽度单隐层网络 \(g\),使 \(\lVert f-g\rVert_\infty<\varepsilon\)。
存在性不等于可训练性
定理的直观理解
- 每个使用 ReLU 激活函数的神经元可以被看作一个“开关”,它在某个点上改变函数的斜率,像一个“铰链”。
- 通过组合足够多的这种“铰链”,我们可以构建出任意形状的分段线性函数。
- 这就像用足够多的短直线段来逼近一条任意复杂的曲线。
这只说明函数类具有表示能力;是否学到有用模式仍须由训练算法、样本、正则化与最终测试共同验证。
万能近似定理的可视化
下图只是 ReLU 分段线性逼近的有限示意,不是优化收敛或样本外泛化的证明。
训练目标:最小化代价函数
- 度量:代价/损失函数 \(J(\mathbf{W}, \mathbf{b})\) 量化预测值与真实值的差距。
- 参数:权重 \(\mathbf{W}\) 与偏置 \(\mathbf{b}\)。
- 训练目标:寻找使 \(J\) 尽可能小的参数。
代价函数:回归任务
对于预测连续值(如股价、GDP增长率)的回归问题,最常用的是均方误差 (Mean Squared Error, MSE)。
\[
\large{
J(\mathbf{W}, \mathbf{b}) = \frac{1}{n} \sum_{i=1}^{n} (\hat{y}^{(i)} - y^{(i)})^2
}
\]
其中 \(\hat{y}^{(i)}\) 是网络对第 \(i\) 个样本的预测值,\(y^{(i)}\) 是真实值。
代价函数:分类任务
对于预测离散类别(如违约/不违约)的分类问题,常用的是交叉熵 (Cross-Entropy)。
\[
\large{
J(\mathbf{W}, \mathbf{b}) = -\frac{1}{n} \sum_{i=1}^{n} [y^{(i)} \log(\hat{y}^{(i)}) + (1 - y^{(i)}) \log(1 - \hat{y}^{(i)})]
}
\]
它衡量的是两个概率分布(模型的预测概率和真实的标签概率)之间的差异。
如何寻找最优参数?
- 规模:\(J(\mathbf{W}, \mathbf{b})\) 可以涉及数百万个参数。
- 几何:通常为复杂非凸函数,存在多个局部极小值。
- 困难:通常无法用解析方法直接找到全局最小值。
代码
import numpy as np # 生成一维参数网格并计算非凸代价
# 生成含两个局部极小值的非凸损失 SVG,解释初始化为何可能落入不同盆地。
def generate_nonconvex_svg():
# 建立 `x` 的有序取值网格,用于展示“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”随参数变化的比较结果。
x = np.linspace(-4.5, 3.5, 200)
# 在参数网格上计算四次非凸代价 `y`,形成含两个局部谷底的曲线。
y = 0.1*x**4 + 0.2*x**3 - 2*x**2 - 2*x + 10
# 把数值缩放到 SVG 视图框坐标
# 将数值映射为画布坐标 `x_svg`,使“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”按统一尺度绘制。
x_svg = (x + 4.5) / 8 * 600 + 100
# 将数值映射为画布坐标 `y_svg`,使“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”按统一尺度绘制。
y_svg = 280 - (y - np.min(y)) / (np.max(y) - np.min(y)) * 220
# 把连续坐标编码为 SVG 路径 `path_data`,用于绘制“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”的曲线。
path_data = "M " + " ".join([f"{px:.2f},{py:.2f}" for px, py in zip(x_svg, y_svg)])
# 计算两个局部极小值点
# 在 x=-3.2 处代入非凸函数,取得左侧局部极小值坐标。
min1_x, min1_y = -3.2, 0.1*(-3.2)**4 + 0.2*(-3.2)**3 - 2*(-3.2)**2 - 2*(-3.2) + 10
# 在 x=2.0 处代入同一函数,取得右侧局部极小值坐标。
min2_x, min2_y = 2.0, 0.1*(2.0)**4 + 0.2*(2.0)**3 - 2*(2.0)**2 - 2*(2.0) + 10
# 将数值映射为画布坐标 `min1_x_svg`,使“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”按统一尺度绘制。
min1_x_svg = (-3.2 + 4.5) / 8 * 600 + 100
# 将数值映射为画布坐标 `min1_y_svg`,使“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”按统一尺度绘制。
min1_y_svg = 280 - (min1_y - np.min(y)) / (np.max(y) - np.min(y)) * 220
# 将数值映射为画布坐标 `min2_x_svg`,使“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”按统一尺度绘制。
min2_x_svg = (2.0 + 4.5) / 8 * 600 + 100
# 将数值映射为画布坐标 `min2_y_svg`,使“神经网络代价函数是一个复杂的非凸函数,存在多个局部最小值。”按统一尺度绘制。
min2_y_svg = 280 - (min2_y - np.min(y)) / (np.max(y) - np.min(y)) * 220
# 把非凸损失曲线与两个局部极小值坐标写入完整 SVG。
svg = f'''
<svg viewBox="0 0 800 320" style="max-width: 100%; height: auto;">
<title>非凸代价函数</title>
<desc>一条具有多个局部最小值的曲线,说明了优化问题的复杂性。</desc>
<g id="bf10-nonconvex">
<style>
#bf10-nonconvex .curve {{ fill: none; stroke: #4285F4; stroke-width: 3.5; }}
#bf10-nonconvex .axis {{ stroke: #3c4043; stroke-width: 2; }}
#bf10-nonconvex .label {{ font-family: "Source Han Serif SC", "Noto Serif CJK SC", serif; font-size: 20px; text-anchor: middle; fill: #3c4043; }}
#bf10-nonconvex .title {{ font-size: 20px; font-weight: bold; }}
#bf10-nonconvex .min-point {{ fill: #EA4335; }}
#bf10-nonconvex .min-label {{ fill: #c0392b; font-size: 20px; }}
#bf10-nonconvex .global-min-label {{ fill: #16713A; font-weight: bold; }}
</style>
<text x="400" y="40" class="label title">非凸代价函数的地形</text>
<!-- Axes -->
<line x1="80" y1="280" x2="720" y2="280" class="axis" />
<line x1="80" y1="50" x2="80" y2="280" class="axis" />
<text x="400" y="305" class="label">参数值 (例如某个 w)</text>
<text x="50" y="165" transform="rotate(-90, 50, 165)" class="label">代价 J(w)</text>
<!-- Curve -->
<path d="{path_data}" class="curve"/>
<!-- Minima -->
<circle cx="{min1_x_svg}" cy="{min1_y_svg}" r="7" class="min-point"/>
<text x="{min1_x_svg}" y="{min1_y_svg + 25}" class="label min-label">局部最小值</text>
<circle cx="{min2_x_svg}" cy="{min2_y_svg}" r="7" class="min-point"/>
<text x="{min2_x_svg}" y="{min2_y_svg + 25}" class="label min-label">局部最小值</text>
<text x="{min2_x_svg}" y="{min2_y_svg - 20}" class="label global-min-label">全局最小值</text>
<path d="M {min2_x_svg} {min2_y_svg - 15} L {min2_x_svg} {min2_y_svg-5}" stroke="#16713A" stroke-width="2"/>
</g>
</svg>
'''
# 返回 `svg`,把“如何寻找最优参数?”的计算结果交给调用方。
return svg
display(HTML(generate_nonconvex_svg())) # 将非凸损失曲线作为内联 SVG DOM 输出
梯度下降法的直观比喻
解决方案:梯度下降法 (Gradient Descent)
想象你身处一座大山的浓雾之中,想要走到谷底。
- 你看不清整个地形。
- 但你可以感知脚下哪个方向是下坡最陡的。
- 你不断沿着这个最陡峭的方向迈出一小步。
- 重复这个过程,最终就能到达一个山谷的底部。
在数学上,这个“最陡峭的下坡方向”就是代价函数梯度的负方向 \((-\nabla J)\)。
梯度下降法的更新规则
梯度下降是一个迭代过程。在每一步,我们都按照以下规则更新每一个权重 \(w\) 和偏置 \(b\):
\[
\large{
w := w - \alpha \frac{\partial J}{\partial w}
}
\]
\[
\large{
b := b - \alpha \frac{\partial J}{\partial b}
}
\]
- \(\alpha\) 是学习率 (Learning Rate),一个超参数,控制我们每一步“走”多远。
- \(\frac{\partial J}{\partial w}\) 是代价函数对具体某个权重的偏导数,即梯度。
关键超参数:学习率 \(\alpha\)
学习率的选择至关重要,它决定了训练的效率和稳定性。
核心挑战:高效计算梯度
直接对神经网络的代价函数求导是一个极其繁琐且计算量巨大的任务,因为网络结构是深度嵌套的。
\[
\large{
J(\mathbf{W}, \mathbf{b}) = \frac{1}{n} \sum_{i=1}^n (y^{(i)} - g^{(L)}( \mathbf{W}^{(L)} g^{(L-1)}(\dots) + \mathbf{b}^{(L)}))^2
}
\]
如果网络有数百万个参数,逐个计算偏导数是不可行的。
解决方案:反向传播算法
反向传播 (Backpropagation) 不是一个新的优化算法,它仅仅是一种非常高效地计算所有梯度的算法。
其本质是巧妙地应用微积分中的链式法则,从输出层开始,逐层向后计算梯度。
反向传播的直觉
就像一个公司的绩效评估,从最终的利润(总误差)出发,逐级追溯到每个部门、每个员工(每一层、每个神经元)的绩效贡献。
反向传播的数学概览
我们在此不进行严格的数学推导,但展示其核心思想:链式法则。
- 计算代价函数对输出层激活值的梯度 \(\frac{\partial J}{\partial a^{(L)}}\)。
- 利用链式法则,计算对输出层加权和的梯度:\(\large{\frac{\partial J}{\partial z^{(L)}} = \frac{\partial J}{\partial a^{(L)}} \frac{\partial a^{(L)}}{\partial z^{(L)}}}\)。
- 继续反向传播,计算对前一层激活值的梯度:\(\large{\frac{\partial J}{\partial a^{(L-1)}} = \frac{\partial J}{\partial z^{(L)}} \frac{\partial z^{(L)}}{\partial a^{(L-1)}}}\)。
- 重复此过程,直到计算出对所有权重 \(\mathbf{W}^{(l)}\) 和偏置 \(\mathbf{b}^{(l)}\) 的梯度。
现代深度学习框架如 PyTorch 会为我们自动完成这一切。
训练效率:梯度下降的变种
标准的梯度下降在每次更新前需要计算整个训练集的梯度,当数据集很大时,这会非常缓慢。因此,实践中我们使用其变种。
批量梯度下降 (BGD)
- 数据使用: 整个训练集
- 优点:
- 代价函数下降路径平滑。
- 在光滑性、合适步长等条件下可使目标下降并趋近驻点;非凸网络中不保证局部或全局最优。
- \(\nabla J=0\) 只说明驻点;例如 \(J(x)=x^3\) 的 \(x=0\) 不是最小值。
- 缺点:
- 速度非常慢。
- 内存需求大,无法处理超大规模数据集。
随机梯度下降 (SGD)
- 数据使用: 每次只用 1 个随机样本
- 优点:
- 缺点:
- 代价函数下降路径非常嘈杂、震荡。
- 收敛速度慢,可能永远不会精确收敛。
小批量梯度下降 (Mini-batch GD)
- 数据使用: 每次用一小批样本 (e.g., 32, 64, 128)
- 优点:
- 兼具 BGD 和 SGD 的优点。
- 通过矩阵运算充分利用 GPU 的并行计算能力。
- 收敛路径比 SGD 更平滑。
- 这是现代深度学习常用的梯度计算方式;具体优化器与训练注意事项仍需验证。
关键挑战:过拟合 (Overfitting)
由于神经网络模型极其灵活(参数众多),它们很容易在训练数据上“死记硬背”,导致对训练集表现极好,但对未见过的新数据(测试集)表现很差。
识别过拟合:训练与验证损失
在训练过程中,我们同时监控模型在训练集和验证集上的损失。一个典型的过拟合迹象是:训练损失持续下降,而验证损失在某个点后开始上升。
对抗过拟合(1): 正则化
正则化 (Regularization) 通过在开发期目标函数中加入惩罚,改变拟合解的偏差—方差权衡。
L2 对权重平方施加惩罚;\(\lambda\) 增大通常会收缩权重。
收缩可能降低估计方差,也可能增加偏差并导致欠拟合;是否改善样本外误差必须通过开发期验证。
L2 正则化 (权重衰减)
在代价函数后加入所有权重平方和的项。这与我们在线性回归中学过的岭回归 (Ridge Regression) 思想完全相同。
\[
\large{
J_{reg}(\mathbf{W}, \mathbf{b}) = \underbrace{J(\mathbf{W}, \mathbf{b})}_{\text{原始损失}} + \underbrace{\frac{\lambda}{2n} \sum_{l} \sum_{j} \sum_{k} (w_{jk}^{(l)})^2}_{\text{惩罚项}}
}
\]
- \(\lambda\) 是正则化参数,控制惩罚强度;增大它通常使权重向 0 收缩,但不保证改善泛化。
- \(\lambda\) 只在开发期训练—验证流程中选择;最终测试期不得参与选择。
对抗过拟合(2): 提前终止 (Early Stopping)
这是一个非常简单但极其有效的正则化方法。
- 在训练时,持续监控验证集上的损失。
- 当发现验证集上的损失不再下降,甚至开始上升时,就立即停止训练。
- 保存并使用验证损失最小时的模型参数。
对抗过拟合(3): 随机失活 (Dropout)
Dropout 是一个非常巧妙且强大的正则化技术,专门为神经网络设计。
- 工作原理: 在每次训练迭代中,以某个概率 \(p\)(例如 \(p=0.5\))随机地“关闭” 隐藏层中的一些神经元。
- 直观效果:
- 强迫网络不能过度依赖任何一个神经元。
- 鼓励网络学习到更鲁棒、更具冗余性的特征。
- 可以被看作是一种高效地训练大量不同网络架构的集成模型的方法。
Dropout 的工作原理
在每次训练迭代中,网络都在使用一个“残缺”的子网络进行学习。
实践环节:进入代码世界
理论讲完了,现在是时候动手了。
我们将使用一个真实的金融数据集来解决一个回归问题,并使用两个业界最主流的 Python 库来构建我们的模型。
我们的任务:预测沪深 300 下一交易日收益
- 数据:公开
index/hs300_index_only.h5 中的沪深 300 日行情。
- 特征时点:当日收益、振幅和成交额变化可观测后。
- 预测目标:下一交易日收益。
- 用途边界:展示神经网络流程,不构成可交易策略。
输入特征 (X):
目标变量 (y):
这是一个典型的时间序列回归问题。
数据来源:公开沪深 300 数据
- 下载:公开沪深 300 数据。
- HDF5 键:
hs300。
- 本地缓存:首次运行后保存到本章
data/course/ 目录。
下载不需要账号或 API 密钥。行情字段只在收盘后可得,因此预测时点定义为 t 日收盘后,目标为 t+1 日收盘收益。
Step 1:读取公开指数数据
第一步只读取沪深 300 所需字段,并按日期排序。
代码
# 为“Step 1:读取公开指数数据”导入 `pandas` 并绑定 `pd`,用于整理当前任务的表格、字段与时间索引。
import pandas as pd
# 为“Step 1:读取公开指数数据”导入 `numpy` 并绑定 `np`,用于执行当前任务的数组、数值或随机机制计算。
import numpy as np
from pathlib import Path # 管理下载后的课程数据路径
from urllib.request import urlretrieve # 复用本章隐藏设置单元安装的浏览器标识下载器
# 按 Linux 共享数据、Windows 共享数据、项目缓存的顺序选择指数行情文件。
index_data_path = next((candidate_path for candidate_path in [Path('/home/ubuntu/r2_data_mount/data/index/hs300_index_only.h5'), Path('C:/qiufei/data/index/hs300_index_only.h5'), Path('data/course/hs300_index_only.h5')] if candidate_path.exists()), Path('data/course/hs300_index_only.h5'))
if not index_data_path.exists():
index_data_path.parent.mkdir(parents=True, exist_ok=True)
urlretrieve('https://assets.qiufei.site/data/index/hs300_index_only.h5', index_data_path)
raw_df = pd.read_hdf(index_data_path, key='hs300')[['datetime', 'close', 'high', 'low', 'total_turnover']] # 只保留建模字段
raw_df['date'] = pd.to_datetime(raw_df['datetime'].astype(str), format='%Y%m%d%H%M%S') # 将交易时间转换为可排序日期
raw_df = raw_df.sort_values('date').set_index('date') # 按真实交易顺序建立时间索引
print(raw_df.head()) # 核对公开数据版本的字段与时间顺序
datetime close high low total_turnover
date
2005-01-04 20050104000000 982.79 994.77 980.66 4.431977e+09
2005-01-05 20050105000000 992.56 997.32 979.88 4.529208e+09
2005-01-06 20050106000000 983.17 993.79 980.33 3.921015e+09
2005-01-07 20050107000000 983.96 995.71 979.81 4.737469e+09
2005-01-10 20050110000000 993.88 993.96 979.79 3.762932e+09
Step 2:按信息时点构造特征
所有特征均在 t 日收盘后可知,目标通过向前移动一日构造。
代码
df_model = raw_df.copy() # 保留原始数据版本以便检查特征构造
df_model['market_return'] = df_model['close'].pct_change() # 计算 t 日收盘收益率
df_model['intraday_range'] = (df_model['high'] - df_model['low']) / df_model['close'] # 度量 t 日振幅
df_model['turnover_change'] = df_model['total_turnover'].pct_change() # 度量 t 日成交额变化
df_model['next_return'] = df_model['market_return'].shift(-1) # 将 t+1 日收益设为预测目标
df_model = df_model.replace([np.inf, -np.inf], np.nan).dropna() # 删除比率计算产生的无效观测
feature_names = ['market_return', 'intraday_range', 'turnover_change'] # 固定可检查的特征顺序
X = df_model[feature_names] # 构建特征矩阵
y = df_model['next_return'] # 构建下一交易日目标
print(X.head()) # 核对预测时点可得特征
print(y.head()) # 核对目标与特征错开一日
market_return intraday_range turnover_change
date
2005-01-05 0.009941 0.017571 0.021938
2005-01-06 -0.009460 0.013690 -0.134282
2005-01-07 0.000804 0.016159 0.208225
2005-01-10 0.010082 0.014257 -0.205708
2005-01-11 0.003280 0.008484 -0.015641
date
2005-01-05 -0.009460
2005-01-06 0.000804
2005-01-07 0.010082
2005-01-10 0.003280
2005-01-11 -0.000391
Name: next_return, dtype: float64
Step 3:先最终测试期,再划分架构训练/验证窗
- 架构训练窗(64%):拟合候选架构与 scaler。
- 架构验证窗(16%):只用于选择架构。
- 最终测试窗(20%):始终封存;选择完成前不创建测试张量、不计算测试指标。
代码
development_end = int(len(X) * 0.8) # 确定开发期与最终测试期边界
X_development = X.iloc[:development_end].copy() # 保留前80%作为开发期
y_development = y.iloc[:development_end].copy() # 对齐开发期目标
X_test = X.iloc[development_end:].copy() # 最终测试最后20%的特征,选择完成前不读取其结果
y_test = y.iloc[development_end:].copy() # 最终测试最后20%的目标
architecture_end = int(len(X_development) * 0.8) # 在开发期内部确定架构验证边界
X_architecture_train = X_development.iloc[:architecture_end].copy() # 较早窗口拟合候选架构
y_architecture_train = y_development.iloc[:architecture_end].copy() # 对齐架构训练目标
X_architecture_validation = X_development.iloc[architecture_end:].copy() # 较晚开发窗口只用于架构选择
y_architecture_validation = y_development.iloc[architecture_end:].copy() # 对齐架构验证目标
print({'架构训练期': (X_architecture_train.index.min().date(), X_architecture_train.index.max().date()), '架构验证期': (X_architecture_validation.index.min().date(), X_architecture_validation.index.max().date()), '最终测试期': (X_test.index.min().date(), X_test.index.max().date())}) # 报告三段时间边界
{'架构训练期': (datetime.date(2005, 1, 5), datetime.date(2018, 6, 28)), '架构验证期': (datetime.date(2018, 6, 29), datetime.date(2021, 11, 11)), '最终测试期': (datetime.date(2021, 11, 12), datetime.date(2026, 1, 29))}
Step 4:scaler 只在架构训练窗拟合
代码
from sklearn.preprocessing import StandardScaler # 为神经网络建立只由架构训练样本逐特征拟合的缩放器
architecture_scaler = StandardScaler() # 创建候选比较专用的逐特征缩放器
X_architecture_train_scaled = architecture_scaler.fit_transform(X_architecture_train) # 只从较早架构训练样本逐特征估计均值与标准差
X_architecture_validation_scaled = architecture_scaler.transform(X_architecture_validation) # 用确定的训练样本逐特征统计量转换较晚验证窗
print({'scaler拟合样本': len(X_architecture_train_scaled), '验证样本': len(X_architecture_validation_scaled), '测试状态': '仍最终测试'}) # 明确选择阶段未打开测试期
{'scaler拟合样本': 3276, '验证样本': 819, '测试状态': '仍最终测试'}
标准化、架构选择和任何早停判断都只能使用前两段。最终架构确定后,才允许在完整开发期重新拟合 scaler 与模型。
路线图:Scikit-learn vs. PyTorch
我们将使用两个库来构建模型,以对比它们的异同。
Scikit-learn
- 特点: 高度封装,API简洁。
- 优点: 上手快,几行代码就能构建标准模型。
- 用途: 快速原型验证,基准模型搭建。
PyTorch
- 特点: 灵活,更底层。
- 优点: 可定义复杂结构、支持 GPU 张量计算;完整流程收益依任务实测。
- 用途: 学术研究,工业级深度学习应用。
Scikit-learn: 快速原型验证
scikit-learn 中的 MLPRegressor 让我们可以用几行代码就构建一个用于回归任务的多层感知器(即标准神经网络)。
Scikit-learn:事先确定完整候选、基准与暂不采用门槛
- MLP 候选:只含下列三个 sklearn MLP。
- 排除项:PyTorch 段仅演示训练机制,不进入选择集合。
- 朴素候选:零收益与训练均值基准。
- 不采用门槛:若三个 MLP 都未胜最佳朴素基准,则确定基准并宣告
no-deploy。
代码
from sklearn.metrics import mean_squared_error # 统一计算逐观测均方误差
from sklearn.neural_network import MLPRegressor # 构建可复算的多层感知器候选
candidate_architectures = {'小型': (16,), '中型': (32, 16), '深型': (10, 20, 20, 10)} # 事先确定三种网络容量
candidate_models = {} # 保存每个训练完成的候选模型
validation_mse_by_architecture = {} # 保存同一验证窗上的选择指标
for architecture_name, hidden_layers in candidate_architectures.items(): # 对事先确定候选使用相同训练预算
candidate_model = MLPRegressor(hidden_layer_sizes=hidden_layers, activation='relu', random_state=42, max_iter=500) # 固定随机种子与优化预算
candidate_model.fit(X_architecture_train_scaled, y_architecture_train) # 仅在较早架构训练窗拟合
validation_predictions = candidate_model.predict(X_architecture_validation_scaled) # 只生成较晚开发窗预测
candidate_models[architecture_name] = candidate_model # 保留候选供训练曲线检查
validation_mse_by_architecture[architecture_name] = mean_squared_error(y_architecture_validation, validation_predictions) # 计算同窗验证MSE
validation_baseline_mse = {'零收益基准': mean_squared_error(y_architecture_validation, np.zeros(len(y_architecture_validation))), '训练均值基准': mean_squared_error(y_architecture_validation, np.full(len(y_architecture_validation), y_architecture_train.mean()))} # 事先确定两个同级朴素候选
selected_architecture_name = min(validation_mse_by_architecture, key=validation_mse_by_architecture.get) # 找到 MLP 集合内部的验证赢家
selected_hidden_layers = candidate_architectures[selected_architecture_name] # 确定后续重训所需层宽
nn_sklearn = candidate_models[selected_architecture_name] # 指向已确定候选以展示训练曲线
best_baseline_name = min(validation_baseline_mse, key=validation_baseline_mse.get) # 确定验证期表现最佳的朴素基准
is_mlp_deployable = validation_mse_by_architecture[selected_architecture_name] < validation_baseline_mse[best_baseline_name] # 执行事先确定增量价值门槛
frozen_decision = f'MLP:{selected_architecture_name}' if is_mlp_deployable else f'保留简单模型:{best_baseline_name}' # 在打开测试期前确定实际使用或暂不采用决策
print({'MLP候选': validation_mse_by_architecture, '朴素候选': validation_baseline_mse}) # 完整报告选择集合与基准
print({'确定决策': frozen_decision, '隐藏层': selected_hidden_layers if is_mlp_deployable else None, '测试状态': '仍最终测试'}) # 记录门槛结果
{'MLP候选': {'小型': 0.0018348890437432461, '中型': 0.0005827779758484771, '深型': 0.0002814556857201893}, '朴素候选': {'零收益基准': 0.00018048537504390547, '训练均值基准': 0.00018024293670566174}}
{'确定决策': '保留简单模型:训练均值基准', '隐藏层': None, '测试状态': '仍最终测试'}
Scikit-learn:验证期性能
代码
selected_validation_predictions = nn_sklearn.predict(X_architecture_validation_scaled) # 复算确定候选的验证预测
selected_validation_mse = mean_squared_error(y_architecture_validation, selected_validation_predictions) # 报告选择阶段指标
print(f'确定候选的验证 MSE: {selected_validation_mse:.8f}') # 避免把验证结果称为最终测试证据
Scikit-learn: 训练过程可视化
- 记录对象:
MLPRegressor.loss_curve_ 保存每轮优化的训练目标。
- 当前缩放:
loss='squared_error' 时,数据拟合项按半均方平方误差缩放,并叠加配置的 L2 惩罚边界。
- 不可替代:它不是另算的验证或最终测试 MSE。
代码
# 将同一坐标说明封装成可复用的损失曲线 SVG。
def generate_loss_curve_svg(loss_curve, title, color):
# 固定 `epochs` 的训练轮数,界定本次教学运行的计算预算。
epochs = len(loss_curve)
# 取得损失序列上界,把数值归一到 SVG 画布的纵向范围。
max_loss = max(loss_curve)
# 取得损失序列下界,与上界共同定义 SVG 坐标变换。
min_loss = min(loss_curve)
# 将数值映射为画布坐标 `x_coords`,使“Scikit-learn MLPRegressor 的训练损失曲线”按统一尺度绘制。
x_coords = [100 + (i / (epochs - 1)) * 600 for i in range(epochs)]
# 将数值映射为画布坐标 `y_coords`,使“Scikit-learn MLPRegressor 的训练损失曲线”按统一尺度绘制。
y_coords = [280 - ((loss - min_loss) / (max_loss - min_loss + 1e-9)) * 220 for loss in loss_curve]
# 把连续坐标编码为 SVG 路径 `path_data`,用于绘制“Scikit-learn MLPRegressor 的训练损失曲线”的曲线。
path_data = "M " + " ".join([f"{x:.2f},{y:.2f}" for x, y in zip(x_coords, y_coords)])
# 把逐轮损失路径写入 SVG,显示训练误差随 epoch 的下降比较结果。
svg = f'''
<svg viewBox="0 0 800 320" style="max-width: 100%; height: auto;">
<title>{title}</title>
<desc>显示训练过程中损失函数值随迭代次数变化的曲线。</desc>
<g class="bf10-loss">
<style>
.bf10-loss .axis {{ stroke: #3c4043; stroke-width: 2; }}
.bf10-loss .grid {{ stroke: #e0e0e0; stroke-width: 1; stroke-dasharray: 4,4; }}
.bf10-loss .label {{ font-family: "Source Han Serif SC", "Noto Serif CJK SC", serif; font-size: 20px; text-anchor: middle; fill: #3c4043; }}
.bf10-loss .title {{ font-size: 20px; font-weight: bold; }}
.bf10-loss .loss-curve {{ stroke: {color}; stroke-width: 3; fill: none; }}
</style>
<text x="400" y="35" class="label title">{title}</text>
<!-- Axes -->
<line x1="100" y1="280" x2="700" y2="280" class="axis"/>
<line x1="100" y1="50" x2="100" y2="280" class="axis"/>
<text x="400" y="305" class="label">训练迭代次数 (Epochs)</text>
<text x="60" y="165" transform="rotate(-90, 60, 165)" class="label">训练目标(半平方误差 + L2)</text>
<!-- Curve -->
<path d="{path_data}" class="loss-curve" />
</g>
</svg>
'''
# 返回 `svg`,把“Scikit-learn: 训练过程可视化”的计算结果交给调用方。
return svg
# 将 MLP 的逐轮 `loss_curve_` 转成内联 SVG 字符串,供下一行嵌入幻灯片。
sklearn_svg = generate_loss_curve_svg(nn_sklearn.loss_curve_, 'Scikit-learn 神经网络训练损失下降情况', '#4285F4')
display(HTML(sklearn_svg)) # 将训练损失曲线作为内联 SVG DOM 输出
深入探索:为何需要PyTorch?
scikit-learn 非常方便,但对于严肃的深度学习任务,PyTorch 是更专业的选择:
- 灵活性:
PyTorch 允许我们定义任意复杂的、非标准的网络结构(如 RNN, Transformers)。
- GPU 计算:
PyTorch 支持把适合并行的张量运算放到 GPU;收益依赖硬件、模型/批量、精度、数据搬运、数据流程、内核与实现,只比较同一任务的实测吞吐和耗时。
- 自动求导: 其核心
autograd 引擎可以自动计算任何复杂函数的梯度,是反向传播的强大实现。
- 生态系统: 它是现代深度学习研究和应用的核心框架,拥有庞大的社区和丰富的工具库。
PyTorch 实践(1):只准备架构训练与验证数据
PyTorch 实现复用同一时间边界和训练窗 scaler;此处不创建测试张量。
代码
import torch # 使用张量与自动求导实现神经网络
from torch.utils.data import DataLoader, TensorDataset # 管理训练和验证小批量
X_train_tensor = torch.tensor(X_architecture_train_scaled.astype(np.float32)) # 转换较早架构训练特征
y_train_tensor = torch.tensor(y_architecture_train.to_numpy(dtype=np.float32)).view(-1, 1) # 对齐架构训练目标
X_validation_tensor = torch.tensor(X_architecture_validation_scaled.astype(np.float32)) # 转换较晚架构验证特征
y_validation_tensor = torch.tensor(y_architecture_validation.to_numpy(dtype=np.float32)).view(-1, 1) # 对齐架构验证目标
train_data = TensorDataset(X_train_tensor, y_train_tensor) # 建立架构训练数据集
validation_data = TensorDataset(X_validation_tensor, y_validation_tensor) # 建立只读验证数据集
batch_size = 16 # 确定课堂演示的小批评分标准模
train_loader = DataLoader(train_data, batch_size=batch_size, shuffle=True) # 仅在训练窗内部打乱小批量
validation_loader = DataLoader(validation_data, batch_size=batch_size, shuffle=False) # 保持验证窗时间顺序
print({'PyTorch训练样本': len(train_data), 'PyTorch验证样本': len(validation_data), '测试张量': '未创建'}) # 核对隔离状态
{'PyTorch训练样本': 3276, 'PyTorch验证样本': 819, '测试张量': '未创建'}
理解 DataLoader
DataLoader 是 PyTorch 中一个极其重要的效率工具。它会自动为我们完成:
- 批量处理 (Batching): 将数据集打包成我们设定大小 (batch_size) 的小批量。
- 数据打乱 (Shuffling): 在每个训练轮次开始时随机打乱数据,这有助于模型训练和泛化。
- 并行加载:
num_workers>0 等配置可尝试让数据准备与训练重叠,但不保证 GPU 不空闲,须结合吞吐剖析调整。
PyTorch 实践(2): 定义网络结构
在 PyTorch 中,我们通过创建一个继承自 torch.nn.Module 的类来定义神经网络。
代码
# 导入 PyTorch 神经网络接口并绑定为 `nn`,用于定义线性层、激活函数与模块基类。
import torch.nn as nn
# 定义 `IndexReturnPredictorNN`,封装本节使用的模型结构。
class IndexReturnPredictorNN(nn.Module):
# 按输入特征数搭建两层隐藏表示和单输出回归头,确定本页网络拓扑。
def __init__(self, input_features=3):
# 调用父类初始化方法,为当前模型建立 `nn.Module` 基础状态。
super(IndexReturnPredictorNN, self).__init__()
# 设置 network,明确其在神经网络收益预测中的用途并保留复算入口。
self.network = nn.Sequential(
# 将 3 项输入映射到 64 个隐层单元,并用 ReLU 引入非线性。
nn.Linear(input_features, 64), nn.ReLU(),
# 将 64 个单元扩展到 128 个单元,增加网络表达容量。
nn.Linear(64, 128), nn.ReLU(),
# 训练时随机屏蔽 20% 隐层激活,缓解网络对训练样本的过度依赖。
nn.Dropout(0.2),
# 将 128 个单元压缩到 64 个单元,形成预测前的紧凑表示。
nn.Linear(128, 64), nn.ReLU(),
# 将 64 维表示映射为单一连续收益预测值。
nn.Linear(64, 1)
# 完成 `self.network` 的顺序层定义,使前向传播按上述层次依次执行。
)
# 规定批次特征依次经过隐藏层与 ReLU,再输出连续收益预测值。
def forward(self, x):
# 返回 `self.network(x)`,把“PyTorch 实践(2): 定义网络结构”的计算结果交给调用方。
return self.network(x)
# 选择可用的 CPU/GPU 设备 `device`,使张量与网络位于同一计算环境。
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 打印本机选择的 `cpu` 或 `cuda` 设备,明确后续张量和模型应放置的训练设备。
print(f'将使用 {device} 设备进行训练')
torch.manual_seed(42) # 固定 CPU 训练的参数初始化以便复算
if torch.cuda.is_available(): # 仅在 GPU 可用时固定其随机状态
torch.cuda.manual_seed_all(42) # 固定 GPU 训练的参数初始化
model = IndexReturnPredictorNN().to(device) # 建立沪深 300 下一日收益预测网络
# 输出网络结构,核对各线性层维度、ReLU 与 Dropout 的排列。
print(model)
将使用 cpu 设备进行训练
IndexReturnPredictorNN(
(network): Sequential(
(0): Linear(in_features=3, out_features=64, bias=True)
(1): ReLU()
(2): Linear(in_features=64, out_features=128, bias=True)
(3): ReLU()
(4): Dropout(p=0.2, inplace=False)
(5): Linear(in_features=128, out_features=64, bias=True)
(6): ReLU()
(7): Linear(in_features=64, out_features=1, bias=True)
)
)
PyTorch 实践(3): 定义损失与优化器
我们还需要明确两件事:
- 损失函数 (Criterion): 如何衡量预测的好坏。对于回归问题,我们使用均方误差
nn.MSELoss。
- 优化器 (Optimizer): 使用哪种梯度下降算法来更新权重。我们使用
Adam,一种非常流行且高效的自适应学习率优化算法。
代码
# 把均方误差设为 `criterion`,使网络训练直接最小化收益预测偏差。
criterion = nn.MSELoss()
# 配置 `optimizer` 更新网络权重,固定优化算法与学习率。
optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=1e-5)
PyTorch 实践(4): 训练循环的核心
这是 PyTorch 训练的核心。对于每一个训练轮次 (epoch),我们对数据中的每一个小批量 (mini-batch) 执行以下五个步骤:
- 清空梯度 (
optimizer.zero_grad())
- 前向传播
- 计算损失
- 反向传播 (
loss.backward())
- 更新权重 (
optimizer.step())
训练循环:代码详解
代码
# 固定 `num_epochs` 的训练轮数,界定本次教学运行的计算预算。
num_epochs = 200
# 初始化逐轮训练损失历史;此时尚未执行任何优化步骤。
train_losses = []
# 将 `model` 切换到训练模式。
model.train()
# 遍历 `range(num_epochs)` 的候选或观测,为“训练循环:代码详解”逐项更新结果。
for epoch in range(num_epochs):
# 将本轮批次损失累积器清零,避免不同 epoch 相互混入。
epoch_loss = 0.0
# 遍历 `train_loader` 的候选或观测,为“训练循环:代码详解”逐项更新结果。
for inputs, targets in train_loader:
# 将当前批次特征与目标同时移到模型所在设备,避免跨设备运算错误。
inputs, targets = inputs.to(device), targets.to(device)
# 清空优化器中上一批次累积的参数梯度。
optimizer.zero_grad()
# 前向传播当前批次,得到连续收益预测值用于损失计算。
outputs = model(inputs)
# 计算 `loss`(损失),量化“训练循环:代码详解”的模型表现。
loss = criterion(outputs, targets)
# 从当前损失反向传播并计算参数梯度。
loss.backward()
# 根据当前梯度更新优化器管理的参数。
optimizer.step()
# 提取当前批次损失的 Python 标量,用于累计整轮训练损失。
epoch_loss += loss.item()
# 计算 `avg_epoch_loss`(损失),量化“训练循环:代码详解”的模型表现。
avg_epoch_loss = epoch_loss / len(train_loader)
# 把 `avg_epoch_loss` 加入 `train_losses`,保留“训练循环:代码详解”本次循环的实际结果。
train_losses.append(avg_epoch_loss)
# 每完成 20 个训练轮次打印一次损失,控制课堂输出频率。
if (epoch + 1) % 20 == 0:
# 每 20 轮报告当前轮次与平均训练损失,观察优化是否收敛。
print(f'Epoch [{epoch+1}/{num_epochs}], Loss: {avg_epoch_loss:.4f}')
Epoch [20/200], Loss: 0.0003
Epoch [40/200], Loss: 0.0003
Epoch [60/200], Loss: 0.0003
Epoch [80/200], Loss: 0.0003
Epoch [100/200], Loss: 0.0003
Epoch [120/200], Loss: 0.0003
Epoch [140/200], Loss: 0.0003
Epoch [160/200], Loss: 0.0003
Epoch [180/200], Loss: 0.0003
Epoch [200/200], Loss: 0.0003
PyTorch 实践(5): 训练过程可视化
我们可以像之前一样,绘制训练过程中的损失曲线。
代码
pytorch_svg = generate_loss_curve_svg(train_losses, 'PyTorch 神经网络训练损失下降情况', '#EA4335') # 把已记录训练损失转成可投影曲线
display(HTML(pytorch_svg)) # 将 PyTorch 损失曲线作为内联 SVG DOM 输出
PyTorch 实践(6):只评估架构验证窗
该实现用于教学对照,不参与最终测试期的重复窥视。
代码
model.eval() # 关闭 Dropout 并进入验证模式
pytorch_validation_batches = [] # 收集较晚开发窗的逐批预测
with torch.no_grad(): # 验证阶段不构建梯度图
for validation_inputs, validation_targets in validation_loader: # 只遍历架构验证窗
validation_outputs = model(validation_inputs.to(device)) # 生成同窗验证预测
pytorch_validation_batches.append(validation_outputs.cpu().numpy().ravel()) # 移回CPU统一计量
pytorch_validation_predictions = np.concatenate(pytorch_validation_batches) # 按原顺序拼接验证预测
pytorch_validation_mse = mean_squared_error(y_architecture_validation, pytorch_validation_predictions) # 计算PyTorch验证MSE
print(f'PyTorch 教学实现验证 MSE: {pytorch_validation_mse:.8f}') # 明确该数字不是测试结果
PyTorch 教学实现验证 MSE: 0.00018213
结果对比:选择阶段只看验证窗
代码
validation_zero_mse = mean_squared_error(y_architecture_validation, np.zeros(len(y_architecture_validation))) # 计算零收益验证基准
validation_mean_mse = mean_squared_error(y_architecture_validation, np.full(len(y_architecture_validation), y_architecture_train.mean())) # 计算训练均值验证基准
validation_comparison = pd.DataFrame({'模型': [*[f'Sklearn {name}' for name in candidate_architectures], '零收益基准', '训练均值基准', 'PyTorch教学实现(不参与选择)'], '验证MSE': [*[validation_mse_by_architecture[name] for name in candidate_architectures], validation_zero_mse, validation_mean_mse, pytorch_validation_mse]}) # 展示完整候选、基准与排除项
display(validation_comparison.sort_values('验证MSE').reset_index(drop=True).round(8)) # 展示选择阶段完整比较
确定后重训,并只打开一次测试期
- 重训规则:只有 MLP 胜过最佳朴素基准,才重训确定架构。
- 失败路径:否则执行
no-deploy,用确定基准进入一次性测试检查。
- 决策隔离:测试结果不得推翻验证期决策。
代码
final_scaler = StandardScaler() # 为最终重训创建全新的逐特征缩放器
X_development_scaled = final_scaler.fit_transform(X_development) # 只用完整开发样本逐特征重新估计均值与标准差
X_test_scaled = final_scaler.transform(X_test) # 用确定的开发样本逐特征统计量首次转换最终测试特征
if is_mlp_deployable: # 只有通过基准门槛才允许重训复杂模型
final_model = MLPRegressor(hidden_layer_sizes=selected_hidden_layers, activation='relu', random_state=2026, max_iter=500).fit(X_development_scaled, y_development) # 从新种子重训确定架构
sealed_test_predictions = final_model.predict(X_test_scaled) # 对最终测试期生成一次 MLP 预测
else: # 验证期未胜基准时执行暂不采用规则
sealed_test_predictions = np.zeros(len(y_test)) if best_baseline_name == '零收益基准' else np.full(len(y_test), y_development.mean()) # 使用确定朴素基准检查测试期
sealed_test_mse = mean_squared_error(y_test, sealed_test_predictions) # 计算唯一一次模型测试指标
zero_test_mse = mean_squared_error(y_test, np.zeros(len(y_test))) # 计算事先确定零收益测试基准
mean_test_mse = mean_squared_error(y_test, np.full(len(y_test), y_development.mean())) # 计算开发期均值测试基准
sealed_test_table = pd.DataFrame({'模型': [frozen_decision, '零收益基准', '开发期均值基准'], '最终测试MSE': [sealed_test_mse, zero_test_mse, mean_test_mse]}) # 汇总确定决策的一次性检查
display(sealed_test_table.round(8)) # 如实展示模型是否胜过朴素基准
结论边界
- 选择数据:架构选择只使用中间验证窗。
- 正式决策:若验证期没有 MLP 胜基准,则“暂不采用 MLP”。
- 测试用途:最终测试表只做一次最终检查,不产生新的调参决策。
- 禁止反馈:测试结果无论高低,都不得回写候选集合或门槛。
阶段小结
- 神经网络通过堆叠简单的计算单元和非线性激活函数,能够学习极其复杂的函数关系。
- 训练过程通过梯度下降和反向传播算法,迭代地调整模型参数以最小化代价函数。
- 过拟合是使用神经网络时的主要挑战,可以通过正则化、Dropout和提前终止等方法来缓解。
- 我们掌握了使用
scikit-learn 和 PyTorch 这两个工具来解决实际金融预测问题的能力。
关键概念回顾
- 感知器
- 激活函数 (ReLU, Sigmoid)
- 前向传播
- 反向传播
- 代价函数 (MSE)
- 梯度下降
- 学习率
- 过拟合
- 正则化 (L2, Dropout)
未来方向
前馈神经网络是基础。金融领域还广泛应用其他更专业的网络结构:
- 循环神经网络 (RNNs) / Transformers: 用于处理时间序列数据(如股价预测)和文本数据(如财报分析)。
- 卷积神经网络 (CNNs): 用于处理图像数据(如利用卫星图像预测经济活动)。
独立任务:网络结构与验证边界
独立提交八项证据,前面的示例结果只作接口示范,不能替代学生的图、手算与检查:
画出 2-3-2-3-1 网络,隐层为 ReLU、输出为线性,并标出每层第一个神经元函数。
前向传播手算:2-2-2-1 网络,\((x_1,x_2)=(1,1)\)。
- 隐层 1:权重行 \((0.5,-2),(-1.2,-2.7)\);偏置 \((0.06,-2.94)\)。
- 隐层 2:权重行 \((3,-4),(1.1,-0.93)\);偏置 \((5.5,-0.8)\)。
- 输出层:权重 \((1.8,0.1)\);偏置 4.2;激活为 Sigmoid。
区分训练目标(含正则项时须写明)与同一验证窗 MSE,不能把训练损失当泛化证据。
报告 candidate_architectures 的全部验证 MSE 比较结果,不只报赢家。
- 同窗报告零收益与训练均值两个朴素基线;复杂模型必须严格胜最佳基线才可采用。
- 证明候选期 scaler 只拟合较早 64% 训练窗;确定后 scaler 从头拟合完整 80% 开发期。
- 在开测试前确定“架构/基线 + 从头重训规则”;未胜基线时写
保留简单模型。
- 写出结果不理想时如何解释:全样本预缩放、按测试 MSE 改架构、沿用候选权重追加训练,任一出现即请重做。
欠拟合诊断
- 证据条件:训练误差仍高,且同口径验证误差也高。
- 开发期处置:事先确定增加宽度/深度、降低正则或延长训练预算,然后重新比较完整验证结果。
- 禁止捷径:不得仅凭“拟合不佳”直接增加复杂度。
练习:先完成再查看答案:网络候选先最终测试
提交上述八项:结构/函数、前向手算、欠拟合与验证规则、完整架构比较结果、两个基线、折内 scaler、确定/保留简单模型 决策、结果不理想时如何解释。八项齐全后才开放反馈。
独立任务反馈
- 结构/函数:
2-3-2-3-1,隐层首元为 ReLU 仿射式,输出线性。
- 手算:两层隐向量为 \((0,0)\)、\((5.5,0)\);\(z_O=14.1\),Sigmoid \(approx0.99999925\)。
- 欠拟合:训练与验证误差均高;只在开发期改复杂度、正则或预算。
- 目标/比较结果:训练目标可含正则;验证 MSE 折外计分;提交完整
validation_mse_by_architecture。
- 两个基线:同窗零收益与训练均值;复杂候选须严格胜最佳基线。
- scaler:候选期只拟合 64%;确定后从头拟合 80% 开发期。
- 确定:测试只开一次;未胜基线执行
保留简单模型。
- 失败:全样本预缩放、测试改选或候选权重续训,任一即请重做。
完成要求
- 分数门槛:至少 7/8;scaler、确定和结果不理想时如何解释三项不得失分。
- 机制错误:回看 前向传播 与 反向传播。
- 证据不全:回看 候选与基线 与 验证结果 后重做。
- 以下页面:结构图、计算页与规则页是反馈展开,不是新任务。
结构与函数反馈展开
- 网络结构图与函数:
- 改善欠拟合的方法 (增加模型复杂度):
- 增加网络宽度: 增加每个隐藏层中的神经元数量。
- 增加网络深度: 增加更多的隐藏层。
- 减少正则化: 如果使用了正则化,可以减小正则化参数 \(\lambda\) 或降低 Dropout 的比率。
- 训练更长时间: 确保模型有足够的时间来收敛。
前向手算反馈:题目口径
下图展开独立任务第 2 项的同一组参数,用于核对提交,不新增需要完成的内容。
习题2: 网络结构与参数
习题2: 计算步骤
Step 1: 计算第一隐藏层的输出 (ReLU)
- 神经元 a₁₁:
- \(z_{11} = b_{11} + w_{11,1}x_1 + w_{11,2}x_2 = 0.06 + 0.5(1) + (-2)(1) = -1.44\)
- \(a_{11} = \text{ReLU}(-1.44) = 0\)
- 神经元 a₁₂:
- \(z_{12} = b_{12} + w_{12,1}x_1 + w_{12,2}x_2 = -2.94 + (-1.2)(1) + (-2.7)(1) = -6.84\)
- \(a_{12} = \text{ReLU}(-6.84) = 0\)
习题2: 最终解答
Step 2: 计算第二隐藏层的输出 (ReLU)
- 神经元 a₂₁:
- \(z_{21} = b_{21} + w_{21,1}a_{11} + w_{21,2}a_{12} = 5.5 + 3(0) + (-4)(0) = 5.5\)
- \(a_{21} = \text{ReLU}(5.5) = 5.5\)
- 神经元 a₂₂:
- \(z_{22} = b_{22} + w_{22,1}a_{11} + w_{22,2}a_{12} = -0.8 + 1.1(0) + (-0.93)(0) = -0.8\)
- \(a_{22} = \text{ReLU}(-0.8) = 0\)
Step 3: 计算输出层的最终输出 (Sigmoid)
- 神经元 O:
- \(z_{O} = b_{O} + w_{O,1}a_{21} + w_{O,2}a_{22} = 4.2 + 1.8(5.5) + 0.1(0) = 14.1\)
- \(\text{Output} = \text{Sigmoid}(14.1) = \large{\frac{1}{1 + e^{-14.1}}} \approx 0.99999925 \approx 1.0\)
验证规则反馈展开
核对独立任务第 6—8 项:为什么不能在测试 MSE 最低后再改架构,以及 scaler 的两次合法拟合各使用什么数据。
反馈:架构确定在测试开启之前
完整答案
- 测试用途:只承担一次最终检查;若据测试 MSE 改选模型,测试集就退化为验证集,产生选择偏差。
- 候选阶段:
architecture_scaler 只在较早 64% 样本拟合,转换中间 16% 验证窗。
- 确定后重训:
final_scaler 从头在完整 80% 开发期拟合,再转换最后 20% 最终测试期。
- 时间边界:两次拟合服务不同阶段,均未读取对应未来窗口的统计量。
答案提示
- 未通过:出现“对全样本先标准化”、“比较测试集后选最优网络”或“沿用候选权重追加训练”。
- 必须写出:训练—验证—测试的时间顺序、模型选择完成时点、全开发期从头重训与一次性测试。
未通过者回看 相关内容 的候选期与重训边界;通过者直接进入 相关内容 完成本章小结。
本章小结
- 能写出前向、损失与反向梯度链,并复现开发期候选和一次最终测试。
- 万能近似只保证紧致域上连续函数的表示存在,不保证宽度、训练、收敛或泛化。
- 若开发窗 MLP 未胜基线,结论是暂不采用,而不是读取测试后改架构。
- 下一章进入解释工具,检查预测归因而不越界为因果。