一起用代码吸猫!本文正在参与【喵星人征文活动】
题目说明
本题为吴恩达的课后编程作业,也是笔者初入机器学习的第一个demo,可在github.com/Asthestarsf…找到数据集和完整代码。
数据集中有两种图片:
一种是猫,一种不是猫,我们的任务就是训练一个分类器,对输入图片进行分类,从而得到该图片的类别——是猫,或者不是猫。
总体思路
由于是初入机器学习,这里我们不选用卷积神经网络,而是考虑使用多层线性感知机(MLP)来实现,也就是我们常说的全连接层。
我将整个流程分为以下步骤:
- 数据读取与处理
- 参数初始化
- 前向传播
- 计算误差
- 反向传播
- 更新参数
- 预测
- 额外功能的实现
为了更清晰地理解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
进行储存,将每一层的权重和偏置保存到指定文件夹,使用统一的命名方式,方便保存和读取,以四层的网络为例,可以得到如下:
可视化界面
经过几分钟的训练,我们得到了训练好的参数文件,我们可以加载这些参数来进行预测,但是怎么能没有一个“好看的”界面呢?这里我使用了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上运行即可正常显示中文): \
觉得不好看的话可以在上述的代码中进行修改
点击下方的按钮可选择一张图片进行预测:
下方的笑脸就表示是猫,同时命令行也会给出是猫的概率为多少
完整代码
完整代码和数据集可以访问我的github获得:github.com/Asthestarsf…
MegEngine
这里只实现模型的部分:
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)
复制代码
运行可以得到网络的结构图:
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)
复制代码
二者代码十分相似。 有任何问题可以联系我解答!