用 Python 来手写一个卷积神经网络(前向传播)|Python 主题月

1,808 阅读11分钟

本文正在参加「Python主题月」,详情查看活动链接

在开始介绍卷积神经网络之前,觉得有必要先说一说计算机是如何读解图像语义的。在计算机中是采用什么样数据结构来保存图像。只有理解这些内容我们还此基础上开始研究让给你计算机像人类一样读取图片语义来做一些计算机任务。

其实用矩阵来保存一张图像,通过将 3 维矩阵来表示一张图像,可以理解将图像切分一个一个小方块(pixel)每个小方块都有一个位置信息坐标,pixel 颜色通过 RBG 来表示,可能还具有透明通道,或者带有表示深度的通道,就用这些信息来表示一张图像输入给计算机。这样这种表示存储图像方式比较低级,会丢失许多语义信息。

conv_008.jpg

conv_016.png

conv_017.png

那么计算机在面对这些只有位置和颜色的信息数据,想要得到更多信息就需要靠自己,因为信息不足,这样对于计算机来理解或者读懂一张图片的语义无形带来困难。

conv_009.jpeg

以及有关图像的一些问题,我们需要读懂一张图像就像知道图像变化,计算机通过感知这些变化(边缘变化),有了边缘信息可以将图像进行分隔具有一定含义区域。

conv_007.png

为了把握图片 intensity 变化看层空间上变换,空间变化程度可以看成图片的一些特征,在边和角是图片.

conv_006.png

我们可以将图像处理为灰度图,在灰度图中明暗可以 intensity 来描述图片。这里 intensity 可以简单理解为我们某一像素(点)对光感知程度。怎么把这件事说清楚呢。我们看到物体是物体反射的光,发生不同颜色光以及光强度来反映一个物体。在计算机上我们将图像数字化过程,就是给图像每一个位置一个像素值,这个值可以分解为多个通道,值大小表示感光的程度。这就是我们在计算机视觉中要研究的对象。

通过 3D 视图形象地将我们如何通过灰度图 intensity 来表示图。

卷积

  • 滤波Filtering
  • 卷积Convolution
  • 矩阵Matrix
  • 颜色值Color values
  • 卷积核 kernel:

空间频率

卷积滤波用于修改图像的空间频率特性。

卷积的定义

  • 一般卷积可以用于图片的滤镜效果,是对图像
  • 卷积是运算,用整数组成的矩阵扫过图像
  • 卷积在图片上作用可以看作,通过将所有相邻像素的加权值相加来确定中心像素的值
  • 输出经过过滤的图像
  • 卷积核就是放置权重的模版

卷积是如何处理图片

convolution.jpeg

通过将一个像素及其相邻像素的颜色值乘以一个矩阵,也就是对应位置数值相乘。

conv_001.png

V=iq(jqfijdij)FV = \frac{\sum_i^q \left( \sum_j^q f_{ij} d_{ij} \right)}{F}

  • fijf_{ij} 卷积的像素
  • dijd_{ij} 像素的值
  • F 系数
  • V 表示输出像素

卷积核进行旋转 180 度后,再去做卷积操作,如果不进行旋转卷积核的操作叫做相关,其实通常卷积核都是对称的。可以将我们神经网络学习到卷积核

conv_003.png

卷积的性质

  • 叠加性: filter(f1+f2)=filter(f1)+filter(f2)filter(f_1 + f_2) = filter(f_1) + filter(f_2)
  • 平移不变性: filter(shift(f))=shift(filter(f))filter(shift(f)) = shift(filter(f))
  • 交换律
  • 结合律
  • 分配律
  • 标量

边界填充

一般经过卷积操作的图像会变小,所以为了保持输入和输出图像一样大小,需要在做卷积前对边界进行填充。

  • 拉伸填充
  • 镜像填充
  • 0 填充

平滑和锐化

conv_005.png 纹理特征(Texture Features) 形态特征(Morphological Features)

图像平滑

根据某一个像素其周围值来重新计算得到改点的新的值从而实现平滑。 10+5+3+4+5+1+1+1+79=7 \frac{10 + 5 + 3 + 4 + 5 + 1 + 1+ 1+ 7}{9} = 7

互相关

G[i,j]=u=kkv=kkH[u,v]F[i+u,j+v]G[i,j] = \sum_{u = -k}^k \sum_{v = -k}^k H[u,v] F[i + u, j+ v]

卷积

G[i,j]=u=kkv=kkH[u,v]F[iu,jv]G[i,j] = \sum_{u = -k}^k \sum_{v = -k}^k H[u,v] F[i - u, j - v]

非极大值抑制

我们观察一下,发现我们找到的边缘粗细不均匀,在图像标示出来比较粗的部分。那么对于这个问题如何解决,这里就是通过极大值抑制来解决。

数据集简介

还是他,对就是他,呵呵,MNIST 数据集,选择这个数据集主要原因就是便于演示,即使你们没有 GPU,也可以跑起来这个小型数据集。

卷积层实现

现在我们对卷积有了一个大概的了解,知道了图像卷积是如何工作的,以及卷积的用途,今天主要看看卷积在 CNN 中的实际应用。CNN 包括卷积层,使用一组过滤器,有的也叫特征图将输入图像转化为输出图像。卷积层的主要参数是所拥有的过滤器的数量。

对于我们的 MNIST CNN 来说,仅是为了演示如何设计和实现一个 CNN 卷积层,所以只设计了一个有 8 个过滤器的小的 conv 层作为卷积神经网络的层。这意味着将把 28x28 的输入图像讲过卷积层后变成 26x26x8 的输出。

001.svg

提示,图像经过卷积得到输出是 26x26x8,而不是 28x28x8,这是因为没有使用 padding

N=(WF+2P)/S+1N = (W − F + 2P )/S+1
N=(283+2×0)/1+1=25+1=26N = (28 - 3 + 2 \times 0) /1 + 1 = 25 + 1 = 26

这里 W 是卷积前图像的大小,F 表示卷积核大小,而 P 为 padding 大小,这里没有设置 padding 所以 P 为 0,而 S 表示 stride 也就是步长为 1

卷积层中的 8 个滤波器中的每一个都能产生 26x26 的输出,图像经过卷积层就编程 26x26x8 的形状,也就是每个卷积核输出一张特征图。那么参数数量就是 3×3(过滤器大小)×8(过滤器数量)=72

作为程序员,我们还是相信 coding,卷积反向传播还是相对比较复杂,所以线实现卷积层的前向传播这部分。在前向传播中用滤波器与输入图像进行卷积运算,得到一系列特征图。为了简单起见,滤波器选择 3x3,可以自己尝试 5x5 和 7x7 的过滤器。

import numpy as np

class Conv3x3:
  # 使用 3x3 大小卷积核的卷积层
  def __init__(self, num_filters):
    self.num_filters = num_filters

    # 滤波器(卷积核)是 3d 数组,形状为 (num_filters, 3, 3),之所以除以 9 这个在之前分享中也给大家说明过
    self.filters = np.random.randn(num_filters, 3, 3) / 9

Conv3x3 类只接受一个参数,过滤器的数量。并使用 NumPy 的 randn() 方法初始化一组随机的滤波器的参数值。

class Conv3x3:
  # ...

  def iterate_regions(self, image):
    '''
    这里使用有效 padding(valid) 将图片在步长 1 以窗口大小 3x3 来滑动一个一个小图片
  
    '''
    h, w = image.shape

    for i in range(h - 2):
      for j in range(w - 2):
        im_region = image[i:(i + 3), j:(j + 3)]
        yield im_region, i, j

  def forward(self, input):
    '''
    在 forward 函数中,用一个一个卷积核在给定图像上做卷积操作从而得到 3d(h, w, num_filters) numpy 数组
    - 输入是 2d numpy array
    '''
    h, w = input.shape
    output = np.zeros((h - 2, w - 2, self.num_filters))

    for im_region, i, j in self.iterate_regions(input):
      output[i, j] = np.sum(im_region * self.filters, axis=(1, 2))

    return output

iterate_regions() 是一个 generator 方法,为我们产生所有有效的 3x3 图像区域。这里 im_region 变量用于以数组形式来保存 3x3 图像区域,这里 self.filters 是一个 3D 数组,做 im_region * self.filters,这里知道说一下因为 im_region 是 2D ,所以使用 numpy 的广播功能,将 im_region 复制出 num_filters 份,然后将两个数组进行元素相乘后求和。结果是一个与 self.filters 相同维度的 3D 数组。 设置参数 axis=(1, 2) ,对上一步的所有元素在 1 和 2 维度进行求和 np.sum(),这样一来就会产生一个长度为 num_filters 的 1d 数组,其中每个元素都包含相应过滤器的卷积结果。把这个结果赋值给 output[i, j],上面的工作对输出中的每个像素都要执行,从而得到最终输出特征图,以上代码保存 conv.py 文件,然后我们创建 cnn.py 来进行测试


from mlxtend.data import loadlocal_mnist
from conv import Conv3x3

import numpy as np
import matplotlib.pyplot as plt

X, y = loadlocal_mnist(images_path="data/MNIST/raw/train-images-idx3-ubyte",labels_path="data/MNIST/raw/train-labels-idx1-ubyte")
# print(y)
X = np.reshape(X,(X.shape[0],28,28))[:1000]
y = y[:1000]


conv = Conv3x3(8)
output = conv.forward(train_images[0])
print(output.shape) # (26, 26, 8)

实现池化层(pooling)

图像中相邻的像素往往有相似的值,因此 conv 层通常也会在输出中为相邻的像素相似的值,基于这个认识提出池化层,所谓池化将输入中的数值聚集在一起,来减少输入的大小,其实池化操作就是为了减少参数量,降低计算量,当然也难免信息丢失。聚集通常由一个简单的操作完成,比如最大、最小或平均进行聚合。下面是池子大小为 2 的 Max Pooling 层的例子。

002.gif

用 2x2 卷积核遍历输入图像(卷积核的大小=2)来实现最大池化的效果, 从卷积核覆盖的元素中选择最大值,替换掉输出图像的相应像素值。输出图像大小是池化将输入的宽度和高度除以池的大小后得到尺寸。对于我们的 MNIST CNN 来说,图像经过上面卷积层得到 26x26x8 特征图。池化层将把 26x26x8 的输入转化为 13x13x8 的输出。

003.svg

import numpy as np

class MaxPool2:
  # A Max Pooling layer using a pool size of 2.

  def iterate_regions(self, image):
    '''
    Generates non-overlapping 2x2 image regions to pool over.
    - image is a 2d numpy array
    '''
    h, w, _ = image.shape
    new_h = h // 2
    new_w = w // 2

    for i in range(new_h):
      for j in range(new_w):
        im_region = image[(i * 2):(i * 2 + 2), (j * 2):(j * 2 + 2)]
        yield im_region, i, j

  def forward(self, input):
    '''
    '''
    h, w, num_filters = input.shape
    output = np.zeros((h // 2, w // 2, num_filters))

    for im_region, i, j in self.iterate_regions(input):
      output[i, j] = np.amax(im_region, axis=(0, 1))

    return output

其实池化层类的工作原理有点类似于之前实现的 Conv3x3 类。不用的是这一次操作更加简单,就从给定的图像区域中找到最大值做输出对应位置像素值,使用 np.max(),numpy 的数组最大值方法。为什么需要设置 axis=(0, 1),这是因为只想在前 2 个维度(高度和宽度)上找最大化,而不是第 3 个维度(num_filters)。

conv = Conv3x3(8)
pool = MaxPool2()

output = conv.forward(train_images[0])
output = pool.forward(output)
print(output.shape) # (13, 13, 8)

实现 Softmax

经过一个卷积层和池化层我们算完成 CNN 结构,现在提取到图片一些特征,随后就需要利用者特征进行预测,首先将特征层展平一个为一个向量,随后通常会用全连接层将其进行降维到类别数大小等同维度向量,也就是将特征向量经过一个线性变换为 10 维输出,这里暂时不需要激活函数,所有就是 softmax 将 10 向量变成一个概率分别,也可以理解激活函数,也就是非线性变换。

提醒:全连接层的每个节点都与前一层的每个输出相连。有关全连接在之前一篇文章已经实现请参考 用 Python 来手写一个神经网络|Python 主题月

005.svg

经过 softmax 层 使用输出 10 维概率分布,每一个维度值表示该图像为某一个数字的概率,作为卷积神经网络的最后一层。通过 softmax变换后,概率最高的节点所代表的数字将成为 CNN 对于图像预测。

交叉熵(Cross-Entropy) Loss

我们知道在概率分布中,概率最高值对应数值就是机器给出预测值,所以叫 softmax ,是 max 有一定差别,max 是只有一个是 1 其他都是 0 ,而 softmax 没有 max 那么强硬,而是每一个维度都有一定概率,即使概率最大所对应的数值预测对了,这样即使我们模型根据 softmax 给出正确答案,但是由于其概率值还是比较小例如 0.6 那么说明模型对其预测还是不自信,还有提升的空间。所以我们衡量模型好坏就可以通过引入交叉熵损失函数来衡量模型给出预测值。

L=ln(pc)L = -\ln(p_c)

其中 c 是标签正确的类别,例如对于某一个样本正确答案是 3 ,那么从模型给出概率分布 3 所对应概率 pcp_c 然后计算其负 log,也就是损失值损失越小越好。

import numpy as np

class Softmax:

    def __init__(self, input_len, nodes):
        self.weights = np.random.randn(input_len,nodes)/input_len
        self.biases = np.zeros(nodes)

    def forward(self, input):

        input = input.flatten()

        input_len, nodes = self.weights.shape

        totals = np.dot(input, self.weights) + self.biases
        exp = np.exp(totals)
        return exp / np.sum(exp, axis=0)

到现在,我们已经初步完成了一个卷积神经网络的前向传播的工作。接下来就是这些代码整合之后看效果,


from mlxtend.data import loadlocal_mnist
from conv import Conv3x3
from maxpool import MaxPool2
from softmax import Softmax
import numpy as np
import matplotlib.pyplot as plt

X, y = loadlocal_mnist(images_path="data/MNIST/raw/train-images-idx3-ubyte",labels_path="data/MNIST/raw/train-labels-idx1-ubyte")
# print(y)
X = np.reshape(X,(X.shape[0],28,28))[:1000]
y = y[:1000]

# plt.imshow(X[0])
# plt.show()


conv = Conv3x3(8)
pool = MaxPool2()
softmax = Softmax(13*13*8, 10)

def forward(image,label):
    # print(label)
    # print(image.shape)

    # print('-'*50)
    out = conv.forward((image/255) -0.5)
    # print(out.shape)
    out = pool.forward(out)
    # print(out.shape)
    out = softmax.forward(out)
    # print(out)
    loss = -np.log(out[label])
    acc = 1 if np.argmax(out) == label else 0
    # print('-'*50)
    return out, loss, acc

print('MNIST CNN initialzied!')

loss = 0
num_correct = 0

for i,(im,label) in enumerate(zip(X,y)):
    # print(im.shape)
    # print(label.shape)
    _, l, acc = forward(im,label)
    loss += l
    num_correct += acc

    if i % 100 == 99:
        print('[Step %d] Past 100 steps: Average Loss %.3f | Accuracy: %d%%' % (i + 1, loss/100, num_correct))
        # print(f"[Step {i+1}] Pass 100 steps: Average Loss {loss/100:3f} | Accuracy {num_correct}")


        loss = 0
        num_correct = 0

# output = conv.forward(X[0])
# output = pool.forward(output)
# print(output.shape)

随后我们会依次实现这些层反向传播,感谢大家支持。