声明:欢迎转载,转载请注明出处以及链接,码字不易,欢迎小伙伴们点赞和分享。本人知乎账号同名:搬砖的小少年。
现在回忆两年前的quora的比赛,这是Bert模型刚刚出来的时候,现在的nlp比赛中出现的模型无非就是Bert或者Bert的魔改版,一些大力出奇迹模型。预训练模型甚至不需要进行预处理输入即可食用。个人还是喜欢用简单一点的模型来逼近这些预训练模型,减少对算力的依赖。这个比赛是我刚学nlp的时候打的比赛,在这个比赛中学习到很多知识。
比赛页面
quora这个比赛很特殊是个内核比赛,限制在两个小时之内运行完训练和预测且不能用外部文件,也就是说那时候刚出来的Bert是不能用于比赛的。官方还提供了三种预训练词向量供使用,这个比赛可是Bert之前最后的狂欢了。
官方提供的词向量
其中提供的词向量包括谷歌新闻、glove词向量、paragram词向量、维基新闻词向量等。因为不能导入预训练模型,所以比赛中就只能使用各种lstm和cnn等模型来做评论识别。
本次比赛本人使用的是pytorch框架写的代码,因为之前发现tensorflow有随机性,每次运行结果都不一样,就算固定随机种子也无法复现,我推测可能是cudnnlstm这个包里面还加了cudn的一种随机。pytorch可以固定住随机种子保证每次改进是因为方法的原因而不是因为随机性。
pytorch随机种子固定函数
剩下的就是将词向量官方提供的词向量加载进来,把加载出来的词向量作为神经网络的Embedding层表达,相当句子在进入神经网络之前就有了个先验信息,会加速神经网络收敛避免过拟合。
def load_glove(word_index):
EMBEDDING_FILE = '../input/embeddings/glove.840B.300d/glove.840B.300d.txt'
def get_coefs(word,*arr): return word, np.asarray(arr, dtype='float32')[:300]
embeddings_index = dict(get_coefs(*o.split(" ")) for o in open(EMBEDDING_FILE))
all_embs = np.stack(embeddings_index.values())
emb_mean,emb_std = -0.005838499,0.48782197
embed_size = all_embs.shape[1]
# word_index = tokenizer.word_index
nb_words = min(max_features, len(word_index))
# Why random embedding for OOV? what if use mean?
embedding_matrix = np.random.normal(emb_mean, emb_std, (nb_words, embed_size))
#embedding_matrix = np.random.normal(emb_mean, 0, (nb_words, embed_size)) # std 0
for word, i in word_index.items():
if i >= max_features: continue
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None: embedding_matrix[i] = embedding_vector
return embedding_matrix
def load_fasttext(word_index):
EMBEDDING_FILE = '../input/embeddings/wiki-news-300d-1M/wiki-news-300d-1M.vec'
def get_coefs(word,*arr): return word, np.asarray(arr, dtype='float32')
embeddings_index = dict(get_coefs(*o.split(" ")) for o in open(EMBEDDING_FILE) if len(o)>100)
all_embs = np.stack(embeddings_index.values())
emb_mean,emb_std = all_embs.mean(), all_embs.std()
embed_size = all_embs.shape[1]
# word_index = tokenizer.word_index
nb_words = min(max_features, len(word_index))
embedding_matrix = np.random.normal(emb_mean, emb_std, (nb_words, embed_size))
#embedding_matrix = np.random.normal(emb_mean, 0, (nb_words, embed_size))
for word, i in word_index.items():
if i >= max_features: continue
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None: embedding_matrix[i] = embedding_vector
return embedding_matrix
def load_para(word_index):
EMBEDDING_FILE = '../input/embeddings/paragram_300_sl999/paragram_300_sl999.txt'
def get_coefs(word,*arr): return word, np.asarray(arr, dtype='float32')
embeddings_index = dict(get_coefs(*o.split(" ")) for o in open(EMBEDDING_FILE, encoding="utf8", errors='ignore') if len(o)>100)
all_embs = np.stack(embeddings_index.values())
emb_mean,emb_std = -0.0053247833,0.49346462
embed_size = all_embs.shape[1]
# word_index = tokenizer.word_index
nb_words = min(max_features, len(word_index))
embedding_matrix = np.random.normal(emb_mean, emb_std, (nb_words, embed_size))
#embedding_matrix = np.random.normal(emb_mean, 0, (nb_words, embed_size))
for word, i in word_index.items():
if i >= max_features: continue
embedding_vector = embeddings_index.get(word)
if embedding_vector is not None: embedding_matrix[i] = embedding_vector
return embedding_matrix
这里有多种词向量可以利用,利用的方式也有几种,比如平均词向量、拼接词向量等,向平均词向量的优点是词向量的维度不会变化,导入神经网络的时候不会导致参数变多和速度变慢,缺点是这样平均词向量可解释性很差而且效果不如拼接的。拼接词向量会导致词向量的维度增加,所以词向量不能拼接太多,会导致Embedding层的参数变多。
glove_embeddings = load_glove(word_index)
paragram_embeddings = load_para(word_index)
fasttext_embeddings = load_fasttext(word_index)
embedding_matrix = np.mean([glove_embeddings, paragram_embeddings, fasttext_embeddings], axis=0)
因为想利用多种词向量而不拖累算法运行时间,于是采用三种词向量进行平均。
向现在的Bert模型可以不做文本清洗工作,Bert做了清洗的话可能会降低效果丢失信息,因为Bert在大量文本数据中做了预训练,预训练语料中可能也是包含噪点的数据,所以能够提取出信息,但是lstm这种模型是没有预训练的,所以需要对文本进行预处理和清洗工作,能够给模型效果带来收益。
我主要做了如下几种文本清洗工作:
1、对于数字进行归一化
2、对于英文字母小写化
3、进行文本中的奇异字符进行清洗
4、缺失数据进行统一字符填补
5、进行文本缩略词展开(这个比较有效)
文本清洗不做不行,但是又不能做的太过了,得把握一个度。之所以说文本缩略词展开有效果的原因是国外喜欢用使用缩略词来表达意思,但是缩略词所含的信息比较少,可能在embedding是oov的情况,没有表示信息,所以没有表示的词从零开始学习。如果将缩略词写成完整的词就会大大增加文本表达的信息。
数据在进入模型中需要变成向量也需要统一输入句子的向量长度,需要训练分词器,使用测试集和训练集的文本训练可以保证都有表示,如果训练集文本够多就只用训练集文本数据训练分词器即可。
## Tokenize the sentences
tokenizer = Tokenizer(num_words=max_features)
tokenizer.fit_on_texts(list(train_X))
train_X = tokenizer.texts_to_sequences(train_X)
test_X = tokenizer.texts_to_sequences(test_X)
## Pad the sentences
train_X = pad_sequences(train_X, maxlen=maxlen)
test_X = pad_sequences(test_X, maxlen=maxlen)
进入模型前打乱训练集是为了避免模型过拟合,提高模型鲁棒性。
class NeuralNet(nn.Module):
def __init__(self):
super(NeuralNet, self).__init__()
fc_layer = 16
fc_layer1 = 16
self.embedding = nn.Embedding(max_features, embed_size)
self.embedding.weight = nn.Parameter(torch.tensor(embedding_matrix, dtype=torch.float32))
self.embedding.weight.requires_grad = False
self.embedding_dropout = nn.Dropout2d(0.1)
self.lstm = nn.LSTM(embed_size, hidden_size, bidirectional=True, batch_first=True)
self.gru = nn.GRU(hidden_size * 2, hidden_size, bidirectional=True, batch_first=True)
self.lstm_attention = Attention(hidden_size * 2, maxlen)
self.gru_attention = Attention(hidden_size * 2, maxlen)
self.bn1 = nn.BatchNorm1d(hidden_size*8+3, momentum=0.5)
self.bn = nn.BatchNorm1d(16, momentum=0.5)
self.bn2= nn.BatchNorm1d(Num_capsule*Dim_capsule, momentum=0.5)
self.linear = nn.Linear(hidden_size*8+3, fc_layer1) #643:80 - 483:60 - 323:40
self.relu = nn.ReLU()
self.dropout = nn.Dropout(0.1)
self.fc = nn.Linear(fc_layer**2,fc_layer)
self.out = nn.Linear(fc_layer, 1)
self.lincaps = nn.Linear(Num_capsule * Dim_capsule, 1)
self.caps_layer = Caps_Layer()
def forward(self, x):
h_embedding = self.embedding(x[0])
h_embedding = torch.squeeze(
self.embedding_dropout(torch.unsqueeze(h_embedding, 0)))
h_lstm, _ = self.lstm(h_embedding)
h_gru, _ = self.gru(h_lstm)
##Capsule Layer
content3 = self.caps_layer(h_gru)
content3 = self.dropout(content3)
batch_size = content3.size(0)
content3 = content3.view(batch_size, -1)
content3 = self.bn2(content3)
content3 = self.relu(self.lincaps(content3))
##Attention Layer
h_lstm_atten = self.lstm_attention(h_lstm)
h_gru_atten = self.gru_attention(h_gru)
# global average pooling
avg_pool = torch.mean(h_gru, 1)
# global max pooling
max_pool, _ = torch.max(h_gru, 1)
f = torch.tensor(x[1], dtype=torch.float).cuda()
#[512,160]
conc = torch.cat((h_lstm_atten, h_gru_atten,content3, avg_pool, max_pool,f), 1)
conc = self.bn1(conc)
conc = self.relu(self.linear(conc))
conc = self.bn(conc)
conc = self.dropout(conc)
out = self.out(conc)
return out
模型结构大概是Embedding层之后一层双向LSTM一层双向GRU,这里之所以换成GRU的原因是因为比赛第二阶段测试集比第一阶段多七倍,运行时间还是两个小时之内。后面用了一个比较冷门的算法。
胶囊网络(Capsual Network)是NeurIPS 2017的一项工作。作者首先总结了当前卷积神经网络的限制与不足:①CNN通过池化操作能够获得invariance,有助于分析,但是同时一些局部信息也会丢失。如果数据发生旋转、倾斜,其效果会很差 ②CNN很解释部分与整体之间的位置关系。当前针对问题1现有两种方法解决方案,一是数据增强,通过对训练样本进行旋转、位移生成新的训练集;二是使用更多的参数去建模,也就是构建一个更深层的网络。但是这些方法局限性也很大,缺少局部的等变特性(equivariance),所以其泛化能力很弱。
胶囊网络使用动态路由来更新权重,本来胶囊网络的作者是想用胶囊网络来代替CNN,不过想得有点多,虽然CNN存在不少缺陷,但是你的胶囊网络不是缺陷更大吗?胶囊网络放在这里主要是提取文本中模糊的语义信息,卷积神经网络在池化层中丢失了大量信息,降低了空间分辨率,因此当输入发生微小的变化,输出基本不变,这是一个在整个网络中必须保留详细信息的问题。通过胶囊网络,详细的姿态信息(如精确的目标位置、旋转、厚度、倾斜、大小等)将在整个网络中被保存,而不是丢失了之后再恢复,输入的小变化导致输出的小变化——信息被保存,这就是所谓的“等变化(equivariance)”。但是胶囊网络的缺点也很明显,比如训练速度很慢、对于语义相近的信息不能区分等。
胶囊网络之后会再接一层attention机制,用来计算全局词的权重,attention机制搞nlp的应该都有了解过原理,这里就不做过多的赘述了。但是如果文本不长的话可以不需要 上attention机制,因为需要较长的上下文才能发挥他完全的效果。
然后通过提取gru层的最大池和最小池特征concat的一起,为什么不提取attention后面的特征的原因是因为那些特征被高度浓缩了,想从gru后面浅层的特征通过池化保留重要信息和深层特征融合通过全连接层去提纯特征分类。
该比赛使用如下几个trick:
1、周期性学习率
也就是说学习率随周期性变化而变化,里面超参数有基础学习率和最大学习率,所谓基础学习率就是模型在平常运行在基础学习率上面,周期性跳到最大学习率上,这样做的好处就是调得好的话可以加快收敛,可以跳出局部最小值困境,缺点就是比较难调。
class CyclicLR(object):
def __init__(self, optimizer, base_lr=1e-3, max_lr=6e-3,
step_size=2000, mode='triangular', gamma=1.,
scale_fn=None, scale_mode='cycle', last_batch_iteration=-1):
if not isinstance(optimizer, Optimizer):
raise TypeError('{} is not an Optimizer'.format(
type(optimizer).__name__))
self.optimizer = optimizer
if isinstance(base_lr, list) or isinstance(base_lr, tuple):
if len(base_lr) != len(optimizer.param_groups):
raise ValueError("expected {} base_lr, got {}".format(
len(optimizer.param_groups), len(base_lr)))
self.base_lrs = list(base_lr)
else:
self.base_lrs = [base_lr] * len(optimizer.param_groups)
if isinstance(max_lr, list) or isinstance(max_lr, tuple):
if len(max_lr) != len(optimizer.param_groups):
raise ValueError("expected {} max_lr, got {}".format(
len(optimizer.param_groups), len(max_lr)))
self.max_lrs = list(max_lr)
else:
self.max_lrs = [max_lr] * len(optimizer.param_groups)
self.step_size = step_size
if mode not in ['triangular', 'triangular2', 'exp_range'] \
and scale_fn is None:
raise ValueError('mode is invalid and scale_fn is None')
self.mode = mode
self.gamma = gamma
if scale_fn is None:
if self.mode == 'triangular':
self.scale_fn = self._triangular_scale_fn
self.scale_mode = 'cycle'
elif self.mode == 'triangular2':
self.scale_fn = self._triangular2_scale_fn
self.scale_mode = 'cycle'
elif self.mode == 'exp_range':
self.scale_fn = self._exp_range_scale_fn
self.scale_mode = 'iterations'
else:
self.scale_fn = scale_fn
self.scale_mode = scale_mode
self.batch_step(last_batch_iteration + 1)
self.last_batch_iteration = last_batch_iteration
def batch_step(self, batch_iteration=None):
if batch_iteration is None:
batch_iteration = self.last_batch_iteration + 1
self.last_batch_iteration = batch_iteration
for param_group, lr in zip(self.optimizer.param_groups, self.get_lr()):
param_group['lr'] = lr
def _triangular_scale_fn(self, x):
return 1.
def _triangular2_scale_fn(self, x):
return 1 / (2. ** (x - 1))
def _exp_range_scale_fn(self, x):
return self.gamma**(x)
def get_lr(self):
step_size = float(self.step_size)
cycle = np.floor(1 + self.last_batch_iteration / (2 * step_size))
x = np.abs(self.last_batch_iteration / step_size - 2 * cycle + 1)
lrs = []
param_lrs = zip(self.optimizer.param_groups, self.base_lrs, self.max_lrs)
for param_group, base_lr, max_lr in param_lrs:
base_height = (max_lr - base_lr) * np.maximum(0, (1 - x))
if self.scale_mode == 'cycle':
lr = base_lr + base_height * self.scale_fn(cycle)
else:
lr = base_lr + base_height * self.scale_fn(self.last_batch_iteration)
lrs.append(lr)
return lrs
周期性学习率代码实现
2、使用AdamW优化器,我在那场比赛里应该是唯一用AdamW优化器的把,当时听到第二阶段会增加七倍测试集数量很慌,这是对模型运行时间和泛化能力的双重考验,于是当时为了提高模型泛化能力除了用BN层之外还进行一些操作的搜寻,看了很多论文和博客找到一个AdamW优化器复现出来,AdamW相当于Adam+l2正则化操作,避免模型过拟合。pytorch高阶封装框架fastai默认AdamW优化器,不过此优化器争议比较大,我试过有那么点用。
import math
import torch
from torch.optim.optimizer import Optimizer
class AdamW(Optimizer):
"""Implements Adam algorithm.
It has been proposed in `Adam: A Method for Stochastic Optimization`_.
Arguments:
params (iterable): iterable of parameters to optimize or dicts defining
parameter groups
lr (float, optional): learning rate (default: 1e-3)
betas (Tuple[float, float], optional): coefficients used for computing
running averages of gradient and its square (default: (0.9, 0.999))
eps (float, optional): term added to the denominator to improve
numerical stability (default: 1e-8)
weight_decay (float, optional): weight decay (L2 penalty) (default: 0)
amsgrad (boolean, optional): whether to use the AMSGrad variant of this
algorithm from the paper `On the Convergence of Adam and Beyond`_
.. _Adam\: A Method for Stochastic Optimization:
https://arxiv.org/abs/1412.6980
.. _On the Convergence of Adam and Beyond:
https://openreview.net/forum?id=ryQu7f-RZ
"""
def __init__(self, params, lr=1e-3, betas=(0.9, 0.999), eps=1e-8,
weight_decay=0, amsgrad=False):
if not 0.0 <= lr:
raise ValueError("Invalid learning rate: {}".format(lr))
if not 0.0 <= eps:
raise ValueError("Invalid epsilon value: {}".format(eps))
if not 0.0 <= betas[0] < 1.0:
raise ValueError("Invalid beta parameter at index 0: {}".format(betas[0]))
if not 0.0 <= betas[1] < 1.0:
raise ValueError("Invalid beta parameter at index 1: {}".format(betas[1]))
defaults = dict(lr=lr, betas=betas, eps=eps,
weight_decay=weight_decay, amsgrad=amsgrad)
super(AdamW, self).__init__(params, defaults)
def __setstate__(self, state):
super(AdamW, self).__setstate__(state)
for group in self.param_groups:
group.setdefault('amsgrad', False)
def step(self, closure=None):
"""Performs a single optimization step.
Arguments:
closure (callable, optional): A closure that reevaluates the model
and returns the loss.
"""
loss = None
if closure is not None:
loss = closure()
for group in self.param_groups:
for p in group['params']:
if p.grad is None:
continue
grad = p.grad.data
if grad.is_sparse:
raise RuntimeError('Adam does not support sparse gradients, please consider SparseAdam instead')
amsgrad = group['amsgrad']
state = self.state[p]
# State initialization
if len(state) == 0:
state['step'] = 0
# Exponential moving average of gradient values
state['exp_avg'] = torch.zeros_like(p.data)
# Exponential moving average of squared gradient values
state['exp_avg_sq'] = torch.zeros_like(p.data)
if amsgrad:
# Maintains max of all exp. moving avg. of sq. grad. values
state['max_exp_avg_sq'] = torch.zeros_like(p.data)
exp_avg, exp_avg_sq = state['exp_avg'], state['exp_avg_sq']
if amsgrad:
max_exp_avg_sq = state['max_exp_avg_sq']
beta1, beta2 = group['betas']
state['step'] += 1
# if group['weight_decay'] != 0:
# grad = grad.add(group['weight_decay'], p.data)
# Decay the first and second moment running average coefficient
exp_avg.mul_(beta1).add_(1 - beta1, grad)
exp_avg_sq.mul_(beta2).addcmul_(1 - beta2, grad, grad)
if amsgrad:
# Maintains the maximum of all 2nd moment running avg. till now
torch.max(max_exp_avg_sq, exp_avg_sq, out=max_exp_avg_sq)
# Use the max. for normalizing running avg. of gradient
denom = max_exp_avg_sq.sqrt().add_(group['eps'])
else:
denom = exp_avg_sq.sqrt().add_(group['eps'])
bias_correction1 = 1 - beta1 ** state['step']
bias_correction2 = 1 - beta2 ** state['step']
step_size = group['lr'] * math.sqrt(bias_correction2) / bias_correction1
# p.data.addcdiv_(-step_size, exp_avg, denom)
p.data.add_(-step_size, torch.mul(p.data, group['weight_decay']).addcdiv_(1, exp_avg, denom) )
return loss
3、针对数据不平衡进行阈值分割
训练数据不平衡之事实在是非常常见的事,我使用了很多方法来做这个,包括上下采样、smote采样、调loss的正负权重、focalloss等方法都会使效果下降,找到一个比较有效的方法就是进行阈值分割,通过预测验证集寻找到评测指标最高一个切分点的阈值作为测试集切分的阈值,这样有效的处理了数据不平衡问题。
4、多则交叉
使用多则交叉主要是为了降低数据方差扰动,也平衡了模型预测误差,有点类似于随机森林的bagging的思想,在比赛时间允许的情况下多则能够带来更好的效果。
两年参加的比赛,知道今天才想起要写点什么,现在都是各种Bert魔改版横行,虽然我后面打比赛也是会用Bert,但是还是喜欢小一点的模型达到很好的效果,信奉大道至简的原理。
比赛结果比较一般只拿到银牌了,不过这场比赛是开启我走向NLP的开端,希望能够在这一行有更加深入的沉淀。