Beacon学习周记2(2022暑期项目)

2022.7.29

学习总结

本周了解了TensorFlow、Keras,并了解了使用Python语言并应用TensorFlow进行机器学习的基本方法。

本周还阅读了Beacon的Github上的源代码,并对其中一部分做了注释以表达我对代码的疑问和见解。

在本周伊始的时候,我先是看Beacon代码。但妄图没学会走路就想跑的我读到第一行就吃不消了。我对代码引入的tensorflow库很感兴趣,并发现接下来的代码全是与它有关的,于是我不得不好好了解tensorflow及机器学习,做一个初步的入门。于是看代码被搁置到后面进行。

机器学习(Machine Learning,ML)学习总结

我学习机器学习的方法是结合一个GitHub上的教程

lmoroney/mlday-tokyo: Colabs for ML Day Tokyo (github.com)

以及TensorFlow油管官方账号的系列视频ML Zero to Hero(链接中仅是第一集)

机器学习导论(ML Zero to Hero, part 1) - YouTube

进行的。

现直接将一个服装图片分类器的训练代码展示如下,稍后我会对其各部分进行总结、分析:

import tensorflow as tf
print(tf.__version__)
mnist = tf.keras.datasets.fashion_mnist
(training_images, training_labels), (test_images, test_labels) = mnist.load_data()
training_images=training_images.reshape(60000, 28, 28, 1)
training_images=training_images / 255.0
test_images = test_images.reshape(10000, 28, 28, 1)
test_images=test_images/255.0
model = tf.keras.models.Sequential([
  tf.keras.layers.Conv2D(64, (3,3), activation='relu', input_shape=(28, 28, 1)),
  tf.keras.layers.MaxPooling2D(2, 2),
  tf.keras.layers.Conv2D(64, (3,3), activation='relu'),
  tf.keras.layers.MaxPooling2D(2, 2),
  tf.keras.layers.Flatten(),
  tf.keras.layers.Dense(128, activation='relu'),
  tf.keras.layers.Dense(10, activation='softmax')
])
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])
model.summary()
model.fit(training_images, training_labels, epochs=5)
test_loss = model.evaluate(test_images, test_labels)

首先先是代码的前两行:

import tensorflow as tf
print(tf.__version__)

引入了tensorflow库。这个库帮助我们使用Keras这个API。事实上,TensorFlow是Keras的后端。

然后打印TensorFlow的版本。

接下来是数据集的获取部分:

mnist = tf.keras.datasets.fashion_mnist
(training_images, training_labels), (test_images, test_labels) = mnist.load_data()

这里使用的是Fashion MNIST数据集。很厉害也很令我惊奇的一点是,数据集居然直接内置在Keras里,敲一行Python代码就能获取到。

第二行分别提取出训练集和测试集

接下来是数据预处理部分:

training_images=training_images.reshape(60000, 28, 28, 1)
training_images=training_images / 255.0
test_images = test_images.reshape(10000, 28, 28, 1)
test_images=test_images/255.0

这个数据集是包含有10种服装的图像的数据集,有60000个训练样本和10000个测试样本。每个样本是28x28像素的灰度图像,灰度取值0-255,所以神经网络的张量(tensor)分别是60000x28x28x1(其中1是指灰度,若是RGB图像则为3)和10000x28x28x1。将每个元素除以255,就可以很方便的归一化。

接下来是网络构建部分:

model = tf.keras.models.Sequential([
  tf.keras.layers.Conv2D(64, (3,3), activation='relu', input_shape=(28, 28, 1)),
  tf.keras.layers.MaxPooling2D(2, 2),
  tf.keras.layers.Conv2D(64, (3,3), activation='relu'),
  tf.keras.layers.MaxPooling2D(2, 2),
  tf.keras.layers.Flatten(),
  tf.keras.layers.Dense(128, activation='relu'),
  tf.keras.layers.Dense(10, activation='softmax')
])

这里做了两组卷积+池化的操作。这是CNN(卷积神经网络)的常规操作。

对于二维的图像而言,卷积(convolution)就是对图像中的每个像素点的值重新赋值。重新计算值的方法如下:

卷积

将某一像素点周围的像素考虑进来,如上图则考虑了值为192的点周围的3x3的区域。有一个可学习的过滤器,它是一个和该区域同型(3x3)的矩阵。将两矩阵的对应元素相乘(Hadamard方法,或许这就类似于“元素感知”吧),再相加,得到新的值就是值为192的点的新值。

这里要注意一点,过滤器是可学习的,一般神经网络会依照用户要求产生指定数量个随机的过滤器,在卷积的过程中衡量损失、拟合度,不断选出最好的过滤器。

还有一点,过滤器的所有元素之和最好为1。我认为是为了计算出来的新值仍处在原来的范围内(本例是0-255)。

卷积的用处是增强图像的某些信息并凸显出特定的特点,从而让机器学习更有效率,能够在样本有限的情况下有更高的准确度。

有两个很不错的、有意思的例子: 2个例子

当过滤器竖中线元素均为0时,会凸显出图像的垂直线条;横中线为0则凸显水平线条。但原因我不了解,这里做一个疑问。

事实上,我查找资料,发现还有其他各种不同的、有趣的过滤器。有模糊化的、锐化的等等。

Kernel (image processing) - Wikipedia

我现在特别想知道为什么这些矩阵充当过滤器可以产生对应的效果。例如我曾思考,比如上图的第一个矩阵,其竖中线为0。做相乘累加的操作后,相当于不考虑选中像素那一列的像素值,而仅依靠其周边两列的值。但为什么这样就能凸显垂直线条呢? 接下来是池化(pooling):

池化

如图显示的是最大池化(maxpooling)的过程。池化做了一件压缩图像并仍保留图像特征的事情。

如图,若使用2x2最大池化,则将图像分成若干个2x2的块,将这一块作为一个新像素点,值为块中四个像素的最大值。这样,原图被压缩成1/4大小,但看起来仍和原来差不多,做到了保留特征的同时却让神经网络训练不那么繁复,提高了效率。

所以上述代码做了两次卷积和两次池化。对于卷积,均是产生了64个过滤器,卷积范围为3x3,激活函数是relu。对于池化,均是做2x2最大池化

需要注意的是,每一个卷积、池化都是一个神经网络层。数据在这些层中前后转移。

接下来还有几个层。

平滑层(flatten)用于将输入张量降维,变成一维的。

下面是两个全连接层(dense),分别有128个和10个神经元。激活函数分别是relu和softmax。

这里浅写一下relu和softmax。

relu将大于0的部分保留,小于0的置为0。

softmax将一组数据中最大的置为1,其余的置为0。

特别注意,第一层(第一个卷积层)和最后一层(第二个全连接层)是要分别和输入和输出的shape对应的。例如第一层特别指出了input_shape=(28, 28, 1),最后一层神经元为10个,正好对应了有10种服装。

接下来是模型编译:

model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy'])

这里有两个神经网络重要的概念:优化函数和损失函数。

损失函数(loss)衡量每次训练后与正确结果的误差;优化函数(optimizer)提供新的方向去进一步拟合正确结果。

这里用adam作为优化函数,sparse_categorical_crossentropy(稀疏分类交叉熵)作为损失函数。

这里简单提一下交叉熵(crossentropy)。

熵本来是无损编码事件信息的最小平均编码长度。

估计的概率分布为Q。假设经过观测后,我们得到了真实概率分布P,在预测时,就可以使用P计算平均编码长度,实际编码长度基于Q计算,这个计算结果就是P和Q的交叉熵。这样,实际编码长度和理论最小编码长度就有了对比的意义。

如下图:

交叉熵

这里有一个one-hot标签。两机器Q1、Q2以概率形式给出了预测结果。谁的预测更好呢?经计算,Q2的交叉熵小,并且很接近0。则Q2好。对于交叉熵,越小、越接近0,则表示预测越准。预测完全正确时,交叉熵就为0。所以交叉熵可以作为损失函数。

回到正题,最后是个训练结束后要分析的指标(metrics)。这里是让模型每一次训练结束后打印出当前准确度(accuracy)。

接下来是打印模型摘要部分:

model.summary()

摘要(summary)会打印出模型的各层简略信息。

接下来是模型拟合部分:

model.fit(training_images, training_labels, epochs=5)
test_loss = model.evaluate(test_images, test_labels)

这一部分主要制定了训练的epoch数。这里是5次。

一个epoch大概相当于数据沿网络各层前向、后向传播一次,也就是一次训练。

还指示了用于评估损失的测试集。

至此,一个CNN的大致框架分析完毕。

事实上,简单、普通的NN,大致只需要平滑层和全连接层。这是神经网络不可或缺的部分。

Beacon源码学习总结

学完上面一大堆东西后就正式开始看代码了。

GitHub上的README.md开头就说cmatrix_generator.py是用来生成论文中相关矩阵C的。这也的确是整个工程的第一步,于是我从这个源码开始看起。不料看到第三行再次“汗颜”,寻思着这导入的utils是什么,原来是另一个Beacon写好的源码文件。于是我转而阅读utils.py去了。

我对utils.py的学习总结都以注释的形式写出,所以接下来我把utils.py中的一部分我已经阅读完的代码加上我的注释复制粘贴在此:

import scipy.sparse as sp
import numpy as np, os, re, itertools, math


# build_knowledge总结:
# 1.输出的最大序列长度是囊括训练集、验证集的实际最大长度-1
# 2.将训练集、验证集中所有序列的最后一个或两个篮子中的物品算进频度
def build_knowledge(training_instances, validate_instances):
    MAX_SEQ_LENGTH = 0  # 最大序列长度
    item_freq_dict = {}  # 物品频度字典

    for line in training_instances:  # training_instances是训练集,这个training_instances是train.txt按行读取后的内容。每个line是一个篮子序列
        elements = line.split("|")  # 生成每个line所表示的蓝子列表,len(elements)即序列中的篮子个数(对应README.md中所说的格式:篮子之间用"|"分割)

        if len(elements) - 1 > MAX_SEQ_LENGTH:  # 貌似若仅一个篮子,则最大序列长度依然是0,若有两个篮子,就会为1,所以实际上最大篮子序列长度会-1,因为第一个"篮子"永远是0或1
            MAX_SEQ_LENGTH = len(elements) - 1

        if len(elements) == 3:  # 3个篮子(实际长度为2)的序列如此特殊
            basket_seq = elements[1:]  # 取第二个篮子和第三个篮子(也就是两个实际的篮子)作为basket_seq
        else:
            basket_seq = [elements[-1]]  # 否则若序列的篮子数不为3就只取最后一个篮子作为basket_seq。官方回答是This method is to build the adjacency matrix. In order to avoid duplicate counts on computing pair frequency, hence I ignore baskets that are already considered.

        for basket in basket_seq:  # 对于basket_seq中的每个篮子
            item_list = re.split('[\\s]+', basket)  # item_list即是该篮子中物品的列表(对应README.md中所说的格式:篮子之间用" "分割,其中[\\s]+匹配任意多个空白字符)
            for item_obs in item_list:  # 对于item_list中的每个物品
                if item_obs not in item_freq_dict:  # 如果这个item_freq_dict字典中没有以item_obs物品为键的键值对的话
                    item_freq_dict[item_obs] = 1  # 添加该物品并置频度为1
                else:
                    item_freq_dict[item_obs] += 1  # 频度+1

    for line in validate_instances:  # 验证集
        elements = line.split("|")

        if len(elements) - 1 > MAX_SEQ_LENGTH:
            MAX_SEQ_LENGTH = len(elements) - 1

        label = int(elements[0])  # label是该篮子序列的第一个篮子
        if label != 1 and len(elements) == 3:  # 经过查看,在训练集、验证集、测试集文件中,确实只有validate.txt中的篮子序列,第一个"篮子"有可能出现"1",其他两个数据集这一处只可能是"0"
            basket_seq = elements[1:]
        else:
            basket_seq = [elements[-1]]

        for basket in basket_seq:
            item_list = re.split('[\\s]+', basket)
            for item_obs in item_list:
                if item_obs not in item_freq_dict:
                    item_freq_dict[item_obs] = 1
                else:
                    item_freq_dict[item_obs] += 1  # 验证集数据也算进总频度字典中了

    items = sorted(list(item_freq_dict.keys()))  # items是一个按物品各自的ID升序排序的物品列表,列出所有出现过的物品
    item_dict = dict()
    item_probs = []  # 可能性?
    for item in items:
        item_dict[item] = len(item_dict)  # 物品ID可能不连续有跳跃,例如items可能为[1,2,4,7,9],现在我们把这五个重新命名为第0号物品、第1号物品...第四号物品,item_dict此时就为{1: 0, 2: 1, 4: 2, 7: 3, 9: 4}(见附录第一条)
        item_probs.append(item_freq_dict[item])  # 复制频度,相当于把item_freq_dict按键升序排列后依次提取值(频度)组成item_probs列表

    item_probs = np.asarray(item_probs, dtype=np.float32)  # 将item_probs转化为ndarray类型,其中每个频度又被转化成32位浮点数格式
    item_probs /= np.sum(item_probs)  # 归一化了,现在每个元素体现出"占比"

    reversed_item_dict = dict(zip(item_dict.values(), item_dict.keys()))  # reversed_item_dict为{0: 1, 1: 2, 2: 4, 3: 7, 4: 9}
    return MAX_SEQ_LENGTH, item_dict, reversed_item_dict, item_probs  # build_knowledge方法接收训练集、验证集,返回最长篮子序列长度、ID-序号字典、序号-ID字典、按ID升序的物品频度可能性数组

def build_sparse_adjacency_matrix_v2(training_instances, validate_instances, item_dict):
    NB_ITEMS = len(item_dict)  # 所有出现过的物品的数量,不过貌似是最后一个或两个篮子中的物品才会被统计到

    pairs = {}
    for line in training_instances:
        elements = line.split("|")

        if len(elements) == 3:
            basket_seq = elements[1:]
        else:
            basket_seq = [elements[-1]]

        for basket in basket_seq:
            item_list = re.split('[\\s]+', basket)
            id_list = [item_dict[item] for item in item_list]  # 将每个篮子中物品的序号(原始顺序的)依次存入id_list(例如[4,2,0,1,3])

            for t in list(itertools.product(id_list, id_list)):  # 做笛卡尔积,结合外层的两循环,产生了统计训练集中,每个篮子序列最后一个或两个篮子中共同出现的物品对的频度(对于共同出现的物品A、B,A与B、B与A分别统计1次)
                add_tuple(t, pairs)

    for line in validate_instances:
        elements = line.split("|")

        label = int(elements[0])
        if label != 1 and len(elements) == 3:
            basket_seq = elements[1:]
        else:
            basket_seq = [elements[-1]]

        for basket in basket_seq:
            item_list = re.split('[\\s]+', basket)
            id_list = [item_dict[item] for item in item_list]

            for t in list(itertools.product(id_list, id_list)):  # 验证集的物品对频度也被累计统计到了
                add_tuple(t, pairs)

    return create_sparse_matrix(pairs, NB_ITEMS)

# add_tuple作用是让pairs来记录出现在同一篮子中的两个不同物品构成的二元组出现的频度(论文中的F)
def add_tuple(t, pairs):  
    assert len(t) == 2  # 确保一下是二元组
    if t[0] != t[1]:
        if t not in pairs:
            pairs[t] = 1
        else:
            pairs[t] += 1

# create_sparse_matrix作用是产生论文中的矩阵F的压缩形态(多个坐标+值的三元组形态,因为可能太稀疏了)
def create_sparse_matrix(pairs, NB_ITEMS):
    row = [p[0] for p in pairs]  # 将pairs字典中的键(序号二元组)中的第一个元素提取出来做成列表row
    col = [p[1] for p in pairs]  # 将pairs字典中的键(序号二元组)中的第二个元素提取出来做成列表col
    data = [pairs[p] for p in pairs]  # 将pairs字典中的值(频度)提取出来做成列表data

    adj_matrix = sp.csc_matrix((data, (row, col)), shape=(NB_ITEMS, NB_ITEMS), dtype="float32")  # 此时adj_matrix就是F的压缩形态。具体而言,将物品按从0开始挨个编序后,这样的序号构成的二元组就是pairs字典的键,其值是频度。sp.csc_matrix以坐标+值的方式构造出来NB_ITEMSxNB_ITEMS大小的矩阵。也就是把这个矩阵中row[0],col[0]的坐标处的值置为data[0](32位浮点数格式),也就是频度
    nb_nonzero = len(pairs)  # adj_matrix中非零元素的个数
    density = nb_nonzero * 1.0 / NB_ITEMS / NB_ITEMS  # adj_matrix的密度
    print("Density: {:.6f}".format(density))  # 打印密度,保留6位小数

    return sp.csc_matrix(adj_matrix, dtype="float32")

这里尤其指出一点,以上这些我目前看完的代码段正在努力生成论文中的F矩阵。

难题与解决思路

基本上都在前文和注释中写到了。

比如想了解更多有关卷积过滤器的知识,尤其是为什么特定过滤器能产生特定效果。

另外在utils.py中:

training_instances和validate_instances是什么?应该是训练集和验证集没错了。我目前的分析均是在这个认知之上的。

为什么只取最后一个或两个篮子?

训练集中,为什么篮子数量为3的篮子序列那么特殊?还有validate_instances当中的label又是什么?label为1代表什么?为什么label为1且有三个篮子的验证集序列也是很特殊的?

为什么最大序列长度是实际-1?

源代码方面的问题,我觉得继续读源码可能就能解决,搞明白这些举动的意图;过滤器那个问题等有空查查资料研究研究。

继续读源码效果不大,解决不了上述划掉的问题。

但是它们还是解决了,在看过数据集长什么样以后。解释我上面代码中有写到。

比如其实数据集第一个“篮子”是标签,所以序列长度-1。另外其实三个篮子的序列其实就是两个篮子的序列,为了扩大信息所以不止收集最后一个篮子的信息。另外只取最后一个篮子本身就是build knowledge的目的,很大程度上避免了冗余信息的影响。

心得收获

NN很好玩,而且其实代码压力不大,代码不过寥寥数行。

源码一定要时时刻刻联系着论文看。想办法做到一一对应。

下周计划

阅读代码阅读代码阅读代码,加油!