2024年1月

一:背景

1. 讲故事

这个问题的由来是在
.NET高级调试训练营第十期
分享ThreadStatic底层玩法的时候,有朋友提出了
AsyncLocal
是如何实现的,虽然做了口头上的表述,但总还是会不具体,所以觉得有必要用
文字+图表
的方式来系统的说一下这个问题。

二:AsyncLocal 线程间传值

1. 线程间传值途径

在 C# 编程中实现多线程以及线程切换的方式大概如下三种:

  • Thread
  • Task
  • await,async

这三种场景下的线程间传值有各自的实现方式,由于篇幅限制,先从 Thread 开始聊吧。本质上来说 AsyncLocal 是一个纯托管的C#玩法,和 coreclr,Windows 没有任何关系。

2. Thread 小例子

为了方便讲述,先来一个例子看下如何在新Thread线程中提取 _asyncLocal 中的值,参考代码如下:


    internal class Program
    {
        static AsyncLocal<int> _asyncLocal = new AsyncLocal<int>();

        static void Main(string[] args)
        {
            _asyncLocal.Value = 10;

            var t = new Thread(() =>
            {
                Console.WriteLine($"Tid={Thread.CurrentThread.ManagedThreadId}, AsyncLocal value: {_asyncLocal.Value},");
                Debugger.Break();
            });

            t.Start();

            Console.ReadLine();
        }
    }

从截图看 tid=7 线程果然拿到了 主线程设置的
10
,哈哈,是不是充满了好奇心?接下来逐一分析下吧。

3. 流转分析

首先观察下
_asyncLocal.Value = 10
在源码层做了什么,参考代码如下:


    public T Value
    {
        set
        {
            ExecutionContext.SetLocalValue(this, value, m_valueChangedHandler != null);
        }
    }

    internal static void SetLocalValue(IAsyncLocal local, object newValue, bool needChangeNotifications)
    {
        ExecutionContext executionContext = Thread.CurrentThread._executionContext;

        Thread.CurrentThread._executionContext = new ExecutionContext(asyncLocalValueMap, array, flag2));
    }

从源码中可以看到这个 10 最终封印在
Thread.CurrentThread._executionContext
字段中,接下来就是核心问题了,它是如何被送到新线程中的呢?

其实仔细想一想,要让我实现的话,我肯定这么实现。

  1. 将主线程的 _executionContext 字段赋值给新线程 t._executionContext 字段。


  2. var t = new Thread()
    中的t作为参数传递给 win32 的 CreateThread 函数,这样在新线程中就可以提取 到 t 了,然后执行 t 的callback。

这么说大家可能有点抽象,我就直接画下C#是怎么流转的图吧:

有了这张图之后接下来的问题就是验证了,首先看一下 copy 操作在哪里? 可以观察下 Start 源码。


    private void Start(bool captureContext)
    {
        StartHelper startHelper = _startHelper;
        if (startHelper != null)
        {
            startHelper._startArg = null;
            startHelper._executionContext = (captureContext ? System.Threading.ExecutionContext.Capture() : null);
        }
        StartCore();
    }
    public static ExecutionContext? Capture()
    {
        ExecutionContext executionContext = Thread.CurrentThread._executionContext;
        return executionContext;
    }

从源码中可以看到将主线程的
_executionContext
字段给了新线程t下的
startHelper._executionContext

接下来我们观察下在创建 OS 线程的时候是不是将 Thread 作为参数传过去了,如果传过去了,那就可以直接在新线程中拿到
Thread._startHelper._executionContext
字段,验证起来也很简单,在win32 的
ntdll!NtCreateThreadEx
上下一个断点即可。


0:000> bp ntdll!NtCreateThreadEx
0:000> g
Breakpoint 1 hit
ntdll!NtCreateThreadEx:
00007ff9`0fe8e8c0 4c8bd1          mov     r10,rcx
0:000> r
rax=00007ff8b4a529d0 rbx=0000000000000000 rcx=0000008471b7df28
rdx=00000000001fffff rsi=0000027f2ca25b01 rdi=0000027f2ca25b60
rip=00007ff90fe8e8c0 rsp=0000008471b7de68 rbp=00007ff8b4a529d0
 r8=0000000000000000  r9=ffffffffffffffff r10=0000027f2c8a0000
r11=0000008471b7de40 r12=0000008471b7e890 r13=0000008471b7e4f8
r14=ffffffffffffffff r15=0000000000010000
iopl=0         nv up ei pl nz na po nc
cs=0033  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000206
ntdll!NtCreateThreadEx:
00007ff9`0fe8e8c0 4c8bd1          mov     r10,rcx
0:000> !t
ThreadCount:      4
UnstartedThread:  1
BackgroundThread: 2
PendingThread:    0
DeadThread:       0
Hosted Runtime:   no
                                                                                                            Lock  
 DBG   ID     OSID ThreadOBJ           State GC Mode     GC Alloc Context                  Domain           Count Apt Exception
   0    1     2cd8 0000027F2C9E6610    2a020 Preemptive  0000027F2E5DB438:0000027F2E5DB4A0 0000027f2c9dd670 -00001 MTA 
   6    2     2b24 0000027F2CA121E0    21220 Preemptive  0000000000000000:0000000000000000 0000027f2c9dd670 -00001 Ukn (Finalizer) 
   7    3     2658 0000027F4EAA0AE0    2b220 Preemptive  0000000000000000:0000000000000000 0000027f2c9dd670 -00001 MTA 
XXXX    4        0 0000027F2CA25B60     9400 Preemptive  0000000000000000:0000000000000000 0000027f2c9dd670 -00001 Ukn 

从输出中可以看到 NtCreateThreadEx 方法的第二个参数即
rdi=0000027f2ca25b60
就是我们的托管线程,如果你不相信的话可以再用 windbg 找到它的托管线程信息,输出如下:


0:000> dt coreclr!Thread 0000027F2CA25B60 -y m_ExposedObject
   +0x1c8 m_ExposedObject : 0x0000027f`2c8f11d0 OBJECTHANDLE__

0:000> !do poi(0x0000027f`2c8f11d0)
Name:        System.Threading.Thread
MethodTable: 00007ff855090d78
EEClass:     00007ff85506a700
Tracked Type: false
Size:        72(0x48) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.25\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ff8550c76d8  4000b35        8 ....ExecutionContext  0 instance 0000000000000000 _executionContext
0000000000000000  4000b36       10 ...ronizationContext  0 instance 0000000000000000 _synchronizationContext
00007ff85508d708  4000b37       18        System.String  0 instance 0000000000000000 _name
00007ff8550cb9d0  4000b38       20 ...hread+StartHelper  0 instance 0000027f2e5db3b0 _startHelper
...

有些朋友可能要说,你现在的 _executionContext 字段是保留在 _startHelper 类里,并没有赋值到Thread._executionContext字段呀?那这一块在哪里实现的呢?从上图可以看到其实是在新线程的执行函数上,在托管函数执行之前会将 _startHelper._executionContext 赋值给 Thread._executionContext , 让 windbg 继续执行,输出如下:


0:009> k
 # Child-SP          RetAddr               Call Site
00 00000084`728ff778 00007ff8`b4c23d19     KERNELBASE!wil::details::DebugBreak+0x2
01 00000084`728ff780 00007ff8`b43ba7ea     coreclr!DebugDebugger::Break+0x149 [D:\a\_work\1\s\src\coreclr\vm\debugdebugger.cpp @ 148] 
02 00000084`728ff900 00007ff8`54ff56e3     System_Private_CoreLib!System.Diagnostics.Debugger.Break+0xa [/_/src/coreclr/System.Private.CoreLib/src/System/Diagnostics/Debugger.cs @ 18] 
03 00000084`728ff930 00007ff8`b42b4259     ConsoleApp9!ConsoleApp9.Program.<>c.<Main>b__1_0+0x113
04 00000084`728ff9c0 00007ff8`b42bddd9     System_Private_CoreLib!System.Threading.Thread.StartHelper.Callback+0x39 [/_/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 42] 
05 00000084`728ffa00 00007ff8`b42b2f4a     System_Private_CoreLib!System.Threading.ExecutionContext.RunInternal+0x69 [/_/src/libraries/System.Private.CoreLib/src/System/Threading/ExecutionContext.cs @ 183] 
06 00000084`728ffa70 00007ff8`b4b7ba53     System_Private_CoreLib!System.Threading.Thread.StartCallback+0x8a [/_/src/coreclr/System.Private.CoreLib/src/System/Threading/Thread.CoreCLR.cs @ 105] 
07 00000084`728ffab0 00007ff8`b4a763dc     coreclr!CallDescrWorkerInternal+0x83
08 00000084`728ffaf0 00007ff8`b4b5e713     coreclr!DispatchCallSimple+0x80 [D:\a\_work\1\s\src\coreclr\vm\callhelpers.cpp @ 220] 
09 00000084`728ffb80 00007ff8`b4a52d25     coreclr!ThreadNative::KickOffThread_Worker+0x63 [D:\a\_work\1\s\src\coreclr\vm\comsynchronizable.cpp @ 158] 
...
0d (Inline Function) --------`--------     coreclr!ManagedThreadBase_FullTransition+0x2d [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 7569] 
0e (Inline Function) --------`--------     coreclr!ManagedThreadBase::KickOff+0x2d [D:\a\_work\1\s\src\coreclr\vm\threads.cpp @ 7604] 
0f 00000084`728ffd60 00007ff9`0e777614     coreclr!ThreadNative::KickOffThread+0x79 [D:\a\_work\1\s\src\coreclr\vm\comsynchronizable.cpp @ 230] 
10 00000084`728ffdc0 00007ff9`0fe426a1     KERNEL32!BaseThreadInitThunk+0x14
11 00000084`728ffdf0 00000000`00000000     ntdll!RtlUserThreadStart+0x21
...

在上面的回调函数中看的非常清楚,在执行托管函数
<Main>b__1_0
之前执行了一个
ExecutionContext.RunInternal
函数,对,就是它来实现的,参考代码如下:


    private sealed class StartHelper
    {
        internal void Run()
        {
            System.Threading.ExecutionContext.RunInternal(_executionContext, s_threadStartContextCallback, this);
        }
    }

    internal static void RunInternal(ExecutionContext executionContext, ContextCallback callback, object state)
    {
        Thread currentThread = Thread.CurrentThread;
        RestoreChangedContextToThread(currentThread, executionContext, executionContext3);
    }

    internal static void RestoreChangedContextToThread(Thread currentThread, ExecutionContext contextToRestore, ExecutionContext currentContext)
    {
        currentThread._executionContext = contextToRestore;
    }

既然将 StartHelper.executionContext 塞到了 currentThread._executionContext 中,在
<Main>b__1_0
方法中自然就能通过
_asyncLocal.Value
提取了。

三:总结

说了这么多,其实精妙之处在于创建OS线程的时候,会把C# Thread实例(coreclr对应线程) 作为参数传递给新线程,即下面方法签名中的
lpParameter
参数,新线程拿到了Thread实例,自然就能获取到调用线程赋值的
Thread._executionContext
字段,所以这是完完全全的C#层面玩法,希望能给后来者解惑吧!


HANDLE CreateThread(
  [in, optional]  LPSECURITY_ATTRIBUTES   lpThreadAttributes,
  [in]            SIZE_T                  dwStackSize,
  [in]            LPTHREAD_START_ROUTINE  lpStartAddress,
  [in, optional]  __drv_aliasesMem LPVOID lpParameter,
  [in]            DWORD                   dwCreationFlags,
  [out, optional] LPDWORD                 lpThreadId
);

图片名称

本文分享自华为云社区《
浅谈如何处理大语言模型训练数据之二数据影响分析
》,作者:码上开花_Lancer。

由于大语言模型的训练需要巨大的计算资源,通常不可能多次迭代大语言模型预训练。千亿级参数量的大语言模型每次预训练的计算需要花费数百万元人民币。因此,在训练大语言模型之前,构建一个准备充分的预训练语料库尤为重要。

本篇文章中,将从数据规模、数量质量以及数据多样性三个方面分析数据对大语言模型的性能的影响。需要特别的说明的是,由于在千亿参数规模的大语言模型上进行实验的成本非常高,很多结论是在100 亿甚至是10 亿规模的语言模型上进行的实验,其结果并不能完整的反映数据对大语言模型的影响。此外,一些观点仍处于猜想阶段,需要进一步验证。需要各位读者甄别判断。

1.数据规模影响

随着大语言模型参数规模的增加,为了有效地训练模型,需要收集足够数量的高质量数据,在文献 针对模型参数规模、训练数据量以及总计算量与模型效果之间关系的研究之前,大部分大语言模型训练所采用的训练数据量相较于LLaMA 等最新的大语言模型模型都少很多。

表1给出了模型参数量和预训练数据量对比。在Chinchilla 模型提出之前,大部分的大语言模型都在着重提升模型的参数量,但是所使用的训练语料数量都在3000 亿词元左右,LAMDA 模型所使用的训练语料数量甚至仅有1680 亿。虽然Chinchilla 模型参数量仅有LAMDA 模型一半大小,但是训练语料的词元数量却达到了1.4 万亿词元,是LaMDA 模型训练语料的8 倍多。

表1模型参数量与训练语料数量对比

DeepMind 的研究人员在文献 中给出了他们通过训练参数范围从7000 万到160 亿,训练词元数量从5 亿到5000 亿不等的400 多个语言模型所得到分析结果。研究发现,如果模型训练要达到计算最优(Compute-optimal),模型大小和训练词元数量应该等比例缩放,即模型大小加倍则训练词元数量也应该加倍。为了验证该分析结果,他们使用与Gopher 语言模型训练相同的计算资源,根据上述理论预测了Chinchilla 语言模型的最优参数量与词元数量组合。最终确定Chinchilla语言模型为700 亿参数,使用了1.4 万亿词元进行训练。通过实验发现,Chinchilla 在很多下游评估任务中都显著地优于Gopher(280B)、GPT-3(175B)、Jurassic-1(178B)以及Megatron-Turing NLG(530B)。

图1.1 给出了在同等计算量情况下,训练损失随参数量的变化情况。针对9 种不同的训练参数量设置,使用不同词元数量的训练语料,训练不同大小的模型参数量,使得最终训练所需浮点运算数达到预定目标。对于每种训练量预定目标,图中左侧绘制了平滑后的训练损失与参数量之间的关系。从左图中可以看到,训练损失值存在明显的低谷,这意味着对于给定训练计算量目标,存在一个最佳模型参数量和训练语料数量配置。利用这些训练损失低谷的位置,还可以预测更大的模型的最佳模型参数量大小和训练词元数量,如图3.5中间和右侧所示。图中绿色线表示使用Gopher训练计算量所预测的最佳模型参数量和训练数据词元数量。还可以使用幂律(Power Law)对计算量限制、损失最优模型参数量大小以及训练词元数之间的关系进行建模。C 表示总计算量、Nopt表示模型最优参数量、Dopt 表示最优训练词元数量,他们之间的关系符合一下关系:

图1.1 在同等计算量情况下,训练损失随参数量的变化情况

LLaMA 模型在训练时采用了与文献相符的训练策略。研究发现,70 亿参数的语言模型在训练超过1 万亿个词元(1T Tokens)后,性能仍在持续增长。因此,Meta 的研究人员在LLaMA2模型训练中,进一步增大了训练数据量,训练数据量达到了2 万亿词元。文献给出了不同参数量模型在训练期间,随着训练数据量的增加,模型在问答和常识推理任务上的效果演变过程,如图2.1所示。研究人员分别在TriviaQA、HellaSwag、NaturalQuestions、SIQA、WinoGrande以及PIQA 等6 个数据集上进行了测试。可以看到,随着训练数据量的不断提升,模型在分属两类任务的6 个数据集上的性能都在稳步提高。通过使用更多的数据和更长的训练时间,较小的模型也可以实现良好的性能。

图1.2 LLaMA 模型训练期间在问答和常识推理任务上效果演变

文献对不同任务类型所依赖的语言模型训练数量进行了分析。针对分类探查(ClassifierProbing)、信息论探查(Info-theoretic Probing)、无监督相对可接受性判断(Unsupervised Relative Acceptability Judgment)以及应用于自然语言理解任务的微调(Fine-tuning on NLU Tasks)等四类任务,基于不同量级预训练数据的RoBERTa模型在上述不同类型任务上的效果进行了实验验证和分析。分别针对预训练了1M、10M、100M 和1B 个单词的RoBERTa 模型进行了能力分析。研究发现,模型只需要约10M 到100M 个单词的训练,就可以可靠地获得语法和语义特征。但是需要更多的数据量训练才能获得足够的常识知识和其他技能,并在典型下游自然语言理解任务中取得较好的结果。

2 数据质量影响

数据质量通常被认为是影响大语言模型训练效果的关键因素之一,包含大量重复的低质量数据甚至导致训练过程不稳定,造成模型训练不收敛。现有的研究表明训练数据的构建时间、包含噪音或有害信息情况以及数据重复率等因素,都对语言模型性能存在较大影响。截止到2023 年9 月的研究都得出了相同的结论,即语言模型在经过清洗的高质量数据上训练数据可以得到更高的性能。

文献介绍了Gopher 语言模型在训练时针对文本质量进行的相关实验。如图2.1所示,具有140 亿参数的模型在OpenWebText、C4 以及不同版本的MassiveWeb 数据集上训练得到的模型效果对比。他们分别测试了利用不同数据训练得到的模型在Wikitext103 单词预测、Curation Corpus摘要以及LAMBADA 书籍级别的单词预测三个下游任务上的表现。图中Y 轴表示不同任务上的损失,数值越低表示性能越好。从结果可以看到,使用经过过滤和去重后的MassiveWeb 数据训练得到的语言模型在三个任务上都远好于使用未经处理的数据训练得到的模型。使用经过处理的MassiveWeb 数据训练得到的语言模型在下游任务上的表现也远好于使用OpenWebText 和C4 数据集训练得到的结果。

图2.1 Gopher 语言模型使用不同数据质量训练效果分析

GLaM 语言模型构建时,同样也对训练数据质量的影响的进行了分析。该项分析同样使用包含17 亿参数的模型,针对下游少样本任务性能进行了分析。使用相同超参数,对通过原始数据集和经过质量筛选后的数据训练得到的模型效果进行了对比,实验结果如图2.2所示。可以看到,使用高质量数据训练的模型在自然语言生成和自然语言理解任务上表现更好。特别是,高质量数据对自然语言生成任务上的影响大于在自然语言理解任务。这可能是因为自然语言生成任务通常需要生成高质量的语言,过滤预训练语料库对于语言模型的生成能力至关重要。文献[86] 的研究强调了预训练数据的质量在下游任务的性能中也扮演着关键角色。

Google Research 的研究人员针对数据构建时间、文本质量、是否包含有害信息开展了系统研究。使用具有不同时间、毒性和质量以及领域组成的数据,训练了28 个15 亿参数的仅解码器结构语言模型。研究结果表明,语言模型训练数据的时间、内容过滤方法以及数据源对下游模型行为具有显著影响。

图2.2 GLaM 语言模型使用不同数据质量训练效果分析

针对数据时效性对于模型效果的影响问题,研究人员们在C4 语料集的2013、2016、2019 和2022 等不同版本上训练了四个自回归语言模型。对于每个版本,从Common Crawl 数据上删除了截止年份之后爬取的所有数据。使用新闻、Twitter 和科学领域的评估任务来衡量时间错位的影响。

这些评估任务的训练集和测试集按年份划分,分别在每个按年份划分的数据集上微调模型,然后在所有年份划分的测试集上进行评估。图2.3给出了使用4 个不同版本的数据集所训练得到的模型在5 个不同任务上的评测结果。热力图颜色(Heatmap Colors)根据每一列进行归一化得到。从图中可以看到,训练数据和测试数据的时间错配会在一定程度上影响模型的效果。

图2.3 训练数据和测试数据之间的时间错位情况下性能分析

Anthropic 的研究人员针对数据集中的重复问题开展了系统研究。为了研究数据重复对大语言模型的影响,研究人员构建了特定的数据集,其中大部分数据是唯一的,但是只有一小部分数据被重复多次,并使用这个数据集训练了一组模型。研究发现了一个强烈的双峰下降现象,即重复数据可能会导致训练损失在中间阶段增加。例如,通过将0.1% 的数据重复100 次,即使其余90% 的训练数据保持不变,一个800M 参数的模型的性能也可能降低到与400M 参数的模型相同。此外,研究人员还设计了一个简单的复制评估,即将哈利·波特(Harry Potter)的文字复制11 次,计算模型在第一段上的损失。在仅有3% 的重复数据的情况下,训练过程中性能最差的轮次仅能达到参数量为1/3 的模型的结果。

文献 中对大语言模型的记忆能力进行分析,根据训练样例在训练数据中出现的次数,显示了记忆率的变化情况,如图2.4所示。可以看到,在训练中只见过一次的样例,Palm 模型的记忆率为0.75%,而见过500 次以上的样例的记忆率超过40%。这也在一定程度上说明重复数据对于语言模型建模具有重要影响。这也可能进一步影响使用上下文学习的大语言模型的泛化能力。由于Palm 方法仅使用了文档级别过滤,因此片段级别(100 个以上Token)可能出现非常高的重复次数。

图2.4 大语言模型记忆能力评

3.数据多样性影响

来自不同领域、使用不同语言、应用于不同场景的训练数据具有不同的语言特征,包含不同语义知识。通过使用不同来源的数据进行训练,大语言模型可以获得广泛的知识。表3.2给出了LLaMA模型训练所使用数据集合。可以看到LLaMA 模型训练混合了大量不同来源数据,包括网页、代码、论文、图书、百科等。针对不同的文本质量,LLaMA 模型训练针对不同质量和重要性的数据集设定了不同的采样概率,表中给出了不同数据集在完成1.4 万亿词元训练时,每个数据集的采样轮数。

Gopher 模型训练过程对数据分布进行了消融实验,验证混合来源对下游任务的影响情况。

表3.1 LLaMA 模型所使用数据集合

针对MassiveText 子集设置了不同权重的数据组合,并用于训练语言模型。利用Wikitext103、LAMBADA、C4 和Curation Corpus 测试不同权重组合训练得到的语言模型在下游任务上的性能。为了限制数据组合分布范围,实验中固定了Wikipedia 和GitHub 两个数据集合的采样权重。对于Wikipedia,要求对训练数据进行完整的学习,因此将采样权重固定为2%。对于GitHub,采样权重设置为3%。

对于剩余的的四个子集(MassiveWeb、News、Books 和C4)设置了7 种不同的组合。图3.2给出了7 种不同子集采样权重训练得到Gopher 模型在下游任务上的性能。可以看到,使用不同数量子集采样权重训练,所获得模型效果差别很大。在所有任务中表现良好且在Curation Corpus 上取得最佳表现的绿色配置是10% 的C4、50% 的MassiveWeb、30% 的Books 和10% 的News。增加书籍数据的比例可以提高模型从文本中捕获长期依赖关系的能力,因此可以降低LAMBADA 语料集上的损失。而使用更高比例的C4 数据集则有助于在C4 验证集上获得更好的表现。

图3.2 采用不同采样权重训练得到的Gopher 语言模型在下游任务上的性能

以上就是从数据规模、数量质量以及数据多样性三个方面分析数据对大语言模型的性能的影响的介绍,在构建大语言模型时,数据的质量和多样性对于提高模型的性能至关重要。同时,为了推动大语言模型的研究和应用,学术界和工业界也开放了多个针对大语言模型的开源数据集。下一篇文章中,我还会介绍典型的开源数据集合,敬请关注!

点击关注,第一时间了解华为云新鲜技术~

一、前言

两年多来未曾更新博客,最近一位朋友向我咨询中文输入法问题。具体而言,他在使用CefSharp WPF版本时遇到了一个问题,即输入法突然出现在屏幕的左上角。在这里记录下处理这个问题的过程,希望能够帮助到其他遇到类似问题的开发者。让我们一起来探讨如何解决能更好的处理CefSharp WPF版本中的中文输入法显示问题。

二、WpfImeKeyboardHandler


https://github.com/cefsharp/cefsharp
上查看了关于CefSharp的Issues,搜索IME(Input Method Editor 输入法编辑器),可以看到一些 Chinese IME相关的问题

点击第一个链接找到了一个源码关于cefsharp fix chinese IMES的提交记录

仅展示关键代码,使用方式如下:

    public classCustomWebBrowser : ChromiumWebBrowser
{
publicCustomWebBrowser()
{
this.WpfKeyboardHandler = new WpfImeKeyboardHandler(this);
}
}

虽然官方提供了关于WpfImeKeyboardHandler的处理方法,但在我的测试中,效果并不理想,仍然存在输入法位置偏移的情况。可能是我使用的方式不对,也有可能是官方并未覆盖某些特定情境。

如果有哪位朋友通过上述方式成功解决了中文输入法问题,不妨留言分享一下,这将对我和其他遇到相似问题的开发者都是极大的帮助。

二、CefSharp.Wpf.HwndHost

在浏览 CefSharp 仓库时,我注意到了 CefSharp.Wpf.HwndHost,并且红框中的说明引起了我的关注,特别是关于 IME支持的部分。

IME 支持可能是为了解决输入法相关的问题。

下载CefSharp.Wpf.HwndHost 仓库,运行如下:

效果来看基于HwndHost解决了输入法框偏移的问题。

与此同时使用HwndHost也会带来一些问题,如:

  • 需要遵守“空域规则”,即 WPF 和 Win32 的内容必须总是使用它们自己的不同窗口区域,不能重叠或遮挡。
  • 需要处理一些复杂的细节,如键盘焦点、鼠标捕获、消息泵等。
  • 可能会影响 WPF 的布局和渲染,因为 Win32 窗口不是 WPF 的可视化对象,不能使用 WPF 的属性和事件。

官方的Readme如下:

翻译如下:

为那些想要本机基于Win32的实现(用于IME支持和更好的性能)的人设计的替代CefSharp.Wpf.ChromiumWebBrowser。

该控件使用HwndHost来托管本机CefBrowser实例。

与任何HwndHost为基础的控制标准空域问题适用。

三、使用WindowsFormsHost承载CefSharp.WinForms

下载
https://github.com/cefsharp/cefsharp
仓库,测试了CefSharp.WinForms

运行结果来看CefSharp.WinForms可以更好的支持中文输入法。

当然WPF通过WindowsFormsHost来承载Winform窗体同样也会带来以下问题:

  • WindowsFormsHost同样需要遵守“空域规则”
  • 需要处理一些复杂的细节,如键盘焦点、鼠标捕获、消息泵等。
  • 可能会影响 WPF 的布局和渲染,不能使用 WPF 的属性和事件。

四、结尾

考虑到CefSharp.WinForms和CefSharp.WPF的接口大部分相似,我们在项目中选择使用WindowsFormsHost来承载CefSharp.WinForms,以解决中文输入法的问题。如果有哪位园友有更优的解决方案,欢迎留言分享,让我们共同解决中文输入法的问题。

五、源码地址

gitee地址:
https://gitee.com/sirius_machao/mweb-browser

github地址:
https://github.com/sirius-chao/MWebBrowser

项目邀请:如对该项目有兴趣,欢迎联系我共同开发!!!

作者:来自 vivo 互联网大数据团队

本文根据刘开周老师在“2023 vivo开发者大会"现场演讲内容整理而成。公众号回复【2023 VDC】获取互联网技术分会场议题相关资料。

本文介绍了vivo在万亿级数据增长驱动下,基础数据架构建设的演进过程,在实时和离线计算过程中,如何基于业务发展,数据质量,计算成本等方面的挑战,构建稳定,可靠,低成本、高性能的双活计算架构。

基础数据是公司大数据应用的关键底座,价值挖掘的基石,
内容
包括:大数据集成,数据计算,架构容灾等几个主要方面。
建设的目标
包括:确保基础数据及时准确、计算性能好、资源成本消耗低、架构容灾能力强、研发效率高,这也是基础数据工作的核心能力。

一、基础数据发展与挑战

1.1 vivo 早期的基础数据架构

为了满足业务发展,0-1构建基础数据的基础框架,数据来源主要是日志,通过实时采集,缓存到Kafka,按小时离线转存到ODS表,日处理数据量在百亿级,整个数据链路简洁高效,但是,随着业务发展,数据增长,用户的诉求多样化,该基础数据架构逐渐面临诸多挑战。

图片

1.2 vivo 业发展带来挑战

一是:
数据规模增长,日增记录数从百亿到万亿级,日增存储量从GB级到PB级,实时并发QPS量级达到数据百万。

二是:
计算场景增加,从离线计算扩展到准实时,实时,甚至流批一体计算场景。

三是:
性能要求提高,实时计算端到端延时,需要从小时到秒级;离线计算单小时数据量级从GB达到10TB+,业务发展速度超过了技术架构迭代速度,必然给技术带来更大的挑战。

图片

1.3 技术挑战

首先是单个Topic数据量每天数百亿,多个消费组同时消费,重复消费导致计算和存储资源浪费;Kafka集群稳定性越来越差。

数据量的增加,数据采集和ETL计算时延越来越长,无法满足链路秒级时延,每小时超过10TB的离线处理时间超过2~3小时。

考虑存储成本的原因,Kafka生命周期配置有限,长时间的故障会导致数据丢失。

由于计算性能和吞吐有限,需要不断增加资源,运维值班的压力日益增长,每月有超过20天都有起夜的情况。

当然,除了技术挑战,还有面临用户的挑战。

图片

1.4 用户诉求

  • 数据安全方面
    :数据加密,计算|需要解密|和鉴权,确保数据的安全合规

  • 带宽成本方面
    :数据压缩,计算|需要解压缩|和拆分,降低传输的带宽成本

  • 存储成本方面
    :数据输出,需要支持|不同压缩格式,以降低存储成本

  • 使用便捷方面
    :需要扩充|基础数据|公共维度,避免下游重复计算

  • 使用门槛方面
    :实时和离线数据|需要满足SQL化查询,降低用户使用门槛

图片

二、vivo 基础数据架构应用实践

2.1 整体架构

基于业务发展,构建多机房多集群,双活容灾链路基础架构,全面支持多种周期(秒级/分钟/小时/天等)数据计算场景。

图片

相比较历史架构,我们新增了离线采集链路,直接从源端拷贝LOG日志,缓存到HDFS目录,再解析入库写ODS表,与原实时链路互备,可实现链路故障容灾切换,同时,实时计算增加分拣层,收敛消费,支持多组件的配置化输出,为了确保数据及时和准确性,构建了完善的数据校验和监控体系。

显然,当前的架构有点类似Lambda架构,可能会有以下几个疑问:

  • 实时和离线链路会出现存储和计算冗余,浪费资源多;

  • 实时和离线计算会存在数据一致性问题,运维成本大;

  • 现在都发展到流批/湖仓一体计算,此架构不够先进。

大数据计算架构,满足公司和业务发展,才是最好的,过于追求先进,又或者太过落后,都不利于公司和业务的发展,基础数据,重点是稳定高可用,通过持续的优化和迭代,将资源浪费问题,数据一致性问题和性能问题解决,构建一种双活容灾全新架构,才是我们初衷。

结合业务发展和使用调研,发现批计算场景远多于实时计算场景,并且有以下特点:

  1. 因Kafka的存储与HDFS存储比较,成本高,如果将万亿级数据全部缓存Kafka,存储成本巨大。

  2. 实时应用场景占比很少,约20%,海量数据消费资源持续空跑,导致大量计算资源浪费。

  3. Kafka数据使用门槛高,不能直接SQL查询,理解和使用的效率太低。

  4. 离线重跑频繁,Kafka消费重置offset操作不方便,运维难度较大。

  5. 流批/湖仓一体架构成熟度有限,技术挑战难度较大,稳定性存在挑战。

  6. 基础数据的双链路一致性问题、资源冗余问题、性能问题,通过架构调整是可以解决的。

图片

2.2 双链路设计

结合2种用数场景,将离线和实时计算链路,数据缓存和计算分离,减少实时存储和计算的资源,减少故障风险。

图片

只有
实时计算
诉求,开启实时采集;写入到Kafka或者Pulsar集群,缓存8-24小时(可根据需要调整),用于后续

时计算。

只有
离线计算
诉求,开启离线采集;按小时拷贝到HDFS缓存集群,保存2-7天(可根据需要调整),用于后续离线计算。

同时,数据采集端确保实时和离线数据不冗余,这样设计的
好处
就是:

  • 数据缓存 HDFS 比 Kafka 成本更低(降低40%成本),不容易丢,离线重跑更加便捷;

  • 实时链路出问题可立即切换到离线链路(定点采集,分钟级切换入仓),容灾能力会更加强大。

随着业务发展,实时场景逐渐增加,切换到实时链路后,会与原离线数据比较,数据不一致性风险更大,为此,我们通过三个措施解决,将ETL过程组件化,标准化,配置化。

一是:
开发上线通用组件,离线和实时ETL共用

二是:
成立ETL|专属团队,统一处理逻辑

三是:
构建ETL处理平台,配置化开发

这样,通过链路切换,处理逻辑统一,功能和逻辑一致,既提升了研发效率,也消除了数据不一致风险;而在计算方面,实时和离线计算集群相互独立,实时和离线数据缓存计算相互独立,互不影响,计算更加稳定。

解决了Kafka存储成本、双链路数据不一致、链路容灾问题,接下来就是
计算性能
的问题需要解决:

  1. 实时计算,存在每天百亿级别的大Topic,多消费组重复消费,计算资源浪费。

  2. 实时计算,数据全链路端到端(数据生产端到数据用端)秒级延迟诉求无法满足。

  3. 离线计算,单次处理数据量10TB+,计算时间长超过2小时,计算内存配置TB级,及时性没法保证。

  4. 离线计算,单小时数据量级不固定,任务配置的计算资源是固定的,当数据量增加时,常有oom现象,必然,导致值班运维压力就比较大。

2.3 实时计算性能优化

增加统一分拣层,通过Topic一次消费,满足不同业务的数据要求,避免重复消费,存储换计算,降低成本。

图片

为了解决百亿级大Topic=重复消费问题,我们构建了实时分拣层,主要是基于用户不同诉求,将不同用户,需要的部分数据,单独分拣到子Topic,提供用户消费,该分拣层,只需要申请一个消费组,一次消费,一次处理即可,有效避免重复消费和计算,这样,通过对大Topic部分数据的适当冗余,以存储换计算,可降低资源成本30%以上,同时,有效确保下游数据的一致性。

为了实现实时链路秒级延时,也遇到了一些困难,  主要介绍下高并发场景下的
Redis批量动态扩容问题

在实时ETL环节,会存在多个维表关联,维表缓存Redis,实时并发请求量达到数百万,因并发量持续增加,在Redis动态批量扩容时,会因数据均衡导致请求延迟,严重时达30分,单次扩容量机器越多越严重,这种延时部分业务无法接受, 我们考虑到=后续组件容灾的需要,通过
请求时延、并发量、扩容影响
等几个方面的kv组件验证测试,最终采用了HBase2.0,得益于它毫秒级的请求延时,优秀的异步请求框架,扩容批量复制region功能,因此,我们将HBase引入到实时链路中,达到解决Redis批量扩容导致消费延时的问题。

对于动态扩容延时敏感业务,优先采用HBase缓存维表,Redis作为降级容灾组件;对于动态扩容延时不敏感业务,优先采用Redis缓存维表,HBase作为降级容灾组件。

图片

在实际应用中,还有两个小
建议

一是
:实时任务重启时,瞬间会产生大量Redis连接请求,Redis服务器负载急剧增加,会存在无法建立连接直接抛弃的情况,因此,建议在Redis连接代码中增加重试机制,或者,连接量比较大时,可以适当分批连接。

二是
:Redis组件的单点故障,不管是不是集群部署,难免出现问题,以免到时束手无策,建议增加额外组件降级容灾,我们主要是HBase和Redis并存。

2.4 离线计算性能优化

批处理,参考流计算的原理,采用微批处理模式,解决超过10TB/小时的性能问题。

图片

前面多次提到的离线计算,单次处理数据量超过10TB,消耗特别多的资源,数据经常出现延迟,从图中可以看出,链路处理环节比较多,尤其在Join大维表时,会产生大量shuffle读写,频繁出现7337端口异常现象(这里的7337是ESS服务端口),因集群没有类似RSS这样的服务,即使有,也不一定能抗住这个量级的shuffle读写,所以,降低shuffle数量,是我们提升离线计算性能的关键。

为了降低shuffle数量,首先想到的就是降低单次处理数据量,于是,我们借鉴了流式计算模型,设计了微批计算架构,其
原理介绍
下:

数据采集写HDFS频率由小时改为分钟级(如10分钟);持续监控缓存目录,当满足条件时(比如大小达到1TB),自动提交Spark批处理任务;读取该批次文件,识别文件处理状态,并写元数据,处理完,更新该批次文件状态,以此循环,将小时处理,调整为无固定周期的微批处理;当发现某小时数据处理完成时,提交hive表分区(注意:是否处理完我们调用采集接口,这里不做详细描述)。

这种微批计算架构,通过充分利用时间和资源,在提升性能和吞吐量的同时,也提升了资源利用率。至此,我们降低了单次处理的数据量,比如:业务表单次处理数据量从百亿下将到10亿,但是,join多张大维表时shuffle量依然很大,耗时较长,资源消耗较高,这不是完美的解决方案,还需要在维表和join方式上持续优化。

维表的优化,将全局全量维表,修改为多个业务增量维表,降低Join维表数据量,以适当冗余存储换Join效率。

因为维表都是公司级的全量表,数据在4~10亿左右,且需要关联2到3个不同维表,关联方式是Sort Merge Join,会产生shuffle和Sort的开销,效率很低。

图片

因此,我们做了降低维表量级,调整Join模式两个优化,降维表如下:

首先:
基于业务表和维表,构建业务增量维表,维表数据量从亿级下降到千万级;

其次:
所有维表都存储在HBase,增量维表半年重新初始化一次(减少无效数据);

最后:
Join时优先使用增量维表,少部分使用全量维表,并且每次计算都会更新增量维表。

接下来,调整业务表和维表的Join方式,首先,来看下原来大表关联使用的Sort Merge Join的原理。

图片

先读取数据,基于SortShuffleManager机制,做内存排序,磁盘溢写,磁盘文件合并等操作,然后,对每个分区的数据做排序,最后匹配关联,可以有效解决大数据量关联,不能全部内存Join的痛点。

而我们降低了业务表和维表的数据量,分区减少了,shuffle量自然也会减少,如果再把消耗比较大的分区排序去掉,就可以大大提升关联性能。

而对于千万级维表如果采用广播方式,可能造成Driver端OOM,毕竟维表还是GB级别的,所以,采用Shuffle Hash Join方式是最佳方案。

图片

最大的
优点
就是,就是将维表分区的数据加载到内存中,并且使用Map结构保存,Join时,通过get的方式遍历,避免排序,简单高效。

这样,通过降低业务表和维表数据量,改变Join方式,相比较原来计算性能提升60%+,至此,离线计算性能问题得到解决,数据产出及时性也就迎刃而解。

2.5 数据完整性

在数据采集,实时ETL和离线ETL,写ODS过程中,如何确保数据不丢,不错,保持数据完整性 ?其挑战主要有三个。

  1. 数据完整如何判定,比如A表数据量,下降20%?或者30%,表示不完整?很难统一定义,也是行业痛点。

  2. 出现问题,并且是异常,如何快速定位?

  3. 不完整的数据,给到下游用户,成千上万的任务都在使用错误的数据计算,影响面很大,故障恢复成本很高。

而这一切的基础,都需要依赖元数据,因此,元数据收集成了很关键的工作,必须优先设计和建设,这里不展开讲实时元数据的收集内容。

图片

当有了丰富的元数据后,利用实时元数据,我们在链路中,增加了三层实时数据完整性对账校验,它们分别是:

  • 数据采集,完整性对账

  • ETL处理,完整性对账

  • 组件输出,完整性对账

这样,通过可视化输出对账结果,能够快速定位和发现问题,定位时长从天级别下降到分钟级别。

图片

为了准确识别数据异常波动,我们结合业务特征,建设出了多种完整性校验方法,并构建多功能交叉验证体系,应用于数据校验,主要有以下几种校验方案:

  1. 短周期内的同比和环比

  2. 基于历史趋势的算法校验

  3. 基于数据时延的偶发漂移

  4. 基于节假日的数据起伏等

  5. 基于时间段的操作特征等

将这些验证方案,交叉叠加应用到,不同的表和Topic,可以明显提升异常发现的准确率,实际从85%提升到99%,如果出现异常告警,也会自动阻断下游任务,这样会大大降低对下游用户的影响。

三、vivo 基础数据架构总结展望

3.1 架构实践总结

基础数据架构应用诸多实践,没有全部详细描述,有关业务痛点,用户诉求,研发幸福感经过长期的建设,也取得了一些进步。

图片

  1. 基础数据架构,从单链路升级到流批存算分离双活架构,多机房/集群/组件容灾,基础数据链路高可用。

  2. 实时计算,避免重复消费,数据按需分拣,构建低延时的计算架构,满足数百万并发处理请求。

  3. 离线计算,任务化整为零,数据分拆减量,计算降低过程开销,存储换性能,整体性能提升60%。

  4. 数据及时性,整体架构升级改造,数据处理量级从百亿级到数万亿级,SLA及时率稳定保持在99.9%。

  5. 数据完整性,三层级实时对账,多功能数据校验,准确的监控告警,SLA完整性稳定99.9995%。

  6. 值班运维,得益于高可用架构和链路,高性能计算,起夜值班天数从月均20+下降到月均5天以内。

而数据压缩,数据安全,数据易用性,便捷性,在过程中都有涉及,只是没有详细讲述。

3.2 架构迭代规划

打造更敏捷高效,低成本的湖仓一体大数据计算架构。

图片

  • 离线采集
    ,重点解决源端宕机数据丢失问题,因为当前部分数据离线采集,端侧服务器宕机,可能会有数据丢失风险。

  • 离线计算
    ,重点解决Shuffle问题,从ESS切到RSS,实现Shuffle数据的存储和计算分离,解决ESS服务的性能问题。

  • 实时运维
    ,提升异常发现和处理的智能化水平,重点是实时元数据的捕获与归因分析,解决实时运维中定位难,处理时间要求短的问题。

  • 实时计算
    ,将联合相关团队,构建更敏捷高效,低成本的,湖仓一体化大数据计算架构。

代码

原文地址

预备知识:

1.什么是标记索引(token indices)?

标记索引是一种用于表示文本中的单词或符号的
数字编码
。它们可以帮助计算机理解和处理自然语言。例如,假如有一个字典{"我": 1, "是": 2, "Bing": 3, ".": 4},那么文本"我是Bing."的标记索引就是[1, 2, 3, 4]。不同的模型或任务可能使用不同的字典或编码方式,因此标记索引也可能有所不同。

2.什么是交替段落标记索引(alternating segment token indices)?

交替段落标记索引是一种用于区分文档中不同句子的方法。它可以帮助PLM更好地理解文档的结构和语义。具体来说,就是在每个句子的开头和结尾添加一个特殊的标记,例如0或1,表示该句子属于哪个段落。这样,PLM就可以根据这些标记来分析文档中的句子之间的关系。

3.什么是Spacy?

spaCy是一个用于高级自然语言处理的开源软件库,用Python和Cython编程语言编写。

摘要

从句子级到文档级,关系抽取(RE)的研究需要处理更长的文本和更复杂的实体交互。因此,如何有效地编码关键信息源——相关上下文和实体类型,是一个挑战性的问题。然而,
现有的方法在训练RE的过程中只是隐式地学习这些关键信息源的建模
。这导致了它们在监督信号和模型预测的可解释性方面存在不足。为了解决这些问题,本文提出了一种
显式地指导模型捕获相关上下文和实体类型的方法
,即通过
监督和增强中间步骤(Supervising and Augmenting
Intermediate Steps ,SAIS)
进行RE。本文基于一系列精心设计的任务,提出了SAIS方法,它不仅能够由于更有效的监督而提取更高质量的关系,而且能够更准确地检索出与RE实例相关的证据,从而提高模型的可解释性。此外,通过评估模型的不确定性,SAIS还能够通过基于证据的数据增强和集成推理进一步提升性能,同时降低计算成本。最终,SAIS在三个基准数据集(DocRED,CDR和GDA)上达到了RE的最新水平,并在DocRED上的证据检索任务上相对于第二名提高了5.04%的F1分数。

1 Introduction

RE:识别给定文本片段中的实体对之间的关系,将非结构化文本转化为结构化知识。
早期的RE研究主要关注句子级别的关系预测,而
Peng等人(2020)
通过大量的实验发现,这些方法之所以有效,是因为它们能够利用文本中的上下文和实体类型信息。
现有的文档级关系抽取(DocRE)在训练过程中,只是隐式地学习了文本上下文和实体类型的信息,导致了结果的不理想和不可解释。
本文提出了SAIS,来显式地指导模型捕获文本上下文和实体类型信息,从而进行RE。
本文认为,从带有注释实体提及的输入文档到RE的最终输出,推理过程包含四个中间步骤。如图1所示:
(1) 共指消解(Coreference Resolution,CR):识别文档中指代同一实体的不同提及。
句子0提到了“Carl Linnaeus the Younger”的“国籍”,而句子1谈到了“Linnaeus filius”的“父亲”。其实,这两个名字都是指同一个人。因此,对于一个文档,要先用CR来确定同一实体的不同提及在上下文中的角色。
(2) 实体类型(Entity Typing ,ET):标注实体的类型信息。
例如,实体A,“Linnaeus filius”,是“PER”(人)类型。在一个实体对中,头实体和尾实体的类型信息可以帮忙排除不合理的关系。比如,“year_of_birth”关系不可能存在于两个“PER”(人)类型的实体之间。
(3) 汇总证据检索(Pooled Evidence Retrieval,PER):区分有和没有有效支持句的实体对。
(4) 细粒度证据检索(Fine-grained Evidence Retrieval,FER):为每种有效关系提供具体的证据句子。
不是所有的实体对都有有效的关系(例如,示例中的实体D和B)。有些实体对还有多种关系(例如,实体A是实体D的“educated_at”和“employee”),每种关系都有自己的证据。所以,用PER来判断实体对是否有有效的支持句子,用FER来给出每种有效关系的独特和可解释的证据。
为了进一步提高性能,本文还采用了两种基于证据的数据增强方法和集成推理方法。基于证据的数据增强方法包括使用伪文档和注意力掩码来过滤出与关系相关的上下文,集成推理方法则是在模型对原始预测不确定时,结合多个模型的输出来提高置信度。这些方法只需要很少的额外计算代价,就可以显著提升模型的效果。
SAIS在三个RE基准测试(DocRED, CDR, GDA)上都取得了最好的性能,并且在DocRED上的ER F1分数相对于第二名提高了5.04%,说明SAIS不仅提高了关系的准确性,还提高了关系的可解释性。

2 Background

2.1 Problem Formulation

给定一个文档

,它由一组句子


和一组实体

组成。每个实体

都有一个属于

的类型,并且在

中至少出现了一次,用


表示它的所有提及。对于任意一对头实体和尾实体

,DocRE的任务是根据



的某些提及在

中是否表达了某种属于

的关系

,来判断它们之间是否存在这种关系。这里,



分别是预定义的实体类型集合和关系类型集合。另外,对于

和它们之间的任意一种有效关系

,证据检索(ER)的任务是找出

中的一个子集

,它包含了表达

的足够信息。

2.2 Related Work

DocRE的两种方法:
(1)
基于图的方法
:通常将上下文信息融入到基于启发式的文档图中,并通过先进的神经技术进行多跳推理。
(2)
基于Transformer的方法
:利用预训练的语言模型的优势来编码长距离的上下文依赖。
然而,大多数之前的方法都是在训练RE模型的过程中隐式地学习上下文信息。因此,它们面临着监督不充分和模型预测不可解释的问题。
与EIDER相比,SAIS使用了更精确和可解释的FER来检索证据,并提出了两种不同的基于证据的数据增强方法,而且还通过
只对不确定的关系三元组应用集成学习来节省计算成本
。因此,SAIS不仅通过更有效的监督提高了RE的性能,而且还检索了更准确的证据,以提高可解释性。

3 Supervising Intermediate Steps

3.1 Document Encoding

为了利用预训练语言模型(PLM)在各种下游任务中的优势,本文使用PLM对文档进行编码。具体来说,对于一个文档

,在每个句子

的首尾分别添加一个分类器标记“[CLS]”和一个分隔符标记“[SEP]”。还用一对实体标记“*”标注出每个提及

的位置。接着,将文档,以及每个句子的交替段落标记索引(
Liu和Lapata, 2019
),输入到一个PLM中,得到:
其中,

是词嵌入,

是跨词注意力。

是PLM的最后一个Transformer层中所有注意力头的平均值。



中的词数,

是PLM的嵌入维度。把每个提及或句子前的“*”或“[CLS]”对应的嵌入作为该提及或句子的嵌入。

3.2 Coreference Resolution (CR)

作为一个案例研究,
Yao等人(2019)
报告了在DocRED数据集中,有17.6%的关系实例需要进行共指消解。因此,在对文档进行编码后,本文使用CR模块来将重复的上下文提及映射到同一个实体。具体来说,对于一对提及

,通过将它们的嵌入



输入一个分组双线性层来计算它们是否指代同一个实体的概率。该层将嵌入分成

个等大小的组,即

,对

同理,并在每个组内应用双线性变换,参数为

其中



是sigmoid函数。
由于在DocRED数据集中,大多数提及对应不同的实体(每个实体平均只有1.34个提及),本文使用焦点损失(
Lin等人,2017
)来缓解这种极度的类别不平衡,它是在二元交叉熵的基础上增加的:
其中

表示



指代同一个实体,否则为0。类别权重



的频率成反比,

是一个超参数。

3.3 Entity Typing (ET)

在一对实体中,类型信息可以用来过滤掉不可能的关系。为了保证实体嵌入能够反映类型信息,本文通过ET模块来对实体嵌入进行正则化。具体来说,首先利用logsumexp池化来融合实体

的各个提及

的嵌入:
由于实体

可以出现在实体对的头部或尾部,本文分别用两个线性层来生成头部实体嵌入

和尾部实体嵌入

其中



无论实体

出现在实体对的哪个位置,它的头部和尾部嵌入都应该包含

的类型信息。因此,通过将



)输入一个线性层来预测实体

的类型概率:
然后,使用多类交叉熵损失来优化类型预测:
其中



是softmax函数。

表示实体

属于类型

,否则为0。

3.4 Pooled Evidence Retrieval (PER)

为了更好地理解文本中的上下文信息,本文使用 PER 模块来显式地让 PLM 关注每个实体对之间的关联句子。也就是说,目标是找出文档

中对于实体对

有用的证据句子集合

,而不考虑每个句子

所表达的具体关系。在这种情况下,给定一个实体对

,首先根据公式1中的交叉词注意力机制来计算一个独特的上下文嵌入

上式中,

表示元素级的乘法。



对文档中所有词的注意力(即,

的提及级注意力的平均值)。

同理。然后通过将实体对

的上下文嵌入

和句子

的嵌入

送入一个分组双线性层来计算句子

是否属于证据句子集合

的概率:
其中



这里同样遇到了一个严重的类别不平衡的问题,因为大多数实体对(在 DocRED 中占 97.1%)没有有效的关系或证据。因此,类似于第 3.2 节,本文也使用了带有二元交叉熵的焦点损失函数:
其中

,类别权重



的频率成反比,

是一个超参数。

3.5 Fine-grained Evidence Retrieval (FER)

为了进一步提高模型的可解释性,不仅要对实体对进行PER,还要对每个实体对和关系的三元组进行FER,从而恢复它们的特定上下文证据。具体来说,给定一个三元组

,首先将



的关系嵌入

通过一个线性层融合成一个三元组嵌入

其中

表示连接,而

是从 PLM 的嵌入矩阵中获取的。
然后,使用一个分组双线性层来计算一个句子

是否属于三元组的细粒度证据集合

的概率:
其中



由于 FER 只针对有有效关系的实体对,所以这里的类别不平衡问题比 PER 要小一些。因此,定义

,并使用标准的二元交叉熵损失函数:

3.6 Relation Extraction (RE)

根据上述四个互补任务,本文对每个实体对

的相关上下文

进行编码,并保留它们的实体类型信息



。然后,通过两个独立的线性层从

中提取出头实体和尾实体所需的上下文:
其中


接着,将上下文信息和类型信息结合起来,得到头实体和尾实体的表示:
接下来,使用一个分组双线性层来计算头实体

和尾实体

之间是否存在关系

的对数几率:
其中



正如前文所述,只有少数实体对之间有有效的关系,而且一个实体对之间可能有多个关系。因此,为了处理多标签不平衡分类的问题,本文参考
Zhou等人 (2021)
的方法,引入了一个阈值关系类

,并采用了一个自适应阈值损失函数:
这个损失函数的目的是提高有效关系

的对数几率,降低无效关系

的对数几率,两者都相对于

总之,为了提高模型的 RE 性能,本文设计了四个任务来显式地监督模型在各个中间步骤的输出,从而更好地捕捉实体类型信息和文本上下文。在附录 A 中展示了整个流程

,并通过最小化多任务学习目标函数来整合所有的任务:
其中



是用来平衡不同任务权重的超参数。
在使用当前流程

进行推理时,通过比较其对数几率和阈值对数几率 (即

) 来判断一个三元组

是否有效 (即实体对

之间是否有关系

)。对于每个预测的三元组

,通过比较



来判断一个句子

是否属于证据集

,其中

是一个阈值。

4 Augmenting Intermediate Steps

4.1 When to Augment Intermediate Steps


FER 预测的证据是每个三元组

唯一的。但是,所有可能的三元组的总数太多了(在 DocRED 的开发集中约有 4000 万个),如果要用每个三元组的证据来增强推理结果,计算量太大,不现实。
因此,本文采用了选择性预测的思想 (
El-Yaniv等人, 2010
),
从所有的三元组中选出一个子集

,这个子集里的三元组是模型对原始流程

的关系预测最不确定的

具体来说,用

来衡量模型对

的置信度,然后把置信度最低的

的三元组放入不确定集

。对于

中的三元组,
放弃原来的关系预测,而是用基于证据的数据增强来提高性能
(详见第 4.2 节)。
为了确定拒绝率

(注意

不是一个超参数),先按照绝对置信度

对开发集中的所有三元组进行排序。当

增加时,不在

中的三元组的风险(即不准确率)会降低,反之亦然。
本文既想降低关系预测的风险,又想降低拒绝率,这样才能减少数据增强的计算成本。为了平衡这两个目标,选择使

达到最小的

。如图 2 所示,在 DocRED 的开发集中找到

。在实践中,还可以限制每个实体对的拒绝三元组数不超过 10,这样就能把

的大小缩小到 DocRED 开发集中所有三元组的 1.5%。

4.2 How to Augment Intermediate Steps

考虑一个三元组

,它表示实体



之间存在关系

。为了验证这个三元组是否有效,需要根据第 3.5 节的方法,计算给定的句子

是否属于关系

的证据集合

,即计算

的值。基于这个概率,本文提出了两种基于证据的数据增强方法,分别是:
(1)基于伪文档的方法(

):从文本中选取一些满足

的句子,将它们拼接成一个伪文档,然后输入到原始的模型中,得到三元组的置信度

(2)基于注意力掩码的方法(

):根据

的值生成一个注意力掩码

,用它来调整上下文嵌入


,其中

是文本的隐藏状态,



是实体的注意力权重。保持模型的其他部分不变,得到三元组的置信度

为了综合这两种方法的优势,本文参考了Eider的工作,使用一个混合参数

对每个关系





和原始置信度

进行加权平均,得到:
通过最小化开发集上的

的二元交叉熵损失来训练混合参数:
其中

表示三元组

是有效的,否则为 0。在测试阶段,对于每个

,只有当它的混合置信度

大于 0 时,才认为它是有效的三元组。
这种方法可以在模型对原始预测不确定时利用证据信息提高关系抽取的性能,在模型对原始预测有信心时节省计算成本。在附录 B 中给出了基于证据的数据增强和集成推理的总体步骤


这些步骤只在

的训练之后执行,因此对计算成本的影响可以忽略不计。