文本分类是NLP领域的较为容易的入门问题,本文记录我自己在做文本分类任务以及复现相关论文时的基本流程,绝大部分操作都使用了torch和torchtext两个库。
1. 文本数据预处理
首先数据存储在三个csv文件中,分别是train.csv,valid.csv,test.csv,第一列存储的是文本数据,例如情感分类问题经常是用户的评论review,例如imdb或者amazon数据集。第二列是情感极性polarity,N分类问题的话就有N个值,假设值得范围是0~N-1。
下面是很常见的文本预处理流程,英文文本的话不需要分词,直接按空格split就行了,这里只会主要说说第4点。
1、去除非文本部分
2、分词
3、去除停用词
4、对英文单词进行词干提取(stemming)和词型还原(lemmatization)
5、转为小写
6、特征处理
Bag of Words
Tf-idf
N-gram
Word2vec
词干提取和词型还原
from nltk.stem import SnowballStemmer stemmer = SnowballStemmer("english") # 选择语言 from nltk.stem import WordNetLemmatizer wnl = WordNetLemmatizer()
SnowballStemmer较为激进,转换有可能出现错误,这里较为推荐使用WordNetLemmatizer,它一般只在非常肯定的情况下才进行转换,否则会返回原来的单词。
stemmer.stem('knives') # knive wnl.lemmatize('knives') # knife
因为我没有系统学习和研究过NLTK的代码,所以就不多说了,有兴趣的可以自己去阅读NLTK的源码。
2. 使用torchtext加载文本数据
本节主要是用的模块是torchtext里的data模块,处理的数据同上一节所描述。
首先定义一个tokenizer用来处理文本,比如分词,小写化,如果你已经根据上一节的词干提取和词型还原的方法处理过文本里的每一个单词后可以直接分词就够了。
tokenize = lambda x: x.split()
或者也可以更保险点,使用spacy库,不过就肯定更耗费时间了。
import spacy spacy_en = spacy.load('en') def tokenizer(text): return [toke.text for toke in spacy_en.tokenizer(text)]
然后要定义Field,至于Field是啥,你可以简单地把它理解为一个能够加载、预处理和存储文本数据和标签的对象。我们可以用它根据训练数据来建立词表,加载预训练的Glove词向量等等。
def DataLoader(): tokenize = lambda x: x.split() # 用户评论,include_lengths设为True是为了方便之后使用torch的pack_padded_sequence REVIEW = data.Field(sequential=True,tokenize=tokenize, include_lengths=True) # 情感极性 POLARITY = data.LabelField(sequential=False, use_vocab=False, dtype = torch.long) # 假如train.csv文件并不是只有两列,比如1、3列是review和polarity,2列是我们不需要的数据, # 那么就要添加一个全是None的元组, fields列表存储的Field的顺序必须和csv文件中每一列的顺序对应, # 否则review可能就加载到polarity Field里去了 fields = [('review', REVIEW), (None, None), ('polarity', POLARITY)] # 加载train,valid,test数据 train_data, valid_data, test_data = data.TabularDataset.splits( path = 'amazon', train = 'train.csv', validation = 'valid.csv', test = 'test.csv', format = 'csv', fields = fields, skip_header = False # 是否跳过文件的第一行 ) return REVIEW, POLARITY, train_data
加载完数据可以开始建词表。如果本地没有预训练的词向量文件,在运行下面的代码时会自动下载到当前文件夹下的'.vector_cache'文件夹内,如果本地已经下好了,可以用Vectors指定文件名name,路径cache,还可以使用Glove。
from torchtext.vocab import Vectors, Glove import torch REVIEW, POLARITY, train_data = DataLoader() # vectors = Vectors(name='glove.6B.300d.txt', cache='.vector_cache') REVIEW.build_vocab(train_data, # 建词表是用训练集建,不要用验证集和测试集 max_size=400000, # 单词表容量 vectors='glove.6B.300d', # 还有'glove.840B.300d'已经很多可以选 unk_init=torch.Tensor.normal_ # 初始化train_data中不存在预训练词向量词表中的单词 ) # print(REVIEW.vocab.freqs.most_common(20)) 数据集里最常出现的20个单词 # print(REVIEW.vocab.itos[:10]) 列表 index to word # print(REVIEW.vocab.stoi) 字典 word to index
接着就是把预训练词向量加载到model的embedding weight里去了。
pretrained_embeddings = REVIEW.vocab.vectors model.embedding.weight.data.copy_(pretrained_embeddings) UNK_IDX = REVIEW.vocab.stoi[REVIEW.unk_token] PAD_IDX = REVIEW.vocab.stoi[REVIEW.pad_token] # 因为预训练的权重的unk和pad的词向量不是在我们的数据集语料上训练得到的,所以最好置零 model.embedding.weight.data[UNK_IDX] = torch.zeros(EMBEDDING_DIM) model.embedding.weight.data[PAD_IDX] = torch.zeros(EMBEDDING_DIM)
然后用torchtext的迭代器来批量加载数据,torchtext.data里的BucketIterator非常好用,它可以把长度相近的文本数据尽量都放到一个batch里,这样最大程度地减少padding,数据就少了很多无意义的0,也减少了矩阵计算量,也许还能对最终准确度有帮助(误)?我凭直觉猜的,没有做实验对比过,但是至少能加速训练迭代应该是没有疑问的,如果哪天我有钱了买了台好点的服务器做完实验再来补充。
sort_within_batch设为True的话,一个batch内的数据就会按sort_key的排列规则降序排列,sort_key是排列的规则,这里使用的是review的长度,即每条用户评论所包含的单词数量。
train_iterator, valid_iterator, test_iterator = data.BucketIterator.splits( (train_data, valid_data, test_data), batch_size=32, sort_within_batch=True, sort_key = lambda x:len(x.review), device=torch.device('cpu'))
最后就是加载数据喂给模型了。
for batch in train_iterator: # 因为REVIEW Field的inclue_lengths为True,所以还会包含一个句子长度的Tensor review, review_len = batch.review # review.size = (seq_length, batch_size) , review_len.size = (batch_size, ) polarity = batch.polarity # polarity.size = (batch_size, ) predictions = model(review, review_lengths) loss = criterion(predictions, polarity) # criterion = nn.CrossEntropyLoss()
3. 使用pytorch写一个LSTM情感分类器
下面是我简略写的一个模型,仅供参考
import torch.nn as nn import torch.nn.functional as F from torch.nn.utils.rnn import pack_padded_sequence import torch class LSTM(nn.Module): def __init__(self, vocab_size, embedding_dim, hidden_dim, output_dim, n_layers, bidirectional, dropout, pad_idx): super(LSTM, self).__init__() self.embedding = nn.Embedding(vocab_size, embedding_dim, padding_idx=pad_idx) self.lstm = nn.LSTM(embedding_dim, hidden_dim, num_layers=n_layers, bidirectional=bidirectional, dropout=dropout) self.Ws = nn.Parameter(torch.Tensor(hidden_dim, output_dim)) self.bs = nn.Parameter(torch.zeros((output_dim, ))) nn.init.uniform_(self.Ws, -0.1, 0.1) nn.init.uniform_(self.bs, -0.1, 0.1) self.dropout = nn.Dropout(p=0.5) def forward(self, x, x_len): x = self.embedding(x) x = pack_padded_sequence(x, x_len) H, (h_n, c_n) = self.lstm(x) h_n = self.dropout(h_n) h_n = torch.squeeze(h_n) res = torch.matmul(h_n, self.Ws) + self.bs y = F.softmax(res, dim=1) # y.size(batch_size, output_dim) return y
训练函数
def train(model, iterator, optimizer, criterion): epoch_loss = 0 num_sample = 0 correct = 0 model.train() for batch in iterator: optimizer.zero_grad() review, review_lengths = batch.review polarity = batch.polarity predictions = model(review, review_lengths) correct += torch.sum(torch.argmax(preds, dim=1) == polarity) loss = criterion(predictions, polarity) loss.backward() epoch_loss += loss.item() num_sample += len(batch) optimizer.step() return epoch_loss / num_sample, correct.float() / num_sample if __name__ == '__main__': for epoch in range(N_EPOCHS): train_loss, acc = train(model, train_iter, optimizer, criterion) print(f'\tTrain Loss: {train_loss:.3f} | Train Acc: {acc* 100:.2f}%')
注意事项和遇到的一些坑
文本情感分类需不需要去除停用词?
应该是不用的,否则acc有可能下降。
data.TabularDataset.splits虽然好用,但是如果你只想加载训练集,这时候如果直接不给validation和test参数赋值,那么其他代码和原来一样,比如这样
train_data = data.TabularDataset.splits( path = '', train = 'train.csv', format = 'csv', fields = fields, skip_header = False # 是否跳过文件的第一行 )
那么底下你一定会报错,因为data.TabularDataset.splits返回的是一个元组,也就是如果是训练验证测试三个文件都给了函数,就返回(train_data, valid_data, test_data),这时候你用三个变量去接受函数返回值当然没问题,元组会自动拆包。
当只给函数一个文件train.csv时,函数返回的是(train_data)而非train_data,因此正确的写法应该如下
train_data = data.TabularDataset.splits( path = '', train = 'train.csv', format = 'csv', fields = fields, skip_header = False # 是否跳过文件的第一行 )[0] # 注意这里的切片,选择元组的第一个也是唯一一个元素赋给train_data
同理data.BucketIterator.splits也有相同的问题,它不但返回的是元组,它的参数datasets要求也是以元组形式,即(train_data, valid_data, test_data)进行赋值,否则在下面的运行中也会出现各种各样奇怪的问题。
如果你要生成两个及以上的迭代器,那么没问题,直接照上面写就完事了。
如果你只要生成train_iterator,那么正确的写法应该是下面这样
train_iter = data.BucketIterator( train_data, batch_size=32, sort_key=lambda x:len(x.review), sort_within_batch=True, shuffle=True # 训练集需要shuffle,但因为验证测试集不需要 # 可以生成验证和测试集的迭代器直接用data.iterator.Iterator类就足够了 )
出现的问题 x = pack_padded_sequence(x, x_len) 当数据集有长度为0的句子时, 就会后面报错
Adagrad效果比Adam好的多
4. 总结
不仅仅是NLP领域,在各大顶会中,越来越多的学者选择使用Pytorch而非TensorFlow,主要原因就是因为它的易用性,torchtext和pytorch搭配起来是非常方便的NLP工具,可以大大缩短文本预处理,加载数据的时间。
我本人之前用过tf 1.x以及keras,最终拥抱了Pytorch,也是因为它与Numpy极其类似的用法,更Pythonic的代码,清晰的源码让我在遇到bug时能一步一步找到问题所在,动态图让人能随时看到输出的Tensor的全部信息,这些都是Pytorch的优势。
现在tf 2.0也在不断改进,有人笑称tf越来越像pytorch了,其实pytorch也在不断向tf学习,在工业界,tf仍然处于王者地位,不知道未来pytorch能不能在工业界也与tf平分秋色,甚至更胜一筹呢?
以上这篇使用pytorch和torchtext进行文本分类的实例就是小编分享给大家的全部内容了,希望能给大家一个参考,也希望大家多多支持易盾网络。