LLM架构从基础到精通之LSTM

LLM架构从基础到精通之长短期记忆网络(LSTM)

以下是已更新文章:
1. LLM大模型架构专栏|| 从NLP基础谈起
2.LLM大模型架构专栏|| 自然语言处理(NLP)之建模
3. LLM大模型架构之词嵌入(Part1)
4. LLM大模型架构之词嵌入(Part2)
5. LLM大模型架构之词嵌入(Part3)
6. LLM架构从基础到精通之循环神经网络(RNN)
欢迎关注公众号【柏企科技圈】【柏企阅文

在之前对循环神经网络(RNNs)的探讨中,我们领略了其处理序列数据的独特设计,这使其在分析时间序列数据或处理语言等任务中表现出色。接下来,我们将聚焦于一种能够应对传统 RNNs 所面临重大挑战的变体——长短期记忆网络(LSTMs)。相较于传统 RNNs,LSTMs 复杂度更高,它通过一套门控系统来精细控制信息在网络中的流动,从而能够在长序列数据中决定哪些信息需要保留,哪些信息可以遗忘。

一、LSTMs 简介

LSTM(Long - Short - Term - Memory)属于循环神经网络(RNN)家族中的特殊一员。它具备一种默认的能力,即能够长时间记住关键且相关的信息,以此学习长期依赖关系。为了更形象地理解 LSTMs 的核心思想,让我们来看一个简单的故事:从前,国王维克拉姆击败了国王 XYZ,但随后不幸离世。他的儿子小维克拉姆继位后英勇作战,却也在战斗中牺牲。而小维克拉姆的儿子超级小维克拉姆,虽身体没有那么强壮,却凭借智慧最终再次击败国王 XYZ,成功为家族复仇。

当我们阅读这个故事或者任何一系列事件时,我们的大脑首先会关注眼前的细节,比如会先处理国王维克拉姆的胜利与死亡。但随着故事中更多角色的出现,我们会不断调整对整个故事的长期理解,持续追踪小维克拉姆和超级小维克拉姆的情况。这种对上下文的持续更新过程,就如同 LSTMs 的工作方式:当新信息不断涌入时,它们能够同时维护和更新短期记忆与长期记忆。

传统的 RNNs 在平衡短期和长期上下文方面存在困难。就像我们可能会清晰记得电视剧的最新一集内容,但却容易忘记前面的细节一样,RNNs 在处理新数据时常常会丢失长期信息。而 LSTMs 则通过创建两条路径——一条用于短期记忆,一条用于长期记忆——有效地解决了这个问题,从而使模型能够保留关键信息并舍弃不重要的信息。

在 LSTMs 中,信息通过细胞状态进行流动,细胞状态就像一条传送带,它能够携带有用信息不断向前传递,同时选择性地遗忘不相关的细节。与 RNNs 中 新数据会覆盖旧数据的方式不同,LSTMs 采用了谨慎的数学运算——加法和乘法,以此来保留关键信息。这使得它们能够高效地对新数据和过去的数据进行优先级排序和管理。

每个细胞状态都依赖于三个不同的因素:前一细胞状态(即在前一时间步结束时存储的信息)、前一隐藏状态(等同于前一细胞的输出)以及当前时间步的输入(即当前时间步的新信息)。接下来,让我们更详细地探讨 LSTMs 的架构和功能。

二、LSTM 架构

循环神经网络(RNNs)的架构是由一系列重复的神经网络组成的链状结构,其重复模块具有一个简单且单一的函数——tanh 激活函数。

LSTM 的架构与 RNNs 类似,也是由一系列重复模块/神经网络组成的链状结构。但不同的是,LSTM 的重复模型拥有四个不同的功能,并且这四个功能操作紧密相连。它们分别是:Sigmoid 激活函数、Tanh 激活函数、逐点乘法和逐点加法。

在整个网络中,信息以向量形式进行传递。下面来介绍一下上述架构图中的不同符号含义:

  • 方形框:表示单个神经网络。
  • 圆形:代表逐点运算,即对两个向量的对应元素逐个进行运算。
  • 箭头标记:表示向量信息从一层转换到另一层。
  • 两条线合并为一条线:表示将两个向量进行连接。
  • 一条线分为两条线:意味着将相同的信息传递到两个不同的操作或层中。

首先,我们来讨论 LSTM 架构中的主要函数和操作。

(一)激活函数和线性运算

  • Sigmoid 函数:Sigmoid 函数也被称为逻辑激活函数,它具有平滑的“S”形曲线,其输出结果始终在 0 到 1 的范围内。在需要预测概率作为输出的模型中,sigmoid 或逻辑激活函数是非常合适的选择,因为任何输入的概率都存在于 0 到 1 这个区间内。

  • Tanh 激活函数:Tanh 激活函数看起来与 sigmoid/逻辑函数相似,实际上它是一种缩放后的 sigmoid 函数。Tanh 函数结果值的范围是 -1 到 +1,通过使用这个函数,我们可以区分出强正、中性或强负的输入。

  • 逐点乘法:逐点乘法是对两个向量的对应元素逐个进行乘法运算。例如,对于向量 $A = [1,2,3,4]$ 和向量 $B = [2,3,4,5]$,它们的逐点乘法结果为 $[2,6,12,20]$。
  • 逐点加法:逐点加法是将两个向量的对应元素逐个进行相加。例如,对于向量 $A = [1,2,3,4]$ 和向量 $B = [2,3,4,5]$,它们的逐点加法结果为 $[3,5,7,9]$。

(二)LSTM 算法背后的关键概念

LSTM 的主要独特行为在于其细胞状态,它就像一条带有一些轻微线性相互作用的传送带。这意味着细胞状态通过加法和乘法等基本运算来传递信息,因此信息能够相对平滑地沿着细胞状态流动,与原始信息相比不会有太大的变化。

LSTMs 具有独特的结构来识别哪些信息是重要的,哪些是不重要的,并能够根据重要性向细胞状态中添加或移除信息。这些特殊的结构被称为门(gates)。门是一种独特的信息转换方式,LSTMs 通过这些门来决定哪些信息需要记住、移除或传递到另一层等。

每个门都由一个 sigmoid 神经网络层和一个逐点乘法运算组成。LSTMs 共有三种门,分别是:忘记门(Forget Gate)、输入门(Input Gate)和输出门(Output Gate)。

1. 忘记门(Forget Gate)

在 LSTM 架构的重复模块中,第一个门就是忘记门。它的主要任务是决定哪些信息应该被保留,哪些信息应该被丢弃,也就是决定哪些信息要发送到细胞状态进行进一步处理。忘记门接收来自前一隐藏状态和当前输入的信息,并将这两个状态的信息进行合并,然后将其通过 sigmoid 函数进行处理。

sigmoid 函数的输出结果在 0 到 1 之间,如果结果接近 0,则表示要忘记相应的信息;如果结果接近 1,则表示要保留/记住该信息。

2. 输入门(Input Gate)

LSTM 架构在忘记门之后设有输入门,其作用是更新细胞状态信息。输入门包含两种神经网络层,一种是 sigmoid 层,另一种是 tanh 层。这两个网络层都接收前一隐藏状态信息和当前输入信息作为输入。

sigmoid 网络层的输出结果范围在 0 到 1 之间,tanh 层的输出结果范围在 -1 到 1 之间。sigmoid 层负责决定哪些信息是重要的需要保留,tanh 层则用于调节网络。

在对隐藏状态和当前信息应用 sigmoid 和 tanh 函数之后,我们将这两个函数的输出进行相乘。最后,sigmoid 输出将决定从 tanh 输出中保留哪些重要信息。

3. 输出门(Output Gate)

LSTM 中的最后一个门是输出门,其主要任务是决定哪些信息应该成为下一个隐藏状态。这意味着输出层的输出将作为下一个隐藏状态的输入。

输出门也有两个神经网络层,与输入门类似,但操作有所不同。从输入门我们得到了更新后的细胞状态信息,在输出门中,我们需要将隐藏状态和当前输入信息通过 sigmoid 层进行处理,将更新后的细胞状态信息通过 tanh 层进行处理,然后将 sigmoid 层和 tanh 层的结果进行相乘。

最终的结果将被发送到下一个隐藏层作为输入。

三、LSTM 工作流程

在 LSTM 架构中,首要步骤是确定前一细胞状态中的哪些信息是重要的,哪些是可以丢弃的。在 LSTM 中执行此过程的第一个门是“忘记门”。

忘记门接收前一时间步的隐藏层信息($h_{t - 1}$)和当前时间步的输入($x_t$),并将其通过 sigmoid 神经网络层进行处理。处理结果是一个包含 0 和 1 值的向量形式,然后对前一细胞状态($C_{t - 1}$)信息(也是向量形式)和 sigmoid 函数的输出($f_t$)进行逐点乘法运算。

忘记门的最终输出结果中,1 表示“完全保留此信息”,0 表示“不保留此信息”。

接下来的步骤是决定要在当前细胞状态($C_t$)中存储哪些信息,这由另一个门——“输入门”来完成。

整个更新细胞状态的过程是通过使用两种激活函数/神经网络层来实现的:sigmoid 神经网络层和 tanh 神经网络层。

首先,sigmoid 网络层像忘记门一样接收输入:前一时间步的隐藏层信息($h_{t - 1}$)和当前时间步($x_t$),该过程决定了哪些值需要更新。然后,tanh 神经网络层也接收与 sigmoid 神经网络层相同的输入,它会以向量($\tilde{C}_t$)的形式创建新的候选值来调节网络。

现在,我们对 sigmoid 和 tanh 层的输出进行逐点乘法运算,之后,对忘记门的输出和输入门中逐点乘法的结果进行逐点加法运算,以此来更新当前细胞状态信息($C_t$)。

LSTM 架构的最后一步是决定输出哪些信息,执行此过程的最后一个门是“输出门”。这个输出将基于我们的细胞状态,但会是一个经过筛选的版本。

在这个门中,我们首先应用 sigmoid 神经网络层,它接收与之前门的 sigmoid 层相同的输入:前一时间步隐藏层信息($h_{t - 1}$)和当前时间输入($x_t$),以此来决定细胞状态信息中的哪些部分将作为输出。

然后,将更新后的细胞状态信息通过 tanh 神经网络层进行处理,以将值调节到 -1 和 1 之间,接着对 sigmoid 和 tanh 神经网络层的结果进行逐点乘法运算。

整个过程在 LSTM 架构的每个模块中都会重复进行。

四、LSTM 架构类型

LSTM 是解决或处理序列预测问题的一个非常有趣的起点。根据 LSTM 网络作为层的使用方式,我们可以将 LSTM 架构分为多种不同类型。本节将主要讨论五种常用的 LSTM 架构,它们分别是:

  • Vanilla LSTM:这是最基本的 LSTM 架构,它只有一个单一的隐藏层和一个输出层用于预测结果。
  • Stacked LSTM:Stacked LSTM 架构是由多个 LSTM 层堆叠而成的网络模型,也被称为深度 LSTM 网络模型。在这种架构中,每个 LSTM 层都会预测一系列输出并将其发送到下一个 LSTM 层,而不是只预测一个单一的输出值,最终由最后一个 LSTM 层预测出单一输出。
  • CNN LSTM:CNN LSTM 架构是卷积神经网络(CNN)和 LSTM 架构的组合。这种架构利用 CNN 网络层从输入中提取关键特征,然后将这些特征发送到 LSTM 层以支持序列预测。例如,一个应用场景是为输入图像或图像序列(如视频)生成文本描述。
  • Encoder - Decoder LSTM:Encoder - Decoder LSTM 架构是一种特殊的 LSTM 架构,主要用于解决序列到序列的问题,如机器翻译、语音识别等。它的另一个名称是 seq2seq(序列到序列)。在自然语言处理领域,序列到序列问题是具有挑战性的问题,因为在这些问题中,输入和输出的项目数量可能会有所不同。Encoder - Decoder LSTM 架构包含一个编码器,用于将输入转换为中间编码器向量,然后一个解码器将中间编码器向量转换为最终结果。编码器和解码器都是堆叠的 LSTMs。
  • Bidirectional LSTM:Bidirectional LSTM 架构是传统 LSTM 架构的扩展,它更适用于序列分类问题,如情感分类、意图分类等。Bidirectional LSTM 架构使用两个 LSTMs,一个用于正向方向(从左到右),另一个用于反向方向(从右到左)。这种架构能够为网络提供比传统 LSTM 更多的上下文信息,因为它可以从单词的两侧(左侧和右侧)收集信息,从而加速序列分类问题的性能。

五、Python 从零构建 LSTM

在本节中,我们将逐步介绍在 Python 中实现 LSTM 的过程,并参考本文前面所涵盖的数学基础和概念。我们将在谷歌股票数据上训练我们从头构建的模型,该数据集来自 Kaggle,可免费用于商业用途。

(一)导入和初始设置

  • numpy(np)和 pandas(pd):这两个库用于所有的数组和数据框操作,它们在任何数值计算中都是基础的,特别是在神经网络的实现中。
  • 自定义类:包括 WeightInitializer、PlotManager 和 EarlyStopping。

1. WeightInitializer

import numpy as np
import pandas as pd
from src.model import WeightInitializer

class WeightInitializer:
    def __init__(self, method='random'):
        self.method = method

    def initialize(self, shape):
        if self.method == 'random':
            return np.random.randn(*shape)
        elif self.method == 'xavier':
            return np.random.randn(*shape) / np.sqrt(shape[0])
        elif self.method == 'he':
            return np.random.randn(*shape) * np.sqrt(2 / shape[0])
        elif self.method == 'uniform':
            return np.random.uniform(-1, 1, shape)
        else:
            raise ValueError(f'Unknown initialization method: {self.method}')

WeightInitializer 是一个自定义类,用于处理权重的初始化。这是至关重要的,因为不同的初始化方法会显著影响 LSTM 的收敛行为。

2. PlotManager

import matplotlib.pyplot as plt

class PlotManager:
    def __init__(self):
        self.fig, self.ax = plt.subplots(3, 1, figsize=(6, 4))

    def plot_losses(self, train_losses, val_losses):
        self.ax.plot(train_losses, label='Training Loss')
        self.ax.plot(val_losses, label='Validation Loss')
        self.ax.set_title('Training and Validation Losses')
        self.ax.set_xlabel('Epoch')
        self.ax.set_ylabel('Loss')
        self.ax.legend()

    def show_plots(self):
        plt.tight_layout()

PlotManager 是一个实用类,用于管理绘图,它使我们能够绘制训练和验证损失。

3. EarlyStopping

class EarlyStopping:
    def __init__(self, patience=7, verbose=False, delta=0):
        self.patience = patience
        self.verbose = verbose
        self.counter = 0
        self.best_score = None
        self.early_stop = False
        self.delta = delta

    def __call__(self, val_loss):
        score = -val_loss
        if self.best_score is None:
            self.best_score = score
        elif score < self.best_score + self.delta:
            self.counter += 1
            if self.counter >= self.patience:
                self.early_stop = True
        else:
            self.best_score = score
            self.counter = 0

EarlyStopping 是一个实用类,用于在训练过程中处理早停,以防止过拟合。

LSTM 类

首先,让我们整体看一下这个类的结构,然后再将其分解为更易于理解的步骤:

class LSTM:
    """
    长短期记忆(LSTM)网络。
    参数:
    - input_size: int,输入空间的维度
    - hidden_size: int,LSTM 单元的数量
    - output_size: int,输出空间的维度
    - init_method: str,权重初始化方法(默认: 'xavier')
    """
    def __init__(self, input_size, hidden_size, output_size, init_method='xavier'):
        self.input_size = input_size
        self.hidden_size = hidden_size
        self.output_size = output_size
        self.weight_initializer = WeightInitializer(method=init_method)
        self.wf = self.weight_initializer.initialize((hidden_size, hidden_size + input_size))
        self.wi = self.weight_initializer.initialize((hidden_size, hidden_size + input_size))
        self.wo = self.weight_initializer.initialize((hidden_size, hidden_size + input_size))
        self.wc = self.weight_initializer.initialize((hidden_size, hidden_size + input_size))
        self.bf = np.zeros((hidden_size, 1))
        self.bi = np.zeros((hidden_size, 1))
        self.bo = np.zeros((hidden_size, 1))
        self.bc = np.zeros((hidden_size, 1))
        self.why = self.weight_initializer.initialize((output_size, hidden_size))
        self.by = np.zeros((output_size, 1))

    @staticmethod
    def sigmoid(z):
        """
        Sigmoid 激活函数。
        参数:
        - z: np.ndarray,激活函数的输入
        返回:
        - np.ndarray,激活函数的输出
        """
        return 1 / (1 + np.exp(-z))

    @staticmethod
    def dsigmoid(y):
        """
        Sigmoid 激活函数的导数。
        参数:
        - y: np.ndarray,sigmoid 激活函数的输出
        返回:
        - np.ndarray,sigmoid 函数的导数
        """
        return y * (1 - y)

    @staticmethod
    def dtanh(y):
        """
        双曲正切激活函数的导数。
        参数:
        - y: np.ndarray,双曲正切激活函数的输出
        返回:
        - np.ndarray,双曲正切函数的导数
        """
        return 1 - y * y

    def forward(self, x):
        """
        LSTM 网络的前向传播。
        参数:
        - x: np.ndarray,网络的输入
        返回:
        - np.ndarray,网络的输出
        - list,包含用于反向传播的中间值的缓存
        """
        caches = []
        h_prev = np.zeros((self.hidden_size, 1))
        c_prev = np.zeros((self.hidden_size, 1))
        h = h_prev
        c = c_prev
        for t in range(x.shape[0]):
            x_t = x[t].reshape(-1, 1)
            combined = np.vstack((h_prev, x_t))
            f = self.sigmoid(np.dot(self.wf, combined) + self.bf)
            i = self.sigmoid(np.dot(self.wi, combined) + self.bi)
            o = self.sigmoid(np.dot(self.wo, combined) + self.bo)
            c_ = np.tanh(np.dot(self.wc, combined) + self.bc)
            c = f * c_prev + i * c_
            h = o * np.tanh(c)
            cache = (h_prev, c_prev, f, i, o, c_, x_t, combined, c, h)
            caches.append(cache)
            h_prev, c_prev = h, c
        y = np.dot(self.why, h) + self.by
        return y, caches

    def backward(self, dy, caches, clip_value=1.0):
        """
        LSTM 网络的反向传播。
        参数:
        - dy: np.ndarray,损失相对于输出的梯度
        - caches: list,前向传播的缓存
        - clip_value: float,用于裁剪梯度的值(默认: 1.0)
        返回:
        - tuple,损失相对于参数的梯度
        """
        dWf, dWi, dWo, dWc = [np.zeros_like(w) for w in (self.wf, self.wi, self.wo, self.wc)]
        dbf, dbi, dbo, dbc = [np.zeros_like(b) for b in (self.bf, self.bi, self.bo, self.bc)]
        dWhy = np.zeros_like(self.why)
        dby = np.zeros_like(self.by)
        dy = dy.reshape(self.output_size, -1)
        dh_next = np.zeros((self.hidden_size, 1))
        dc_next = np.zeros_like(dh_next)
        for cache in reversed(caches):
            h_prev, c_prev, f, i, o, c_, x_t, combined, c, h = cache
            dh = np.dot(self.why.T, dy) + dh_next
            dc = dc_next + (dh * o * self.dtanh(np.tanh(c)))
            df = dc * c_prev * self.dsigmoid(f)
            di = dc * c_ * self.dsigmoid(i)
            do = dh * self.dtanh(np.tanh(c))
            dc_ = dc * i * self.dtanh(c_)
            dcombined_f = np.dot(self.wf.T, df)
            dcombined_i = np.dot(self.wi.T, di)
            dcombined_o = np.dot(self.wo.T, do)
            dcombined_c = np.dot(self.wc.T, dc_)
            dcombined = dcombined_f + dcombined_i + dcombined_o + dcombined_c
            dh_next = dcombined[:self.hidden_size]
            dc_next = f * dc
            dWf += np.dot(df, combined.T)
            dWi += np.dot(di, combined.T)
            dWo += np.dot(do, combined.T)
            dWc += np.dot(dc_, combined.T)
            dbf += df.sum(axis=1, keepdims=True)
            dbi += di.sum(axis=1, keepdims=True)
            dbo += do.sum(axis=1, keepdims=True)
            dbc += dc_.sum(axis=1, keepdims=True)
            dWhy += np.dot(dy, h.T)
            dby += dy
        gradients = (dWf, dWi, dWo, dWc, dbf, dbi, dbo, dbc, dWhy, dby)
        for i in range(len(gradients)):
            np.clip(gradients[i], -clip_value, clip_value, out=gradients[i])
        return gradients

    def update_params(self, grads, learning_rate):
        """
        使用梯度更新网络的参数。
        参数:
        - grads: tuple,损失相对于参数的梯度
        - learning_rate: float,学习率
        """
        dWf, dWi, dWo, dWc, dbf, dbi, dbo, dbc, dWhy, dby = grads
        self.wf -= learning_rate * dWf
        self.wi -= learning_rate * dWi
        self.wo -= learning_rate * dWo
        self.wc -= learning_rate * dWc
        self.bf -= learning_rate * dbf
        self.bi -= learning_rate * dbi
        self.bo -= learning_rate * dbo
        self.bc -= learning_rate * dbc
        self.why -= learning_rate * dWhy
        self.by -= learning_rate * dby

初始化

__init__ 方法根据指定的输入、隐藏和输出层的大小以及权重初始化方法来初始化一个 LSTM 实例。为门(遗忘门 wf、输入门 wi、输出门 wo 和细胞状态门 wc)以及连接最后隐藏状态到输出的权重(why)进行初始化。通常选择 Xavier 初始化,因为它在保持层间激活的方差方面是一个较好的默认选择。所有门和输出层的偏置都初始化为零,这是一种常见做法,尽管有时会添加一些小常数以避免在开始时出现神经元死亡的情况。

前向传播方法

首先,我们将前一个隐藏状态 h_prev 和细胞状态 c_prev 设置为零,这对于第一个时间步是典型的做法。

def forward(self, x) 方法中,输入 x 会逐个时间步进行处理,在每个时间步更新门的激活值、细胞状态和隐藏状态。

for t in range(x.shape[0]):
    x_t = x[t].reshape(-1, 1)
    combined = np.vstack((h_prev, x_t))

在每个时间步,输入和前一个隐藏状态会垂直堆叠,形成一个用于矩阵运算的组合输入。这对于高效地执行线性变换至关重要。

f = self.sigmoid(np.dot(self.wf, combined) + self.bf)
i = self.sigmoid(np.dot(self.wi, combined) + self.bi)
o = self.sigmoid(np.dot(self.wo, combined) + self.bo)
c_ = np.tanh(np.dot(self.wc, combined) + self.bc)
c = f * c_prev + i * c_
h = o * np.tanh(c)

每个门(遗忘门、输入门、输出门)都使用 sigmoid 函数计算其激活值,这会影响细胞状态和隐藏状态的更新。在这里,遗忘门(f)决定保留前一个细胞状态的多少;输入门(i)决定添加多少新的候选细胞状态(c_);最后,输出门(o)计算将细胞状态的哪一部分作为隐藏状态输出。细胞状态通过前一个状态和新候选状态的加权和进行更新,隐藏状态则是通过将更新后的细胞状态经过 tanh 函数处理,然后再经过输出门的筛选得到。

cache = (h_prev, c_prev, f, i, o, c_, x_t, combined, c, h)
caches.append(cache)

我们将反向传播所需的相关值存储在缓存中,包括状态、门的激活值和输入。

y = np.dot(self.why, h) + self.by

最后,输出 y 通过对最后一个隐藏状态进行线性变换计算得到。该方法返回输出和缓存的值,以便在反向传播期间使用。

反向传播方法

此方法用于计算损失函数相对于 LSTM 的权重和偏置的梯度。这些梯度在训练期间用于更新模型的参数。

dWf, dWi, dWo, dWc = [np.zeros_like(w) for w in (self.wf, self.wi, self.wo, self.wc)]
dbf, dbi, dbo, dbc = [np.zeros_like(b) for b in (self.bf, self.bi, self.bo, self.bc)]
dWhy = np.zeros_like(self.why)
dby = np.zeros_like(self.by)

所有权重(dWfdWidWodWcdWhy)和偏置(dbfdbidbodbcdby)的梯度都初始化为零,这是因为梯度是在序列的每个时间步上累积的。

dy = dy.reshape(self.output_size, -1)
dh_next = np.zeros((self.hidden_size, 1))
dc_next = np.zeros_like(dh_next)

在这里,我们确保 dy 的形状适合矩阵运算,dh_nextdc_next 存储从后续时间步反向传播回来的梯度。

for cache in reversed(caches):
    h_prev, c_prev, f, i, o, c_, x_t, combined, c, h = cache

从缓存中获取每个时间步的 LSTM 状态和门的激活值。处理从最后一个时间步开始并向后进行(reversed(caches)),这对于在循环神经网络中正确应用链式法则(时间反向传播 - BPTT)至关重要。

dh = np.dot(self.why.T, dy) + dh_next
dc = dc_next + (dh * o * self.dtanh(np.tanh(c)))
df = dc * c_prev * self.dsigmoid(f)
di = dc * c_ * self.dsigmoid(i)
do = dh * self.dtanh(np.tanh(c))
dc_ = dc * i * self.dtanh(c_)

dhdc 是损失相对于隐藏状态和细胞状态的梯度。每个门(dfdido)和候选细胞状态(dc_)的梯度通过链式法则计算,涉及到 sigmoid(dsigmoid)和 tanh(dtanh)函数的导数,这些在门控机制中已经讨论过。

dWf += np.dot(df, combined.T)
dWi += np.dot(di, combined.T)
dWo += np.dot(do, combined.T)
dWc += np.dot(dc_, combined.T)
dbf += df.sum(axis=1, keepdims=True)
dbi += di.sum(axis=1, keepdims=True)
dbo += do.sum(axis=1, keepdims=True)
dbc += dc_.sum(axis=1, keepdims=True)

这些行在所有时间步上累积每个权重和偏置的梯度。

for i in range(len(gradients)):
    np.clip(gradients[i], -clip_value, clip_value, out=gradients[i])

为了防止梯度爆炸,我们将梯度裁剪到指定的范围(clip_value),这在训练 RNN 时是一种常见的做法。

参数更新方法

def update_params(self, grads, learning_rate):
    dWf, dWi, dWo, dWc, dbf, dbi, dbo, dbc, dWhy, dby = grads
    self.wf -= learning_rate * dWf
    self.wi -= learning_rate * dWi
    self.wo -= learning_rate * dWo
    self.wc -= learning_rate * dWc
    self.bf -= learning_rate * dbf
    self.bi -= learning_rate * dbi
    self.bo -= learning_rate * dbo
    self.bc -= learning_rate * dbc
    self.why -= learning_rate * dWhy
    self.by -= learning_rate * dby

每个权重和偏置通过减去相应梯度的一部分(学习率)来更新。这一步调整模型参数以最小化损失函数。

训练与验证

LSTMTrainer 类

class LSTMTrainer:
    """
    LSTM 网络的训练器。
    参数:
    - model: LSTM,要训练的 LSTM 网络
    - learning_rate: float,优化器的学习率
    - patience: int,早停之前等待的轮数
    - verbose: bool,是否打印训练信息
    - delta: float,验证损失被视为改进的最小变化量
    """
    def __init__(self, model, learning_rate=0.01, patience=7, verbose=True, delta=0):
        self.model = model
        self.learning_rate = learning_rate
        self.train_losses = []
        self.val_losses = []
        self.early_stopping = EarlyStopping(patience, verbose, delta)

    def train(self, X_train, y_train, X_val=None, y_val=None, epochs=10, batch_size=1, clip_value=1.0):
        """
        训练 LSTM 网络。
        参数:
        - X_train: np.ndarray,训练数据
        - y_train: np.ndarray,训练标签
        - X_val: np.ndarray,验证数据(可选)
        - y_val: np.ndarray,验证标签(可选)
        - epochs: int,训练轮数
        - batch_size: int,小批量的大小
        - clip_value: float,梯度裁剪的值
        """
        for epoch in range(epochs):
            epoch_losses = []
            for i in range(0, len(X_train), batch_size):
                batch_X = X_train[i:i + batch_size]
                batch_y = y_train[i:i + batch_size]
                losses = []
                for x, y_true in zip(batch_X, batch_y):
                    y_pred, caches = self.model.forward(x)
                    loss = self.compute_loss(y_pred, y_true.reshape(-1, 1))
                    losses.append(loss)
                    dy = y_pred - y_true.reshape(-1, 1)
                    grads = self.model.backward(dy, caches, clip_value=clip_value)
                    self.model.update_params(grads, self.learning_rate)
                batch_loss = np.mean(losses)
                epoch_losses.append(batch_loss)
            avg_epoch_loss = np.mean(epoch_losses)
            self.train_losses.append(avg_epoch_loss)
            if X_val is not None and y_val is not None:
                val_loss = self.validate(X_val, y_val)
                self.val_losses.append(val_loss)
            print(f'Epoch {epoch + 1}/{epochs} - Loss: {avg_epoch_loss:.5f}, Val Loss: {val_loss:.5f}')
            self.early_stopping(val_loss)
            if self.early_stopping.early_stop:
                print("Early stopping")
                break
            else:
                print(f'Epoch {epoch + 1}/{epochs} - Loss: {avg_epoch_loss:.5f}')

    def compute_loss(self, y_pred, y_true):
        """
        计算均方误差损失。
        """
        return np.mean((y_pred - y_true) ** 2)

    def validate(self, X_val, y_val):
        """
        在独立的数据集上验证模型。
        """
        val_losses = []
        for x, y_true in zip(X_val, y_val):
            y_pred, _ = self.model.forward(x)
            loss = self.compute_loss(y_pred, y_true.reshape(-1, 1))
            val_losses.append(loss)
        return np.mean(val_losses)

这个训练器类负责协调多个轮次的训练过程,处理数据批次,并可选择对模型进行验证。

在训练循环中:

for epoch in range(epochs):
    epoch_losses = []
    for i in range(0, len(X_train), batch_size):
        batch_X = X_train[i:i + batch_size]
        batch_y = y_train[i:i + batch_size]
        losses = []

对于每一轮训练,我们将训练数据按照指定的批次大小进行划分。外层循环遍历训练轮次,内层循环则遍历训练数据,每次取出一个批次的数据进行处理。

for x, y_true in zip(batch_X, batch_y):
    y_pred, caches = self.model.forward(x)
    loss = self.compute_loss(y_pred, y_true.reshape(-1, 1))
    losses.append(loss)
    dy = y_pred - y_true.reshape(-1, 1)
    grads = self.model.backward(dy, caches, clip_value=clip_value)
    self.model.update_params(grads, self.learning_rate)

对于每个批次中的每个样本,我们首先通过模型的前向传播得到预测值 y_pred,并计算预测值与真实值 y_true 之间的损失。然后,根据损失计算预测误差 dy,并通过模型的反向传播得到梯度 grads。最后,使用这些梯度更新模型的参数。

batch_loss = np.mean(losses)
epoch_losses.append(batch_loss)
avg_epoch_loss = np.mean(epoch_losses)
self.train_losses.append(avg_epoch_loss)

在处理完一个批次的所有样本后,我们计算该批次的平均损失,并将其添加到当前轮次的损失列表 epoch_losses 中。在当前轮次结束后,我们计算该轮次的平均损失,并将其添加到训练损失列表 train_losses 中。

if X_val is not None and y_val is not None:
    val_loss = self.validate(X_val, y_val)
    self.val_losses.append(val_loss)
print(f'Epoch {epoch + 1}/{epochs} - Loss: {avg_epoch_loss:.5f}, Val Loss: {val_loss:.5f}')

如果提供了验证数据,我们将在每一轮训练结束后使用验证数据对模型进行验证,并计算验证损失。然后,我们打印出当前轮次的训练损失和验证损失,以便监控模型的训练过程。

self.early_stopping(val_loss)
if self.early_stopping.early_stop:
    print("Early stopping")
    break
else:
    print(f'Epoch {epoch + 1}/{epochs} - Loss: {avg_epoch_loss:.5f}')

我们使用早停机制来监控验证损失的变化。如果验证损失在指定的轮次内没有改善,早停机制将触发,训练过程将提前停止,以防止模型过拟合。

训练过程示例

look_back = 1
hidden_size = 256
output_size = 1
lstm = LSTM(input_size=1, hidden_size=hidden_size, output_size=output_size)
trainer = LSTMTrainer(lstm, learning_rate=1e-3, patience=50, verbose=True, delta=0.001)
trainer.train(trainX, trainY, testX, testY, epochs=1000, batch_size=32)

在这里,我们创建了一个 LSTM 模型实例,并使用 LSTMTrainer 类对其进行训练。我们设置了学习率为 1e-3,早停耐心值为 50,验证损失改进的最小变化量为 0.001,训练轮次为 1000,批次大小为 32

在训练过程中,模型将每 10 轮打印一次模型性能,输出结果类似于以下内容:

Epoch 1/1000 - Loss: 0.25707, Val Loss: 0.43853
Epoch 11/1000 - Loss: 0.06463, Val Loss: 0.06056
Epoch 21/1000 - Loss: 0.05313, Val Loss: 0.02100
Epoch 31/1000 - Loss: 0.04862, Val Loss: 0.01134
Epoch 41/1000 - Loss: 0.04512, Val Loss: 0.00678
Epoch 51/1000 - Loss: 0.04234, Val Loss: 0.00395
Epoch 61/1000 - Loss: 0.04014, Val Loss: 0.00210
Epoch 71/1000 - Loss: 0.03840, Val Loss: 0.00095
Epoch 81/1000 - Loss: 0.03703, Val Loss: 0.00031
Epoch 91/1000 - Loss: 0.03595, Val Loss: 0.00004
Epoch 101/1000 - Loss: 0.03509, Val Loss: 0.00003
Epoch 111/1000 - Loss: 0.03442, Val Loss: 0.00021
Epoch 121/1000 - Loss: 0.03388, Val Loss: 0.00051
Epoch 131/1000 - Loss: 0.03346, Val Loss: 0.00090
Epoch 141/1000 - Loss: 0.03312, Val Loss: 0.00133
Early stopping

从输出结果可以看出,训练损失和验证损失在早期轮次中迅速下降,这表明我们的初始化技术(Xavier 初始化)可能不是最优的。尽管早停机制在大约 90 轮后被触发,模型取得了不错的性能,但我们可以尝试降低学习率并增加训练轮次,或者尝试使用其他技术,如学习率调度器或 Adam 优化器。

最后,我们可以使用以下代码绘制训练损失和验证损失的曲线,以更好地了解模型的收敛情况:

plot_manager = PlotManager()
plot_manager.plot_losses(trainer.train_losses, trainer.val_losses)
plot_manager.show_plots()

通过观察图表,我们可以更直观地了解模型的训练过程和性能变化,以便进一步优化模型。

在 LSTM 的训练和验证过程中,合理设置训练参数、使用早停机制以及监控损失变化是非常重要的。通过不断调整和优化这些参数,我们可以提高模型的性能和泛化能力,使其在实际应用中取得更好的效果。

本文由mdnice多平台发布

posted @   图南CBQ  阅读(29)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?
点击右上角即可分享
微信分享提示