手写数字识别任务(上)

举报
黄生 发表于 2025/02/12 22:10:56 2025/02/12
197 0 0
【摘要】 写数字识别二分类:从传统编程方法,到机器学习方法,再到使用Pytorch本notebook来源:AI学习https://developer.huaweicloud.com/techfield/ai/study.html中的深度学习实践课程深度学习基础知识综合实践,内容以MNIST手写数字识别任务为主线,从易到难逐步提高手写数字识别的准确率。 第一节 手写数字识别任务简介 1. MNIST数据...

写数字识别二分类:从传统编程方法,到机器学习方法,再到使用Pytorch

本notebook来源:AI学习https://developer.huaweicloud.com/techfield/ai/study.html
中的深度学习实践课程
深度学习基础知识综合实践,内容以MNIST手写数字识别任务为主线,从易到难逐步提高手写数字识别的准确率。

第一节 手写数字识别任务简介

1. MNIST数据集简介

MNIST 数据集来自美国国家标准与技术研究所(National Institute of Standards and Technology,简称 NIST ),总共有7万张图,其中训练集6万张,由 250 个不同人的手写数字构成, 50% 是高中学生, 另外 50% 是人口普查局的工作人员,测试集1万张图,也是由同样比例的人手写的数字。该数据集在深度学习领域,是一个很经典的入门学习数据集,部分手写数字的图片样例如下:
mnist

2. 下载MNIST数据集

运行如下代码即可将数据集下载到当前datasets目录下的MNIST_data子目录中

# 创建datasets目录
import os
datasets_dir = '../datasets'
if not os.path.exists(datasets_dir):
    os.makedirs(datasets_dir)

# 下载数据集,由于数据源在华为云OBS中,所以本代码只能在华为云 ModelArts 中运行
import moxing as mox
if not os.path.exists(os.path.join(datasets_dir, 'MNIST_data.zip')):
    mox.file.copy('obs://modelarts-labs-bj4/course/hwc_edu/deep_learning/datasets/MNIST_data.zip', 
                  os.path.join(datasets_dir, 'MNIST_data.zip'))
    os.system('cd %s; unzip MNIST_data.zip' % (datasets_dir))

3. 读取MNIST数据集图片

import os
import torchvision.datasets.mnist as mnist

datasets_dir = '../datasets'
# 读取训练样本
train_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-images-idx3-ubyte'))
train_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-labels-idx1-ubyte'))
# 读取测试样本
test_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-images-idx3-ubyte'))
test_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-labels-idx1-ubyte'))

print('训练集规模:', train_data.shape, train_label.shape)  # 60000个训练样本
print('测试集规模:', test_data.shape, test_label.shape)  # 10000个测试样本
训练集规模: torch.Size([60000, 28, 28]) torch.Size([60000])
训练集规模: torch.Size([10000, 28, 28]) torch.Size([10000])

4. 查看图片及其标签

from PIL import Image
img = train_data[0].numpy()  # train_data[1]是tensor格式,转成numpy格式
print('图像的标签:', train_label[1].item())
print('图像的大小:', img.shape)
Image.fromarray(img)  # 转成PIL格式进行图片显示
图像的标签: 0
图像的大小: (28, 28)

5. 任务说明

如上所示,手写数字识别任务就是要对每张28*28大小的图片进行预测,判断该图片是数字0~9中的哪一个。

第二节 传统编程方法实现手写数字识别二分类

注意事项

  1. 本案例使用AI引擎**:** Pytorch-1.0.0

  2. 本案例最低硬件规格要求**:** 2vCPU + 4GiB

  3. 切换硬件规格方法**:** 如需切换硬件规格,您可以在本页面右边的工作区进行切换

  4. 运行代码方法**:** 点击本页面顶部菜单栏的三角形运行按钮或按Ctrl+Enter键 运行每个方块中的代码

  5. JupyterLab的详细用法**:** 请参考《ModelAtrs JupyterLab使用指导》

  6. Kernel Restarting、Kernel died及其他常见问题的解决办法**:** 请参考《ModelAtrs JupyterLab常见问题解决办法》

案例内容介绍

手写数字识别任务,是要对每张28*28大小的图片进行预测,判断该图片是数字0-9中的哪一个,因此这是一个10分类的任务。
做科研的常规方法是先对一个问题做一些假设或简化,尝试去解决这个简单的问题,等简单问题得到较好的解决之后,再减少假设,尝试解决更贴近现实情况、也更复杂的问题。
本课程也将遵循这种方法,先假设手写数字识别任务只需要识别0和1两个数字,我们先尝试解决这个简单的二分类问题,之后再解决10分类的问题。
接下来的第3~7章内容都是解决手写数字0和1的二分类问题。
实现手写数字0和1的二分类,有很多种方法,我们先采用非机器学习的方法,也就是采用传统编程的方法来实现数字0和1的二分类。

1. 准备手写数字0和1的数据集

由于整个MNIST数据集是包含0~9的所有图片,我们现在研究的是简化的0和1的二分类问题,所以先从整个数据集中将所有手写数字0和1的图片挑选出来,同样也需要区分训练集和测试集。

import os
import numpy as np
import torchvision.datasets.mnist as mnist

datasets_dir = '../datasets'
if not os.path.exists(datasets_dir):
    os.makedirs(datasets_dir)
import moxing as mox
if not os.path.exists(os.path.join(datasets_dir, 'MNIST_data.zip')):
    mox.file.copy('obs://modelarts-labs-bj4/course/hwc_edu/deep_learning/datasets/MNIST_data.zip', 
                  os.path.join(datasets_dir, 'MNIST_data.zip'))
    os.system('cd %s; unzip MNIST_data.zip' % (datasets_dir))

# 读取完整训练样本
train_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-images-idx3-ubyte')).numpy().astype(np.uint8)
train_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-labels-idx1-ubyte')).numpy().astype(np.uint8)
# 读取完整测试样本
test_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-images-idx3-ubyte')).numpy().astype(np.uint8)
test_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-labels-idx1-ubyte')).numpy().astype(np.uint8)
train_zeros = train_data[train_label == 0]
train_ones = train_data[train_label == 1]
test_zeros = test_data[test_label == 0]
test_ones = test_data[test_label == 1]

print('数字0,训练集规模:', len(train_zeros), ',测试集规模:', len(test_zeros))
print('数字1,训练集规模:', len(train_ones), ',测试集规模:', len(test_ones))
INFO:root:Using MoXing-v2.1.0.5d9c87c8-5d9c87c8
INFO:root:Using OBS-Python-SDK-3.20.9.1
数字0,训练集规模: 5923 ,测试集规模: 980
数字1,训练集规模: 6742 ,测试集规模: 1135

2. 进行样本分析

2.1 查看样本的整体概况

# 查看30张数字0的图片
import numpy as np
from PIL import Image
Image.fromarray(np.hstack(train_zeros[:30]))

# 查看30张数字1的图片
#Image.fromarray(np.hstack(train_ones[:30]))

2.2 查看单张图片的细节

上一节已经讲到,MNIST数据集中的每张图片都是28*28大小,使用python模块读取图片文件后,图片可以用一个28*28的矩阵来表示,下面我们就来查看一下这个矩阵中的具体数值

# 查看图片的像素值
import pandas as pd
df = pd.DataFrame(train_data[1])
df.style.set_properties(**{'font-size':'6pt'}).background_gradient('Greys')

如上是图片数字0的矩阵值,
可以发现一个现象: 矩阵中的每一个值都代表图片中的一个像素,没有笔画的地方是0像素值,有笔画的地方是非零像素,而且按照常理,同样大小的图片中,数字0的笔画面积一般会比数字1的笔画面积要多
由此产生一个思路: 能否根据笔画产生的非零像素在整幅图像中的占比来区分数字0和1?
先分别统计数字0和数字1的非零像素在整幅图像中的占比均值,由于数字0的非零像素占比一般比数字1的要大,所以只需要找到一个合适的非零像素占比阈值(用变量th表示),如果某张图片的非零像素占比大于th,就可以将该图片分类为0,否则分类为1。为实现这个思路,我们接下来可以采用传统的编程方法来一步步实现。

3. 定义非零像素占比函数

def calc_nonzero_ratio(img):
    '''实现方法:使用np.count_nonzero函数统计矩阵中的非零像素个数,除以图像大小,即可得到非零像素占比'''
    img = np.asarray(img)
    return np.count_nonzero(img) / img.size

统计数字0的非零像素占比均值

zeros_ratio = 0
for zero in train_zeros:
    zeros_ratio += calc_nonzero_ratio(zero)
zeros_ratio = zeros_ratio / len(train_zeros)
print('数字0的非零像素占比均值:', zeros_ratio)
数字0的非零像素占比均值: 0.24486587223104606

统计数字1的非零像素占比均值

ones_ratio = 0
for one in train_ones:
    ones_ratio += calc_nonzero_ratio(one)
ones_ratio = ones_ratio / len(train_ones)
print('数字1的非零像素占比均值:', ones_ratio)
数字1的非零像素占比均值: 0.10949749968216262

4. 设置像素占比分类阈值

先采取一个简单的策略来设置分类阈值,直接取数字0和数字1的非零像素占比的平均值,取4位有效小数

th = round((zeros_ratio + ones_ratio) / 2, 4)
print('分类阈值:', th)
分类阈值: 0.1772

5. 定义分类预测函数

这个分类方法很简单,如果某张图片的非零像素占比大于th,就将该图片分类为0,否则分类为1

def predict(img):
    if calc_nonzero_ratio(img) > th:
        pred_label = 0
    else:
        pred_label = 1
    return pred_label

6. 预测准确率统计

对数字0的测试样本进行预测,并统计准确率

zero_right_count = 0
for zero in test_zeros:
    pred_result = predict(zero)
    if pred_result == 0:
        zero_right_count += 1
print('数字0测试样本准确率:%.4f' % (float(zero_right_count) / len(test_zeros)))
数字0测试样本准确率:0.9571

对数字1的测试样本进行预测,并统计准确率

one_right_count = 0
for one in test_ones:
    pred_result = predict(one)
    if pred_result == 1:
        one_right_count += 1
print('数字1测试样本准确率:%.4f' % (float(one_right_count) / len(test_ones)))
数字1测试样本准确率:0.9762

统计综合准确率

print('测试样本综合准确率:%.4f' % (float(zero_right_count + one_right_count) / (len(test_zeros) + len(test_ones))))
测试样本综合准确率:0.9674

如上所示,使用“统计非零像素占比,比较阈值” 这种很简单的策略,也可以实现手写数字0和1的分类,数字0和数字1的分类准确率分别是 95.71% 和 97.62%,综合准确率达到 96.74%

恭喜你,实现了一个手写数字0和1分类效果还不错的模型!

接下来,请你思考:

(1)如果仍然采用“统计非零像素占比,比较阈值” 的简单策略,你能否再提升上述模型的分类准确率?
(2)采用“统计非零像素占比,比较阈值” 的简单策略,你成功地实现了图片的二分类,如果沿用这个策略,你能否对10个数字进行分类?

第三节 机器学习方法实现手写数字识别二分类

案例内容介绍

上一节,我们已经使用“统计非零像素占比,比较阈值” 的简单策略实现了手写数字0和1的二分类,本节我们开始采用机器学习的思方法来实现。
有很多种机器学习方法可以实现二分类问题,本节打算先尝试采用机器学习中很简单的感知机模型,来试试分类效果如何。
感知机模型的诞生是受到了人脑神经元的启发,如下图所示是单个人脑神经元的结构,每个神经元都与其他神经元进行相连,当神经元“兴奋”时,就会向相连的神经元传递化学物质,另一个神经元就是从树突接收其他神经元的化学物质输入,化学物质会改变轴突上的电位,当电位大于一定阈值时,这个神经元也被激活到了“兴奋”状态,也通过轴突末梢向其他神经元传递化学物质。
神经元
感知机模型的结构与神经元类似,如下图所示,它有一个输入层和输出层,输入层就相当于神经元的树突,用于接收输入,输出层就相当于轴突和轴突末梢,用于累计输入并判断是否激活至“兴奋状态”。
感知机
感知机可以使用的激活函数有很多种,上图中使用的是sigmoid函数,它的函数曲线如下图所示,可以看出函数的取值区间是(0.0, 1.0)。
sigmoid函数曲线
那么,我们如何使用感知机来实现手写数字0和1的二分类呢?
我们已知手写数字图片的数据可以用28*28的数组来表示,那么可以将这个数组转换为784*1的列向量,这个列向量作为感知机的输入,也就是说输入和权值都是784个。
感知机的输出是0.0到1.0之间的小数,而我们需要的是二分类问题,期望最终输出的结果是0或1。因为我们可以在得到感知机的输出y之后,再加一个后处理判断,如果y>0.5,则判为类别1,否则判为类别0,这就是采用感知机来实现手写数字0和1二分类的方法,可以表示为下图:(偏置b在下图中未展示出来)
感知机实现二分类

1. 传统编程方法与机器学习方法的本质区别

用感知机来实现二分类的可行性已经得到验证了,在开始具体的实现之前,我们先来理解传统编程方法与机器学习方法之间的区别。

传统编程方法的模式

传统编程方法的模式可以用下图来表示,大多数程序都可以看成是一个盒子,给定明确的输入,得到明确的输出,而且这个程序的实现逻辑都是由程序员一行行编写出来的,人告诉计算机一步步怎么做,人完全能解释为什么给某个输入能得到某个输出。
传统编程方法的模式

机器学习方法的模式

机器学习方法的模式可以用下图来表示,核心在于“网络结构 + 参数”,也可以把它看成一个特殊的程序,只不过这个程序不是由程序员编写出来的,人只定义了网络结构,而参数是由计算机通过机器学习方法“学习”得来的,人无法解释为什么要取这些参数值。
机器学习方法的模式

机器学习方法的学习模式

那么机器学习方法是如何“学习”的呢?思想其实很简单,可以用下图来表示:
机器学习方法的学习模式

上图的学习模式可以归纳为以下几点:
(1)定义网络结构,并给网络中的所有参数 w 赋予随机值;
(2)使用网络对一批样本进行预测,得到预测值pred_y;
(3)定义一个损失函数,如 loss=(pred_ytrue_y)2loss=(pred\_y-true\_y)^2,true_y是指标签,模型训练的目的就是使得所有训练数据的损失之和尽可能地小;
(4)求得损失函数对所有参数的梯度 gradient_w;
(5)按照公式 w = w - lr * gradient_w 对所有参数w进行更新,其中lr是指学习率,常见的取值有0.01、0.001、0.0001等。

下面我们将按照以上归纳的几点来实现整个机器学习过程,现在不理解这几点没关系,等完成所有代码实现之后,再来回顾理解。

2. 加载数据集

开始实现机器学习过程之前,仍然是要先加载数据集。由于整个MNIST数据集是包含0~9的所有图片,我们现在研究的是简化的0和1的二分类问题,所以先从整个数据集中将所有手写数字0和1的图片挑选出来,同样也需要区分训练集和测试集。

挑选数字0和1的训练样本和测试样本

import os
import numpy as np
import torchvision.datasets.mnist as mnist

datasets_dir = '../datasets'
if not os.path.exists(datasets_dir):
    os.makedirs(datasets_dir)
import moxing as mox
if not os.path.exists(os.path.join(datasets_dir, 'MNIST_data.zip')):
    mox.file.copy('obs://modelarts-labs-bj4/course/hwc_edu/deep_learning/datasets/MNIST_data.zip', 
                  os.path.join(datasets_dir, 'MNIST_data.zip'))
    os.system('cd %s; unzip MNIST_data.zip' % (datasets_dir))

# 读取完整训练样本
train_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-images-idx3-ubyte')).numpy().astype(np.uint8)
train_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-labels-idx1-ubyte')).numpy().astype(np.uint8)
# 读取完整测试样本
test_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-images-idx3-ubyte')).numpy().astype(np.uint8)
test_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-labels-idx1-ubyte')).numpy().astype(np.uint8)

train_zeros = train_data[train_label == 0]
train_ones = train_data[train_label == 1]
test_zeros = test_data[test_label == 0]
test_ones = test_data[test_label == 1]

print('数字0,训练集规模:', len(train_zeros), ',测试集规模:', len(test_zeros))
print('数字1,训练集规模:', len(train_ones), ',测试集规模:', len(test_ones))
数字0,训练集规模: 5923 ,测试集规模: 980
数字1,训练集规模: 6742 ,测试集规模: 1135

将数字0和1的样本进行汇总

train_x = np.vstack((train_zeros, train_ones))  # 将数字0和1的训练样本汇总起来,np.vstack表示将两个数组进行垂直拼接
train_y = np.array([0] * len(train_zeros) + [1] * len(train_ones)).astype(np.uint8)

test_x = np.vstack((test_zeros, test_ones))  # 将数字0和1的测试样本汇总起来,np.vstack表示将两个数组进行垂直拼接
test_y = np.array([0] * len(test_zeros) + [1] * len(test_ones)).astype(np.uint8)

train_x = train_x.reshape(-1, 28*28)  # 每个样本变成一个行向量,因为行向量便于计算
train_y = train_y.reshape(-1, 1)

test_x = test_x.reshape(-1, 28*28)  # 每个样本变成一个行向量,因为行向量便于计算
test_y = test_y.reshape(-1, 1)

print(train_x.shape, train_y.shape, test_x.shape, test_y.shape)
(12665, 784) (12665, 1) (2115, 784) (2115, 1)

3. 混洗数据集

因为机器学习的方法是通过训练样本的训练来获取一个具备分类能力的模型,并且这个模型会更倾向于“记住”最后一批训练样本。如果你给模型的训练数据的前一段全是数字0,后一段全是数字1,那么该模型最终就会倾向于更擅长分类数字1。因此,如果要使模型获得较平衡的分类能力,我们需要将训练数据打乱顺序。
测试集仅用于测试,不参与训练,不强制进行混洗,是一个可选的操作。

train_data = np.hstack((train_x, train_y))  # np.hstack表示将两个数组进行水平拼接
test_data = np.hstack((test_x, test_y))  # np.hstack表示将两个数组进行水平拼接
np.random.seed(0)
np.random.shuffle(train_data)  # 打乱train_data数组的行顺序
np.random.shuffle(test_data)  # 打乱test_data数组的行顺序
train_x = train_data[:, :-1]  # 重新取出train_x和train_y
train_y = train_data[:, -1].reshape(-1, 1)
test_x = test_data[:, :-1]  # 重新取出train_x和train_y
test_y = test_data[:, -1].reshape(-1, 1)

#查看图片及标签是否已被打乱

from PIL import Image
batch_size = 10  # 查看10个样本
print(train_y.flatten()[:batch_size].tolist())
batch_img = train_x[0].reshape(28, 28)
for i in range(1, batch_size):
    batch_img = np.hstack((batch_img, train_x[i].reshape(28, 28)))  # 将一批图片水平拼接起来,方便下一步进行显示
Image.fromarray(batch_img)
[1, 0, 0, 1, 0, 0, 1, 1, 0, 0]

4. 数据预处理

大多数机器学习的方法都要先将数据进行预处理再进行学习,预处理的方法有很多种,本案例只对数据进行归一化预处理。
归一化就是指将训练数据的取值范围变成[0, 1]。数据归一化有很多的好处,既可以使学习过程更快,也可以防止某些情况下训练过程出现计算溢出。
图像数据有很多种归一化方式,本案例采用的是整个数组除以255的方式,因为图像数组的最大值是255,除以255就可以使图像数据的取值范围变成[0, 1]。

train_x = train_x.astype(np.float) / 255.0
train_y = train_y.astype(np.float)

test_x = test_x.astype(np.float) / 255.0
test_y = test_y.astype(np.float)

5. 封装成load_data函数

到此,我们就完成了训练数据的准备工作,可以将以上操作封装成load_data函数,以便后面再次用到

%%writefile ../datasets/MNIST_data/load_data_zeros_ones.py
def load_data_zeros_ones(datasets_dir):
    import os
    import numpy as np
    import torchvision.datasets.mnist as mnist

    datasets_dir = '../datasets'
    if not os.path.exists(datasets_dir):
        os.makedirs(datasets_dir)
    import moxing as mox
    if not os.path.exists(os.path.join(datasets_dir, 'MNIST_data.zip')):
        mox.file.copy('obs://modelarts-labs-bj4/course/hwc_edu/deep_learning/datasets/MNIST_data.zip', 
                      os.path.join(datasets_dir, 'MNIST_data.zip'))
        os.system('cd %s; unzip MNIST_data.zip' % (datasets_dir))
    
    # 读取完整训练样本
    train_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-images-idx3-ubyte')).numpy().astype(np.uint8)
    train_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/train-labels-idx1-ubyte')).numpy().astype(np.uint8)
    # 读取完整测试样本
    test_data = mnist.read_image_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-images-idx3-ubyte')).numpy().astype(np.uint8)
    test_label = mnist.read_label_file(os.path.join(datasets_dir, 'MNIST_data/raw/t10k-labels-idx1-ubyte')).numpy().astype(np.uint8)

    train_zeros = train_data[train_label == 0]
    train_ones = train_data[train_label == 1]
    test_zeros = test_data[test_label == 0]
    test_ones = test_data[test_label == 1]

    print('数字0,训练集规模:', len(train_zeros), ',测试集规模:', len(test_zeros))
    print('数字1,训练集规模:', len(train_ones), ',测试集规模:', len(test_ones))
    
    train_x = np.vstack((train_zeros, train_ones))  # 将数字0和1的样本汇总起来,np.vstack表示将两个数组进行垂直拼接
    train_y = np.array([0] * len(train_zeros) + [1] * len(train_ones)).astype(np.uint8)

    test_x = np.vstack((test_zeros, test_ones))  # 将数字0和1的样本汇总起来,np.vstack表示将两个数组进行垂直拼接
    test_y = np.array([0] * len(test_zeros) + [1] * len(test_ones)).astype(np.uint8)
    
    train_x = train_x.reshape(-1, 28*28)  # 每个样本变成一个行向量,因为行向量便于计算
    train_y = train_y.reshape(-1, 1)

    test_x = test_x.reshape(-1, 28*28)  # 每个样本变成一个行向量,因为行向量便于计算
    test_y = test_y.reshape(-1, 1)
    
    train_data = np.hstack((train_x, train_y))  # np.hstack表示将两个数组进行水平拼接
    test_data = np.hstack((test_x, test_y))  # np.hstack表示将两个数组进行水平拼接
    np.random.seed(0)
    np.random.shuffle(train_data)  # 打乱train_data数组的行顺序
    np.random.shuffle(test_data)  # 打乱test_data数组的行顺序
    train_x = train_data[:, :-1]  # 重新取出train_x和train_y
    train_y = train_data[:, -1].reshape(-1, 1)
    test_x = test_data[:, :-1]  # 重新取出train_x和train_y
    test_y = test_data[:, -1].reshape(-1, 1)
    
    train_x = train_x.astype(np.float) / 255.0
    train_y = train_y.astype(np.float)

    test_x = test_x.astype(np.float) / 255.0
    test_y = test_y.astype(np.float)

    return train_x, train_y, test_x, test_y
Overwriting ../datasets/MNIST_data/load_data_zeros_ones.py

6. 定义网络结构

感知机
本案例的网络采用如上图所示的感知机结构,除去输入X和输出y之外,图中的权值W、阈值b、加权求和函数单元、非线性函数单元都需要定义,可以通过下面的代码来实现网络结构的定义

np.random.seed(0)

class Network(object):
    def __init__(self, num_of_weights):
        self.w = np.random.randn(num_of_weights, 1)  # 使用np.random.randn随机生成一个 num_of_weights*1 的列向量,该向量即为权值w
        self.b = 0. # 初始化为 0 的偏置项/阈值
    
    def forward(self, x):  # 定义了网络的前向传播过程:加权求和单元和非线性函数单元通过定义计算过程来实现
        z = np.dot(x, self.w) + self.b  # 加权求和
        pred_y = 1.0 / (1.0 + np.exp(-z))  # 非线性函数sigmoid
        return pred_y

我们随机初始化两个网络,并对同一个样本进行预测

net1 = Network(28*28)
sample = train_x[0] # 表示输入样本
true_y = train_y[0] # 表示样本的真实标签
pred_y_1 = net1.forward(sample)
print('true_y:', true_y, 'pred_y:', pred_y_1)
true_y: [1.] pred_y: [6.12431635e-05]
net2 = Network(28*28)
sample = train_x[0]
true_y = train_y[0]
pred_y_2 = net2.forward(sample)
print('true_y:', true_y, 'pred_y:', pred_y_2)
true_y: [1.] pred_y: [0.99999943]

7. 定义损失函数

上面的两个网络对同一个标签为1的样本进行了预测,net1的预测值是0.00006124,net2的预测值是0.99999943,显然net2的预测值更接近真实值,我们可以做出评价:net2对train_x[0]这个样本的预测效果比net1更好。
我们要设计出一个能自我学习、自我改进的网络,我们就得告诉计算机当前这个网络到底好不好,并且要有一个量化的指标来衡量“好”或“不好”的程度,这个量化的指标在机器学习当中就称为损失值loss。
计算loss有很多种方法,常用的一种方法就是均方误差,计算公式如下:
loss=(pred_ytrue_y)2loss=(pred\_y-true\_y)^2

于是,我们可以计算上面net1和net2两个网络在train_x[0]这个样本上的损失值,可以看到loss2比loss1小

loss1 = (pred_y_1 - true_y)**2
loss2 = (pred_y_2 - true_y)**2
print('loss1:', loss1, 'loss2:', loss2)
loss1: [0.99987752] loss2: [3.22665012e-13]

但是,我们评价某个网络好不好,不是在单个样本上进行评价,而是要在一批样本上进行评价,所以就要计算一批样本的损失值。我们对上面单个样本的损失值计算公式进行改进,就可以得到一批样本的损失值计算公式,具体如下:
loss=1Ni=0N(pred_ytrue_y)2 loss=\frac{1}{N}\sum^{N}_{i=0}{(pred\_y-true\_y)^2}

于是,我们可以使用如下代码来定义网络的损失函数

class Network(object):
    def __init__(self, num_of_weights):
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)  # 使用np.random.randn随机生成一个 num_of_weights*1 的列向量,该向量即为权值W
        self.b = 0.
    
    def forward(self, x):  # 加权求和单元和非线性函数单元通过定义计算过程来实现
        z = np.dot(x, self.w) + self.b  # 加权求和
        pred_y = 1.0 / (1.0 + np.exp(-z))  # 非线性函数sigmoid
        return pred_y

    def loss_fun(self, pred_y, true_y):
        """
        pred_y:网络对一批样本的预测值组成的列向量
        true_y:一批样本的真实标签
        """
        error = pred_y - true_y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples
        return cost

下面我们可以计算十个样本的总体损失值

net3 = Network(28*28)
sample = train_x[0:10]
true_y = train_y[0:10]
pred_y = net3.forward(sample)
print('loss:', net3.loss_fun(pred_y, true_y))
loss: 0.4621188993657137

8. 定义评价函数

定义好损失函数之后,我们还要定义评价函数。这两个函数都是用来评估模型的表现,很多人会混淆它们的含义,下面来解释两者的区别:
(1)损失函数是用于衡量模型的预测值和真实值之间的偏差,偏差越大,梯度就越大,对参数更新的幅度就越大,整个机器学习过程的目标就是是损失函数的值尽可能地小;
(2)评价函数有多种评价指标,常用的指标是准确率,它是用于统计模型预测结果的正确率,比如有100个测试样本,其中有99个都预测正确了,准确率就是99%;

言而简之,损失函数是作用于梯度下降过程的,评价函数是统计模型的评价指标给人看的。

class Network(object):
    def __init__(self, num_of_weights):
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)  # 使用np.random.randn随机生成一个 num_of_weights*1 的列向量,该向量即为权值W
        self.b = 0.
    
    def forward(self, x):  # 前向传播过程 加权求和单元和非线性函数单元通过定义计算过程来实现
        z = np.dot(x, self.w) + self.b  # 加权求和
        pred_y = 1.0 / (1.0 + np.exp(-z))  # 非线性函数sigmoid
        return pred_y

    def loss_fun(self, pred_y, true_y): # 损失函数
        """
        pred_y:网络对一批样本的预测值组成的列向量
        true_y:一批样本的真实标签
        """
        error = pred_y - true_y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples # 平均平方误差(Mean Squared Error, MSE)
        return cost
    
    def evaluate(self, pred_y, true_y, threshold=0.5): # 评估函数
        pred_y[pred_y < threshold] = 0  # 预测值小于0.5,则判为类别0 pred_y < threshold:生成一个布尔数组 pred_y[pred_y < threshold]:选择 pred_y 中所有满足条件(即小于 threshold)的元素。
        pred_y[pred_y >= threshold] = 1

        acc = (pred_y == true_y).float().mean() #pred_y == true_y compares each prediction with the true label, resulting in a boolean array. .float() converts the boolean values to 0s and 1s (False → 0, True → 1).
        return acc

9. 手工推导实现梯度下降算法

实现梯度下降算法就是两个步骤:
(1)求得损失函数对所有参数的梯度 gradient_w;
(2)按照公式 w = w - lr * gradient_w 对所有参数w进行更新

关键的步骤在第一步,如果需从零实现损失函数对所有参数的梯度计算方法,则需要一定的数学基础进行公式推导,对此过程感兴趣的话,可以查看博文《手工推导梯度下降公式》。如果不关注公式的推导过程,可以直接查看下面代码中的 gradient 函数。
第二步的实现很简单,见下面代码中的 update 函数。

class Network(object):
    def __init__(self, num_of_weights):
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)  # 使用np.random.randn随机生成一个 num_of_weights*1 的列向量,该向量即为权值W
        self.b = 0.
    
    def forward(self, x):  # 加权求和单元和非线性函数单元通过定义计算过程来实现
        z = np.dot(x, self.w) + self.b  # 加权求和
        pred_y = 1.0 / (1.0 + np.exp(-z))  # 非线性函数sigmoid
        return pred_y

    def loss_fun(self, pred_y, true_y):
        """
        pred_y:网络对一批样本的预测值组成的列向量
        true_y:一批样本的真实标签
        """
        error = pred_y - true_y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples
        return cost
    
    def evaluate(self, pred_y, true_y, threshold=0.5):
        pred_y[pred_y < threshold] = 0  # 预测值小于0.5,则判为类别0
        pred_y[pred_y >= threshold] = 1

        acc = (pred_y == true_y).float().mean()
        return acc
    
    def gradient(self, x, y, pred_y):
        gradient_w = (pred_y-y)*pred_y*(1-pred_y)*x
        gradient_w = np.mean(gradient_w, axis=0)
        gradient_w = gradient_w[:, np.newaxis]
        gradient_b = (pred_y - y)*pred_y*(1-pred_y)
        gradient_b = np.mean(gradient_b)        
        return gradient_w, gradient_b
    
    def update(self, gradient_w, gradient_b, eta = 0.01):
        self.w = self.w - eta * gradient_w
        self.b = self.b - eta * gradient_b

10. 实现训练函数

机器学习方法的学习模式
训练函数的过程,就是把上图中的2、3、4、5点子过程串起来,详情请查看下面代码中的 train 函数

class Network(object):
    def __init__(self, num_of_weights):
        np.random.seed(0)
        self.w = np.random.randn(num_of_weights, 1)  # 使用np.random.randn随机生成一个 num_of_weights*1 的列向量,该向量即为权值W
        self.b = 0.
    
    def forward(self, x):  # 加权求和单元和非线性函数单元通过定义计算过程来实现
        z = np.dot(x, self.w) + self.b  # 加权求和
        pred_y = 1.0 / (1.0 + np.exp(-z))  # 非线性函数sigmoid
        return pred_y

    def loss_fun(self, pred_y, true_y):
        """
        pred_y:网络对一批样本的预测值组成的列向量
        true_y:一批样本的真实标签
        """
        error = pred_y - true_y
        num_samples = error.shape[0]
        cost = error * error
        cost = np.sum(cost) / num_samples
        return cost
    
    def evaluate(self, pred_y, true_y, threshold=0.5):
        pred_y[pred_y < threshold] = 0  # 预测值小于0.5,则判为类别0
        pred_y[pred_y >= threshold] = 1

        acc = np.mean((pred_y == true_y).astype(np.float))
        return acc
    
    def gradient(self, x, y, pred_y): # 梯度计算 计算权重梯度、偏置梯度
        gradient_w = (pred_y-y)*pred_y*(1-pred_y)*x
        gradient_w = np.mean(gradient_w, axis=0)
        gradient_w = gradient_w[:, np.newaxis]
        gradient_b = (pred_y - y)*pred_y*(1-pred_y)
        gradient_b = np.mean(gradient_b)        
        return gradient_w, gradient_b
    
    def update(self, gradient_w, gradient_b, lr = 0.01): # 权重、偏置更新
        self.w = self.w - lr * gradient_w
        self.b = self.b - lr * gradient_b
    
    def train(self, train_x, train_y, test_x, test_y, max_epochs=100, lr=0.01):
        train_losses = []
        test_losses = []
        train_accs = []
        test_accs = []
        for epoch in range(1, max_epochs + 1):
            pred_y_train = self.forward(train_x)
            gradient_w, gradient_b = self.gradient(train_x, train_y, pred_y_train)
            self.update(gradient_w, gradient_b, lr)              
            if (epoch == 1) or (epoch % 200 == 0):
                pred_y_test = self.forward(test_x)
                train_loss = self.loss_fun(pred_y_train, train_y)
                test_loss = self.loss_fun(pred_y_test, test_y)
                train_acc = self.evaluate(pred_y_train, train_y)
                test_acc = self.evaluate(pred_y_test, test_y)
                print('epoch: %d, train_loss: %.4f, test_loss: %.4f, train_acc: %.4f, test_acc: %.4f' % (epoch, train_loss, test_loss, train_acc, test_acc))
                train_losses.append(train_loss)
                test_losses.append(test_loss)
                train_accs.append(train_acc)
                test_accs.append(test_acc)
        return train_losses, test_losses, train_accs, test_accs

11. 开始训练

训练耗时约600秒

import time
start_time = time.time()
# 创建网络
net = Network(28*28)
max_epochs = 3000
# 启动训练 2C4G的配置,CPU跑满,CPU计算是瓶颈
train_losses, test_losses, train_accs, test_accs = net.train(train_x, train_y, test_x, test_y, max_epochs=max_epochs, lr=0.01)
print('cost time: %.1f s' % (time.time() - start_time))

从上面的结果可以看到,使用感知机模型,花费600秒左右的时间,训练3000个epoch之后达到了0.9073的准确率,这个二分类的准确率比上一节使用传统编程方法的0.9674的准确率还低。

12. 训练过程可视化

将训练过程中的train_loss, test_loss, train_acc, test_acc绘制成曲线图,分析这些指标的变化趋势

import matplotlib.pyplot as plt
%matplotlib inline

# 画出各指标的变化趋势
plot_x = np.arange(0, max_epochs+1, 200)
plot_y_1 = np.array(train_losses)
plot_y_2 = np.array(test_losses)
plot_y_3 = np.array(train_accs)
plot_y_4 = np.array(test_accs)
plt.plot(plot_x, plot_y_1)
plt.plot(plot_x, plot_y_2)
plt.plot(plot_x, plot_y_3)
plt.plot(plot_x, plot_y_4)
plt.show()

接下来,请你思考:

(1)本案例使用的模型是感知机,参数值只有一个长度为784的权值向量w和一个阈值偏置b,采用的参数初始化方式也很简单,用np.random.randn(784, 1)来初始化w,用0来初始化b,其实除了这种初始化方式之外,还有其他很多种初始化方式,并且不同的初始化方式将会影响模型的学习效果,你是否可以实现其他的初始化方式呢?
(2)从上面的曲线图可以判断出模型的训练尚未达到瓶颈,你可以调大max_epochs,看看感知机模型在手写数字0和1的二分类任务上,最高可以达到多高的精度?
(3)本案例使用的损失函数为均方误差函数,如果使用其他函数,那么该如何计算梯度呢?

本节扩展学习材料

神经网络基础概念
参数初始化

第四节 使用Pytorch框架重写手写数字识别二分类

案例内容介绍

上一节我们从零开始实现了感知机的模型结构、定义了损失函数和评价函数,并且手动推导了梯度下降的公式,最终经过3000个epoch的训练,使得感知机模型在手写数字0和1的二分类任务上达到了0.9以上的准确率。
可能你已经感受到,这个从零开始实现整个机器学习过程的方式是比较费劲的,特别是在手动推导梯度下降公式这一块,需要一定的数学知识。如果我们更换另一个损失函数,那又得再重新推导一遍新的梯度下降公式,这对调模型来说是费力的事情。
好在当今已经有很多的深度学习框架,它们已经友好地封装了模型结构定义、损失函数定义、梯度下降实现等过程,只需要进行一些简单的函数调用,就可以实现完成的机器学习训练过程,无需关注底层的梯度下降是如何实现的,极大地提高了模型开发的效率。
下面,我们就用Pytorch框架来重写手写数字二分类代码

1. 加载数据集

由于上一节已经定义了load_data_zeros_ones函数,所以在本节我们直接进行调用即可

import os
import sys
sys.path.insert(0, os.path.join(os.getcwd(), '../datasets/MNIST_data'))
from load_data_zeros_ones import load_data_zeros_ones

datasets_dir = '../datasets'
train_x, train_y, test_x, test_y = load_data_zeros_ones(datasets_dir)
数字0,训练集规模: 5923 ,测试集规模: 980
数字1,训练集规模: 6742 ,测试集规模: 1135

load_data_zeros_ones函数返回的数据格式是np.ndarray格式,但是在Pytorch中要求的格式是torch.tensor格式,因此要执行下面的代码进行数据格式转换

import torch
import numpy as np

train_x = torch.tensor(train_x.astype(np.float32))
train_y = torch.tensor(train_y.astype(np.float32))
test_x = torch.tensor(test_x.astype(np.float32))
test_y = torch.tensor(test_y.astype(np.float32))

2. 定义网络结构

使用Pytorch实现感知机模型非常简单,只需要调用nn.Linear定义一个全连接层,再加上一个Sigmoid单元即可,并且nn.Linear会对权值w和阈值偏置b自动进行初始化,代码如下:

from torch import nn

class Network(nn.Module):
    def __init__(self, num_of_weights):
        torch.manual_seed(0)
        super().__init__()
        self.fc = nn.Linear(in_features=num_of_weights, out_features=1, bias=True)  # 定义一个全连接层
        self.nonlinearity = nn.Sigmoid()
    
    def forward(self, x):  # 加权求和单元和非线性函数单元通过定义计算过程来实现
        z = self.fc(x)
        pred_y = self.nonlinearity(z)
        return pred_y

3. 定义损失函数

Pytorch支持很多种损失函数,都定义torch.nn.functional模块中,我们直接使用该模块中的mse_loss函数,这就是均方误差函数,代码如下:

import torch.nn.functional as F
loss_fun = F.mse_loss

4. 定义评价函数

评价函数直接复用上一节的定义即可

class Network(nn.Module):
    def __init__(self, num_of_weights):
        torch.manual_seed(0)
        super().__init__()
        self.fc = nn.Linear(in_features=num_of_weights, out_features=1, bias=True)  # 定义一个全连接层
        self.nonlinearity = nn.Sigmoid()
    
    def forward(self, x):  # 加权求和单元和非线性函数单元通过定义计算过程来实现
        z = self.fc(x)
        pred_y = self.nonlinearity(z)
        return pred_y
   
    def evaluate(self, pred_y, true_y, threshold=0.5):
        pred_y[pred_y < threshold] = 0  # 预测值小于0.5,则判为类别0
        pred_y[pred_y >= threshold] = 1

        acc = (pred_y == true_y).float().mean()
        return acc

5. 一行代码实现梯度下降算法

Pytorch框架有一个特性,称为:自动微分,微分就是求导的意思,自动微分意味着Pytorch框架能对任意函数进行自动求导,也就是说使用Pytorch框架可以定义任意的网络、任意的损失函数,它都能自动地求导得出参数的梯度,根本就不需要我们进行任何的公式推导过程。
Pytorch还支持很多种梯度下降的优化器,都定义在torch.optim模块中,我们只需要一行代码,直接调用该模块中的SGD函数即可,代码如下:

net = Network(28*28)  # 创建网络
optimizer = torch.optim.SGD(net.parameters(), lr=0.01)  # 实现梯度下降

6. 实现训练函数

一个模型的训练过程就是:1)前向传播;2)计算损失;3)计算梯度;4)更新权值,把这些过程拼装起来即可

def train(net, train_x, train_y, test_x, test_y, max_epochs=100):
    train_losses = []
    test_losses = []
    train_accs = []
    test_accs = []
    for epoch in range(1, max_epochs + 1):
        net.train()  # 切换为训练模式
        pred_y_train = net.forward(train_x)  # 前向传播
        train_loss = loss_fun(pred_y_train, train_y)  # 计算损失

        # 计算梯度,更新权值
        train_loss.backward()
        optimizer.step()
        optimizer.zero_grad()

        if (epoch == 1) or (epoch % 10 == 0):
            net.eval()  # 切换为评价模式,评价模式不计算梯度,计算更快
            pred_y_test = net.forward(test_x)
            test_loss = loss_fun(pred_y_test, test_y)
            train_acc = net.evaluate(pred_y_train, train_y)
            test_acc = net.evaluate(pred_y_test, test_y)
            print('epoch %d, train_loss %.4f, test_loss %.4f, train_acc: %.4f, test_acc: %.4f' % (epoch, train_loss.item(), test_loss.item(), train_acc, test_acc))
    return train_losses, test_losses, train_accs, test_accs

7. 开始训练

训练耗时约1秒

import time
start_time = time.time()
max_epochs = 50
train_losses, test_losses, train_accs, test_accs = train(net, train_x, train_y, test_x, test_y, max_epochs=max_epochs)
print('cost time: %.1f s' % (time.time() - start_time))
epoch 1, train_loss 0.2319, test_loss 0.2241, train_acc: 0.7596, test_acc: 0.8293
epoch 10, train_loss 0.1780, test_loss 0.1723, train_acc: 0.9811, test_acc: 0.9853
epoch 20, train_loss 0.1397, test_loss 0.1350, train_acc: 0.9914, test_acc: 0.9957
epoch 30, train_loss 0.1138, test_loss 0.1097, train_acc: 0.9928, test_acc: 0.9976
epoch 40, train_loss 0.0954, test_loss 0.0915, train_acc: 0.9935, test_acc: 0.9981
epoch 50, train_loss 0.0818, test_loss 0.0782, train_acc: 0.9938, test_acc: 0.9986
cost time: 0.9 s

从上面的结果可以看到,使用Pytorch实现的感知机模型,仅使用1秒的时间,训练了50个epoch之后就达到了0.9986的准确率,相比上一节的实现,训练又快又好,这说明使用Pytorch来进行模型的开发,不仅开发效率更高,实现的结果也更优,这就是使用深度学习框架带来的优势。

接下来,请你思考:

(1)Pytorch的nn.Linear函数自动对感知机的权值w和阈值偏置b自动进行了初始化,请你自行查阅资料,了解一下它使用的参数初始化方式是什么?
(2)torch.optim模块中定义了很多种梯度下降优化器,本案例中使用的是SGD方法,你能否换一种方法,看看训练效果怎样?
(3)本案例我们使用Pytroch框架快速实现了手写数字识别的二分类?那么,我们怎样才能推广到完整的十分类任务?

鼓励你在ModelArts论坛写下你的想法,与其他开发者进行交流!

本节扩展学习材料

torch.nn.Linear
优化器介绍

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

作者其他文章

评论(0

抱歉,系统识别当前为高风险访问,暂不支持该操作

    全部回复

    上滑加载中

    设置昵称

    在此一键设置昵称,即可参与社区互动!

    *长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

    *长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。