使用传统的LSTM
用的飞桨上的中文情感分类的数据集
进行分词,我使用了jieba对中文分词,构建词表
这里如果语料库比较小,可以使用jieba 的
全词模式
进行分词,这样可以得到更多的词,减少OOV的概率- 一般是先进行分词,再统计每个词的频率,若语料库比较大,可以根据情况过滤掉低频词(也可以不过滤)
- 最后得到词表和stoi(词到索引的映射)、itos(索引到词的映射)
- 通常我们会加入一些特殊的词,例如
<unk>
表示一个词表不存在的词、<pad>
在训练的时候进行填充来保证输入的向量维度相同
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
34class Vocab:
def __init__(self, train_csv_path):
df = pd.read_csv(train_csv_path, sep="\t", encoding="utf-8")
df.rename(columns={"text_a": "text"}, inplace=True)
self.labels = df["label"].tolist()
texts = df["text"].tolist()
# 进行分词
self.tokenized_texts = []
for text in texts:
self.tokenized_texts.append(jieba.lcut(text))
special_tokens = ["<unk>", "<pad>"]
# 这里没有限制词的频率
all_word = special_tokens + [word for text in self.tokenized_texts for word in text]
self.vocab = Counter(all_word)
self.__itos = self.vocab.keys()
self.__stoi = {word: idx for idx, word in enumerate(self.__itos)}
self.UNK_IDX = self.__stoi["<unk>"] # unknown index
self.PAD_IDX = self.__stoi["<pad>"] # padding index
def stoi(self, word):
if word in self.__itos:
return self.__stoi[word]
return self.UNK_IDX
def itos(self, idx):
if idx <0 or idx >= len(self.__itos):
raise IndexError("Index out of range")
return self.__itos[idx]
def __len__(self):
return len(self.vocab)
需要注意的是我们只能使用训练集中的数据来构建词表
- 将词转为词向量(这个也有很多方法,例如词频统计、TF-IDF,word2vec,BGE) 这里我使用了gensim来训练自己的词向量,每个词使用100维的向量表示
1 |
|
上面我们通过gensim训练了自己的词向量,得到了词向量矩阵,现在我们给定一个词,可以通过词向量矩阵得到这个词的词向量,当然word2vec有一个比较明显的缺点,就是存在OOV的词,这里可以使用fasttext等一些更先进的方法。
nn.Embedding: nn.Embedding是一个词向量的查找表,给定一个词的idx,就可以得到这个词的词向量,和上面的word2vec得到的矩阵类似,但是不同的是nn.Embedding可以作为我们模型的一层,跟随模型一起训练,并且可以反向传播更新词向量的参数
这么一来的话,上述的word2vec的工作岂不是白做了,也不一定,
我们上面通过word2vec得到了词向量,然后我们可以使用得到的词向量的权重参数来初始化nn.Embedding
。这样可以加快收敛
下面三个图是分别使用3种不同的方法得到的训练阶段的情感分类的准确率随迭代次数的图。 1. 随机初始化nn.Embedding 2. 使用word2vec得到的词向量来初始化nn.Embedding 3. 使用word2vec得到的词向量来初始化nn.Embedding 并且不对nn.Embedding进行参数更新
可以看出第二种方法的收敛速度和准确率都是最高的,而第三种的准确率最差,从这里可以看出使用nn.Embedding相比于只是用word2vec,模型最后的效果提升还是比较明显
构建数据集
由于我们使用了nn.Embedding,因此这里我们
__getitem__
函数只要返回每个样本中的词对应的idx即可 由于我们是将好几个样本打包成一个batch进行训练,但是样本中text的长度是不一样的,因此我们通常会将该batch中长度对齐最大的那个样本,将长度不足的样本进行padding
1 |
|
- 构建神经网络
- 这里我们使用上面训练得到的词向量来初始化 nn.Embedding
- 使用了双向lstm,隐藏层的大小为100,num_layers =2
- 一开始我使用的是单向的lstm、隐藏层的大小为64,num_laryers =1 ,但是训练的时候loss一直不下降,好像加大隐藏层的大小也没用,后面看了李沐老师的动手学深度学习的情感分析一章, 修改了模型结构,训练的效果有了明显的提升。现在看来应该是当时的模型太简单,欠拟合了。
1 |
|
训练
因为我们上面做了padding操作,在有些任务计算loss的时候需要忽略padding的元素的影响,不对其计算loss,但是这里是一个文本分类任务,这里就不用考虑 训练了3个epoch,acc大概在0.85-0.9左右,可以提高一下模型的复杂度、训练更多的epoch,看看效果会不会更好
1 |
|
基于 transformer 的bert的方法
这里我们使用的是hugging face生态
遇到的一些问题
- bert对中文的分词是基于字的,就是单纯地把一句话的每个字分开,由于刚开始学习NLP,对很多东西不是特别了解,起初以为是bert对中文的分词效果不行(实际上是中文的分词有基于字的分词和基于词的分词,像bert就是基于字的,jieba等就是基于词的)。
- 然后我后面用来了qwen2的tokenizer进行了尝试,qwen2使用的是B-BPE算法,是基于词的中文分词,哈哈,现在听起来有点奇怪,tokenizer使用qwen2,model使用bert。最夸张的是还跑起来了。(直接使用qwen2的tokenizer进行分词再把结果给bert是跑不起来的,因为qwen2的词表大小比bert的大,我是基于qwen2的tokenizer在自己的数据集上微调了一下,得到了一个自己的tokenizer,词表大小相对较小,比bert的小,因此能跑起来,但是有一些特殊字符也和bert的对不上)不过效果不是很好,大概只有0.85的准确率,比传统的bi-lstm的效果还差。不过正常来说,我们微调模型的时候,tokenizer应该和model保持一致,不然的话得改很多东西,因为词表的大小,还有特殊字符等很多东西都不太一样。
- 之后使用了bert进行微调,还没跑的时候觉得基于字的分词效果肯定不行,但现实直接被打脸,直接使用bert进行情感分类的准确率就大概有0.92,微调了几个epoch之后准确率到了0.945。确实效果比传统的bi-lstm的效果好一些。这样看起来基于字的中文分词的效果好像也不是很差
基于字的分词和基于词的分词
- 基于字的分词
- 不依赖于分词算法,不会出现分词边界切分错误
- 基本上不会出现OOV问题
- 基于词的分词
- 序列相对于基于字的分词更短
基于词的分词通常可以看作一个序列标注问题,即对每个token进行分类,具体可以看这篇文章,这篇文章介绍了如何使用bert来做中文分词,本质上就是一个序列分类任务
代码
1 |
|
一些需要注意的点: 1. 这里我们定义了compute_metrics来自定义评价指标,preprocess_logits_for_metrics 函数对模型的输出结果做一些处理,根据logits得到最后的预测结果(这一步是和compute_metrics紧密相关的,如果不定义preprocess_logits_for_metrics的话,trainer在进行eval的时候会把输出的logits都保存下来,然后我们在compute_metrics函数中对logits进行处理,计算acc,正常来说也确实是没什么问题,因为我们这里输出的logits的大小比较小,是一个batch_size * 2 的一个tensor,但是我以前在微调大模型的时候碰到过输出的logits很大,然后如果你的eval_dataset也比较大的话,就很容易VOOM,而preprocess_logits_for_metrics 可以在eval阶段的每个batch之后将对logits进行处理,这里我们是得到了最终的预测结果,这样的话就只需要保存预测结果,大小为 batch_size *1,相对于logits就小很多,这个在微调大模型的时候很有用)
- 还有就是如果自定义了compute_metrics函数,需要在TrainingArguments 中设定label_names 参数来指定标签是哪一个字段,否则不会执行自定义的compute_metrics。这里我们dataset中的标签的字段是label,但是DataCollatorWithPadding函数中会把label字段变为labels
- 由于bert最高只支持512长度的序列,在代码中我们对长度超过512的序列进行了裁剪,这是一个比较糙的做法,这里可以参考一些这篇文章,里面提到了一些对于输入长度超过了512的一些解决办法。简单来说 1. 我们可以对输入进行裁剪 2. 对bert的结构修改,消除长度限制 3. 使用滑动窗口的形式来对输入进行采样得到多个子样本,这样一方面扩大了数据集,也提高了准确率(不是每一种任务都可以这样做,但文本分类任务可以)
添加滑动窗口来解决bert的长度限制问题
1 |
|
一些需要注意的点:
- 这部分的代码实现的不是很优雅,应该会有更好的实现办法
- 因为在compute_metrics阶段要用到logits,因此这里删掉了preprocess_logits_for_metrics
- 在compute_metrics阶段要用到eval_overlapping,但是由于bert模型的输入用不到overlapping这个字段,trainer会把没有使用到的列自动删除,否则代码会报错,因为我们这里使用了partial来将eval_overlapping传进来
- 由于我们的验证集中超过了500的数据并不多,貌似只有10个左右,所以提升并不明显,maybe 提升0.2%
数据集和模型
可以在hugging face上找到数据集和微调之后的模型,正常的数据集 , 滑动窗口版本的数据集 。没有使用滑动窗口的方法进行微调得到的模型
项目代码
代码请查看left0ver/Sentiment-Classification
- 本文作者: leftover
- 版权声明: 本文版权归leftover所有,如需转载清标明来源!