Jack He's Blog

some idea or work

KNN-OOD

Out-of-Distribution Detection with Deep Nearest Neighbors-腾讯云开发者社区-腾讯云

简单地使用从标准分类模型的特征嵌入导出的最近邻距离是不高效的

为了检测 OOD 样本,我们计算测试输入的嵌入与训练集的嵌入之间的第 k 个最近邻 (KNN) 距离,并使用基于阈值的标准来确定输入是否为 OOD。

KNN 提供了令人信服的优势:
(1)无分布假设
(2)与 OOD 无关(即,仅根据 ID 数据估计距离阈值,并且不依赖于未知数据的信息)
(3)容易使用(即不需要计算协方差矩阵的逆矩阵,因为协方差矩阵在数值上可能不稳定)
(4)与模型无关(即测试过程适用于不同的模型架构和训练损失)

紧凑且标准化的特征空间是 OOD 检测的最近邻方法成功的关键
基于 KNN 的 OOD 检测可以拒绝相当于贝叶斯最优估计器的输入

理论

基于距离的方法利用从模型中提取的特征嵌入,并在测试 OOD 样本距离 ID 数据相对较远的假设下进行操作。
image.png

之前基于距离的 OOD 检测方法采用参数密度估计,并将特征嵌入空间建模为多元高斯分布的混合(Lee et al., 2018)。 然而,这种方法对学习的特征空间做出了很强的分布假设,这可能不一定成立。

基于 KNN 的 OOD 检测器可以拒绝相当于估计的贝叶斯二元决策函数的输入。 较小的 KNN 距离 rk(zi) 直接转化为较高的 ID 概率,反之亦然。

检测OOD

具体来说,我们计算每个测试图像的嵌入与训练集之间的第 k 个最近邻距离,并使用一个简单的基于阈值的标准来确定输入是否 OOD。

我们使用归一化倒数第二个特征 $z = φ(x)/ \lvert \lvert φ(x) \rvert \rvert_{2}$ 进行 OOD 检测,其中 $φ : X → R^m$ 是特征编码器。

将训练数据的嵌入集表示为 $Z_n = (z_1, z_2, …, z_n)$。 在测试过程中,我们推导测试样本 $x^*$ 的归一化特征向量 $z^*$,并计算相对于嵌入向量 $z_i ∈ Z_n$ 的欧几里得距离 $\lvert \lvert z_i -z^* \rvert \rvert_{2}$。

对集合 $Z_{n}$ 按距离 $\parallel z_{i} - z^* \parallel_{2}$ 递增重新排序,得到序列 $Z’_{n} = (z_{(1)}, z_{(2)}, …, z_{(n)})$。OOD 检测决策函数定义为:

$$
G(z^*;k) = \mathbf{1}{-r_{k}(z^*)}
$$

其中 $r_{k}(z^*) = \parallel z^* - z_{(k)} \parallel_{2}$ 是到第 $k$ 近邻的距离,$\mathbf{1}{\cdot}$ 为指示函数。通常选择阈值 $\lambda$ 使 95% ID 数据正确分类,该阈值独立于 OOD 数据。

实验结果

实验指标

(1) ID样本真阳性率为95%时OOD样本的假阳性率(FPR95)
(2) 受试者工作特征曲线下面积(AUROC)
(3) ID 分类精度 (ID ACC)
(4) 每幅图像的推理时间(以毫秒为单位,测试图像的平均值)。

训练损失

在我们的实验中,我们的目的是证明基于 KNN 的 OOD 检测与训练过程无关,并且与在不同损失下训练的模型兼容。 我们考虑两种类型的损失函数,分别有和没有对比学习。
我们采用
(1)交叉熵损失,这是分类中最常用的训练目标
(2)监督对比学习(SupCon)(Khosla 等人,2020)——表示学习的最新发展,它利用了标签 通过在嵌入空间中对齐属于同一类的样本来获取信息

实验细节

我们使用 ResNet-18 作为 CIFAR-10 的骨干网。 遵循 Khosla 等人的原始设置,具有 SupCon 损失的模型训练了 500 个 epoch,批量大小为 1024。温度 τ 为 0.1。
我们执行最近邻搜索的倒数第二个特征的维度是 512。投影头的维度是 128。我们使用从 0.5 开始的余弦退火学习率(Loshchilov & Hutter,2016)。

我们对 CIFAR-10 使用 k = 50,对 CIFAR-100 使用 k = 200,使用验证方法从 k = {1, 10, 20, 50, 100, 200, 500, 1000, 3000, 5000} 中选择 (Hendrycks 等人,2019)。 我们使用动量为 0.9、权重衰减为 10−4 的随机梯度下降来训练模型。 没有对比学习的模型训练了 100 个 epoch。 起始学习率为 0.1,在第 50、75 和 90 时期分别衰减 10 倍。

对比学习的帮助

 (1) 我们使用 UMAP (McInnes et al., 2018) 可视化图 1 中学习到的特征嵌入,其中颜色编码不同的类标签。 一个显着的观察结果是,SupCon 的表示比从 CE 损失获得的表示更具可区分性和紧凑性。 高质量的嵌入空间确实为基于 KNN 的 OOD 检测带来了好处。
 (2) 除了可视化之外,我们还使用 SupCon 与 CE 训练的嵌入来定量比较基于 KNN 的 OOD 检测的性能。 如表 1 所示,与使用经过 CE 损失训练的模型的嵌入相比,具有对比学习表示的 KNN+ 降低了所有测试 OOD 数据集上的 FPR95。

关于k

我们注意到几个有趣的观察结果:(1)最佳 OOD 检测(由 FPR95 测量)在不同的随机采样率 α 下保持相似。 (2) 最优 k 与我们的验证策略选择的一致。 例如,当α=100%时,最优k为1000; 当α = 1%时,最优k变为10。 (3) 当 k 相对较小(例如 k < 1000)时,改变 k 不会显着影响推理速度,如图 3 (a) 所示。

image.png

特征归一化

在本次消融中,我们对比了使用和不使用特征归一化的基于 KNN 的 OOD 检测的性能。 第 $k$ 个 N 距离可以分别通过 $r_{k}( \frac{φ(x)}{\lvert φ(x) \rvert })$ 和 $r_k(φ(x))$ 导出。如图 3 (b) 所示,使用特征归一化改进了与没有归一化的情况相比,FPR95 大幅提高了 61.05% 为了更好地理解这一点,我们研究了两个向量 $u$ 和 $v$ 之间的欧几里得距离 $r = \lvert \lvert u − v \rvert \rvert_{2}$ 。特征向量 $u$ 和 $v$ 的范数可以显着提高。

有趣的是,最近的研究分享了图4(a)中的观察结果,即 ID 数据具有比 OOD 数据更大的 L2 特征范数 (Tack et al., 2020; Huang et al., 2021)。 因此,ID 特征之间的欧几里德距离可能很大(图 4 (b),这与 ID 数据具有比 OOD 数据更小的 k-NN 距离的希望相矛盾。事实上,归一化有效地缓解了这个问题,如图所示。如图4(c)所示,归一化对于最近邻方法在 OOD 检测中的成功起着关键作用,如图3(b)所示。

image.png
image.png

使用倒数第二层的特征比使用投影头更好

在本文中,我们遵循SSD+的惯例,使用倒数第二层的特征来代替投影头。我们还在图3(C)中验证了在所有测试OOD数据集上使用倒数第二层的功能比使用投影头要好。这可能是因为倒数第二层比投影头保留了更多的信息,后者的尺寸要小得多。

image.png

目前存在的蜉蝣生物检测问题

  1. 表示能力不足
    1. 海洋中浮游生物的巨大多样性和丰富性不可避免地导致一些类群之间难以区分的相似性
    2. 不同的光学成像仪器受到自身物理和方法的限制,所获取的图像在分辨率、对比度、噪声、模糊、色彩、动态范围等方面具有不同的质量和限制
    3. 原位 2D 成像还伴随着表示 3D 目标的多种固有缺陷,例如不完整、遮挡和视角模糊等
  2. 数据分布漂移DSS
    1. 测试集与训练集的分布不一致
  3. OOD问题
  4. 数据分布不均匀
    1. 少样本问题
    2. 难以识别少数类群

标准

FPR95

14cd3637ef71ef8f0f1d908910f3544.jpg|300

FPR (False Positive Rate) 和 TPR (True Positive Rate) 是机器学习和统计学中用于评估分类器性能的重要指标。FPR(TPR@95%) 是指当分类器的 TPR(真正例率)达到 95% 时,对应的 FPR(假正例率) 值。

TPR增大的时候,会看到这个边界会往右走,而且我们希望边界还可以往下走(减少假阳性样本)

各术语含义:

  1. TPR (True Positive Rate)
    表示分类器正确识别正例的比例,公式为:
    $$\text{TPR} = \frac{\text{TP}}{\text{TP} + \text{FN}}$$
    其中,TP 是正确预测为正的样本数量,FN 是实际为正但被预测为负的样本数量。
  2. FPR (False Positive Rate)
    表示分类器错误将负例预测为正例的比例,公式为:
    $$\text{FPR} = \frac{\text{FP}}{\text{FP} + \text{TN}}$$
    其中,FP 是实际为负但被预测为正的样本数量,TN 是正确预测为负的样本数量。
  3. FPR@95%
    意味着在模型调整到能够达到 95% TPR 的点时(即能正确识别 95% 的正例),我们观察此时模型的 FPR。

FPR(TPR@95%) 的意义

  • 它衡量在模型保证高 TPR(真正例率)的情况下,对负例的误分类情况(假正例率)有多高。
  • 低 FPR(TPR@95%) 表明分类器在保持高真正例率时,对负例的误分类也很少,性能较好。

AUROC

AUPR

在评估分类模型(尤其是处理不平衡数据或分布外检测任务)时,AUPR(Area Under the Precision-Recall Curve)和FPR@95(False Positive Rate at 95% True Positive Rate)是两个常用的指标,但它们的设计目的和关注点存在显著差异。以下是详细分析:

1. 指标定义与核心目标

(1) AUPR(精确率-召回率曲线下面积)

  • 定义:通过不同分类阈值下的精确率(Precision)和召回率(Recall)绘制曲线,计算其下方的面积。
  • 目标
    • 衡量模型在正类样本(如异常/OOD样本)上的综合性能,尤其是在类别极度不平衡时(正样本远少于负样本)。
    • 反映模型对精确率(避免误报)和召回率(避免漏检)的平衡能力

(2) FPR@95(在召回率95%时的假阳性率)

  • 定义:当模型的召回率(True Positive Rate, TPR)被设定为95%时,对应的假阳性率(False Positive Rate, FPR)。
    • 例如:在OOD检测中,当模型检测到95%的OOD样本时,误将多少ID样本判定为OOD。
  • 目标
    • 衡量模型在确保高召回率(如不漏检关键样本)时,对负类样本的误判程度
    • 直接服务于高召回率优先的场景(如医疗诊断、自动驾驶中的安全关键任务)。

2. 核心区别

维度 AUPR FPR@95
关注点 正类(如OOD)的精确率与召回率的整体平衡 在高召回率(TPR=95%)下对负类的误判控制
数据敏感性 对类别不平衡敏感(适合正样本少的场景) 依赖阈值选择(关注单一操作点)
优化方向 综合提升精确率和召回率 在高召回率下最小化假阳性率
应用场景 数据不平衡、需全面评估正类性能 安全关键领域,要求严格避免漏检

3. 为什么两者的目的不一致?

(1) 问题本质不同

  • AUPR
    • 反映模型在所有可能阈值下对正类的识别能力,强调整体性能
    • 例如:在OOD检测中,若大部分OOD样本得分明显低于ID样本,AUPR会较高。
  • FPR@95
    • 仅关注特定阈值(TPR=95%)下的负类误判率,服务于特定需求(如“宁可误报,不可漏报”)。
    • 例如:在自动驾驶中,必须检测到95%的未知障碍物,同时控制误判已知物体的频率。

(2) 对类别不平衡的响应不同

  • AUPR
    • 在正样本极少时,PR曲线对模型性能变化更敏感,优于AUROC。
    • 例如:若正样本占比1%,AUPR=0.8表明模型在极端不平衡下仍表现良好。
  • FPR@95
    • 不直接受类别分布影响,但实际应用中若负样本(如ID数据)远多于正样本,FPR@95的微小上升可能导致大量误判。
    • 例如:FPR@95=5%时,若测试集中有100万个ID样本,误判数将高达5万。

(3) 优化目标的冲突

  • 提高AUPR:需要同时提升精确率和召回率,可能需要对分类边界进行全面优化。
  • 降低FPR@95:只需在高召回率区域(TPR≥95%)优化假阳性率,可能与全局性能无关。
    • 例如:一个模型可能在TPR=95%时表现极佳(FPR@95低),但在其他阈值下性能波动(导致AUPR一般)。

4. 实际场景中的典型关系

(1) 理想情况

若模型在所有阈值下均表现良好,AUPR和FPR@95可能同步优化。例如:

  • 完美分离ID和OOD样本时,AUPR=1.0,FPR@95=0%。

(2) 常见情况

两者常呈现权衡关系

  • 高AUPR但高FPR@95:模型整体区分能力强,但在高召回率区域(如TPR=95%)误判较多。
    • 例如:OOD检测模型对明显异常的样本敏感,但对边界样本(接近ID分布)误判率高。
  • 低FPR@95但低AUPR:模型在高召回率区域严格控制误判,但整体性能较差。
    • 例如:保守模型仅对极端OOD样本报警,漏检大量边界OOD样本,导致AUPR低。

5. 如何选择指标?

(1) 优先AUPR的场景

  • 数据极度不平衡(如OOD样本占比<1%)。
  • 需综合评估正类性能(如既要减少漏检,又要避免误报)。
  • 示例:金融欺诈检测,需平衡欺诈交易的召回率和正常交易的误拦截率。

(2) 优先FPR@95的场景

  • 安全关键任务(如医疗诊断、自动驾驶),漏检后果严重。
  • 明确要求高召回率(如“必须检测到95%的异常”)。
  • 示例:癌症筛查模型,要求尽可能检测所有疑似病例(TPR=95%),同时控制健康人的误诊率(FPR@95)。

6. 总结

  • AUPR和FPR@95的目的不一致
    • AUPR关注正类识别的全局平衡,FPR@95关注高召回率下的负类误判控制。
  • 实际应用中需根据需求选择
    • 若需全面评估模型对正类的识别能力,优先AUPR。
    • 若需保证高召回率下的可靠性,优先FPR@95。
  • 两者结合使用
    • 例如:在自动驾驶中,可同时要求AUPR>0.9(整体性能强)且FPR@95<5%(高召回率下误判可控)。

OOD

ID与OOD

ID指的是in-distribution数据,也就是我们熟悉的训练数据;OOD指的是out-of-distribution,在不同的领域也可能被叫做outlier或者是anomaly data,说的是与ID分布不一致的数据。

其实ID和OOD的界定比较模糊,通常我们是将语意信息相差较大的两个数据集构成ID和OOD。例如,我们在CIFAR-10上训练好了一个图像分类网络,那么对于这个网络来讲,CIFAR-10数据集的图像就是ID数据,而MNIST,或者是SVHN,以及LSUN等数据集就可以看做是OOD。

[!question] 通常一个比较难以回答的问题就是,在CIFAR-100上训练好的网络,那么CIFAR-10对于网络来说是OOD吗?因为二者相似性很高。
这个实际上由不同的观点,一部分人认为能识别来自与ID不同的类别就叫OOD;但另一部分人认为,应该选择协方差偏移的样本才叫OOD。
我们构造验证试验的时候,还是需要尽量选取语义信息具有差异性的两个数据集构成ID与OOD。

分布外检测在现实世界中部署机器模型时,可靠的分类器不仅应该准确地对已知的分布内 (ID) 样本进行分类,而且还应将任何 OOD 输入识别为“未知”。
这可以通过 OOD 检测器与分类模型 $f$ 配合来实现。 OOD 检测可以表述为二元分类问题。 在测试时,OOD 检测的目标是确定样本 $x ∈ X$ 是否来自 $P_{in} (ID)$ 或不是 (OOD)。 可以通过水平集估计做出决定:
image.png

OOD与常规问题的区别

传统模式识别系统假设:

  • 封闭世界假设
  • 训练测试域数据是独立同分布的(I.I.D)
  • 各类别数据量充足,不会出现不均衡问题

OOD问题与上面的假设均相反

与其他领域的一些区别

  1. Open Set Recognition(OSR)
    1. 不仅要求能够检测未知类别,还要求正确分类已知的类别。
    2. 评价标准:AUROC,AUPR,CCR@FPRx
  2. Out-of-Distribution Detection(OOD)
    1. 保证ID类测试样本的分类性能,拒绝OOD测试样本,ID样本往往具有多个类别,OOD的类别不能与ID的类别重合。
    2. 经常使用CIFAR-10作为ID样本进行训练,其他数据集如SVHN等作为OOD测试样本。
    3. 评价标准:AUROC,AUPR,or F-scores,FPR@TPRx,TNR@TPRx
  3. 和OSR的区别:
    1. OSR常常用一个数据集,一部分类别作为ID一部分作为OOD。而OOD则是一个数据集作为ID样本,再用别的数据集作为OOD样本(仍要保证类别不重合)。
    2. OOD检测方法的范围更广,如多标签分类,解空间更大。

OOD检测的主流是Detecting semantic shift。当然在一些领域中,也十分的关注covariate shift,即要拒绝和原本的ID样本具有不同分布的样本,即使类别可能一样。如医学图像或者隐私敏感的领域,就不能让模型对具有不同分布的样本进行泛化。

1. 定义与核心目标

  • 异常检测(AD)
    旨在识别与“正常”数据显著偏离的样本,即使这些异常可能仍属于训练数据的分布(例如同一分布中的低概率事件)。例如:信用卡欺诈检测、工业设备故障发现等。
  • 分布外检测(OOD)
    专注于检测来自与训练数据完全不同的分布的数据。例如:在猫狗分类模型中,输入汽车的图像会被标记为OOD。

2. 数据假设

  • AD
    • 假设异常是同一分布中的罕见事件(如分布的“尾部”),或由相同生成机制中的异常参数导致(如设备故障)。
    • 训练数据通常仅包含正常样本(无监督学习)。
  • OOD
    • 假设测试数据来自与训练数据完全无关的分布(如不同领域、不同采集条件)。
    • 训练时可能仅使用分布内数据,但需模型在推理时拒绝分布外样本。

3. 应用场景

  • AD的典型场景
    • 网络安全(入侵检测)、医疗诊断(罕见病识别)、质量控制(缺陷产品检测)。
    • 核心需求:在已知环境中捕捉异常模式。
  • OOD的典型场景
    • 自动驾驶(识别未知障碍物)、医疗AI(拒绝非医学图像输入)、开放世界分类(避免对未知类别过度自信)。
    • 核心需求:防止模型对不相关数据做出高置信度预测。

4. 方法与技术

  • AD常用方法
    • 统计方法(如高斯混合模型、马氏距离)。
    • 重建误差(自编码器、生成对抗网络)。
    • 单类分类(如单类SVM、孤立森林)。
  • OOD常用方法
    • 基于置信度(如Softmax阈值、蒙特卡洛Dropout)。
    • 特征空间分析(Mahalanobis距离、能量模型)。
    • 对抗训练或合成OOD样本(如ODIN、Outlier Exposure)。

5. 关键区别总结

维度 异常检测(AD) 分布外检测(OOD)
数据来源 同一分布中的异常(低概率事件) 完全不同的数据分布
问题焦点 应用层面的异常(如故障、欺诈) 数据分布层面的差异(跨域、跨类别)
训练假设 通常仅需正常数据(无监督) 可能需要分布内数据的表征学习
典型方法 重建误差、单类分类 置信度校准、特征空间度量

6. 重叠与联系

  • 交叉场景:某些情况下,OOD样本可能被视作AD中的异常(如模型未见过的类别)。
  • 技术共享:部分方法(如自编码器)可同时用于AD和OOD检测,但优化目标不同。

总结

  • AD 更关注在已知环境(同一分布)中发现异常,强调应用层面的异常模式。
  • OOD 更关注模型泛化性,强调识别与训练数据分布无关的样本,确保模型在开放环境中的安全性。
  • 两者相辅相成,实际应用中可能结合使用(如先检测OOD样本,再在分布内进行AD)。

常规数据集

首先我们介绍OOD样本,OOD样本的偏移包括两类,第一类是semantic shift (语义偏移),即OOD样本是来自于和ID(In-distribution)不同的类别(例如plankton 50 和后面的40个类别)。另一类是covariate shift(协方差偏移),即OOD样本来自于和ID样本不同的domain(如CIFAR-10和Imagenet)。在OOD Survey综述中,所讨论的shift,主要是semantic shift,即OOD样本和ID样本具有不同的类别。

在OOD研究中,常用的数据集主要是一些小规模数据,超大规模数据集目前还没有在研究中大范围的看到,这里介绍一下经常使用的几个并说明实验的构造方法。

MNIST,Fashion- MNIST,Omniglot:这是三个单通道的灰度数据集。MNIST与Fashion-MNIST大家都比较熟悉,一个是手写数字,一个是服装图像。Omniglot是一个在小样本学习中经常使用的数据集,它里面包含了1623个类别,每个类别仅有20个样本。在OOD检测中,通常是使用MNIST作为ID数据,将Fashion-MNIST与Omniglot的混合作为OOD数据;或者是将Fashion- MNIST作为ID数据,将MNIST与Omniglot的混合作为OOD数据。由于Omniglot并不适合训练常规的分类网络,因此通常不作为ID数据。

CIFAR-10,CIFAR-100,TinyImageNet,LSUN,SVHN:这是5个RGB三通道的彩色数据集。CIFAR-10与CIFAR-100大家应该都比较熟悉,在分类网络的评测中经常看到它们。TinyImageNet是类似于ImageNet数据集的一个规模较小的数据集,它里面包含有200个种类的数据。LSUN是一个大规模的场景识别数据集,包含厨房、客厅、卧室等等之类的图像。SVHN是街景数字识别,主要是一些门牌号上的数字。这些数据集的下载链接都比较容易找到,我把它们列在文章的最后。在OOD实验设计中,通常是有以下几种设计方法,当然这些组合方案并不绝对,只是为了方便与大家常用的组合进行比较:

  • CIFAR-10作为ID数据,SVHN、LSUN、TinyImageNet作为OOD数据
  • CIFAR-100作为ID数据,SVHN、LSUN、TinyImageNet作为OOD数据
  • SVHN作为ID数据,LSUN、CIFAR-10、TinyImageNet作为OOD数据

因为需要在ID数据上训练分类网络,因此,在设计实验时,通常会选择不太复杂的数据集作为ID,而将其他数据作为OOD数据。

在OOD检测的训练中,我们通常约定不可以使用任何形式的OOD数据,我们能接触到的只有ID数据,这也是符合OOD研究的目的的。在实际使用中,网络不可能会见到所有的OOD图像,因此,如果在网络接触到的OOD数据上评测它的拒识能力是没有意义的。在一些方法中,作者使用了OOD数据来finetune网络,这样得到的指标结果是偏高的,并且说服力不强。

OOD方法总结

Jingkang50/OODSurvey: The Official Repository for “Generalized OOD Detection: A Survey”

Generalized Out-of-Distribution Detection: A Survey

image.png
image.png

1. Classification-based Methods

简而言之,就是利用maximum softmax probability作为判断ID样本的分数。早期的OOD检测方法侧重于基于神经网络的输出来获得更好的OOD评分。

1.1 Output-based Methods
a. Post-hoc Detection:事后训练方法的优点是易于使用,无需修改训练过程和目标。研究工作包括ODIN score,energy score。
b. Confidence Enhancement Methods:训练神经网络在分布内和分布外的数据之间产生高度可区分的置信度分数。
c. Outlier Exposure:OOD检测方法的另一个分支是在训练过程中使用一组收集的OOD样本,或称“离群值”,以帮助模型学习ID/OOD差异。

1.2 Label Space Redesign
one-hot编码通常用于对分类信息进行编码。然而,one-hot编码忽略了标签之间的内在关系。例如,狗和猫之间的距离与狗和汽车之间有相同的距离是不合理的。为此,一些工作试图使用标签空间中的信息来进行OOD检测。

在测试时,将与来自不同头部的所有embedding vectors距离最小的label作为预测。

1.3 OOD Data Generation
当没有OOD样本可用时,一些方法尝试合成OOD样本以实现ID/OOD可分离性。现在的方法有利用GAN来进行操作的。

1.4 Gradient-based Methods
依赖于梯度值的OOD检测方法也是一个比较有趣的方向。ODIN方法(Enhancing The Reliability of Out-of-distribution Image Detection in Neural Networks)首先使用了梯度信息用于OOD检测,通过对输入增加一些小的扰动,检测梯度的变化。在输入增加的扰动,可以增强模型的预测置信度,最终在ID和OOD样本输入的softmax分数之间产生更大的差距,帮助判断样本是否为OOD样本。类似使用梯度值方法的还有(On the Importance of Gradients for Detecting Distributional Shifts in the Wild),该方法显式的从梯度空间推导评分函数。

1.5 Bayesian Models
最具代表性的Bayesian神经网络方法(Bayesian Learning for Neural Networks)由于其方法预测的不准确性和高额的计算代价被遗弃。进一步的探索采用自然梯度变分推理,实现实用且负担得起的现代深度学习训练,同时保留贝叶斯原则的好处(Practical Deep Learning with Bayesian Principles)。类似的工作还有(Predictive Uncertainty Estimation via Prior Networks;Reverse KL-Divergence Training of Prior Networks: Improved Uncertainty and Adversarial Robustness;Towards Maximizing the Representation Gap between In-Domain & Out-of-Distribution Examples;Locally Most Powerful Bayesian Test for Out-of-Distribution Detection using Deep Generative Models)

1.6 Large-scale OOD Detection
这个方向也是近期比较火的一个方向——大规模的OOD检测。例如,(MOS: Towards Scaling Out-of-distribution Detection for Large Semantic Space)揭示了在 CIFAR 基准上开发的方法可能无法有效地转化为具有大语义空间的 ImageNet 基准,突出了在大规模现实世界设置中评估 OOD 检测的必要性。该方法将大规模语义空间解耦为具有相似概念的小组,有助于简化已知类和未知类之间的边界。(Exploring the Limits of Out-of-Distribution Detection;Pretrained Transformers Improve Out-of-Distribution Robustness;OODformer: Out-Of-Distribution Detection Transformer.)这些方法证明了,大规模的预训练transformer模型可以有效提高方法在OOD任务上的性能。

2. Density-based Methods

OOD检测中的基于密度的方法显式地用一些概率模型对分布内数据进行建模,并将低密度区域的测试数据标记为OOD。一些作品尝试使用似然比来解决问题(Likelihood Ratios for Out-of-Distribution Detection;Input complexity and out-of-distribution detection with likelihood-based generative models)发现似然对输入复杂度表现出强烈的偏差,并提出了一种基于似然比的方法来补偿输入复杂度的影响。相对来说还是Classification-based Methods表现更好。

3. Distance-based Methods

基于距离的方法的基本思想是,OOD样本应该相对远离分布内类的质心或原型。(A Simple Fix to Mahalanobis Distance for Improving Near-OOD Detection)方法通过将图片的前景和背景分离,然后计算Mahalanobis distance。与参数化方法相比,最近的工作(Out-of-distribution Detection with Deep Nearest Neighbors)显示出非参数最近邻距离用于 OOD 检测的强大前景。非参数的方法无需提前对特征空间进行分布的假设,因此更加的简单、灵活和通用。

还有一些方法使用余弦相似度来判断测试样本特征和类内特征的距离,从而判断是否是OOD样本(Hyperparameter-Free Out-of-Distribution Detection Using Cosine Similarity;A Boundary Based Out-of-Distribution Classifier for Generalized Zero-Shot Learning)。此外,其他工作利用输入特征和类质心之间的径向基函数核(Uncertainty Estimation Using a Single Deep Deterministic Neural Network)、欧氏距离(Feature Space Singularity for Out-of-Distribution Detection.)和geodesic distance(Igeood: An Information Geometry Approach to Out-of-Distribution Detection)的距离。

除了计算样本与类质心之间的距离外,在主空间的正交补空间中的特征范数对OOD检测也是有效的(ViM: Out-Of-Distribution with Virtual-logit Matching)。CIDER(CIDER: Exploiting Hyperspherical Embeddings for Out-of-Distribution Detection)引入了一种用于OOD检测的新的表示学习框架,增大了不同类质心的最大角距离,并让类内样本尽可能的靠近类质心。

4. Reconstruction-based Methods

该类方法的核心想法是,encoder-decoder架构对于类内样本和分布外样本往往产生不同的输出。编码器解码器模型的输出表现可以用来判断样本时ID样本还是OOD样本(通常模型生成得到的类内样本效果会更好,以此来判断)。例如,如果一个模型只使用ID data来进行训练,那么输入OOD data,他便无法进行很好的处理,这样就能判断样本是不是OOD样本了。

但很显然,这样的方法是pixel-level的,因此训练成本会比较高。为了解决该问题,(Rethinking Reconstruction Autoencoder-Based Out-of-Distribution Detection)方法尝试reconstruct with hidden features,即只生成隐式特征,不需要恢复原图。

虽然是课上第一次学(噢不对,应该是在素红奶奶课上第一次学),但实际上之前已经学过很多次,现在对其数学原理进行进一步分析:

聚类分析属于非监督分类,也就是说基本上无先验知识可依据或参考。
聚类分析根据模式之间的相似性对模式进行分类,对一批没有标出类别的模式样本集,将相似的归为一类,不相似的归为另一类。

相似性

对于特征向量$\mathbf{X} = [x_{1},x_{2}, \dots x_{n}]^T$,将特征空间中向量与向量间的距离作为模式相似性的一种测量方法。以“距离”作为模式分类的一种依据(考)。除此以外还有基于密度的测量等方法。

教材主要介绍的是基于距离的聚类,故以下内容均在此前提下展开

聚类分析也会与数据的分布有关,如果数据是成多簇分布的,那么容易用距离函数进行分类;如果数据均在同一簇,则难以聚类分析

相似性测度(距离测量)

欧氏距离

$$
D(\mathbf{X_{i}},\mathbf{X_{j}})= \vert \vert \mathbf{X_{i}} - \mathbf{X_{j}} || = \sqrt{ (\mathbf{X_{i}-\mathbf{X_{j}}})^T(\mathbf{X_{i}-\mathbf{X_{j}}}) }
$$

  • 注意由于存在量纲的影响,需要对数据进行标准化,可以统一成标准正态分布或以下形式:

$$
\hat{x_{i}} = \frac{x_{i}}{\sum_{i=1}^{N}x_{i}}
$$

马氏距离

f166523c9289ebdce0b5757b0345ae5.png|500

常用平方形式表示,设$\mathbf{X}$为模式向量,$\mathbf{M}$为某类模式的均值向量,$\mathbf{C}$为该模式总体的协方差矩阵,则马氏距离定义为

$$
D^2 = (\mathbf{X}-\mathbf{M})^TC^{-1}(\mathbf{X}-\mathbf{M})
$$

其中,$\mathbf{C}$的计算方式为:

$$\begin{aligned}
\mathbf{C}=&E{(\mathbf{X-M})(\mathbf{X-M})^T}=E\begin{bmatrix}
(x_{1}-m_{1})\(x_{2}-m_{2})\ \vdots \ (x_{n}-m_{n})
\end{bmatrix}[(x_{1}-m_{1})\quad (x_{2} - m_{2}) \cdots (x_{n}-m_{n})] \ = &\begin{bmatrix}
&E(x_{1}-m_{1}) (x_{1}-m_{1}) &\cdots &E(x_{1}-m_{1}) (x_{n}-m_{n}) \ &\vdots &\ddots &\vdots \
&E(x_{n}-m_{n}) (x_{1}-m_{1}) &\cdots &E(x_{n}-m_{n}) (x_{n}-m_{n})
\end{bmatrix} \
= &\begin{bmatrix}
&\sigma_{11}^2 &\cdots &\sigma_{1n}^2\ &\vdots &\ddots &\vdots \
&\sigma_{n1}^2 &\cdots &\sigma_{nn}^2
\end{bmatrix}
\end{aligned}
$$

马氏距离的优点是排除了模式样本之间的相关性影响(考)。
例如我们取一个模式特征向量,可能其中有九个分量反映的是同一特征$A$,而只有一个分量反映特征$B$,这时如用欧氏距离计算,则主要反映了特征$A$,而用马氏距离计算则可避免这个缺点。
当$\mathbf{C}$为单位矩阵$\mathbf{I}$时,马氏距离等同于欧氏距离。

明氏距离

$$
D_{m}(\mathbf{X_{i},\mathbf{X_{j}}})=\left[ \sum_{k=1}^{n}\vert x_{ik}-x_{jk}\vert^m \right]^{1/m}
$$

当$m=2$时,明式距离即为欧式距离
当$m=1$时,有

$$
D_{m}(\mathbf{X_{i},\mathbf{X_{j}}})= \sum_{k=1}^{n}\vert x_{ik}-x_{jk}\vert
$$

此时即为曼哈顿距离

汉明距离

如果模式向量各分量仅取1或(-1),即为二值模式。
用汉明距离来衡量相似性

$$
D_{h}(\mathbf{X_{i}},\mathbf{X_{j}})=\frac{1}{2}\left( n-\sum_{k=1}^{n}x_{ik} \cdot x_{jk} \right)
$$

如果各分量取值均不同,则汉明距离为$n$;若各分量取值均相同,则汉明距离为0

角度(余弦)相似度

$$
\cos \theta = S(\mathbf{X_{i},X_{j}}) = \frac{\mathbf{X_{i}^T X_{j}}}{\vert \vert \mathbf{X_{i}} || \cdot ||\mathbf{X_{j}}||}
$$

  • 越接近1代表相似度越大
  • 模式向量$\mathbf{X_{i},X_{j}}$之间的夹角余弦,也是对应的两个单位向量的点积
  • 对坐标系旋转、放大缩小不变
  • 当取值仅为01二值时,$\mathbf{X_{i}^T X_{j}}$的值表示两向量共有的特征数目,而$\vert \vert \mathbf{X_{i}}|| \cdot ||\mathbf{X_{j}}|| = \sqrt{ (\mathbf{X_{i}^T X_{i}})(\mathbf{X_{j}^T X_{j}}) }$表示两向量中具有特征数目的几何平均。于是$S(\mathbf{X_{i},X_{j}})$表示两向量中具有共有特征数目的相似性测度

Tanimoto相似度(考)

Tanimoto测度(通常称为Tanimoto系数Tanimoto相似度)是用于衡量两个样本(通常是向量或集合)相似性的一种指标。它是Jaccard相似系数的一种推广,尤其常用于二值向量、集合、或化学分子结构的比较。在特定领域,如化学信息学,它被广泛用于比较分子指纹特征的相似性。

定义:

给定两个集合 $A$ 和 $B$,Tanimoto系数的定义与Jaccard相似系数类似,公式为:

$$
T(A, B) = \frac{|A \cap B|}{|A \cup B|}
$$

其中:

  • $|A \cap B|$ 是集合 $A$ 和 $B$ 的交集的大小,表示它们的共同元素的数量。
  • $|A \cup B|$ 是集合 $A$ 和 $B$ 的并集的大小,表示它们的所有不同元素的总数。

对于二进制向量(0和1构成的向量),Tanimoto系数可以推广为如下公式:

$$
T(\mathbf{x}, \mathbf{y}) = \frac{\mathbf{x} \cdot \mathbf{y}}{|\mathbf{x}|^2 + |\mathbf{y}|^2 - \mathbf{x} \cdot \mathbf{y}}=\frac{共有的特征数}{占有的特征数目的总数}
$$

其中:

  • $\mathbf{x} \cdot \mathbf{y}$ 表示两个向量的点积。
  • $|\mathbf{x}|^2$ 和 $|\mathbf{y}|^2$ 是向量 $\mathbf{x}$ 和 $\mathbf{y}$ 的范数(向量长度)的平方。
解释:
  1. 值范围:Tanimoto系数的值在0和1之间:
    • 当两个集合(或向量)完全相同,Tanimoto系数为1。
    • 当两个集合没有任何共同元素,Tanimoto系数为0。
  2. 应用:Tanimoto系数经常用于以下领域:
    • 集合相似性:用于评估两个集合的相似度,尤其是在文档分类或推荐系统中。
    • 化学信息学:用于比较分子指纹,帮助寻找具有相似化学性质的分子。
    • 机器学习和数据挖掘:在特征选择和相似性度量中,尤其是在稀疏向量或二值数据(如推荐系统的用户行为数据)中使用。

聚类准则

根据相似性测度确定的,衡量模式之间是否相似的标准。即把不同模式聚为一类,还是归为不同类的准则(考)

确定方式:

  • 阈值准则:根据规定的距离阈值进行分类
  • 函数准则:根据聚类准则函数进行分类的准则

函数准则

误差平方和

$$
J=\sum_{j=1}^{c}\sum_{\mathbf{X}\in S_{j}} ||\mathbf{X-M_{j}}||^2
$$

其中,$c$ 表示共有$c$个模式类,$\mathbf{M_{j}}=\frac{1}{N}\sum_{\mathbf{X}\in S_{j}}\mathbf{X}$,为 $S_{j }$中样本的均值向量,$N_{j}$为 $\mathbf{S_{j}}$ 中的样本数目

当$J$达到极小时,说明达到了满意的分类效果。这种准则通常称为最小方差划分
适用于各类样本密集且数量相差不多,而不同类间样本又明显分开的情况

基于距离阈值的聚类算法

近邻聚类法

  1. 任取样本$X_{i}$ 作为第一个聚类中心的初始值,如令$Z_{1}=X_{1}$ 。
  2. 计算样本$X_{2}$ 到$Z_{1}$ 的欧氏距离$D_{21}=|| X_{2}-Z_{1}||$,
    若$D_{21}>T$,定义一新的聚类中心$Z_{2} = X_{2}$ ;
    否则 $X_{2} \in $以$Z_{1}$为中心的聚类。
  3. 假设已有聚类中心$Z_{1},Z_{2}$,计算$D_{31}=|| X_{3}-Z_{1}||$和$D_{32}=|| X_{3}-Z_{2}||$
    若$D_{31}>T$且$D_{32}>T$,则建立第三个聚类中心$Z_{3}=X_{3}$
    否则 $X_{3} \in $离$Z_{1}$和$Z_{2}$最近的聚类。

算法特点

  1. 局限性:很大程度上依赖于第一个聚类中心的位置选择、待分类模式样本的排列次序、距离阈值T的大小以及样本分布的几何性质等。
  2. 优点:计算简单。(一种虽粗糙但快速的方法)

最大最小距离算法(小中取大)

  1. 选任意一模式样本做为第一聚类中心$Z_{1}$
  2. 选择离$Z_{1}$距离最远的样本作为第二聚类中心$Z_{2}$
  3. 逐个计算各模式样本与已确定的所有聚类中心之间的距离,并选出其中的最小距离。例当聚类中心数k=2时,计算$D_{i_{1}}=|| x_{1}-z_{1}||, D_{i_{2}}= || x_{1} - z_{2} ||$,找到$D_{i_{1}}, D_{i_{1}}$最小值 $\min (D_{i_{1}}, D_{i_{2}})$
  4. 在所有最小距离中选出最大距离,如该最大值达到$|| Z_{1}-Z_{2}||$的一定分数比值(阈值$T$) 以上,则相应的样本点取为新的聚类中心,返回3;否则,寻找聚类中心的工作结束。
    例如$k=2$,若$\max{\min(D_{i_{1}},D_{i_{2}})}>\theta|| Z_{1}-Z_{2}||$,则$Z_{3}$存在
  5. 重复步骤3,4,直到没有新的聚类中心出现为止。
  6. 将样本${X_{i}, i=1,2,\dots,N}$按最近距离划分到相应聚类中心对应的类别中。
    image.png

层次聚类法

每个样本先自成一类,然后按距离准则逐步合并,减少类数。

算法描述

  1. N个初始模式样本自成一类,即建立$N$类:
  2. $$
    G_{1}(0),G_{2}(0),\dots,G_{N}(0)
    $$

计算各类之间(即各样本间)的距离,得一$N×N$维距离矩阵D(0)。“0”表示初始状态。
2. 假设已求得距离矩阵$D(n)$(n为逐次聚类合并的次数),找出$D(n)$中的最小元素,将其对应的两类合并为一类。由此建立新的分类:$G_{1}(n+1),G_{2}(n+1)$
3. 计算合并后新类别之间的距离,得$D(n+1)$。
4. 跳至第2步,重复计算及合并。

结束条件

  1. 取距离阈值$T$,当$D(n)$的最小分量超过给定值 $T$ 时,算法停止。所得即为聚类结果。
  2. 或不设阈值T,一直将全部样本聚成一类为止,输出聚类的分级树

类间距离计算

最短距离法

$$
D_{HK}=\min{D(X_{H},X_{k})} X_{H} \in H, X_{K} \in K
$$

$D(X_{H},X_{K})$ H类中的某个样本$X_{H}$和K类中的某个样本$X_{K}$之间的欧式距离

最长距离法

$$
D_{HK}=\max{D(X_{H},X_{k})} X_{H} \in H, X_{K} \in K
$$

中间距离法

如果K类由I类和J类合并而成,则H和K类之间的距离为

$$
D_{HK}=\sqrt{ \frac{1}{2}D_{HI}^2 + \frac{1}{2} D_{HJ}^2 - \frac{1}{4} D_{IJ}^2}
$$

重心法

将每类中包含的样本数考虑进去。若$I$类中有$n_{I}$个样本,$J$类中有$n_{J}$个样本,则类与类之间的距离递推式为
$$
D_{HK}=\sqrt{ \frac{n_{I}}{n_{I}+n_{J}}D_{HI}^2 + \frac{n_{J}}{n_{I}+n_{J}} D_{HJ}^2 - \frac{n_{I}n_{J}}{(n_{I}+n_{J})^2} D_{IJ}^2}
$$

类平均距离

$$
D_{HK}=\sqrt{ \frac{1}{n_{H}n_{K}}\sum_{i \in H, j \in K} d_{ij}^2 }
$$
$d_{ij}^2$: H类任一样本$X_{i}$和K类任一样本$X_{j}$之间的欧氏距离平方。

(考)
image.png

image.png

image.png

image.png

Ward’s 簇间距离

$$
\begin{equation}
\begin{aligned}
d(C_{k},C_{j}) &= \sqrt{ 2\times \left( \sum_{x \in C_{k} \cup C_{j}} dist(x, \mu_{C_{k} \cup C_{j}})^2 - \left( \sum_{x \in C_{k}}dist(x, \mu_{C_{k}})^2+ \sum_{x \in C_{j}}dist(x, \mu_{C_{j}})^2\right) \right)} \
&=\sqrt{ \frac{2 \cdot count(C_{k}) \cdot count(C_{j})}{count(C_{k}) + count(C_{j})} }\cdot dist(\mu_{C_{k}},\mu C_{j}) \&= \sqrt{ 2\times (SST(C_{k}\cup C_{j})-(SST(C_{k})+SST(C_{j}))) }
\end{aligned}
\end{equation}
$$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
clustering_algorithms = (
('Single linkage', 'single'),
('Average linkage', 'average'),
('Complete linkage', 'complete'),
('Ward linkage', 'ward'),
)

for name, method in clustering_algorithms:

# 绘制树形图
fig, ax = plt.subplots()

plt.title(name)
dend = dendrogram(linkage(X,
method = method))

# 层次聚类
cluster = AgglomerativeClustering(n_clusters=3,
metric='euclidean',
linkage=method)

# 完成聚类预测
Z = cluster.fit_predict(X)

# 可视化聚类结果
fig, ax = plt.subplots()
plt.title(name)

# 可视化散点图
plt.scatter(x=X[:, 0], y=X[:, 1], c=Z, alpha=1.0,
linewidth = 1, edgecolor=[1,1,1])

ax.set_xticks(np.arange(4, 8.5, 0.5))
ax.set_yticks(np.arange(1.5, 5, 0.5))
ax.set_xlim(4, 8)
ax.set_ylim(1.5, 4.5)
plt.xlabel(iris.feature_names[0])
plt.ylabel(iris.feature_names[1])
ax.grid(linestyle='--', linewidth=0.25, color=[0.5,0.5,0.5])
ax.set_aspect('equal')
plt.show()

基于密度的聚类

[!note] 本部分主要介绍DBSCAN,其余方法作为补充

基本概念

DBSCAN算法包含以下几个基本概念:

  • $\varepsilon$邻域:对于一个给定的数据点,$\varepsilon$邻域是指在其半径$\varepsilon$范围内的所有数据点的集合。$\varepsilon$是一个用户定义的距离参数。
  • MinPts阈值(min_samples):MinPts是定义一个点是否为核心点的阈值。一个点的$\varepsilon$邻域内至少包含MinPts个数据点,该点才被认为是核心点。
  • 核心点:如果一个点的$\varepsilon$邻域内包含至少MinPts个点,则该点是一个核心点。
  • 边界点:边界点是指在核心点的$\varepsilon$邻域内,但自身的$\varepsilon$邻域内的点数小于MinPts的点。
  • 噪声点:既不是核心点也不是边界点的点被称为噪声点。
  • 密度可达:如果点P在点Q的$\varepsilon$邻域内,并且Q是核心点,那么点P是从点Q密度可达的。
  • 密度连接:如果存在一个点链,使得每一对相邻点之间都是密度可达的,那么两个点之间就是密度连接的。

聚类过程

给出平面内8个样本数据点,以每个数据点为中心,$ε$ 为半径扫描整个平面,且定义 min_samples = 4。 发现只有样本点 $x (5)$的 $ε$ 邻域内有 4 个样本点 (包括 $x (5)$自身);因此,$x5$为核心点,$x (2)、x (4)和 x (7)$为 边界点,剩余其他数据点为噪点。
image.png

如果一个点既是边界点又是核心点,那么此时会有两个簇连接在一起,以此类推:
image.png

调节参数

邻域范围

eps 控制邻域范围大小。eps 值选取过大,会导致整个数据集被分为一簇;但是 eps 取值过小,会 导致簇过多且分散,并且标记过多噪音点。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
for eps in np.array([0.1,0.2,0.4,0.6]):

dbscan = cluster.DBSCAN(eps=eps,min_samples=10)

y_pred = dbscan.fit_predict(X)

fig, ax = plt.subplots()

colors = np.array(list(islice(cycle(['#377eb8', '#ff7f00', '#4daf4a',
'#f781bf', '#a65628', '#984ea3',
'#999999', '#e41a1c', '#dede00']),
int(max(y_pred) + 1))))
# 增加黑色
colors = np.append(colors, ["#000000"])
# 绘制散点图
plt.scatter(X[:, 0], X[:, 1], s=10, color=colors[y_pred])

plt.title('eps = %0.2f' % eps)
plt.xlim(-2.5, 2.5)
plt.ylim(-2.5, 2.5)
plt.xticks(())
plt.yticks(())
plt.axis('equal')

plt.show()

邻域内样本个数

min_samples 调节 DBSCAN 算法对噪声的容忍度;当数据噪音过大时,应该适当提高 min_samples。
k 均值和 GMM 聚类算法需要预先声明聚类数量;但是,DBSCAN 则不需要。DBSCAN 聚类不需要 预设分布类型,不受数据分布影响,且可以分辨离群数据。
DBSCAN 算法对 eps 和 min_samples 这两个初始参数都很敏感;协同调节 eps 和 min_samples 两个参数显得非常重要。

image.png

优点:

  • 无需指定簇的个数:与K-means等算法不同,DBSCAN不需要预先指定聚类的数量。
  • 处理噪声:能够有效识别并处理噪声点。
  • 发现任意形状的簇:能够发现任意形状的簇,而不仅仅是圆形或球形的簇。

缺点:

  • 参数敏感:算法对参数Eps和MinPts较为敏感,选择不当会影响聚类结果。
  • 性能问题:在高维数据集上性能不佳,计算Eps邻域的时间复杂度为O(n^2),对于大规模数据集不够高效。
  • 不适用于不同密度的簇:如果数据集中的簇有显著不同的密度,DBSCAN可能无法很好地识别所有簇。
  • 对密度分布较为均匀的数据集,可能会出现聚类失效

动态聚类

K-means(考计算)

比如,二聚类问题有两个簇质心$\mu_{1}$和$\mu_{2}$
如果以欧式距离进行距离度量,那么离质心$\mu_{1}$更近的点,被划分为$C_{1}$簇;反之被划分为$C_{2}$簇

由于采用欧氏距离,图 1中簇质心 $µ_{1}$ 和 $µ_{2}$等高线为两组同心圆;同心圆颜色相同,代表距离簇质心 $µ_{1}$ 和 $µ_{2}$距离相同。因此,同色同心圆的交点位于决策边界上。

实际上就是质心之间的中垂线
三聚类时更加明显:

优化目标

将所有样本点划分为$K$簇,并使得簇内距离平方和最小

$$
\arg \min_{c} \sum_{k=1}^{K} \sum_{x \in C_{k}} ||x - \mu_{k}||^2
$$

对于每一个聚类集,将准则函数定义为

$$
J_{j} = \sum_{x \in C_{j}} ||x - \mu_{j}||^2
$$
由于要使得准则函数最小,对其求偏导

$$
\frac{\partial}{\partial \mu_{j}} \sum_{x \in C_{j}} ||x - \mu_{j}||^2 = \frac{\partial}{\partial \mu_{j}} \sum_{x \in C_{j}}(x-\mu_{j})^T(x-\mu_{j})=0
$$

需要对其进行展开

$$
\frac{\partial}{\partial \mu_{j}} \sum_{x \in C_{j}}(x-\mu_{j})^T(x-\mu_{j})=\frac{\partial}{\partial \mu_{j}} \sum_{x \in C_{j}}(x^Tx-2x^T\mu_{j}+\mu_{j}^2)=\sum_{x \in C_{j}}(-2x^T+2\mu_{j})=0
$$

其中,$-2$可以消去,再次进行展开

$$
\sum_{x \in C_{j}} x^T = \sum_{x \in C_{j}} \mu_{j}
$$

由于$\mu_{j}$与$x$无关,故可提到求和之外,右侧可化简

$$
\sum_{x \in C_{j}} x^T = |C_{j}| \mu_{j}
$$

解得

$$
\mu_{j} = \frac{1}{|C_{j}|}\sum_{x \in C_{j}} x^T
$$

说明$C_{j}$类的聚类中心应为该类样本的均值

迭代过程

此处$Z_i$和$\mu_{i}$意思等同,只是因为老师强制要求符号这么写,所以进行记录

  1. 任选K个初始聚类中心:$Z_{1}(1)$, $Z_{2}(1)$,…, $Z_{K}(1)$。(括号内序号表示迭代运算的次序号)
  2. 按最小距离原则将其余样品分配到K个聚类中心中的某一个,即:

$$
若\min {||x-Z_{i}(k)||}=||x-Z_{j}(k)|| = D_{j}(k), 则X \in C_{j}(k)
$$

  1. 计算各个聚类中心的新向量值:$Z_{j}(k+1),j=1,2,\dots,K$

$$
Z_{j}(k+1)=\frac{1}{|C_{j}|}\sum_{x \in C_{j}(k)}{x},\ j = 1,2,\dots,K
$$

  1. 如果$Z_{j}(k+1) \neq Z_{j}(k), j=1,2,\dots,K$,回到(2),重新分类迭代计算;如果取等,此时算法收敛

image.png
image.png
image.png
image.png
image.png

代码1 (比较复杂可以不看)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# 导入鸢尾花数据
iris = datasets.load_iris()
# 取出鸢尾花前两个特征
X_train = iris.data[:, :2]
y_train = iris.target

# 创建KMeans对象
kmeans = KMeans(n_clusters=3, n_init = 'auto')
# 使用KMeans算法训练数据
kmeans.fit(X_train)

# 生成网格数据
plot_step = 0.02
xx, yy = np.meshgrid(np.linspace(4, 8, int(4/plot_step + 1)),
np.linspace(1.5, 4.5, int(3/plot_step + 1)))

# 使用KMeans模型对网格中的点进行预测
# 并将预测结果整形成与网格相同形状的矩阵
Z = kmeans.predict(np.c_[xx.ravel(), yy.ravel()])
Z = Z.reshape(xx.shape)

fig, ax = plt.subplots()

# plot regions
plt.contourf(xx, yy, Z, cmap=cmap_light)

# plot sample data
plt.scatter(x=X_train[:, 0], y=X_train[:, 1], color=np.array([0, 68, 138])/255., alpha=1.0, linewidth = 1, edgecolor=[1,1,1])

# plot decision boundaries
plt.contour(xx, yy, Z, levels=[0,1,2], colors=np.array([0, 68, 138])/255.)

# plot centroids
centroids = kmeans.cluster_centers_

plt.scatter(centroids[:, 0], centroids[:, 1], marker="x", s=100, linewidths=1.5, color="k")

ax.set_xticks(np.arange(4, 8.5, 0.5))
ax.set_yticks(np.arange(1.5, 5, 0.5))
ax.set_xlim(4, 8)
ax.set_ylim(1.5, 4.5)
plt.xlabel(iris.feature_names[0])
plt.ylabel(iris.feature_names[1])
ax.grid(linestyle='--', linewidth=0.25, color=[0.5,0.5,0.5])
ax.set_aspect('equal')
plt.show()
1. np.meshgrid() 和生成网格数据

np.meshgrid 是用来生成二维坐标网格的函数,它会根据提供的输入向量生成两个矩阵,表示网格点的横坐标和纵坐标。

解释代码:

1
2
3
plot_step = 0.02
xx, yy = np.meshgrid(np.linspace(4, 8, int(4/plot_step + 1)),
np.linspace(1.5, 4.5, int(3/plot_step + 1)))
  • plot_step = 0.02:表示网格的间距,每个网格点之间相差 0.02
  • np.linspace(4, 8, int(4/plot_step + 1)):这部分生成从 4 到 8 之间的等间隔点,步长为 plot_step。生成的点的个数是 int(4/plot_step + 1),即从 4 到 8 之间一共生成 (8 - 4) / 0.02 + 1 = 201 个点。这个向量表示网格在 x 轴上的坐标。
  • np.linspace(1.5, 4.5, int(3/plot_step + 1)):同理,生成从 1.5 到 4.5 之间的等间隔点,表示 y 轴上的坐标。
  • np.meshgrid():它会将两个向量(x 和 y 坐标)组合成一个二维网格,这样可以方便地进行二维平面上的计算。xx 表示网格中每个点的 x 坐标,yy 表示网格中每个点的 y 坐标。

例如,xxyy 的形状将会是相同的,它们都是 $201 \times 151$ 的矩阵。xx 的每一行都表示 x 坐标的值,而 yy 的每一列都表示 y 坐标的值。

2. np.c_[xx.ravel(), yy.ravel()]

np.c_ 是 NumPy 用于水平拼接数组的功能,它将多个数组按列进行拼接。

解释代码:

1
Z = kmeans.predict(np.c_[xx.ravel(), yy.ravel()])
  • xx.ravel():将矩阵 xx 拉平成一维数组,按照行优先顺序展平。
  • yy.ravel():同样,将 yy 展平成一维数组。
    xx.ravel()yy.ravel() 现在是两个一维数组,它们的长度均为 (201 \times 151 = 30351)。
  • np.c_[xx.ravel(), yy.ravel()]:将 xx.ravel()yy.ravel() 按列拼接在一起,形成一个形状为 (30351 \times 2) 的二维数组。每一行代表网格中的一个点,第一列是 x 坐标,第二列是 y 坐标。
    通过 np.c_[xx.ravel(), yy.ravel()],我们创建了一个包含所有网格点坐标的二维数组,便于后续使用 KMeans 模型进行预测。
总结:
  • np.meshgrid() 生成了二维平面的网格坐标。
  • np.c_[xx.ravel(), yy.ravel()] 将网格中的每一个点的 x 和 y 坐标组合成一个 $30351 \times 2$ 的数组,表示网格中所有点的坐标,供 kmeans.predict 进行分类。
    image.png

代码2 常用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# pandas导入
df = pd.read_csv('./ch1ex1.csv')
points = df.values

xs = points[:, 0]
ys = points[:, 1]

model = KMeans(n_clusters = 3, n_init = 'auto')
model.fit(points)
labels = model.predict(points)
print(labels) # 打印每个点被分到的类

# 聚类中心
centroids = model.cluster_centers_
centroids_x = centroids[:, 0]
centroids_y = centroids[:, 1]

# 原始数据点
xs = points[:, 0]
ys = points[:, 1]

# 建立装饰和颜色
mk0 = ['o', ',', 'v']
cs0 = ['r', 'g', 'b']
mk1 = []
cs1 = []

for e in labels:
mk1.append(mk0[e])
cs1.append(cs0[e])

# 画点和质心
plt.figure(figsize=(10, 6), dpi = 120)
plt.subplot(111)
for x, y, cr, m ,in zip(xs, ys, cs1, mk1):
plt.scatter(x, y, edgecolors=cr, facecolors = 'none', marker=m)
plt.scatter(centroids_x, centroids_y, marker = 'X', s = 200, c = 'k')
plt.show()

肘部系数

用于判断合适的聚类簇值K

$$
SSE(X|K)=\sum_{k=1}^{K}SSE(C_{k})=\sum_{k=1}^{K}\sum_{x \in C_{k}}||x-\mu_{k}||^2
$$

曲线的拐点对应着接近最优的K值(SSE减小量、计算量以及分类效果的权衡)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
seeds_df = pd.read_csv('./seeds-less-rows.csv')
# print(seeds_df.grain_variety.value_counts())
varieties = list(seeds_df['grain_variety'])

samples = seeds_df.values
#print(len(samples))
ks = range(1, 6)
inertias = []

for k in ks:
#Create a KMeans instance with k clusters: model
model = KMeans(n_clusters=k, n_init = 'auto')
#Fit model to samples
model.fit(samples)
# Append the inertia to the list of inertias
inertias.append(model.inertia_)

plt.figure(figsize=(10, 6), dpi=80)
plt.subplot(111)
#plot ks vs inertias
plt.plot(ks, inertias, '-o')
plt.xlabel('number of clusters, k')
plt.ylabel('inertia')
plt.xticks(ks)
plt.show()

image.png

轮廓图:选定聚类簇值

轮廓图上每一条线代表的是轮廓系数:

$$
s_{i}=\frac{b_{i}-a_{i}}{\max {a_{i},b_{i}}}
$$

其中,$a_{i}$为簇内不相似度,$b_{i}$为簇间不相似度

簇内不相似度

样本$i \in C_{k}$到同簇其他样本$j(j \in C_{k}, i \neq j)$距离的平均值:

$$
a_{i}=\frac{1}{count(C_{k})-1}\sum_{j \in C_{k},i\neq j} {d_{i,j}}
$$

$d_{i,j}$为样本$i$和$j$之间的距离,$a_{i}$越小1,说明越应该被划分到$C_{k}$簇

簇间不相似度

样本$i \in C_{k}$到其他簇样本$j(j \in C_{m}, C_{m} \neq C_{k})$距离的平均值:

$$
b_{i} = \min \frac{1}{count(C_{m})}\sum_{j \in C_{m}}{d_{i,j}}
$$

[!note] 当簇数超过2时,$b_{i}$需要在不同簇中找到最小值

计算轮廓系数的函数为 sklearn.metrics.silhouette_score
yellowbrick.cluster.SilhouetteVisualizer 函数绘制轮廓图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from sklearn.cluster import KMeans
from yellowbrick.cluster import SilhouetteVisualizer
from sklearn.metrics import silhouette_score

kmeans = KMeans(n_clusters=n_clusters, random_state=10)
cluster_labels = kmeans.fit_predict(X)

silhouette_avg = silhouette_score(X, cluster_labels)
print("For n_clusters =", n_clusters,
"The average silhouette_score is :", silhouette_avg)
# For n_clusters = 3 The average silhouette_score is : 0.445052569

visualizer = SilhouetteVisualizer(kmeans, colors='yellowbrick')

visualizer.fit(X)
# Fit the data to the visualizer
visualizer.show()

沃罗诺伊图

质心中垂线相交分割区域

image.png

迭代自组织(ISODATA)

聚类算法:ISODATA算法 - 华东博客 - 博客园

与K-均值算法的比较

  • K-均值算法通常适合于分类数目已知的聚类,而ISODATA算法则更加灵活;
  • 从算法角度看, ISODATA算法与K-均值算法相似,聚类中心都是通过样本均值的迭代运算来决定的;
  • ISODATA算法加入了一些试探步骤,并且可以结合成人机交互的结构,使其能利用中间结果所取得的经验更好地进行分类。
  • K-均值算法的聚类中心个数不变;ISODATA的聚类中心个数变化。

image.png

算法步骤

第一步:输入$N$个模式样本${x_{i},i=1,2,…,N}$

预选$N_{c}$个初始聚类中心${z_{1},z_{2},…z_{N_{c}}}$,它可以不等于所要求的聚类中心的数目,其初始位置可以从样本中任意选取。

预选:
$K$  = 预期的聚类中心数目
$θ_{N}$ = 每一聚类域中最少的样本数目,若少于此数即不作为一个独立的聚类;
$θ_{S}$ = 一个聚类域中样本距离分布的标准差;标准差向量的每一分量反映样本在特征空间的相应维上,与聚类中心的位置偏差(分散程度)。要求每一聚类内,其所有分量中的最大分量应小于$θ_{S}$,否则该类将被分裂为两类。
$θ_{c}$ = 两个聚类中心间的最小距离,若小于此数,两个聚类需进行合并;
$L$ = 在一次迭代运算中可以合并的聚类中心的最多对数;
$I$  = 迭代运算的次数。

第二步:将$N$个模式样本分给最近的聚类$S_{j}$,假若$D_{j}=||x-z_{j}||=min{‖x−z_{i}‖,i=1,2,⋯N_{c}}$,即$||x−z_{j}||$的距离最小,则$x\in S_{j}$。

第三步:如果$S_{j}$中的样本数目$S_{j}<θ_{N}$,则取消该样本子集,此时$N_{c}$减去1。
(以上各步对应基本步骤(1))

第四步:修正各聚类中心

$$
z_{j}=\frac{1}{N_{j}}∑_{x\in S_{j}}x,\ j=1,2,⋯,N_{c}
$$

第五步:计算各聚类域$S_{j}$中模式样本与各聚类中心间的平均距离

$$
\bar{D_{j}}=\frac{1}{N_{j}}∑_{x\in S_{j}}∥x−z_{j}∥,j=1,2,⋯,N_{c}
$$

第六步:计算全部模式样本和其对应聚类中心的总平均距离

$$
\bar{D}=\frac{1}{N}\sum_{j=1}^{N_{c}}N_{j}\bar{D_{j}}
$$

(以上各步对应基本步骤(2))

第七步:判别分裂、合并及迭代运算

  1. 若迭代运算次数已达到$I$次,即最后一次迭代,则置$θ_{c} =0$,转至第十一步。
  2. 若$N_{c} ≤ \frac{K}{2}$,即聚类中心的数目小于或等于规定值的一半,则转至第八步,对已有聚类进行分裂处理。
  3. 若迭代运算的次数是偶数次,或$N_c≥2K$,即聚类中心数目大于或等于希望数的两倍,不进行分裂处理,转至第十一步(合并);否则(即既不是偶数次迭代,又不满足$N_{c}≥2K$),转至第八步,进行分裂处理。
    (以上对应基本步骤(3))
    image.png

第八步:计算每个聚类中样本距离的标准差向量 $\sigma_{j}=[\sigma_{j_{1}},\sigma_{j_{2}},…,\sigma_{jn}]T$
其中向量的各个分量为

$$
\sigma_{ji}=\sqrt{ \frac{1}{N_{j}}\sum_{x \in S_{j}}(x_{ji}-z_{ji})^2 }=\sqrt{ S^2 }
$$

式中,$i = 1, 2, …, n$为样本特征向量的维数,$j = 1, 2, …, N_{c}$为聚类数,$N_{j}$为$S_j$中的样本个数。

第九步:求每一标准差向量 ${\sigma_{j}, j = 1, 2, …, N_{c}}$ 中的最大分量,以 ${\sigma_{jmax}, j = 1, 2, …, Nc}$ 代表。

第十步:在任一最大分量集${\sigma_{jmax}, j = 1, 2, …, N_{c}}$中,若有 $\sigma_{jmax}>θ_{S}$ (标准差阈值),说明 $S_{j}$ 类样本在对应方向上的标准差大于允许的值,同时又满足如下两个条件之一:

  1. $\bar{D_{j}}>\bar{D}$和 $N_{j} > 2(θ_{N} + 1)$ ,即类内 平均距离大于总体平均距离 ,且 $S_{j}$ 中样本总数 超过规定值一倍以上 ( $\theta_{N}$ 为每个聚类中的最少样本数);
  2. $N_{c} ≤ \frac{K}{2}$,即聚类数小于或等于希望数目的一半

则将 $z_{j}$ 分裂为两个新的聚类中心$Z_{j}^+$和$Z_{j}^-$,且$N_{c}$加1。$Z_{j}^+ = \sigma_{jmax}$+$k\sigma_{jmax}$,$Z_{j}^-=\sigma_{jmax}-k\sigma_{jmax}$,$0<k\leq{1}$,称为分裂系数。

如果本步骤完成了分裂运算,迭代次数+1,则转至第二步,否则继续。
(以上对应基本步骤(4)进行分裂处理)

合并处理
第十一步:计算全部聚类中心的距离

$$
D_{ij}=||z_{i}−z_{j}||,i=1,2,…,N_{c}−1,j=i+1,…,N_{c}
$$

第十二步:比较$D_{ij}$与$θ_{c}$(两聚类中心的最小距离)的值,将$D_{ij} <θ_c$ 的值按最小距离次序递增排列,即

$$
D_{i_{1}j_{1}},D_{i_{2}j_{2}},…,D_{i_{L}j_{L}}
$$

式中 $D_{i_{1}j_{1}}<D_{i_{2}j_{2}}<…<D_{i_{L}j_{L}}$ 。

第十三步:将距离为 $D_{i_{k}j_{k}}$ 的两个聚类中心$Z_{ik}$和$Z_{jk}$合并,得新的中心为:

$$
z^∗_{k}= \frac{1}{N_{ik}+N_{jk}} [N_{ik}z_{ik}+N_{jk}z_{jk}],k=1,2,⋯,L
$$

式中,被合并的两个聚类中心向量分别以其聚类域内的样本数加权,使$Z^∗_{k}$为真正的平均向量。
(以上对应基本步骤(5)进行合并处理)

第十四步:如果是最后一次迭代运算(即第$I$次),则算法结束;否则,若需要操作者改变输入参数,转至第一步;若输入参数不变,转至第二步。
在本步运算中,迭代运算的次数每次应加1。

Transformer Encoder 的不同实现方式

[!question] 根据给定的代码与数据,参考课件内容,填充完整models.py的代码,比较Transformer,Bert,冻结参数的Bert的性能

先介绍两个参数

数据预处理时,增加了两个参数,让我们来看看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def encode_data(data, tokenizer, intents_num, slots_num, max_len=128):
input_ids = []
attention_masks = []
token_type_ids = []
intent_labels = []
intent_counts = []
slot_labels = []

for item in data:
tokens = item['tokens']
slots = item['slots']
intents = item['intents']
intent_count = item['intent_count']

encoding = tokenizer(
tokens,
is_split_into_words=True, # 已经分词
padding='max_length', # 填充到最大长度
truncation=True, # 截断
max_length=max_len, # 最大长度
return_offsets_mapping=True, # 词间距
return_tensors='pt'
)

input_ids.append(encoding['input_ids'][0])
attention_masks.append(encoding['attention_mask'][0])
token_type_ids.append(encoding['token_type_ids'][0] if 'token_type_ids' in encoding else torch.zeros_like(encoding['input_ids'][0]))

# 多标签意图编码
intent_label = [0] * len(intents_num)
for intent in intents:
intent_id = intents_num.get(intent, -1)
if intent_id != -1:
intent_label[intent_id] = 1
intent_labels.append(torch.tensor(intent_label, dtype=torch.float))

# 意图数量
intent_counts.append(torch.tensor(intent_count, dtype=torch.long))

# 插槽标签对齐
labels = []
word_ids = encoding.word_ids(batch_index=0)
previous_word_idx = None
for word_idx in word_ids:
if word_idx is None:
labels.append(-100)
elif word_idx != previous_word_idx:
if word_idx < len(slots):
labels.append(slots_num.get(slots[word_idx], slots_num['O']))
else:
labels.append(slots_num['O'])
else:
labels.append(-100)
previous_word_idx = word_idx
slot_labels.append(torch.tensor(labels, dtype=torch.long))

return {
'input_ids': torch.stack(input_ids),
'attention_mask': torch.stack(attention_masks),
'token_type_ids': torch.stack(token_type_ids),
'intent_labels': torch.stack(intent_labels),
'intent_counts': torch.stack(intent_counts),
'slot_labels': torch.stack(slot_labels)
}

attention_mask

attention_mask 是一个指示哪些token应该被BERT模型“关注”的张量。通常,它用于告知模型哪些部分是实际的输入,哪些部分是填充(padding)部分。attention_mask 的作用是确保BERT模型不会把填充的部分(通常是为了保证输入序列长度一致而添加的无用的token)用于计算注意力机制。

具体含义:

  • 值为1:表示对应位置的token是实际的输入,模型应该对该token进行计算。
  • 值为0:表示对应位置的token是填充token(padding token),模型应该忽略它,不会对其进行计算。
1
attention_masks.append(encoding['attention_mask'][0])

这行代码从encoding字典中获取生成的attention_mask并将其添加到attention_masks列表。encoding['attention_mask'] 是一个张量,表示输入序列中每个token的有效性,填充token的attention_mask值为0。

image.png|525
image.png|367

token_type_ids

token_type_ids 是BERT模型中用于区分不同句子的标识符,尤其是在处理如问答任务或句对任务时非常重要。BERT模型在输入时通常有两种不同的token类型:一个是表示句子A,另一个是表示句子B。token_type_ids 用于标识每个token所属的句子类别。

  • 值为0:表示该token属于第一个句子(句子A)。
  • 值为1:表示该token属于第二个句子(句子B)。
1
token_type_ids.append(encoding['token_type_ids'][0] if 'token_type_ids' in encoding else torch.zeros_like(encoding['input_ids'][0]))

这里检查encoding字典中是否包含token_type_ids,如果包含,提取它;如果没有(在某些任务中可能没有这个字段),则生成一个与input_ids相同形状的零张量。

BERT模型通常会将输入的两句话(例如:问题和上下文)作为一对句子对待,每个token会有一个标识符,指示它属于哪个句子。比如:“在2019年5月,广州市荔湾区的力诚欣悦湾与珠江金茂府相比,哪个小区的均价更高?”
1745071576466.png|675

使用huggingface的Transformer相关模型

由于Bert是Transformer的Encoder架构,因此可以直接拿这个来改。对应有几种方法,一种是使用预训练模型,另一种是重新训练模型

重新预训练模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Transformer(nn.Module):
def __init__(self, model_name, num_intent_labels, num_slot_labels, max_intents):
super().__init__()
config = BertConfig(
vocab_size=30522, # 词汇表大小,不会实用,就写个大点的数字
hidden_size=128, # Transformer的隐层维度
num_hidden_layers=2, # Transformer Encoder层数
num_attention_heads=4, # 多头注意力个数
intermediate_size=512, # 前馈网络的中间层维度
hidden_dropout_prob=0.1,
attention_probs_dropout_prob=0.1,
# 其他参数可不改,默认即可
)

self.encoder = BertModel(config)
self.intent_count_logits = nn.Linear(config.hidden_size, max_intents)
self.intent_classifier = nn.Linear(config.hidden_size, num_intent_labels)
self.slot_classifier = nn.Linear(config.hidden_size, num_slot_labels)

def forward(self, input_ids, attention_mask, token_type_ids):
outputs = self.encoder(input_ids, attention_mask, token_type_ids)
# output.last_hidden_state: [batch_size, seq_len, hidden_size]

pooled_output = outputs.pooler_output # 最后一层 CLS 输出,[batch_size, hidden_size]
sequence_output = outputs.last_hidden_state # 最后一层每个token的隐藏状态,[batch_size, seq_len, hidden_size]

intent_count_logits = self.intent_count_logits(pooled_output) # [batch_size, max_intents]
intent_logits = self.intent_classifier(pooled_output) # [batch_size, num_intent_labels]
slot_logits = self.slot_classifier(sequence_output) # [batch_size, seq_len, num_slots]

return intent_count_logits, intent_logits, slot_logits

输入数据的处理self.encoder 接收输入的 input_ids, attention_masktoken_type_ids

  • **input_ids**:表示输入文本的词汇ID的张量。每个ID代表词汇表中的一个词。
  • **attention_mask**:表示哪些词是填充词的张量。0代表填充词,1代表实际输入的词。
  • **token_type_ids**:用于区分不同句子的标识符。在一些任务(如问答任务)中,输入中有两个句子,而BERT需要知道哪个词属于第一个句子,哪个属于第二个句子。
    输出self.encoder 会返回两个主要的输出:
  • **outputs.pooler_output**:pooler_output 是BERT模型的最后一层[CLS] token对应的向量。通常用于分类任务,因为[CLS] token的表示被认为是整个输入序列的聚合表示。这个输出是一个维度为 [batch_size, hidden_size] 的张量。
  • **outputs.last_hidden_state**:last_hidden_state 是BERT模型的最后一层每个token的隐藏状态。它是一个维度为 [batch_size, seq_len, hidden_size] 的张量,其中 seq_len 是输入序列的长度。这个输出用于序列标注任务,因为它包含了每个词(token)的表示。

image.png

模型微调

也可以加载原本的预训练参数,通过关闭中间层的梯度更新,只更新下游线性头来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class BertFreeze(nn.Module):
def __init__(self, model_name, num_intent_labels, num_slot_labels, max_intents):
super().__init__()
self.bert = BertModel.from_pretrained('bert-base-chinese') # 加载预训练的BERT模型
hidden_size = self.bert.config.hidden_size # 隐藏层大小

for param in self.bert.parameters():
param.requires_grad = False # 冻结BERT模型参数,通过关闭梯度更新来实现

self.intent_count_fc = nn.Linear(hidden_size, max_intents)
self.intent_classifier = nn.Linear(hidden_size, num_intent_labels)
self.slot_classifier = nn.Linear(hidden_size, num_slot_labels)

def forward(self, input_ids, attention_mask, token_type_ids):
outputs = self.bert(input_ids, attention_mask, token_type_ids) # 输入BERT模型

pooled_output = outputs.pooler_output # 取出[CLS]输出,[batch_size, hidden_size]
sequence_output = outputs.last_hidden_state # 取出每个token的隐藏状态,[batch_size, seq_len, hidden_size]

intent_count_logits = self.intent_count_fc(pooled_output)
intent_logits = self.intent_classifier(pooled_output)
slot_logits = self.slot_classifier(sequence_output)

return intent_count_logits, intent_logits, slot_logits

param.requires_grad 是 PyTorch 中一个非常重要的属性,通常用于控制模型参数是否需要计算梯度,从而决定它们在反向传播时是否会更新。

在 PyTorch 中,requires_grad 是一个布尔值,表示某个张量是否需要计算梯度。如果 requires_grad=True,那么 PyTorch 会追踪该张量的所有操作,以便在反向传播时计算梯度。如果 requires_grad=False,则 PyTorch 不会为该张量计算梯度,也不会在反向传播中更新它。

效果:

  • 冻结模型:冻结BERT模型的参数意味着在训练过程中,BERT的预训练权重将保持不变,只有你自己定义的其他层(如 intent_count_fcintent_classifierslot_classifier)会参与训练。这种方法通常用于迁移学习,在你只需要对自己特定的任务进行微调时使用。
  • 防止过拟合:冻结部分层(特别是像BERT这样的预训练大模型)可以防止模型在小数据集上的过拟合,允许你通过训练较小的部分来保留大模型的特征学习能力。

实验结果

笔记本跑得非常漫长。。这里贴一个23epoch的微调结果:
image.png

使用Pytorch的Transformer

Pytorch内部集成了Transformer模块,比Bert的封装程度低一些,需要我们自己写入embedding

  • BERT模型:BERT已经预训练了一个词嵌入层,因此当你使用BERT时,输入的 input_ids 是直接将单词转换为数字ID的,这些数字ID会传入BERT的嵌入层(Embedding Layer),然后自动映射到一个高维的词向量空间中。因此,BERT本身的输入已经包含了词嵌入的功能,不需要再额外添加一个嵌入层。
  • Transformer实现的Bert:你在构建一个自定义的Transformer模型(而不是直接使用BERT),并且从头开始训练模型的词嵌入。所以,必须手动进行词嵌入操作,使用 nn.Embedding 将输入的 input_ids(即词汇表中的ID)转换成向量表示。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Bert(nn.Module):
def __init__(self, model_name, vocab_size, num_intent_labels, num_slot_labels, max_intents):
super().__init__()
self.embedding = nn.Embedding(vocab_size, 512)
self.encoder_layer = nn.TransformerEncoderLayer(d_model=512, nhead=8, batch_first=True)
self.transformer_encoder = nn.TransformerEncoder(self.encoder_layer, num_layers=6)

# 分类器
self.intent_count_fc = nn.Linear(512, max_intents)
self.intent_classifier = nn.Linear(512, num_intent_labels)
self.slot_classifier = nn.Linear(512, num_slot_labels)

def forward(self, input_ids, attention_mask, token_type_ids=None):
# 1. 词嵌入
embedded = self.embedding(input_ids) # [batch, seq_len, 512]

# 2. Transformer编码
key_padding_mask = (attention_mask == 0) # 转换为bool类型
outputs = self.transformer_encoder(
src=embedded,
src_key_padding_mask=key_padding_mask
) # [batch, seq_len, 512]

# 3. 意图分类(使用[CLS])
cls_output = outputs[:, 0, :] # 第一个token
intent_count_logits = self.intent_count_fc(cls_output)
intent_logits = self.intent_classifier(cls_output)

# 4. 槽填充(全序列)
slot_logits = self.slot_classifier(outputs) # [batch, seq_len, num_slots]

return intent_count_logits, intent_logits, slot_logits

TransformerEncoderLayer 是Transformer中的一个基本编码层,它由多头自注意力机制和前馈神经网络组成,主要用于对输入的序列进行处理。

  • 输入
    • src(输入张量):通常是 [batch_size, seq_len, embedding_size] 的张量。这里 embedding_size 就是你模型中每个token的表示大小,通常与输入的嵌入维度相同。
    • src_key_padding_mask(填充掩码):这是一个形状为 [batch_size, seq_len] 的布尔张量,指示哪些位置是填充token,模型将忽略这些位置的计算。
  • 输出
    • 输出张量:Transformer Encoder层会输出处理后的张量,形状为 [batch_size, seq_len, d_model],其中 d_model 是输入和输出的嵌入维度。这个输出代表了经过注意力机制和前馈网络处理后的每个token的表示。

TransformerEncoder 是多个 TransformerEncoderLayer 堆叠起来的组成部分,通常用来对序列进行更深层次的表示学习。通过多个Encoder层的堆叠,模型可以捕获输入序列中的更复杂的模式。

  • 输入
    • src:与 TransformerEncoderLayer 的输入相同,形状为 [batch_size, seq_len, d_model],即输入序列的嵌入表示。
    • src_key_padding_mask:同样是一个布尔张量,用于指示哪些位置是填充token,形状为 [batch_size, seq_len]
  • 输出
    • 输出张量TransformerEncoder 的输出是每个输入token经过所有堆叠的Encoder层之后的最终表示,形状为 [batch_size, seq_len, d_model]
    • cls_output = outputs[:, 0, :]解释:
      • **:**:代表选择所有批次中的样本(即选择所有的 batch_size)。
      • **0**:表示选择每个序列中的第一个token([CLS] token)。由于在BERT和Transformer模型中,第一个token是用来表示整个句子或文本的语义信息的,因此我们常常提取它用于分类任务。
      • **:**:代表选择所有隐藏层的维度,即选取每个token在 hidden_size 维度上的表示。
      • 因此,outputs[:, 0, :] 的作用是从每个样本的输出中提取第一个token的表示,输出的形状是 [batch_size, hidden_size]

[!info] 新版本torch的区别:
注意我的torch环境是2.2.2的,但是2.6版本的torch对TransformerEncoder进行了重写,相关参数如下:
image.png
image.png
这样就不需要强制让attention_mask变成bool类型,可以传入tensor

两者区别:

  • **TransformerEncoderLayer**:是一个单独的Transformer编码层,包含自注意力机制和前馈网络,输出每个token的表示。
  • **TransformerEncoder**:是由多个 TransformerEncoderLayer 组成的层堆栈,对输入序列进行编码,输出每个token的最终表示。

实验结果

有点长,没有完全跑完,但感觉插槽填充的结果不太对劲,得找个时间仔细一点debug

image.png|469

手动实现

手动实现Encoder部分,建议回去再复习一下Encoder架构:

  • Embedding
  • Position Encoding
  • Multi-head Attention
  • shortcut&LayerNorm
  • Feed-forward network
  • shortcut&LayerNorm

注意掩码区别

1. PyTorch Transformer中的掩码

在PyTorch中,nn.Transformer 模块的掩码通常分为两类:

  • **src_key_padding_mask**:用于指示源序列中哪些位置是填充(padding)的位置。这个掩码会被传递到自注意力机制中,以防止模型在计算注意力时关注到填充部分。
  • **tgt_key_padding_mask**:用于指示目标序列中哪些位置是填充(padding)的位置。通常在序列到序列(seq2seq)任务中使用。
  • **src_mask**:用于指示源序列的特定位置是否应该被注意力机制忽略。在自回归模型(如GPT)中,src_mask 用于防止当前位置之后的token被注意。
PyTorch中的掩码参数和功能:
  • src_key_padding_mask: 通常是一个形状为 [batch_size, seq_len] 的布尔张量,其中填充的token的位置标记为 True1,非填充的token位置标记为 False0
  • tgt_key_padding_mask: 形状也是 [batch_size, seq_len],用于指示目标序列中的填充位置。
  • src_mask: 形状为 [seq_len, seq_len],通常用于自回归模型中来阻止未来的信息泄漏,类似于遮挡注意力。

2. Hugging Face Transformers中的掩码

在Hugging Face的transformers库中,掩码的使用方法与PyTorch有所不同,尤其是在处理预训练模型(如BERT、GPT等)时。Hugging Face库中的掩码通常用于输入的 attention_masktoken_type_ids

  • **attention_mask**:它是一个形状为 [batch_size, seq_len] 的张量,用于指示哪些位置是填充token。1 表示该位置为有效token,0 表示该位置是填充token,模型会忽略填充位置。
    • 作用:与PyTorch中的 src_key_padding_mask 类似,用来指示模型哪些token是有效的,哪些是填充token。
  • **token_type_ids**:用于指示输入中不同句子的边界,特别是在任务如问答或句对任务(例如sentence_pair_classification)中,用来区分两个句子。0表示句子A,1表示句子B。
Hugging Face中的掩码参数和功能:
  • attention_mask: 形状为 [batch_size, seq_len],填充的token位置是0,有效的token位置是1,通常用于标记哪些token需要被关注,哪些是填充,避免模型将填充部分作为有效信息处理。
  • token_type_ids: 形状为 [batch_size, seq_len],用于区分输入中的两个句子。

Embedding

这块差别不大

1
2
3
4
5
6
7
8
class Embedding(nn.Module):
def __init__(self, vocab_size, d_model):
super(Embedding, self).__init__()
self.embedding = nn.Embedding(vocab_size, d_model)
self.d_model = d_model

def forward(self, x):
return self.embedding(x) * math.sqrt(self.d_model)

Position Encoding

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Positional Encoding
class PositionalEncoding(nn.Module):
'''
Positional Encoding 层,用于给输入序列添加位置信息。
计算方式:
PE(pos,2i) = sin(pos/10000^(2i/d_model))
PE(pos,2i+1) = cos(pos/10000^(2i/d_model))
'''
def __init__(self, d_model, max_len=5000):
super(PositionalEncoding, self).__init__()

# 创建一个(max_len, d_model)的零张量,存储位置编码
pe = torch.zeros(max_len, d_model)

position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) # (max_len, 1)

# 根据公式计算位置编码
div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) # (d_model/2)

# 偶数位置编码
pe[:, 0::2] = torch.sin(position * div_term)

# 奇数位置编码
pe[:, 1::2] = torch.cos(position * div_term)

pe = pe.unsqueeze(0) # (1, max_len, d_model)

# 注册为参数,以便在训练过程中更新
self.register_buffer('pe', pe)

def forward(self, x):
# 输入的x的形状为(batch_size, seq_len, d_model)

seq_len = x.size(1)

# 扩展位置编码,使其与输入的x形状相同
pe = self.pe[:, :seq_len]

# 与输入相加
x = x + pe

return x

多头注意力

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class MultiHeadAttention(nn.Module):
def __init__(self, d_model, num_heads, dropout=0.1):
'''
:param d_model: 输入序列的维度
:param num_heads: 多头注意力的数量
:param dropout: dropout概率
'''

super(MultiHeadAttention, self).__init__()
assert d_model % num_heads == 0, "d_model必须被num_heads整除"

self.d_model = d_model
self.num_heads = num_heads
self.d_k = d_model // num_heads

self.W_q = nn.Linear(d_model, d_model)
self.W_k = nn.Linear(d_model, d_model)
self.W_v = nn.Linear(d_model, d_model)

self.fc = nn.Linear(d_model, d_model)

self.dropout = nn.Dropout(dropout)
self.softmax = nn.Softmax(dim=-1)

def forward(self, x, mask=None):
'''
:param x: 输入序列,形状为(batch_size, seq_len, d_model)
:param mask: 掩码,形状为(batch_size, seq_len, seq_len)
:return: 输出序列,形状为(batch_size, seq_len, d_model)
'''

batch_size = x.size(0)
seq_len = x.size(1)

# Q, K, V = x * W_q, x * W_k, x * W_v
Q = self.W_q(x)
K = self.W_k(x)
V = self.W_v(x)

# 多头注意力 ==> (batch_size, num_heads, seq_len, d_k)
Q = Q.view(batch_size, seq_len, self.num_heads, self.d_k).permute(0, 2, 1, 3)
K = K.view(batch_size, seq_len, self.num_heads, self.d_k).permute(0, 2, 1, 3)
V = V.view(batch_size, seq_len, self.num_heads, self.d_k).permute(0, 2, 1, 3)

# 计算注意力权重,维度为(batch_size, num_heads, seq_len, seq_len),公式 (Q * K.T) / sqrt(d_k)
scores = torch.matmul(Q, K.transpose(-2, -1) / math.sqrt(self.d_k))

if mask is not None:
# print(scores.shape, mask.shape)
mask = mask.unsqueeze(1).unsqueeze(1) # 变为[batch_size, 1, 1, seq_len]
scores = scores.masked_fill(mask == 0, float('-inf'))

attn = self.softmax(scores)
attn = self.dropout(attn)

# 计算输出,维度为(batch_size, num_heads, seq_len, d_k)
context = torch.matmul(attn, V)

# 合并多头注意力
context = context.permute(0, 2, 1, 3).contiguous()
context = context.view(batch_size, seq_len, self.d_model)

# 做线性映射,输出形状为(batch_size, seq_len, d_model)
out = self.fc(context)

return out

FFN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# FFN: Feed Forward Network
class PositionwiseFeedForward(nn.Module):
def __init__(self, d_model, d_ff, dropout=0.1):
'''
:param d_model: 输入/输出序列的维度
:param d_ff: 前馈网络的隐藏层维度
:param dropout: dropout概率
'''

super(PositionwiseFeedForward, self).__init__()

self.fc1 = nn.Linear(d_model, d_ff)
self.fc2 = nn.Linear(d_ff, d_model)
self.dropout = nn.Dropout(dropout)
self.relu = nn.ReLU()

def forward(self, x):
'''
:param x: 输入序列,形状为(batch_size, seq_len, d_model)
:return: 输出序列,形状为(batch_size, seq_len, d_model)
'''

out = self.fc1(x)
out = self.relu(out)
out = self.dropout(out)
out = self.fc2(out)
out = self.dropout(out)

return out

Encoder封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class EncoderLayer(nn.Module):
def __init__(self, d_model, num_heads, d_ff, dropout=0.1):
'''
:param d_model: 输入/输出序列的维度
:param num_heads: 多头注意力的数量
:param d_ff: 前馈网络的隐藏层维度
'''

super(EncoderLayer, self).__init__()

self.self_attn = MultiHeadAttention(d_model, num_heads, dropout)
self.feed_forward = PositionwiseFeedForward(d_model, d_ff, dropout)
self.layer_norm1 = nn.LayerNorm(d_model, eps=1e-6)
self.layer_norm2 = nn.LayerNorm(d_model, eps=1e-6)
self.dropout = nn.Dropout(dropout)

def forward(self, x, mask=None):
'''
:param x: 输入序列,形状为(batch_size, seq_len, d_model)
:param mask: 掩码,形状为(batch_size, seq_len, seq_len)
:return: 输出序列,形状为(batch_size, seq_len, d_model)
'''

# 自注意力(带残差 + LayerNorm)
attn_out = self.self_attn(x, mask)
x = x + self.dropout(attn_out)
x = self.layer_norm1(x)

# 前馈网络(带残差 + LayerNorm)
ff_out = self.feed_forward(x)
x = x + self.dropout(ff_out)
x = self.layer_norm2(x)

return x

TransformerEncoder结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# 多层Encoder:堆叠 N 个 EncoderLayer
class TransformerEncoder(nn.Module):
def __init__(self, d_model=512, n_heads=8, d_ff=2048, num_layers = 6, dropout=0.1, max_len = 5000, use_pos_encoding=True):
'''
:param d_model: 输入/输出序列的维度
:param n_heads: 多头注意力的数量
:param d_ff: 前馈网络的隐藏层维度
:param num_layers: 编码器的层数
:param dropout: dropout概率
:param max_len: 输入序列的最大长度
:param use_pos_encoding: 是否使用位置编码
'''

super(TransformerEncoder, self).__init__()
self.use_pos_encoding = use_pos_encoding
if use_pos_encoding:
self.pos_encoding = PositionalEncoding(d_model, max_len=max_len)

self.layers = nn.ModuleList([
EncoderLayer(d_model, n_heads, d_ff, dropout)
for _ in range(num_layers)
])

self.dropout = nn.Dropout(dropout)
self.layer_norm = nn.LayerNorm(d_model, eps=1e-6)

def forward(self, x, mask=None):
'''
:param x: 输入序列,形状为(batch_size, seq_len, d_model)
:param mask: 掩码,形状为(batch_size, seq_len, seq_len)
:return: 输出序列,形状为(batch_size, seq_len, d_model)
'''
if self.use_pos_encoding:
x = self.pos_encoding(x)

for layer in self.layers:
x = layer(x, mask)

x = self.layer_norm(x)

return x
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class MyTransformer(nn.Module):
def __init__(self, vocab_size, num_intent_labels, num_slot_labels, max_intents,
d_model=512, n_heads=8, d_ff=2048, num_layers=6, dropout=0.1, max_len=5000):
"""
参数:
vocab_size: 词汇表大小
num_intent_labels: 意图类别数
num_slot_labels: 槽位类别数
max_intents: 最大意图数量
d_model: 模型维度
n_heads: 多头注意力头数
d_ff: 前馈网络隐藏层维度
num_layers: Transformer层数
dropout: dropout概率
max_len: 最大序列长度
"""
super().__init__()

# 1. 输入嵌入层
self.embedding = Embedding(vocab_size, d_model)

# 2. 位置编码
self.position_encoding = PositionalEncoding(d_model, max_len)

# 3. Transformer编码器
self.encoder = TransformerEncoder(
d_model=d_model,
n_heads=n_heads,
d_ff=d_ff,
num_layers=num_layers,
dropout=dropout,
max_len=max_len
)

# 4. 意图数量预测头
self.intent_count_head = nn.Linear(d_model, max_intents)

# 5. 意图分类头
self.intent_head = nn.Linear(d_model, num_intent_labels)

# 6. 槽填充头
self.slot_head = nn.Linear(d_model, num_slot_labels)

# 初始化参数
self._init_weights()

def _init_weights(self):
"""初始化模型参数"""
for p in self.parameters():
if p.dim() > 1:
nn.init.xavier_uniform_(p)

def forward(self, input_ids, attention_mask, token_type_ids):
"""
前向传播

参数:
input_ids: 输入token ids [batch_size, seq_len]
attention_mask: 注意力掩码 [batch_size, seq_len]

返回:
intent_count_logits: 意图数量预测 [batch_size, max_intents]
intent_logits: 意图分类 [batch_size, num_intent_labels]
slot_logits: 槽位预测 [batch_size, seq_len, num_slot_labels]
"""
# 1. 嵌入层
x = self.embedding(input_ids) # [batch, seq_len, d_model]

# 2. 位置编码
x = self.position_encoding(x)

# 3. Transformer编码
encoder_output = self.encoder(x, mask=~attention_mask) # [batch, seq_len, d_model],注意这里的mask需要取反

# 4. 意图数量预测(使用[CLS]标记)
cls_output = encoder_output[:, 0, :] # 取第一个token的输出
intent_count_logits = self.intent_count_head(cls_output)
intent_logits = self.intent_head(cls_output)
slot_logits = self.slot_head(encoder_output)

return intent_count_logits, intent_logits, slot_logits

实验结果

跑3个Epoch实验的时候就发现效果其实跟Bert正常的效果差不多了:
image.png

说明应该没写错()

[!info] 任务难度不是很大,关键的问题其实是维度。要始终记得神经网络拟合的只是一个分布,不是一个结果

task 1 序列标注效果评价

序列标注问题介绍

序列标注是一种自然语言处理任务,目标是为序列中的每个元素分配一个标签。
应用领域:自然语言处理(NLP):如分词、命名实体识别、情感分析。
生物信息学:基因序列的标注。
其他领域:时间序列数据分析、视频中每帧图像标注等。

常见方法:

  • 传统方法:
    • 隐马尔可夫模型(HMM)
    • 条件随机场(CRF)
  • 基于深度学习的方法:
    • 循环神经网络(RNN)
    • 长短时记忆网络(LSTM)
    • 双向LSTM(BiLSTM)+ CRF 等
    • Transformer

任务介绍

[!question] 根据给定的代码与数据,参考课件内容,填充完整models.py的代码,并比较CNN、LSTM、GRU的在序列标注任务的性能

比上周的任务多了一个插槽填充。需要注意的是,槽填充需要对每个位置都进行槽位预测,因此我们需要有同样长的经过神经网络处理后的序列作为输入,才能得到对应每个位置的槽位

CNN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class CNN(nn.Module):
def __init__(self, vocab_size, embed_dim, num_intent_labels, num_slot_labels, max_intents):
super().__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.pool = nn.AdaptiveMaxPool1d(1) # 池化层

self.conv1 = nn.Sequential(
nn.Conv1d(embed_dim, embed_dim, kernel_size=3, padding=1),
nn.BatchNorm1d(embed_dim),
nn.ReLU(),
nn.Dropout(0.5)
)
'''
中间内容与上周一致
'''
self.intent_num_classifier = nn.Linear(embed_dim, max_intents)
self.intent_classifier = nn.Linear(embed_dim, num_intent_labels)
self.slot_classifier = nn.Linear(embed_dim, num_slot_labels)

def forward(self, x):
# 词嵌入
x = self.embedding(x) # [batch_size, seq_len, embed_dim]
x = x.permute(0, 2, 1) # [batch_size, embed_dim, seq_len]

# 卷积
out_1 = self.conv1(x) # [batch_size, embed_dim, seq_len]
out_2 = self.conv2(out_1) # [batch_size, embed_dim * 2, seq_len]
out_3 = self.conv3(out_2) # [batch_size, embed_dim, seq_len]

# 池化
pooled = self.pool(out_3) # [batch_size, embed_dim, 1]
result = pooled.squeeze(-1) # [batch_size, embed_dim]

c_out_t = out_3.permute(0, 2, 1) # [batch_size, seq_len, embed_dim]

# 分类
ic_logits = self.intent_num_classifier(result) # [batch_size, max_intents]
it_logits = self.intent_classifier(result) # [batch_size, num_intent_labels]
slot_logits = self.slot_classifier(c_out_t) # [batch_size, seq_len, num_slot_labels]

return ic_logits, it_logits, slot_logits

对于槽填充任务,我们需要padding来帮我们保持序列长度一致。经过卷积后,我们还需要把维度进行调整,因为线性层处理的是最后一个维度,但我们需要保持序列长度一致,不能对seq_len处理,只能对embed_dim处理,把它再映射回原本的 num_slot_labels

image.png|475

LSTM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class LSTM(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_intent_labels, num_slot_labels, max_intents):
super(LSTM, self).__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=1, bidirectional=True, batch_first=True)
self.intent_num_classifier = nn.Linear(hidden_dim*2, max_intents)
self.intent_classifier = nn.Linear(hidden_dim*2, num_intent_labels)
self.slot_classifier = nn.Linear(hidden_dim*2, num_slot_labels)

def forward(self, x):
# 词嵌入
x = self.embedding(x) # [batch_size, seq_len, embed_dim]

# 编码
x, (h_n, c_n) = self.lstm(x) # x的维度:[batch_size, seq_len, hidden_dim*2], h_n和c_n的维度:[num_layers*num_directions, batch_size, hidden_dim]

h_last = torch.cat((h_n[-2], h_n[-1]), dim=1) # 取最后一个时刻的隐层状态作为最后的隐层状态,维度:[batch_size, hidden_dim*2]

# 分类
ic_logits = self.intent_num_classifier(h_last) # [batch_size, max_intents]
it_logits = self.intent_classifier(h_last) # [batch_size, num_intent_labels]

slot_logits = self.slot_classifier(x) # [batch_size, seq_len, num_slot_labels]

return ic_logits, it_logits, slot_logits

对于意图识别任务,同样可以传入最后一个隐状态。
但是对于槽填充任务,需要将整个lstm的输出作为输入,然后通过线性头得到对应的slot_label

1744092411985.png|475

GRU

内容类似,在此不再阐述(不过在此超参下训练效果不是很好,上周优化时发现,学习率调到1e-3会好很多,不过这周的重点是比较不同的训练方式和方法)

image.png|475

task2 联合训练与分别训练效果对比

[!question] 比较意图识别与插槽填充任务单独训练与执行(即单独训练意图识别/插槽填充并推理)与联合训练(即多任务学习)时不同模型性能的比较

代码

原本的训练代码(整体结构没有太大变化,因此不对代码进行详细阐述):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
intent_count_loss_fn = nn.CrossEntropyLoss()
intent_loss_fn = nn.BCEWithLogitsLoss()
slot_loss_fn = nn.CrossEntropyLoss(ignore_index=-100)

def train_epoch(model, loader, optimizer, intent_count_loss_fn, intent_loss_fn, slot_loss_fn, device):
model.train()
total_loss = 0
for batch in tqdm(loader, desc="Training", leave=False):
optimizer.zero_grad()

input_ids = batch['input_ids'].to(device)
intent_labels = batch['intent_labels'].to(device)
intent_counts = batch['intent_counts'].to(device)
slot_labels = batch['slot_labels'].to(device)

ic_logits, it_logits, slot_logits = model(input_ids)

# 假设意图数量已 1..N->0..N-1,否则可以保留 -1A
loss_ic = intent_count_loss_fn(ic_logits, intent_counts - 1)
loss_it = intent_loss_fn(it_logits, intent_labels)
loss_sl = slot_loss_fn(slot_logits.view(-1, slot_logits.size(-1)), slot_labels.view(-1))

loss = loss_ic + loss_it + loss_sl
loss.backward()
optimizer.step()

total_loss += loss.item()
avg_loss = total_loss / len(loader)
print(f"训练损失: {avg_loss:.4f}")
return avg_loss

在NLP任务中,由于需要保持输入序列长度的一致性,通常会将其填充至相同的长度,填充的部分通常用一个特殊的标签(如 -100)表示。通过设置 ignore_index=-100,这些填充标签就不会影响损失的计算和模型的训练。

在 PyTorch 的 nn.CrossEntropyLoss 中,ignore_index 是一个参数,用于指定在计算损失时需要忽略的目标标签的值。具体来说,当目标标签的值等于 ignore_index 时,这个标签对应的损失将不会被计算,也不会对梯度产生影响。

在目前的loss计算中,可以看到 loss = loss_ic + loss_it + loss_sl,意图识别和槽填充是进行联合训练的,我们需要将其变为单独训练。我的做法比较简单,把原本的函数拆成两个任务函数,然后在训练过程中分别调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def train_epoch_intent(model, loader, optimizer, intent_count_loss_fn, intent_loss_fn, slot_loss_fn, device):
model.train()
total_loss = 0
for batch in tqdm(loader, desc="Training", leave=False):
optimizer.zero_grad()

input_ids = batch['input_ids'].to(device)
intent_labels = batch['intent_labels'].to(device)
intent_counts = batch['intent_counts'].to(device)

ic_logits, it_logits, _ = model(input_ids)

# 假设意图数量已 1..N->0..N-1,否则可以保留 -1A
loss_ic = intent_count_loss_fn(ic_logits, intent_counts - 1)
loss_it = intent_loss_fn(it_logits, intent_labels)

loss = loss_ic + loss_it
loss.backward()
optimizer.step()

total_loss += loss.item()
avg_loss = total_loss / len(loader)
print(f"意图识别训练损失: {avg_loss:.4f}")
return avg_loss

def train_epoch_slot(model, loader, optimizer, intent_count_loss_fn, intent_loss_fn, slot_loss_fn, device):
model.train()
total_loss = 0
for batch in tqdm(loader, desc="Training", leave=False):
optimizer.zero_grad()

input_ids = batch['input_ids'].to(device)
slot_labels = batch['slot_labels'].to(device)

_, _, slot_logits = model(input_ids)

# 假设意图数量已 1..N->0..N-1,否则可以保留 -1A
loss_sl = slot_loss_fn(slot_logits.view(-1, slot_logits.size(-1)), slot_labels.view(-1))

loss = loss_sl
loss.backward()
optimizer.step()

total_loss += loss.item()
avg_loss = total_loss / len(loader)
print(f"槽填充训练损失: {avg_loss:.4f}")
return avg_loss

CNN

image.png|450

跟联合训练差别不大

LSTM

image.png|450

意图识别的结果明显比联合训练的好,在GRU上也可看到

GRU

image.png|450

同样效果比先前联合训练好。

总结

跟课上提到的一样,多任务联合学习虽然可以提高泛化能力,但是训练的难度更大,同时需要更大的数据集。如果不做特殊处理(调整超参等),效果可能没有分别训练好。
在训练过程中也可以看到,联合训练时,loss一直都比较高,对于意图识别会有明显的影响。怎么更好地解决这个问题,task3给出了一些思路。

task3 论文复现

[!question] 复现《A RESULT BASED PORTABLE FRAMEWORK FOR SPOKEN LANGUAGE UNDERSTANDING》中关于RBFN的部分

关于此论文的具体叙述在后面。由于只需要复现RBFN部分,因此对于历史嵌入向量我们直接用embedding进行代替。backbone使用LSTM。
代码量不大,主要是思考公式与代码之间是怎么进行衔接的,麻烦的地方在于矩阵维数的处理。
其实这也相当于做了一个简单的消融实验,就是比较有没有这个结构对结果带来的影响

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class SLU(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_intent_labels, num_slot_labels, max_intents):
super(SLU, self).__init__()
# self.embedding = nn.Embedding(vocab_size, embed_dim)
self.lstm = nn.LSTM(embed_dim, hidden_dim, num_layers=1, bidirectional=True, batch_first=True)
self.intent_num_classifier = nn.Linear(hidden_dim*2, max_intents)
self.intent_classifier = nn.Linear(hidden_dim*2, num_intent_labels)
self.slot_classifier = nn.Linear(hidden_dim*2, num_slot_labels)

def forward(self, x):
# 编码
x, (h_n, c_n) = self.lstm(x)

h_last = torch.cat((h_n[-2], h_n[-1]), dim=1)

# 分类
ic_logits = self.intent_num_classifier(h_last)
it_logits = self.intent_classifier(h_last)

slot_logits = self.slot_classifier(x)

return ic_logits, it_logits, slot_logits

class RBFN(nn.Module):
def __init__(self, vocab_size, embed_dim, hidden_dim, num_intent_labels, num_slot_labels, max_intents):
super(RBFN, self).__init__()
self.embedding = nn.Embedding(vocab_size, embed_dim)
# 定义三个SLU模型
self.slu1 = SLU(vocab_size, embed_dim, hidden_dim, num_intent_labels, num_slot_labels, max_intents)
self.slu2 = SLU(vocab_size, embed_dim, hidden_dim, num_intent_labels, num_slot_labels, max_intents)
self.slu3 = SLU(vocab_size, embed_dim, hidden_dim, num_intent_labels, num_slot_labels, max_intents)

self.intent_embedding = nn.Linear(num_intent_labels, hidden_dim)
self.slot_embedding = nn.Linear(num_slot_labels, hidden_dim)
self.Va = nn.Linear(hidden_dim, 1, bias=False)
self.slot_att = nn.Linear(hidden_dim, hidden_dim)

self.new_intent = nn.Linear(hidden_dim + embed_dim, embed_dim)
self.new_slot = nn.Linear(hidden_dim + embed_dim, embed_dim)

self.softmax = nn.Softmax(dim=1)
self.sigmoid = nn.Sigmoid()
self.tanh = nn.Tanh()

def forward(self, x):
embed = self.embedding(x) # [batch_size, seq_len, embed_dim]

# slu1输出
ic_logits, it_logits1, slot_logits1 = self.slu1(embed) # [batch_size, max_intents], [batch_size, num_intent_labels], [batch_size, seq_len, num_slot_labels] e^H

# 转化为隐式映射
lsvI = self.intent_embedding(self.sigmoid(it_logits1)) # [batch_size, hidden_dim] lsvI = S^I \cdot resI
ls = self.slot_embedding(self.softmax(slot_logits1)) # [batch_size, seq_len, hidden_dim] ls_{j} = S^S \cdot s_{j}
ls_att = self.softmax(self.Va(self.tanh(self.slot_att(ls)))) # [batch_size, seq_len, 1] \alpha_{j} = \frac{\exp(V_{a} \cdot \tanh(W_{a} \cdot ls_{j} + b_{a}))}{\sum_{p=1}^{k} \exp(V_{a} \cdot \tanh(W_{a} \cdot ls_{p}+ b_{a}))}
lsvS = torch.sum(ls_att * ls, dim=1) # [batch_size, hidden_dim] lsvS = \sum_{j=1}^{k}\alpha_{j}ls_{j}

# cat1 = torch.cat((lsvI.unsqueeze(1).expand(-1, embed.size(1), -1), embed), dim=-1) # [batch_size, seq_len, hidden_dim + embed_dim]
# cat2 = torch.cat((lsvS.unsqueeze(1).expand(-1, embed.size(1), -1), embed), dim=-1) # [batch_size, seq_len, hidden_dim + embed_dim]
new_intent = self.new_intent(torch.cat((lsvI.unsqueeze(1).expand(-1, embed.size(1), -1), embed), dim=-1)) # e^I_{j}=W^I \cdot(lsvI \oplus e^H_{j}) + b^I
new_slot = self.new_slot(torch.cat((lsvS.unsqueeze(1).expand(-1, embed.size(1), -1), embed), dim=-1)) # e^S_{j}=W^S \cdot(lsvS \oplus e^H_{j}) + b^S
# slu2输出
_, it_logits2, _ = self.slu2(new_slot) # [batch_size, num_intent_labels]

# slu3输出
_, _, slot_logits2 = self.slu3(new_intent) # [batch_size, seq_len, num_slot_labels]

intent_logits = (it_logits1 + it_logits2) / 2 # [batch_size, num_intent_labels]
slot_logits = (slot_logits1 + slot_logits2) / 2 # [batch_size, seq_len, num_slot_labels]

return ic_logits, intent_logits, slot_logits

维度分析

  1. lsvI.unsqueeze(1).expand(-1, embed.size(1), -1):
    • lsvI 的维度是 [batch_size, hidden_dim]
    • unsqueeze(1) 会把 lsvI 的维度变成 [batch_size, 1, hidden_dim]
    • expand(-1, embed.size(1), -1) 会把第二维扩展到 seq_len,即 [batch_size, seq_len, hidden_dim]
  2. embed:
    • embed 的维度是 [batch_size, seq_len, embed_dim]
  3. torch.cat(..., dim=-1):
    • 将扩展后的 lsvI(维度 [batch_size, seq_len, hidden_dim])和 embed(维度 [batch_size, seq_len, embed_dim])沿最后一维(dim=-1)拼接。
    • 拼接后的维度是 [batch_size, seq_len, hidden_dim + embed_dim]

所以,拼接后的张量的维度是 [batch_size, seq_len, hidden_dim + embed_dim],然后通过 self.new_intentself.new_slot 进行线性变换,再转换成embed_dim

实验结果

image.png|450

使用LSTM作为backbone,很明显看到,相比于先前的联合训练和分别训练,意图识别的F1分数均有了明显提高,一方面证明了意图识别与槽填充这两个任务的相关性,另一方面是这个结果反馈网络的有效性。

论文:A RESULT BASED PORTABLE FRAMEWORK FOR SPOKEN LANGUAGE UNDERSTANDING

AAAI 2025
Author: Lizhi Cheng, Wenmian Yang, Weijia Jia

问题背景

现有的多轮口语理解(SLU)方法存在以下问题:

  1. 移植性差:现有方法的基础模型与多轮模块耦合紧密,难以直接替换为最新的单轮SLU模型。
  2. 信息利用不足:仅利用历史对话的原始文本,忽略了历史预测结果(如意图和槽位标签)的语义信息。
  3. 任务间交互缺失:意图检测(ID)和槽位填充(SF)的预测结果未充分交互,导致错误传递或冗余。

为解决上述问题,本文提出了一种基于结果的可移植的SLU框架(RPFSLU)
RPFSLU允许大多数现有的单轮SLU模型从多轮对话中获取上下文信息,并在预测过程中充分利用预测结果。在RPFSLU中,现有的单匝SLU模型(即基本模型)是一个只需要提供预测结果的黑箱。所以没有必要去了解或改变它们的内在结构。
RPFSLU一般由两部分组成,即:对话历史表示(DHR)和基于结果的双反馈网络(RBFN)

  • DHR的目标是从历史话语和历史预测结果中获取上下文信息。
  • RBFN的目的是将ID和SF的预测结果融入网络中,并利用结果中包含的语义信息进行更准确的预测。
  • 更具体地说,DHR输入当前话语及其对话历史,包括历史话语和预测结果,并输出包含上下文信息的嵌入序列。
  • RBFN包含两轮预测过程。在实际应用中,我们首先从基本模型(任意单圈SLU模型)中获取ID和SF的第一轮预测结果,并将结果嵌入到本征状态向量中。然后,将潜在状态向量与话语的单词嵌入进行合并,并将合并后的嵌入重新发送到基本模型中,以预测第二轮结果。最后,我们将两轮的结果合并,并输出最终的SLU结果。

image.png

工作内容

Formulation

多回合SLU任务的输入是用户话语序列$U = {u_1, u_2,…, u_n}$,其中$n$表示话语总数。$n = 1$意味着输入没有对话历史。对于任意$u_t∈U$, $u_t = {x_1, x_2,…x_k}$是一个记号序列,其中$k$表示话语$u_t$中tokens的个数。
给定$U$作为输入,我们的任务由两个子任务组成,即ID和SF。ID是一个语义分类任务,用于预测U中每个话语的意图标签; SF是一个序列标记任务,用于给每个话语中的token一个槽标签。

Overview

RPFSLU旨在将现有的单回合SLU模型(即所谓的基本模型)应用于多回合SLU任务,并在预测过程中充分利用预测结果。由于基本模型在RPFSLU中作为黑盒工作,只需要提供预测结果。没有必要去理解或改变它们的内部结构。因此,RPFSLU可以使大多数现有的SLU模型受益

简而言之,RPFSLU由两部分组成:DHR和RBFN。DHR工作在整个网络的最开始,旨在从对话历史中获得话语和预测结果中包含的语义信息,并将这些上下文信息提供给基本模型。RBFN旨在将预测结果中包含的语义信息整合到基本模型中,提高每一次的表现。

image.png

作者提出了一种基于结果的可移植框架(RPFSLU),包含两个核心模块:

  1. 对话历史表示(DHR)
    • 从历史对话的原始文本及其预测结果(ID和SF)中提取语义信息,生成上下文相关的嵌入表示。
    • 通过注意力机制加权历史潜在状态向量,并与当前词嵌入融合(公式7-10)。
  2. 基于结果的双反馈网络(RBFN)
    • 进行两轮预测:第一轮生成初步结果,第二轮利用结果的潜在状态向量(公式1-4)优化预测。
    • 通过双向反馈(ID结果指导SF,SF结果验证ID)增强任务间交互(公式11-14)。

结果表示机制

在SLU中,预测结果的每个类别都有特定的含义,因此包含了基本的语义信息。为了有效地利用结果的语义,我们设计了一种结果表示机制,旨在通过特定的潜在状态向量来表示预测结果的分布
具体来说,受[24]的启发,首先使用两个潜在状态矩阵(嵌入层),即$S^I∈R^{d_I×d_i}$和$S^S∈R^{d_S×d_s}$来表达ID和SF结果的潜在状态,其中$d_I$为意图潜在状态维数,$d_S$为槽潜在状态维数。
然后,对于ID,我们将结果分布$resI$与潜在状态矩阵$S^I$结合,得到基于结果的潜在状态向量$lsvI∈R^{d_I}$
$$
lsvI = S^I \cdot resI
$$
对于槽填充结果$s_{j}$也同理,由$resS = {s_{1},\dots,s_{k}}$:
$$
ls_{j} = S^S \cdot s_{j}
$$
此外,由于SF返回的是一组潜在状态向量序列,我们进一步设计了一种注意机制来计算该序列的加权平均,得到一个话语级潜在状态向量$lsvS∈R^{d_S}$,
$$
lsvS = \sum_{j=1}^{k}\alpha_{j}ls_{j}
$$

其中$α_j$为$ls_j$的权值,
$$
\alpha_{j} = \frac{\exp(V_{a} \cdot \tanh(W_{a} \cdot ls_{j} + b_{a}))}{\sum_{p=1}^{k} \exp(V_{a} \cdot \tanh(W_{a} \cdot ls_{p}+ b_{a}))}
$$
其中$W_a∈R^{d_a×d_S}$和$V_a∈R^{1×d_a}$为全连接矩阵。$b_a∈R^{d_a}$是偏置向量,$d_{a}$是注意层的维数。

通过这个过程,我们得到了两个潜在状态向量$lsvI$和$lsvS$,它们包含了来自预测结果的语义信息,有助于后续的预测过程。

Dialogue History Representation

为了记录对话历史,我们使用记忆列表来存储历史话语和预测结果。如图3所示,内存列表最初是空的,并在每次对话之后更新。在T−1轮对话后,我们将T−1轮的话语、预测ID结果和SF结果保存在内存列表$M = (< u_{1}, resI_{1}, resS_{1} >,…, < u_{T−1},resI_{T−1},resS_{T−1} >)$。

image.png|525

在第t轮对话中,我们首先通过结果表示机制将所有历史结果映射到潜在空间中,得到历史潜在状态向量(latent state vector):

  • ID: $LSVI = {lsvI_{1},…, lsvI_{T−1}}$
  • SF: $LSVS = {lsvS_{1},…, lsvS_{T−1}}$。

同时,对于从记忆表中取出的每个话语$u_{t}$,我们计算其句子级语义向量$he^t$
$$
e^t = Embedding(u_{t})
$$
$$he_{t} = BiGRU(e^t)$$

实际上,与当前对话语义相似度更高的历史对话应该会产生更大的影响。因此,我们计算每个历史对话的权重$W_h = {w_{1},…, w_{T−1}}$
$$
w_{t} = \frac{\exp(he_{T}^T \cdot he_{t})}{\sum_{j=1}^{T-1} \exp(he_{T}^T \cdot he_{j}) }
$$
然后,我们计算历史ID和SF结果的加权潜在状态向量
$$
lsvI_{H} = \sum_{i=1}^{T-1} w_{t} \cdot lsvI_{t}
$$
$$
lsvS_{H} = \sum_{i=1}^{T-1} w_{t} lsvS_{t}
$$
为了将上下文信息整合到基本模型中,我们将历史潜在状态向量与当前词嵌入$e^T = {e^T_{1},…, e^T_{k}}$,得到新的嵌入$e^H = {e^H_{1},…e_{k}^H}$
$$
e_{j}^H = W^H \cdot (e_{j}^T \oplus lsvI_{H} \oplus lsvS_{H}) + b^H
$$

$e^H$包含当前话语信息以及对话历史中的话语、ID和SF信息。最后,我们将$e^H$输入到任意合适的SLU模型中,得到当前话语的预测结果。

基于结果的双向反馈网络 RBFN

在本节中,我们将详细介绍RBFN,它将预测结果中的语义信息整合到基本模型中,从而获得更全面的预测结果。在实践中,RBFN包含三个步骤。首先,RBFN接收来自DHR的词嵌入序列$e^H$。RBFN利用$e^H$,利用基本模型实现第一轮ID结果分布$resI1$和第一轮SF结果分布$resS^1 = {s^1_{1},…, s^1_{k}}$。

如前所述,在SLU中,ID的预测结果会影响SF的标注,而SF的结果可以验证ID的预测(所谓的Bi-Feedback)。受此启发,在RBFN的第二步,我们通过结果表示机制获得了第一轮结果$lsvI$和$lsvS$的潜在状态向量。然后将潜在状态向量$lsvI$和$lsvS$分别与词嵌入$e^H$合并进行第二轮预测或验证。

具体而言,对于ID结果信息,我们首先将$lsvI$与话语词嵌入$e^H$合并,得到基于ID结果的嵌入序列$e^I = {e^I_{1},…, e^I_{k}∈R^{d_w}}$
$$
e^I_{j}=W^I \cdot(lsvI \oplus e^H_{j}) + b^I
$$

其中$⊕$为拼接操作,$W^I ∈ R^{d_{w} \times (d_w+d_I)}$为全连通矩阵,$b^I ∈ R^{d_w}$为偏置向量。

与ID类似,对于SF结果的信息,我们也将$lsvS$与话语词嵌入$e^H$合并,得到基于SF结果的嵌入序列$eS = {e^S_{1},\dots,e^S_{k}∈R^d_{w}}$
$$
e^S_{j}=W^S \cdot(lsvS \oplus e^H_{j}) + b^S
$$

其中$W^S∈r^{d_w \times (d_{w}+d_{S})}$为全连通矩阵,$b^S∈R^{d_w}$为偏置向量。

随后,我们再次使用基本模型进行第二轮预测。为了利用意图信息指导SF过程,我们将 $e^I$ 重新发送到基本模型中,得到第二轮SF结果$resS^2 = {s^2_{1},\dots, s^2_{k}}$。为了利用槽位信息验证ID预测,我们将 $e^S$ 重新发送到基本模型中以获得第二轮ID结果$resI^2$。特别是,由于$resI^2$的目的是验证$resI^1$,所以在从基本模型输出$resI^2$时,我们将softmax函数替换为sigmoid函数。

在获得两轮结果后,我们将它们合并并计算最终的ID结果$resI$和SF结果$resS$
$$
resI = resI^1 \otimes resI^2
$$
$$
resS = resS^1 \otimes resS^2
$$

最后通过$f(x) = x_i/\sum x_j$对$resI$和$resS$进行归一化

实验

  • 为了评估RPFSLU的有效性,在多回合数据集KVRET[25]上进行了实验。
  • 数据集由3031个多回合对话组成,其中2425个对话在训练集中,302个在验证集中,304个在测试集中。
  • 我们利用验证集来选择超参数,并在测试集上使用我们的框架评估基线模型。
  • 对于每个模型,我们都进行了50次实验,并选择了最佳结果。
  • 在训练过程中,我们将基本模型中使用的所有超参数(例如批大小,epoch,优化器,学习率等)设置为原始论文。对于RPFSLU中使用的超参数,我们将意图嵌入大小dI设置为8,将槽嵌入大小dS设置为32,将词嵌入大小dw设置为与基本模型相同。注意层数据的维数设置为64。

实验结果

image.png

在公开数据集KVRET上的实验表明:

  1. 性能提升
    • 所有单轮SLU模型(如BiLSTM、Stack-Propagation)结合RPFSLU后,ID准确率、SF F1值和整体准确率均显著提升。
    • 例如,Stack-Propagation的SF F1值提升4.3%,Bi-model的整体准确率提升4.8%。
  2. 模块有效性(消融实验):
    • DHR单独使用即可提升模型性能(如1-layer BiLSTM的SF F1值提升1.7%)。
    • RBFN通过两轮预测和双向反馈进一步优化结果。
  3. 兼容性
    • RPFSLU框架无需修改单轮模型的内部结构,支持多种模型(如BiLSTM、Slot-gated等)。

总结

RPFSLU通过灵活利用历史预测结果和任务间交互,解决了多轮SLU的移植性和信息利用问题,显著提升了现有模型的性能。其核心创新在于将预测结果的语义信息显式建模,并通过双向反馈机制实现任务协同优化。

0%