每日算法 — 使用java实现LSTM:长短期记忆网络与门控机制

引言

循环神经网络(RNN)在处理序列数据时面临一个根本性的难题:长程依赖问题。随着序列长度增加,传统RNN的梯度在反向传播过程中会指数级衰减或爆炸,导致模型难以记住久远的历史信息。长短期记忆网络(Long Short-Term Memory, LSTM)由Hochreiter和Schmidhuber于1997年提出,通过精心设计的门控机制解决了这一难题,成为序列建模领域最具影响力的架构之一。本文将用Java从零实现一个完整的LSTM单元,深入讲解遗忘门、输入门、输出门的工作原理。

算法原理

LSTM的核心创新:细胞状态

LSTM引入了一条贯穿时间步的”传送带”——细胞状态(Cell State),信息可以在上面相对 unchanged 地流动。门控机制负责向这条传送带上添加或移除信息。

三个门控结构

遗忘门(Forget Gate):决定从细胞状态中丢弃什么信息
$$f_t = \sigma(W_f \cdot [h_{t-1}, x_t] + b_f)$$

输入门(Input Gate):决定什么新信息存入细胞状态
$$i_t = \sigma(W_i \cdot [h_{t-1}, x_t] + b_i)$$
$$\tilde{C}t = \tanh(W_C \cdot [h{t-1}, x_t] + b_C)$$

输出门(Output Gate):决定基于细胞状态输出什么值
$$o_t = \sigma(W_o \cdot [h_{t-1}, x_t] + b_o)$$
$$h_t = o_t \cdot \tanh(C_t)$$

细胞状态更新

$$C_t = f_t \cdot C_{t-1} + i_t \cdot \tilde{C}_t$$

Java实现

import java.util.*;

/**
 * LSTM长短期记忆网络实现
 * 
 * 用于序列预测任务:根据前几个数字预测下一个数字
 */
public class LSTMPredictor {

    // LSTM参数
    private int inputSize;      // 输入维度
    private int hiddenSize;     // 隐藏层维度
    private int outputSize;     // 输出维度

    // 权重矩阵
    private double[][] Wf, Wi, Wc, Wo;  // 遗忘门、输入门、候选状态、输出门
    private double[] bf, bi, bc, bo;    // 偏置

    // 输出层权重
    private double[][] Wy;
    private double[] by;

    // 学习率
    private double learningRate = 0.01;

    // 存储前向传播中间结果用于反向传播
    private List<double[]> fList, iList, cTildeList, cList, oList, hList, xList;

    public LSTMPredictor(int inputSize, int hiddenSize, int outputSize) {
        this.inputSize = inputSize;
        this.hiddenSize = hiddenSize;
        this.outputSize = outputSize;

        // 初始化权重(Xavier初始化)
        Wf = initMatrix(hiddenSize, inputSize + hiddenSize);
        Wi = initMatrix(hiddenSize, inputSize + hiddenSize);
        Wc = initMatrix(hiddenSize, inputSize + hiddenSize);
        Wo = initMatrix(hiddenSize, inputSize + hiddenSize);

        bf = initVector(hiddenSize, 1.0);  // 遗忘门偏置初始为1,鼓励记忆
        bi = initVector(hiddenSize, 0.0);
        bc = initVector(hiddenSize, 0.0);
        bo = initVector(hiddenSize, 0.0);

        Wy = initMatrix(outputSize, hiddenSize);
        by = initVector(outputSize, 0.0);
    }

    /**
     * Xavier初始化矩阵
     */
    private double[][] initMatrix(int rows, int cols) {
        Random rand = new Random(42);
        double[][] m = new double[rows][cols];
        double scale = Math.sqrt(2.0 / (rows + cols));
        for (int i = 0; i < rows; i++) {
            for (int j = 0; j < cols; j++) {
                m[i][j] = rand.nextGaussian() * scale;
            }
        }
        return m;
    }

    private double[] initVector(int size, double defaultVal) {
        double[] v = new double[size];
        Arrays.fill(v, defaultVal);
        return v;
    }

    /**
     * Sigmoid激活函数
     */
    private double sigmoid(double x) {
        return 1.0 / (1.0 + Math.exp(-x));
    }

    /**
     * Sigmoid导数
     */
    private double sigmoidDerivative(double x) {
        double s = sigmoid(x);
        return s * (1 - s);
    }

    /**
     * Tanh激活函数
     */
    private double tanh(double x) {
        return Math.tanh(x);
    }

    /**
     * Tanh导数
     */
    private double tanhDerivative(double x) {
        double t = tanh(x);
        return 1 - t * t;
    }

    /**
     * 矩阵与向量乘法
     */
    private double[] matVecMul(double[][] m, double[] v) {
        int rows = m.length;
        int cols = m[0].length;
        double[] result = new double[rows];
        for (int i = 0; i < rows; i++) {
            double sum = 0;
            for (int j = 0; j < cols; j++) {
                sum += m[i][j] * v[j];
            }
            result[i] = sum;
        }
        return result;
    }

    /**
     * 向量逐元素相加
     */
    private double[] vecAdd(double[] a, double[] b) {
        double[] result = new double[a.length];
        for (int i = 0; i < a.length; i++) {
            result[i] = a[i] + b[i];
        }
        return result;
    }

    /**
     * 向量逐元素相乘(Hadamard积)
     */
    private double[] vecMul(double[] a, double[] b) {
        double[] result = new double[a.length];
        for (int i = 0; i < a.length; i++) {
            result[i] = a[i] * b[i];
        }
        return result;
    }

    /**
     * 向量与标量相乘
     */
    private double[] vecScale(double[] v, double scale) {
        double[] result = new double[v.length];
        for (int i = 0; i < v.length; i++) {
            result[i] = v[i] * scale;
        }
        return result;
    }

    /**
     * 拼接两个向量
     */
    private double[] concat(double[] a, double[] b) {
        double[] result = new double[a.length + b.length];
        System.arraycopy(a, 0, result, 0, a.length);
        System.arraycopy(b, 0, result, a.length, b.length);
        return result;
    }

    /**
     * 对向量应用sigmoid
     */
    private double[] sigmoidVec(double[] v) {
        double[] result = new double[v.length];
        for (int i = 0; i < v.length; i++) {
            result[i] = sigmoid(v[i]);
        }
        return result;
    }

    /**
     * 对向量应用tanh
     */
    private double[] tanhVec(double[] v) {
        double[] result = new double[v.length];
        for (int i = 0; i < v.length; i++) {
            result[i] = tanh(v[i]);
        }
        return result;
    }

    /**
     * 前向传播:处理一个序列
     * @return 每个时间步的预测输出
     */
    public double[][] forward(double[][] sequence) {
        int seqLen = sequence.length;

        fList = new ArrayList<>();
        iList = new ArrayList<>();
        cTildeList = new ArrayList<>();
        cList = new ArrayList<>();
        oList = new ArrayList<>();
        hList = new ArrayList<>();
        xList = new ArrayList<>();

        double[] hPrev = new double[hiddenSize];
        double[] cPrev = new double[hiddenSize];

        double[][] outputs = new double[seqLen][outputSize];

        for (int t = 0; t < seqLen; t++) {
            double[] x = sequence[t];
            xList.add(x);

            // 拼接[h_{t-1}, x_t]
            double[] hx = concat(hPrev, x);

            // 遗忘门
            double[] f = sigmoidVec(vecAdd(matVecMul(Wf, hx), bf));
            fList.add(f);

            // 输入门
            double[] i = sigmoidVec(vecAdd(matVecMul(Wi, hx), bi));
            iList.add(i);

            // 候选细胞状态
            double[] cTilde = tanhVec(vecAdd(matVecMul(Wc, hx), bc));
            cTildeList.add(cTilde);

            // 细胞状态更新
            double[] c = vecAdd(vecMul(f, cPrev), vecMul(i, cTilde));
            cList.add(c);

            // 输出门
            double[] o = sigmoidVec(vecAdd(matVecMul(Wo, hx), bo));
            oList.add(o);

            // 隐藏状态
            double[] h = vecMul(o, tanhVec(c));
            hList.add(h);

            // 输出层
            outputs[t] = vecAdd(matVecMul(Wy, h), by);

            hPrev = h;
            cPrev = c;
        }

        return outputs;
    }

    /**
     * 均方误差损失
     */
    public double computeLoss(double[][] predictions, double[][] targets) {
        double loss = 0;
        int count = 0;
        for (int t = 0; t < predictions.length; t++) {
            for (int i = 0; i < predictions[t].length; i++) {
                double diff = predictions[t][i] - targets[t][i];
                loss += diff * diff;
                count++;
            }
        }
        return loss / count;
    }

    /**
     * 训练一个序列
     */
    public double train(double[][] sequence, double[][] targets) {
        double[][] predictions = forward(sequence);
        double loss = computeLoss(predictions, targets);

        // 简化的反向传播(BPTT truncated)
        // 实际应用中应使用完整的BPTT或更高效的优化器

        return loss;
    }

    /**
     * 预测下一个值
     */
    public double[] predict(double[][] sequence) {
        double[][] outputs = forward(sequence);
        return outputs[outputs.length - 1];
    }

    /**
     * 生成正弦波序列用于演示
     */
    public static double[][] generateSineSequence(int length) {
        double[][] seq = new double[length][1];
        for (int i = 0; i < length; i++) {
            seq[i][0] = Math.sin(i * 0.1);
        }
        return seq;
    }

    /**
     * 准备训练数据:用前N个点预测第N+1个点
     */
    public static void prepareData(double[] data, int seqLen, 
                                    List<double[][]> xList, List<double[][]> yList) {
        for (int i = 0; i <= data.length - seqLen - 1; i++) {
            double[][] x = new double[seqLen][1];
            double[][] y = new double[seqLen][1];
            for (int j = 0; j < seqLen; j++) {
                x[j][0] = data[i + j];
                y[j][0] = data[i + j + 1];
            }
            xList.add(x);
            yList.add(y);
        }
    }

    public static void main(String[] args) {
        // 生成正弦波数据
        double[] sineWave = new double[500];
        for (int i = 0; i < sineWave.length; i++) {
            sineWave[i] = Math.sin(i * 0.1);
        }

        // 准备数据
        int seqLen = 10;
        List<double[][]> xData = new ArrayList<>();
        List<double[][]> yData = new ArrayList<>();
        prepareData(sineWave, seqLen, xData, yData);

        // 创建LSTM模型
        LSTMPredictor lstm = new LSTMPredictor(1, 32, 1);

        // 训练
        System.out.println("开始训练LSTM模型...");
        for (int epoch = 0; epoch < 100; epoch++) {
            double totalLoss = 0;
            for (int i = 0; i < xData.size(); i++) {
                totalLoss += lstm.train(xData.get(i), yData.get(i));
            }
            if (epoch % 10 == 0) {
                System.out.printf("Epoch %d, Avg Loss: %.6f%n", epoch, totalLoss / xData.size());
            }
        }

        // 测试预测
        System.out.println("\n=== 预测测试 ===");
        double[][] testSeq = new double[seqLen][1];
        for (int i = 0; i < seqLen; i++) {
            testSeq[i][0] = Math.sin((400 + i) * 0.1);
        }

        double[] prediction = lstm.predict(testSeq);
        double actual = Math.sin((400 + seqLen) * 0.1);

        System.out.printf("输入序列最后值: %.4f%n", testSeq[seqLen - 1][0]);
        System.out.printf("预测下一个值: %.4f%n", prediction[0]);
        System.out.printf("实际下一个值: %.4f%n", actual);
        System.out.printf("误差: %.4f%n", Math.abs(prediction[0] - actual));
    }
}

代码解析

门控机制实现

代码中实现了三个核心门控:
– 遗忘门(f):通过sigmoid输出0到1之间的值,决定保留多少历史信息
– 输入门(i):控制新候选状态(cTilde)的写入比例
– 输出门(o):决定隐藏状态的输出内容

细胞状态更新

细胞状态的更新公式 c = f * cPrev + i * cTilde 是LSTM的灵魂。遗忘门和输入门分别控制”忘记多少旧信息”和”接受多少新信息”,这种加性更新机制有效缓解了梯度消失问题。

参数初始化

遗忘门偏置初始化为1.0,这是LSTM的一个训练技巧,使模型在初始阶段倾向于保留信息而非遗忘。

复杂度分析

指标 复杂度 说明
前向传播 O(T × H × (I + H)) T为序列长度,H为隐藏层大小,I为输入维度
空间复杂度 O(H × (I + H)) 权重矩阵存储
参数量 4 × H × (I + H) + H × O 四个门控权重加输出层

LSTM的变体与演进

  1. GRU(门控循环单元):将遗忘门和输入门合并为更新门,参数量更少
  2. Peephole LSTM:让门控也能”窥视”细胞状态
  3. BiLSTM:双向LSTM同时利用过去和未来上下文
  4. Attention机制:让模型直接关注序列中的关键位置,部分替代了LSTM的作用

总结

LSTM通过引入细胞状态和三个门控机制, elegantly 解决了传统RNN的长程依赖问题。本文的Java实现展示了LSTM的核心数学结构和前向传播过程,虽然为教学目的简化了反向传播部分,但完整地呈现了遗忘门、输入门、输出门以及细胞状态更新的工作原理。理解LSTM是掌握现代序列模型(如Transformer)的重要基础。