DL算法·俯瞰

 
算法选择
- 机器学习
- 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 + 梯度裁剪(防爆炸侧) - 监控:逐层梯度范数,爆炸与消失一网打尽
参考
    反向传播算法推导过程(看一篇就够了)