前言
本示例主要包含以下几个主要部分:
- 下载 Cifar10 数据集
- 将 Images 写入 lmdbs
- 定义并且训练 model
- 保存训练好的 model
- 加载训练好的 model
- 在 testing imdb 上面执行 inference
- 继续训练以提高 test accuracy
- 测试 retrained model
项目地址: https://github.com/caffe2/tutorials
导入必要的包
1 | from __future__ import absolute_import |
下载并解压数据集
1 | import requests |
接下来看一看数据集中的一些示例:1
2
3
4
5
6
7
8
9
10
11
12
13import glob
from skimage import io
# Grab 5 image paths from training set to display
sample_imgs = glob.glob(os.path.join(data_folder, "cifar", "train") + '/*.png')[:5]
# Plot images
f, ax = plt.subplots(1, 5, figsize=(10,10))
plt.tight_layout()
for i in range(5):
ax[i].set_title(sample_imgs[i].split("_")[-1].split(".")[0])
ax[i].axis('off')
ax[i].imshow(io.imread(sample_imgs[i]).astype(np.uint8))
创建 LMDBs 数据结构
现在我们已经拥有了数据, 接下来需要写入LMDBs用于训练, 验证和测试. 为了根据每个类别来分离图片, 下面将会使用到一个经常在caffe框架中使用的技术, 创建label files. 具体来说, label files就是将每个.png图片对应到它的类别上面去:
/path/to/im1.png 7
/path/to/im2.png 3
/path/to/im3.png 5
/path/to/im4.png 0
…
根据不同的数据集, 创建labels的方式有些许区别
1 | # Paths to train and test directories |
输出如下:
1 | classes: { |
既然现在我们已经拥有了从类别字符串名到数字的映射关系, 我们就可以创建用于训练, 验证和推演的标签文件了. 我们会将数据分成以下三个部分:
- training: 44000 images (73%)
- validation: 6000 images (10%)
- testing: 10000 image (17%)
注意到我们的验证数据集仅仅只是训练数据集的一个子集, 我们这么做是为了查看我们当前的模型在面对没有见过的数据集时的表现怎么样. 为了得到分布相对均匀的训练集合验证集, 我们首先读取所有的图片(路径), 将其随机打乱, 然后拆分成数据集和验证集. 代码如下:
1 | from random import shuffle |
现在, 我们已经可以开始用这些创建好的标签文件来构建我们的 LMDBs 数据集了. 下面的代码采纳自 Caffe2 的 lmdb_create_example.py 脚本. 请注意在将数据送入 LMDB 之前, 首先要将图片的颜色通道从 RGB 变成 BGR, 并且要将图片矩阵从 HWC 变成 CHW. 另外, Caffe2 接受的输入格式为 NCHW, 这里的 N 代表 batch-size.
1 | def write_lmdb(labels_file_path, lmdb_path): |
定义 CNN 模型
既然我们已经将数据存储成了 LMDBs 的格式, 那么是时候建立相应的网络模型了! 首先, 设置一些路径变量, 定义数据集的具体参数, 同时定义训练参数.
1 | # caffe2 的 init 网络 和 predict 网络的位置 |
创建相应的目录, 然后将该目录设置为工作目录.
1 | # root_folder = /home/zerozone/caffe2_notebooks/tutorial_files/tutorial_cifar10 |
AddInput() 数据输入层
下面的任务是利用 helper functions 来模块化我们的代码, 最终使其构成相应的网络模型. 我们将会使用 ModelHelper 类来定义和表示我们的模型, 同时会包含相应的参数信息. 我们将利用 brew 模块来向我们的模型中添加相应的网络层.
需要注意的是, 调用上面的代码并不会使模型执行任何实际上的计算, 相反的, 我们是在搭建一个计算操作的图结构(graph of operators), 这个图最终指明了当数据传入时, 网络应该进行哪些 forward 和 backward 计算.
第一个 helper function 是AddInput
, 它向我们的网络添加了输入(数据)层. 注意到, 图片已经存储到了我们的 LMDBs 数据结构中, 因此在将其送入网络模型之前, 还需要一些简单的预处理操作. 第一, 我们先从 LMDB 中读取 raw image data 和 labels, 它们的类型都是 uint8([0,255] pixel values). 接着我们将数据转换成浮点类型, 同时将图片的像素值放缩到 [0,1] 之间, 使其在训练时可以更快收敛. 最后, 我们会调用model.StopGradient(data, data)
来组织在 backward 过程中计算关于数据的梯度(因为我们只需要关于参数的梯度). 最后, 是一些关于引号中的名称含义:
- “data_unint8” 和 “label” 代表着与 DB 输入数据相关的 blobs 的名字
- 如果名字是 input blob, 则代表当 operator 运行时的 blob_name
- 如果名字是 output blob, 则代表是某个 operator 创建的 blob 的名字.
1 | def AddInput(model, batch_size, db, db_dtype): |
根据后文的调用语句:1
2
3
4
5data, label = AddInput(
train_model_zz, batch_size=training_net_batch_size, # 100
db=training_lmdb_path, # /home/zerozone/caffe2_notebooks/tutorial_data/cifar10/training_lmdb
db_type="lmdb"
)
调用后, 模型中会增加四个新的 op
(原本模型中的 op
为 0 个), 如下所示: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
38name: "train_net"
op {
input: "dbreader_/home/zerozone/caffe2_notebooks/tutorial_data/cifar10/training_lmdb"
output: "data_uint8"
output: "label"
name: ""
type: "TensorProtosDBInput"
arg {
name: "batch_size"
i: 100
}
}
op {
input: "data_uint8"
output: "data"
name: ""
type: "Cast"
arg {
name: "to"
i: 1
}
}
op {
input: "data"
output: "data"
name: ""
type: "Scale"
arg {
name: "scale"
f: 0.00390625
}
}
op {
input: "data"
output: "data"
name: ""
type: "StopGradient"
}
Add_Original_CIFAR10_Model() 模型核心
处理好输入层以后, 接下来实现 CNN 模型的定义. 本教程使用的网络模型拥有三层卷积层和池化层, 并且使用 ReLU 激活函数. 我们将会使用 update_dims
函数来帮助我们根据卷积层和池化层造成的维度的降低程度. 维度会以下列公式为依据发生变化:
尽管使用 update_dims
函数不是必须的, 但我们发现这是一种避免手动计算维度变化的很不错的策略选择, 尤其是在决定全连接层的参数的时候.
1 | # 辅助计算维度变化的函数 |
后文在调用该函数时的代码如下所示:1
2
3
4softmax = Add_Original_CIFAR10_Model(
train_model, data, num_classes
image_height, image_width, image_channels
)
由于此处涉及的 op 过多, 并且大部分都是重复的, 因此这里我们仅仅贴出网络中 conv1, pool1, relu1 的 op, 其他网络层的 op 类似.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
67op {
input: "data"
input: "conv1_w"
input: "conv1_b"
output: "conv1"
name: ""
type: "Conv"
arg {
name: "exhaustive_search"
i: 0
}
arg {
name: "stride"
i: 1
}
arg {
name: "order"
s: "NCHW"
}
arg {
name: "pad"
i: 2
}
arg {
name: "kernel"
i: 5
}
engine: "CUDNN"
}
op {
input: "conv1"
output: "pool1"
name: ""
type: "MaxPool"
arg {
name: "kernel"
i: 3
}
arg {
name: "cudnn_exhaustive_search"
i: 0
}
arg {
name: "stride"
i: 2
}
arg {
name: "order"
s: "NCHW"
}
engine: "CUDNN"
}
op {
input: "pool1"
output: "relu1"
name: ""
type: "Relu"
arg {
name: "cudnn_exhaustive_search"
i: 0
}
arg {
name: "order"
s: "NCHW"
}
engine: "CUDNN"
}
AddTrainingOperators() 损失函数及优化器
我们的下一个辅助函数是 AddTrainingOperators()
. 这个函数将会被我们的训练模型所调用, 用于添加相应的损失函数和优化机制. 我们将会在模型的 softmax
输出和图片的真实标签上使用平均交叉熵来计算模型的损失. 然后我们会添加一个计算损失函数梯度的 operators 到模型中. 最终, 我们会使用 Caffe2 中 optimizer
类的 build_sgd
函数作为我们的优化函数.
1 | def AddTrainingOperators(model, softmax, label): |
后文的调用语句如下:1
AddTrainingOperators(train_model, softmax, label)
此处涉及到的 op 也非常的多, 主要包含了计算之前每一个操作的梯度, 这里我们也只简单的列出一些 op 作为示例:
1 | op { |
AddAccuracy() 模型准确率
下面的函数用于计算我们当前模型的 top-1 准确率.
1 | def AddAccuracy(model, softmax, label): |
后文的调用语句如下:1
AddAccuracy(val_model, softmax, label)
本函数增加了一个新的 op, 如下所示:1
2
3
4
5
6
7op {
input: "softmax"
input: "label"
output: "accuracy"
name: ""
type: "Accuracy"
}
检查点
下面的函数会在每经过一定次数的迭代之后输出一个 checkpoint db. 一个 checkpoint 本质上来说是模型在训练过程中的一种保存成文件的模型状态. checkpoint 在快速加载训练好或者训练中的模型时非常有用, 并且它们能够在长时间的模型训练的过程中提高有力的安全保障. Caffe2 的 checkpoint 文件类似于 Caffe 的定期输出的 .caffemodel 文件. 我们利用 brew
和 iter
operator 来跟踪迭代次数, 并把它们保存成 LMDBs 的形式.
在使用 checkpoints 的时候, 你必须时刻注意在训练过程是否将之前同名检查点给覆盖掉. 如果你尝试覆盖一个 checkpoint db, 那么将会产生报错. 为了解决这种情况, 我们将会把 checkpoints 以一种唯一的命名字典保存在我们的 root_folder
下面. 这个字典的名字会基于当前的系统时间戳, 以避免出现重复.
1 | import datetime |
利用 ModelHelper 初始化模型
现在既然我们已经创建了必要的辅助函数, 那么是时候初始化模型, 同时使用相应的函数来定义模型的计算图(operator graphs). 请记住此时我们还没有执行模型.
首先, 定义 train model:
- 用 ModelHelper class 初始化模型
- 利用
AddInput()
函数添加数据输入层 - 添加 Cifar10 模型, 该模型返回一个 softmax blob
- 利用
AddTrainingOperators()
函数添加 training operators, 此处需要使用第三步中的 softmax blob - 利用
AddCheckpoints()
函数添加定时的 checkpoints.
下面, 我们来定义 validation model, 该模型在结构上与 training model 是相同的, 但是其数据来源于另一个 LMDB 文件, 并且使用了不同的 batch size. 具体定义如下:
- 利用 ModelHelper class 对模型进行初始化(需要将参数
init_params
设置为False
); - 利用
AddInput()
函数添加数据输入层; - 添加 Cifar10 模型, 该模型返回一个 softmax blob;
- 利用
AddAccuracy()
函数添加 accuracy layer, 此处需要使用第三步中的 softmax blob;
最后, 我们定义 deploy model 如下:
- 利用 ModelHelper class 初始化模型(需令
init_params=False
); - 添加 Cifar10 模型, 以 “data” 作为模型输入 input blob.
1 | arg_scope = {"order": "NCHW"} # 字典, 在 ModelHelper 中, arg_scope["order"] 的默认值即为 "NCHW", 因此该语句可省略 |
开始训练
最后, 既然我们已经拥有了模型, 并且定义了相应的 operator graphs, 那么就应该开始真正意义上的执行 training process 了. Under the hood, 我们已经定义将我们的模型以 operator graphs 的形式定义, 并且序列化成了 protobuf 格式. 最后一步就是要将这些 protobufs 送入 Caffe2 的 C++ 后端, 以便建立模型并执行它. 还记得 ModelHelper 模型对象拥有的两个网络吗:
- param_init_net: 包含参数和初始数据
- net: 包含定义好的网络结构(operator graph)
以上的两个网络都需要执行, 并且我们必须先执行 param_init_net 网络, 注意, 该网络只需要被执行一次, 因此我们利用 workspace.RunNetOnce()
函数来执行, 这个函数会经过一个实例化, 运行, 然后销毁网络的过程. 如果我们需要多次运行某个网络, 就像我们需要多次运行 training nets 和 validation nets 一样, 那么我们必须先利用 workspace.CreateNet()
手动创建网络, 然后利用多次调用 workspace.RunNet()
的方式达到多次运行网络的目的, 从这里我们也就可以看出 RunNetOnce
和 RunNet
之间的区别了, 简单来说, 前者会在执行之前自动创建网络, 而后者只能运行已经创建好的网络, 也正是因为这样, 后者的执行速度要比前者快.
当我们利用 workspace.RunNet()
执行 train_model 以后, 这会在一个 batch 的数据上执行 forward 和 backward 过程, 而在执行 val_model 时, 只会执行 forward 过程(同时会计算准确率).
1 | import math |
下面的代码用于将训练过程中的 accuracy 和 loss 绘制出来, 以方便我们观察和分析.
1 | plt.title("Training loss vs. Validation Accuracy") |
保存训练好的模型
当我们在 workspace 中训练好模型的参数以后, 我们就可以利用 mobile_exporter
来导出部署模型. 在 Caffe2 中, 预训练的模型通常情况下会存储成两个单独的 protobuf(.pb) 文件(inti_net 和 predict_net). 模型也可以被存储为 db 格式, 但是最好存储成 pb 格式, 因为这种格式在社区中更流行. 为了保持一致性, 我们将这些参数保存在相同的 checkpoints 的唯一目录下.
1 | # 初始化并创建 deploy 网络 |
利用训练好的模型进行推演
在保存好模型以后, 我们试着来使用这个模型进行推演, 首先定义一些路径相关变量, 方便我们加载特定的模型.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22# Train lmdb, 定义训练集的 lmdb路径
TRAIN_LMDB = os.path.join(os.path.expanduser('~'),"caffe2_notebooks/tutorial_data/cifar10/training_lmdb")
# Test lmdb, 定义验证集的 lmdb 路径
TEST_LMDB = os.path.join(os.path.expanduser('~'),"caffe2_notebooks/tutorial_data/cifar10/testing_lmdb")
# 提起保存好的 protobuf 文件.
part1_runs_path = os.path.join(os.path.expanduser('~'), "caffe2_notebooks", "tutorial_files", "tutorial_cifar10")
# runs 为一个列表, 列表的长度跟路径中 checkpoint 的个数有关, 按日期排序, 日期大的在后面
runs = sorted(glob.glob(part1_runs_path + "/*"))
# Init net
INIT_NET = os.path.join(runs[-1], "cifar10_init_net.pb")
# Predict net
PREDICT_NET = os.path.join(runs[-1], "cifar10_predict_net.pb")
# Make sure they all exist
if (not os.path.exists(TRAIN_LMDB)) or (not os.path.exists(TEST_LMDB)) or (not os.path.exists(INIT_NET)) or (not os.path.exists(PREDICT_NET)):
print("ERROR: input not found!")
else:
print("Success, you may continue!")
构建 Testing model
1 | # 利用 ModelHelper 创建 testing model |
下面我们使用保存好的模型参数来填充(populate) Model Helper. 想要构建用于 testing 的模型, 我们不需要在 model helper 重新创建 params, 同样也不需要添加 gradient operators, 因为我们仅仅执行 forward pass. 我们只需要利用 predict_net.pb 和 init_net.pb 文件中的参数填充 testing model 的 .net
和 .param_init_net
成员即可. 为此, 我们需要从 pb 文件中创建 caffe2_pb
对象, 然后再利用 caffe2_pb
对象创建 Net
对象, 接着将 net
对象 添加(append) 到 .net
和 .param_init
成员中即可. 请注意, 这里的添加操作非常重要, 如果没有执行 append, 我们将会抹去刚刚添加的输入层.
会想上面的程序, 保存好的模型需要一个名为 “data” 输入, 同时会产生一个名为 “softmax” 的输出. Conveniently(but not accidentally), 输入层函数(AddInputLayer()
)会从 lmdb 中读取数据, 并且将读取到的信息以 “data” 命名的 blob 存入 workspace 当中. 同样重要的是要记住我们添加到模型中的每个保存的网络中包含着什么. predict_net
包含着模型的结构, 包括 forward pass 的相关 ops. 而 init_net
包含着 predict_net
中的 ops 所需的参数的权重信息. 举例来说, 如果在 predict_net
中存在一个名为 fc1
的 op, 那么 init_net
就会包含这个全连接层的权重矩阵和偏置项 (fc1_w)
和 (fc1_b)
.
当我们 append 网络以后, 我们添加了一个 accuracy 层到模型中, 它是利用网络输出的 softmax
以及数据的真实标签来计算 accuracy
的. 最终, 我们可以利用 workspace.FetchBlob()
来手动的获取指定 blob 的值.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 用 init net 来填充(populate) model helper 对象, 用于给模型提供权重参数
init_net_proto = caffe2_pb2.NetDef()
with open(INIT_NET, "rb") as f:
init_net_proto.ParseFromString(f.read())
test_model.param_init_net = test_model.param_init_net.AppendNet(
core.Net(init_net_proto)
)
# 用 predict net 来添加模型的 net, 用于定义模型的结构
predict_net_proto = caffe2_pb2.NetDef()
with open(PREDICT_NET, "rb") as f:
predict_net_proto.ParseFromString(f.read())
test_model.net = test_model.net.AppendNet(core.Net(predict_net_proto))
# 添加 accuracy, 三个参数分别为: model, blobs_in, blobs_out
accuracy = brew.accuracy(test_model, ['softmax', 'label'], 'accuracy')
接下来, 我们来运行 testing model 以查看结果.
1 | # 先利用 init_net 初始化 test_model, 然后再创建该模型 |
输出结果如下:
1 | Iter: 500, Current Accuracy: 0.654 |
利用 checkpoint 对模型进行再训练
从上面的结果我们可以看出, 我们模型在精度上虽然比随机猜测要高一些, 但是如果能够进行更多次迭代的训练, 模型的精度应该还会进一步提高, 为此, 我们需要进行以下步骤:
- 创建一个新的 model helper
- 指定训练数据集和 training imdb
- 利用
Add_Original_CIFAR10_Model()
重新定义模型的结构. - 从之前保存的 init_net.pb 文件中获取相应的参数权重
- 继续训练
下面我们新创建了一个 model helper 对象用于训练, 请注意要将 init_params
参数设置为 False
. 这一点非常重要, 因为我们不希望 brew 自动初始化参数, 而是从文件中加载权重(也就是我们自己来设置这些参数的值). 一旦我们创建了 model helper, 我们将会添加 input layer 并将其指向 training lmdb.
1 | # 定义训练的迭代次数 |
下面的代码指定了损失函数和优化器, 和之前的代码没什么不同.1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16# 向模型中添加 training operators
xent = train_model.LabelCrossEntropy([softmax, 'label'], 'xent')
# 计算 loss
loss = train_model.AveragedLoss(xent, "loss")
# 根据模型的精度
accuracy = brew.accuracy(train_model, [softmax, "label"], "accuracy")
# 添加计算梯度的 ops
train_model.AddGradientOperators([loss])
# 指定优化算法
optimizer.build_ssd(
train_model,
base_learning_rate=0.01,
policy="fixed",
momentum=0.9,
weight_decay=0.004
)
注意:
我们可以利用 GetOptimizationParamInfo()
函数来获得会被优化函数优化的参数. 如果你希望以不同的方式训练你的模型, 而模型看起来并没有被正确训练, 那么检查一下这个函数的返回值. 如果返回值为空, 那么就说明定义的网络没有训练任何东西! 这就是我们在 Add_Original_CIFAR10_Model()
函数中利用 brew 来创建网络层的原因之一, 因为 brew 会自动帮我们创建网络层所需的参数. 而如果我们已经在 Model Helper 中 append .net
, 那么就会返回空, 意味着没有任何参数被优化. 在工作区中, 当你 append 一个 net 以后, 你需要利用 create_param()
手动的创建 params.
1 | for param in train_model.GetOptimizationParamInfo(): |
正常情况下, 应该输出如下:1
2
3
4
5
6
7
8
9
10Param to be optimized: conv3_b
Param to be optimized: fc2_w
Param to be optimized: fc1_b
Param to be optimized: conv1_b
Param to be optimized: conv2_b
Param to be optimized: conv3_w
Param to be optimized: fc2_b
Param to be optimized: fc1_w
Param to be optimized: conv2_w
Param to be optimized: conv1_w
定义好模型以后, 输入下面的代码开始训练:
1 | # 初始化并创建 train model 的网络 |
正常情况下, 输出如下:
1 | Iter: 0, Loss: 1.02017736435, Accuracy: 0.629999995232 |
利用 Retrained 模型进行推演
1 | arg_scope = {"order": "NCHW"} |
正常情况下, 输出如下
1 | Iter: 500, Current Accuracy: 0.712 |
查看结果
1 | # Plot confusion matrix |
输出如下: