引言
Word2Vec是2013年Google提出的经典词嵌入模型,能够将词语映射到低维连续向量空间,使得语义相近的词在向量空间中距离相近。本文用Java实现Word2Vec的核心训练算法——Skip-Gram模型结合负采样(Negative Sampling),帮助读者理解词向量从无到有的训练过程。
核心原理
Skip-Gram模型
Skip-Gram的目标是根据中心词预测其上下文词。给定中心词 w_t,模型预测窗口内的上下文词 w_{t-c}, …, w_{t+c}(不包括自身)。
负采样优化
原始Softmax分母需要遍历整个词表,计算代价极高。负采样通过将多分类问题转化为二分类问题来加速训练:
- 正样本:中心词与真实上下文词的组合 (w_t, w_c)
- 负样本:中心词与从噪声分布中随机采样的词的组合 (w_t, w_n)
目标函数变为最大化正样本的sigmoid得分,同时最小化负样本的sigmoid得分。
目标函数
J = log σ(v’{w_O} · v{w_I}) + Σ_{i=1}^{k} E_{w_i ~ P_n(w)} [log σ(-v’{w_i} · v{w_I})]
其中 v_{w_I} 是中心词向量,v’_{w_O} 是上下文词向量,k 是负采样数量。
Java实现
项目结构
src/
├── Word2Vec.java // 核心训练类
├── HuffmanTree.java // 霍夫曼树(用于层次Softmax,本文使用负采样)
├── Word2VecDemo.java // 演示主类
└── utils/
└── DataUtils.java // 数据预处理工具
核心训练类
import java.util.*;
/**
* Word2Vec Skip-Gram + 负采样实现
*/
public class Word2Vec {
private final int vectorSize; // 词向量维度
private final int windowSize; // 上下文窗口大小
private final int negativeSamples; // 负采样数量
private final double learningRate; // 学习率
private final int minCount; // 最小词频阈值
// 词表
private Map<String, Integer> wordToId;
private Map<Integer, String> idToWord;
private int vocabSize;
// 词向量: syn0是中心词向量, syn1Neg是负采样上下文向量
private double[][] syn0;
private double[][] syn1Neg;
// 词频与负采样噪声分布
private double[] noiseDistribution;
private int[] wordFreq;
private int totalWords;
private final Random rand;
public Word2Vec(int vectorSize, int windowSize, int negativeSamples,
double learningRate, int minCount) {
this.vectorSize = vectorSize;
this.windowSize = windowSize;
this.negativeSamples = negativeSamples;
this.learningRate = learningRate;
this.minCount = minCount;
this.rand = new Random(42);
}
/**
* 构建词表
*/
public void buildVocab(List<String> sentences) {
Map<String, Integer> freqMap = new HashMap<>();
for (String sentence : sentences) {
String[] words = sentence.trim().toLowerCase().split("\\s+");
for (String word : words) {
if (word.isEmpty()) continue;
freqMap.merge(word, 1, Integer::sum);
}
}
wordToId = new HashMap<>();
idToWord = new HashMap<>();
List<Integer> freqs = new ArrayList<>();
for (Map.Entry<String, Integer> entry : freqMap.entrySet()) {
if (entry.getValue() >= minCount) {
int id = wordToId.size();
wordToId.put(entry.getKey(), id);
idToWord.put(id, entry.getKey());
freqs.add(entry.getValue());
}
}
vocabSize = wordToId.size();
wordFreq = new int[vocabSize];
for (int i = 0; i < vocabSize; i++) {
wordFreq[i] = freqs.get(i);
totalWords += wordFreq[i];
}
// 构建负采样的噪声分布 (使用词频的0.75次幂)
noiseDistribution = new double[vocabSize];
double sum = 0;
for (int i = 0; i < vocabSize; i++) {
noiseDistribution[i] = Math.pow(wordFreq[i], 0.75);
sum += noiseDistribution[i];
}
for (int i = 0; i < vocabSize; i++) {
noiseDistribution[i] /= sum;
}
System.out.println("词表大小: " + vocabSize);
}
/**
* 初始化词向量
*/
public void initVectors() {
syn0 = new double[vocabSize][vectorSize];
syn1Neg = new double[vocabSize][vectorSize];
for (int i = 0; i < vocabSize; i++) {
for (int j = 0; j < vectorSize; j++) {
syn0[i][j] = (rand.nextDouble() - 0.5) / vectorSize;
}
}
}
/**
* 根据噪声分布采样负样本词ID
*/
private int sampleNegative() {
double r = rand.nextDouble();
double cumulative = 0;
for (int i = 0; i < vocabSize; i++) {
cumulative += noiseDistribution[i];
if (r <= cumulative) {
return i;
}
}
return vocabSize - 1;
}
/**
* Sigmoid函数
*/
private double sigmoid(double x) {
if (x > 6) return 1.0;
if (x < -6) return 0.0;
return 1.0 / (1.0 + Math.exp(-x));
}
/**
* 训练一个中心词-上下文词对
*/
private void trainPair(int centerId, int contextId) {
double[] centerVec = syn0[centerId];
double[] neu1e = new double[vectorSize]; // 误差累积
// 正样本训练
double dot = 0;
for (int j = 0; j < vectorSize; j++) {
dot += centerVec[j] * syn1Neg[contextId][j];
}
double g = (1 - sigmoid(dot)) * learningRate;
for (int j = 0; j < vectorSize; j++) {
neu1e[j] += g * syn1Neg[contextId][j];
syn1Neg[contextId][j] += g * centerVec[j];
}
// 负样本训练
for (int n = 0; n < negativeSamples; n++) {
int negId = sampleNegative();
if (negId == contextId) continue; // 跳过正样本
dot = 0;
for (int j = 0; j < vectorSize; j++) {
dot += centerVec[j] * syn1Neg[negId][j];
}
g = (0 - sigmoid(dot)) * learningRate;
for (int j = 0; j < vectorSize; j++) {
neu1e[j] += g * syn1Neg[negId][j];
syn1Neg[negId][j] += g * centerVec[j];
}
}
// 更新中心词向量
for (int j = 0; j < vectorSize; j++) {
centerVec[j] += neu1e[j];
}
}
/**
* 训练模型
*/
public void train(List<String> sentences, int epochs) {
for (int epoch = 0; epoch < epochs; epoch++) {
int processed = 0;
for (String sentence : sentences) {
String[] words = sentence.trim().toLowerCase().split("\\s+");
List<Integer> ids = new ArrayList<>();
for (String word : words) {
Integer id = wordToId.get(word);
if (id != null) ids.add(id);
}
// Skip-Gram: 每个词作为中心词,预测窗口内的上下文
for (int i = 0; i < ids.size(); i++) {
int center = ids.get(i);
int start = Math.max(0, i - windowSize);
int end = Math.min(ids.size(), i + windowSize + 1);
for (int j = start; j < end; j++) {
if (i == j) continue;
trainPair(center, ids.get(j));
}
}
processed++;
}
System.out.println("Epoch " + (epoch + 1) + "/" + epochs + " 完成");
}
}
/**
* 计算两个词的余弦相似度
*/
public double similarity(String word1, String word2) {
Integer id1 = wordToId.get(word1.toLowerCase());
Integer id2 = wordToId.get(word2.toLowerCase());
if (id1 == null || id2 == null) return -1;
double dot = 0, norm1 = 0, norm2 = 0;
for (int i = 0; i < vectorSize; i++) {
dot += syn0[id1][i] * syn0[id2][i];
norm1 += syn0[id1][i] * syn0[id1][i];
norm2 += syn0[id2][i] * syn0[id2][i];
}
return dot / (Math.sqrt(norm1) * Math.sqrt(norm2));
}
/**
* 获取词的向量
*/
public double[] getVector(String word) {
Integer id = wordToId.get(word.toLowerCase());
return id != null ? syn0[id].clone() : null;
}
}
演示主类
import java.util.*;
public class Word2VecDemo {
public static void main(String[] args) {
// 构造简单语料(实际应用应使用大规模文本)
List<String> sentences = Arrays.asList(
"king is a strong man",
"queen is a wise woman",
"boy is a young man",
"girl is a young woman",
"prince is a young king",
"princess is a young queen",
"man is strong",
"woman is wise",
"prince is a boy will be king",
"princess is a girl will be queen"
);
// 训练Word2Vec模型
Word2Vec model = new Word2Vec(10, 2, 5, 0.025, 1);
model.buildVocab(sentences);
model.initVectors();
model.train(sentences, 1000);
// 测试语义相似度
System.out.println("\n=== 语义相似度测试 ===");
System.out.printf("king vs queen: %.4f%n", model.similarity("king", "queen"));
System.out.printf("king vs prince: %.4f%n", model.similarity("king", "prince"));
System.out.printf("king vs princess: %.4f%n", model.similarity("king", "princess"));
System.out.printf("boy vs girl: %.4f%n", model.similarity("boy", "girl"));
System.out.printf("man vs woman: %.4f%n", model.similarity("man", "woman"));
System.out.printf("king vs man: %.4f%n", model.similarity("king", "man"));
System.out.printf("queen vs woman: %.4f%n", model.similarity("queen", "woman"));
}
}
复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 词表构建 | O(N) | N为总词数 |
| 单次训练更新 | O(k · d) | k为负采样数,d为向量维度 |
| 完整训练 | O(E · W · k · d) | E为epoch数,W为窗口内词对数 |
| 相似度计算 | O(d) | 向量维度 |
负采样将Softmax的 O(V) 复杂度降为 O(k),其中 V 是词表大小,k 通常取5-20,极大地加速了训练。
总结
本文用Java实现了Word2Vec的Skip-Gram模型与负采样优化,完整覆盖了词表构建、向量初始化、噪声采样、前向传播与反向更新。读者可基于此扩展实现CBOW模型、层次Softmax,或将其应用到文本分类、推荐系统等下游任务中。