算法选择 - 机器学习 - DL深度学习 - CV - NLP 选择一个算法是为了解决一类问题,而DL算法通常包括以下三部分 或者说,将解决某个问题的算法,拆分为三部分 - 模型:网络参数结构定义 - 损失函数:衡量模型输出与数据标签的分布差异,值越小越好 - 优化器:调整模型的参数的方法 模型只是定义了参数/参数结构 怎么调整,尤其是如何自动化地调整模型的参数, 使得模型的输出与数据标签的分布越来越接近才是关键 损失函数与优化器配合,完成了参数的自动化调整 - 梯度下降法,偏导的正负就是下降的方向 - 没错,正负这个方向,比数值本身更重要 - 它为每个参数指明了下降的方向 - w = w - a*w.grad - a是一个小数,小于1, - w.grad是“损失函数”在参数w处的偏导 - 损失函数关于某个参数的偏导函数,如果有极小值 - 那么 w = w - a*w.grad 会慢慢使损失函数逼近这个极小值 - w.grad为正,w减小,小到一定程度, - 比如,在函数曲线上越过了极小值点,变大了,那么w.grad会变为负值 - 此时的w = w - a*w.grad就会变大 - 损失函数取得极小值时的位置,w就在这个位置附近振荡 - 极小值,是最低位置,w.grad为0,w并不一定为0 - 损失函数极小值,也不一定只有一个,可能会有多个 - 极小,是相对一定范围的w来说的,可能其他范围也有极小 - 就比如水坑,湖泊,河流,都有一个低极点,其偏导为0,但极点则有很多
从整个DL算法的解决来看,损失函数是入口,
输入的是模型与标签,
输出的是分布差异,
优化器是辅助工具,它负责更新损失函数中模型的参数
损失函数的结果是一个数值,随着训练,它的值可能是1000,800,500,300,100,80,... 下降是我们期望的,即逐渐降低,变小, 自变量x数轴,右为负无穷,左为正无穷,从右到左,x逐渐变大 即随着x的变大,损失函数变小 损失函数值降低的过程,即对应x处的导数为负数, 损失函数值上升的过程,即对应x处的导数为正数, x = x - a*x.grad 会向一个极小值附近靠拢 当导数为正,即x越过极小值时,x会变小,还会折回来, 然后就是在极小值附近来回移动... 但这个极小值不一定就是0值, 即模型所能表述的规律,很难与真实的数据标签的分布 完全相符 最好的结果就是:表象在规律附近振荡...
|
数据加载,预处理
import torch
import os
from tpf import stp
from tpf import pkl_save,pkl_load
from tpf.params import TPF_DATADIR
def load_boston(split=True,test_size=0.15, reload=False):
"""房价(回归问题),后续默认加载首次生成的文件
X,y = load_boston(split=False)
X_train, y_train, X_test, y_test = load_boston(split=True,test_size=0.15)
print(type(X)) # class 'numpy.ndarray'
print(X.shape) # (506, 13)
print(y.shape) # (506,)
"""
if split and (not reload):
tmp_path = os.path.join(TPF_DATADIR,"fangjia_boston_split.pkl")
else:
tmp_path = os.path.join(TPF_DATADIR,"fangjia_boston.pkl")
if os.path.exists(tmp_path):
return pkl_load(tmp_path)
data_url = "http://lib.stat.cmu.edu/datasets/boston"
raw_df = pd.read_csv(data_url, sep="\s+", skiprows=22, header=None)
# print(raw_df.info())
data = np.hstack([raw_df.values[::2, :], raw_df.values[1::2, :2]])
target = raw_df.values[1::2, 2]
if split:
X_train, X_test, y_train, y_test = train_test_split(data, target, test_size=test_size, random_state=73)
stp(X_train,"X_train")
stp(y_train,"y_train")
pkl_save((X_train,y_train, X_test, y_test),file_path=tmp_path)
return X_train,y_train, X_test, y_test
else:
stp(data, "X")
stp(target,"y")
pkl_save((data,target),file_path=tmp_path)
return data,target
X_train,y_train, X_test, y_test = load_boston()
stp(X_test) #shape:(76, 13), type:class 'numpy.ndarray'
# -------------------------------------------------
# 数据预处理
# -------------------------------------------------
def get_data():
X_train,y_train, X_test, y_test = load_boston()
# 从训练集中提取参数
mean_ = X_train.mean(axis=0) # 按列方向取均值,
std_ = X_train.std(axis=0) # 相同特征的一列数据归一
# 预处理训练集和测试集
X_train1 = (X_train - mean_) / std_
X_test1 = (X_test - mean_) / std_
# 共享内存
X_train = torch.from_numpy(X_train1).float()
X_test = torch.from_numpy(X_test1).to(dtype=torch.float32)
# 标签转列向量
y_train = torch.from_numpy(y_train.reshape((-1, 1))).float()
y_test = torch.from_numpy(y_test.reshape((-1, 1))).float()
return X_train,y_train, X_test, y_test
X_train,y_train, X_test, y_test = get_data()
# from tpf import stp
# stp(y_test,"y_test") # shape:torch.Size([76, 1]), type:class 'torch.Tensor', y_test
# print(y_test.dtype) # torch.float32
模型
# -------------------------------------------------
# 模型构造
# -------------------------------------------------
class LinearRegression(object):
"""模型构造:线性回归
"""
loss = None
def __init__(self, device=None,in_feature=13) -> None:
if device:
self.device = device
else:
# 若存在GPU则使用GPU
self.device = "cuda:0" if torch.cuda.is_available() else "cpu"
# 定义模型的参数
self.w = torch.randn(in_feature, 1, requires_grad=True, dtype=torch.float32, device=device)
self.b = torch.randn(1, requires_grad=True, dtype=torch.float32, device=device)
# print(b) # tensor([0.1403], requires_grad=True)
# stp(b,"b") # shape:torch.Size([1]), type:class 'torch.Tensor', b
def forward(self, X):
return X@self.w + self.b
# -------------------------------------------------
# 损失函数设计
# -------------------------------------------------
@classmethod
def loss_fn(cls, y_pred, y_true):
cls.loss = ((y_pred - y_true) ** 2).mean()
return cls.loss
def grad_reduce(self, learning_rate):
"""优化方法:梯度下降法
一种优化方法,让模型输出不断接近真实标签的方法
梯度指导数
"""
# 使用导数优化更新参数
self.w.data -= learning_rate * self.w.grad.data
self.b.data -= learning_rate * self.b.grad.data
# 清空梯度,以防止不断累加
self.w.grad.data.zero_()
self.b.grad.data.zero_()
def print_loss(self):
print(self.loss.item())
def train(self, X,y, learning_rate, loss_fn=None):
"""训练
每训练一次,就优化一次参数
"""
# 正向传播
y_pred = self.forward(X=X)
# print("y pred",y_pred[1],"y label",y_train[1])
# 模型预测与真实标签间的差异
if loss_fn:
loss = loss_fn(y_pred, y)
else:
loss = self.loss_fn(y_pred=y_pred, y_true=y_train)
# 反向传播,逆向求偏导grad
loss.backward()
model.grad_reduce(learning_rate=learning_rate)
def predict(self, X):
"""
模型预测
"""
with torch.no_grad():
y_pred = self.forward(X=X)
return y_pred
def batch_train(model, X, y, learning_rate=1e-2, epochs=300):
"""训练
梯度下降法求函数极值
"""
for epoch in range(epochs):
model.train(X=X,y=y, learning_rate=learning_rate)
# 过程监控
model.print_loss()
训练
# 若存在GPU则使用GPU
device = "cuda:0" if torch.cuda.is_available() else "cpu"
X_train.to(device=device)
y_train.to(device=device)
X_test.to(device=device)
y_test.to(device=device)
model = LinearRegression()
batch_train(model=model,X=X_train,y=y_train)
预测
y_pred = model.predict(X=X_test)
print(y_pred[:5])
损失
model.print_loss()
23.729873657226562
|
数据处理,正向传播,损失函数,优化器,训练,预测... 这些步骤中,理解难度大在的地方在于两点:正向传播, 优化器 正向传播
def forward(self, X):
return X@self.w + self.b
这涉及线性代数,也有微分的思想,
大概意思就是,众人拾柴火焰高,人多力量大,勤能补拙...
以众多局部的直,去模拟实际的曲,
提一句微分,
把一件复杂的事,拆分成多个简单的成分,这就是微分,
从多个简单的角度去描述一件复杂的事物,这就是积分!
就是这么简单...只是有人把这种思想以数学的形式,精准地描述出来了!
难理解就是难理解,实现中,不只是普通人,
神经网络刚出来的时候,许多的学者,教授都不理解,不看好,认为不可能...
本人在学到这个时,连连惊奇并研究了好几个月... 没错,本人并不聪明,一个简单的神经网络研究了好几个月!!!
X是常量,虽然每次可以输入不同的X,即不同的样本,但它还是常量 说它是常量,是因为X在整个计算过程中不变,就是一个常量“矩阵” X是矩阵,是矩阵,是矩阵,w也是矩阵,也是矩阵,也是矩阵
但w是变量,是变化的,
对于不同的X,w是相同的,代表了数据集的共性提取方法
任务的目的,就在于,对于某个数据集,
找到一个参数矩阵/参数网络,
它能够将常量X(即样本),映射到标签的维度上
优化器
def grad_reduce(self, learning_rate):
"""优化方法:梯度下降法
一种优化方法,让模型输出不断接近真实标签的方法
梯度指导数
"""
# 使用导数优化更新参数
self.w.data -= learning_rate * self.w.grad.data
self.b.data -= learning_rate * self.b.grad.data
# 清空梯度,以防止不断累加
self.w.grad.data.zero_()
self.b.grad.data.zero_()
如果说前面的Ax=b是复杂问题简单化/线性化,是简单粗暴的微分描述,
那么
w = w - learning_rate*w.grad
则是用规则/公式 去直接描述变化/变化趋势
也有微分的思想,因为这里的w指的是众多参数的中每一个
这个公式的关键在于,
通过“损失函数”梯度的正负特性,建立参数/变量w与函数极小值之间的关系
效果是
我们想找到模型输出与标签分布之间的最小差值,即损失函数的极小值,
那么可以通过公式w = w - learning_rate*w.grad 来调整参数w实现
w = w - learning_rate*w.grad
这公式赋予了神经网络自动化的能力,给予了神经网络超越机器学习的底气,
在深度学习发展,具有里程碑的意义!
|
|
|
|
|
|
|
|
|
**RNN 隐藏状态递推**(BPTT = Backpropagation Through Time,沿时间反向传播):
$$h_t = \tanh(W_h h_{t-1} + W_x x_t + b)$$
**BPTT 反传 = 雅可比矩阵的连乘**:对 $t-k$ 时刻求梯度时,链式法则要求穿越 $k$ 个时间步:
$$\frac{\partial h_t}{\partial h_{t-k}} = \prod_{i=t-k+1}^{t} W_h^\top \mathrm{diag}(\tanh'(\cdot))$$
设每步雅可比的谱范数上界为 $\gamma$,则梯度范数满足:
$$\left\lVert \frac{\partial L}{\partial h_{t-k}} \right\rVert \lesssim \gamma^k$$
- $\gamma > 1$:梯度随时间距离 $k$ **指数级增长** → 梯度爆炸
- $\gamma < 1$:梯度**指数级衰减** → 梯度消失
- 连乘的**链条长度 $k$**(时间距离/网络深度)直接决定爆炸与消失的程度
**爆炸的典型表现**:
- loss 突然变为 `NaN` / `inf`,或剧烈震荡
- 参数一步更新冲出合理区域(overshoot),之前的训练成果被一步摧毁
- 常由个别异常 batch 或特定长序列触发,是**尖峰型**的瞬态现象
|
|
**做法**:标准 BPTT 从最后一个时间步一路反传到第一个(贯穿全长 $T$);截断版两步走:
1. **前向 $k_1$ 步**:隐藏状态跨段保留,不重置(历史信息不丢)
2. **反向 $k_2$ 步**:只往回传 $k_2$ 步,而非整个序列
**为什么能抑制爆炸**:梯度连乘链长从 $T$ 被硬性截断为至多 $k_2$,上界从 $\gamma^{T} \cdot C$ 收紧为 $\gamma^{k_2} \cdot C$——即使 $\gamma > 1$,$\gamma^{k_2}$ 也是**有界的常数**,不再随序列总长 $T$ 无限放大。
一句话:**截断不是消除 $\gamma > 1$ 的成因,而是给爆炸的"放大倍数"封了顶**。
| 参数 | 含义 | 影响 |
|------|------|------|
| $k_1$ | 前向传播步数 | 决定权重更新频率,影响训练速度 |
| $k_2$ | 反向传播步数 | 决定能学到的依赖长度;太小 → 学不到长程依赖;太大 → 连乘链变长,回到爆炸/消失风险 |
**代价与收益**:
- 代价:无法学习超过 $k_2$ 步的长程依赖(梯度是有偏估计)
- 收益:梯度有界 + 计算量与显存大幅下降(不必保存整条序列的计算图)
|
|
**最常见方案(Common solution)**,两种形式:
**按范数裁剪**(保留方向,只缩幅度):
$$\text{if } \lVert g \rVert_2 > v: \quad g \leftarrow \frac{v}{\lVert g \rVert_2} \, g$$
**按元素裁剪**(各坐标独立截断):
$$g_i \leftarrow \min(\max(g_i, -v),\ v)$$
**为什么有效**:爆炸的后果链是 $\Delta\theta = -\eta g$,梯度巨大 → 参数一步跨飞;裁剪后**最大更新步长被硬性封顶**:
$$\lVert \Delta\theta \rVert \le \eta \cdot v$$
- 相当于给梯度下降加了**信赖域**,强制满足优化对步长的要求
- 范数版**方向信息不丢**:裁剪后仍是有效的下降方向,只是步幅受限
- 对尖峰型爆炸尤其合适:"方向我采纳,幅度我不信",一步异常不会摧毁参数
```python
loss.backward()
# 按范数裁剪:max_norm 即阈值 v
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)
optimizer.step()
```
**局限**:治标不治本(梯度仍在大,只是更新被压住);对梯度消失完全无效;阈值常取观测梯度范数的分位数(如 95%)或经验值 1.0~5.0。
|
|
**思路**:不裁梯度,而是用**梯度平方的指数移动平均**逐参数缩放学习率:
$$E[g^2]_t = \gamma E[g^2]_{t-1} + (1-\gamma)\, g_t^2 \quad (\gamma \approx 0.9)$$
$$\theta_{t+1} = \theta_t - \frac{\eta}{\sqrt{E[g^2]_t} + \epsilon}\, g_t$$
**三个机制对付爆炸**:
1. **逐坐标自适应**:历史上梯度大的坐标分母大 → 有效学习率自动变小,各参数更新量级被拉平,"少数坐标巨幅梯度主导更新"的病态被缓解
2. **内置软裁剪(单步上界可证)**:当前梯度 $g_t^2$ 以权重 $(1-\gamma)$ 计入分母,故
$$\lvert \Delta\theta_i \rvert = \eta \frac{\lvert g_i \rvert}{\sqrt{E[g^2]_t}} \le \frac{\eta}{\sqrt{1-\gamma}}$$
即无论梯度多大,每个参数一步最多走 $\eta/\sqrt{1-\gamma} \approx 3.16\eta$($\gamma=0.9$)——天然自带一个"自适应的按元素裁剪"
3. **平滑适应**:EMA 时间尺度约 $1/(1-\gamma) \approx 10$ 步,瞬时尖峰被分母部分吸收;若大梯度持续,分母追上后更新自动回落,训练继续平稳
**局限**:只是**适应**爆炸而非根治;无动量与偏差修正,实践多用升级版 **Adam**(RMSprop + 动量 + 偏差修正)。
```python
opt = torch.optim.RMSprop(model.parameters(), lr=1e-3, alpha=0.9)
```
|
|
| | Truncated BPTT | 梯度裁剪 | RMSprop |
|---|---|---|---|
| 作用层面 | 反传路径长度(源头) | 梯度幅值(出口硬限制) | 学习率(出口软限制) |
| 核心机制 | 连乘链 $T \to k_2$ | $\lVert\Delta\theta\rVert \le \eta v$ | $\lvert\Delta\theta_i\rvert \le \eta/\sqrt{1-\gamma}$ |
| 保留方向 | 是 | 是(范数版) | 是 |
| 主要代价 | 学不到超长依赖 | 阈值敏感、治标 | 仅适应、不根治 |
| 对梯度消失 | 无效 | 无效 | 无效 |
三者的关系是**互补而非替代**:
- Truncated BPTT 从**源头**缩短爆炸链条
- Clipping 在**出口**硬性封顶,防止一步崩盘
- RMSprop 通过**历史统计**自适应缩放,让更新长期平稳
RNN/LSTM 训练的标准配方通常是后两者(或三者)同时使用。
**注意共同盲区:对梯度消失都无能为力**——裁剪只能压大梯度,救不回已衰减为 0 的梯度,详见下一节。
|
|
与爆炸同源:**同一个雅可比连乘框架**,只是方向相反:
$$\left\lVert \frac{\partial L}{\partial h_{t-k}} \right\rVert \lesssim \gamma^k, \quad \gamma < 1 \Rightarrow \gamma^k \to 0$$
梯度随时间距离 $k$ **指数级衰减**,远距离的梯度信号在反传中被稀释到机器精度以下。深层前馈网络同理——**层数就是连乘长度**。
**激活函数的双重伤害(以 sigmoid 为例)**:
$$\sigma'(x) = \sigma(x)(1-\sigma(x)) \le 0.25$$
1. 导数最大值只有 **0.25**,连乘 $0.25^k$ 极速衰减:10 层就只剩约 $10^{-6}$
2. 饱和区($\lvert x \rvert$ 较大时)导数 $\approx 0$,梯度直接被掐断
tanh 稍好(导数上界为 1),但饱和区导数同样趋近 0。
**与爆炸的关键区别——隐蔽性**:
| | 梯度爆炸 | 梯度消失 |
|---|---|---|
| 现象 | loss 变 NaN/inf,剧烈震荡 | loss 下降极慢/停滞 |
| 报错 | 立即显性暴露 | **无任何报错** |
| 危害方式 | 一步摧毁参数 | 早期层/远时间步参数几乎不更新 |
| 易误判为 | — | 欠拟合、学习率太小、数据问题 |
爆炸会"喊疼",消失是**沉默的失败**——这是它更危险的地方。
**检测手段**:逐层/逐时间步打印梯度范数,若早期层 $\approx 0$ 而末期层正常,即可确诊。
|
|
**最直接的一招:换掉会压缩梯度的激活函数**。
| 激活函数 | 导数上界 | 连乘效果 |
|---|---|---|
| sigmoid | $0.25$ | $0.25^k$ 指数衰减 |
| tanh | $1$ | 饱和区仍趋近 0 |
| **ReLU** | $1$(正区间恒为 1) | **连乘不再衰减** |
$$f(x) = \max(0, x), \quad f'(x) = \begin{cases} 1 & x > 0 \\ 0 & x < 0 \end{cases}$$
**优点**:
- 正区间导数恒为 1,梯度原样通过,百层网络也不衰减
- 计算简单(无指数运算),且让部分神经元输出 0,带来稀疏性
**缺点与修补**:
- **死神经元**:负区间导数恒 0,若神经元长期落入负区将永久失活 → **Leaky ReLU / PReLU** 给负区间一个小斜率(如 0.01)
- **不防爆炸**:导数恒 1 意味着连乘不再衰减、也不再受抑,$\gamma > 1$ 时照样爆炸 → 需与初始化/裁剪配合
```python
nn.ReLU() # 默认首选
nn.LeakyReLU(0.01) # 缓解死神经元
```
|
|
**从结构上根治:把"连乘"改成"连加",给梯度修一条高速公路**。
普通 RNN 每一步都是 $h_t = \tanh(W_h h_{t-1} + \cdots)$——每步都要过一个会压缩梯度的非线性。LSTM 引入**细胞状态 $C_t$,沿时间加性更新**:
$$C_t = f_t \odot C_{t-1} + i_t \odot \tilde{g}_t$$
于是相邻时间步的梯度流变为:
$$\frac{\partial C_t}{\partial C_{t-1}} = f_t \quad (\text{逐元素})$$
**三个关键点**:
1. **没有矩阵连乘、没有激活函数压缩**:梯度走的是直通道,衰减从指数级降为近似线性
2. **门控可学习**:$f_t$(遗忘门)接近 1 时信息与梯度畅通传递(该记的记),接近 0 时主动切断(该忘的忘)——是否传递由网络自己学出来
3. **工程细节**:遗忘门偏置常初始化为 1~2,让训练初期 $f_t \approx 1$,梯度先畅通、再学习"遗忘"
**GRU**:LSTM 的简化版,更新门 $z_t$ 一体承担遗忘与输入:
$$h_t = (1 - z_t) \odot h_{t-1} + z_t \odot \tilde{h}_t$$
参数更少、训练更快,效果与 LSTM 相当,是常用的默认选择。
一句话总结:**LSTM/GRU 不是让梯度变大,而是让梯度"有路可走"**。
|
|
**思路**:让每层的缩放因子 $\gamma$ 尽量贴近临界值 1,从源头避免衰减/放大。
**1. 合理初始化**
- **Xavier 初始化**:权重方差取 $\frac{2}{n_{in}+n_{out}}$,使每层输出方差稳定,$\gamma \approx 1$
- **He 初始化**:配合 ReLU(半边输出为 0、方差减半),方差取 $\frac{2}{n_{in}}$ 补偿
- **正交初始化**:$W$ 取正交阵,满足 $\lVert Wx \rVert = \lVert x \rVert$,$\gamma$ 恰为 1——RNN 常用
**2. 残差连接(ResNet 式)**
$$y = F(x) + x \quad \Rightarrow \quad \frac{\partial y}{\partial x} = F'(x) + 1$$
梯度中恒有一个**直通项 1**:即使 $F'(x)$ 很小,梯度也不会消失——这是 ResNet 能训上百层、Transformer 必备残差的原因。
**3. 归一化(BatchNorm / LayerNorm)**
控制每层输入的分布稳定,避免大量落入激活函数饱和区,间接保证梯度健康。RNN/Transformer 中 **LayerNorm** 是标配。
**4. 辅助手段**
- 梯度直通的拓扑:跳跃连接、DenseNet 等,思想同残差
- warmup + 自适应优化器,让训练初期更平稳
```python
nn.init.xavier_uniform_(layer.weight) # Xavier
nn.init.kaiming_uniform_(layer.weight) # He(配 ReLU)
nn.init.orthogonal_(rnn.weight_hh) # 正交(RNN 常用)
```
|
|
| | 梯度爆炸 | 梯度消失 |
|---|---|---|
| 数学条件 | $\gamma > 1$ | $\gamma < 1$ |
| 直观现象 | loss=NaN/inf,参数一步飞出 | loss 停滞,早期层不更新 |
| 隐蔽性 | 显性,立即报错 | 隐蔽,易误判为欠拟合 |
| 主方案 | 梯度裁剪、Truncated BPTT、RMSprop | ReLU、LSTM/GRU、残差、初始化 |
| 方案思路 | **出口拦截**:把大梯度压小 | **路径修复**:让梯度有路可走 |
**为什么消失不能像爆炸那样"出口拦截"?**
爆炸可以把大梯度**裁小**(方向保留);但消失若把小梯度**放大**回去,会同时把数值噪声一起放大,信号早已被噪声淹没——所以只能回到**结构与初始化**层面,让梯度在传播路径上不衰减。
**完整的组合拳(实践清单)**:
- 结构:ReLU 系激活 + LSTM/GRU(序列)+ 残差连接(深层)
- 初始化:He / Xavier / 正交
- 归一化:LayerNorm / BatchNorm
- 优化:Adam/RMSprop + 梯度裁剪(防爆炸侧)
- 监控:逐层梯度范数,爆炸与消失一网打尽
|
反向传播算法推导过程(看一篇就够了)