引言
束搜索(Beam Search)是序列生成任务中广泛使用的启发式搜索算法,在机器翻译、语音识别、文本摘要等领域发挥着关键作用。与贪心搜索每次只保留一个最优候选不同,束搜索在每一步保留 k 个最优候选(称为束宽,beam width),在探索更多可能性的同时控制计算开销。本文将用Java实现标准束搜索及其长度归一化变体,并结合机器翻译场景展示其序列解码能力。
核心原理
贪心搜索的局限
贪心搜索(Greedy Decoding)每一步选择概率最高的词,但局部最优不等于全局最优。一个早期的高概率选择可能导致后续生成质量急剧下降。
束搜索策略
束搜索维护一个大小为 k 的候选集合(束)。在每一步:
- 对当前束中每个候选,扩展所有可能的下一个词
- 计算每个扩展路径的累积得分
- 从所有扩展结果中保留得分最高的 k 个作为新束
- 重复直到生成结束符或达到最大长度
长度归一化
由于长序列的联合概率是多个小于1的数相乘,束搜索天然偏向短序列。长度归一化通过将得分除以序列长度来消除这种偏差:
score(y) = (1 / |y|^α) · Σ_{t=1}^{|y|} log P(y_t | y_{<t}, x)
其中 α 为长度惩罚系数,通常取0.6-1.0。
Java实现
项目结构
src/
├── BeamSearch.java // 核心束搜索实现
├── SequenceNode.java // 搜索树节点
└── BeamSearchDemo.java // 演示主类
搜索树节点
import java.util.ArrayList;
import java.util.List;
/**
* 束搜索中的序列节点
* 代表一个部分解码的序列及其累积得分
*/
public class SequenceNode implements Comparable<SequenceNode> {
public final List<Integer> tokens; // 已生成的词ID序列
public final double logProb; // 累积对数概率
public final boolean isComplete; // 是否已生成结束符
public SequenceNode(List<Integer> tokens, double logProb, boolean isComplete) {
this.tokens = new ArrayList<>(tokens);
this.logProb = logProb;
this.isComplete = isComplete;
}
/**
* 扩展当前序列,添加一个新词
*/
public SequenceNode extend(int token, double tokenLogProb) {
List<Integer> newTokens = new ArrayList<>(tokens);
newTokens.add(token);
return new SequenceNode(newTokens, logProb + tokenLogProb, token == 0); // 0表示结束符
}
public int length() {
return tokens.size();
}
/**
* 长度归一化得分
*/
public double normalizedScore(double alpha) {
return logProb / Math.pow(length(), alpha);
}
@Override
public int compareTo(SequenceNode other) {
// 按累积对数概率降序排列
return Double.compare(other.logProb, this.logProb);
}
@Override
public String toString() {
return "SequenceNode{tokens=" + tokens + ", logProb=" + String.format("%.4f", logProb) + "}";
}
}
束搜索核心实现
import java.util.*;
/**
* 束搜索解码器
*
* @param <T> 模型接口类型,提供nextTokenLogProbs方法
*/
public class BeamSearch<T extends LanguageModel> {
private final T model;
private final int beamWidth;
private final int maxLength;
private final double lengthPenalty;
private final int eosToken; // 结束符ID
public BeamSearch(T model, int beamWidth, int maxLength,
double lengthPenalty, int eosToken) {
this.model = model;
this.beamWidth = beamWidth;
this.maxLength = maxLength;
this.lengthPenalty = lengthPenalty;
this.eosToken = eosToken;
}
/**
* 执行束搜索解码
* @param encoderOutput 编码器输出上下文
* @return 最优的k个完整序列,按得分降序排列
*/
public List<SequenceNode> decode(double[] encoderOutput) {
// 初始化: 从空序列开始
PriorityQueue<SequenceNode> beam = new PriorityQueue<>();
beam.offer(new SequenceNode(Collections.emptyList(), 0.0, false));
// 存储已完成的序列
List<SequenceNode> completed = new ArrayList<>();
for (int step = 0; step < maxLength; step++) {
PriorityQueue<SequenceNode> candidates = new PriorityQueue<>();
// 对当前束中每个候选进行扩展
for (SequenceNode node : beam) {
if (node.isComplete) {
// 已完成序列不再扩展,直接保留
candidates.offer(node);
continue;
}
// 获取模型预测的下一个词的对数概率分布
double[] logProbs = model.nextTokenLogProbs(encoderOutput, node.tokens);
// 只保留概率最高的topK个扩展,减少计算量
int topK = Math.min(logProbs.length, beamWidth * 2);
int[] topIndices = getTopKIndices(logProbs, topK);
for (int idx : topIndices) {
SequenceNode newNode = node.extend(idx, logProbs[idx]);
candidates.offer(newNode);
}
}
// 从所有候选中选择最优的beamWidth个作为新束
beam = new PriorityQueue<>();
while (!candidates.isEmpty() && beam.size() < beamWidth) {
SequenceNode best = candidates.poll();
if (best.isComplete) {
completed.add(best);
} else {
beam.offer(best);
}
}
// 如果所有候选都已完成,提前终止
if (beam.isEmpty()) break;
}
// 将未完成的序列也加入结果
completed.addAll(beam);
// 按长度归一化得分排序
completed.sort((a, b) -> Double.compare(
b.normalizedScore(lengthPenalty),
a.normalizedScore(lengthPenalty)
));
return completed.subList(0, Math.min(beamWidth, completed.size()));
}
/**
* 获取对数概率最高的k个索引
*/
private int[] getTopKIndices(double[] logProbs, int k) {
Integer[] indices = new Integer[logProbs.length];
for (int i = 0; i < indices.length; i++) indices[i] = i;
Arrays.sort(indices, (a, b) -> Double.compare(logProbs[b], logProbs[a]));
int[] result = new int[k];
for (int i = 0; i < k; i++) result[i] = indices[i];
return result;
}
}
/**
* 语言模型接口
*/
interface LanguageModel {
/**
* 预测下一个词的对数概率分布
* @param context 编码器上下文
* @param prefix 已生成的词序列
* @return 词表上每个词的对数概率
*/
double[] nextTokenLogProbs(double[] context, List<Integer> prefix);
}
演示:简单翻译模型
import java.util.*;
/**
* 模拟一个简单的统计机器翻译模型
*/
class SimpleTranslationModel implements LanguageModel {
private final int vocabSize;
private final Random rand;
public SimpleTranslationModel(int vocabSize) {
this.vocabSize = vocabSize;
this.rand = new Random(42);
}
@Override
public double[] nextTokenLogProbs(double[] context, List<Integer> prefix) {
// 模拟模型输出: 基于前缀长度和上下文生成概率分布
double[] probs = new double[vocabSize];
double sum = 0;
for (int i = 0; i < vocabSize; i++) {
// 模拟一些词概率更高(如高频词)
probs[i] = Math.exp(-0.1 * i) + rand.nextDouble() * 0.5;
if (prefix.size() > 0 && i == prefix.get(prefix.size() - 1)) {
// 降低重复词的概率
probs[i] *= 0.3;
}
sum += probs[i];
}
// 转换为对数概率
double[] logProbs = new double[vocabSize];
for (int i = 0; i < vocabSize; i++) {
logProbs[i] = Math.log(probs[i] / sum);
}
return logProbs;
}
}
/**
* 束搜索演示主类
*/
public class BeamSearchDemo {
public static void main(String[] args) {
int vocabSize = 100;
int beamWidth = 5;
int maxLength = 10;
double lengthPenalty = 0.8;
int eosToken = 0;
SimpleTranslationModel model = new SimpleTranslationModel(vocabSize);
BeamSearch<SimpleTranslationModel> decoder = new BeamSearch<>(
model, beamWidth, maxLength, lengthPenalty, eosToken
);
// 模拟编码器输出
double[] encoderOutput = new double[]{1.0, 0.5, 0.3};
System.out.println("=== 束搜索解码 (beamWidth=" + beamWidth + ") ===\n");
List<SequenceNode> results = decoder.decode(encoderOutput);
System.out.println("Top-" + results.size() + " 解码结果:");
for (int i = 0; i < results.size(); i++) {
SequenceNode node = results.get(i);
System.out.printf("%d. 序列: %s%n", i + 1, node.tokens);
System.out.printf(" 累积对数概率: %.4f%n", node.logProb);
System.out.printf(" 长度归一化得分: %.4f%n", node.normalizedScore(lengthPenalty));
System.out.printf(" 长度: %d, 完成: %s%n%n", node.length(), node.isComplete);
}
// 对比: 贪心搜索
System.out.println("=== 贪心搜索对比 ===");
BeamSearch<SimpleTranslationModel> greedyDecoder = new BeamSearch<>(
model, 1, maxLength, lengthPenalty, eosToken
);
List<SequenceNode> greedyResults = greedyDecoder.decode(encoderOutput);
if (!greedyResults.isEmpty()) {
SequenceNode greedy = greedyResults.get(0);
System.out.println("贪心结果: " + greedy.tokens);
System.out.println("贪心得分: " + String.format("%.4f", greedy.normalizedScore(lengthPenalty)));
}
}
}
复杂度分析
| 策略 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 贪心搜索 | O(T · V) | O(T) |
| 束搜索(束宽=k) | O(T · k · V · log V) | O(T · k) |
| 束搜索(topK剪枝) | O(T · k² · log V) | O(T · k) |
其中 T 为目标序列最大长度,V 为词表大小,k 为束宽。通过topK剪枝(每步只扩展概率最高的几个词),可将每步扩展复杂度从 O(V) 降至 O(k),整体接近 O(T · k²)。
优化策略
1. 多样化束搜索(Diverse Beam Search)
通过添加多样性惩罚项,鼓励不同束组生成差异更大的序列,避免多个结果过于相似。
2. 约束束搜索(Constrained Beam Search)
在解码过程中强制包含特定词或短语,适用于术语翻译、关键词保留等场景。
3. 非自回归解码
使用迭代 refine 策略,一次性预测整个序列然后逐步修正,突破自回归的 O(T) 串行瓶颈。
总结
本文用Java实现了束搜索算法及其长度归一化变体,通过维护多个候选序列在解码空间中进行启发式搜索,有效平衡了生成质量与计算效率。读者可将此实现集成到机器翻译系统、对话模型或代码生成工具中,并通过调整束宽和长度惩罚系数来适配不同任务需求。