这是网安本科速通系列的最后一篇了, 主题还是 pytorch
, 但是问题换成了 NLP
, 并且还是以经典的文本分类作为示例, 数据集使用与前面经典问题之文本分类相同的一份小数据集, 方便进行比较.
阅读本篇前需要先看前两篇, 经典问题之文本分类与基于 PyTorch 的手写数字分类. 本篇的项目代码和数据集是以前两篇作为基础的, 并且也会精简正文内容.
环境准备与项目结构
环境使用 pytorch
环境.
项目结构与文本分类项目结构一致, 并且使用相同的数据集.
快速上手
导入库
1 2 3 4 5 6 7 8 9 10 11
| import jieba import numpy as np from sklearn.datasets import load_files from sklearn.model_selection import train_test_split from sklearn.metrics import accuracy_score, classification_report, f1_score, precision_score, recall_score import torch from torch import nn, optim from torch.utils.data import DataLoader, Dataset from torch.nn.utils.rnn import pad_sequence
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
前两个项目的结合, 都是些常规库.
构建数据集
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| class Vocabulary: PAD = "<PAD>" UNK = "<UNK>"
def __init__(self): self.token2id = {self.PAD: 0, self.UNK: 1} self.id2token = [self.PAD, self.UNK]
def __len__(self): return len(self.token2id)
def add_token(self, token): if token not in self.token2id: self.token2id[token] = len(self.token2id) self.id2token.append(token)
def encode(self, text): return [self.token2id.get(x, self.token2id[self.UNK]) for x in text]
def decode(self, ids): return [self.id2token[x] for x in ids]
|
先写一个词表类, 这个词表可以按需求添加生词, 并且将分词后的文本进行词与序号之间的编解码操作, 核心目的是提供文本向量化的能力.
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
| def load_raw_data(path): raw_data = load_files(path, encoding="utf8", shuffle=True, decode_error="ignore", random_state=1) data_x = raw_data["data"] data_y = raw_data["target"] index2label = raw_data["target_names"] label2index = {l: i for i, l in enumerate(index2label)}
return (data_x, data_y), (index2label, label2index)
def preprocess(data_x, data_y): data_x = [jieba.lcut(s) for s in data_x] train_x, test_x, train_y, test_y = train_test_split( data_x, data_y, train_size=0.7, shuffle=True, stratify=data_y, random_state=1 )
vocab = Vocabulary() for text in train_x: for word in text: vocab.add_token(word)
train_x = [vocab.encode(x) for x in train_x] test_x = [vocab.encode(x) for x in test_x]
return (train_x, test_x, train_y, test_y), vocab
|
然后封装一下之前文本分类项目里的数据集加载操作, 手动使用 jieba.lcut
进行分词并构建词表, 最后返回划分好的训练集与测试集.
1 2 3 4 5 6 7 8 9 10 11 12
| class MyDataset(Dataset): def __init__(self, x, y): self.inputs = x self.targets = y
def __len__(self): return len(self.inputs)
def __getitem__(self, item): input_ = torch.tensor(self.inputs[item]).long().to(DEVICE) target = torch.tensor([self.targets[item]]).long().to(DEVICE) return (input_, target)
|
自定义数据集类, 与数字分类项目里的定义方式类似, 但是输入参数换成直接获取前面预处理好的数据集.
定义神经网络结构
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
| class MyNetwork(nn.Module): def __init__(self, vocab_size, output_size=7): super().__init__() _embedding_size = 128 _hidden_size = 128 _filter_sizes = (3, 4, 5) self.embedding = nn.Embedding(vocab_size, _embedding_size) self.dropout = nn.Dropout() self.convs = nn.ModuleList([nn.Conv1d(_embedding_size, _hidden_size, k) for k in _filter_sizes]) self.fc = nn.Linear(_hidden_size * len(_filter_sizes), output_size)
def _convpool(self, x): outputs = [] for conv in self.convs: output = torch.relu(conv(x)) output = torch.max_pool1d(output, output.size(2)).squeeze() outputs.append(output) return torch.cat(outputs, -1)
def forward(self, inputs): outputs = self.embedding(inputs) outputs = self.dropout(outputs) outputs = outputs.transpose(1, 2) outputs = self._convpool(outputs) outputs = self.fc(outputs) return outputs
|
继续使用与数字分类中相同的卷积结构, 也就是 TextCNN
, 卷积核选择经典的三个值. 各个步骤的维度变化在注释里有标注.
定义指标评价函数
见定义评价指标函数.
训练与评估函数
见定义训练函数与定义评估函数
校对函数 (collate_fn)
1 2 3 4 5
| def collate_fn(data: list): inputs, targets = map(list, zip(*data)) inputs = pad_sequence(inputs, batch_first=True) targets = torch.stack(targets) return inputs, targets
|
这个东西可能第一次见, 并且用了一些很奇怪的操作, 比如那个 map
, 但是首先要明白这个函数用于干什么.
回忆我们在数字分类项目里使用 DataLoader
时, 它可以给我们提供一个 loader 来迭代我们自定义的 Dataset
. Dataset
每次取出来的东西是一个二元组, 里面包含样本与标签两部分, 而 Dataloader
又能够按 batch_size
的大小批量获取这些二元组, 形成一个 list
. 这个长度为 batch_size
, 内容为二元组的 list
就是 collate_fn
的输入参数.
再看 map
那一行操作, 涉及了几个 python
函数用法, 这里直接说结论, 它将 data
里的样本与标签拆分成了两个单独的 list
.
然后再说 pad_sequence
操作, 对于神经网络来说, 所有的计算过程都是矩阵计算, 但是对于文本任务, 每个样本句子长度几乎都不是相同的, 因此需要进行对齐操作, 对短句进行填充. 需要注意的是, 默认参数里的填充值是 0
, 这与我们前面词表里的定义是一致的, 如果不一致则需要手动填一下.
最后是参数的返回值, 直接对应了我们从 loader 里迭代数据时获取的变量形式, 此处就是和之前一样, 分别返回样本列表与标签列表.
定义训练超参数
1 2 3 4 5 6 7
| seed = 1 learning_rate = 1e-3 batch_size = 16 epochs = 25
torch.manual_seed(seed) torch.cuda.manual_seed(seed)
|
与前面的项目类似, 但是 batch_size
设的稍小一些, 因为对于文本处理, 按批次训练时, 进行了填充操作, 越大的 batch_size
能够得到越快的训练速度, 但是在一个 batch
内会引入更多不必要的 0
填充值, 可以酌情尝试调整.
加载数据集
1 2 3 4 5 6 7
| (data_x, data_y), (index2label, label2index) = load_raw_data("./data/") (train_x, test_x, train_y, test_y), vocab = preprocess(data_x, data_y)
train_dataset = MyDataset(train_x, train_y) test_dataset = MyDataset(test_x, test_y) train_dataloader = DataLoader(train_dataset, shuffle=True, batch_size=batch_size, collate_fn=collate_fn) test_dataloader = DataLoader(test_dataset, shuffle=True, batch_size=batch_size, collate_fn=collate_fn)
|
最大的不同就是添加了 collate_fn
参数, 其余的都是之前涉及过的操作.
实例化模型训练需要的对象
1 2 3
| model = MyNetwork(len(vocab)).to(DEVICE) loss_fn = nn.CrossEntropyLoss().to(DEVICE) optimizer = optim.Adam(model.parameters(), lr=learning_rate)
|
与之前的定义也是几乎一致的, 唯一的不同就是 MyNetwork
多了一个 vocab_size
的参数需要传进去, 其他参数都用的默认值.
训练模型
代码见训练模型, 这里只贴一下后 5 轮的训练结果.
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
| =============================== Epoch 21 ------------------------------- Train Loss: 0.0071 Acc: 1.0000 F1: 1.0000(1.0000/1.0000) Eval Loss: 0.1102 Acc: 0.9667 F1: 0.9669(0.9669/0.9667) =============================== Epoch 22 ------------------------------- Train Loss: 0.0055 Acc: 1.0000 F1: 1.0000(1.0000/1.0000) Eval Loss: 0.1099 Acc: 0.9644 F1: 0.9645(0.9645/0.9643) =============================== Epoch 23 ------------------------------- Train Loss: 0.0056 Acc: 1.0000 F1: 1.0000(1.0000/1.0000) Eval Loss: 0.1062 Acc: 0.9644 F1: 0.9645(0.9645/0.9643) =============================== Epoch 24 ------------------------------- Train Loss: 0.0048 Acc: 1.0000 F1: 1.0000(1.0000/1.0000) Eval Loss: 0.1019 Acc: 0.9667 F1: 0.9669(0.9669/0.9667) =============================== Epoch 25 ------------------------------- Train Loss: 0.0042 Acc: 1.0000 F1: 1.0000(1.0000/1.0000) Eval Loss: 0.1051 Acc: 0.9644 F1: 0.9645(0.9645/0.9643) ===============================
|
可以看到在训练集上已经完全拟合了, 并且测试集上 F1 得分也高达 0.9645.
输出最终的测试结果
代码见输出最终的测试结果, 这里只贴一下输出结果.
1 2 3 4 5 6 7 8 9 10 11 12 13
| precision recall f1-score support
0 0.9524 1.0000 0.9756 60 1 0.9667 0.9667 0.9667 60 2 1.0000 1.0000 1.0000 60 3 0.9649 0.9167 0.9402 60 4 0.9833 0.9833 0.9833 60 5 0.9333 0.9180 0.9256 61 6 0.9508 0.9667 0.9587 60
accuracy 0.9644 421 macro avg 0.9645 0.9645 0.9643 421 weighted avg 0.9644 0.9644 0.9642 421
|
可以和之前使用朴素贝叶斯的文本分类做个比较, 可以看到还是有明显提升的, 2
号类别甚至已经完全正确了. 当然这个对比不是很科学, 毕竟这是一个很小的数据集, 而且两者都还有大量的可调整空间. 朴素贝叶斯里有很多超参数, 而且样本的特征提取也有待进一步升级; TextCNN
网络里也有很多超参数可调, 比如词向量的长度等等.
不过神经网络的强处正是在于能够自动提取深层次特征, 避免了人工构造特征的麻烦, 也就是非常擅长 "找规律", 只要数据集充足, 选择合适的网络结构, 然后经过一番超参数调整之后, 效果都不会很差. 就是玄学炼丹.
相关资源
各个模块的使用方法可以去看 pytorch
的官方网站, 项目中使用的数据集在前面的文章里也有, 这里再贴一下, 点击下载.