基于中文简历的命名实体识别项目(持续更新中)

命名实体识别是一个极具挑战性的任务。首先,在大多数语言和领域中,训练数据非常少;其次,对可以成为实体的单词类型的限制很小,因此很难从小样本数据集训练出一个泛化性强的模型。本项目尝试了多种模型算法(如HMM, CRF,Bi-LSTM + CRF)来处理中文命名实体识别的问题。数据集来自 Chinese NER using Lattice LSTM(ACL 2018)中收集到的简历数据集。

数据

总共的数据集有 4775(大概5000条数据),按照以下的 8:1:1 分成训练集、验证集和测试集。

数据集分类 数量
train set 3821
dev set 477
test set 477

数据的格式如下,它的每一行由一个字及其对应的标注组成,标注集采用BIOES,句子之间用一个空行隔开。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
美	B-LOC
国	E-LOC
的	O
华	B-PER
莱	I-PER
士	E-PER

我	O
跟	O
他	O
谈	O
笑	O
风	O
生	O 

汉     S

党 B-TITLE
员 E-TITLE

训练数据 词典大小总数 1792,tag 总数28

一份简历信息可以划分成借个中长句子。每一条就是一个训练数据集。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
['高', '勇', ':', '男', ',', '中', '国', '国', '籍', ',', '无', '境', '外', '居', '留', '权', ',']
'高勇:男,中国国籍,无境外居留权,'

['1', '9', '6', '6', '年', '出', '生', ',', '汉', '族', ',', '中', '共', '党', '员', ',', '本', '科', '学', '历', ',', '工', '程', '师', '、', '美', '国', '项', '目', '管', '理', '协', '会', '注', '册', '会', '员', '(', 'P', 'M', 'I', 'M', 'e', 'm', 'b', 'e', 'r', ')', '、', '注', '册', '项', '目', '管', '理', '专', '家', '(', 'P', 'M', 'P', ')', '、', '项', '目', '经', '理', '。']
'1966年出生,汉族,中共党员,本科学历,工程师、美国项目管理协会注册会员(PMIMember)、注册项目管理专家(PMP)、项目经理。'

['2', '0', '0', '7', '年', '1', '0', '月', '至', '今', '任', '人', '和', '投', '资', '董', '事', ';']
'2007年10月至今任人和投资董事;'

['2', '0', '0', '7', '年', '1', '2', '月', '至', '2', '0', '1', '3', '年', '2', '月', '任', '公', '司', '董', '事', '、', '董', '事', '会', '秘', '书', '、', '综', '合', '管', '理', '部', '部', '长', ';']
'2007年12月至2013年2月任公司董事、董事会秘书、综合管理部部长;'

['2', '0', '1', '3', '年', '2', '月', '至', '今', '任', '山', '东', '三', '维', '石', '化', '工', '程', '股', '份', '有', '限', '公', '司', '董', '事', '、', '董', '事', '会', '秘', '书', '、', '副', '总', '经', '理', '。']
'2013年2月至今任山东三维石化工程股份有限公司董事、董事会秘书、副总经理。'

特征向量(对于一条训练数据集而言)

1
[{'w': '高', 'w-1': '<s>', 'w+1': '勇', 'w-1:w': '<s>高', 'w:w+1': '高勇', 'bias': 1}, {'w': '勇', 'w-1': '高', 'w+1': ':', 'w-1:w': '高勇', 'w:w+1': '勇:', 'bias': 1}, {'w': ':', 'w-1': '勇', 'w+1': '男', 'w-1:w': '勇:', 'w:w+1': ':男', 'bias': 1}, {'w': '男', 'w-1': ':', 'w+1': ',', 'w-1:w': ':男', 'w:w+1': '男,', 'bias': 1}, {'w': ',', 'w-1': '男', 'w+1': '中', 'w-1:w': '男,', 'w:w+1': ',中', 'bias': 1}, {'w': '中', 'w-1': ',', 'w+1': '国', 'w-1:w': ',中', 'w:w+1': '中国', 'bias': 1}, {'w': '国', 'w-1': '中', 'w+1': '国', 'w-1:w': '中国', 'w:w+1': '国国', 'bias': 1}, {'w': '国', 'w-1': '国', 'w+1': '籍', 'w-1:w': '国国', 'w:w+1': '国籍', 'bias': 1}, {'w': '籍', 'w-1': '国', 'w+1': ',', 'w-1:w': '国籍', 'w:w+1': '籍,', 'bias': 1}, {'w': ',', 'w-1': '籍', 'w+1': '无', 'w-1:w': '籍,', 'w:w+1': ',无', 'bias': 1}, {'w': '无', 'w-1': ',', 'w+1': '境', 'w-1:w': ',无', 'w:w+1': '无境', 'bias': 1}, {'w': '境', 'w-1': '无', 'w+1': '外', 'w-1:w': '无境', 'w:w+1': '境外', 'bias': 1}, {'w': '外', 'w-1': '境', 'w+1': '居', 'w-1:w': '境外', 'w:w+1': '外居', 'bias': 1}, {'w': '居', 'w-1': '外', 'w+1': '留', 'w-1:w': '外居', 'w:w+1': '居留', 'bias': 1}, {'w': '留', 'w-1': '居', 'w+1': '权', 'w-1:w': '居留', 'w:w+1': '留权', 'bias': 1}, {'w': '权', 'w-1': '留', 'w+1': ',', 'w-1:w': '留权', 'w:w+1': '权,', 'bias': 1}, {'w': ',', 'w-1': '权', 'w+1': '</s>', 'w-1:w': '权,', 'w:w+1': ',</s>', 'bias': 1}]

一元语法(unigram)、二元语法(bigram)、三元语法(trigram)和多元词法(n-gram),指的是文本中连续出现的n 个词语。对于英文来说 gram 越大,那么上下文信息越是明显,但是训练的时间成本相对变大;对于中文来说,使用unigram 就可以(左右各一个)

BFGS 算法是一种拟牛顿算法。

为什么使用BiLSTM,有人认为这种倒叙的是没有意义的。但是在实际工程中,BilSTM 的效果十有八九是好于LSTM,所以一般时候都是这样使用的。双向LSTM 的结构也很简单,就是两个单向LSTM 分别沿着正序和反序进行传播,然后将最后输出拼接在一起。注意该层输出的size 是 2 * hidden_size

代码

(1)数据预处理阶段:

输入是一个list,返回一个dictionary,其中key 是list 内容,val 是相应的index,所以完成了 char2index 的功能。实现比较巧妙 maps[e] =len(maps)

1
2
3
4
5
6
7
8
def build_map(lists):
    maps = {}
    for list_ in lists:
        for e in list_:
            if e not in maps:
                maps[e] = len(maps)

    return maps

只有在训练数据集上需要建立词典索引和tag 索引,在验证集和测试集上是不需要建立索引的。

(2)HMM 模型

train 阶段

HMM 的训练就是根据训练语料对模型参数进行估计:我们有观测序列和对应的状态序列,然后使用极大似然的方法估计hmm 模型的参数。无论是转移概率矩阵还是观测概率矩阵,都是做的一个统计的工作。以观测矩阵为例

1
2
3
4
5
6
7
8
9
        # 估计观测概率矩阵
        for tag_list, word_list in zip(tag_lists, word_lists):
            assert len(tag_list) == len(word_list)
            for tag, word in zip(tag_list, word_list):
                tag_id = tag2id[tag]
                word_id = word2id[word]
                self.B[tag_id][word_id] += 1
        self.B[self.B == 0.] = 1e-10
        self.B = self.B / self.B.sum(dim=1, keepdim=True)

问题:当某个元素没有出现过的时候,该位置为0。解决方案是,将0替换成很小的数字,比如说 $e ^{-10}$。

decoding阶段

使用维特比算法,给定观测序列求解状态序列,这个是就是生成标注的过程。维特比算法实际上使用动态规划解hmm模型预测问题,基于dp 求解概率最大路径。

维特比算法(viterbi algorithm)使用的是动态规划的思想解决HMM 和CRF 中的预测问题。即在HMM模型中寻找最有可能的隐状态的路径。使用一句话概括为:在每一时刻,计算当前时刻是由上一个时刻中的每种隐状态导致的最大概率,并且记录这个最大概率是从前一时刻哪个隐状态转移过来的,最后回溯最大概率,即为路径。

DP 递推方程

\begin{equation} \delta_{t}(j)=\max \left[\delta_{t-1}(i) \times a_{i j} \times b_{j}\left(o_{t}\right)\right] \end{equation}

  • $\delta_{t}(j)$ : $t$ 时刻沿着一条状态路径 $q_1$, $q_2$, $q_t$, 当 $t$ 时刻处于$j$状态,产生$o_1$, $o_2$,… $o_t$的最大的概率
  • $a_{ij}$ :从状态$i$ 到状态 $j$ 的转移概率
  • $b_j(o_t)$ :从状态 $j$ 到输出$o_t$ 的概率

时间复杂度 $O(TN^2)$,其中$T$ 表示时刻, $N$表示有多少种隐状态, $N^2$表示隐状态的组合。

问题1: 当$T$很长的时候,连乘容易下溢。解决方案:使用对数概率,这样相乘变成了相加。 问题2:如果某个词不再字典中,那么假设其为均匀分布。 问题3:最优路径的计算。正向传播的时候,记录当前时刻最大概率是由上一时刻哪个隐状态转换过来的,然后回溯求解最大值。

(3)CRF 模型

对于中文的特征函数写的比较简单。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def word2features(sent, i):
    """抽取单个字的特征"""
    word = sent[i]
    prev_word = "<s>" if i == 0 else sent[i-1]
    next_word = "</s>" if i == (len(sent)-1) else sent[i+1]
    # 使用的特征:
    # 前一个词,当前词,后一个词,
    # 前一个词+当前词, 当前词+后一个词
    features = {
        'w': word,
        'w-1': prev_word,
        'w+1': next_word,
        'w-1:w': prev_word+word,
        'w:w+1': word+next_word,
        'bias': 1
    }
    return features

(4)Bi-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
class BiLSTM(nn.Module):
   def __init__(self, vocab_size, emb_size, hidden_size, out_size):
       """初始化参数:
           vocab_size:字典的大小
           emb_size:词向量的维数
           hidden_size:隐向量的维数
           out_size:标注的种类
       """
       super(BiLSTM, self).__init__()
       self.embedding = nn.Embedding(vocab_size, emb_size)
       self.bilstm = nn.LSTM(emb_size, hidden_size,
                             batch_first=True,
                             bidirectional=True)

       self.lin = nn.Linear(2*hidden_size, out_size)

   def forward(self, sents_tensor, lengths):
       emb = self.embedding(sents_tensor)  # [B, L, emb_size] # 分词之后是经过一个embedding,而不是one -hot

       packed = pack_padded_sequence(emb, lengths, batch_first=True) 
       # 这个操作有点怪呀, 在进入bilstm之前是需要把padding pack,然后出了lstm 之后需要把packed 
       rnn_out, _ = self.bilstm(packed) # embedding 给padding
       # rnn_out:[B, L, hidden_size*2] 
      # 这种操作可能的原因是减少 lstm 中的参数量吧
       rnn_out, _ = pad_packed_sequence(rnn_out, batch_first=True)
       scores = self.lin(rnn_out)  # [B, L, out_size]
       return scores

   def test(self, sents_tensor, lengths, _):
       """第三个参数不会用到,加它是为了与BiLSTM_CRF保持同样的接口"""
       logits = self.forward(sents_tensor, lengths)  # [B, L, out_size]
       _, batch_tagids = torch.max(logits, dim=2)
       return batch_tagids

文本的数据预处理无非是将文本转换成模型可以理解的数字,在训练lstm 的时候需要加上四个特殊的token:

  • < PAD>: 补全字符。
  • < EOS>: 解码器端的句子结束标识符。
  • < UNK>: 低频词或者一些未遇到过的词等。
  • < GO>: 解码器端的句子起始标识符。

这种基于 len() 的实现实在是太骚了

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# LSTM模型训练的时候需要在word2id和tag2id加入PAD和UNK
# 如果是加了CRF的lstm还要加入<start>和<end> (解码的时候需要用到)
def extend_maps(word2id, tag2id, for_crf=True):
    word2id['<unk>'] = len(word2id)
    word2id['<pad>'] = len(word2id)
    tag2id['<unk>'] = len(tag2id)
    tag2id['<pad>'] = len(tag2id)
    # 如果是加了CRF的bilstm  那么还要加入<start> 和 <end>token
    if for_crf:
        word2id['<start>'] = len(word2id)
        word2id['<end>'] = len(word2id)
        tag2id['<start>'] = len(tag2id)
        tag2id['<end>'] = len(tag2id)

    return word2id, tag2id

如果一个类中只有函数的(如 utils.py),那么使用 from utils import fun1 就可以;如果某个类中包含类crf.py, 在引用的时候,使用 from crf import CRF的语句。

(5)Bi-LSTM +CRF模型

为了使转移分数矩阵更具鲁棒性,我们加上START 和 END两类标签。START代表一个句子的开始(不是句子的第一个单词),END代表一个句子的结束。对于这种写法是非常有条理的:train,evaluation 和test 数据集是需要经过不同的处理,然后可以写一个函数,引入不同的参数,表示不同的处理。

1
2
3
4
5
6
7
8
def prepocess_data_for_lstmcrf(word_lists, tag_lists, test=False):
    assert len(word_lists) == len(tag_lists)
    for i in range(len(word_lists)):
        word_lists[i].append("<end>") # 对于训练数据集
        if not test:  # 如果是测试数据,就不需要加end token了
            tag_lists[i].append("<end>")

    return word_lists, tag_lists

如果是 bi-lstm 模型,因为相当于是一个语言模型, 通过softmax 可以获得输出序列的概率 $p(y| X)$,然后取对数,这个时候的损失函数就定义为 $- \log p(y|X)$,使用最大似然估计求解。到了 bi-lstm + CRF中是可以使用各种损失函数的。

LSTM without CRF 的损失函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
def cal_loss(logits, targets, tag2id):
    """计算损失
    参数:
        logits: [B, L, out_size]
        targets: [B, L]
        lengths: [B]
    """
    PAD = tag2id.get('<pad>')
    assert PAD is not None

    mask = (targets != PAD)  # [B, L]
    targets = targets[mask]
    out_size = logits.size(2)
    logits = logits.masked_select(
        mask.unsqueeze(2).expand(-1, -1, out_size)
    ).contiguous().view(-1, out_size)

    assert logits.size(0) == targets.size(0)
    loss = F.cross_entropy(logits, targets)

    return loss

LSTM with CRF的损失函数。相比于上一个损失函数,该损失函数增加了对于生成label之间的约束。具体如下:

输入数据 $X$ 表示如下:

\begin{equation} \mathbf{X}=\left(\mathbf{x} _{1}, \mathbf{x} _{2}, \dots, \mathbf{x} _{n}\right) \end{equation}

\begin{equation} \mathbf{y}=\left(y_{1}, y_{2}, \ldots, y_{n}\right) \end{equation}

we consider $P$ to be the matrix of scores output by the bidirectional LSTM network. P is of size $n × k$, where k is the number of distinct tags, and $P_{i,j} corresponds to the score of the $j$ th tag of the $i$th word in a sentence. For a sequence of predictions

其中分数 score 定义为: \begin{equation} s(\mathbf{X}, \mathbf{y})=\sum_{i=0}^{n} A_{y_{i}, y_{i+1}}+\sum_{i=1}^{n} P_{i, y_{i}} \end{equation}

$A$ 是转移矩阵,$A_{i, j}$表示从tags $i$ 到 tags$j$的转移。

对结果使用softmax 可以得到:

\begin{equation} p(\mathbf{y} | \mathbf{X})=\frac{e^{s(\mathbf{X}, \mathbf{y})}}{\sum_{\widetilde{\mathbf{y}} \in \mathbf{Y}_{\mathbf{X}}} e^{s(\mathbf{X}, \widetilde{\mathbf{y}})}} \end{equation}

在训练过程中,经常将结果取对数。最终的tag 用 $y^*$表示为:

\begin{equation} \mathbf{y}^{*}=\underset{\tilde{\mathbf{y}} \in \mathbf{Y}_{\mathbf{X}}}{\operatorname{argmax}} s(\mathbf{X}, \widetilde{\mathbf{y}}) \end{equation}

{% fold 开/合 %}

 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
def cal_lstm_crf_loss(crf_scores, targets, tag2id):
    """计算双向LSTM-CRF模型的损失
    该损失函数的计算可以参考:https://arxiv.org/pdf/1603.01360.pdf
    """
    pad_id = tag2id.get('<pad>')
    start_id = tag2id.get('<start>')
    end_id = tag2id.get('<end>')

    device = crf_scores.device

    # targets:[B, L] crf_scores:[B, L, T, T]
    batch_size, max_len = targets.size()
    target_size = len(tag2id)

    # mask = 1 - ((targets == pad_id) + (targets == end_id))  # [B, L]
    mask = (targets != pad_id)
    lengths = mask.sum(dim=1)
    targets = indexed(targets, target_size, start_id)

    # # 计算Golden scores方法1
    # import pdb
    # pdb.set_trace()
    targets = targets.masked_select(mask)  # [real_L]

    flatten_scores = crf_scores.masked_select(
        mask.view(batch_size, max_len, 1, 1).expand_as(crf_scores)
    ).view(-1, target_size*target_size).contiguous()

    golden_scores = flatten_scores.gather(
        dim=1, index=targets.unsqueeze(1)).sum()

    # 计算golden_scores方法2:利用pack_padded_sequence函数
    # targets[targets == end_id] = pad_id
    # scores_at_targets = torch.gather(
    #     crf_scores.view(batch_size, max_len, -1), 2, targets.unsqueeze(2)).squeeze(2)
    # scores_at_targets, _ = pack_padded_sequence(
    #     scores_at_targets, lengths-1, batch_first=True
    # )
    # golden_scores = scores_at_targets.sum()

    # 计算all path scores
    # scores_upto_t[i, j]表示第i个句子的第t个词被标注为j标记的所有t时刻事前的所有子路径的分数之和
    scores_upto_t = torch.zeros(batch_size, target_size).to(device) # 凡是数据类型是 tensor 的,都是可以在后面加上 to(device) 的
    for t in range(max_len):
        # 当前时刻 有效的batch_size(因为有些序列比较短)
        batch_size_t = (lengths > t).sum().item()
        if t == 0:
            scores_upto_t[:batch_size_t] = crf_scores[:batch_size_t,
                                                      t, start_id, :]
        else:
            # We add scores at current timestep to scores accumulated up to previous
            # timestep, and log-sum-exp Remember, the cur_tag of the previous
            # timestep is the prev_tag of this timestep
            # So, broadcast prev. timestep's cur_tag scores
            # along cur. timestep's cur_tag dimension
            scores_upto_t[:batch_size_t] = torch.logsumexp(
                crf_scores[:batch_size_t, t, :, :] +
                scores_upto_t[:batch_size_t].unsqueeze(2),
                dim=1
            )
    all_path_scores = scores_upto_t[:, end_id].sum()

    # 训练大约两个epoch loss变成负数,从数学的角度上来说,loss = -logP
    loss = (all_path_scores - golden_scores) / batch_size
    return loss

{% endfold %}

如果数据集比较小,那么是可以通过切分的方式获得一个 batch size 的数据的。其中的预先排序感觉有点意思,这样保证一个batch 中长度基本上是保持一致的。

 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
    def train(self, word_lists, tag_lists,
              dev_word_lists, dev_tag_lists,
              word2id, tag2id):
        # 对数据集按照长度进行排序
        word_lists, tag_lists, _ = sort_by_lengths(word_lists, tag_lists)
        dev_word_lists, dev_tag_lists, _ = sort_by_lengths(
            dev_word_lists, dev_tag_lists)

        B = self.batch_size
        for e in range(1, self.epoches+1):
            self.step = 0
            losses = 0.
            for ind in range(0, len(word_lists), B): # 非常直接得到了一个batch 的数据集
                batch_sents = word_lists[ind:ind+B]
                batch_tags = tag_lists[ind:ind+B]
                losses += self.train_step(batch_sents,
                                          batch_tags, word2id, tag2id)
                if self.step % TrainingConfig.print_step == 0:
                    total_step = (len(word_lists) // B + 1)
                    print("Epoch {}, step/total_step: {}/{} {:.2f}% Loss:{:.4f}".format(
                        e, self.step, total_step,
                        100. * self.step / total_step,
                        losses / self.print_step
                    ))
                    losses = 0.
            # 每轮结束测试在验证集上的性能,保存最好的一个
            val_loss = self.validate(
                dev_word_lists, dev_tag_lists, word2id, tag2id)
            print("Epoch {}, Val Loss:{:.4f}".format(e, val_loss))

所以LSTM-CRF最大的特点就是:由LSTM提供特征,而且特征是有参数的,是可以学习的!因此它可能根据不同问题学到各种合适的底层特征;而CRF的特征是人工定义出来的,不可变的,我们最多改改这个特征的参数。

(6) ensemble多个模型

这个ensemble机制也是比较简单,就是众人投票机制。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def ensemble_evaluate(results, targets, remove_O=False):
    """ensemble多个模型"""
    for i in range(len(results)):
        results[i] = flatten_lists(results[i])

    pred_tags = []
    for result in zip(*results):
        ensemble_tag = Counter(result).most_common(1)[0][0]
        pred_tags.append(ensemble_tag)

    targets = flatten_lists(targets)
    assert len(pred_tags) == len(targets)

    print("Ensemble 四个模型的结果如下:")
    metrics = Metrics(targets, pred_tags, remove_O=remove_O)
    metrics.report_scores()
    metrics.report_confusion_matrix()

其他函数。

下面有两个函数,第一个函数是能够递归的处理多层(三层及以上)的嵌套,第二个函数只能处理两层的嵌套。本质是需要采取递归的思路。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
t=[1,2,3,[3,4,5,[5,4,3],5],1,2,[4,5],7,4,[6,34]]
res =[]
def flatten_list(list1):
    for item in list1:
        if type(item) ==list:
            flatten_list(item)
        else:
            res.append(item)

flatten_list(t)
print(res)
            
def flatten_lists(lists):
    flatten_list = []
    for l in lists:
        if type(l) == list:
            flatten_list += l
        else:
            flatten_list.append(l)
    return flatten_list
print(flatten_lists(t))

(7)评估模型

主要是针对precision, recall 和F1 的实现问题。按照行输出混淆矩阵。

{% fold 开/合 %}

 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
82
83
84
85
86
87
88
89
90
91
92
93
94
class Metrics(object):
    
    def __init__(self, golden_tags, predict_tags, remove_0 =False):
        
        self.golden_tags = flattent_list(golen_tags)
        self.predict_tags =flattent_list(predict_tags)
        
        if remove_0:
            self._remove_0tags()
        
        # 辅助性的计算变量
        self.tagset = set(self.golden_tags)
        # true positive 
        self.correct_tags_number = self.count_correct_tags()
        
        self.predict_tags_counter =Counter(self.predict_tags)
        self.golden_tags_counter =Counter(self.golden_tags)
        self.precision_scores =self.cal_precision()
        self.recall_scores =self.cal_recall()
        
        self.f1_scores =self.cal_f1()
        
    def cal_precision(self):
        
        precision_scores ={}
        
        for tag in self.tagset:
            precision_scores[tag] =self.correct_tags_number.get(tag, 0) /self.predict_tags_counter[tag]
        return precision_scores
    
    def cal_recall(self):
        
        recall_scores ={}
        for tag in self.tagset:
            recall_scores[tag] =self.correct_tags_number.get(tag, 0) /self.golden_tags_counter[tag]
        return recall_scores
        
    def cal_f1(self):
        f1_scores ={}
        
        for tag in self.tagset:
            p, r =self.precision_scores[tag], self.recall_scores[tag]
            f1_scores[tag] =2*p*r /(p+r +1e-10)
        return f1_scores
    
    
    
    def report_confusion_matrix(self):
        
        print("Confusion Matrix:")
        
        tag_list =list(self.tagset)
        tags_size =len(tag_list)
        
        matrix=[] # 最后的结果就是一个二维矩阵
        for i in range(tags_size):
            matrix.append([0] * tags_size)
        
        for golden_tag, predict_tag in zip(self.golden_tags, self.predict_tags):
            
            try:
                row =tag_list.index(golden_tag)
                col =tag_list.index(predict_tag)
                
                matrix[row][col] +=1
            except ValueError: # 极小的概率发生: 出现在gold tag 中,但是没有出现在predic tag中
                continue 
        
        row_format_ ='{:>7} '*(tags_size+1)
        print(row_format_("", * tags_list))
        # 写得比较好的是以行为单位进行输出,这样至少看起来是非常美观的
        for i , row in enumerate(matrix):
            print(row_format_.format(tags_list[i], *row))
            
    
    def _cal_weighted_average(self):
        weighted_average ={}
        total =len(self.golden_tags)
        
        weighted_average['precision'] =0.
        weighted_average['recall'] =0.
        weighted_average['f1'] =0.
        
        for tag in self.tagset:
            size =self.golden_tags_counter[tag]
            weighted_average['precision' ] += self.precision_scores[tag] * size
            weighted_average['recall'] += self.recall_scores[tag] *size
            weighted_average['f1'] +=self.f1_score[tag] *size
        
        # 这个加权平均和中的权重是 size长度
        for metric in weighted_average.keys():
            weighted_average[metric] /= total
        
        return weighted_average

{% endfold %}

(8)常见的参数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class TrainingConfig(object):
    batch_size = 64
    # 学习速率
    lr = 0.001
    #epoches = 30
    epoches = 5
    print_step = 5


class LSTMConfig(object):
    emb_size = 128  # 词向量的维数
    hidden_size = 128  # lstm隐向量的维数
    
# 多说一句,最后out size 的维度是 len(tags), 因为模型是为了给定一个字,然后predict 其 tag,所以这个是没有问题的; 实际上问的embedding size ,那么是想要考虑 word embedding的大小, 是 128
out_size =

使用DropOut 提高了最后的结果

Initial experiments showed that character-level embeddings did not improve our overall performance when used in conjunction with pre-trained word representations. To encourage the model to depend on both representations, we use dropout training, applying a dropout mask to the final embedding layer just before the input to the bi-directional LSTM. We observe a significant improvement in our model’s performance after using dropout.

(8)手写viterbi 算法

{% fold 开/合 %}

  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
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
import torch
class HMM(object):
    def __init__(self, N, M):
        """Args:
            N: 状态数,这里对应存在的标注的种类
            M: 观测数,这里对应有多少不同的字
        """
        self.N = N
        self.M = M

        # 状态转移概率矩阵 A[i][j]表示从i状态转移到j状态的概率
        self.A = torch.zeros(N, N)
        # 观测概率矩阵, B[i][j]表示i状态下生成j观测的概率
        self.B = torch.zeros(N, M)
        # 初始状态概率  Pi[i]表示初始时刻为状态i的概率
        self.Pi = torch.zeros(N)

    def train(self, word_lists, tag_lists, word2id, tag2id):
        """HMM的训练,即根据训练语料对模型参数进行估计,
           因为我们有观测序列以及其对应的状态序列,所以我们
           可以使用极大似然估计的方法来估计隐马尔可夫模型的参数
        参数:
            word_lists: 列表,其中每个元素由字组成的列表,如 ['担','任','科','员']
            tag_lists: 列表,其中每个元素是由对应的标注组成的列表,如 ['O','O','B-TITLE', 'E-TITLE']
            word2id: 将字映射为ID
            tag2id: 字典,将标注映射为ID
        """

        assert len(tag_lists) == len(word_lists)

        # 估计转移概率矩阵
        for tag_list in tag_lists:
            seq_len = len(tag_list)
            for i in range(seq_len - 1):
                current_tagid = tag2id[tag_list[i]]
                next_tagid = tag2id[tag_list[i+1]]
                self.A[current_tagid][next_tagid] += 1
        # 问题:如果某元素没有出现过,该位置为0,这在后续的计算中是不允许的
        # 解决方法:我们将等于0的概率加上很小的数
        self.A[self.A == 0.] = 1e-10
        self.A = self.A / self.A.sum(dim=1, keepdim=True)

        # 估计观测概率矩阵
        for tag_list, word_list in zip(tag_lists, word_lists):
            assert len(tag_list) == len(word_list)
            for tag, word in zip(tag_list, word_list):
                tag_id = tag2id[tag]
                word_id = word2id[word]
                self.B[tag_id][word_id] += 1
        self.B[self.B == 0.] = 1e-10
        self.B = self.B / self.B.sum(dim=1, keepdim=True)

        # 估计初始状态概率
        for tag_list in tag_lists:
            init_tagid = tag2id[tag_list[0]]
            self.Pi[init_tagid] += 1
        self.Pi[self.Pi == 0.] = 1e-10
        self.Pi = self.Pi / self.Pi.sum()

    def test(self, word_lists, word2id, tag2id):
        pred_tag_lists = []
        for word_list in word_lists:
            pred_tag_list = self.decoding(word_list, word2id, tag2id)
            pred_tag_lists.append(pred_tag_list)
        return pred_tag_lists

    def decoding(self, word_list, word2id, tag2id):
        """
        使用维特比算法对给定观测序列求状态序列, 这里就是对字组成的序列,求其对应的标注。
        维特比算法实际是用动态规划解隐马尔可夫模型预测问题,即用动态规划求概率最大路径(最优路径)
        这时一条路径对应着一个状态序列
        """
        # 问题:整条链很长的情况下,十分多的小概率相乘,最后可能造成下溢
        # 解决办法:采用对数概率,这样源空间中的很小概率,就被映射到对数空间的大的负数
        #  同时相乘操作也变成简单的相加操作
        A = torch.log(self.A)
        B = torch.log(self.B)
        Pi = torch.log(self.Pi)

        # 初始化 维比特矩阵viterbi 它的维度为[状态数, 序列长度]
        # 其中viterbi[i, j]表示标注序列的第j个标注为i的所有单个序列(i_1, i_2, ..i_j)出现的概率最大值
        seq_len = len(word_list)
        viterbi = torch.zeros(self.N, seq_len)
        # backpointer是跟viterbi一样大小的矩阵
        # backpointer[i, j]存储的是 标注序列的第j个标注为i时,第j-1个标注的id
        # 等解码的时候,我们用backpointer进行回溯,以求出最优路径
        backpointer = torch.zeros(self.N, seq_len).long()

        # self.Pi[i] 表示第一个字的标记为i的概率
        # Bt[word_id]表示字为word_id的时候,对应各个标记的概率
        # self.A.t()[tag_id]表示各个状态转移到tag_id对应的概率

        # 所以第一步为
        start_wordid = word2id.get(word_list[0], None)
        Bt = B.t()
        if start_wordid is None:
            # 如果字不再字典里,则假设状态的概率分布是均匀的
            bt = torch.log(torch.ones(self.N) / self.N)
        else:
            bt = Bt[start_wordid]
        viterbi[:, 0] = Pi + bt
        backpointer[:, 0] = -1

        # 递推公式:
        # viterbi[tag_id, step] = max(viterbi[:, step-1]* self.A.t()[tag_id] * Bt[word])
        # 其中word是step时刻对应的字
        # 由上述递推公式求后续各步
        for step in range(1, seq_len):
            wordid = word2id.get(word_list[step], None)
            # 处理字不在字典中的情况
            # bt是在t时刻字为wordid时,状态的概率分布
            if wordid is None:
                # 如果字不再字典里,则假设状态的概率分布是均匀的
                bt = torch.log(torch.ones(self.N) / self.N)
            else:
                bt = Bt[wordid]  # 否则从观测概率矩阵中取bt
            for tag_id in range(len(tag2id)):
                max_prob, max_id = torch.max(
                    viterbi[:, step-1] + A[:, tag_id],
                    dim=0
                )
                viterbi[tag_id, step] = max_prob + bt[tag_id]
                backpointer[tag_id, step] = max_id

        # 终止, t=seq_len 即 viterbi[:, seq_len]中的最大概率,就是最优路径的概率
        best_path_prob, best_path_pointer = torch.max(
            viterbi[:, seq_len-1], dim=0
        )

        # 回溯,求最优路径
        best_path_pointer = best_path_pointer.item()
        best_path = [best_path_pointer]
        for back_step in range(seq_len-1, 0, -1):
            best_path_pointer = backpointer[best_path_pointer, back_step]
            best_path_pointer = best_path_pointer.item()
            best_path.append(best_path_pointer)

        # 将tag_id组成的序列转化为tag
        assert len(best_path) == len(word_list)
        id2tag = dict((id_, tag) for tag, id_ in tag2id.items())
        tag_list = [id2tag[id_] for id_ in reversed(best_path)]

        return tag_list

{% endfold %}

实验结果

下面结果是不同类别加权平均和得来的。

hmm crf bi-lstm bilstm+crf ensemble
precision 91.49% 95.43% 95.37% 95.74% 95.69%
recall 91.22% 95.43% 95.32% 95.72% 95.65%
F1 91.30% 95.42% 95.32% 95.70% 95.64%

可以看出bilstm+crf 得到了最好的结果 $F1=95.70%$,当使用 ensemble时候,并不见得能够得到更好的结果,尤其是当子模型有较大的重合度且模型效果不是很好的时候。

数据特点:

(1)IOBES标注方式更加细化,相应的label 预测要求更加高。 作者提到了两种tagging scheme,一种是IOB标注形式的,这种形式标注集为{B、I、O},B表示命名实体的开头词,I表示命名实体非开始的词,O表示非命名实体词。另一个中是IOBES标注形式,标注集合为{B、I、E、O、S},添加的E表示命名实体结尾词,S表示单个词的命名实体。

优化点:

(1)CRF 模型中自定义更加丰富的特征函数,相对于HMM而言,能够表示上下文。 (2)损失函数是交叉熵。ilstm+crf 中参考的是16年一篇论文中计算$y_{pred}$,但是那篇论文中的数据集是针对外文的(英文,德文)等,主要思想是加上了 $A_{i,j}$ 转换约束 CRF: 通过上述的双向LSTM获得整个句子的表示后,一个简单有效的标记方法就是独立地为每个单词输出标签。尽管这种方法在简单任务如POS上很成功,但是它在输出标签有强相关性时有很大的局限性。NER就是这样,输出标签是有强相关性的,比如I-PER后不能接B-LOC。因此,使用条件随机场CRF来解决这个问题。

展望:

(1)基于少量的标注数据进行NER 也是研究的热点。一种常见的思路是使用海量无标注数据训练一个语言模型,然后使用这个训练好的语言模型获取当前要标注词的语言模型向量,然后将这些向量加入到原始的双向LSTM-RNN 的模型中。实验结果表明,加入语言模型向量可以提高NER 的效果。