条件随机场(Conditional Random Field,CRF)是概率图模型中的经典判别式方法,特别适用于序列标注任务,如命名实体识别、词性标注和分词等。与隐马尔可夫模型不同,CRF直接建模条件概率 P(Y|X),避免了HMM的观测独立假设限制。本文用Java实现线性链CRF,讲解特征函数设计、前向-后向算法以及维特比解码,包含完整可运行的代码。
一、问题建模与数学基础
给定输入序列 X = (x₁, x₂, ..., xₙ) 和标注序列 Y = (y₁, y₂, ..., yₙ),线性链CRF建模条件概率为:
P(Y|X) = (1/Z(X)) * exp(Σ_k λ_k * f_k(y_{t-1}, y_t, X, t))
其中:
– f_k 为特征函数(分为状态特征和转移特征)
– λ_k 为特征权重(模型参数)
– Z(X) 为归一化因子(配分函数)
特征函数分为两类:
– 转移特征:f(y_{t-1}, y_t, X, t),依赖于相邻标签和当前位置
– 状态特征:f(y_t, X, t),仅依赖当前标签和输入
二、特征工程与特征模板
import java.util.*;
/**
* 特征模板:定义从序列中提取哪些特征
*/
public class FeatureTemplate {
// 状态特征模板窗口大小
public int windowSize;
public FeatureTemplate(int windowSize) {
this.windowSize = windowSize;
}
/**
* 为位置t提取状态特征(基于输入序列X)
* @param x 输入序列(如词序列)
* @param t 当前位置
* @return 特征名称列表
*/
public List<String> extractStateFeatures(String[] x, int t) {
List<String> features = new ArrayList<>();
int n = x.length;
// 当前词
features.add("w0=" + x[t]);
// 窗口内的词特征
for (int offset = -windowSize; offset <= windowSize; offset++) {
int pos = t + offset;
if (pos >= 0 && pos < n) {
features.add("w" + offset + "=" + x[pos]);
} else {
features.add("w" + offset + "=_BOS_"); // 序列开头
if (pos >= n) features.set(features.size() - 1, "w" + offset + "=_EOS_");
}
}
// 词的前缀/后缀特征(假设为中文)
String word = x[t];
if (word.length() >= 1) {
features.add("first=" + word.charAt(0));
features.add("last=" + word.charAt(word.length() - 1));
}
if (word.length() >= 2) {
features.add("prefix2=" + word.substring(0, 2));
features.add("suffix2=" + word.substring(word.length() - 2));
}
return features;
}
}
三、特征字典与参数管理
/**
* 特征-参数管理器
*/
public class FeatureManager {
private final Map<String, Integer> featureToId = new HashMap<>();
private final List<String> idToFeature = new ArrayList<>();
private final Map<String, Integer> labelToId = new HashMap<>();
private final List<String> idToLabel = new ArrayList<>();
private double[] weights; // 特征权重
public int getOrCreateFeatureId(String feature) {
return featureToId.computeIfAbsent(feature, k -> {
int id = idToFeature.size();
idToFeature.add(k);
return id;
});
}
public int getOrCreateLabelId(String label) {
return labelToId.computeIfAbsent(label, k -> {
int id = idToLabel.size();
idToLabel.add(k);
return id;
});
}
public int getLabelId(String label) { return labelToId.getOrDefault(label, -1); }
public String getLabel(int id) { return id < idToLabel.size() ? idToLabel.get(id) : null; }
public int labelCount() { return idToLabel.size(); }
public int featureCount() { return idToFeature.size(); }
public void initWeights() {
weights = new double[featureCount()];
// 可设置小的随机初始化
Random rand = new Random(42);
for (int i = 0; i < weights.length; i++) {
weights[i] = rand.nextGaussian() * 0.01;
}
}
public double getWeight(int fid) { return weights[fid]; }
public void setWeight(int fid, double w) { weights[fid] = w; }
public void addWeight(int fid, double delta) { weights[fid] += delta; }
public double[] getWeights() { return weights; }
}
四、前向-后向算法
前向-后向算法是CRF训练的核心,用于高效计算配分函数 Z(X) 和边缘概率。
/**
* 线性链CRF模型
*/
public class LinearChainCRF {
private final FeatureManager fm;
private final FeatureTemplate template;
private final double l2Reg; // L2正则化系数
public LinearChainCRF(FeatureManager fm, FeatureTemplate template, double l2Reg) {
this.fm = fm;
this.template = template;
this.l2Reg = l2Reg;
}
/**
* 计算位置t、标签y的局部特征分数(状态特征+转移特征)
* @param x 输入序列
* @param t 位置
* @param yCurrent 当前标签ID
* @param yPrev 前一标签ID(-1表示无)
*/
public double score(String[] x, int t, int yCurrent, int yPrev) {
double s = 0;
// 状态特征分数
List<String> stateFeats = template.extractStateFeatures(x, t);
String labelStr = fm.getLabel(yCurrent);
for (String feat : stateFeats) {
String key = feat + "@" + labelStr;
int fid = fm.getOrCreateFeatureId(key);
s += fm.getWeight(fid);
}
// 转移特征分数
if (yPrev >= 0) {
String prevLabel = fm.getLabel(yPrev);
String transKey = prevLabel + "->" + labelStr;
int transFid = fm.getOrCreateFeatureId(transKey);
s += fm.getWeight(transFid);
}
return s;
}
/**
* 前向算法:计算alpha(对数域避免溢出)
* @return alpha[t][y] = 到位置t、标签y的累计对数概率(未归一化)
*/
public double[][] forward(String[] x) {
int n = x.length;
int L = fm.labelCount();
double[][] alpha = new double[n][L];
// 初始化t=0
for (int y = 0; y < L; y++) {
alpha[0][y] = score(x, 0, y, -1);
}
// 递推
for (int t = 1; t < n; t++) {
for (int y = 0; y < L; y++) {
double maxVal = Double.NEGATIVE_INFINITY;
for (int yp = 0; yp < L; yp++) {
double val = alpha[t - 1][yp] + score(x, t, y, yp);
if (val > maxVal) maxVal = val;
}
// 数值稳定:log-sum-exp技巧
double sum = 0;
for (int yp = 0; yp < L; yp++) {
sum += Math.exp(alpha[t - 1][yp] + score(x, t, y, yp) - maxVal);
}
alpha[t][y] = maxVal + Math.log(sum);
}
}
return alpha;
}
/**
* 后向算法
*/
public double[][] backward(String[] x) {
int n = x.length;
int L = fm.labelCount();
double[][] beta = new double[n][L];
// 初始化t=n-1
for (int y = 0; y < L; y++) {
beta[n - 1][y] = 0; // log(1) = 0
}
for (int t = n - 2; t >= 0; t--) {
for (int y = 0; y < L; y++) {
double maxVal = Double.NEGATIVE_INFINITY;
for (int yn = 0; yn < L; yn++) {
double val = beta[t + 1][yn] + score(x, t + 1, yn, y);
if (val > maxVal) maxVal = val;
}
double sum = 0;
for (int yn = 0; yn < L; yn++) {
sum += Math.exp(beta[t + 1][yn] + score(x, t + 1, yn, y) - maxVal);
}
beta[t][y] = maxVal + Math.log(sum);
}
}
return beta;
}
/**
* 计算配分函数 Z(X)
*/
public double logZ(String[] x) {
double[][] alpha = forward(x);
int n = x.length;
double maxVal = Double.NEGATIVE_INFINITY;
for (int y = 0; y < fm.labelCount(); y++) {
if (alpha[n - 1][y] > maxVal) maxVal = alpha[n - 1][y];
}
double sum = 0;
for (int y = 0; y < fm.labelCount(); y++) {
sum += Math.exp(alpha[n - 1][y] - maxVal);
}
return maxVal + Math.log(sum);
}
/**
* 计算边缘概率 P(y_t = y | X)
*/
public double[][] marginalProb(String[] x) {
double[][] alpha = forward(x);
double[][] beta = backward(x);
double logz = logZ(x);
int n = x.length;
int L = fm.labelCount();
double[][] prob = new double[n][L];
for (int t = 0; t < n; t++) {
for (int y = 0; y < L; y++) {
prob[t][y] = Math.exp(alpha[t][y] + beta[t][y] - logz);
}
}
return prob;
}
}
五、维特比解码算法
维特比算法用于预测时寻找概率最大的标注序列,是动态规划在序列问题中的经典应用。
/**
* 维特比解码:寻找最优标签序列
*/
public class ViterbiDecoder {
/**
* @param crf 模型
* @param x 输入序列
* @return 最优标签ID序列
*/
public static int[] decode(LinearChainCRF crf, String[] x, FeatureManager fm) {
int n = x.length;
int L = fm.labelCount();
// dp[t][y] = 到位置t、标签y的最大分数
double[][] dp = new double[n][L];
int[][] path = new int[n][L]; // 记录前驱标签
// 初始化
for (int y = 0; y < L; y++) {
dp[0][y] = crf.score(x, 0, y, -1);
path[0][y] = -1;
}
// 递推
for (int t = 1; t < n; t++) {
for (int y = 0; y < L; y++) {
double maxScore = Double.NEGATIVE_INFINITY;
int bestPrev = -1;
for (int yp = 0; yp < L; yp++) {
double s = dp[t - 1][yp] + crf.score(x, t, y, yp);
if (s > maxScore) {
maxScore = s;
bestPrev = yp;
}
}
dp[t][y] = maxScore;
path[t][y] = bestPrev;
}
}
// 回溯
int[] bestPath = new int[n];
double bestFinal = Double.NEGATIVE_INFINITY;
for (int y = 0; y < L; y++) {
if (dp[n - 1][y] > bestFinal) {
bestFinal = dp[n - 1][y];
bestPath[n - 1] = y;
}
}
for (int t = n - 2; t >= 0; t--) {
bestPath[t] = path[t + 1][bestPath[t + 1]];
}
return bestPath;
}
}
六、参数估计:SGD训练
/**
* CRF训练器(SGD + L2正则)
*/
public class CRFTrainer {
/**
* 单样本梯度计算与参数更新
*/
public static void trainStep(LinearChainCRF crf, FeatureManager fm,
String[] x, int[] yTrue,
double lr, double l2Reg) {
int n = x.length;
int L = fm.labelCount();
// 前向-后向
double[][] alpha = crf.forward(x);
double[][] beta = crf.backward(x);
double logz = crf.logZ(x);
// 计算转移边缘概率 P(y_{t-1}, y_t | X)
double[][][] transProb = new double[n][L][L];
for (int t = 1; t < n; t++) {
for (int yp = 0; yp < L; yp++) {
for (int y = 0; y < L; y++) {
transProb[t][yp][y] = Math.exp(
alpha[t - 1][yp] + crf.score(x, t, y, yp) + beta[t][y] - logz
);
}
}
}
// 计算状态边缘概率
double[][] stateProb = new double[n][L];
for (int t = 0; t < n; t++) {
for (int y = 0; y < L; y++) {
stateProb[t][y] = Math.exp(alpha[t][y] + beta[t][y] - logz);
}
}
// 计算特征期望并更新权重
// 真实路径的特征计数
Map<Integer, Integer> trueFeatureCount = new HashMap<>();
for (int t = 0; t < n; t++) {
List<String> feats = crf.template.extractStateFeatures(x, t);
String label = fm.getLabel(yTrue[t]);
for (String f : feats) {
int fid = fm.getOrCreateFeatureId(f + "@" + label);
trueFeatureCount.merge(fid, 1, Integer::sum);
}
if (t > 0) {
String trans = fm.getLabel(yTrue[t - 1]) + "->" + label;
int fid = fm.getOrCreateFeatureId(trans);
trueFeatureCount.merge(fid, 1, Integer::sum);
}
}
// 模型期望 - 真实计数 = 负梯度
// 状态特征期望
for (int t = 0; t < n; t++) {
List<String> feats = crf.template.extractStateFeatures(x, t);
for (int y = 0; y < L; y++) {
String label = fm.getLabel(y);
for (String f : feats) {
int fid = fm.getOrCreateFeatureId(f + "@" + label);
double modelExpect = stateProb[t][y];
double trueCount = trueFeatureCount.getOrDefault(fid, 0);
// 只更新真实路径出现过的特征(简化)
if (trueFeatureCount.containsKey(fid)) {
double grad = modelExpect - trueCount;
fm.addWeight(fid, -lr * (grad + l2Reg * fm.getWeight(fid)));
}
}
}
}
// 转移特征期望
for (int t = 1; t < n; t++) {
for (int yp = 0; yp < L; yp++) {
for (int y = 0; y < L; y++) {
String trans = fm.getLabel(yp) + "->" + fm.getLabel(y);
int fid = fm.getOrCreateFeatureId(trans);
if (trueFeatureCount.containsKey(fid)) {
double grad = transProb[t][yp][y] - trueFeatureCount.getOrDefault(fid, 0);
fm.addWeight(fid, -lr * (grad + l2Reg * fm.getWeight(fid)));
}
}
}
}
}
}
七、完整演示:中文分词
public class CRFDemo {
public static void main(String[] args) {
// 简化版中文分词:BIES标注
// B=词首, I=词中, E=词尾, S=单字词
String[][] trainX = {
"今天 天气 真好".split(" "),
"我 喜欢 编程".split(" "),
"自然 语言 处理 很 有趣".split(" "),
"机器 学习 是 热门 领域".split(" "),
"深度 学习 改变 世界".split(" "),
};
String[][] trainY = {
"B E B E B E".split(" "),
"S B E B E".split(" "),
"B E B E B E S B E".split(" "),
"B E B E S B E B E".split(" "),
"B E B E B E B E".split(" "),
};
FeatureManager fm = new FeatureManager();
FeatureTemplate template = new FeatureTemplate(1);
// 注册所有标签
for (String[] labels : trainY) {
for (String l : labels) fm.getOrCreateLabelId(l);
}
fm.initWeights();
LinearChainCRF crf = new LinearChainCRF(fm, template, 0.01);
// 训练
System.out.println("Training CRF...");
int epochs = 100;
for (int epoch = 0; epoch < epochs; epoch++) {
double totalLoss = 0;
for (int i = 0; i < trainX.length; i++) {
int[] yIds = new int[trainY[i].length];
for (int j = 0; j < trainY[i].length; j++) {
yIds[j] = fm.getLabelId(trainY[i][j]);
}
CRFTrainer.trainStep(crf, fm, trainX[i], yIds, 0.01, 0.01);
// 计算对数似然损失
double logz = crf.logZ(trainX[i]);
double trueScore = 0;
for (int t = 0; t < trainX[i].length; t++) {
int yp = (t > 0) ? yIds[t - 1] : -1;
trueScore += crf.score(trainX[i], t, yIds[t], yp);
}
totalLoss += logz - trueScore;
}
if (epoch % 20 == 0) {
System.out.printf("Epoch %d: loss=%.4f\n", epoch, totalLoss);
}
}
// 测试解码
System.out.println("\nTesting Viterbi decoding...");
String[] testX = "深度 学习 非常 强大".split(" ");
int[] predIds = ViterbiDecoder.decode(crf, testX, fm);
System.out.print("Predicted: ");
for (int id : predIds) {
System.out.print(fm.getLabel(id) + " ");
}
System.out.println();
// 另一测试
String[] testX2 = "自然 语言".split(" ");
int[] predIds2 = ViterbiDecoder.decode(crf, testX2, fm);
System.out.print("Predicted: ");
for (int id : predIds2) {
System.out.print(fm.getLabel(id) + " ");
}
System.out.println();
// 展示前向-后向的概率分布
System.out.println("\nMarginal probabilities for testX2:");
double[][] marg = crf.marginalProb(testX2);
for (int t = 0; t < testX2.length; t++) {
System.out.printf(" %s: ", testX2[t]);
for (int y = 0; y < fm.labelCount(); y++) {
System.out.printf("%s=%.3f ", fm.getLabel(y), marg[t][y]);
}
System.out.println();
}
}
}
八、CRF与HMM对比
| 特性 | HMM | CRF |
|---|---|---|
| 模型类型 | 生成式 | 判别式 |
| 建模目标 | P(X, Y) | P(Y|X) |
| 特征设计 | 受限(发射概率) | 灵活(任意特征函数) |
| 标注偏置 | 有(局部归一化) | 无(全局归一化) |
| 特征依赖 | 观测独立假设 | 无此假设 |
| 训练算法 | EM/Baum-Welch | SGD/L-BFGS |
| 解码算法 | 维特比 | 维特比 |
九、复杂度分析
| 操作 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 特征提取(每位置) | O(F) | O(F) |
| 前向/后向算法 | O(T·L²) | O(T·L) |
| 维特比解码 | O(T·L²) | O(T·L) |
| 单步SGD更新 | O(T·L²·F) | O(T·L²) |
其中 T 为序列长度,L 为标签数,F 为特征数。
十、总结
本文完整实现了线性链条件随机场的核心算法:
- 特征模板设计:定义状态特征(基于输入窗口)和转移特征(基于相邻标签),灵活表达序列上下文
- 前向-后向算法:对数域动态规划高效计算配分函数与边缘概率,避免数值下溢
- 维特比解码:基于动态规划寻找全局最优标注序列,时间复杂度
O(T·L²) - SGD参数估计:通过对比模型期望与真实路径特征计数进行梯度下降训练
CRF作为序列标注的标准基线模型,其全局归一化和灵活特征设计使其在实际NLP任务中表现稳定可靠。