在实际图像处理项目中输入神经网络的是具有高分辨率的彩色图片,会使得送入全连接(Dense)层的输入特数过多。随着隐藏层层数增加,网络规模过大,待优化参数过大,容易使得模型过拟合。
为了减少待训练参数,在实际应用时,会先对原始图片进行特征提取,把提取出来的特征再送给全连接网络,并让其输出识别结果。而卷积计算就是一种很有效的特征提取方法。
用一个正方形(也有少量长方形)的卷积核,按指定步长,在输入特征图上滑动,遍历输入特征图中的每个像素点。每一个步长,卷积核会与输入特征图出现重合区域,重合区域对应元素相乘、求和再加上偏置项(每一个卷积核仅带有一个偏置项)得到输出特征的一个像素点。
感受野(Receptive Field):卷积神经网络各输出特征图中的每个像素点,在原始输入图片上映射区域的大小。
对下图中左边的5x5原始输入图片,用黄色的3x3卷积核作用,会输出右边的3x3输出特征图。这个输出特征图上的每个像素点,映射到原始图片上是3x3的区域,所以它的感受野是3。
如果再对这个3x3的特征图,用绿色的3x3卷积核作用,则会输出一个1x1的输出特征图。这个新的输出特征图上的像素点,映射到原始图片是5x5的区域,所以它的感受野是5。
如果对原始输入图片直接用蓝色的5x5卷积核作用,会输出一个1x1的输出特征图。这个输出特征图上的像素点,映射到原始图片是5x5的区域,所以它的感受野也是5。
所以可以发现,这一层5x5卷积核与两层3x3卷积核的感受野都是5,也就是两者特征提取能力是一样的,那么,到底选择哪一种比较好呢?
设输入特征图宽、高均为x,卷积计算步长为1:
可见,当x>10时,两层3x3卷积核是要优于一层5x5卷积核的。这也是为什么现在的CNN都会用多层小卷积核来代替单层大卷积核的原因。
tf.keras.layers.Conv2D(
filters = 卷积核个数
kernel_size = 卷积核尺寸 #正方形写核长整数,或(核高h,核宽w)
strides = 滑动步长 #横纵向相同写步长整数,或(纵向步长h,横向步长w),默认为1
padding = 'same' or 'valid' #使用全零填充是'same',不使用是'valid'(默认)
activation = 'relu' or 'sigmoid' or 'tanh' or 'softmax'等 #如有BN此处不写
input_shape = (高,宽,通道数) #输入特征图维度,可省略
)
'''三种构建方式,第三种关键字传递法比较好'''
model = tf.keras.models.Sequential([
Conv2D(6, 5, padding='valid', activation='sigmoid'),
MaxPool2D(2, 2),
Conv2D(6, (5, 5), padding='valid', activation='sigmoid'),
MaxPool2D(2, (2, 2)),
Conv2D(filters=6, kernel_size=(5, 5), padding='valid', activation='sigmoid'),
MaxPool2D(pool_size=(2, 2), strides=2),
Flatten(),
Dense(10, activation='softmax')
])
有时候,我们希望卷积计算保持输入特征图的尺寸不变,则可以使用全零填充(Padding):在输入特征图周围填充0。
对于下图,这个5x5x1的输入特征图经过全零填充后,再通过3x3x1的卷积核进行步长为1的卷积计算,输出特征图仍是5x5x1。
卷积输出特征图维度计算公式:
输 出 特 征 图 边 长 { 全 零 填 充 = 入 长 / 步 长 ( 向 上 取 整 ) 非 全 零 填 充 = ( 入 长 − 核 长 ) / 步 长 + 1 ( 向 上 取 整 ) 部 分 全 零 填 充 = ( 入 长 − 核 长 + 2 × 填 充 长 度 ) / 步 长 + 1 ( 向 上 取 整 ) 输出特征图边长\left\{ \begin{aligned} &全零填充 = 入长/步长 (向上取整) \\ &非全零填充 = (入长-核长)/步长 +1(向上取整)\\ &部分全零填充 = (入长-核长+2\times填充长度)/步长 +1(向上取整) \end{aligned} \right. 输出特征图边长⎩⎪⎨⎪⎧全零填充=入长/步长(向上取整)非全零填充=(入长−核长)/步长+1(向上取整)部分全零填充=(入长−核长+2×填充长度)/步长+1(向上取整)
在tensorflow中描述全零填充,用参数padding = ‘SAME’ 或 padding = 'VALID’表示。
神经网络对于0附近的数据更为敏感(因为激活函数的原因),当时随着网络层数的增加,特征数据会出现偏离0均值的情况,在这种情况下就需要标准化。
对于批标准化后,第k个卷积核的输出特征图的第i个像素点,其数值为:
H i ′ k = H i k − μ b a t c h k σ b a t c h k H'^{k}_{i}=\frac{H^{k}_{i}-\mu^{k}_{batch}}{\sigma^{k}_{batch}} Hi′k=σbatchkHik−μbatchk
其中 μ b a t c h k = 1 m ∑ i = 1 m H i k \mu^{k}_{batch}=\frac{1}{m}\displaystyle\sum_{i=1}^{m}H_i^k μbatchk=m1i=1∑mHik, σ b a t c h k = δ + 1 m ∑ i = 1 m ( H i k − μ b a t c h k ) 2 \sigma^{k}_{batch}=\sqrt{\delta+\frac{1}{m}\displaystyle\sum_{i=1}^{m}(H_i^k-\mu^{k}_{batch})^2} σbatchk=δ+m1i=1∑m(Hik−μbatchk)2
注意这里的均值和标准差都是第k个卷积核对于这整个batch的输出图片所求的。
批标准化操作可以将原本偏移的特征数据重新拉回0均值,使进入激活函数的数据分布在激活函数的线性区,令输入函数的微小变化可以更明显的体现在激活函数的输出,提升了激活函数对于输入数据的区分力。
但是这种简单的批标准化会使得数据集中在线性区域,使激活函数丧失了非线性特性。因此在BN操作中为每个卷积核引入两个可训练参数:缩放因子 γ \gamma γ以及偏移因子 β \beta β。反向传播时,这两个参数会与其他待训练的参数一同被训练优化,使标准正态分布后的数据优化特征数据分布的宽窄和偏移量,保证了网络的非线性表达力。
X i k = γ k H i ′ k + β k X_i^k=\gamma_{k}H'^{k}_{i}+\beta_k Xik=γkHi′k+βk
tf.keras.layers.BatchNormalization()
BN层位于卷积层之后,激活层之前
池化操作用于减少卷积神经网络中特征数据量。主要方法分为最大值池化和均值池化,其中最大值池化可以提取图片纹理,均值池化可以保留背景特征。
下图是用2x2的池化核对输入图片以2为步长进行池化,输出图片将变为输入图片的四分之一大小。
tf.keras.layers.MaxPool2D(
pool_size = 池化核尺寸 #正方形写核长整数,或(核高h,核宽w)
strides = 滑动步长 #横纵向相同写步长整数,或(纵向步长h,横向步长w),默认为pool_size
padding = 'same' or 'valid' #使用全零填充是'same',不使用是'valid'(默认)
)
tf.keras.layers.AveragePooling2D(
pool_size = 池化核尺寸 #正方形写核长整数,或(核高h,核宽w)
strides = 滑动步长 #横纵向相同写步长整数,或(纵向步长h,横向步长w),默认为pool_size
padding = 'same' or 'valid' #使用全零填充是'same',不使用是'valid'(默认)
)
为了缓解神经网络过拟合,在神经网络训练过程中,常把隐藏层的部分神经元按照一定比例从神经网络中临时舍弃。在使用神经网络时,再把所有神经元恢复到神经网络中。
Tensorflow中实现:tf.keras.layers.Dropout(舍弃的概率,如0.2)
卷积神经网络就是借助卷积核对输入特征进行特征提取后,再把提取到的特征送入全连接网络进行识别预测。
卷积就是特征提取器,就是CBPAD
model = tf.keras.models.Sequential([
Conv2D(filters=6, kernel_size=(5, 5), padding='same'), #卷积层
BatchNormalization(), #BN层
Activation('relu'), #激活层
Maxpool2D(pool_size=(2, 2), strides=2, padding='same'), #池化层
Dropout(0.2) #舍弃层
])
Cifar10数据集一共有6万张彩色图片,每张图片有32行32列像素点的红绿蓝三通道数据。其中五万张用于训练,一万张用于测试。共十分类。
p24_cifar10_datasets.py
依照下图所示规格搭建模型。
由于网络相对复杂了,使用Class类搭建网络结构。
class Baseline(Model):
def __init__(self):
super(Baseline, self).__init__()
self.c1 = Conv2D(filters=6, kernel_size=(5, 5), padding='same') # 卷积层
self.b1 = BatchNormalization() # BN层
self.a1 = Activation('relu') # 激活层
self.p1 = MaxPool2D(pool_size=(2, 2), strides=2, padding='same') # 池化层
self.d1 = Dropout(0.2) # dropout层
self.flatten = Flatten()
self.f1 = Dense(128, activation='relu')
self.d2 = Dropout(0.2)
self.f2 = Dense(10, activation='softmax')
def call(self, x):
x = self.c1(x)
x = self.b1(x)
x = self.a1(x)
x = self.p1(x)
x = self.d1(x)
x = self.flatten(x)
x = self.f1(x)
x = self.d2(x)
y = self.f2(x)
return y
p27_cifar10_baseline.py
LeNet由Yann LeCun于1998年提出,是卷积神经网络的开篇之作。通过共享卷积核减少了网络的参数。
在统计卷积神经网络层数时,一般只统计卷积计算层和全连接计算层,其余操作可以认为是卷积计算层的附属。
LeNet一共有五层网络构成,其中C1、C3是两层卷积层(配有S2、S4两个池化层),后面C5、F6、Output是三层全连接网络。LeNet时代还未提出BN以及Dropout,而且主流的激活函数是Sigmoid。
其中值得注意的是,C5其实是卷积层,卷积核数目为120个,大小为5x5。由于第四层输出的feature map大小为5x5,因此第五层也可以看成全连接层,输出为120个大小为1x1的feature map。
class LeNet5(Model):
def __init__(self):
super(LeNet5, self).__init__()
self.c1 = Conv2D(filters=6, kernel_size=(5, 5),
activation='sigmoid')
self.p1 = MaxPool2D(pool_size=(2, 2), strides=2)
self.c2 = Conv2D(filters=16, kernel_size=(5, 5),
activation='sigmoid')
self.p2 = MaxPool2D(pool_size=(2, 2), strides=2)
self.flatten = Flatten()
self.f1 = Dense(120, activation='sigmoid')
self.f2 = Dense(84, activation='sigmoid')
self.f3 = Dense(10, activation='softmax')
def call(self, x):
x = self.c1(x)
x = self.p1(x)
x = self.c2(x)
x = self.p2(x)
x = self.flatten(x)
x = self.f1(x)
x = self.f2(x)
y = self.f3(x)
return y
P31_cifar10_lenet5.py
AlexNet网络诞生于2012年,是Hinton的代表作之一。获得了当年ImageNet的冠军,Top5错误率为16.4%。使用了Relu激活函数提升了训练速度,使用Dropout缓解了过拟合。
这幅图分为上下两个部分的网络,论文中提到这两部分网络是分别对应两个GPU,只有到了特定的网络层后才需要两块GPU进行交互,这种设置完全是利用两块GPU来提高运算的效率,其实在网络结构上差异不是很大。为了更方便的理解,我们假设现在只有一块GPU或者我们用CPU进行运算,我们从这个稍微简化点的方向区分析这个网络结构。
同时,原论文中使用的是局部响应标准化LRN,但由于LRN操作近些年用的很少,而且它的功能与批标准化相似,所以这里选择使用当前主流的BN操作实现特征标准化。
class AlexNet8(Model):
def __init__(self):
super(AlexNet8, self).__init__()
self.c1 = Conv2D(filters=96, kernel_size=(3, 3))
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.p1 = MaxPool2D(pool_size=(3, 3), strides=2)
self.c2 = Conv2D(filters=256, kernel_size=(3, 3))
self.b2 = BatchNormalization()
self.a2 = Activation('relu')
self.p2 = MaxPool2D(pool_size=(3, 3), strides=2)
self.c3 = Conv2D(filters=384, kernel_size=(3, 3), padding='same',
activation='relu')
self.c4 = Conv2D(filters=384, kernel_size=(3, 3), padding='same',
activation='relu')
self.c5 = Conv2D(filters=256, kernel_size=(3, 3), padding='same',
activation='relu')
self.p3 = MaxPool2D(pool_size=(3, 3), strides=2)
self.flatten = Flatten()
self.f1 = Dense(2048, activation='relu')
self.d1 = Dropout(0.5)
self.f2 = Dense(2048, activation='relu')
self.d2 = Dropout(0.5)
self.f3 = Dense(10, activation='softmax')
def call(self, x):
x = self.c1(x)
x = self.b1(x)
x = self.a1(x)
x = self.p1(x)
x = self.c2(x)
x = self.b2(x)
x = self.a2(x)
x = self.p2(x)
x = self.c3(x)
x = self.c4(x)
x = self.c5(x)
x = self.p3(x)
x = self.flatten(x)
x = self.f1(x)
x = self.d1(x)
x = self.f2(x)
x = self.d2(x)
y = self.f3(x)
return y
p34_cifar10_alexnet8.py
VGGNet是2014年ImageNet竞赛的亚军,Top5错误率减小到7.3%。它主要的贡献是展示出网络的深度(depth)是算法优良性能的关键部分。VGGNet使用小尺寸卷积核,在减少参数的同时,提高了识别准确率。同时它的网络结构很规整,非常适合硬件加速。
VGGNet包含很多级别的网络,深度从11层到19层不等,比较常用的是VGGNet-16和VGGNet-19。这里实现的是VGGNet-16,下图是其结构图示。
下面则是其结构描述。网络结构是两次CBA、CBAPD,随后三次CBA、CBA、CBAPD,最后是三层全连接。
设计这个网络时,卷积核的个数从64到128到256到512,逐渐增加。因为越靠后,特征图尺寸越小。通过增加卷积核的个数,增加了特征图的深度,保持了信息的承载能力。
p36_cifar10_vgg16.py
InceptionNet是当年ImageNet竞赛战胜了VGGNet的冠军,Top5错误率为6.67%。InceptionNet引入了Inception结构块,在同一层网络内使用不同尺寸的卷积核,提升了模型感知力;提出并使用了批标准化(BN),缓解了梯度消失。
InceptionNet的核心是它的基本单元Inception结构块。Inception结构块在同一层网络中使用了多个尺寸的卷积核,可以提取不同尺寸的特征。通过1x1卷积核,作用到输入特征图的每个像素点;通过设定少于输入特征图深度的1x1卷积核个数,减少了输出特征图深度,起到了降维的作用,减少了参数量和计算量。
下图中展示了一个Inception结构块。其包含四个分支,分别经过1x1卷积核输出到卷积连接器;经过1x1卷积核配合3x3卷积核输出到卷积连接器;经过1x1卷积核配合5x5卷积核输出到卷积连接器;经过3x3最大池化核配合1x1卷积核输出到卷积连接器。送到卷积连接器的特征数据尺寸相同。卷积连接器会把收到的这四路特征数据按深度方向拼接,形成Inception结构块的输出。
观察发现Inception结构块中的卷积操作均采用了CBA结构,且激活函数都是Relu,所以将其定义成一个新的类ConvBNRelu,可以减少代码长度,增加可读性。
class ConvBNRelu(Model):
def __init__(self, ch, kernelsz=3, strides=1, padding='same'):
super(ConvBNRelu, self).__init__()
self.model = tf.keras.models.Sequential([
Conv2D(ch, kernelsz, strides=strides, padding=padding),
BatchNormalization(),
Activation('relu')
])
def call(self, x):
x = self.model(x, training=False) #在training=False时,BN通过整个训练集计算均值、方差去做批归一化,training=True时,通过当前batch的均值、方差去做批归一化。推理时 training=False效果好
return x
Inception结构块的实现:x1、x2_2、x3_2、x4_2是四个分支的输出,使用tf.concat函数将他们堆叠在一起(axis=3指定堆叠的维度是沿深度方向的)。
class InceptionBlk(Model):
def __init__(self, ch, strides=1):
super(InceptionBlk, self).__init__()
self.ch = ch
self.strides = strides
self.c1 = ConvBNRelu(ch, kernelsz=1, strides=strides)
self.c2_1 = ConvBNRelu(ch, kernelsz=1, strides=strides)
self.c2_2 = ConvBNRelu(ch, kernelsz=3, strides=1)
self.c3_1 = ConvBNRelu(ch, kernelsz=1, strides=strides)
self.c3_2 = ConvBNRelu(ch, kernelsz=5, strides=1)
self.p4_1 = MaxPool2D(3, strides=1, padding='same')
self.c4_2 = ConvBNRelu(ch, kernelsz=1, strides=strides)
def call(self, x):
x1 = self.c1(x)
x2_1 = self.c2_1(x)
x2_2 = self.c2_2(x2_1)
x3_1 = self.c3_1(x)
x3_2 = self.c3_2(x3_1)
x4_1 = self.p4_1(x)
x4_2 = self.c4_2(x4_1)
# concat along axis=channel
x = tf.concat([x1, x2_2, x3_2, x4_2], axis=3)
return x
有了Inception结构块后,就可以搭建出一个精简版本的十层InceptionNet了。InceptionNet10的结构如下所示:
代码实现如下。这里设定了默认init_ch=16,也就是默认输出深度(即卷积核个数)是16。网络共有十层,第一层采用16个3x3的卷积核,可以直接调用ConvBNRelu。随后是四个Inception结构块顺序相连,每两个Inception结构块组成一个block。每个block中的第一个Inception结构块,卷积步长为2,;第二个Inception结构块,卷积步长是1。这使得第一个Inception结构块的输出特征图尺寸减半,因此,我们把输出特征图深度加深,尽可能保证特征抽取中信息的承载量一致。
block_0的通道数设置为16,经过了四个分支,输出的深度为4*16=64。由于在最后给通道数加倍了,所以block_1通道数是block_0通道数的两倍,也就是16*2=32。同样经过四个分支后,输出的深度为4*32=128。这128个通道的数据会被送入平均池化,再送入10个分类的全连接。
class Inception10(Model):
def __init__(self, num_blocks, num_classes, init_ch=16, **kwargs):
super(Inception10, self).__init__(**kwargs)
self.in_channels = init_ch
self.out_channels = init_ch
self.num_blocks = num_blocks
self.init_ch = init_ch
self.c1 = ConvBNRelu(init_ch)
self.blocks = tf.keras.models.Sequential()
for block_id in range(num_blocks):
for layer_id in range(2):
if layer_id == 0:
block = InceptionBlk(self.out_channels, strides=2)
else:
block = InceptionBlk(self.out_channels, strides=1)
self.blocks.add(block)
# enlarger out_channels per block
self.out_channels *= 2
self.p1 = GlobalAveragePooling2D()
self.f1 = Dense(num_classes, activation='softmax')
def call(self, x):
x = self.c1(x)
x = self.blocks(x)
x = self.p1(x)
y = self.f1(x)
return y
model = Inception10(num_blocks=2, num_classes=10) #共有两个block,分类数为10(用于Cifar10数据集)
注意最后使用了全局平均池化(Global Average Pooling, GAP)代替了全连接层(这列最后的Dense层仅是使用Softmax进行分类)。在获取特征后,传统的做法是接上全连接层后再进行激活分类。但问题出在这个全连接层,参数实在太大了,很容易造成过拟合。
使用GAP 来替代最后的全连接层可以直接实现降维,并且也极大地降低了网络参数 (全连接层的参数在整个 CNN 中占有很大的比重),更重要的一点是保留了由前面各个卷积层和池化层提取到的空间信息,实际应用中效果提升也比较明显。另外 GAP 的另一个重要作用就是去除了对输入大小的限制,这一方面在卷积可视化 Grad-CAM 中比较重要。GAP 的网络结构图如下:
GAP 真正的意义在于它实现了在整个网络结构上的正则化以实现防止过拟合的功能,原理在于传统的全连接网络 (如上图的左图) 对输出特征图进行处理时附带了庞大的参数以达到 “暗箱操作” 获取足够多的非线性特征,然后接上分类器,由于参数众多,难免存在过拟合的现象。假设我们现在要进行10分类,那么最后一层的卷积输出的特征图就只有10个通道。GAP 直接从输出特征图的通道信息下手,分别对每个特征图,累加所有像素值并求平均,最后得到10个数值,这就相当于直接赋予了每个通道类别的意义。
p40_cifar10_inception10.py
ResNet是当年ImageNet竞赛的冠军,Top5错误率为3.57%。ResNet提出了层间残差跳连,引入了前方信息,缓解了梯度消失,使神经网络层数增加成为可能。
ResNet的作者何凯明在Cifar10数据集上做了一个实验,发现56层卷积网络的错误率是要高于20层卷积网络的错误率。他认为,单纯堆叠神经网络层数会使得神经网络模型退化,以至于后边的特征丢失了前边特征的原本模样。
于是他用了一根跳连线,将前边的特征直接接到了后边。使后方的输出结果H(x)包含了堆叠卷积的非线性输出F(x)以及跳过这两层堆叠卷积直接连接过来的恒等映射x,让他们对应元素相加。这一操作,有效缓解了神经网络模型堆叠导致的退化,使得神经网络可以向着更深层级发展。
注意,ResNet块中的 “+”与Inception块中的 “+”是不同的。
ResNet块中有两种情况。一种情况用图中实线表示,这种情况两层堆叠卷积没有改变特征图的维度,也就是它们特征图的个数、高、宽和深度均相同,可以直接将F(x)与x相加;另一种情况用图中虚线表示,这种情况中这两层堆叠卷积改变了特征图大的维度,需要借助1x1的卷积来调整x的维度,使W(x)与F(x)的维度一样。
先封装ResNet的Block,分为两种情况,靠if语句判别是否需要过1x1卷积核改变特征图维度。
class ResnetBlock(Model):
def __init__(self, filters, strides=1, residual_path=False):
super(ResnetBlock, self).__init__()
self.filters = filters
self.strides = strides
self.residual_path = residual_path
self.c1 = Conv2D(filters, (3, 3), strides=strides, padding='same', use_bias=False)
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.c2 = Conv2D(filters, (3, 3), strides=1, padding='same', use_bias=False)
self.b2 = BatchNormalization()
# residual_path为True时,对输入进行下采样,即用1x1的卷积核做卷积操作,保证x能和F(x)维度相同,顺利相加
if residual_path:
self.down_c1 = Conv2D(filters, (1, 1), strides=strides, padding='same', use_bias=False)
self.down_b1 = BatchNormalization()
self.a2 = Activation('relu')
def call(self, inputs):
residual = inputs # residual等于输入值本身,即residual=x
# 将输入通过卷积、BN层、激活层,计算F(x)
x = self.c1(inputs)
x = self.b1(x)
x = self.a1(x)
x = self.c2(x)
y = self.b2(x)
if self.residual_path:
residual = self.down_c1(inputs)
residual = self.down_b1(residual)
out = self.a2(y + residual) # 最后输出的是两部分的和,即F(x)+x或F(x)+Wx,再过激活函数
return out
以下给出的框图是ResNet18用CBAPD表示的结构。第一层是一个卷积,然后是8个ResNet块,接着是一个GAP,最后过分类用的全连接。每一个ResNet块有两层卷积,一共是18层网络。
使用之前写出的ResNet块来搭建这个网络结构:
class ResNet18(Model):
def __init__(self, block_list, initial_filters=64): # block_list表示每个block有几个卷积层
super(ResNet18, self).__init__()
self.num_blocks = len(block_list) # 共有几个block
self.block_list = block_list
self.out_filters = initial_filters
self.c1 = Conv2D(self.out_filters, (3, 3), strides=1, padding='same', use_bias=False)
self.b1 = BatchNormalization()
self.a1 = Activation('relu')
self.blocks = tf.keras.models.Sequential()
# 构建ResNet网络结构
for block_id in range(len(block_list)): # 第几个resnet block
for layer_id in range(block_list[block_id]): # 第几个卷积层
if block_id != 0 and layer_id == 0: # 对除第一个block以外的每个block的输入进行下采样
block = ResnetBlock(self.out_filters, strides=2, residual_path=True)
else:
block = ResnetBlock(self.out_filters, residual_path=False)
self.blocks.add(block) # 将构建好的block加入resnet
self.out_filters *= 2 # 下一个block的卷积核数是上一个block的2倍
self.p1 = tf.keras.layers.GlobalAveragePooling2D()
self.f1 = tf.keras.layers.Dense(10, activation='softmax', kernel_regularizer=tf.keras.regularizers.l2())
def call(self, inputs):
x = self.c1(inputs)
x = self.b1(x)
x = self.a1(x)
x = self.blocks(x)
x = self.p1(x)
y = self.f1(x)
return y
model = ResNet18([2, 2, 2, 2])
p46_cifar10_resnet18.py