大家好啊,我是董董灿。
这是从零手写Resnet50实战的第3篇文章。
请跟着我的思路,一点点地手动搭建一个可以完成图片分类的神经网络,而且不依赖第三方库,完全自主可控的手写算法。
如对网络中的算法和原理不太熟悉,请移步万字长文解析Resnet50的算法原理。
我的目标是,识别出下面的这张图片是一只猫:
正文
上一篇文章[我们已经把Resnet50中的所有权值参数都保存到txt中了。
接下来,把上图中猫的图片导入到内存中。
# 使用 Pillar 库来导入图片
# 仅使用该库导入图片
# 不使用该库进行任何其他的计算操作
from PIL import Image
# 读打开图片并读入到 img 中
img = Image.open('../cat.jfif')
# 将图片resize成长宽为(224,224)的图片
img = img.resize((224, 224))
这里说一下为什么要做 resize?
因为 Resnet50 首层卷积,接收的是一个 3 通道的图片数据。如果图片长宽太大,会使得卷积运算量过大,运行速度很慢,这一点在本文后面的实验可以看出来。
而且在试验 Resnet50 中,一个很常见的使用小图做运算的方法,便是将不规则大小的图片 resize 成(224,224)。其中两个 224 分别代表图片的长和宽,3 代表图片有 3 个通道。
在将图片导入到内存中之后,剩下的就是要将图片数据输入到神经网络中。但在此,需要先将核心算法完成,才能搭建成神经网络。
手写算法之——卷积
在Resnet50中,存在 6 种算法,分别是
- 卷积(Convolution,Conv)
- 批归一化(Batch Normal,BN)
- 池化(Pooling)
- 激活(Relu)
- 加法(Add)
- 全连接(Fully Connected, FC)
其中,Conv 和 FC 可以看作一类:都是在某些维度做乘累加计算;Pooling与卷积类似,只不过少了channel维度的累加。
BN是对输入数据的做批归一化操作,算法实现也不太难;而激活和加法就更简单了,属于两行代码就能搞定的算法。
所以,擒贼先擒王,先手写一个卷积算法试试水。
# 使用NHWC的 layout 来计算
# 卷积暂时不考虑 dilation 的存在
# 因为Resnet50中的卷积都不带 dilation 参数
def my_conv2d(img, weight, hi, wi, ci, co, kernel, stride, pad):
'''
img:输入图片数据
weight:权值数据(卷积核)
hi:输入图片高度-height
wi:输入图片宽度-width
ci:输入图片通道-channel,与weight的channel一致
co:输出图片通道-channle,与weight的个数一致
kernel:卷积核的大小
stride:卷积核在输入图片上滑动步长
pad:输入图片周围补充的pad值
'''
# 通过输入参数计算输出图片的长和宽
# 在 Resnet50 中,卷积核在 h 方向和 w 方向的
# 尺寸都是一样的,pad 也都是一样的,因此,
# 这里用一个值来代表。
ho = (hi + 2 * pad - kernel) // stride + 1
wo = (wi + 2 * pad - kernel) // stride + 1
# 将权值数据 reshape 成 co, kh, kw, ci 的形式
weight = np.array(weight).reshape(co, kernel, kernel, ci)
# 在输入图片周围补充pad值
img_pad = np.pad(img, ((pad, pad), (pad, pad), (0, 0)), 'constant')
# 初始化输出图片
img_out = np.zeros((ho, wo, co))
# 下面是卷积计算的核心逻辑
# 其效果类似于 nn.conv2d
for co_ in range(co):
for ho_ in range(ho):
in_h_origin = ho_ * stride - pad
for wo_ in range(wo):
in_w_origin = wo_ * stride - pad
filter_h_start = max(0, -in_h_origin)
filter_w_start = max(0, -in_w_origin)
filter_h_end = min(kernel, hi - in_h_origin)
filter_w_end = min(kernel, wi - in_w_origin)
acc = float(0)
for kh_ in range(filter_h_start, filter_h_end):
hi_index = in_h_origin + kh_
for kw_ in range(filter_w_start, filter_w_end):
wi_index = in_w_origin + kw_
for ci_ in range(ci):
in_data = img[hi_index][wi_index][ci_]
weight_data = weight[co_][kh_][kw_][ci_]
acc = acc + in_data * weight_data
img_out[ho_][wo_][co_] = acc
return img_out
上面是手写的一个卷积算法,采用了最原始的堆叠循环的方式,没有对算法做任何的优化。
之所以这么写,是因为这样可以很清晰地看到卷积的计算过程。
将图片输入给卷积进行运算
在定义完上述卷积运算后,就可以将上一步导入的图片,输入给卷积,计算一下试试水了。
# 读入图片并转换为指定大小
img = Image.open('../cat.jfif')
img = img.resize((224, 224))
# 将Pillow Image对象转换为numpy数组
# data is layout as NHWC
out = np.array(img)
# 这个函数用来从保存的权值文件中读取权值数据
def get_weight_from_file(f):
k = []
with open(f, 'r') as f_:
lines = f_.readlines()
for l in lines:
k.append(float(l))
return k
import datetime
# resnet50 第一次卷积的权值保存在项目中的路径
file_name = "../model_parser/dump_txt/resnet50_conv1_weight.txt"
# 将权值加载到内存中,赋值给K
k = get_weight_from_file(file_name)
# 打印当前时间戳
print(datetime.datetime.now())
# 调用手写的卷积进行计算,输出卷积结果
out = my_conv2d(out, k, 224, 224, 3, 64, 7, 2, 3)
# 打印计算完成的时间戳
print(datetime.datetime.now())
# 打印卷积计算结果的 shape
print(out.shape)
上面在调用 my_conv2d 之前,加了两个时间戳打印,看一下这个卷积运算的耗时。
$ 2023-04-13 08:21:20.473301
$ 2023-04-13 08:23:00.855593
从时间戳上可以看到,两个时间戳之间的间隔在1分多钟,说明这个卷积运算消耗了1分多钟,这可能与我用的虚拟机配置很低有关。
不过这种循环堆叠的卷积实现方式,很耗时是真的。
在卷积运算完之后,把这一层的输出的 shape 也打印出来。
$ (112, 112, 64)
可以看到,卷积的输出 shape 为 (112, 112, 64),通道数由输入图片的 3 通道变成了 64 通道,是因为使用了 64 个卷积核。
这里64个通道,实际上可以理解为这一层卷积在原始输入图片的像素之间,抽取出了 64 个特征出来。
至于是什么特征,我也不知道,有可能是猫的鼻子和耳朵。
至于上面说的卷积运算耗时的问题,暂时先不管他。在完成整网的推理,正确识别出来猫之后,我会继续将算法都优化一遍的。
后面还有 Pooling, Bn 算法的手写,写完之后,就可以按照 Resnet50 的结构,搭出神经网络来了。
今天,离识别出来猫,又进了一步。
本文为作者原创,请勿转载,转载请联系作者