4、能量函数与边际损失函数归纳总结

好,咱们今天聊聊 TransE 里最核心的两个东西:能量函数和边际损失函数。

说白了,能量函数就是衡量一个三元组(头实体、关系、尾实体)到底有多“合理”。而边际损失函数,就是告诉模型怎么去学、学到什么程度才算好。这两个东西一配合,TransE 才能跑起来。

我个人习惯把能量函数看作一把尺子,把损失函数看作一个教练。尺子量好坏,教练定标准。嗯,咱们一个一个掰开来看。

4.1 能量函数:怎么判断一个三元组合不合理?

TransE 的核心假设特别简单:头实体向量 + 关系向量 ≈ 尾实体向量。

那能量函数呢,就是来衡量这个“≈”到底差了多少。公式长这样:

f(h, r, t) = || h + r - t ||_p

这里 || · ||_p 可以是 L1 范数或者 L2 范数。我个人在项目中常用 L1 范数,为什么?因为 L1 对异常值没那么敏感,训练过程中更稳定。你想想看,L2 会把大误差放大得更大,有时候一个离谱的负样本就能把梯度带偏。

能量值越小,说明这个三元组越合理。比如(阿司匹林,治疗,头痛),能量值应该很低。而 (阿司匹林,治疗,汽车),能量值就得高。

范数类型 公式 特点 我的建议
L1 |h + r - t| 梯度恒定,对异常值不敏感 推荐,训练更平滑
L2 (h + r - t)² 梯度随误差增大,对异常敏感 调试时可尝试,收敛可能更快

注意,这里的实体和关系都是嵌入向量,维度要一致。比如都是 100 维或 200 维。维度怎么选?

💡 我的经验:维度选择

5 万实体、1 万关系的药物图谱,我用 128 维就够。但如果你数据量更大(比如几十万实体),我建议试试 200 维。低了欠拟合,高了容易过拟合。

来看一段 Python 实现,其实特别简单:

def energy_function(h, r, t, norm=1):
    """
    计算三元组能量值
    :param h: 头实体向量
    :param r: 关系向量
    :param t: 尾实体向量
    :param norm: 1 为 L1,2 为 L2
    :return: 能量值(标量)
    """
    if norm == 1:
        return torch.sum(torch.abs(h + r - t))
    elif norm == 2:
        return torch.sum((h + r - t) ** 2)

4.2 边际损失函数:让正例和负例拉开距离

光能算能量还不够,你得告诉模型:正样本(正确的三元组)能量要低,负样本(错误的三元组)能量要高,而且两者之间要留出足够宽的缓冲区。

这个缓冲区,就是边际(margin)。边际损失函数的公式长这样:

L = Σ max(0, γ + f(h, r, t) - f(h', r, t'))

其中:
γ —— 边际值,超参数
f(h, r, t) —— 正样本能量
f(h', r, t') —— 负样本能量

为什么叫“边际”?说白了,正样本的能量必须比负样本的能量至少低 γ 这么多。如果差值没达到 γ,就有损失;如果已经超过了,那就没有损失,模型就不需要更新了。

我在项目中遇到过一个问题:γ 设得太小,比如 0.1,正负样本几乎分不开;设得太大,比如 10,模型会一直尝试拉开距离,训练根本稳不下来。所以 γ 的选择很关键。

⚠️ 我曾经踩过的坑

有一回我把 γ 设成了 50,想着“越大越安全”,结果训练了三天都没收敛。后来改成 1.0,两小时就好了。记住:边际不是越大越好,而是刚好够用。

再来看损失函数的实现:

def margin_loss(pos_energy, neg_energy, margin=1.0):
    """
    计算边际损失
    :param pos_energy: 正样本能量
    :param neg_energy: 负样本能量
    :param margin: 边际值
    :return: 损失值(标量)
    """
    # 核心:正样本 - 负样本 + 边际,取正的部分
    losses = torch.relu(margin + pos_energy - neg_energy)
    return torch.mean(losses)

4.3 负样本怎么产生?

我刚才提到了负样本,但没有细说。实际训练时,你用的一般是正样本三元组。那负样本从哪来?替换法。

  • 替换头实体:把正确的头实体换成随机的其他实体
  • 替换尾实体:把正确的尾实体换成随机的其他实体

比如正样本(阿司匹林,治疗,头痛),可以变成:

  • (对乙酰氨基酚,治疗,头痛)—— 替换头实体
  • (阿司匹林,治疗,胃痛)—— 替换尾实体

这两者都是负样本,能量应该高。但要注意:替换的时候,有可能运气不好,替换成了另一个正确的三元组。比如(对乙酰氨基酚,治疗,头痛)其实也是正确的!这就叫“假阴性”。

💡 避坑指南

我曾经用统一的训练集去采样,结果负样本里有 5% 是假阴性。后来我改用“伯努利采样”:以 50% 的概率替换头实体,50% 替换尾实体,并且过滤掉训练集中已知的正确三元组。这能大幅度提高模型质量。

4.4 训练流程:把能量函数和损失函数串起来

现在我们来梳理一下整个训练步骤:

  1. 采样一个正样本:从训练集随机取一个三元组
  2. 生成对应的负样本:替换头或尾实体
  3. 计算正样本能量:f(h, r, t) = ||h + r - t||
  4. 计算负样本能量:f(h', r, t') = ||h' + r - t'||
  5. 计算损失:max(0, γ + f(h, r, t) - f(h', r, t'))
  6. 反向传播:更新实体和关系的嵌入向量
  7. 重复:直到损失收敛

来看一下完整的训练循环:

for epoch in range(num_epochs):
    total_loss = 0
    for batch in dataloader:
        # batch 包含正样本的 (h, r, t)
        pos_h, pos_r, pos_t = batch
        
        # 生成负样本
        neg_h, neg_r, neg_t = generate_negative(pos_h, pos_r, pos_t)
        
        # 计算能量
        pos_energy = energy_function(pos_h, pos_r, pos_t)
        neg_energy = energy_function(neg_h, neg_r, neg_t)
        
        # 计算损失
        loss = margin_loss(pos_energy, neg_energy, margin=1.0)
        
        # 反向传播
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        
        total_loss += loss.item()
    
    print(f"Epoch {epoch}: Loss = {total_loss / len(dataloader):.4f}")

4.5 关键超参数速查表

我这里整理了一个表格,方便你以后调参时快速参考:

参数 推荐范围 过大 过小
嵌入维度 d 50~200 过拟合 欠拟合
边际值 γ 0.5~5.0 不收敛 正负样本无法区分
学习率 lr 0.001~0.01 振荡 收敛太慢
范数类型 p L1 或 L2 —— ——

我个人常用的组合是:d=128, γ=1.0, lr=0.005, L1。这组参数在药物图谱上表现相当稳健。

4.6 核心逻辑归纳:一张图看懂

好,接下来我用一张 SVG 图把能量函数和边际损失函数的关系理清楚:

能量函数与边际损失函数核心逻辑 输入三元组 (h, r, t) 能量函数 f = || h + r - t || 边际损失 L = max(0, γ + f₊ - f₋) 正样本 f(h, r, t) ↓ 负样本 f(h', r, t') ↑ 度量差值 Δ = f₊ - f₋ 期望 Δ ≤ -γ 训练目标:让正样本能量远小于负样本能量 正样本低 → 损失小 | 负样本高 → 损失小 | 两者接近 → 损失大

图里可以看到:正样本能量往下走,负样本能量往上走,两者差距够大时损失为 0。这就是边际损失的精髓。

4.7 归纳总结:记住这四点

最后,我把能量函数和边际损失函数的核心要点提炼成几句话,帮你快速记住:

  • 能量函数:f = ||h + r - t||,越小越合理。
  • 边际损失:确保正样本能量 ≤ 负样本能量 - γ。
  • γ 是安全边际:推荐 1.0 起步,根据验证集调整。
  • L1 比 L2 更稳健:我推荐 L1,尤其是在数据有噪音的药物图谱中。

其实这两个函数加在一起,干的事特别简单:拉开好三元组和坏三元组之间的距离。你先把这个逻辑理解透,后面再怎么调参、怎么扩展,都离不开这个基础。