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

2022.8.5

学习总结

本周完成了上次遗留的任务:了解RNN、LSTM、GRU。

还看了不少代码,Beacon代码目前接近看完了。

RNN

循环神经网络(Recurrent Neural Network,RNN)与传统的神经网络不同。

传统的神经网络隐藏层是全连接层,每一层节点之间并无联系,而是独立的。但这样无法处理序列,即无法处理前一个数据和后一个数据的关系。序列按时序展开后,每个节点并不是独立的,为此便引入了RNN。

RNN最大的特点就是隐藏层之间的节点不再无连接而是有连接的,并且隐藏层的输入不仅包括输入层的输出还包括上一时刻隐藏层的输出。这使得RNN可以依靠节点之间信息的传递记忆一串序列早期(头部)的信息,并传递给之后的节点,尽管越早的记忆会伴随着越大的衰减。

这是常见的RNN结构:

常见的RNN结构

但是普通的RNN有一个很大的问题。

普通的RNN共享同一组参数(U,W,b),因此模型在反向传播时会连乘这些参数矩阵,导致产生梯度爆炸/消失的问题(越乘越大/小)。

由此引入了LSTM和GRU这两种RNN的改进方案。

LSTM

相比RNN只有一个传输状态,LSTM有两个传输状态,一个cell state,和一个hidden state。cell state变化慢,hidden state变化快。

LSTM模型有三个门控:选择门zi、遗忘门zf、输出门zo。

LSTM模型三个门控

如图,通过调整zf和zi,我们选择性“忘记”上一时刻状态ct-1,然后选择性“记住”现在的输入z。

最后zo负责决定哪些将会被当成当前状态的输出。

LSTM通过门控状态来控制传输状态,记住需要长时间记忆的,忘记不重要的信息,而不像普通的RNN那样仅有一种记忆叠加方式,有效地缓解了梯度爆炸/消失问题。

GRU

GRU同样是解决梯度爆炸/消失问题的。而且是在LSTM上做改进的。

相比于LSTM,GRU仅使用两个门,比LSTM少一个。GRU因此拥有更少计算量,但效果却和LSTM几乎一样。

GRU使用重置门rt和更新门zt。

GRU公式

结合前两个式子,首先,我们先通过上一个传输下来的状态ht−1和当前节点的输入xt来获取两个门控状态。

在第三个式子中,用rt进行重置,将重置后的状态加入当前隐藏状态中,有效地记忆了当前时刻的状态。

最后用zt同时进行记忆和遗忘。zt负责记住当前节点信息;1-zt负责忘记传递过来的信息。这一举两得的操作是GRU比LSTM少一个门的关键所在。

Beacon源码学习总结

接着上周进度,先把utils.py中的剩余部分注释完。

# 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")

def seq_batch_generator(raw_lines, item_dict, batch_size=32, is_train=True):  # raw_lines是什么?是数据集吗?
    total_batches = compute_total_batches(len(raw_lines), batch_size)  # 32个篮子序列是一批,应该是训练时的批次大小
    
    O = []
    S = []
    L = []
    Y = []

    batch_id = 0
    while 1:
        lines = raw_lines[:]

        if is_train:
            np.random.shuffle(lines)  # 还要把篮子序列之间打乱一下

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

            #label = float(elements[0])  # elements的第一个元素是标签
            bseq = elements[1:-1]  # 去头去尾(头是标签,尾是Target Basket)
            tbasket = elements[-1]  # 最后一个

            # Keep the length for dynamic_rnn
            L.append(len(bseq))  # bseq的长度。看起来是有效篮子序列的长度(这里暂时认为头是标签,尾是供学习的)。每分析一个序列就让L增加一个元素

            # Keep the original last basket
            O.append(tbasket)  # O专存每个序列中供学习的那个最后一个篮子,但由于这一个篮子也是个列表,所以O是列表的列表

            # Add the target basket
            target_item_list = re.split('[\\s]+', tbasket)  # 这个叫目标物品列表。它是最后一个篮子中的物品列表。看起来它真的是供学习的
            Y.append(create_binary_vector(target_item_list, item_dict))  # 创建最后一个篮子的二进制向量表示,并把这个数组加入Y列表中

            s = []
            for basket in bseq:
                item_list = re.split('[\\s]+', basket)
                id_list = [item_dict[item] for item in item_list]  # id_list存的是每一个篮子里物品的序号(源码这里叫ID,但此ID非我前面写的ID。此ID是我所说的序号(0,1,2,...),我说的ID类似于物品名字(独一无二的标识)
                s.append(id_list)  # s是每个篮子物品序号列表的列表,存储了bseq中所有篮子的信息,相当于一个篮子序列
            S.append(s)  # S是篮子序列的列表

            if len(S) % batch_size == 0:  # 若满一批
                yield batch_id, {'S': np.asarray(S), 'L': np.asarray(L), 'Y': np.asarray(Y), 'O': np.asarray(O)}  # 则输出批次号,一个字典,字典中存储着物品、篮子详情S,每个篮子序列长度L,最后一个篮子二进制向量表示数组Y(应该是用于验证、学习),最后一个篮子数组O
                S = []
                L = []
                O = []
                Y = []  # 清空S、L、O、Y,为下一批次的重新再次输出做初始化
                batch_id += 1  # 批次号+1

            if batch_id == total_batches:
                batch_id = 0
                if not is_train:
                    break

# create_binary_vector作用是创建数组v,相当于论文中的xt,即篮子的二进制向量表示
def create_binary_vector(item_list, item_dict):  # item_dict是ID-序号字典,item_list是目标物品列表(target_item_list)
    v = np.zeros(len(item_dict), dtype='int32')
    for item in item_list:
        v[item_dict[item]] = 1
    return v

# list_directory作用是依据dir_only的值,输出dir目录下的所有文件夹或者所有文件
def list_directory(dir, dir_only=False):
    rtn_list = []
    for f in os.listdir(dir):
        if dir_only and os.path.isdir(os.path.join(dir, f)):
            rtn_list.append(f)
        elif not dir_only and os.path.isfile(os.path.join(dir, f)):
            rtn_list.append(f)
    return rtn_list

# create_folder作用是创建dir目录
def create_folder(dir):
    try:
        os.makedirs(dir)
    except OSError:
        pass

# read_file_as_lines返回文件中每一行为元素的列表,也就是所谓的按行读文件(行末换行符去除)
def read_file_as_lines(file_path):
    with open(file_path, "r") as f:
        lines = [line.rstrip('\n') for line in f]
        return lines

# recent_model_dir作用是返回dir目录下所有文件夹中,文件名_后面部分值最大的那个文件夹
def recent_model_dir(dir):
    folderList = list_directory(dir, True)
    folderList = sorted(folderList, key=get_epoch)  # 依据文件夹名下划线后面的内容进行升序排序
    return folderList[-1]  # 返回最后一个文件夹

# get_epoch作用是返回的是x中_之后的内容
def get_epoch(x):
    idx = x.index('_') + 1
    return int(x[idx:])  # 返回的是x中_之后的内容

# compute_total_batches作用是返回批数量
def compute_total_batches(nb_intances, batch_size):
    total_batches = int(nb_intances / batch_size)
    if nb_intances % batch_size != 0:
        total_batches += 1  # 说白了就是nb_intances / batch_size向上取整
    return total_batches

# create_identity_matrix作用是返回压缩形态的nb_items阶单位矩阵
def create_identity_matrix(nb_items):
    return sp.identity(nb_items, dtype="float32").tocsr()

# create_zero_matrix作用是返回压缩形态的nb_items阶全0矩阵
def create_zero_matrix(nb_items):
    return sp.csr_matrix((nb_items, nb_items), dtype="float32")

# normalize_adj作用是返回论文中的C的压缩形态
def normalize_adj(adj_matrix):
    """Symmetrically normalize adjacency matrix."""  # 对称地归一化邻接矩阵
    row_sum = np.array(adj_matrix.sum(1))  # row_sum是个数组,类似于论文中的度矩阵D,存储了adj_matrix的每行元素之和
    d_inv_sqrt = np.power(row_sum, -0.5).flatten()  # d_inv_sqrt各个元素都是row_sum对应元素的-0.5次幂
    d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.  # 把修改后的D中的正、负无穷大的位置置为0.
    d_mat_inv_sqrt = sp.diags(d_inv_sqrt)  # 现在真正是论文中的D了(对角线元素就是度的-0.5次幂)

    normalized_matrix = adj_matrix.dot(d_mat_inv_sqrt).transpose().dot(d_mat_inv_sqrt)  # D^-1/2FD^-1/2(利用矩阵乘积的转置是逆序的转置的乘积的这条性质)

    return normalized_matrix.tocsr()  # 返回归一化后的、论文中的C的压缩形态

# remove_diag作用是通过将adj_matrix对角线元素置零的办法,结合去零函数,直接在压缩形态中去除adj_matrix的对角线元素的信息
def remove_diag(adj_matrix):
    new_adj_matrix = sp.csr_matrix(adj_matrix)  # adj_matrix的压缩形态
    new_adj_matrix.setdiag(0.0)  # 将adj_matrix对角线设为0.0,这样会强制在压缩形态中加入对角线元素的项(坐标+值的三元组)
    new_adj_matrix.eliminate_zeros()  # 将压缩形态中值为0的项全去掉(不再存储在压缩形态中)
    return new_adj_matrix  # 把处理好的压缩形态返回

这里着重注意其中的normalize_adj方法。该方法返回论文中的C矩阵。

接下来是cmatrix_generator.py。

import tensorflow as tf
import scipy.sparse as sp
import utils

# Model hyper-parameters  # 机器学习中需要人为设定的参数(一般用于调优)叫超参数,它不是学习得到的模型本身的参数
tf.flags.DEFINE_string("data_dir", None, "The input data directory (default: None)")  # 定义了一个字符串,该字符串叫data_dir,含义是输入数据的文件夹路径,默认为空
tf.flags.DEFINE_integer("nb_hop", 1, "The order of the real adjacency matrix (default:1)")  # 定义了一个整型量,叫nb_hop,含义是真正的邻接矩阵阶数,默认为1(README.md中说该参数用来生成N阶相关矩阵)
# tf.flags.DEFINE_string('f', '', 'kernel')

config = tf.flags.FLAGS  # 命令行控制台。命令行迎合了需要一种灵活的方式对代码某些参数进行调整的需求
print("---------------------------------------------------")
print("Data_dir = " + str(config.data_dir))
print("\nParameters: " + str(config.__len__()))  # 有几个超参数
for iterVal in config.__iter__():
    print(" + {}={}".format(iterVal, config.__getattr__(iterVal)))
print("Tensorflow version: ", tf.__version__)
print("---------------------------------------------------")

SEED_VALUES = [2, 9, 15, 44, 50, 55, 58, 79, 85, 92]

# ----------------------- MAIN PROGRAM -----------------------
data_dir = config.data_dir
output_dir = data_dir + "/adj_matrix"  # 输出文件夹叫adj_matrix

training_file = data_dir + "/train.txt"
validate_file = data_dir + "/validate.txt"
print("***************************************************************************************")
print("Output Dir: " + output_dir)

print("@Create output directory")
utils.create_folder(output_dir)  # 先把输出文件夹创建出来

# Load train, validate & test
print("@Load train,validate&test data")
training_instances = utils.read_file_as_lines(training_file)  # training_instances是以文件中每一行为元素的列表
nb_train = len(training_instances)  # 序列个数
print(" + Total training sequences: ", nb_train)

validate_instances = utils.read_file_as_lines(validate_file)
nb_validate = len(validate_instances)
print(" + Total validating sequences: ", nb_validate)

# Create dictionary
print("@Build knowledge")
MAX_SEQ_LENGTH, item_dict, reversed_item_dict, _ = utils.build_knowledge(training_instances, validate_instances)  # build_knowledge方法接收训练集、验证集,返回最长篮子序列长度、ID-序号字典、序号-ID字典、按ID升序的物品频度可能性数组(貌似只统计了最后一个或两个)

print("#Statistic")
NB_ITEMS = len(item_dict)  # 所有出现过的物品个数(貌似只统计了最后一个或两个)
print(" + Maximum sequence length: ", MAX_SEQ_LENGTH)
print(" + Total items: ", NB_ITEMS)

rmatrix_fpath = output_dir + "/r_matrix_" + str(config.nb_hop) + "w.npz"  # 在adj_matrix文件夹存放r_matrix_(?)w.npz文件,其中"?"是nb.hop也就是真正的邻接矩阵阶数。npz文件是numpy压缩文件(很多数据集就是该格式)

print("@Build the real adjacency matrix")
real_adj_matrix = utils.build_sparse_adjacency_matrix_v2(training_instances, validate_instances, item_dict)  # real_adj_matrix此时是论文中的矩阵F的压缩形态
real_adj_matrix = utils.normalize_adj(real_adj_matrix)  # real_adj_matrix此时是论文中的C的压缩形态

mul = real_adj_matrix
with tf.device('/cpu:0'):  # 使用CPU运算。TF不区分CPU的设备号,设置为0即可。相比于显存,内存一般大多了,于是这个时候就常常人为指定为CPU设备。需要注意的是,这个方法会减少显存的负担,但是从内存把数据传输到显存中是非常慢的,这样做常常会减慢速度。
    w_mul = real_adj_matrix
    coeff = 1.0
    for w in range(1, config.nb_hop):
        coeff *= 0.85  # 0.85是论文中高阶矩阵那部分提到的μ,这一步就是μ^n-1
        w_mul *= real_adj_matrix
        w_mul = utils.remove_diag(w_mul)  # Norm(C^n)

        w_adj_matrix = utils.normalize_adj(w_mul)
        mul += coeff * w_adj_matrix  # 论文中做高阶矩阵的那一步

    real_adj_matrix = mul

    sp.save_npz(rmatrix_fpath, real_adj_matrix)  # 以npz格式在指定位置存储好论文中的C矩阵
    print(" + Save adj_matrix to" + rmatrix_fpath)

论文中说,cmatrix_generator.py用于生成、输出、存储论文中的C矩阵。

位置在“工作目录/adj_matrix/r_matrix_(?)w.npz”,其中“?”是nb.hop也就是真正的邻接矩阵阶数。

接下来是layers.py。它是整个网络模型的基础。

import tensorflow as tf

def create_rnn_cell(cell_type, state_size, default_initializer, reuse=None):  # state_size: The number of units in the cell。TensorFlow的神经元与文献中的不同,是一簇神经元
    if cell_type == 'GRU':
        return tf.nn.rnn_cell.GRUCell(state_size, activation=tf.nn.tanh, reuse=reuse)  # 返回一个GRU神经元
    elif cell_type == 'LSTM':
        return tf.nn.rnn_cell.LSTMCell(state_size, initializer=default_initializer, activation=tf.nn.tanh, reuse=reuse)  # 返回一个LSTM神经元
    else:
        return tf.nn.rnn_cell.BasicRNNCell(state_size, activation=tf.nn.tanh, reuse=reuse)  # 返回一个基本RNN神经元


def create_rnn_encoder(x, rnn_units, dropout_rate, seq_length, rnn_cell_type, param_initializer, seed, reuse=None):
    with tf.variable_scope("RNN_Encoder", reuse=reuse):  # tf.variable_scope: A context manager for defining ops that creates variables (layers). reuse: if None, we inherit the parent scope's reuse flag.
        rnn_cell = create_rnn_cell(rnn_cell_type, rnn_units, param_initializer)
        rnn_cell = tf.nn.rnn_cell.DropoutWrapper(rnn_cell, input_keep_prob=1 - dropout_rate, seed=seed)  # DropoutWrapper: Operator adding dropout to inputs and outputs of the given cell. Dropout就是为了提高模型鲁棒性暂时随机丢弃某些神经元的过程。seed是随机种子
        init_state = rnn_cell.zero_state(tf.shape(x)[0], tf.float32)  # 返回一个以x第一维为大小的张量(列向量),元素全部为0
        # RNN Encoder: Iteratively compute output of recurrent network
        rnn_outputs, _ = tf.nn.dynamic_rnn(rnn_cell, x, initial_state=init_state, sequence_length=seq_length, dtype=tf.float32)  # 创建RNN,返回输出张量和最后状态
        return rnn_outputs

def create_basket_encoder(x, dense_units, param_initializer, activation_func=None, name="Basket_Encoder", reuse=None):
    with tf.variable_scope(name, reuse=reuse):
        return tf.layers.dense(x, dense_units, kernel_initializer=param_initializer,  # tf.layers.dense: Functional interface for the densely-connected layer.
                            bias_initializer=tf.zeros_initializer, activation=activation_func)  # tf.zeros_initializer: Initializer that generates tensors initialized to 0.

def get_last_right_output(full_output, max_length, actual_length, rnn_units):
    batch_size = tf.shape(full_output)[0]  # 提取full_output第一维
    # Start indices for each sample
    index = tf.range(0, batch_size) * max_length + (actual_length - 1)
    # Indexing
    return tf.gather(tf.reshape(full_output, [-1, rnn_units]), index)  # 把full_output中第index行(这每一行元素个数为rnn_units个,其中的-1表示"目前我不确定"),例如batch_size为32、max_length为3、actual_length为2时,index为1,4,7,...,91,94,则把这些行为元素聚集在一起成为一个新张量

layers.py主要建立神经网络各层,提供方法传入模型参数并返回模型单元。

接下来是未看完的models.py。

import tensorflow as tf
import numpy as np

from layers import *
from abc import abstractmethod


class Model:

    def __init__(self, sess, seed, learning_rate, name='model'):  # 4 paras
        self.scope = name
        self.session = sess
        self.seed = seed
        self.learning_rate = tf.constant(learning_rate)

    @abstractmethod
    def train_batch(self, s, s_length, y):
        pass

    @abstractmethod
    def validate_batch(self, s, s_length, y):
        pass

    @abstractmethod
    def generate_prediction(self, s, s_length):
        pass


class Beacon(Model):

    def __init__(self, sess, emb_dim, rnn_units, alpha, 
                 max_seq_length, item_probs, adj_matrix, top_k,
                 batch_size, rnn_cell_type, rnn_dropout_rate, seed, learning_rate):  # 14 paras,4 inherited from the father class

        super().__init__(sess, seed, learning_rate, name="GRN")

        self.emb_dim = emb_dim
        self.rnn_units = rnn_units

        self.max_seq_length = max_seq_length
        self.nb_items = len(item_probs)
        self.item_probs = item_probs
        self.alpha = alpha  # 论文中的α(式7)
        self.batch_size = batch_size
        self.top_k = top_k

        with tf.variable_scope(self.scope):
            # Initialized for n_hop adjacency matrix
            self.A = tf.constant(adj_matrix.todense(), name="Adj_Matrix", dtype=tf.float32)  # 貌似是C

            uniform_initializer = np.ones(shape=(self.nb_items), dtype=np.float32) / self.nb_items  # 用于初始化ω
            self.I_B = tf.get_variable(dtype=tf.float32, initializer=tf.constant(uniform_initializer, dtype=tf.float32), name="I_B")  # 是个向量。get_variable用来get或创建变量。I_B: item bias,论文中的ω
            self.I_B_Diag = tf.nn.relu(tf.diag(self.I_B, name="I_B_Diag"))  # 做成对角阵

            self.C_Basket = tf.get_variable(dtype=tf.float32, initializer=tf.constant(adj_matrix.mean()), name="C_B")  # 用adj_matrix的均值做初始化
            self.y = tf.placeholder(dtype=tf.float32, shape=(batch_size, self.nb_items), name='Target_basket')  # Target_basket      

            # Basket Sequence encoder
            with tf.name_scope("Basket_Sequence_Encoder"):
                self.bseq = tf.sparse_placeholder(shape=(batch_size, self.max_seq_length, self.nb_items), dtype=tf.float32, name="bseq_input")  # sparse_placeholder: Inserts a placeholder for a sparse tensor that will be always fed.
                self.bseq_length = tf.placeholder(dtype=tf.int32, shape=(batch_size,), name='bseq_length')

                self.bseq_encoder = tf.sparse_reshape(self.bseq, shape=[-1, self.nb_items], name="bseq_2d")  # 重塑成nb_items列矩阵
                self.bseq_encoder = self.encode_basket_graph(self.bseq_encoder, self.C_Basket, True)  # bseq_encoder是xt,C_Basket是η,产生论文中的zt
                self.bseq_encoder = tf.reshape(self.bseq_encoder, shape=[-1, self.max_seq_length, self.nb_items], name="bsxMxN")
                self.bseq_encoder = create_basket_encoder(self.bseq_encoder, emb_dim, param_initializer=tf.initializers.he_uniform(), activation_func=tf.nn.relu)  # 产生论文中的bt     

                # batch_size x max_seq_length x H
                rnn_encoder = create_rnn_encoder(self.bseq_encoder, self.rnn_units, rnn_dropout_rate, self.bseq_length, rnn_cell_type, 
                                                    param_initializer=tf.initializers.glorot_uniform(), seed=self.seed)  # 执行论文中的式5
                
                # Hack to build the indexing and retrieve the right output. # batch_size x H
                h_T = get_last_right_output(rnn_encoder, self.max_seq_length, self.bseq_length, self.rnn_units)  # 论文中的ht

            # Next basket estimation
            with tf.name_scope("Next_Basket"):
                W_H = tf.get_variable(dtype=tf.float32, initializer=tf.initializers.glorot_uniform(), shape=(self.rnn_units, self.nb_items), name="W_H")  # 论文中的H相当于NN维度,也就是rnn_units,即神经元数量
                
                next_item_probs = tf.nn.sigmoid(tf.matmul(h_T, W_H))  # 论文中的式6。next_item_probs就是论文中的s
                logits = (1.0 - self.alpha) * next_item_probs + self.alpha * self.encode_basket_graph(next_item_probs, tf.constant(0.0))  # 论文中的式7,巧妙地复用了encode_basket_graph方法(式3)

models.py中清晰的划分了论文中的如Basket Sequence encoder等层级。

这里目前全是Beacon模型的构造函数部分。可以看见,在构造函数中,模型中的各个中间关键变量都已经被计算好了。构造函数接收了所有的模型参数,在内部已经运算出这些变量。例如论文中的zt、bt。

在构造函数中,可以看见论文中许多式子的影子,已全部注释在代码间。

难题与解决思路

并没有遇到难题。TensorFlow、Numpy、Scipy.Sparse都有有官方文档。很多函数直接查阅文档就能知道用途,所以在这周的代码阅读过程中,由于主要都是各种函数的定义与使用,故没有遇到问题。

不过有一点,上周遇到的“不知道一些变量到底对应的是论文中的哪个变量”这个问题,以及这周新出现的“不知道一些张量的shape(形状)中的各维究竟是什么”的问题,在这周深入阅读代码的过程中都得到了解决。

原因是通过查阅特定函数的官方文档得知函数作用后,立马就可以对应到论文中的特定式子(例如产生zt、bt的式子)。这样一来,函数中的各个参数,只要按位置一一跟论文对应,就能弄清楚某个变量是论文中的哪个对应变量,也可以结合论文中例如 $ ∈R^{H⨉H} $ 的表示变量是建立在哪些关系之上的字样推断出shape中各个维度究竟是什么(例如物品数量、序列长度等)。由于GitHub中的代码命名和论文中并不完全一致,所以需要如此推敲。

心得收获

论文和代码对照着看真的很有用。

看代码一定要每个函数都搞懂(不论源码自定义的还是库里有的)。这样一是可以融会贯通整个构建NN的过程,二是可以丰富自己写代码的知识。

下周计划

弄完Beacon代码,运行Beacon程序。最好能开启下一个模型TIFUKNN。