libtorch系列教程3:优雅地训练MNIST分类模型
在这篇文章中,我们对如何使用Libtorch进行MNIST分类模型的训练和测试进行详细描述。首先会浏览官方MNIST示例,然后对其进行模块化重构,为后续别的模型的训练提供 codebase。
由于Libtorch中包含很多和Pytorch中没有的类型,所以看Libtorch代码的时候时常会遇到不了解的函数或者类,这时候可以在这里查找对应的类的实现,了解其作用。Libtorch C++ 代码中的注释虽然不多但基本够用了。
这里列举一些常见的类的代码路径,方便查询:
- Datasets: https://github.com/pytorch/pytorch/blob/main/torch/csrc/api/include/torch/data/datasets/base.h
- DataLoader:https://github.com/pytorch/pytorch/tree/main/torch/csrc/api/include/torch/data/dataloader/base.h
- MNIST: https://github.com/pytorch/pytorch/blob/main/torch/csrc/api/include/torch/data/datasets/mnist.h
- Stack: https://github.com/pytorch/pytorch/blob/main/torch/csrc/api/include/torch/data/transforms/stack.h
- RandomSampler: https://github.com/pytorch/pytorch/tree/main/torch/csrc/api/src/data/samplers/random.cpp
- SequentialSampler: https://github.com/pytorch/pytorch/tree/main/torch/csrc/api/src/data/samplers/sequential.cpp
1. 官方MNIST示例
Libtorch官方的训练代码仓库在这里,拿里面的训练MNIST为例,代码如下: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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
// Where to find the MNIST dataset.
const char* kDataRoot = "./data";
// The batch size for training.
const int64_t kTrainBatchSize = 64;
// The batch size for testing.
const int64_t kTestBatchSize = 1000;
// The number of epochs to train.
const int64_t kNumberOfEpochs = 10;
// After how many batches to log a new update with the loss value.
const int64_t kLogInterval = 10;
struct Net : torch::nn::Module {
Net()
: conv1(torch::nn::Conv2dOptions(1, 10, /*kernel_size=*/5)),
conv2(torch::nn::Conv2dOptions(10, 20, /*kernel_size=*/5)),
fc1(320, 50),
fc2(50, 10) {
register_module("conv1", conv1);
register_module("conv2", conv2);
register_module("conv2_drop", conv2_drop);
register_module("fc1", fc1);
register_module("fc2", fc2);
}
torch::Tensor forward(torch::Tensor x) {
x = torch::relu(torch::max_pool2d(conv1->forward(x), 2));
x = torch::relu(
torch::max_pool2d(conv2_drop->forward(conv2->forward(x)), 2));
x = x.view({-1, 320});
x = torch::relu(fc1->forward(x));
x = torch::dropout(x, /*p=*/0.5, /*training=*/is_training());
x = fc2->forward(x);
return torch::log_softmax(x, /*dim=*/1);
}
torch::nn::Conv2d conv1;
torch::nn::Conv2d conv2;
torch::nn::Dropout2d conv2_drop;
torch::nn::Linear fc1;
torch::nn::Linear fc2;
};
template <typename DataLoader>
void train(
size_t epoch,
Net& model,
torch::Device device,
DataLoader& data_loader,
torch::optim::Optimizer& optimizer,
size_t dataset_size) {
model.train();
size_t batch_idx = 0;
for (auto& batch : data_loader) {
auto data = batch.data.to(device), targets = batch.target.to(device);
optimizer.zero_grad();
auto output = model.forward(data);
auto loss = torch::nll_loss(output, targets);
AT_ASSERT(!std::isnan(loss.template item<float>()));
loss.backward();
optimizer.step();
if (batch_idx++ % kLogInterval == 0) {
std::printf(
"\rTrain Epoch: %ld [%5ld/%5ld] Loss: %.4f",
epoch,
batch_idx * batch.data.size(0),
dataset_size,
loss.template item<float>());
}
}
}
template <typename DataLoader>
void test(
Net& model,
torch::Device device,
DataLoader& data_loader,
size_t dataset_size) {
torch::NoGradGuard no_grad;
model.eval();
double test_loss = 0;
int32_t correct = 0;
for (const auto& batch : data_loader) {
auto data = batch.data.to(device), targets = batch.target.to(device);
auto output = model.forward(data);
test_loss += torch::nll_loss(
output,
targets,
/*weight=*/{},
torch::Reduction::Sum)
.template item<float>();
auto pred = output.argmax(1);
correct += pred.eq(targets).sum().template item<int64_t>();
}
test_loss /= dataset_size;
std::printf(
"\nTest set: Average loss: %.4f | Accuracy: %.3f\n",
test_loss,
static_cast<double>(correct) / dataset_size);
}
auto main() -> int {
torch::manual_seed(1);
torch::DeviceType device_type;
if (torch::cuda::is_available()) {
std::cout << "CUDA available! Training on GPU." << std::endl;
device_type = torch::kCUDA;
} else {
std::cout << "Training on CPU." << std::endl;
device_type = torch::kCPU;
}
torch::Device device(device_type);
Net model;
model.to(device);
auto train_dataset = torch::data::datasets::MNIST(kDataRoot)
.map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
.map(torch::data::transforms::Stack<>());
const size_t train_dataset_size = train_dataset.size().value();
auto train_loader =
torch::data::make_data_loader<torch::data::samplers::SequentialSampler>(
std::move(train_dataset), kTrainBatchSize);
auto test_dataset = torch::data::datasets::MNIST(
kDataRoot, torch::data::datasets::MNIST::Mode::kTest)
.map(torch::data::transforms::Normalize<>(0.1307, 0.3081))
.map(torch::data::transforms::Stack<>());
const size_t test_dataset_size = test_dataset.size().value();
auto test_loader =
torch::data::make_data_loader(std::move(test_dataset), kTestBatchSize);
torch::optim::SGD optimizer(
model.parameters(), torch::optim::SGDOptions(0.01).momentum(0.5));
for (size_t epoch = 1; epoch <= kNumberOfEpochs; ++epoch) {
train(epoch, model, device, *train_loader, optimizer, train_dataset_size);
test(model, device, *test_loader, test_dataset_size);
}
}
代码具体细节可以先不用理解,后文有一些说明。可以看到所有的模型搭建、数据读取、网络训练和测试代码都混在一个文件里面,别的几个例子里面也是类似的写法。
这样写当然是可以的,但对于习惯了Pytorch训练的我们来说,这样所有的代码在一个文件中的写法很不易读,
修改数据和网络都相互有影响,且不利用真正严肃地模型训练迭代。
2. 重构 MNIST 示例代码
所以一个简单的想法是改进写法,将DataLoader, Model 和训练逻辑拆分出来,分别进行模块化,放到单独的文件中处理。
2.1 简单拆分的问题
第一次尝试是将Dataset和DataLoader放到一个模块中,网络定义放到一个模块中,训练和测试代码放到一个模块中。
但这样拆分遇到很大问题,核心原因是 Libtorch 的DataLoader类别太复杂了,对于我这种C++了解不深入的人来说改造难度太大。
举个例子,我们对MNIST Dataset类进行Normalize后Stack,然后构造一个DataLoader对象train_loader
,代码如下:
1 | auto train_dataset = torch::data::datasets::MNIST(data_root) |
生成的train_loader
对象的类型是:
1 | torch::disable_if_t<MapDataset<MapDataset<MNIST, Normalize<>>, Stack<>>::is_stateful || !std::is_constructible<SequentialSampler, size_t>::value, std::unique_ptr<StatelessDataLoader<MapDataset<MapDataset<MNIST, Normalize<>>, Stack<>>, SequentialSampler>>> |
这个类型太复杂了……
因为官方示例是所有代码在一个文件,因此可以通过auto
来让编译器自动判定类型,省去了写着一长串类型的问题。
但如果我们要拆分DataLoader到单独的类里面的话,就没法使用auto
,需要显式的指出DataLoader的类型,然而即使是这样一长串的类型写上了,还是会有不知道是哪里的问题,导致编译报错。
当然也有可能有简单的方法来解决这个问题,欢迎C++高手讨论指导。
这次体验让我真正体会到了动态类型语言的简洁性,以及Python的所有类型转C++会存在哪些坑。
2.2 一种比较简单的重构方案
最后给出了一个妥协的方案:DataSet在单独的类中定义里面,而DataLoader在训练逻辑中构造,避免繁琐的类型问题。
整体代码结构如下:
1 | ├── CMakeLists.txt # CMake配置文件 |
2.2.1 CMake 配置文件
CMake 配置文件CMakeLists.txt
中将几个实现文件加入到编译依赖即可,别的部分与前两篇文章中的类似。
1 | cmake_minimum_required(VERSION 3.0 FATAL_ERROR) |
2.2.2 主入口文件定义
主入口文件实现了超参数设置,网络和数据集初始化,以及调用Trainer进行训练和测试:
1 |
|
2.2.3 网络定义
网络结构采用简单的LeNet,两个conv层和2个fc层。
头文件 my_model.h 内容:
1 |
|
实现文件 my_model.cpp:
1 |
|
可以看到网络的定义还是比较简单直接,可以直接从Python 网络定义迁移过去,几个核心点:
- 网络类的定义需要继承
torch::nn::Module
类 - 实现
forward
函数来进行网络前项运算,其中每个层需要显式地调用forward
函数
2.2.4 数据集定义
由于 Libtorch 自带 MNIST的实现,我们这里只是做了一个简单的封装,作为模块化的例子。
头文件my_dataset.h
内容:
1 |
|
实现文件my_dataset.cpp
内容:
1 |
|
这里有一个需要注意的点,由于MNIST类本身没有默认构造函数,所以在MyDataset
类的初始化列表中就必须给成员变量mnist_dataset
赋值,否则会报下面的错:
1 | constructor for 'MyDataset' must explicitly initialize the member 'mnist_dataset' which does not have a default constructor |
2.2.5 Trainer定义
Trainer 包含训练和测试的两个函数,对数据和网络,优化器等输入进行计算,得到输出,计算loss和准确率。
头文件my_trainer.h
内容:
1 |
|
实现文件my_trainer.cpp
内容:
1 |
|
2.2.6 编译和运行方式
我们基于CMake 编译上面的代码,同时下载MNIST数据集,完整的执行命令如下:
1 | mkdir build |
训练和测试输出如下:
1 | Train Epoch: 1 [59008/60000] Loss: 0.6824 |
可以看到准确率在逐渐提升。
这篇文章的内容主要就是这些,后面会根据训练一个实际一些的例子,比如nanoGPT,将在本文的codebase基础上,主要覆盖下面的内容:
- 自定义数据集的Dataset类的搭建
- 复杂网络的定义(如ResNet, Transformer)
- 模型checkpoint的保存和读取
欢迎点赞和关注!