每日算法 — 使用java实现BERT:双向编码器与掩码语言建模预训练

BERT(Bidirectional Encoder Representations from Transformers)是Google于2018年提出的预训练语言模型,通过双向Transformer编码器和掩码语言建模(MLM)任务,彻底改变了自然语言处理领域。本文将用Java实现一个简化版BERT的核心组件,帮助读者理解双向上下文编码与掩码预测机制。

核心原理

BERT的核心创新在于:

  • 双向编码:传统语言模型只能从左到右或从右到左单向建模,而BERT通过Transformer的自注意力机制同时关注句子中每个词的左右上下文。
  • 掩码语言建模(MLM):随机遮挡输入序列中15%的token,让模型根据上下文预测被遮挡的词,从而学习深层语义表示。
  • 位置编码:由于Transformer没有序列顺序感,BERT引入了可学习的位置嵌入来编码词的位置信息。

完整Java实现

import java.util.*;

/**
 * 简化版BERT实现:包含词嵌入、位置编码、多头自注意力与前馈网络
 */
public class BERT {

    // 超参数
    private final int vocabSize;      // 词表大小
    private final int hiddenSize;     // 隐藏层维度
    private final int numHeads;       // 注意力头数
    private final int numLayers;      // Transformer层数
    private final int maxSeqLen;      // 最大序列长度
    private final double dropoutRate; // Dropout比率

    // 可学习参数
    private double[][] wordEmbedding;   // 词嵌入矩阵 [vocabSize][hiddenSize]
    private double[][] posEmbedding;    // 位置嵌入矩阵 [maxSeqLen][hiddenSize]
    private double[][] tokenTypeEmbedding; // 句子类型嵌入

    // Transformer层参数(每层共享结构,独立参数)
    private List<TransformerLayer> layers;

    // 输出层:掩码语言模型预测头
    private double[][] mlmWeight;  // [hiddenSize][vocabSize]
    private double[] mlmBias;      // [vocabSize]

    private Random random;

    public BERT(int vocabSize, int hiddenSize, int numHeads, 
                int numLayers, int maxSeqLen, double dropoutRate) {
        this.vocabSize = vocabSize;
        this.hiddenSize = hiddenSize;
        this.numHeads = numHeads;
        this.numLayers = numLayers;
        this.maxSeqLen = maxSeqLen;
        this.dropoutRate = dropoutRate;
        this.random = new Random(42);

        initializeParameters();
    }

    /**
     * 初始化所有可学习参数,使用Xavier初始化
     */
    private void initializeParameters() {
        double scale = Math.sqrt(2.0 / hiddenSize);

        // 词嵌入
        wordEmbedding = new double[vocabSize][hiddenSize];
        for (int i = 0; i < vocabSize; i++) {
            for (int j = 0; j < hiddenSize; j++) {
                wordEmbedding[i][j] = random.nextGaussian() * scale;
            }
        }

        // 位置嵌入
        posEmbedding = new double[maxSeqLen][hiddenSize];
        for (int i = 0; i < maxSeqLen; i++) {
            for (int j = 0; j < hiddenSize; j++) {
                posEmbedding[i][j] = random.nextGaussian() * scale;
            }
        }

        // Token类型嵌入(用于区分句子A和句子B)
        tokenTypeEmbedding = new double[2][hiddenSize];
        for (int i = 0; i < 2; i++) {
            for (int j = 0; j < hiddenSize; j++) {
                tokenTypeEmbedding[i][j] = random.nextGaussian() * scale;
            }
        }

        // 初始化Transformer层
        layers = new ArrayList<>();
        for (int i = 0; i < numLayers; i++) {
            layers.add(new TransformerLayer(hiddenSize, numHeads, dropoutRate, random));
        }

        // MLM预测头
        mlmWeight = new double[hiddenSize][vocabSize];
        mlmBias = new double[vocabSize];
        for (int i = 0; i < hiddenSize; i++) {
            for (int j = 0; j < vocabSize; j++) {
                mlmWeight[i][j] = random.nextGaussian() * scale;
            }
        }
    }

    /**
     * 前向传播:输入token IDs,输出每个位置的隐藏表示
     */
    public double[][] forward(int[] inputIds, int[] tokenTypeIds) {
        int seqLen = inputIds.length;
        double[][] hidden = new double[seqLen][hiddenSize];

        // 嵌入层:词嵌入 + 位置嵌入 + 句子类型嵌入
        for (int i = 0; i < seqLen; i++) {
            for (int j = 0; j < hiddenSize; j++) {
                hidden[i][j] = wordEmbedding[inputIds[i]][j] 
                             + posEmbedding[i][j]
                             + tokenTypeEmbedding[tokenTypeIds[i]][j];
            }
        }

        // Layer Norm + Dropout(简化版省略Dropout)
        hidden = layerNorm(hidden);

        // 通过所有Transformer层
        for (TransformerLayer layer : layers) {
            hidden = layer.forward(hidden);
        }

        return hidden;
    }

    /**
     * 掩码语言模型预测:给定隐藏表示,预测被遮挡位置的词
     */
    public double[][] mlmPredict(double[][] hidden) {
        int seqLen = hidden.length;
        double[][] logits = new double[seqLen][vocabSize];

        for (int i = 0; i < seqLen; i++) {
            for (int v = 0; v < vocabSize; v++) {
                double sum = mlmBias[v];
                for (int j = 0; j < hiddenSize; j++) {
                    sum += hidden[i][j] * mlmWeight[j][v];
                }
                logits[i][v] = sum;
            }
        }
        return logits;
    }

    /**
     * 计算MLM损失:交叉熵
     */
    public double computeMLMLoss(double[][] logits, int[] labels, boolean[] mask) {
        double loss = 0.0;
        int count = 0;

        for (int i = 0; i < logits.length; i++) {
            if (!mask[i]) continue;

            // Softmax
            double maxLogit = Double.NEGATIVE_INFINITY;
            for (double v : logits[i]) {
                if (v > maxLogit) maxLogit = v;
            }

            double sumExp = 0.0;
            double[] probs = new double[vocabSize];
            for (int v = 0; v < vocabSize; v++) {
                probs[v] = Math.exp(logits[i][v] - maxLogit);
                sumExp += probs[v];
            }

            // 负对数似然
            loss += -Math.log(probs[labels[i]] / sumExp);
            count++;
        }

        return count > 0 ? loss / count : 0.0;
    }

    /**
     * 掩码输入生成:随机遮挡15%的token
     */
    public static MaskResult createMaskedInput(int[] inputIds, int maskTokenId, 
                                                int vocabSize, Random random) {
        int[] maskedIds = inputIds.clone();
        int[] labels = new int[inputIds.length];
        boolean[] maskPositions = new boolean[inputIds.length];
        Arrays.fill(labels, -1); // -1表示不参与loss计算

        for (int i = 0; i < inputIds.length; i++) {
            if (random.nextDouble() < 0.15) {
                maskPositions[i] = true;
                labels[i] = inputIds[i];

                double r = random.nextDouble();
                if (r < 0.8) {
                    maskedIds[i] = maskTokenId; // 80%替换为[MASK]
                } else if (r < 0.9) {
                    maskedIds[i] = random.nextInt(vocabSize); // 10%替换为随机词
                }
                // 10%保持不变
            }
        }

        return new MaskResult(maskedIds, labels, maskPositions);
    }

    private double[][] layerNorm(double[][] x) {
        int n = x.length;
        int d = x[0].length;
        double[][] out = new double[n][d];
        double eps = 1e-12;

        for (int i = 0; i < n; i++) {
            double mean = 0.0;
            for (double v : x[i]) mean += v;
            mean /= d;

            double var = 0.0;
            for (double v : x[i]) var += (v - mean) * (v - mean);
            var /= d;

            for (int j = 0; j < d; j++) {
                out[i][j] = (x[i][j] - mean) / Math.sqrt(var + eps);
            }
        }
        return out;
    }

    // ============ Transformer层 ============
    static class TransformerLayer {
        private final int hiddenSize;
        private final int numHeads;
        private final int headDim;
        private final double dropoutRate;

        // 多头注意力参数
        private double[][] wq, wk, wv, wo;
        private double[] bq, bk, bv, bo;

        // 前馈网络参数
        private double[][] w1, w2;
        private double[] b1, b2;

        private Random random;

        public TransformerLayer(int hiddenSize, int numHeads, 
                               double dropoutRate, Random random) {
            this.hiddenSize = hiddenSize;
            this.numHeads = numHeads;
            this.headDim = hiddenSize / numHeads;
            this.dropoutRate = dropoutRate;
            this.random = random;

            double scale = Math.sqrt(2.0 / hiddenSize);

            // Q, K, V投影
            wq = new double[hiddenSize][hiddenSize];
            wk = new double[hiddenSize][hiddenSize];
            wv = new double[hiddenSize][hiddenSize];
            wo = new double[hiddenSize][hiddenSize];
            bq = new double[hiddenSize];
            bk = new double[hiddenSize];
            bv = new double[hiddenSize];
            bo = new double[hiddenSize];

            initMatrix(wq, scale); initMatrix(wk, scale);
            initMatrix(wv, scale); initMatrix(wo, scale);

            // 前馈网络 (hiddenSize -> 4*hiddenSize -> hiddenSize)
            w1 = new double[hiddenSize][4 * hiddenSize];
            w2 = new double[4 * hiddenSize][hiddenSize];
            b1 = new double[4 * hiddenSize];
            b2 = new double[hiddenSize];
            initMatrix(w1, scale); initMatrix(w2, scale);
        }

        private void initMatrix(double[][] m, double scale) {
            for (double[] row : m) {
                for (int i = 0; i < row.length; i++) {
                    row[i] = random.nextGaussian() * scale;
                }
            }
        }

        public double[][] forward(double[][] x) {
            // 多头自注意力 + 残差连接
            double[][] attnOut = multiHeadAttention(x);
            double[][] normed1 = layerNorm(add(x, attnOut));

            // 前馈网络 + 残差连接
            double[][] ffOut = feedForward(normed1);
            double[][] output = layerNorm(add(normed1, ffOut));

            return output;
        }

        private double[][] multiHeadAttention(double[][] x) {
            int seqLen = x.length;

            // 线性投影得到Q, K, V
            double[][] q = linear(x, wq, bq);
            double[][] k = linear(x, wk, bk);
            double[][] v = linear(x, wv, bv);

            // 多头分割与注意力计算
            double[][] attnOutput = new double[seqLen][hiddenSize];

            for (int h = 0; h < numHeads; h++) {
                int offset = h * headDim;
                double[][] headOut = scaledDotProductAttention(
                    slice(q, offset, headDim),
                    slice(k, offset, headDim),
                    slice(v, offset, headDim)
                );
                // 合并头输出
                for (int i = 0; i < seqLen; i++) {
                    System.arraycopy(headOut[i], 0, attnOutput[i], offset, headDim);
                }
            }

            // 输出投影
            return linear(attnOutput, wo, bo);
        }

        private double[][] scaledDotProductAttention(double[][] q, double[][] k, double[][] v) {
            int seqLen = q.length;
            double[][] scores = new double[seqLen][seqLen];
            double scale = 1.0 / Math.sqrt(headDim);

            // Q * K^T / sqrt(d)
            for (int i = 0; i < seqLen; i++) {
                for (int j = 0; j < seqLen; j++) {
                    double dot = 0.0;
                    for (int d = 0; d < headDim; d++) {
                        dot += q[i][d] * k[j][d];
                    }
                    scores[i][j] = dot * scale;
                }
            }

            // Softmax
            double[][] weights = softmax(scores);

            // 加权求和
            double[][] output = new double[seqLen][headDim];
            for (int i = 0; i < seqLen; i++) {
                for (int d = 0; d < headDim; d++) {
                    for (int j = 0; j < seqLen; j++) {
                        output[i][d] += weights[i][j] * v[j][d];
                    }
                }
            }
            return output;
        }

        private double[][] feedForward(double[][] x) {
            int seqLen = x.length;
            // 第一层: hiddenSize -> 4*hiddenSize
            double[][] h = linear(x, w1, b1);
            // GELU激活(简化版使用ReLU)
            for (int i = 0; i < seqLen; i++) {
                for (int j = 0; j < h[i].length; j++) {
                    h[i][j] = Math.max(0, h[i][j]); // ReLU
                }
            }
            // 第二层: 4*hiddenSize -> hiddenSize
            return linear(h, w2, b2);
        }

        private double[][] linear(double[][] x, double[][] w, double[] b) {
            int n = x.length;
            int outDim = w[0].length;
            double[][] out = new double[n][outDim];
            for (int i = 0; i < n; i++) {
                for (int j = 0; j < outDim; j++) {
                    double sum = b[j];
                    for (int k = 0; k < x[i].length; k++) {
                        sum += x[i][k] * w[k][j];
                    }
                    out[i][j] = sum;
                }
            }
            return out;
        }

        private double[][] slice(double[][] x, int offset, int len) {
            int n = x.length;
            double[][] out = new double[n][len];
            for (int i = 0; i < n; i++) {
                System.arraycopy(x[i], offset, out[i], 0, len);
            }
            return out;
        }

        private double[][] add(double[][] a, double[][] b) {
            int n = a.length;
            int d = a[0].length;
            double[][] out = new double[n][d];
            for (int i = 0; i < n; i++) {
                for (int j = 0; j < d; j++) {
                    out[i][j] = a[i][j] + b[i][j];
                }
            }
            return out;
        }

        private double[][] layerNorm(double[][] x) {
            // 同外层layerNorm
            int n = x.length;
            int d = x[0].length;
            double[][] out = new double[n][d];
            double eps = 1e-12;
            for (int i = 0; i < n; i++) {
                double mean = 0.0;
                for (double v : x[i]) mean += v;
                mean /= d;
                double var = 0.0;
                for (double v : x[i]) var += (v - mean) * (v - mean);
                var /= d;
                for (int j = 0; j < d; j++) {
                    out[i][j] = (x[i][j] - mean) / Math.sqrt(var + eps);
                }
            }
            return out;
        }

        private double[][] softmax(double[][] x) {
            int n = x.length;
            double[][] out = new double[n][n];
            for (int i = 0; i < n; i++) {
                double max = Double.NEGATIVE_INFINITY;
                for (double v : x[i]) if (v > max) max = v;
                double sum = 0.0;
                for (int j = 0; j < n; j++) {
                    out[i][j] = Math.exp(x[i][j] - max);
                    sum += out[i][j];
                }
                for (int j = 0; j < n; j++) out[i][j] /= sum;
            }
            return out;
        }
    }

    static class MaskResult {
        int[] maskedIds;
        int[] labels;
        boolean[] maskPositions;

        MaskResult(int[] maskedIds, int[] labels, boolean[] maskPositions) {
            this.maskedIds = maskedIds;
            this.labels = labels;
            this.maskPositions = maskPositions;
        }
    }

    /**
     * 主函数:演示BERT前向传播与MLM预测
     */
    public static void main(String[] args) {
        // 小型BERT配置
        int vocabSize = 1000;
        int hiddenSize = 128;
        int numHeads = 4;
        int numLayers = 2;
        int maxSeqLen = 32;
        int maskTokenId = 999; // [MASK] token

        BERT bert = new BERT(vocabSize, hiddenSize, numHeads, numLayers, maxSeqLen, 0.1);

        // 构造示例输入序列
        int[] inputIds = {1, 45, 232, 87, 156, 333, 78, 2}; // [CLS] ... [SEP]
        int[] tokenTypeIds = new int[inputIds.length]; // 单句,全为0

        // 生成掩码输入
        Random rand = new Random(123);
        MaskResult maskResult = createMaskedInput(inputIds, maskTokenId, vocabSize, rand);

        System.out.println("原始输入: " + Arrays.toString(inputIds));
        System.out.println("掩码输入: " + Arrays.toString(maskResult.maskedIds));

        // 前向传播
        double[][] hidden = bert.forward(maskResult.maskedIds, tokenTypeIds);
        System.out.println("隐藏表示维度: [" + hidden.length + ", " + hidden[0].length + "]");

        // MLM预测
        double[][] logits = bert.mlmPredict(hidden);

        // 计算损失
        double loss = bert.computeMLMLoss(logits, maskResult.labels, maskResult.maskPositions);
        System.out.printf("MLM Loss: %.4f%n", loss);

        // 展示掩码位置的预测结果
        System.out.println("\n掩码位置预测:");
        for (int i = 0; i < maskResult.maskPositions.length; i++) {
            if (maskResult.maskPositions[i]) {
                int predictedId = argmax(logits[i]);
                System.out.printf("  位置 %d: 真实=%d, 预测=%d%n", i, maskResult.labels[i], predictedId);
            }
        }
    }

    private static int argmax(double[] arr) {
        int maxIdx = 0;
        for (int i = 1; i < arr.length; i++) {
            if (arr[i] > arr[maxIdx]) maxIdx = i;
        }
        return maxIdx;
    }
}

关键设计解析

多头自注意力机制

每个注意力头独立学习不同的语义关系。通过将隐藏向量分割为numHeads份,模型可以同时关注句法结构、语义关联、指代关系等不同层面的信息。Scaled Dot-Product Attention中的1/sqrt(d)缩放因子防止内积过大导致softmax梯度消失。

掩码语言建模策略

BERT的掩码策略精心设计:80%概率替换为[MASK]标记,10%概率替换为随机词,10%概率保持不变。这种混合策略防止模型仅学习[MASK]位置的特定表示,迫使模型真正理解上下文语义。

层归一化与残差连接

Transformer层中,Layer Normalization对每个样本独立归一化,稳定深层网络训练。残差连接(x + F(x))缓解梯度消失问题,使得BERT可以堆叠多达24层(BERT-Large)。

复杂度分析

组件 时间复杂度 空间复杂度
嵌入层 O(n·d) O(V·d + n·d)
多头注意力 O(n²·d) O(n·d)
前馈网络 O(n·d²) O(d²)
总体(L层) O(L·n²·d) O(L·d²)

其中n为序列长度,d为隐藏维度,V为词表大小。自注意力的O(n²)复杂度是BERT处理长文本时的主要瓶颈。

扩展方向

  1. NSP任务:实现下一句预测(Next Sentence Prediction),用于学习句子间关系。
  2. GELU激活:用高斯误差线性单元替代ReLU,平滑梯度流。
  3. AdamW优化器:实现权重衰减解耦的Adam优化器进行预训练。
  4. 分词器:集成WordPiece或BPE子词分词,处理未登录词。