CNN VS LSTM 选择及优化

1. CNN 还是 LSTM ?

CNN 和 LSTM是深度学习中使用最多,最为经典的两个模型,在NLP中,他们都可以用于提取文本的上下文特征,实际任务中该如何选择呢? 实际中我们主要考虑三个因素:

  • 上下文的依赖长度:
    CNN 提取的是类似于ngram的局部的上下文特征,LSTM由于三个门限单元的加入使得它能够处理提取更长的上下文特征

  • 位置特征:
    CNN由于max_pooling, 移除了文本序列中的相对位置信息,LSTM的序列结构,使得lSTM天然保留了位置信息

  • 效率:
    CNN 没有序列上的前后依赖问题,可以高效的并行运算,而lstm由于存在序列上的传递依赖问题,效率上相比CNN要低

综合上面三个因素的比较,CNN比较适合上下文依赖较短且对相对位置信息不敏感的场景,如情感分析,情感分析里的一些难点如双重否定,在上下文中的距离都离得不远,LSTM适合需要长距离特征,且依赖相对位置信息的场景

2. 实际使用中如何优化LSTM的运行速度?

对于lstm来讲,它的运行速度的瓶颈在于序列上的处理,如果输入序列太长,会非常影响lstm的运行效率,但实际上,虽然lstm相比于RNN能处理更长的上下文特征,但也是相对的,超过一定的距离,lstm的效果也很有限,而且实际的nlp任务中,所需要的上下文的长度一般都不长。所以对于lstm来说,可行的效率优化方法是,确定一个适合的序列处理的长度,去把长文本按此长度切开,把这些切开的片段批量输入lstm模型,避免将整篇长文本当成一个序列输入lstm模型。

序列标注进化史

HMM -> MEMM -> CRF -> RNN -> LSTM -> Attention

序列标注算法主体上经历了从隐马尔可夫,最大熵马尔可夫, 条件随机场, RNN, LSTM 到 transformer的一个演进过程,这是一个递进发展的过程。

HMM

  • 两个假设
    状态独立假设: 当前状态只依赖于上一个状态
    观测独立假设: 当前观测变量只依赖于对应的状态变量

  • 两个问题
    标签偏置问题: 即算法倾向于选择分支较少的状态,这是由于状态独立假设使得在计算标签转移概率时在局部归一化状态转移概率
    特征缺失: 由于观测独立假设,HMM无法将上下文信息融入,所以会有较大的局限性

MEMM

MEMM 取消了HMM的观测独立假设,它是判别模型的一种,引入了特征,可以方便的把上下文信息设计到特征中,解决了上下文信息缺失的问题,但MEMM没有取消HMM的状态独立假设,所以依然存在标签偏置的问题

CRF

CRF 在MEMM的基础上进一步取消了HMM的状态独立假设,将标签转移也作为全局特征之一,在全局进行优化,所以crf解决了标签偏置以及上下文信息缺失的问题

RNN

RNN是经典的深度学习序列模型,相比于CRF, RNN在词向量的基础上做特征抽象,比CRF的特征模板的方式能够更好的避免过拟合,此外,RNN在序列上下文特征上相比crf会更加有效

LSTM

LSTM是RNN的改进模型之一, LSTM加入了门控单元,使得门控单元的阈值可以随上下文以及当前输入动态改变,从而改善RNN由于梯度传播过程中参数连乘造成的梯度消失的问题,能够处理更长的上下文依赖

Attention

attention 使用一种更加直接的方式处理当前词与上下文的关系,相比于LSTM, attention避免了梯度在序列间的传到,可以并行的计算,也能够处理更长的上下文依赖。

短文本的几点思考

1. 为什么lda在短文本上效果不好?

LDA是生成模型的一种,生成模型的参数分布都是从观测值中统计而来,短文本,也就意味着观测变量少,观测变量少,自然统计出来的概率分布不够准确。

2. 为什么短文本做分类经常效果不好?

在文本分类中,真正起关键作用的往往是那一两个关键词,而短文本中往往缺乏相应的一些关键词,以及足够的上下文信息造成分类效果欠佳

3. 如何提升短文本上的分类效果?

  • 特征扩展:
    使用外部的一些知识扩展特征,比如使用在wiki上训练的LDA模型向量化当前的短文本,然后将此向量加入到文本分类的特征中,也可以使用一些attention机制去扩展外部的一些特征

  • 标签传播
    利用搜索引擎如es,在有标签数据中,找最相近的满足条件的数据,做标签的简单voting,或加入权重做voting。

deepsequence-最完整的深度学习序列标注工具

简介

deepsequence 是我在工作学习过程中写的一个做序列标注的工具,基于keras,支持各种经典的序列标注任务,如分词,NER(命名实体识别), 为什么是最完整的呢?

  • 支持BERT+CRF 和 BILSTM+CRF两种最优秀的序列标注的算法架构
  • 支持字符级别的特征,charCNN 或者 charLSTM
  • 支持postag特征的输入
  • 支持领域知识的输入,如用于ner识别的公司字典
  • 支持BIO, BIOLU, BIOES三种标注格式
  • 支持通过配置文件快速定制模型细节及参数

架构解析

BILSTM + CRF

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
def build_bilstm(self, verbose=True):
"""
build model architecture from parameters
"""
word_ids = Input(batch_shape=(None, None), dtype='int32', name='word_input')
inputs = [word_ids]

if self._params.use_pretrain_embedding:
if verbose: logging.info("initial word embedding with pretrained embeddings")
if self._params.word_embedding_dim == 100:
glove_file = self._params.data_dir + '/glove.6B.100d.txt'
elif self._params.word_embedding_dim == 300:
glove_file = self._params.data_dir + '/glove.42B.300d.txt'
else:
logging.error("we only support glove embedding with dimension 100 or 300")
raise ValueError("unmatch word dimension, we only support glove embedding with dimension 100 or 300")
glove_embedding_index = load_glove(glove_file, self._params.word_embedding_dim)
word_vocab = self.input_processor.word_vocab.vocab
glove_embeddings_matrix = np.zeros([len(word_vocab), self._params.word_embedding_dim])
for word, i in word_vocab.items():
vector = glove_embedding_index.get(word)
if vector is not None:
glove_embeddings_matrix[i] = vector

word_embeddings = Embedding(input_dim=glove_embeddings_matrix.shape[0],
output_dim=glove_embeddings_matrix.shape[1],
trainable=False,
mask_zero=True,
weights=[glove_embeddings_matrix],
name='word_embedding')(word_ids)
else:
word_embeddings = Embedding(input_dim=self._params.word_vocab_size,
output_dim=self._params.word_embedding_dim,
mask_zero=True,
name='word_embedding')(word_ids)

input_embeddings = [word_embeddings]
if self._params.use_char:
char_ids = Input(batch_shape=(None, None, None), dtype='int32', name='char_input')
inputs.append(char_ids)
if self._params.char_feature == "lstm":
char_embeddings = Embedding(input_dim=self._params.char_vocab_size,
output_dim=self._params.char_embedding_dim,
mask_zero=True,
name='char_embedding')(char_ids)
if verbose: logging.info("using charcter level lstm features")
char_feas = TimeDistributed(Bidirectional(LSTM(self._params.char_lstm_size)), name="char_lstm")(char_embeddings)
elif self._params.char_feature == "cnn":
# cnn do not support mask
char_embeddings = Embedding(input_dim=self._params.char_vocab_size,
output_dim=self._params.char_embedding_dim,
name='char_embedding')(char_ids)
if verbose: logging.info("using charcter level cnn features")
char_feas = char_cnn_encode(char_embeddings, self._params.n_gram_filter_sizes, self._params.n_gram_filter_nums)
else:
raise ValueError('char feature must be lstm or cnn')

input_embeddings.append(char_feas)

if self._params.use_pos:
if verbose: logging.info("use pos tag features")
pos_ids = Input(batch_shape=(None, None), dtype='int32', name='pos_input')
inputs.append(pos_ids)


pos_embeddings = Embedding(input_dim=self._params.pos_vocab_size,
output_dim=self._params.pos_embedding_dim,
mask_zero=True,
name='pos_embedding')(pos_ids)
input_embeddings.append(pos_embeddings)

if self._params.use_dict:
if verbose: logging.info("use user dict features")
dict_ids = Input(batch_shape=(None, None), dtype='int32', name='dict_input')
inputs.append(dict_ids)

dict_embeddings = Embedding(input_dim=self._params.dict_vocab_size,
output_dim=self._params.dict_embedding_dim,
mask_zero=True,
name='dict_embedding')(dict_ids)
input_embeddings.append(dict_embeddings)

input_embedding = Concatenate(name="input_embedding")(input_embeddings) if len(input_embeddings)>1 else input_embeddings[0]
input_embedding_ln = LayerNormalization(name='input_layer_normalization')(input_embedding)
#input_embedding_bn = BatchNormalization()(input_embedding_ln)
input_embedding_drop = Dropout(self._params.dropout, name="input_embedding_dropout")(input_embedding_ln)

z = Bidirectional(LSTM(units=self._params.main_lstm_size, return_sequences=True, dropout=0.2, recurrent_dropout=0.2),
name="main_bilstm")(input_embedding_drop)
z = Dense(self._params.fc_dim, activation='tanh', name="fc_dense")(z)

if self._params.use_crf:
if verbose: logging.info('use crf decode layer')
crf = CRF(self._params.num_labels, sparse_target=False,
learn_mode='marginal', test_mode='marginal', name='crf_out')
loss = crf.loss_function
pred = crf(z)
else:
loss = 'categorical_crossentropy'
pred = Dense(self._params.num_labels, activation='softmax', name='softmax_out')(z)

model = Model(inputs=inputs, outputs=pred)
model.summary(print_fn=lambda x: logging.info(x + '\n'))
model.compile(loss=loss, optimizer=self._params.optimizer)

self.model = model

BERT + CRF

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
def build_bert(self, verbose=True):

bert_word_ids = Input(batch_shape=(None, None), dtype="int32", name="bert_word_input")
bert_mask_ids = Input(batch_shape=(None, None), dtype="int32", name='bert_mask_input')
bert_segment_ids = Input(batch_shape=(None, None), dtype="int32", name="bert_segment_input")

inputs = [bert_word_ids, bert_mask_ids, bert_segment_ids]

bert_out = BertLayer(n_fine_tune_layers=self._params.n_fine_tune_layers, bert_path=self._params.bert_path, name="bert_layer")([bert_word_ids, bert_mask_ids, bert_segment_ids])

features = bert_out

if self._params.use_dict:
if verbose: logging.info("use user dict features")
dict_ids = Input(batch_shape=(None, None), dtype='int32', name='dict_input')
inputs.append(dict_ids)

dict_embeddings = Embedding(input_dim=self._params.dict_vocab_size,
output_dim=self._params.dict_embedding_dim,
mask_zero=True,
name='dict_embedding')(dict_ids)

features = Concatenate(name="bert_and_dict_features")([features, dict_embeddings])

z = Dense(self._params.fc_dim, activation='relu', name="fc_dense")(features)

if self._params.use_crf:
if verbose: logging.info('use crf decode layer')
crf = CRF(self._params.num_labels, sparse_target=False,
learn_mode='marginal', test_mode='marginal', name='crf_out')
loss = crf.loss_function
pred = crf(z)
else:
loss = 'categorical_crossentropy'
pred = Dense(self._params.num_labels, activation='softmax', name='softmax_out')(z)

model = Model(inputs=inputs, outputs=pred)
model.summary(print_fn=lambda x: logging.info(x + '\n'))

# It is recommended that you use this optimizer for fine tuning, since this
# is how the model was trained (note that the Adam m/v variables are NOT
# loaded from init_checkpoint.)
optimizer = AdamWeightDecayOptimizer(
learning_rate=1e-5,
weight_decay_rate=0.01,
beta_1=0.9,
beta_2=0.999,
epsilon=1e-6,
exclude_from_weight_decay=["LayerNorm", "layer_norm", "bias"])

model.compile(loss=loss, optimizer=optimizer)

self.model = model

示例

训练NER模型:

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
import argparse
import logging

from deepsequence.dataset import load_conll
from deepsequence.model import SequenceModel
from deepsequence.config import Params
from deepsequence.utils import set_logger


def main(params):

train_file = params.data_dir + '/train.txt'
valid_file = params.data_dir + '/valid.txt'
train_data = load_conll(train_file, params)
valid_data = load_conll(valid_file, params)

model = SequenceModel(params)

model_file = params.data_dir + '/model/model.h5'
if params.continue_previous_training is True:

logging.info("restore model from local")
model.restore(model_file)
else:
logging.info("model initializing...")
model.build(params)

model.fit(train_data, valid_data, verbose=True)
model.evaluate(valid_data)
logging.info("model save to {}".format(model_file))
model.save(model_file)

tf_saved_model_dir = params.data_dir + '/model/tf_saved_model'
model.export_sm(tf_saved_model_dir)


if __name__ == '__main__':

parser = argparse.ArgumentParser(description='''train deep sequence model''')
parser.add_argument('--config', required=True)

set_logger('train.log')

args = parser.parse_args()

logging.info("parse config file path: {}".format(args.config))

params = Params(args.config)
logging.info("parameters: {}".format(params.dict))

try:
main(params)
except Exception as e:
logging.error('run fail', exc_info=True)

配置文件:

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
{
"model": "bilstm",

"vocab_pad": "PAD",
"vocab_unk": "UNK",
"max_sent_len": 1000,
"max_word_len": 24,

"word_embedding_dim": 100,
"use_pretrain_embedding": true,

"use_char": true,
"char_embedding_dim": 30,
"char_feature": "cnn",
"n_gram_filter_sizes": [1, 2, 3, 4, 5],
"n_gram_filter_nums": [5, 10, 20, 30, 35],
"char_lstm_size": 50,

"use_pos": true,
"conll_pos_index": 1,
"pos_embedding_dim": 30,

"use_dict": true,
"dict_embedding_dim": 10,

"dropout": 0.5,

"main_lstm_size": 100,
"fc_dim": 100,
"use_crf": true,
"optimizer": "adam",

"batch_size": 50,
"max_train_epoch": 80,

"early_stop": 80,

"continue_previous_training": false,
"data_dir": "/xxxx/deepsequence/examples/data"
}

更多细节可以可以在项目的GitHub页面查看:

1
https://github.com/yangdc1992/deepsequence

主动学习

主动学习是非常有用的模型迭代的策略和思想,具体概念大家可以自行搜索。这里主要介绍下主动学习关注的两个非常重要的点:

  • 数据的多样性,即数据集要尽量覆盖不同类型的数据。
  • 模型结果的不确定性,不确定即指模型对于给出的结果是不确定的,这里最直观的体现就是模型输出的概率,当模型输出一个结果,但相应的概率却较低的时候,就表明我们的模型对这一类case的学习泛化能力还不够。也是我们在模型迭代中要重点关注的对象。

基于如上两个原则,主动学习能够很好的帮助我们了解数据,了解模型,从而指导我们调整数据分布,调整模型,完成模型的迭代。

小数据VS大模型

首先,我要先说下实际工作中我们容易遇到的两个现状或痛点:

  • 像bert,xnet之类的state of the art 模型架构越来越复杂,模型的容量越来越大
  • 实际工作中,获取训练数据的成本高,实际可用的训练数据量小

那直接把小数据喂给大模型会产生什么后果呢?

  • 过拟合,当数据量少,模型容量大的时候,最直接的后果就是过拟合,模型学到的可能只是一些非常肤浅的特征,或只是记住了当前数据集里的一些简单模式,在遇到新的数据时,泛化性能差。

相应的解决方案:

  • 数据增强,可以使用同义词替换等方式对原数据加入更多噪声,避免让模型学到的只是一些肤浅的模式
  • 使用预训练的词向量或模型,预训练的词向量或模型本身带有大量的信息,可以降低整体模型的学习难度
  • 加入更多领域知识,为避免模型学习到的只是一些肤浅的简单模式,可以在输入特征中加入更多的领域知识,指导模型学习

数据不平衡的几点思考

1. 不平衡并不仅仅只是某一类的数据少,数据冗余也是种不平衡

什么是数据冗余?

通俗来讲,数据冗余,就是说某一类case,对于学习模型来说,我只需要100个训练样本,就可以学到这类case,但训练数据里却有远超过100个数量的这一类case

数据冗余的问题?

  • 浪费标注人员的人力,物力
  • 挤占其他类别的学习机会,在计算学习器损失的时候这一类样本的损失占了很大比例,导致其他类别的损失占比小,降低模型收敛速度和效果

2. 脱离数据分布谈评估指标都是耍流氓

在工作中,我们经常遇到的一个情况便是模型在训练测试集上评价指标非常好,但拿一些实际业务数据测的时候却发现效果测试集上评测指标显示的那么理想,为什么?

  • 测试数据分布不合理,当训练数据中,某一类简单的case占了大部分的时候,默认的评价指标自然会比较好看,但这样其实掩盖了真正问题,那些数据较少,又对实际业务影响较大的case,效果如何,很难从默认的评价指标中看出来

3. 什么才是好的训练数据的分布?绝对平衡吗?

说到数据不平衡问题,大家首先想到的是通过降采样或上采样或其他数据增强的方式把不同类的数据变得数量上的绝对平衡,但绝对平衡一定是好的吗?个人认为,好的训练数据分布应该是在不违背总体先验分布的情况下,与各个类别的学习难度成正比,如果某一类别比较复杂,模型比较难学,那就多增加一些这类的样本,如果某一类别比较简单,就少增加一些这类的样本

4. 如何在实际业务中准备分布合理的训练数据?

模型的发展是一个迭代的过程,没有模型能够在一开始就很好的适应各种业务问题,相对应的训练数据也是一个迭代的过程,在迭代的过程中让分布越来越合理,实际工作中,在每一轮模型迭代的过程中,我们会通过模型输出的概率等方式去看模型在实际业务数据中的表现,通过这种方式指导调整数据的分布,以及模型的架构,最后完成这一轮的迭代。

5. 处理数据不平衡的方法总结

  • 调整数据分布
  • 使用非参数模型,如svm,基于决策树的模型(决策树,随机森林,gbdt…), svm基于最大间隔优化,决策树基于最大信息增益优化,他们的优化方式决定了相比逻辑回归等参数权值优化的方式,天然对数据不平衡问题更加鲁棒
  • 在计算损失时,对样本加权,对数量少的类别使用更大的样本权重,使得模型在优化时更加关注这一类的样本