[机器学习]认识你的猫

9,149 阅读7分钟

一起用代码吸猫!本文正在参与【喵星人征文活动】

题目说明

本题为吴恩达的课后编程作业,也是笔者初入机器学习的第一个demo,可在github.com/Asthestarsf…找到数据集和完整代码。

数据集中有两种图片:

image-20211111135131667

image-20211111135213134

一种是猫,一种不是猫,我们的任务就是训练一个分类器,对输入图片进行分类,从而得到该图片的类别——是猫,或者不是猫。

总体思路

由于是初入机器学习,这里我们不选用卷积神经网络,而是考虑使用多层线性感知机(MLP)来实现,也就是我们常说的全连接层

我将整个流程分为以下步骤:

  1. 数据读取与处理
  2. 参数初始化
  3. 前向传播
  4. 计算误差
  5. 反向传播
  6. 更新参数
  7. 预测
  8. 额外功能的实现

为了更清晰地理解MLP中的各个过程,我采用了numpy实现,当然,文末会给出megengine和pytorch这两种框架的实现方式。

代码讲解

相信大家已经对全连接、激活函数、损失函数、梯度下降等有了一定的了解,接下来直接进行代码的详细讲解。

数据读取与处理

本题训练集共有0.jpg到208.jpg共209张图片,测试集有50张图片,并且拥有两个储存着图片类别信息的txt文件,我们需要对这些数据进行读取和处理,代码如下:

def Read_label(path):  # 读取储存类别的文件
    with open(path, 'r') as file:
        # 去除返回字符串中的空格和换行符
        data = list(file.read().replace(' ', '').replace('\n', ''))
    label = list(map(int, data))  # 将列表元素转换为整型
​
    return label
​
​
def Read_data(path):  # 读取图片
    img = []
    filenames = os.listdir(path)
    filenames.sort(key=lambda x: int(x[:-4]))  # 将filenames排序,文件形式为(XX.jpg)
    for filename in filenames:
        img.append(cv2.resize(cv2.imread(
            path + filename, 1), (64, 64)))  # 以BGR形式读取图片
​
    return np.array(img)

分别读取label和图片,由于label与图片是一一对应的,而使用os.listdir读取时不会按照文件名的大小顺序,因此我们将得到的文件名进行排序,使用cv2.imread进行读取,最终得到一个矩阵,后续我们将用这个矩阵进行训练

在输入网络之前,我们需要对读取到的数据进行处理——打平和归一化:

# 测试集和训练集和图片矩阵纵向维度保持一致
train_label = np.array(Read_label(Path_train_label)).reshape(1, -1)
test_label = np.array(Read_label(Path_test_label)).reshape(1, -1)
​
​
# 转置为(64*64*3, files acount)的矩阵(同一图片的矩阵信息转换到一列),并进行归一化
train_data = Read_data(Path_train).reshape(train_label.shape[1], -1).T/255
test_data = Read_data(Path_test).reshape(test_label.shape[1], -1).T/255

这里我们可以使用其他的归一化方法,由大家去探索吧。

参数初始化

def Init_params(layers):  # 初始化权重矩阵和偏置
    # 好的参数初始化可使训练更快
    np.random.seed(3)  # 保证每次初始化一样
    parameters = {}  # 该字典用来储存参数
    L = len(layers)  # 神经网络的层数
​
    for l in range(1, L):
        parameters["W" + str(l)] = np.random.randn(layers[l],
                                                   layers[l - 1]) / np.sqrt(layers[l - 1])  # Xaiver初始化方法
        parameters['b'+str(l)] = np.zeros((layers_dims[l], 1))  # 初始化为0
​
    return parameters

使用Xaiver初始化方法来进行初始化,这样能让网络收敛更快,当然使用random初始化也可以,但是注意不能使用全0初始化,这样网络每一层学习的都是一致的,便失去了多层的意义。

需要注意的是,这里使用的是一个字典来储存各层的权重和偏置,后面会添加保存参数的功能。

前向传播

def Forward_propagation(X, parameters):  # 向前传播
    """
    caches用于储存cache
    每一层的激活值A将输给下一层并作用于线性传播函数
    输出层的激活值为Yhat,将输给损失函数
    """
    caches = []
    A = X
    L = len(parameters) // 2  # 获得整型
    for l in range(1, L):  # (1,3)
        A, cache = Activation_forward(A, parameters['W' + str(l)],
                                      parameters['b' + str(l)], "Hiden")
        caches.append(cache)
    Yhat, cache = Activation_forward(A, parameters['W' + str(L)],
                                     parameters['b' + str(L)], "Output")
    caches.append(cache)
​
    return Yhat, caches

这里的caches储存的参数将用于求梯度

Yhat表示最后一层的输出结果,我们将对这个结果求解loss并进行反向传播

我们在每两层之间添加激活函数,这里我使用了TanH和最后一层的Sigmoid激活函数,当然你也可以使用你知道的激活函数,如ReLU等

def TanH(Z):
    return (np.exp(2*Z)-1)/(np.exp(2*Z)+1)
​
​
def Sigmoid(Z):
    return 1/(1+np.exp(-Z))

使用Activation_forward来封装一次前向传播过程——包含一个线性传播和激活函数的非线性激活,同时再最后一层使用Sigmoid激活函数

def Activation_forward(A_pre, W, b, Type='Hiden'):  # 计算激活值
    """
    Z表示经过线性传播后的矩阵,将输给激活函数
    A_pre表示前一层的激活值,将输给线性传播单元,实现全连接性
    b将先广播至与W一样的大小,再进行运算
    """
    Z = Linear_forward(A_pre, W, b)
    cache = (A_pre, W, b)  # 储存参数用于反向传播
    # 若激活函数有ReLU函数,则需要将Z储存起来,供反向传播时使用
​
    if Type == "Output":
        A = Sigmoid(Z)
    elif Type == "Hiden":
        A = TanH(Z)
​
    return A, cache
​
def Linear_forward(A, W, b):  # 正向线性传播
    # 全连接通过权重矩阵实现,数据将由一层传递向下一层
    return np.dot(W, A) + b
​

计算损失

def Compute_cost(Yhat, Y):
    m = Y.shape[1]  # 图片张数
    # 交叉熵误差计算,与sigmoid函数复合成凸函数,凸函数以最低点为分界两边分别与Y的类别相对应
    cost = -np.sum(np.multiply(np.log(Yhat), Y) +
                   np.multiply(np.log(1 - Yhat), 1 - Y)) / m
    # 计算Yhat的梯度,由此开始反向传播
    dYhat = - (np.divide(Y, Yhat) - np.divide(1 - Y, 1 - Yhat))
​
    return cost, dYhat

这里使用交叉熵作为损失函数,同时求出了loss相对于Yhat的梯度,反向传播由此开始

反向传播

我们需要对线性层和两个激活函数进行反向传播,代码如下:

def Linear_backward(dZ, cache):
    A, W, b = cache  # 拆分cache
    m = A.shape[1]  # 获得图片张数
    # 除以m防止样本过大而导致数据过大
    dW = np.dot(dZ, A.T) / m  # dW/dZ=A.T,相乘代表与cost的梯度
    db = np.sum(dZ, axis=1, keepdims=True) / m  # db/dZ=I,保持维度不变
    dA = np.dot(W.T, dZ)
​
    return dA, dW, db
​
​
def Sigmoid_backward(dA, A):
    # Sigmoid函数导数为S(1-S)
    dZ = dA * A*(1-A)  # 相对于cost的梯度
​
    return dZ
​
​
def TanH_backward(dA, A):
    # TanH函数导数为1-H方
    dZ = dA*(1-A**2)  # 相对于cost的梯度
​
    return dZ

需要注意的一点是,这里利用了链式法则,所以求出的梯度是直接相对与loss的,而不是相对于该层输入的。

接下来对上述函数进行封装,表示反向传播一层:

def Activation_backward(dA, cache, A_next, activation="Hiden"):
    """
    cache储存A_pre,W,b
    A_next为输给下一层的激活值,即本层输出的激活值
    每次向后传播时都将前一层的计算得出的梯度输入,将直接得到该层参数与cost的梯度。
    """
    if activation == "Hiden":
        dZ = TanH_backward(dA, A_next)
    elif activation == "Output":
        dZ = Sigmoid_backward(dA, A_next)
    dA, dW, db = Linear_backward(dZ, cache)
​
    return dA, dW, db
def Backward_propagation(dYhat, Yhat, Y, caches):
    grads = {}  # 用于储存梯度矩阵
    L = len(caches)  # 4
    m = Y.shape[1]  # 图片个数
    # 输出层
    grads["dA" + str(L)], grads["dW" + str(L)], grads["db" + str(L)] = Activation_backward(
        dYhat, caches[L-1], Yhat, "Output")
    # 隐藏层
    for l in reversed(range(L-1)):  # (3,0]
        grads["dA" + str(l + 1)], grads["dW" + str(l + 1)], grads["db" + str(l + 1)] = Activation_backward(
            grads["dA" + str(l + 2)], caches[l], caches[l+1][0], "Hiden")
        # caches[][0]储存的是A,此处意为A_next,即本层输出的激活值
​
    return grads

这里依旧使用字典来储存梯度,这些梯度将被用来更新参数

参数更新

def Update_params(parameters, grads, learning_rate):
    # 梯度下降更新参数
    L = len(parameters) // 2
    for l in range(L):
        parameters["W" + str(l + 1)] -= learning_rate * \
            grads["dW" + str(l + 1)]
        parameters["b" + str(l + 1)] -= learning_rate * \
            grads["db" + str(l + 1)]
​
    return parameters

这么简单相信大家都能看懂吧!

至此,整个网络算是搭建完成了,但是不要高兴太早,我们还需要完善一些其他功能

训练

def Train_model(X, Y, parameters, learning_rate, iterations, threshold):  # 训练用模块
    costs = []  # 储存每100次迭代的损失值,用于绘制折线图
    for i in range(iterations):
        Yhat, caches = Forward_propagation(X, parameters)  # 正向传播
        cost, dYhat = Compute_cost(Yhat, Y)  # 计算误差
        grads = Backward_propagation(dYhat, Yhat, Y, caches)  # 计算梯度
        parameters = Update_params(
            parameters, grads, learning_rate)  # 更新参数
        if i % 100 == 0:
            costs.append(cost)
            print(f"迭代次数:{i},误差值:{cost}")
        if cost < threshold:  # 通过损失值
            costs.append(cost)
            print(f"迭代次数:{i},误差值:{cost}")
            break
​
    return parameters, costs, i

X, Y分别表示为图片和标签,parameters为网络参数, learning_rate为学习率,iterations为迭代次数,threshold可通过损失值来提前终止训练。

这里返回的parameters将被由于保存参数,costs将被用于绘制loss曲线图。

绘制loss曲线

def Plot(costs, layers):
    plt.plot(costs)
    plt.ylabel('cost')
    plt.xlabel('iterations')
    plt.title("Learning rate =" +
              str(learning_rate) + f",layers={layers}")
    plt.show()
​

绘制曲线的同时会添加一些网络的配置信息。

参数的保存与读取

def Save_params(parameters, layers, path):
    # 储存神经网络各层的信息
    np.savetxt(path+'layers.csv', layers, delimiter=',')
    n = len(parameters)//2
    # 将每个参数分开储存,方便读取
    for i in range(1, n+1):
        np.savetxt(path+'W'+str(i)+'.csv',
                   parameters['W'+str(i)], delimiter=',')
        np.savetxt(path+'b'+str(i)+'.csv',
                   parameters['b'+str(i)], delimiter=',')
​
​
def Load_params(path):
    parameters = {}  # 用于接收参数
    layers = list(np.loadtxt(path+'layers.csv', dtype=int, delimiter=','))
    n = len(layers)
    for i in range(1, n):
        parameters['W'+str(i)] = np.loadtxt(path+'W'+str(i) +
                                            '.csv', delimiter=",").reshape(layers[i], -1)
        parameters['b'+str(i)] = np.loadtxt(path+'b'+str(i) +
                                            '.csv', delimiter=",").reshape(layers[i], 1)
​
    return layers, parameters

使用np.savetxt进行储存,将每一层的权重和偏置保存到指定文件夹,使用统一的命名方式,方便保存和读取,以四层的网络为例,可以得到如下:

image-20211111145254570

可视化界面

经过几分钟的训练,我们得到了训练好的参数文件,我们可以加载这些参数来进行预测,但是怎么能没有一个“好看的”界面呢?这里我使用了tkinter包实现了一个简易的可视化界面

def Create_window(parameters):
    global window  # global便于后续引用
    window = tk.Tk()
    window.title('猫咪识别器')
    window.geometry('650x650')
    window.configure(background='lightpink')
    label = tk.Label(window, text='每次识别后等待5秒即可再次识别!',
                     font=('楷书', 15), fg='Purple', bg='orange')
    label.pack(side='top')
    num = tk.Label(window, text='2000301712', font=(
        'fira_Code'), bg='orange', fg='purple') #修改颜色
    num.pack(side='right')
    # lambda可以防止带参数的函数自动运行
    choose_button = tk.Button(window, text='打开一张图片', fg='deeppink', bg='violet', activebackground='yellow',
                              font=('宋体', 20), command=lambda: Show_img(parameters))
    choose_button.pack(side='bottom')
    window.mainloop()
​
​
def Show_img(parameters):
    global window, img
    file = tk.filedialog.askopenfilename()  # 获取选择的文件路径
    Img = Image.open(file)
    # 使用cv2读取图片,供后续预测使用(cv2读取图片通道顺序为BGR)
    data = cv2.resize(cv2.imread(file, 1), (64, 64)).reshape(1, -1).T/255
    img = ImageTk.PhotoImage(Img)
    Predict_button = tk.Button(window, text='识别!', fg='CornflowerBlue', bg='slateblue', activebackground='red',
                               font=('宋体', 20), command=lambda: Predict(data, parameters))
    Predict_button.pack(side='bottom')
    Predict_button.after(5000, Predict_button.destroy)  # 一段时间后销毁按钮
    label_Img = tk.Label(window, image=img)  # 显示图片
    label_Img.pack(side='top')
    label_Img.after(5000, label_Img.destroy)
​

运行可以得到以下“猛男”界面(在windows上运行即可正常显示中文): \

image-20211111151210599

觉得不好看的话可以在上述的代码中进行修改

点击下方的按钮可选择一张图片进行预测:

image-20211111151424140

下方的笑脸就表示是猫,同时命令行也会给出是猫的概率为多少

image-20211111151501429

完整代码

完整代码和数据集可以访问我的github获得:github.com/Asthestarsf…

MegEngine

MegEngine:github.com/MegEngine/M… 这里只实现模型的部分:

import megengine as mge
import megengine.module as M
​
class CustomMLP(M.Module):
    def __init__(self, layers:list, in_dim:int):
        super(CustomMLP, self).__init__() 
        self.modules = M.Sequential(*self._make_layer(layers, in_dim))
​
    def forward(self,inputs):
        for moudule in self.modules:
            inputs = moudule(inputs)
        return inputs
​
    def _make_layer(self, layers, in_dim):
        length = len(layers)
        modules = [M.Linear(in_dim, layers[0])]
        for i in range(length-1):
            activation = M.ReLU()
            layer = M.Linear(layers[i], layers[i+1])
            modules.append(activation)
            modules.append(layer)
        modules.append(M.Sigmoid())
        return modules
​
model = CustomMLP([20,8,7,1],3)
print(model)

运行可以得到网络的结构图:

image-20211111153725217

Pytorch

import torch
import torch.nn as nn
​
class CustomMLP(nn.Module):
    def __init__(self, layers:list, in_dim:int):
        super(CustomMLP, self).__init__() 
        self.modules = nn.Sequential(*self._make_layer(layers, in_dim))
​
    def forward(self,inputs):
        for moudule in self.modules:
            inputs = moudule(inputs)
        return inputs
​
    def _make_layer(self, layers, in_dim):
        length = len(layers)
        modules = [nn.Linear(in_dim, layers[0])]
        for i in range(length-1):
            activation = nn.ReLU()
            layer = nn.Linear(layers[i], layers[i+1])
            modules.append(activation)
            modules.append(layer)
        modules.append(nn.Sigmoid())
        return modules
    
model = CustomMLP([20,8,7,1], 3)
print(model)

二者代码十分相似。 有任何问题可以联系我解答!