Task08:文本分类|数据增强|模型微调
文本情感分类
Text classification is a common task in natural language processing, which transforms a sequence of text of indefinite length into a category of text.
This section will focus on loading data for one of the sub-questions in this field: using text sentiment classification to analyze the emotions of the text’s author. This problem is also called sentiment analysis and has a wide range of applications. For example, we can analyze user reviews of products to obtain user satisfaction statistics, or analyze user sentiments about market conditions and use it to predict future trends.
Similar to search synonyms and analogies, text classification is also a downstream application of word embedding. In this section, we will apply pre-trained word vectors (GloVe) and bidirectional recurrent neural networks with multiple hidden layers [Maas et al., 2011]. We will use the model to determine whether a text sequence of indefinite length contains positive or negative emotion.
- 后续内容将从以下几个方面展开:
- 文本情感分类数据集
- Reading the Dataset
- Tokenization and Vocabulary
- Padding to the Same Length
- Creating the Data Iterator
- 使用循环神经网络进行情感分类
- 搭建双向循环神经网络
- 加载预训练的词向量
- 训练模型
- 评价模型
- 使用卷积神经网络进行情感分类
- 文本情感分类数据集
Sentiment Analysis and the Dataset
We use Stanford’s Large Movie Review Dataset as the dataset for sentiment analysis. This dataset is divided into two datasets for training and testing purposes, each containing 25,000 movie reviews downloaded from IMDb. In each dataset, the number of comments labeled as “positive” and “negative” is equal.
-
Reading the Dataset
def read_imdb(folder='train', data_root="/home/kesci/input/IMDB2578/aclImdb_v1/aclImdb"): data = [] for label in ['pos', 'neg']: folder_name = os.path.join(data_root, folder, label) for file in tqdm(os.listdir(folder_name)): with open(os.path.join(folder_name, file), 'rb') as f: review = f.read().decode('utf-8').replace('\n', '').lower() data.append([review, 1 if label == 'pos' else 0]) random.shuffle(data) return data DATA_ROOT = "/home/kesci/input/IMDB2578/aclImdb_v1/" data_root = os.path.join(DATA_ROOT, "aclImdb") train_data, test_data = read_imdb('train', data_root), read_imdb('test', data_root) # 打印训练数据中的前五个sample for sample in train_data[:5]: print(sample[1], '\t', sample[0][:50]) >>> 1 this movie is simply incredible! i had expected so 1 this is, in my opinion, much better than either of 0 wow, i just saw this on t.v. as one of the "scary" 0 veteran director and producer allan dwan, whose hu 0 it's a bad, very bad movie.<br /><br />well, for p
-
Tokenization and Vocabulary
- 读取数据后,我们先根据文本的格式进行单词的切分,再利用
torchtext.vocab.Vocab
创建词典。
def get_tokenized_imdb(data): ''' @params: data: 数据的列表,列表中的每个元素为 [文本字符串,0/1标签] 二元组 @return: 切分词后的文本的列表,列表中的每个元素为切分后的词序列 ''' def tokenizer(text): return [tok.lower() for tok in text.split(' ')] return [tokenizer(review) for review, _ in data] def get_vocab_imdb(data): ''' @params: data: 同上 @return: 数据集上的词典,Vocab 的实例(freqs, stoi, itos) ''' tokenized_data = get_tokenized_imdb(data) counter = collections.Counter([tk for st in tokenized_data for tk in st]) return Vocab.Vocab(counter, min_freq=5) >>> vocab = get_vocab_imdb(train_data) print('# words in vocab:', len(vocab)) >>> # words in vocab: 46152
- 读取数据后,我们先根据文本的格式进行单词的切分,再利用
-
Padding to the Same Length
- 词典和词语的索引创建好后,就可以将数据集的文本从字符串的形式转换为单词下标序列的形式,以待之后的使用。
def preprocess_imdb(data, vocab): ''' @params: data: 同上,原始的读入数据 vocab: 训练集上生成的词典 @return: features: 单词下标序列,形状为 (n, max_l) 的整数张量 labels: 情感标签,形状为 (n,) 的0/1整数张量 ''' max_l = 500 # 将每条评论通过截断或者补0,使得长度变成500 def pad(x): return x[:max_l] if len(x) > max_l else x + [0] * (max_l - len(x)) tokenized_data = get_tokenized_imdb(data) features = torch.tensor([pad([vocab.stoi[word] for word in words]) for words in tokenized_data]) labels = torch.tensor([score for _, score in data]) return features, labels
-
Creating the Data Iterator
- 利用
torch.utils.data.TensorDataset
,可以创建 PyTorch 格式的数据集,从而创建数据迭代器。
train_set = Data.TensorDataset(*preprocess_imdb(train_data, vocab)) test_set = Data.TensorDataset(*preprocess_imdb(test_data, vocab)) batch_size = 64 train_iter = Data.DataLoader(train_set, batch_size, shuffle=True) test_iter = Data.DataLoader(test_set, batch_size) >>> for X, y in train_iter: print('X', X.shape, 'y', y.shape) break print('#batches:', len(train_iter)) >>> X torch.Size([64, 500]) y torch.Size([64]) #batches: 391
- 利用
Sentiment Analysis: Using Recurrent Neural Networks
-
回顾
-
在“双向循环神经网络”一节中,我们介绍了其模型与前向计算的公式,这里简单回顾一下:
给定输入序列 ${X_1,X_2,\dots,X_T}$,其中 $X_t\in\mathbb{R}^{n\times d}$ 为时间步(批量大小为 $n$,输入维度为 $d$)。在双向循环神经网络的架构中,设时间步 $t$ 上的正向隐藏状态为 $\overrightarrow{H}{t} \in \mathbb{R}^{n \times h}$ (正向隐藏状态维度为 $h$),反向隐藏状态为 $\overleftarrow{H}{t} \in \mathbb{R}^{n \times h}$ (反向隐藏状态维度为 $h$)。我们可以分别计算正向隐藏状态和反向隐藏状态:
\[\begin{aligned} &\overrightarrow{H}_{t}=\phi\left(X_{t} W_{x h}^{(f)}+\overrightarrow{H}_{t-1} W_{h h}^{(f)}+b_{h}^{(f)}\right)\\ &\overleftarrow{H}_{t}=\phi\left(X_{t} W_{x h}^{(b)}+\overleftarrow{H}_{t+1} W_{h h}^{(b)}+b_{h}^{(b)}\right) \end{aligned}\]其中权重 $W_{x h}^{(f)} \in \mathbb{R}^{d \times h}, W_{h h}^{(f)} \in \mathbb{R}^{h \times h}, W_{x h}^{(b)} \in \mathbb{R}^{d \times h}, W_{h h}^{(b)} \in \mathbb{R}^{h \times h}$ 和偏差 $b_{h}^{(f)} \in \mathbb{R}^{1 \times h}, b_{h}^{(b)} \in \mathbb{R}^{1 \times h}$ 均为模型参数,$\phi$ 为隐藏层激活函数。
然后我们连结两个方向的隐藏状态 $\overrightarrow{H}{t}$ 和 $\overleftarrow{H}{t}$ 来得到隐藏状态 $H_{t} \in \mathbb{R}^{n \times 2 h}$,并将其输入到输出层。输出层计算输出 $O_{t} \in \mathbb{R}^{n \times q}$(输出维度为 $q$):
\[O_{t}=H_{t} W_{h q}+b_{q}\]其中权重 $W_{h q} \in \mathbb{R}^{2 h \times q}$ 和偏差 $b_{q} \in \mathbb{R}^{1 \times q}$ 为输出层的模型参数。不同方向上的隐藏单元维度也可以不同。
利用
torch.nn.RNN
或torch.nn.LSTM
模组,我们可以很方便地实现双向循环神经网络,下面是以 LSTM 为例的代码。
-
-
搭建双向循环神经网络
class BiRNN(nn.Module): def __init__(self, vocab, embed_size, num_hiddens, num_layers): ''' @params: vocab: 在数据集上创建的词典,用于获取词典大小 embed_size: 嵌入维度大小 num_hiddens: 隐藏状态维度大小 num_layers: 隐藏层个数 ''' super(BiRNN, self).__init__() self.embedding = nn.Embedding(len(vocab), embed_size) # encoder-decoder framework # bidirectional设为True即得到双向循环神经网络 self.encoder = nn.LSTM(input_size=embed_size, hidden_size=num_hiddens, num_layers=num_layers, bidirectional=True) self.decoder = nn.Linear(4*num_hiddens, 2) # 初始时间步和最终时间步的隐藏状态作为全连接层输入 def forward(self, inputs): ''' @params: inputs: 词语下标序列,形状为 (batch_size, seq_len) 的整数张量 @return: outs: 对文本情感的预测,形状为 (batch_size, 2) 的张量 ''' # 因为LSTM需要将序列长度(seq_len)作为第一维,所以需要将输入转置 embeddings = self.embedding(inputs.permute(1, 0)) # embedding输出 >>> (seq_len, batch_size, embed_size) # rnn.LSTM 返回输出、隐藏状态和记忆单元,格式如 outputs, (h, c) outputs, _ = self.encoder(embeddings) # encoder输出 >>> (seq_len, batch_size, 2*hidden_size) >>> (seq_len, batch, num_directions * hidden_size) encoding = torch.cat((outputs[0], outputs[-1]), -1) # encoding形状 >>> (batch_size, 4*hidden_size) >>> 初始时间步和最终时间步的隐藏状态作为全连接层输入 outs = self.decoder(encoding) # (batch_size, 2) return outs embed_size, num_hiddens, num_layers = 100, 100, 2 net = BiRNN(vocab, embed_size, num_hiddens, num_layers)
-
加载预训练的词向量
- 由于预训练词向量的词典及词语索引与我们使用的数据集并不相同,所以需要根据目前的词典及索引的顺序来加载预训练词向量。
- Then, we will use these word vectors as feature vectors for each word in the reviews. Note that the dimensions of the pre-trained word vectors need to be consistent with the embedding layer output size
embed_size
in the created model. In addition, we no longer update these word vectors during training.
cache_dir = "/home/kesci/input/GloVe6B5429" glove_vocab = Vocab.GloVe(name='6B', dim=100, cache=cache_dir) def load_pretrained_embedding(words, pretrained_vocab): ''' @params: words: 需要加载词向量的词语列表,以 itos (index to string) 的词典形式给出 pretrained_vocab: 预训练词向量 @return: embed: 加载到的词向量 ''' embed = torch.zeros(len(words), pretrained_vocab.vectors[0].shape[0]) # 初始化为0 oov_count = 0 # out of vocabulary for i, word in enumerate(words): try: idx = pretrained_vocab.stoi[word] embed[i, :] = pretrained_vocab.vectors[idx] except KeyError: oov_count += 1 if oov_count > 0: print("There are %d oov words." % oov_count) return embed net.embedding.weight.data.copy_(load_pretrained_embedding(vocab.itos, glove_vocab)) net.embedding.weight.requires_grad = False # 直接加载预训练好的, 所以不需要更新它
-
训练模型
def evaluate_accuracy(data_iter, net, device=None): if device is None and isinstance(net, torch.nn.Module): device = list(net.parameters())[0].device acc_sum, n = 0.0, 0 with torch.no_grad(): for X, y in data_iter: if isinstance(net, torch.nn.Module): # 评估模型 net.eval() acc_sum += (net(X.to(device)).argmax(dim=1) == y.to(device)).float().sum().cpu().item() # 训练模型 net.train() else: if('is_training' in net.__code__.co_varnames): acc_sum += (net(X, is_training=False).argmax(dim=1) == y).float().sum().item() else: acc_sum += (net(X).argmax(dim=1) == y).float().sum().item() n += y.shape[0] return acc_sum / n def train(train_iter, test_iter, net, loss, optimizer, device, num_epochs): net = net.to(device) print("training on ", device) batch_count = 0 for epoch in range(num_epochs): train_l_sum, train_acc_sum, n, start = 0.0, 0.0, 0, time.time() for X, y in train_iter: X = X.to(device) y = y.to(device) y_hat = net(X) l = loss(y_hat, y) optimizer.zero_grad() l.backward() optimizer.step() train_l_sum += l.cpu().item() train_acc_sum += (y_hat.argmax(dim=1) == y).sum().cpu().item() n += y.shape[0] batch_count += 1 test_acc = evaluate_accuracy(test_iter, net) print('epoch %d, loss %.4f, train acc %.3f, test acc %.3f, time %.1f sec' % (epoch + 1, train_l_sum / batch_count, train_acc_sum / n, test_acc, time.time() - start))
-
由于嵌入层的参数是不需要在训练过程中被更新的,所以我们利用
filter
函数和lambda
表达式来过滤掉模型中不需要更新参数的部分。lr, num_epochs = 0.01, 5 optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, net.parameters()), lr=lr) loss = nn.CrossEntropyLoss() train(train_iter, test_iter, net, loss, optimizer, device, num_epochs)
-
-
评价模型
def predict_sentiment(net, vocab, sentence): ''' @params: net: 训练好的模型 vocab: 在该数据集上创建的词典,用于将给定的单词序转换为单词下标的序列,从而输入模型 sentence: 需要分析情感的文本,以单词序列的形式给出 @return: 预测的结果,positive 为正面情绪文本,negative 为负面情绪文本 ''' device = list(net.parameters())[0].device # 读取模型所在的环境 sentence = torch.tensor([vocab.stoi[word] for word in sentence], device=device) label = torch.argmax(net(sentence.view((1, -1))), dim=1) return 'positive' if label.item() == 1 else 'negative' >>> predict_sentiment(net, vocab, ['this', 'movie', 'is', 'so', 'great']) >>> 'positive'
Sentiment Analysis: Using Convolutional Neural Networks
-
一维卷积层
def corr1d(X, K): ''' @params: X: 输入,形状为 (seq_len,) 的张量 K: 卷积核,形状为 (w,) 的张量 @return: Y: 输出,形状为 (seq_len - w + 1,) 的张量 ''' w = K.shape[0] # 卷积窗口宽度 Y = torch.zeros((X.shape[0] - w + 1)) for i in range(Y.shape[0]): # 滑动窗口 Y[i] = (X[i: i + w] * K).sum() return Y >>> X, K = torch.tensor([0, 1, 2, 3, 4, 5, 6]), torch.tensor([1, 2]) print(corr1d(X, K)) >>> tensor([ 2., 5., 8., 11., 14., 17.])
-
多通道一维卷积
def corr1d_multi_in(X, K): # 首先沿着X和K的通道维遍历并计算一维互相关结果。然后将所有结果堆叠起来沿第0维累加 return torch.stack([corr1d(x, k) for x, k in zip(X, K)]).sum(dim=0) # [corr1d(X[i], K[i]) for i in range(X.shape[0])] >>> X = torch.tensor([[0, 1, 2, 3, 4, 5, 6], [1, 2, 3, 4, 5, 6, 7], [2, 3, 4, 5, 6, 7, 8]]) K = torch.tensor([[1, 2], [3, 4], [-1, -3]]) print(corr1d_multi_in(X, K)) >>> tensor([ 2., 8., 14., 20., 26., 32.])
-
由二维互相关运算的定义可知,多输入通道的一维互相关运算可以看作单输入通道的二维互相关运算
-
Max-Over-Time Pooling Layer
- Similarly, we have a one-dimensional pooling layer. The max-over-time pooling layer used in TextCNN actually corresponds to a one-dimensional global maximum pooling layer. Assuming that the input contains multiple channels, and each channel consists of values on different timesteps, the output of each channel will be the largest value of all timesteps in the channel. Therefore, the input of the max-over-time pooling layer can have different timesteps on each channel.
-
-
TextCNN 模型
-
TextCNN 模型主要使用了一维卷积层和时序最大池化层。假设输入的文本序列由 $n$ 个词组成,每个词用 $d$ 维的词向量表示。那么输入样本的宽为 $n$,输入通道数为 $d$。TextCNN 的计算主要分为以下几步。
- 定义多个一维卷积核,并使用这些卷积核对输入分别做卷积计算。宽度不同的卷积核可能会捕捉到不同个数的相邻词的相关性。
- 对输出的所有通道分别做时序最大池化,再将这些通道的池化输出值连结为向量。
- 通过全连接层将连结后的向量变换为有关各类别的输出。这一步可以使用丢弃层应对过拟合。
下图用一个例子解释了 TextCNN 的设计。这里的输入是一个有 11 个词的句子,每个词用 6 维词向量表示。因此输入序列的宽为 11,输入通道数为 6。给定 2 个一维卷积核,核宽分别为 2 和 4,输出通道数分别设为 4 和 5。因此,一维卷积计算后,4 个输出通道的宽为 11−2+1=10,而其他 5 个通道的宽为 11−4+1=8。尽管每个通道的宽不同,我们依然可以对各个通道做时序最大池化,并将 9 个通道的池化输出连结成一个 9 维向量。最终,使用全连接将 9 维向量变换为 2 维输出,即正面情感和负面情感的预测。
下面我们来实现 TextCNN 模型。与上一节相比,除了用一维卷积层替换循环神经网络外,这里我们还使用了两个嵌入层,一个的权重固定,另一个则参与训练。
class TextCNN(nn.Module): def __init__(self, vocab, embed_size, kernel_sizes, num_channels): ''' @params: vocab: 在数据集上创建的词典,用于获取词典大小 embed_size: 嵌入维度大小 kernel_sizes: 卷积核大小列表 num_channels: 卷积通道数列表 ''' super(TextCNN, self).__init__() self.embedding = nn.Embedding(len(vocab), embed_size) # 参与训练的嵌入层 self.constant_embedding = nn.Embedding(len(vocab), embed_size) # 不参与训练的嵌入层 self.pool = GlobalMaxPool1d() # 时序最大池化层没有权重,所以可以共用一个实例 self.convs = nn.ModuleList() # 创建多个一维卷积层 for c, k in zip(num_channels, kernel_sizes): self.convs.append(nn.Conv1d(in_channels = 2*embed_size, out_channels = c, kernel_size = k)) self.decoder = nn.Linear(sum(num_channels), 2) self.dropout = nn.Dropout(0.5) # 丢弃层用于防止过拟合 def forward(self, inputs): ''' @params: inputs: 词语下标序列,形状为 (batch_size, seq_len) 的整数张量 @return: outputs: 对文本情感的预测,形状为 (batch_size, 2) 的张量 ''' embeddings = torch.cat(( self.embedding(inputs), self.constant_embedding(inputs)), dim=2) # (batch_size, seq_len, 2*embed_size) # 根据一维卷积层要求的输入格式,需要将张量进行转置 embeddings = embeddings.permute(0, 2, 1) # (batch_size, 2*embed_size, seq_len) encoding = torch.cat([ self.pool(F.relu(conv(embeddings))).squeeze(-1) for conv in self.convs], dim=1) # encoding = [] # for conv in self.convs: # out = conv(embeddings) # (batch_size, out_channels, seq_len-kernel_size+1) # out = self.pool(F.relu(out)) # (batch_size, out_channels, 1) # encoding.append(out.squeeze(-1)) # (batch_size, out_channels) # encoding = torch.cat(encoding) # (batch_size, out_channels_sum) # 应用丢弃法后使用全连接层得到输出 outputs = self.decoder(self.dropout(encoding)) return outputs embed_size, kernel_sizes, nums_channels = 100, [3, 4, 5], [100, 100, 100] net = TextCNN(vocab, embed_size, kernel_sizes, nums_channels)
-
数据增强
图像增广(image augmentation)
-
图像增广(image augmentation)技术通过对训练图像做一系列随机改变,来产生相似但又不同的训练样本,从而扩大训练数据集的规模。图像增广的另一种解释是,随机改变训练样本可以降低模型对某些属性的依赖,从而提高模型的泛化能力。例如,我们可以对图像进行不同方式的裁剪,使感兴趣的物体出现在不同位置,从而减轻模型对物体出现位置的依赖性。我们也可以调整亮度、色彩等因素来降低模型对色彩的敏感度。可以说,在当年AlexNet的成功中,图像增广技术功不可没。
-
翻转和裁剪:
torchvision.transforms.RandomHorizontalFlip()
torchvision.transforms.RandomVerticalFlip()
torchvision.transforms.RandomResizedCrop()
-
变化颜色:亮度(brightness)、对比度(contrast)、饱和度(saturation)和色调(hue)
torchvision.transforms.ColorJitter(brightness=0.5, contrast=0, saturation=0, hue=0)
-
模型微调(Fine Tuning)
假设我们想从图像中识别出不同种类的椅子,然后将购买链接推荐给用户。一种可能的方法是先找出100种常见的椅子,为每种椅子拍摄1,000张不同角度的图像,然后在收集到的图像数据集上训练一个分类模型。这个椅子数据集虽然可能比Fashion-MNIST数据集(6万张图像)要庞大,但样本数仍然不及ImageNet数据集(超过1,000万的图像和1,000类的物体)中样本数的十分之一。这可能会导致适用于ImageNet数据集的复杂模型在这个椅子数据集上过拟合。同时,因为数据量有限,最终训练得到的模型的精度也可能达不到实用的要求。
-
为了应对上述问题,一个显而易见的解决办法是收集更多的数据。然而,收集和标注数据会花费大量的时间和资金。例如,为了收集ImageNet数据集,研究人员花费了数百万美元的研究经费。虽然目前的数据采集成本已降低了不少,但其成本仍然不可忽略。
-
另外一种解决办法是应用迁移学习(transfer learning),将从源数据集学到的知识迁移到目标数据集上。例如,虽然ImageNet数据集的图像大多跟椅子无关,但在该数据集上训练的模型可以抽取较通用的图像特征,从而能够帮助识别边缘、纹理、形状和物体组成等。这些类似的特征对于识别椅子也可能同样有效。
本节我们介绍迁移学习中的一种常用技术:微调(fine tuning)。
- 微调由以下4步构成:
- 在源数据集(如ImageNet数据集)上预训练一个神经网络模型,即源模型。
- 创建一个新的神经网络模型,即目标模型。它复制了源模型上除了输出层外的所有模型设计及其参数。我们假设这些模型参数包含了源数据集上学习到的知识,且这些知识同样适用于目标数据集。我们还假设源模型的输出层跟源数据集的标签紧密相关,因此在目标模型中不予采用。
- 为目标模型添加一个输出大小为目标数据集类别个数的输出层,并随机初始化该层的模型参数。
- 在目标数据集(如椅子数据集)上训练目标模型。我们将从头训练输出层,而其余层的参数都是基于源模型的参数微调得到的。
-
当目标数据集远小于源数据集时,微调有助于提升模型的泛化能力。
-
注意⚠️
-
在使用预训练模型时,一定要和预训练时作同样的预处理
-
如果你使用的是其他模型,那可能没有成员变量
fc
(比如models中的VGG预训练模型),所以正确做法是查看对应模型源码中其定义部分,这样既不会出错也能加深我们对模型的理解。pretrained-models.pytorch
仓库貌似统一了接口,但是我还是建议使用时查看一下对应模型的源码。# 修改 pretrained_net 模型中最后的输出层 fc # 原输出层 >>> Linear(in_features=512, out_features=1000, bias=True) pretrained_net.fc = nn.Linear(512, 2) # 输出层修改后 >>> Linear(in_features=512, out_features=2, bias=True) # 即将最后的fc成修改我们需要的输出类别数
-
此时,
pretrained_net
的fc
层就被随机初始化了,但是其他层依然保存着预训练得到的参数。由于是在很大的ImageNet数据集上预训练的,所以参数已经足够好,因此一般只需使用较小的学习率来微调这些参数,而fc
中的随机初始化参数一般需要更大的学习率从头训练。PyTorch可以方便的对模型的不同部分设置不同的学习参数,我们在下面代码中将fc
的学习率设为已经预训练过的部分的10倍。# fc层参数 output_params = list(map(id, pretrained_net.fc.parameters())) # 其他层参数 feature_params = filter(lambda p: id(p) not in output_params, pretrained_net.parameters()) lr = 0.01 # 较小的学习率 # 对模型的不同部分设置不同的学习参数 optimizer = optim.SGD([{'params': feature_params}, {'params': pretrained_net.fc.parameters(), 'lr': lr * 10}], lr=lr, weight_decay=0.001) # 微调 >>> epoch 1, loss 3.4516, train acc 0.687, test acc 0.884, time 298.2 sec epoch 2, loss 0.1550, train acc 0.924, test acc 0.895, time 296.2 sec epoch 3, loss 0.1028, train acc 0.903, test acc 0.950, time 295.0 sec epoch 4, loss 0.0495, train acc 0.931, test acc 0.897, time 294.0 sec epoch 5, loss 0.1454, train acc 0.878, test acc 0.939, time 291.0 sec # 全部重新训 >>> scratch_net = models.resnet18(pretrained=False, num_classes=2) lr = 0.1 optimizer = optim.SGD(scratch_net.parameters(), lr=lr, weight_decay=0.001) train_fine_tuning(scratch_net, optimizer) >>> epoch 1, loss 2.6391, train acc 0.598, test acc 0.734, time 292.4 sec epoch 2, loss 0.2703, train acc 0.790, test acc 0.632, time 289.7 sec epoch 3, loss 0.1584, train acc 0.810, test acc 0.825, time 290.2 sec epoch 4, loss 0.1177, train acc 0.805, test acc 0.787, time 288.6 sec epoch 5, loss 0.0782, train acc 0.829, test acc 0.828, time 289.8 sec