gk模型的制作流程

基于图神经网络的知识追踪模型GKTGraph Neural Network Knowledge Tracing (GKT) GKT是最早应用图神经网络模型进行知识追踪的模型之一。它将学生的答题历史视为一个带有学生、问题和学生答题序列信息的三部分图。GKT 通过将学生的知识状态建模成一个有向图,其中每个节点表示一个知识点或题目,

基于图神经网络的知识追踪模型GKTGraph Neural Network Knowledge Tracing (GKT)

GKT是最早应用图神经网络模型进行知识追踪的模型之一。它将学生的答题历史视为一个带有学生、问题和学生答题序列信息的三部分图。GKT 通过将学生的知识状态建模成一个有向图,其中每个节点表示一个知识点或题目,节点之间的边表示知识点或题目之间的关系,如前置关系或相似度等。在每个时间步中,GKT 会根据学生的作答情况和当前的知识状态图,预测下一个时间步学生的答题情况,并根据预测结果更新知识状态图。

具体来说,GKT 在每个时间步中,会对每个节点进行一次消息传递和池化操作,从而获取该节点的特征向量表示,然后通过一个多层感知机(MLP)将该节点的特征向量表示映射到一个概率分布上,即该节点的正确率,从而预测学生在下一个时间步对该题目的答题情况。

同时,GKT 也会对知识状态图进行动态更新,即在每个时间步中根据学生的作答情况更新相应节点的特征向量表示,并更新知识状态图中的边权重和节点之间的连接关系,从而使得知识状态图能够随着学生的学习经历动态演化。

GKT模型的本质是实现了一个基于图神经网络(GNN)的时间序列节点级分类器,用于预测每个时间步中各个知识点的掌握情况,即掌握/未掌握。

在该模型中,每个知识点被视为图中的一个节点,每个学生的知识点掌握情况被视为时间序列中的一个时间步。对于每个时间步,该模型使用GNN来汇聚当前时刻每个知识点的状态,并预测每个知识点的掌握情况。

具体来说,该模型的构建和训练过程包括以下几个步骤:

  1. 建立图结构:将知识点看作图中的节点,学生掌握每个知识点的关系建立成为图的边。

  2. 定义节点嵌入:将每个节点(即知识点)映射到一个低维度的向量空间中,即节点嵌入空间,用于表达节点特征。(self.f_self=MLP,被用来学习每个概念的嵌入(embedding)表示,从而将每个概念的属性映射到固定长度的向量空间中,这里,对于MLP的输入张量大小是128,输出张量大小是32)

  3. 定义GNN结构:使用GNN对节点状态进行汇聚,在这个代码中,使用的GNN是基于Erase&AddGate和门控循环单元(GRU)。

  4. 定义分类器:使用节点嵌入空间中的向量对节点进行分类,将每个节点的向量作为输入,输出对应知识点的掌握情况。(self.predict)

  5. 训练模型:使用带有节点分类器的GNN对每个时间步的节点状态进行预测,使用交叉熵损失函数对预测结果进行优化,训练模型。

# coding: utf-8
# 基于GNN网络模型的知识追踪模型GKT模型代码学习笔记
import numpy as np
import scipy.sparse as sp
# 基于numpy的科学计算库,提供了数学、科学和工程计算的函数和算法,该模块提供了稀疏矩阵的相关函数和算法
import torch
# 张量操作和自动微分
import torch.nn as nn
# 神经网络模块,提供各种常用的神经网络层和模型,例如全连接层、卷积层、循环神经网络
import torch.nn.functional as F
# 提供各种常用的激活函数、损失函数、池化函数
from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
# RNN模块,导入了rnn相关函数例如序列填充、序列打包
from torch.autograd import Variable
# 导入自动求导模块中的variable类,可以对张量进行封装,使其自动求导
from layers import MLP, EraseAddGate, MLPEncoder, MLPDecoder, ScaledDotProductAttention
# 从layers文件中导入这几个类,都是自定义的 实现特定功能
from utils import gumbel_softmax


# 导入自定义的gumbel_softmax函数,用于gumbel-softmax采样

# Graph-based Knowledge Tracing: Modeling Student Proficiency Using Graph Neural Network.
# For more information, please refer to https://dl.acm.org/doi/10.1145/3350546.3352513
# Author: jhljx
# Email: jhljx8918@gmail.com


class GKT(nn.Module):

    def __init__(self, concept_num, hidden_dim, embedding_dim, edge_type_num, graph_type, graph=None, graph_model=None,
                 dropout=0.5, bias=True, binary=False, has_cuda=False):
        # concept_num:概念数;hidden_dim:隐藏层维度,embedding_dim:嵌入层维度;edge_type_num:边类型数量;graph_type:图结构类型
        # graph:图结构;graph_model:图模型 dropout:dropout概率,bias:偏置项;binary:二元分类;hascuda:是否使用gpu
        super(GKT, self).__init__()
        # 调用父类的初始化函数
        self.concept_num = concept_num
        # 将输入参数concept_num赋值给self.~值
        self.hidden_dim = hidden_dim
        # 将输入的参数hidden_dim赋值给self.~值
        self.embedding_dim = embedding_dim
        # 将输入的embedding_dim赋值给self.~值
        self.edge_type_num = edge_type_num
        # 将输入的边类型数量赋值给self.~
        self.res_len = 2 if binary else 12
        # 表示每个知识点的反应次数,或者说,每个题的回答历史情况;具体而言,如果二进制,使用0,1分别表示正确和错误,则知识点可能有两次反应,如果采用多元分类,则一个知识点可能有多个反应
        # 索引res_len表示每个知识点的反应次数,如果采用二进制分类,反应次数就是2,如果多元分类,那么反应次数就是分类的类别数
        self.has_cuda = has_cuda
        # 是否使用gpu
        assert graph_type in ['Dense', 'Transition', 'DKT', 'PAM', 'MHA', 'VAE']
        # assert关键字,检查 graph_type是否在可选值中,如果条件为假,会引发assertionError异常
        # assert expression[,arguments] expression是检查的条件表达式,如果返回值为false,则会引发异常输出arguments参数的信息
        '''
        x = 3
        assert x % 2 == 0, "x is not even"
        AssertionError: x is not even
        '''
        self.graph_type = graph_type
        # 将输入的graph_type参数赋值给self.~值 设置图类型
        if graph_type in ['Dense', 'Transition', 'DKT']:
            # 如果图类型是dense,transition,dkt
            assert edge_type_num == 2
            # 检查边类型数量是否为2 (由于是dense、transition、dkt这几个图,则要求边类型数量为2)
            assert graph is not None and graph_model is None
            # 检查图是否参数而不提供图模型参数(由于是这三个图,则要求提供知识点关系的图的参数,图模型默认为gkt)
            self.graph = nn.Parameter(graph)  # [concept_num, concept_num]
            # 将图参数graph转换为可学习的参数nn.parameter,其中图graph的形状为[概念数,概念数]
            self.graph.requires_grad = False  # fix parameter
            # 固定self.graph参数,使其在反向传播时不会被更新
            self.graph_model = graph_model
            # 设置图模型
        else:  # ['PAM', 'MHA', 'VAE']
            # 如果graph_type是这三个
            assert graph is None
            # 检查是否未提供图类型
            self.graph = graph  # None
            # 如果是这三个基于学习的图结构,则不需要提供给初始的知识点关系的图
            if graph_type == 'PAM':
                assert graph_model is None
                # 如果使用的是PAM,则不需要图模型,生成随机参数矩阵graph,大小[概念数,概念数]
                self.graph = nn.Parameter(torch.rand(concept_num, concept_num))
                # 将生成的随机参数矩阵graph转换为可学习参数矩阵
            else:  # 若是MHA和VAE这两个图类型
                assert graph_model is not None
                # 检查要求提供图模型
            self.graph_model = graph_model
            # 将输入的图模型赋值给self.~
        # one-hot feature and question
        one_hot_feat = torch.eye(self.res_len * self.concept_num)
        # 生成one-hot特征向量,大小是[反应次数*概念数,反应次数*概念数]的单位矩阵(对角线上全为1)
        '''
        例如:回答反应为二分类,即res_len=2;概念数为4,则该one_hot_feat=[8,8],数据类型是浮点数
          1,2,3,4,1',2',3',4'
       1 [1,0,0,0,0,0,0,0;
       2  0,1,0,0,0,0,0,0;
       3  0,0,1,0,0,0,0,0;
       4  0,0,0,1,0,0,0,0;
       1' 0,0,0,0,1,0,0,0;
       2' 0,0,0,0,0,1,0,0;
       3' 0,0,0,0,0,0,1,0;
       4' 0,0,0,0,0,0,0,1]
        '''
        self.one_hot_feat = one_hot_feat.cuda() if self.has_cuda else one_hot_feat
        # 将onehot向量移到gpu上
        self.one_hot_q = torch.eye(self.concept_num, device=self.one_hot_feat.device)
        # 生成onehot问题向量,大小[概念数,概念数]
        '''
          1,2,3,4
      1  [1,0,0,0;
      2   0,1,0,0;
      3   0,0,1,0;
      4   0,0,0,1]
        '''
        zero_padding = torch.zeros(1, self.concept_num, device=self.one_hot_feat.device)
        # 生成一个大小为1行,concept_num列的全0张量
        '''
        [0,0,0,0]
        '''
        self.one_hot_q = torch.cat((self.one_hot_q, zero_padding), dim=0)
        # 通过上面可以看得出两个one-hot大小并不一致,所以利用zero_padding对问题one_hot进行拼接
        '''
        1,2,3,4
      1  [1,0,0,0;
      2   0,1,0,0;
      3   0,0,1,0;
      4   0,0,0,1;
      zp  0,0,0,0]
        '''
        # concept and concept & response embeddings
        self.emb_x = nn.Embedding(self.res_len * concept_num, embedding_dim)
        # batch_size=1,seq_length=res_len * concept_num(假设8);将输入反应回答次数*概念数,进行嵌入低维度向量表示,dim为输入参数:嵌入维度32
        '''
        输入的值范围是[0,7]
        若输入向量是[2,4],输入的嵌入维度是32
        则生成的嵌入矩阵是[2,4,32]
        即,两维张量,每一维是4行,32列
        '''
        # last embedding is used for padding, so dim + 1
        self.emb_c = nn.Embedding(concept_num + 1, embedding_dim, padding_idx=-1)
        # embedding处理的是张量,(batch_size,seq_length)的整数类型输入张量,所以在这里,seq_length=concept_num+1;batch_size=1
        # 一次性对整个序列进行处理,将序列中的每个整数值映射到一个低维浮点数向量中,每一行是一个嵌入向量
        # f_self function and f_neighbor functions
        # 他们是用于计算节点的嵌入向量的函数,这些向量将用于节点分类任务
        mlp_input_dim = hidden_dim + embedding_dim
        # mlp的输入维度是隐藏层维度加嵌入层维度 是64(32+32)
        self.f_self = MLP(mlp_input_dim, hidden_dim, hidden_dim, dropout=dropout, bias=bias)
        # 表示定义一个多层感知机(MLP)用于计算节点自身的输出。输入维度64,输出维度维hidden_dim=32;他将接收节点的特征向量和上一步隐藏状态作为输入,并输出更新后的隐藏状态
        self.f_neighbor_list = nn.ModuleList()
        # 一个空的ModuleList(),用于存储f_neighbor函数的列表,每一个模块/函数用于计算不同类型的边的嵌入向量
        if graph_type in ['Dense', 'Transition', 'DKT', 'PAM']:  # 先判断图类型
            # f_in and f_out functions 使用两个MLP函数来计算邻居节点的嵌入向量
            self.f_neighbor_list.append(MLP(2 * mlp_input_dim, hidden_dim, hidden_dim, dropout=dropout, bias=bias))
            # 表示将f_in函数加入到这个函数列表中,用于计算邻居节点的嵌入向量
            # 输入张量大小[2*(mlpdim)=128],输出[32]
            self.f_neighbor_list.append(MLP(2 * mlp_input_dim, hidden_dim, hidden_dim, dropout=dropout, bias=bias))
            # 表示将f_out函数加入到这个函数列表中,用于计算并输出
        else:  # ['MHA', 'VAE']
            for i in range(edge_type_num):
                # 使用多个MLP函数,循环迭代所有边的类型,进行边的邻居节点的嵌入向量计算
                # 循环到每一条边,产生一个f_neighbor的函数加入到函数列表中,并计算出当前边类型的邻居节点的嵌入向量
                # 输入张量[128],输出张量[32]
                self.f_neighbor_list.append(MLP(2 * mlp_input_dim, hidden_dim, hidden_dim, dropout=dropout, bias=bias))

        # Erase & Add Gate 作为gkt中的擦除添加门,输入维度为32(hidden_dim),输出维度为concept_num
        self.erase_add_gate = EraseAddGate(hidden_dim, concept_num)
        '''
        erase_add_gate = EraseAddGate(hidden_dim, concept_num)
        hidden_state = torch.randn(batch_size, hidden_dim)
        prev_knowledge_state = torch.randn(batch_size, concept_num)
        new_knowledge_state = erase_add_gate(hidden_state, prev_knowledge_state)
        自定义的擦除添加门,用来控制模型对先前学生知识状态进行擦除/添加的过程,将输入的隐藏状态和先前学生知识状态作为输入,通过两个全连接层(分别用于擦除和添加)输出一个向量,更新学生的知识状态
        '''
        # Gate Recurrent Unit 作为gkt的门控循环单元
        self.gru = nn.GRUCell(hidden_dim, hidden_dim, bias=bias)
        '''
        自带门控循环单元模块,他接受一个输入张量和上一个时间步的隐藏状态,输出当前时间步的隐藏状态,在gkt中,这个模块被用来更新学生的隐藏状态
        gru = nn.GRUCell(hidden_dim, hidden_dim, bias=bias)
        input_tensor = torch.randn(batch_size, input_dim)
        prev_hidden_state = torch.randn(batch_size, hidden_dim)
        hidden_state = gru(input_tensor, prev_hidden_state)
        '''
        # prediction layer 全连接层,将隐藏层的维度32(通过一个线性变换)转换为一个标量,用于预测学生是否掌握该知识点,输出维度为1
        # predict = nn.Linear(hidden_dim, 1, bias=bias)
        # hidden_state = torch.randn(batch_size, hidden_dim)
        # prediction = predict(hidden_state)
        self.predict = nn.Linear(hidden_dim, 1, bias=bias)

    # Aggregate step, as shown in Section 3.2.1 of the paper
    '''
    这段代码的作用是在当前时间步更新序列中的知识点embedding的基础上,
    将当前更新的知识点embedding加入到对应的位置,并生成新的知识点embedding张量。
    这一步是GKT模型中非常重要的一个步骤,它将当前时间步的知识点信息与历史信息进行整合,生成一个全局的知识点表示。
    这个全局的知识点表示会被输入到之后的计算中,影响到模型的预测结果。
    每次在forward函数中进行调用,会在每个时间步调用该函数进行概念知识状态的聚合
    '''
    def _aggregate(self, xt, qt, ht, batch_size):
        r"""
        Parameters:
            xt: input one-hot question answering features at the current timestamp
            当前时间戳上,一个batch内所有学生的回答情况,一维张量
            例如[1,1,0,0,1,1,0,0]表示batch中有8个学生,其中1,2,5,6学生答对了题目,3,4,7,8学生答错了题目
            qt: question indices for all students in a batch at the current timestamp
            当前时间戳上所有学生的回到问题的索引,是一个一维张量
            qt[i]表示第i个学生最近一次回答问题的编号,如果该学生还没回答问题,则qt[i]=-1
            假设batch=8,第一个学生最近一次回答的问题编号是3,第二个学生回答的问题编号是1,第3,7,8个学生没有回答:
            qt=[3,1,-1,4,5,2,-1,-1]
            ht: hidden representations of all concepts at the current timestamp
            若batch=8,concept_num=4,hidden_dim=32,ht=[8,4,32]
            [[0.1,1.23,-1,0.234,0,1,... ...2.3,1.02,0,1,0.98;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1],
              (32列)
              [0.1,1.23,-1,0.234,0,1,... ...2.3,1.02,0,1,0.98;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1],
              [0.1,1.23,-1,0.234,0,1,... ...2.3,1.02,0,1,0.98;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1],
              [0.1,1.23,-1,0.234,0,1,... ...2.3,1.02,0,1,0.98;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1;
              1.2,0.89,0,-1,0.76,1,... ...2.4,1.45.1,0,-1]]
            batch_size: the size of a student batch ;一个标量
        Shape:
            xt: [batch_size]
            qt: [batch_size]
            ht: [batch_size, concept_num, hidden_dim]
            tmp_ht: [batch_size, concept_num, hidden_dim + embedding_dim]
            是一个batch内所有的学生回答情况的汇总结果,假设batch里有8个学生,知识点数量维4,hidden_dim=32,embedding_dim=32,
            tmp_ht=[8,4,64]
        Return:
            主要是将学生对不同概念的回答进行聚合,得到该时间戳上各个概念的隐藏状态:
            1、将学生的回答和对应的概念和问题进行匹配
            2、将匹配的结果填入到相应的概念嵌入
            3、将概念嵌入与当前时间戳上各个概念的隐藏状态进行拼接,得到聚合结果
            tmp_ht: aggregation results of concept hidden knowledge state and concept(& response) embedding
        """
        qt_mask = torch.ne(qt, -1)  # [batch_size], qt != -1
        # 方法torch.ne函数生成一个[8]的布尔向量,标记qt中不为-1的位置(即去除未回答的问题学生索引)
        # qt_mask=[True,True,False,True,True,True,False]=[1,1,0,1,1,1,0]
        x_idx_mat = torch.arange(self.res_len * self.concept_num, device=xt.device)
        # x_idx_mat=[0,1,2,3,4,5,6,7]
        #            1  ,2  ,3  ,4  (对应上面的四个知识点的两个回答)
        # 是一个一维张量,长度为res_len*concept_num(8);包含当前时间戳的回答情况的索引,可以理解为一个batch内所有学生回答情况的拼接
        # 若batch=8,res_len=2,concept=4,
        x_embedding = self.emb_x(x_idx_mat)  # [res_len * concept_num, embedding_dim]
        # 获取所有概念的嵌入矩阵 大小[8,32] 每个概念的每种答案选项的嵌入矩阵
        masked_feat = F.embedding(xt[qt_mask], self.one_hot_feat)  # [mask_num(该时间戳下正在回答问题的学生数量), res_len * concept_num]
        # 选择已回答问题的学生的特征xt=[1,1,0,0,1,1,0,0],qt_mask=[True,True,False,True,True,True,False]=[1,1,0,1,1,1,0]
        # xt[qt_mask]=[1,1,0,1,1,0] 最后的masked_feat是(6*8) 即当下回答了问题的学生的onehot编码映射成embedding,最后映射为一个二维张量
        res_embedding = masked_feat.mm(x_embedding)  # [mask_num, embedding_dim]
        # 将每个概念的每个答案的嵌入矩阵 和 回答问题的学生特征嵌入矩阵相乘 得到:所有回答问题学生的所有回答的embedding
        mask_num = res_embedding.shape[0] # 回答问题的学生数量
        # 需要将res_embedding按照qt的顺序放入concept_embedding
        concept_idx_mat = self.concept_num * torch.ones((batch_size, self.concept_num), device=xt.device).long()
        # torch.ones()大小为(8,4)的全1矩阵
        '''
        [[8., 8., 8., 8.],
        [8., 8., 8., 8.],
        [8., 8., 8., 8.],
        [8., 8., 8., 8.],
        [8., 8., 8., 8.],
        [8., 8., 8., 8.],
        [8., 8., 8., 8.],
        [8., 8., 8., 8.]])
        '''
        # 生成索引矩阵 每一行对应学生的所有概念的索引,利用index_put函数将之前生成的概念嵌入到res_embedding,按照问题索引插入到concept_embedding中
        concept_idx_mat[qt_mask, :] = torch.arange(self.concept_num, device=xt.device)
        # 根据qt_mask=[1,1,0,1,1,1,0]中非0位置的索引,将其中对应的行的每个元素替换成0-7的值,表示学生回答正确的题目对应的知识点
        concept_embedding = self.emb_c(concept_idx_mat)  # [batch_size, concept_num, embedding_dim]

        index_tuple = (torch.arange(mask_num, device=xt.device), qt[qt_mask].long())
        # 第一个元素是一个长度为mask_num的一维张量,利用torch.arange函数生成从0到mask_num-1的一维张量,每个元素表示在更新序列中的知识点在concept_embedding中的行索引
        # 第二个元素是qt中除去未回答的问题的问题编号,用来更新序列中的知识点在concept_embedding中的列索引
        concept_embedding[qt_mask] = concept_embedding[qt_mask].index_put(index_tuple, res_embedding)
        # 使用index_put函数将更新后的知识点embedding——res_embedding放在concept_embedding的对应位置中
        # 将qt_mask作为索引,在concept_embedding上进行更新(因为只有qt_mask中对应非0的位置才需要更新)
        tmp_ht = torch.cat((ht, concept_embedding), dim=-1)  # [batch_size, concept_num, hidden_dim + embedding_dim]
        # 最后使用torch.cat矩阵拼接函数,将ht和更新后的concept_embedding进行拼接,dim=-1,即在最后一个维度进行拼接
        # 每一行对应一个知识点,包含他的embedding和隐藏状态
        return tmp_ht

这是基于GNN的聚合步骤 

# GNN aggregation step, as shown in 3.3.2 Equation 1 of the paper
    # 这段代码的作用是将邻居节点的特征聚合到当前节点的隐藏状态中,用于后续的知识迁移和预测。具体的聚合方式和图模型类型的选择在模型的训练过程中自由设定。
    def _agg_neighbors(self, tmp_ht, qt):
        r"""
        Parameters:
            tmp_ht: temporal hidden representations of all concepts after the aggregate step
            qt: question indices for all students in a batch at the current timestamp
             当前时间戳上所有学生的回到问题的索引,是一个一维张量
            qt[i]表示第i个学生最近一次回答问题的编号,如果该学生还没回答问题,则qt[i]=-1
            假设batch=8,第一个学生最近一次回答的问题编号是3,第二个学生回答的问题编号是1,第3,7,8个学生没有回答:
            qt=[3,1,-1,4,5,2,-1,-1]
        Shape:
            tmp_ht: [batch_size, concept_num, hidden_dim + embedding_dim]
            qt: [batch_size]
            m_next: [batch_size, concept_num, hidden_dim]
        Return:
            m_next: hidden representations of all concepts aggregating neighboring representations at the next timestamp
            concept_embedding: input of VAE (optional)
            rec_embedding: reconstructed input of VAE (optional)
            z_prob: probability distribution of latent variable z in VAE (optional)
        """
        qt_mask = torch.ne(qt, -1)  # [batch_size], qt != -1生成qt的遮罩张量[1,1,0,1,1,1,0,0]
        masked_qt = qt[qt_mask]  # [mask_num, ]
        # 老操作,去除当前未回答问题的学生,得到[3,1,4,5,2]即当前时间戳被回答的问题编号
        masked_tmp_ht = tmp_ht[qt_mask]  # [mask_num, concept_num, hidden_dim + embedding_dim]
        # 参照qt的遮罩向量[1,1,0,1,1,1,0,0]对tmp_ht进行取值(去除当前未有学生回答的知识点以及其状态)(tmp_ht 参照上面,是每一行对应一个知识点以及其embedding和隐藏状态)
        mask_num = masked_tmp_ht.shape[0]  # 有效的回答的学生数
        self_index_tuple = (torch.arange(mask_num, device=qt.device), masked_qt.long())
        # 创建一个元组,第一个元素是mask_num长的一维张量,由torch.arange生成,
        # 第二个元素是masked_qt的长整型张量,表示非-1的元素在qt中的位置
        # 即用于在masked_tmp_ht中取出masked_qt对应的行
        self_ht = masked_tmp_ht[self_index_tuple]  # [mask_num, hidden_dim + embedding_dim]
        # 从masked_tmp_ht中取出与masked_qt对应的行==》从ht的状态阵中,按照当前回答问题的学生进行提取回答概念的状态
        self_features = self.f_self(self_ht)  # [mask_num, hidden_dim]
        # 这里的self.f_self在147行,表示定义一个多层感知机(MLP)用于计算节点自身的输出。输入维度64,输出维度维hidden_dim=32;他将接收节点的特征向量和上一步隐藏状态作为输入,并输出更新后的隐藏状态
        '''
        将从masked_tmp-中取出的行作为输入,输入到self.f_self中,这个是一个MLP,用于处理self_ht中的特征,将其映射到hidden_dim(32维)空间中
        通过将self_ht在concept_num维度上扩展,得到了expanded_self_ht。
        然后将这个expanded_self_ht和masked_tmp_ht沿着concept_num维度拼接起来,得到了neigh_ht。
        neigh_ht是当前时间步中各个知识点的相邻节点的特征表示向量。其中,expanded_self_ht表示当前时间步中的知识点的特征表示,
        masked_tmp_ht是经过掩码的、包含有历史信息的知识点的特征表示。
        '''
        expanded_self_ht = self_ht.unsqueeze(dim=1).repeat(1, self.concept_num,1)  # [mask_num, concept_num, hidden_dim + embedding_dim]
        # 将self-ht拓展维与concept_embedding大小一致的形状,再进行复制concept_num次
        # 邻居节点的状态由这两个矩阵进行拼接
        neigh_ht = torch.cat((expanded_self_ht, masked_tmp_ht),dim=-1)  # [mask_num, concept_num, 2 * (hidden_dim + embedding_dim)]
        concept_embedding, rec_embedding, z_prob = None, None, None
        '''
        然后根据图模型类型的不同,分别处理neigh_ht和知识点的特征向量concept_embedding,
        最终得到neigh_features,表示当前知识点的相邻节点的特征表示向量。
        '''
        if self.graph_type in ['Dense', 'Transition', 'DKT', 'PAM']:
            # graph矩阵中,graph[i,j]表示知识点i到知识点j之间是否存在边,通过graph矩阵构建adj和reverse_adj矩阵
            # adj和reverse_adj矩阵分别表示当前知识点到相邻节点和 (reverse_adj)相邻节点到当前知识点的边
            adj = self.graph[masked_qt.long(), :].unsqueeze(dim=-1)  # [mask_num, concept_num, 1]
            reverse_adj = self.graph[:, masked_qt.long()].transpose(0, 1).unsqueeze(dim=-1)  # [mask_num, concept_num, 1]
            # self.f_neighbor_list[0](neigh_ht) shape: [mask_num, concept_num, hidden_dim]
            neigh_features = adj * self.f_neighbor_list[0](neigh_ht) + reverse_adj * self.f_neighbor_list[1](neigh_ht)
            # 使用前面(149行)定义的f_neighbor_list()中的第0个f函数和第1个f函数对neigh-ht进行线性变换处理,得到neigh_features
        else:  # ['MHA', 'VAE']
            # 使用graph_model对graph进行处理(注意 这里这两个图方法是要求图模型却不要求图的,即没有初始的graph矩阵)
            concept_index = torch.arange(self.concept_num, device=qt.device)
            # 对概念进行初始化矩阵构造[4,4] 假设的concept_num=4
            concept_embedding = self.emb_c(concept_index)  # [concept_num, embedding_dim]
            # 直接对概念初始化的矩阵进行embedding,得到所有的知识点的embedding,再用MHA和VAE进行处理
            # 形成初始化的graph
            if self.graph_type == 'MHA':
                query = self.emb_c(masked_qt)
                # 将当前被回答的问题编号为索引,进行embedding,形成张量(8,5,32),假设batch=8,根据qt==[3,1,-1,4,5,2,-1,-1]
                # 即masked_qt.shape(0)=5,masked_qt=[3,1,4,5,2],qt_mask=[1,1,0,1,1,1,0,0]
                key = concept_embedding
                # 以所有知识点的embedding作为关键字
                att_mask = Variable(torch.ones(self.edge_type_num, mask_num, self.concept_num, device=qt.device))
                # 进行多头注意力计算,得到graph矩阵 其中Variable函数引用于variable.py
                for k in range(self.edge_type_num):
                    index_tuple = (torch.arange(mask_num, device=qt.device), masked_qt.long())
                    att_mask[k] = att_mask[k].index_put(index_tuple, torch.zeros(mask_num, device=qt.device))
                graphs = self.graph_model(masked_qt, query, key, att_mask)
            else:  # self.graph_type == 'VAE'
                # 通过函数_get_edges,从当前被回答的问题序列中获取sp_send, sp_rec, sp_send_t, sp_rec_t
                # 函数_get_edges在下面有定义
                sp_send, sp_rec, sp_send_t, sp_rec_t = self._get_edges(masked_qt)
                graphs, rec_embedding, z_prob = self.graph_model(concept_embedding, sp_send, sp_rec, sp_send_t,sp_rec_t)
                # 将concept_embedding作为初始节点的embedding,使用graph_model进行解码和编码,得到graph
            neigh_features = 0
            for k in range(self.edge_type_num):
                adj = graphs[k][masked_qt, :].unsqueeze(dim=-1)  # [mask_num, concept_num, 1]
                if k == 0:
                    neigh_features = adj * self.f_neighbor_list[k](neigh_ht)
                    # 使用前面(149行)定义的f_neighbor_list()中的第k个f函数对neigh-ht进行线性变换处理,得到neigh_features
                else:
                    neigh_features = neigh_features + adj * self.f_neighbor_list[k](neigh_ht)
            if self.graph_type == 'MHA':
                neigh_features = 1. / self.edge_type_num * neigh_features
                # 通过对graph和neigh_ht进行线性变换得到neigh_features
        # neigh_features: [mask_num, concept_num, hidden_dim]
        m_next = tmp_ht[:, :, :self.hidden_dim]
        # 对当前时间步中的知识点的隐藏状态切片处理,m_next=(batch=8,concept_num=4,hidden_dim=32)
        m_next[qt_mask] = neigh_features
        # 通过qt_mask,将m_next中对应的行提取出来,得到大小[mask_num,hidden_dim]=[5,32]的张量self_features
        m_next[qt_mask] = m_next[qt_mask].index_put(self_index_tuple, self_features)
        '''
        # 将邻居节点的特征按照他们在qt中出现的顺序排列,得到一个[5,32]的张量neigh_features
        # 将neigh_features的值更新到self_features中指定的位置,得到更新的张量[5,32]self_features
        # 最后将self_features按照在qt中出现的顺序重新排列,更新回到m_next中相应的位置
        # self_features = self.f_self(self_ht)   [mask_num, hidden_dim]
        # 具体来说,我们通过 self_index_tuple 来确定需要更新的位置,
        # 然后使用 PyTorch 的 index_put() 方法将更新后的 self_features 更新到 m_next 中。
        # 这样就完成了邻居节点特征的更新。
        '''
        return m_next, concept_embedding, rec_embedding, z_prob

接下来是解释update函数,以及其中用到的erase&add和gru的用法

    def _update(self, tmp_ht, ht, qt):
        r"""
        用于过呢更新知识图谱中每个概念的隐藏状态表示,接收参数
        tmp_ht:每个概念在当前时刻的临时隐藏状态表示
        ht:每个概念的当前时刻的隐藏状态表示
        qt:当前时刻被学生回答的问题索引
        得到:h_next:下一个时刻的所有概念的隐藏状态
        可用于VAE模型的变量的更新
        Parameters:
            tmp_ht: temporal hidden representations of all concepts after the aggregate step
            ht: hidden representations of all concepts at the current timestamp
            qt: question indices for all students in a batch at the current timestamp
        Shape:
            tmp_ht: [batch_size, concept_num, hidden_dim + embedding_dim]
            ht: [batch_size, concept_num, hidden_dim]
            qt: [batch_size]
            h_next: [batch_size, concept_num, hidden_dim]
        Return:
            h_next: hidden representations of all concepts at the next timestamp
            concept_embedding: input of VAE (optional)
            rec_embedding: reconstructed input of VAE (optional)
            z_prob: probability distribution of latent variable z in VAE (optional)
        """
        qt_mask = torch.ne(qt, -1)  # [batch_size], qt != -1
        # 老操作,将未回答问题的学生去除
        mask_num = qt_mask.nonzero().shape[0]  # 得到当前回答问题的学生数
        # GNN Aggregation
        # 通过上面的聚合函数_agg_neighbors,得到每个概念的隐藏状态表示:m_next
        m_next, concept_embedding, rec_embedding, z_prob = self._agg_neighbors(tmp_ht,qt)  # [batch_size, concept_num, hidden_dim]
        # Erase & Add Gate
        # 调用init中的erase_add_gate,对m_next进行更新
        '''
        自定义的擦除添加门,用来控制模型对先前学生知识状态进行擦除/添加的过程,
        将输入的隐藏状态和先前学生知识状态作为输入,
        通过两个全连接层(分别用于擦除和添加)输出一个向量,
        更新学生的知识状态
        '''
        m_next[qt_mask] = self.erase_add_gate(m_next[qt_mask])  # [mask_num, concept_num, hidden_dim]
        # GRU
        '''
        GRUCell的实例被用于更新隐藏状态h,其中的输入x被传入GRUCell的forward方法中进行计算,得到更新后的隐藏状态h。
        由于GRUCell只有一个时间步,因此只会更新一次隐藏状态。
        最后,我们返回更新后的隐藏状态h作为函数update的输出。
        这段代码用来更新h_next,首先是对满足qt_mask条件的数据进行处理,
        因为m_next和ht都是形状为(batch_size, concept_num, hidden_dim)的张量,
        而GRUCell的输入要求是一个形状为(seq_len, batch_size, input_size)的张量,
        因此需要把m_next[qt_mask]和ht[qt_mask]展平成一个形状为(mask_num * concept_num, hidden_dim)的张量。
        这里的mask_num指的是满足qt_mask条件的数据数量,也就是需要更新的题目数量。
        GRUCell的输入是一个形状为(seq_len, batch_size, input_size)的张量和上一个时刻的隐藏状态hx,
        输出则是一个形状为(seq_len, batch_size, hidden_size)的张量
        把展平后的m_next[qt_mask]作为输入,ht[qt_mask]作为隐藏状态,
        调用self.gru()进行计算,得到的结果是形状为(mask_num * concept_num, hidden_num)的张量,
        即每个题目都对应一个新的隐藏状态。
        '''
        h_next = m_next
        res = self.gru(m_next[qt_mask].reshape(-1, self.hidden_dim),ht[qt_mask].reshape(-1, self.hidden_dim))  # [mask_num * concept_num, hidden_num]
        '''
        通过调用index_put方法,将计算得到的结果更新到h_next张量中。
        index_put方法的参数是一个元组(index_tuple, value),
        其中index_tuple指定了需要更新的位置,value是需要更新的值。
        这里的index_tuple是一个元组(torch.arange(mask_num, device=qt_mask.device),),
        指定了需要更新的位置为每个mask_num(即每个需要更新的题目)对应的位置,也就是在第一维上按照mask_num的顺序更新。
        value则是调用reshape方法把计算得到的结果形状变为(mask_num, concept_num, hidden_dim),
        然后再把它赋值给h_next的对应位置。这样,就完成了对h_next的更新。
        '''
        index_tuple = (torch.arange(mask_num, device=qt_mask.device),)
        # 实现是通过index_put方法,将更新后的结果res中的每一行与h_next中对应的行进行替换。
        h_next[qt_mask] = h_next[qt_mask].index_put(index_tuple, res.reshape(-1, self.concept_num, self.hidden_dim))
        # 使用了一个名为index_tuple的元组来构造一个与更新后的结果res中的行数相同的行索引。
        # 对于每个索引,我们使用张量的index_put方法将res中的每一行与h_next中对应的行进行替换。
        return h_next, concept_embedding, rec_embedding, z_prob

 其中的erase&add用法如下:(在项目的layers.py中)

class EraseAddGate(nn.Module):
    """
    Erase & Add Gate module
    NOTE: this erase & add gate is a bit different from that in DKVMN.
    For more information about Erase & Add gate, please refer to the paper "Dynamic Key-Value Memory Networks for Knowledge Tracing"
    The paper can be found in https://arxiv.org/abs/1611.08108
    """

    def __init__(self, feature_dim, concept_num, bias=True):
        super(EraseAddGate, self).__init__()
        # weight
        self.weight = nn.Parameter(torch.rand(concept_num))
        # 可学习的参数weight,维度为concept_num,表示每个概念的权重
        self.reset_parameters()
        # 模块的初始化参数的方法,对weight参数进行均匀分布的初始化
        # erase gate
        # 它包含了两个全连接层(进行线性变换),一个层用来计算要删除的信息,一个层用来计算要添加的信息
        self.erase = nn.Linear(feature_dim, feature_dim, bias=bias)
        # add gate
        self.add = nn.Linear(feature_dim, feature_dim, bias=bias)

    def reset_parameters(self):
        stdv = 1. / math.sqrt(self.weight.size(0))
        self.weight.data.uniform_(-stdv, stdv)

    def forward(self, x):
        r"""
        Params:
            x: input feature matrix
        Shape:
            x: [batch_size, concept_num, feature_dim]
            res: [batch_size, concept_num, feature_dim]
            这个 Erase & Add Gate 模块主要是通过擦除门和加入门的线性变换以及权重参数 weight 对输入特征进行操作
            以实现擦除旧信息并加入新信息的功能。
            1、输入x经过擦除门的线性变换,执行sigmoid函数,得到删除信息的比例
            2、用x-权重参数*删除信息比例*x得到tmp_x(临时存储删除信息后的x)
            3、这里注意,由于weight和x的维度不同,所以要使用unsqueeze函数进行维度扩展,保证两个维度相同可以进行运算
            4、再将x放入add门中进行线性变换,再通过tanh函数对其进行计算,得到需要添加的信息
            5、最后的结果就是删除不必要信息的x加上添加信息后的x,就是更新完毕的
        Return:
            res: returned feature matrix with old information erased and new information added
        The GKT paper didn't provide detailed explanation about this erase-add gate. As the erase-add gate in the GKT only has one input parameter,
        this gate is different with that of the DKVMN. We used the input matrix to build the erase and add gates, rather than $\mathbf{v}_{t}$ vector in the DKVMN.
        """
        erase_gate = torch.sigmoid(self.erase(x))
        # 首先对输入的特征矩阵放入擦除门的全连接层内进行线性变化,
        # 再执行sigmoid操作,计算了要删除信息的比例
        # [batch_size, concept_num, feature_dim]
        # self.weight.unsqueeze(dim=1) shape: [concept_num, 1]
        tmp_x = x - self.weight.unsqueeze(dim=1) * erase_gate * x
        # 按照该比例,将原来的信息从特征矩阵中删除
        add_feat = torch.tanh(self.add(x))
        # 使用tanh函数计算了要添加的信息
        # [batch_size, concept_num, feature_dim]
        res = tmp_x + self.weight.unsqueeze(dim=1) * add_feat
        # 最后将擦除后的特征矩阵与添加的信息相加,得到最终更新后的特征矩阵
        '''
        在函数内部,首先使用 torch.sigmoid() 函数将输入 m_next 的每个元素都映射到 0 到 1 之间。
        然后,将输入 m_next 中的一部分元素(对应当前时间戳上回答问题的学生所涉及的概念)与映射后的值相乘,
        得到这部分元素被保留下来的结果。
        同时,将输入 m_next 中的另一部分元素(对应未回答问题的学生所涉及的概念)与 1 减去映射后的值相乘,
        得到这部分元素被抹去的结果。
        最后,将这两部分结果相加,得到经过“擦除-添加门”机制控制后的信息,并将其作为函数的返回值返回。
        '''
        return res

GRUcell的实现主要是包含3个门,重置门、更新门、新状态门,代码如下:(在库的rnn.py中)

class GRUCell(RNNCellBase):
    r"""A gated recurrent unit (GRU) cell

    .. math::

        \begin{array}{ll}
        r = \sigma(W_{ir} x + b_{ir} + W_{hr} h + b_{hr}) \\
        z = \sigma(W_{iz} x + b_{iz} + W_{hz} h + b_{hz}) \\
        n = \tanh(W_{in} x + b_{in} + r * (W_{hn} h + b_{hn})) \\
        h' = (1 - z) * n + z * h
        \end{array}

    where :math:`\sigma` is the sigmoid function, and :math:`*` is the Hadamard product.

    Args:
        input_size: The number of expected features in the input `x`
        hidden_size: The number of features in the hidden state `h`
        bias: If ``False``, then the layer does not use bias weights `b_ih` and
            `b_hh`. Default: ``True``

    Inputs: input, hidden
        - **input** : tensor containing input features
        - **hidden** : tensor containing the initial hidden
          state for each element in the batch.
          Defaults to zero if not provided.

    Outputs: h'
        - **h'** : tensor containing the next hidden state
          for each element in the batch

    Shape:
        - input: :math:`(N, H_{in})` or :math:`(H_{in})` tensor containing input features where
          :math:`H_{in}` = `input_size`.
        - hidden: :math:`(N, H_{out})` or :math:`(H_{out})` tensor containing the initial hidden
          state where :math:`H_{out}` = `hidden_size`. Defaults to zero if not provided.
        - output: :math:`(N, H_{out})` or :math:`(H_{out})` tensor containing the next hidden state.

    Attributes:
        weight_ih: the learnable input-hidden weights, of shape
            `(3*hidden_size, input_size)`
        weight_hh: the learnable hidden-hidden weights, of shape
            `(3*hidden_size, hidden_size)`
        bias_ih: the learnable input-hidden bias, of shape `(3*hidden_size)`
        bias_hh: the learnable hidden-hidden bias, of shape `(3*hidden_size)`

    .. note::
        All the weights and biases are initialized from :math:`\mathcal{U}(-\sqrt{k}, \sqrt{k})`
        where :math:`k = \frac{1}{\text{hidden\_size}}`

    On certain ROCm devices, when using float16 inputs this module will use :ref:`different precision<fp16_on_mi200>` for backward.

    Examples::

        >>> rnn = nn.GRUCell(10, 20)
        >>> input = torch.randn(6, 3, 10)
        >>> hx = torch.randn(3, 20)
        >>> output = []
        >>> for i in range(6):
                hx = rnn(input[i], hx)
                output.append(hx)
    """

    def __init__(self, input_size: int, hidden_size: int, bias: bool = True,
                 device=None, dtype=None) -> None:
        factory_kwargs = {'device': device, 'dtype': dtype}
        super(GRUCell, self).__init__(input_size, hidden_size, bias, num_chunks=3, **factory_kwargs)

    def forward(self, input: Tensor, hx: Optional[Tensor] = None) -> Tensor:
        assert input.dim() in (1, 2), \
            f"GRUCell: Expected input to be 1-D or 2-D but received {input.dim()}-D tensor"
        is_batched = input.dim() == 2
        if not is_batched:
            input = input.unsqueeze(0)

        if hx is None:
            hx = torch.zeros(input.size(0), self.hidden_size, dtype=input.dtype, device=input.device)
        else:
            hx = hx.unsqueeze(0) if not is_batched else hx

        ret = _VF.gru_cell(
            input, hx,
            self.weight_ih, self.weight_hh,
            self.bias_ih, self.bias_hh,
        )

        if not is_batched:
            ret = ret.squeeze(0)

        return ret

在update中是使用GRU单元来更新某些问题的知识状态,更新后的结果存储在h_next张量中。

首先,将m_next张量中与当前问题有关的行选择出来,并将这些行的维度变形成二维张量,维度为 [mask_num * concept_num, hidden_num],其中mask_num表示与当前问题相关的所有知识点的数量,concept_num表示知识点的数量,hidden_dim表示隐藏层的维度。然后,将ht张量中与当前问题有关的行也选择出来,并将这些行的维度变形为二维张量,维度同样为 [mask_num * concept_num, hidden_num]。

接着,将这两个二维张量作为GRU单元的输入,调用GRU单元的forward方法,将结果保存在res张量中。这里需要注意,GRU单元的输入维度为 [seq_len, batch_size, input_size],而这里我们的输入数据维度为 [mask_num * concept_num, hidden_num],因此需要将这个输入数据当做一个长度为1的序列,并将输入数据的维度从 [mask_num * concept_num, hidden_num] 变形为 [1, mask_num * concept_num, hidden_num]。

最后,我们将更新后的结果res的维度再次变形为 [mask_num, concept_num, hidden_num] 的形式,并将其保存在h_next中与当前问题相关的行。具体实现是通过index_put方法,将更新后的结果res中的每一行与h_next中对应的行进行替换。在这里,我们使用了一个名为index_tuple的元组来构造一个与更新后的结果res中的行数相同的行索引。对于每个索引,我们使用张量的index_put方法将res中的每一行与h_next中对应的行进行替换。

接下来是predicate方法

# Predict step, as shown in Section 3.3.3 of the paper
    # 用在更新概念状态后预测每一个学生在下一个时间戳中各个知识点的掌握情况
    def _predict(self, h_next, qt):
        r"""
        Parameters:
            h_next: hidden representations of all concepts at the next timestamp after the update step
            qt: question indices for all students in a batch at the current timestamp
        Shape:
            h_next: [batch_size, concept_num, hidden_dim]
            qt: [batch_size]
            y: [batch_size, concept_num]
        Return:
            y: predicted correct probability of all concepts at the next timestamp
        """
        qt_mask = torch.ne(qt, -1)  # [batch_size], qt != -1
        # 还是老操作啦,去除当前未回答问题的学生
        y = self.predict(h_next).squeeze(dim=-1)  # [batch_size, concept_num]
        # 调用__init__中构造的全连接层self_predict对更新好哒下一个时间戳的概念的隐藏状态进行预测
        y[qt_mask] = torch.sigmoid(y[qt_mask])  # [batch_size, concept_num]
        # 只选择在当前时间戳回答问题的学生,将对应的预测值进行sigmoid函数变换
        '''
        self_predict是一个全连接层,将隐藏表示的每个概念映射到一个标量,代表其正确性概率。
        在这个函数中,y 的形状为 [batch_size, concept_num, 1],其中最后一维表示正确性概率。
        最终的概率可以通过对最后一维应用 sigmoid 函数得到。
        这里预测的是当前回答问题的学生在下一个时间戳对所有概念的掌握情况
        '''
        return y

   

 predicate方法最后返回的是对下一时间戳所有概念掌握情况的预测,即掌握,没掌握;再用知识点的掌握情况去预测学生下一个时间戳问题的回答情况,所以需要下一个时间戳的问题索引,且保证当前时间下,学生回答了问题。

 def _get_next_pred(self, yt, q_next):
        r"""
        实现了根据下一个时间戳的知识点的预测结果和下一个时间戳的问题索引,
        来预测下一个时间戳中,学生将要回答的问题的正确率。
        Parameters:
            yt: predicted correct probability of all concepts at the next timestamp
            q_next: question index matrix at the next timestamp
            这里的q_next 应该是从update方法中传入,具体来说,q_next是下一个时间戳中学生将要回答的问题的索引矩阵,
            在update方法中,从输入参数qt更新提取处理
            batch_size: the size of a student batch
        Shape:
            y: [batch_size, concept_num]
            questions: [batch_size, seq_len]
            pred: [batch_size, ]
        Return:
            pred: predicted correct probability of the question answered at the next timestamp
        """
        next_qt = q_next
        # 首先将下一个时间戳中的问题索引赋值给next_qt
        next_qt = torch.where(next_qt != -1, next_qt, self.concept_num * torch.ones_like(next_qt, device=yt.device))
        # 使用torch.where函数,将问题索引为-1的位置替换为概念数目
        one_hot_qt = F.embedding(next_qt.long(), self.one_hot_q)  # [batch_size, concept_num]
        # 使用F.embedding函数,将next_qt中的每个索引都映射到one-hot向量中
        # 提取下一个时间戳中所有学生将要回答的问题的onehot编码
        # 注意 next_qt中每个位置的值,对应的是学生下一个时间戳将要回答的问题在概念集合的索引,不是实际的问题内容
        # self.one_hot_q:是所有问题one_hot编码组成的张量,通过next_qt中的值,从self.one_hot_q中提取出所有将要回答的问题的one_hot编码
        # dot product between yt and one_hot_qt
        '''
        对预测的知识点掌握概率矩阵 yt 和 one_hot_qt 进行逐元素相乘,
        然后在第一个维度上求和,得到每个学生在下一个时间戳回答问题的掌握概率 pred。
       为什么是第一个维度,是因为yt和one_hot_qt都是按照概念排列排序的,所以要按照概念的维度进行求和
        '''
        pred = (yt * one_hot_qt).sum(dim=1)  # [batch_size, ]
      
        return pred

get_edge函数是VAE中的常用函数

# Get edges for edge inference in VAE
    def _get_edges(self, masked_qt):
        r"""
        在构建知识图谱中的边,用于表示概念之间的联系。
        Parameters:
            masked_qt: qt index with -1 padding values removed
            这个参数是每个函数进行的老操作行为,不再赘述(当前时间戳被回答的问题索引)
        Shape:
            masked_qt: [mask_num, ]
            rel_send: [edge_num, concept_num]
            rel_rec: [edge_num, concept_num]
        Return:
            rel_send: from nodes in edges which send messages to other nodes
            rel_rec:  to nodes in edges which receive messages from other nodes
        """
        mask_num = masked_qt.shape[0]
        # 当前时间戳,在回答问题的学生数量,即要确定添加的边的数量
        row_arr = masked_qt.cpu().numpy().reshape(-1, 1)  # [mask_num, 1]
        # 将masked_qt转化为numpy数组,reshape是把二维张量的列变成一维的列向量,行维度自动计算
        # 若仍假设qt=[3,1,-1,4,5,2,-1,-1] 那么mask_num=5
        # 这里的row_arr =[3
        #                1
        #                4
        #                5
        #                2]
        row_arr = np.repeat(row_arr, self.concept_num, axis=1)  # [mask_num, concept_num]
        # 利用np.repeat函数,将列向量重复concept_num次,变成一个矩阵
        # 即相当于是将问题序列的索引值复制到每个概念节点上,得到row_arr和col_arr
        # 注意一点,row是边的起点,col是边的终点,仍假设concept_num=4
        '''
        [[3,3,3,3]
         [1,1,1,1]
         [4,4,4,4]
         [5,5,5,5]
         [2,2,2,2]]
        '''
        col_arr = np.arange(self.concept_num).reshape(1, -1)  # [1, concept_num]
        # 生成一个行向量,每个元素的值在0-concept_num-1之间 concept_num=4
        # 其中的reshape是将二维张量的行变成一维的,列维度自动计算
        '''
        col_arr=[2,1,0,3]
        '''
        col_arr = np.repeat(col_arr, mask_num, axis=0)  # [mask_num, concept_num]
        # 将行向量复制mask_num次,即复制5次
        '''
        col_arr=[[2,1,0,3]
                 [2,1,0,3]
                 [2,1,0,3]
                 [2,1,0,3]
                 [2,1,0,3]]
        '''
        # add reversed edges
        new_row = np.vstack((row_arr, col_arr))  # [2 * mask_num, concept_num]
        # 将row和col沿行的方向拼接起来,得到新矩阵
        '''
         new_row=
        [[3,3,3,3]
         [1,1,1,1]
         [4,4,4,4]
         [5,5,5,5]
         [2,2,2,2]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]]
        '''
        new_col = np.vstack((col_arr, row_arr))  # [2 * mask_num, concept_num]
        '''
        new_col=
        [[2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [3,3,3,3]
         [1,1,1,1]
         [4,4,4,4]
         [5,5,5,5]
         [2,2,2,2]]
        '''
        row_arr = new_row.flatten()  # [2 * mask_num * concept_num, ]
        # 将矩阵按行展平,得到形状为2*mask_num*concept_num的一维数组
        # flatten方法的作用,把多维数组压缩成一维向量,返回一个复制后的数组
        '''
        例如上述说的 new_row
         new_row=
        [[3,3,3,3]
         [1,1,1,1]
         [4,4,4,4]
         [5,5,5,5]
         [2,2,2,2]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]
         [2,1,0,3]]
         那么row_arr=[3,3,3,3,1,1,1,1,4,4,4,4,5,5,5,5,2,2,2,2,2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3]
        '''
        col_arr = new_col.flatten()  # [2 * mask_num * concept_num, ]
        # 同理那么col_arr=[2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3,3,3,3,3,1,1,1,1,4,4,4,4,5,5,5,5,2,2,2,2]
        data_arr = np.ones(2 * mask_num * self.concept_num)
        # 创建一个元素值全为1的向量‘data_arr’,向量长度为2 * mask_num * concept_num,
        # data_arr=[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1]
        # 用于初始化coo_matrix
        init_graph = sp.coo_matrix((data_arr, (row_arr, col_arr)), shape=(self.concept_num, self.concept_num))
        init_graph.setdiag(0)  # remove self-loop edges
        # 用data_arr、row_arr、col_arr三个向量来初始化一个稀疏矩阵init_graph
        # coo_matrix方法用于创建稀疏矩阵,它将矩阵中的非零元素按行顺序存储在三个数组中,分别记录其行号、列号和值,因此可以节约存储空间。
        # 第一个参数是元素值数组,第二个参数是(row,col)元组的数组,表示每个元素所处的位置,第三个参数是矩阵的形状
        # 该稀疏矩阵的非0元素都位于(row[i],col[i])位置上,对应的值是data_arr的值 是1
        # 创建的矩阵形状是4×4的 其中对角线元素为0
        #   (0, 1)	1.0
        #   (0, 2)	1.0
        #   (0, 3)	1.0
        #   (1, 0)	1.0
        #   (1, 2)	1.0
        #   (1, 3)	1.0
        #   (2, 0)	1.0
        #   (2, 1)	1.0
        #   (2, 3)	1.0
        #   (3, 0)	1.0
        #   (3, 1)	1.0
        #   (3, 2)	1.0
        '''
        在上面的示例中,row_arr和col_arr包括data_arr都是[40],所以需要将一维的数组重新排列成二维坐标数组来表示稀疏矩阵
        首先是将row_arr和col_arr中的元素进行成对的配对,以便将它们表示成(i,j)的形式
        通过将row_arr和col_arr沿这第二个维度进行堆叠实现,因为这两个数组都有40个元素,所以堆叠后得到的数组有80个元素
        得到
        [[3,3,3,3,1,1,1,1,4,4,4,4,5,5,5,5,2,2,2,2,2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3]
         [2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3,2,1,0,3,3,3,3,3,1,1,1,1,4,4,4,4,5,5,5,5,2,2,2,2]]
         
        '''
        row_arr, col_arr, _ = sp.find(init_graph)
        # 使用find()函数找到init_graph中所有非0元素的行下标、列下标以及非0值,由于构造init_graph的时候非0值都设置为1,所以这里并不需要用到他们
        # _表示获取的是非0元素的行、列下标
        row_tensor = torch.from_numpy(row_arr).long()
        # 将行下标转换成pytorch张量
        col_tensor = torch.from_numpy(col_arr).long()
        # 将列下标转换成pytorch张量
        one_hot_table = torch.eye(self.concept_num, self.concept_num)
        # 生成一个大小为(concept,concept)的单位矩阵:每个概念对应一个向量(one_hot形式表示每个概念)
        '''
        [[1,0,0,0]
         [0,1,0,0]
         [0,0,1,0]
         [0,0,0,1]]
        '''
        rel_send = F.embedding(row_tensor, one_hot_table)  # [edge_num, concept_num]
        # 使用PyTorch内置函数F.embedding()将行下标转换成one-hot向量:
        # 将每个边的发送节点的概念下标在概念的one_hot表中查找对应的向量,并将该向量存在rel_send中,即每条边对应一个向量
        # 所以rel_send表示所有边的发送方节点的特征矩阵,大小为(edge_num, concept_num)。
        rel_rec = F.embedding(col_tensor, one_hot_table)  # [edge_num, concept_num]
        # 同理,将每个边的接收节点的概念下标在概念的one_hot表中查找对应的向量,并将该向量存在rel_send中,即每条边对应一个向量
        # rel_rec表示所有边的接收方节点的特征矩阵,大小为(edge_num, concept_num)。
        sp_rec, sp_send = rel_rec.to_sparse(), rel_send.to_sparse()
        # 把上面的结果转换成稀疏张量
        sp_rec_t, sp_send_t = rel_rec.T.to_sparse(), rel_send.T.to_sparse()
        # 把发送和接收边的特征矩阵的转置矩阵也稀疏化
        sp_send = sp_send.to(device=masked_qt.device)
        sp_rec = sp_rec.to(device=masked_qt.device)
        sp_send_t = sp_send_t.to(device=masked_qt.device)
        sp_rec_t = sp_rec_t.to(device=masked_qt.device)
        # 把张量移动到gpu上去计算
        # 最后返回四个稀疏张量和一个稀疏矩阵
        return sp_send, sp_rec, sp_send_t, sp_rec_t, init_graph

最后的forward函数

        for i in range(seq_len):
            # 循环次数是序列长度
            '''
            每次循环的操作:
            1、根据当前的时间步i,从输入数据features和questions中获取当前时间步的输入特征xt和问题qt
               根据qt是否等于-1,生成掩码qt_mask(老操作了),生成当前回答问题的学生的问题索引
            2、调用函数_aggregate,根据当前的输入特征xt,问题qt,当前的隐藏状态ht,以及batch_size大小,
               生成一个临时的隐藏状态tmp_ht,这个临时的隐藏状态的形状为 [batch_size, concept_num, hidden_dim + embedding_dim]
            3、调用函数_update,根据临时的隐藏状态tmp_ht
               生成概念嵌入向量concept_embedding,重构嵌入向量rec_embedding,以及潜变量的概率分布z_prob 
               这个更新后的隐藏状态的形状为 [batch_size, concept_num, hidden_dim]。
            4、将当前时间步的有效隐藏状态'h_next'更新到新的隐藏状态ht中,以便下一次循环使用
            5、通过调用函数 _predict,根据更新后的隐藏状态 h_next 和问题 qt,生成预测向量 yt。
               这个预测向量的形状为 [batch_size, concept_num]
            
            如果当前时间步i不是序列的最后一个时间步,就调用函数_get_next_pred根据预测向量yt 和下一个问题 questions[:, i + 1]
            生成下一个预测值 pred。将这个下一个预测值 pred 添加到 pred_list 列表中。
            将概念嵌入向量 concept_embedding、重构嵌入向量 rec_embedding,以及潜变量的概率分布 z_prob 添加到 ec_list、rec_list 和 z_prob_list 列表中。
            循环结束后,将所有的下一个预测值 pred_list 按时间步拼接成一个 tensor,形状为 [batch_size, seq_len - 1]
            并将预测结果 pred_res、概念嵌入向量列表 ec_list、重构嵌入向量列表 rec_list,以及潜变量的概率分布列表 z_prob_list 返回。
            '''
            # rec_embedding是VAE的一个输出,表示经过VAE解码器(decoder)处理后重构出来的隐变量对应的向量。
            xt = features[:, i]  # [batch_size]
            # 获取当前时间步的输入特征,维度为batch_size
            qt = questions[:, i]  # [batch_size]
            # 获取当前时间步的问题,维度为batch_size
            qt_mask = torch.ne(qt, -1)  # [batch_size], next_qt != -1
            # 去除当前时间步未回答的问题索引
            tmp_ht = self._aggregate(xt, qt, ht, batch_size)  # [batch_size, concept_num, hidden_dim + embedding_dim]
            # 对输入的特征核问题进行聚合,得到临时隐藏状态
            h_next, concept_embedding, rec_embedding, z_prob = self._update(tmp_ht, ht,qt)  # [batch_size, concept_num, hidden_dim]
            # 调用_update函数进行计算,通过临时隐藏状态计算出下一个隐藏状态
            ht[qt_mask] = h_next[qt_mask]  # update new ht
            # 只更新当前时间步被回答了的知识点的隐藏状态
            yt = self._predict(h_next, qt)  # [batch_size, concept_num]
            # 为下一个时间步的隐藏状态进行预测,得到预测结果yt
            if i < seq_len - 1:
                # 如果当前时间步不是最后一个时间步,计算下一个时间步的预测结果
                pred = self._get_next_pred(yt, questions[:, i + 1])
                # 计算下一个时间步的预测结果添加到列表中
                pred_list.append(pred)
            ec_list.append(concept_embedding)
            rec_list.append(rec_embedding)
            z_prob_list.append(z_prob)
            # 将计算的结果都存在列表中
        pred_res = torch.stack(pred_list, dim=1)  # [batch_size, seq_len - 1]
        return pred_res, ec_list, rec_list, z_prob_list

下面是关于train.py的全部代码的分析解释

import numpy as np
import time
import random
import argparse
import pickle
import os
import gc
import datetime
import torch
import torch.optim as optim
from torch.optim import lr_scheduler
from torch.autograd import Variable
from models import GKT, MultiHeadAttention, VAE, DKT
# 使用gkt对学生的知识追踪进行建模,并进行知识状态预测
# MultiHeadAttention用于提取知识状态的信息,VAE用于学习知识状态的潜在分布,DKT用于进行单独的知识状态预测。
from metrics import KTLoss, VAELoss
# 在训练模型时,使用KTLoss和VAELoss作为损失函数进行优化。
from processing import load_dataset

# Graph-based Knowledge Tracing: Modeling Student Proficiency Using Graph Neural Network.
# For more information, please refer to https://dl.acm.org/doi/10.1145/3350546.3352513
# Author: jhljx
# Email: jhljx8918@gmail.com


parser = argparse.ArgumentParser()
parser.add_argument('--no-cuda', action='store_false', default=True, help='Disables CUDA training.')
parser.add_argument('--seed', type=int, default=42, help='Random seed.')
parser.add_argument('--data-dir', type=str, default='data', help='Data dir for loading input data.')
parser.add_argument('--data-file', type=str, default='assistment_test15.csv', help='Name of input data file.')
parser.add_argument('--save-dir', type=str, default='logs', help='Where to save the trained model, leave empty to not save anything.')
parser.add_argument('-graph-save-dir', type=str, default='graphs', help='Dir for saving concept graphs.')
parser.add_argument('--load-dir', type=str, default='', help='Where to load the trained model if finetunning. ' + 'Leave empty to train from scratch')
parser.add_argument('--dkt-graph-dir', type=str, default='dkt-graph', help='Where to load the pretrained dkt graph.')
parser.add_argument('--dkt-graph', type=str, default='dkt_graph.txt', help='DKT graph data file name.')
parser.add_argument('--model', type=str, default='GKT', help='Model type to use, support GKT and DKT.')
parser.add_argument('--hid-dim', type=int, default=32, help='Dimension of hidden knowledge states.')
parser.add_argument('--emb-dim', type=int, default=32, help='Dimension of concept embedding.')
parser.add_argument('--attn-dim', type=int, default=32, help='Dimension of multi-head attention layers.')
parser.add_argument('--vae-encoder-dim', type=int, default=32, help='Dimension of hidden layers in vae encoder.')
parser.add_argument('--vae-decoder-dim', type=int, default=32, help='Dimension of hidden layers in vae decoder.')
parser.add_argument('--edge-types', type=int, default=2, help='The number of edge types to infer.')
parser.add_argument('--graph-type', type=str, default='Dense', help='The type of latent concept graph.')
parser.add_argument('--dropout', type=float, default=0, help='Dropout rate (1 - keep probability).')
parser.add_argument('--bias', type=bool, default=True, help='Whether to add bias for neural network layers.')
parser.add_argument('--binary', type=bool, default=True, help='Whether only use 0/1 for results.')
parser.add_argument('--result-type', type=int, default=12, help='Number of results types when multiple results are used.')
parser.add_argument('--temp', type=float, default=0.5, help='Temperature for Gumbel softmax.')
parser.add_argument('--hard', action='store_true', default=False, help='Uses discrete samples in training forward pass.')
parser.add_argument('--no-factor', action='store_true', default=False, help='Disables factor graph model.')
parser.add_argument('--prior', action='store_true', default=False, help='Whether to use sparsity prior.')
parser.add_argument('--var', type=float, default=1, help='Output variance.')
parser.add_argument('--epochs', type=int, default=50, help='Number of epochs to train.')
parser.add_argument('--batch-size', type=int, default=128, help='Number of samples per batch.')
parser.add_argument('--train-ratio', type=float, default=0.6, help='The ratio of training samples in a dataset.')
parser.add_argument('--val-ratio', type=float, default=0.2, help='The ratio of validation samples in a dataset.')
parser.add_argument('--shuffle', type=bool, default=True, help='Whether to shuffle the dataset or not.')
parser.add_argument('--lr', type=float, default=0.001, help='Initial learning rate.')
parser.add_argument('--lr-decay', type=int, default=200, help='After how epochs to decay LR by a factor of gamma.')
parser.add_argument('--gamma', type=float, default=0.5, help='LR decay factor.')
parser.add_argument('--test', type=bool, default=False, help='Whether to test for existed model.')
parser.add_argument('--test-model-dir', type=str, default='logs/expDKT', help='Existed model file dir.')


'''
对模型的参数进行设置,并保证训练过程的随机性和重现性,以确保模型的稳定性和可靠性。
'''
args = parser.parse_args()
# 从上面的命令行输入中解析参数,将解析结果保存到args变量中。
args.cuda = not args.no_cuda and torch.cuda.is_available()
# 如果不使用 no_cuda 参数,并且GPU可用,则将 args.cuda 设为True,表示使用GPU进行训练。
args.factor = not args.no_factor
# 如果不使用 no_factor 参数,则将 args.factor 设为True,表示启用矩阵分解技术进行模型训练。
'''
使用矩阵分解因子可以将高维稀疏数据转化为低维稠密数据,并提取数据的隐含特征,使得数据表现出更好的可解释性。
同时,它还能够对数据进行降维,减少数据存储和计算复杂度。
而不使用矩阵分解因子则可能会导致高维稀疏数据处理效率低下、过拟合等问题。
'''
print(args)

random.seed(args.seed)
# random.seed()内置的随机数生成器的种子函数,用于生成固定的随机序列,args.seed是通过命令行参数传入的随机种子,可以是任意整数
# 该函数将随机数生成器的状态初始化
# 为一个特定的状态,使得在同一个随机数生成器下,每次生成的随机数序列都相同
np.random.seed(args.seed)
# 这是numpy库中的随机数生成器的种子函数,用法同上
torch.manual_seed(args.seed)
# 设置随机数种子,保证实验的可重复性
# pytorch库中随机数生成器的种子函数,用法同上
if args.cuda:
    torch.cuda.manual_seed(args.seed)
    # 是否开启gpu加速
    torch.cuda.manual_seed_all(args.seed)
    # 设置所有gpu的随机数种子,使得所有使用gpu的部分得到的相同的随机数种子
    torch.backends.cudnn.benchmark = False
    # 控制cuda的自动优化过程,如果将其设置为True,则会在每次运行前自动寻找最优的卷积算法,从而可以加速程序的运行。
    # 但这样做也可能导致程序在不同的GPU上运行结果不一致,所以在设置随机数种子时需要将其设置为False。
    torch.backends.cudnn.deterministic = True
    # 它可以控制是否使用确定性卷积,即是否使用同样的算法和参数进行卷积计算。这样可以保证在不同的GPU上运行结果一致,但会牺牲一些性能。
res_len = 2 if args.binary else args.result_type

# Save model and meta-data. Always saves in a new sub-folder.
log = None
save_dir = args.save_dir
if args.save_dir:
    exp_counter = 0
    now = datetime.datetime.now()
    # timestamp = now.isoformat()
    timestamp = now.strftime('%Y-%m-%d %H-%M-%S')
    if args.model == 'DKT':
        model_file_name = 'DKT'
    elif args.model == 'GKT':
        model_file_name = 'GKT' + '-' + args.graph_type
    else:
        raise NotImplementedError(args.model + ' model is not implemented!')
    save_dir = '{}/exp{}/'.format(args.save_dir, model_file_name + timestamp)
    if not os.path.exists(save_dir):
        os.makedirs(save_dir)
    meta_file = os.path.join(save_dir, 'metadata.pkl')
    model_file = os.path.join(save_dir, model_file_name + '.pt')
    optimizer_file = os.path.join(save_dir, model_file_name + '-Optimizer.pt')
    scheduler_file = os.path.join(save_dir, model_file_name + '-Scheduler.pt')
    log_file = os.path.join(save_dir, 'log.txt')
    log = open(log_file, 'w')
    pickle.dump({'args': args}, open(meta_file, "wb"))
else:
    print("WARNING: No save_dir provided!" + "Testing (within this script) will throw an error.")

# load dataset
dataset_path = os.path.join(args.data_dir, args.data_file)
dkt_graph_path = os.path.join(args.dkt_graph_dir, args.dkt_graph)
if not os.path.exists(dkt_graph_path):
    dkt_graph_path = None
concept_num, graph, train_loader, valid_loader, test_loader = load_dataset(dataset_path, args.batch_size, args.graph_type, dkt_graph_path=dkt_graph_path,
                                                                           train_ratio=args.train_ratio, val_ratio=args.val_ratio, shuffle=args.shuffle,
                                                                           model_type=args.model, use_cuda=args.cuda)

# build models
graph_model = None
# 定义变量,初始化为空值
if args.model == 'GKT':
    # 若命令行参数graph_model=gkt,则继续
    if args.graph_type == 'MHA':
        # 若命令行参数graph_type=MHA,则继续
        graph_model = MultiHeadAttention(args.edge_types, concept_num, args.emb_dim, args.attn_dim, dropout=args.dropout)
    # 创建一个多头注意力实例对象(模型)
    elif args.graph_type == 'VAE':
        # 若命令行参数是vae,则执行下面:
        graph_model = VAE(args.emb_dim, args.vae_encoder_dim, args.edge_types, args.vae_decoder_dim, args.vae_decoder_dim, concept_num,
                          edge_type_num=args.edge_types, tau=args.temp, factor=args.factor, dropout=args.dropout, bias=args.bias)
        # 创建一个VAE实例对象
        # 创建一个VAEloss实例对象
        vae_loss = VAELoss(concept_num, edge_type_num=args.edge_types, prior=args.prior, var=args.var)
        if args.cuda:
            vae_loss = vae_loss.cuda()
    if args.cuda and args.graph_type in ['MHA', 'VAE']:
        graph_model = graph_model.cuda()
    model = GKT(concept_num, args.hid_dim, args.emb_dim, args.edge_types, args.graph_type, graph=graph, graph_model=graph_model,
                dropout=args.dropout, bias=args.bias, has_cuda=args.cuda)
    # 创建一个GKT实例对象(模型)
elif args.model == 'DKT':
    model = DKT(res_len * concept_num, args.emb_dim, concept_num, dropout=args.dropout, bias=args.bias)
    # 创建一个DKT实例对象(模型)
else:
    raise NotImplementedError(args.model + ' model is not implemented!')
'''
if args.model == 'GKT':
elif args.model == 'DKT':
    model = DKT(res_len * concept_num, args.emb_dim, concept_num, dropout=args.dropout, bias=args.bias)
else:
    raise NotImplementedError(args.model + ' model is not implemented!')
    这是最外围的if循环体
若第一步是model==GKT,进入循环选择图模型
则进入下面的循环体 判断是构建一个MHA图模型还是构建一个VAE图模型
 if args.graph_type == 'MHA':...
 elif args.graph_type == 'VAE':...
 并进行超参数的设置,构建完成后将图模型移动到GPU上
若model==DKT
构建一个深度知识追踪模型,同时设置一些超参数
** 这里的循环都是根据model和graph_type的不同选值来选择模型或者图模型
最后当模型不属于两者时,抛出异常,提示模型未被实现     
'''
kt_loss = KTLoss()
# 创建损失函数的实例对象(模型)

# build optimizer
optimizer = optim.Adam(model.parameters(), lr=args.lr)
# 定义了一个Adam优化器,它是一种常用的梯度下降算法。
# model.parameters()表示优化器要优化的模型参数,而args.lr则是学习率的初始值。
scheduler = lr_scheduler.StepLR(optimizer, step_size=args.lr_decay, gamma=args.gamma)
# 定义了一个学习率调度器,它是用来自动调整模型的学习率,这里使用了StepLR调度器。
# StepLR调度器会在每一个step_size轮训练后将学习率乘以gamma,即每隔step_size轮训练,学习率就会乘以gamma。
# 这里的args.lr_decay是step_size的值,而args.gamma是学习率的下降因子。
# args.lr_decay和args.gamma是从命令行参数传递进来的。这意味着,可以在训练过程中通过命令行参数调整这些值,而无需修改代码。

# load model/optimizer/scheduler params
if args.load_dir:
    if args.model == 'DKT':
        model_file_name = 'DKT'
    elif args.model == 'GKT':
        model_file_name = 'GKT' + '-' + args.graph_type
    else:
        raise NotImplementedError(args.model + ' model is not implemented!')
    model_file = os.path.join(args.load_dir, model_file_name + '.pt')
    optimizer_file = os.path.join(save_dir, model_file_name + '-Optimizer.pt')
    scheduler_file = os.path.join(save_dir, model_file_name + '-Scheduler.pt')
    model.load_state_dict(torch.load(model_file))
    optimizer.load_state_dict(torch.load(optimizer_file))
    scheduler.load_state_dict(torch.load(scheduler_file))
    args.save_dir = False

# build optimizer
optimizer = optim.Adam(model.parameters(), lr=args.lr)
scheduler = lr_scheduler.StepLR(optimizer, step_size=args.lr_decay, gamma=args.gamma)

if args.model == 'GKT' and args.prior:
    prior = np.array([0.91, 0.03, 0.03, 0.03])  # TODO: hard coded for now
    print("Using prior")
    print(prior)
    log_prior = torch.FloatTensor(np.log(prior))
    log_prior = torch.unsqueeze(log_prior, 0)
    log_prior = torch.unsqueeze(log_prior, 0)
    log_prior = Variable(log_prior)
    if args.cuda:
        log_prior = log_prior.cuda()

if args.cuda:
    model = model.cuda()
    kt_loss = KTLoss()


def train(epoch, best_val_loss, node_sets):
    # 每次迭代通过一个batch的数据来更新模型的参数
    t = time.time()
    loss_train = []
    kt_train = []
    vae_train = []
    auc_train = []
    acc_train = []
    if graph_model is not None:
        # 可选的参数,如果存在,则表示使用图神经网络模型
        graph_model.train()
    model.train()
    # 将神经网络模型设置为训练模式
    for batch_idx, (features, questions, answers) in enumerate(train_loader):
        # 利用for循环来遍历数据集中的每个batch,features, questions, answers分别表示题目特征、问题、答案
        t1 = time.time()
        if args.cuda:
            features, questions, answers = features.cuda(), questions.cuda(), answers.cuda()
            # 移动到GPU上,
        ec_list, rec_list, z_prob_list = None, None, None
        # 用于记录图神经网络中的编码器、解码器和潜变量后验概率的列表,在选择模型前进行初始化为空值
        # 如果不使用图神经网络,则一直是none值
        # 下面进入模型选择,选择不同的模型,用不同的方式计算得到预测结果
        if args.model == 'GKT':
            pred_res, ec_list, rec_list, z_prob_list = model(features, questions)
            # 调用GKT.model()方法获取预测结果和其他的三个值(gkt模型中forward()方法,返回值pred_res, ec_list, rec_list, z_prob_list)
        elif args.model == 'DKT':
            pred_res = model(features, questions)
            # 调用DKT.model()方法获取预测结果,dkt模型中的forward()方法,返回pred_res
        else:
            raise NotImplementedError(args.model + ' model is not implemented!')
        # 否则则说明模型未能创建成功
        loss_kt, auc, acc = kt_loss(pred_res, answers)
        # 调用kt_loss模型中的forward()方法,计算返回loss_kt,auc,acc
        kt_train.append(float(loss_kt.cpu().detach().numpy()))
        # 将loss_kt转换成numpy数组类型,接着使用detach()方法断开该tensor与计算图的练习,
        # 然后通过cpu()方法将其移动到cpu上,
        # 将其保存为float类型,
        # 添加到kt_train列表中,kt_train用于保存每个batch训练的知识追踪损失函数值
        if auc != -1 and acc != -1:
            auc_train.append(auc)
            acc_train.append(acc)
        # 将auc和acc加入相应的列表里面
        # 下面的循环是根据图模型选择不同的计算损失的方式
        if args.model == 'GKT' and args.graph_type == 'VAE':
            # 先判断模型类型是否是GKT,且图类型是VAE
            if args.prior:
                loss_vae = vae_loss(ec_list, rec_list, z_prob_list, log_prior=log_prior)
            # 在使用VAE作为图模型时,如果定义了log_prior,则使用带先验分布的vae_loss()计算loss_vae

            else:
                loss_vae = vae_loss(ec_list, rec_list, z_prob_list)
                # 否则则不带log_prior计算
                vae_train.append(float(loss_vae.cpu().detach().numpy()))
                # 将计算得到的lossVae的值添加到vae_train列表中
                # 还是四步走,第一步转换为numpy,第二步断开与计算图的联系,第三步移动到cpu上,第四步转换为float类型,最后加到列表中
            print('batch idx: ', batch_idx, 'loss kt: ', loss_kt.item(), 'loss vae: ', loss_vae.item(), 'auc: ', auc, 'acc: ', acc, end=' ')
            # 输出当前beatch的id、loss vae auc acc
            loss = loss_kt + loss_vae
            # 计算总损失值
        else:
            loss = loss_kt
            # 若不使用VAE作为图模型,则只计算kt_loss
            print('batch idx: ', batch_idx, 'loss kt: ', loss_kt.item(), 'auc: ', auc, 'acc: ', acc, end=' ')
        loss_train.append(float(loss.cpu().detach().numpy()))
        # 将训练得到的loss都加入到列表中
        '''
        下面是pytoch训练模型时常用的一组操作,训练循环四部曲
        '''
        loss.backward()
        # 计算损失函数对网络参数的梯度:反向传播算法的核心
        # 它通过链式法则把损失函数的梯度从输出层一层层往前传递到每一个参数上。
        # 反向传播结束后,每个参数都被赋予了一个梯度,表示它对损失函数的贡献大小。
        optimizer.step()
        # 根据梯度更新模型参数,把每个参数的梯度乘以一个学习率,然后用这个值对参数更新,使得它们向着能减少损失函数的方向移动
        # 这里的optimizer是pytorch的提供的优化器
        scheduler.step()
        # 更新学习率,在训练过程中动态的调整学习率
        optimizer.zero_grad()
        # 清空梯度,把模型参数的梯度清零,避免梯度再增加,为下一次反向传播做准备

        del loss
        # 释放内存
        print('cost time: ', str(time.time() - t1))
        # 输出当前batch的耗时

    loss_val = []
    kt_val = []
    vae_val = []
    auc_val = []
    acc_val = []
    # 定义5个空列表,用于存储验证过程中的损失、知识追踪损失、vae损失、验证集的auc和acc
    # 将前面训练的模型的最好模型存储下来后,对这个最好的模型进行验证

    if graph_model is not None:
        graph_model.eval()
        # 将其设置为评估模式 不再进行参数的更新和梯度的计算,只用训练好的模型进行预测计算
    model.eval()
    # 将model也设置为评估模式
    with torch.no_grad():
        # 表示在验证阶段,不需要进行梯度计算,torch.no_grad是禁用梯度计算
        # train loop
        for epoch in range(args.num_epochs):
            # 进入epoch循环,表示需要在验证集上进行多轮模型验证
            # args.num_epoch是指训练过程中,用户指定的epoch数量,代表者整个数据集被遍历多少次
            # 每个epoch中,会遍历数据集中所有的batch,进行模型的训练更新
            # 一般情况下,增加epoch数量会增加模型的训练时间,但也可能出现过拟合问题
        for batch_idx, (features, questions, answers) in enumerate(valid_loader):
            # 每一轮验证要使用valid_loader迭代器从验证集中读取一个批次的数据
            # enumerate函数用于将一个可迭代对象(这里是valid_loader)转换为一个枚举对象,方便统计当前循环到第几批 数据
            # 枚举对象在每次迭代时,会返回当前迭代次数batch_idx
            # 和一个可迭代对象的元素(features, questions, answers)
            '''
            剩下的部分和训练部分的代码几乎一致,不再赘述
            '''
            if args.cuda:
                features, questions, answers = features.cuda(), questions.cuda(), answers.cuda()
            ec_list, rec_list, z_prob_list = None, None, None
            if args.model == 'GKT':
                pred_res, ec_list, rec_list, z_prob_list = model(features, questions)
            elif args.model == 'DKT':
                pred_res = model(features, questions)
            else:
                raise NotImplementedError(args.model + ' model is not implemented!')
            loss_kt, auc, acc = kt_loss(pred_res, answers)
            loss_kt = float(loss_kt.cpu().detach().numpy())
            kt_val.append(loss_kt)
            if auc != -1 and acc != -1:
                auc_val.append(auc)
                acc_val.append(acc)

            loss = loss_kt
            if args.model == 'GKT' and args.graph_type == 'VAE':
                loss_vae = vae_loss(ec_list, rec_list, z_prob_list)
                loss_vae = float(loss_vae.cpu().detach().numpy())
                vae_val.append(loss_vae)
                loss = loss_kt + loss_vae
            loss_val.append(loss)
            del loss
    '''
    下面是对训练过程以及验证过程的相关损失、auc、acc等数据输出到终端(可视)
    通过判断模型和图模型,来选择输出相应的指标到终端
    '''
    if args.model == 'GKT' and args.graph_type == 'VAE':
        print('Epoch: {:04d}'.format(epoch),
              'loss_train: {:.10f}'.format(np.mean(loss_train)),
              'kt_train: {:.10f}'.format(np.mean(kt_train)),
              'vae_train: {:.10f}'.format(np.mean(vae_train)),
              'auc_train: {:.10f}'.format(np.mean(auc_train)),
              'acc_train: {:.10f}'.format(np.mean(acc_train)),
              'loss_val: {:.10f}'.format(np.mean(loss_val)),
              'kt_val: {:.10f}'.format(np.mean(kt_val)),
              'vae_val: {:.10f}'.format(np.mean(vae_val)),
              'auc_val: {:.10f}'.format(np.mean(auc_val)),
              'acc_val: {:.10f}'.format(np.mean(acc_val)),
              'time: {:.4f}s'.format(time.time() - t))
    else:
        print('Epoch: {:04d}'.format(epoch),
              'loss_train: {:.10f}'.format(np.mean(loss_train)),
              'auc_train: {:.10f}'.format(np.mean(auc_train)),
              'acc_train: {:.10f}'.format(np.mean(acc_train)),
              'loss_val: {:.10f}'.format(np.mean(loss_val)),
              'auc_val: {:.10f}'.format(np.mean(auc_val)),
              'acc_val: {:.10f}'.format(np.mean(acc_val)),
              'time: {:.4f}s'.format(time.time() - t))
        '''
        这个if语句的作用是在训练过程中检查模型的性能,并自动保存最好的模型。
        如果不检查模型的性能并手动保存模型
        则需要在训练结束后使用torch.save()函数手动保存模型,而且需要自己定义何时为最佳模型。
        '''
    if args.save_dir and np.mean(loss_val) < best_val_loss:
        # save_dir保存模型的路径,若存在,进入条件语句,计算当前验证集上损失函数的平均值,best是当前验证集上最优的损失函数
        print('Best model so far, saving...')
        torch.save(model.state_dict(), model_file)
        torch.save(optimizer.state_dict(), optimizer_file)
        torch.save(scheduler.state_dict(), scheduler_file)
        # pytorch提供的save()函数将当前模型的参数、优化器参数和学习率调度器参数保存到指定文件中
        if args.model == 'GKT' and args.graph_type == 'VAE':
            # 循环次数是epoch数
            print('Epoch: {:04d}'.format(epoch),
                  'loss_train: {:.10f}'.format(np.mean(loss_train)),
                  'kt_train: {:.10f}'.format(np.mean(kt_train)),
                  'vae_train: {:.10f}'.format(np.mean(vae_train)),
                  'auc_train: {:.10f}'.format(np.mean(auc_train)),
                  'acc_train: {:.10f}'.format(np.mean(acc_train)),
                  'loss_val: {:.10f}'.format(np.mean(loss_val)),
                  'kt_val: {:.10f}'.format(np.mean(kt_val)),
                  'vae_val: {:.10f}'.format(np.mean(vae_val)),
                  'auc_val: {:.10f}'.format(np.mean(auc_val)),
                  'acc_val: {:.10f}'.format(np.mean(acc_val)),
                  'time: {:.4f}s'.format(time.time() - t), file=log)

            del kt_train
            del vae_train
            del kt_val
            del vae_val
            # 删除变量语句,将之前的变量删除,释放内存
        else:
            print('Epoch: {:04d}'.format(epoch),
                  'loss_train: {:.10f}'.format(np.mean(loss_train)),
                  'auc_train: {:.10f}'.format(np.mean(auc_train)),
                  'acc_train: {:.10f}'.format(np.mean(acc_train)),
                  'loss_val: {:.10f}'.format(np.mean(loss_val)),
                  'auc_val: {:.10f}'.format(np.mean(auc_val)),
                  'acc_val: {:.10f}'.format(np.mean(acc_val)),
                  'time: {:.4f}s'.format(time.time() - t), file=log)

        log.flush()
        # 将缓存的数据刷新存入到文件中
    res = np.mean(loss_val)
    # 计算验证集平均损失res
    del loss_train
    del auc_train
    del acc_train
    del loss_val
    del auc_val
    del acc_val
    gc.collect()
    # 删除变量,释放内存
    if args.cuda:
        torch.cuda.empty_cache()
        # 清空gpu缓存
    return res


def test():
    loss_test = []
    kt_test = []
    vae_test = []
    auc_test = []
    acc_test = []
   # 代码定义了5个空列表:loss_test,kt_test,vae_test,auc_test,acc_test,
    # 用于存储测试过程中的损失值、知识追踪损失、VAE损失、AUC值和准确率
    if graph_model is not None:
        graph_model.eval()
    model.eval()
    # 将模型设置为评估模式
    model.load_state_dict(torch.load(model_file))
    # 将模型的权重加载进来,使用torch.load函数,从指定位置:model_file路径加载进来
    with torch.no_grad():
    # 禁用梯度计算
        for batch_idx, (features, questions, answers) in enumerate(test_loader):
            if args.cuda:
                features, questions, answers = features.cuda(), questions.cuda(), answers.cuda()
            ec_list, rec_list, z_prob_list = None, None, None
            if args.model == 'GKT':
                pred_res, ec_list, rec_list, z_prob_list = model(features, questions)
            elif args.model == 'DKT':
                pred_res = model(features, questions)
            else:
                raise NotImplementedError(args.model + ' model is not implemented!')
            loss_kt, auc, acc = kt_loss(pred_res, answers)
            loss_kt = float(loss_kt.cpu().detach().numpy())
            if auc != -1 and acc != -1:
                auc_test.append(auc)
                acc_test.append(acc)
            kt_test.append(loss_kt)
            loss = loss_kt
            if args.model == 'GKT' and args.graph_type == 'VAE':
                loss_vae = vae_loss(ec_list, rec_list, z_prob_list)
                loss_vae = float(loss_vae.cpu().detach().numpy())
                vae_test.append(loss_vae)
                loss = loss_kt + loss_vae
            loss_test.append(loss)
            del loss
    print('--------------------------------')
    print('--------Testing-----------------')
    print('--------------------------------')
    if args.model == 'GKT' and args.graph_type == 'VAE':
        print('loss_test: {:.10f}'.format(np.mean(loss_test)),
              'kt_test: {:.10f}'.format(np.mean(kt_test)),
              'vae_test: {:.10f}'.format(np.mean(vae_test)),
              'auc_test: {:.10f}'.format(np.mean(auc_test)),
              'acc_test: {:.10f}'.format(np.mean(acc_test)))
    else:
        print('loss_test: {:.10f}'.format(np.mean(loss_test)),
              'auc_test: {:.10f}'.format(np.mean(auc_test)),
              'acc_test: {:.10f}'.format(np.mean(acc_test)))
    if args.save_dir:
        print('--------------------------------', file=log)
        print('--------Testing-----------------', file=log)
        print('--------------------------------', file=log)
        if args.model == 'GKT' and args.graph_type == 'VAE':
            print('loss_test: {:.10f}'.format(np.mean(loss_test)),
                  'kt_test: {:.10f}'.format(np.mean(kt_test)),
                  'vae_test: {:.10f}'.format(np.mean(vae_test)),
                  'auc_test: {:.10f}'.format(np.mean(auc_test)),
                  'acc_test: {:.10f}'.format(np.mean(acc_test)), file=log)
            del kt_test
            del vae_test
        else:
            print('loss_test: {:.10f}'.format(np.mean(loss_test)),
                  'auc_test: {:.10f}'.format(np.mean(auc_test)),
                  'acc_test: {:.10f}'.format(np.mean(acc_test)), file=log)
        log.flush()
    del loss_test
    del auc_test
    del acc_test
    gc.collect()
    if args.cuda:
        torch.cuda.empty_cache()

if args.test is False:
    # Train model
    # 则执行模型训练,使用train函数进行训练,循环epoch次,每次调用train函数进行训练
    # 其中val_loss表示每个epoch在验证集上的损失值。
    # 在训练过程中,如果当前epoch的验证集上的损失值val_loss小于之前最小的损失值best_val_loss,
    # 则更新best_val_loss的值为val_loss,并记录当前的epoch为最佳epoch。
    print('start training!')
    t_total = time.time()
    # 记录开始训练时间
    best_val_loss = np.inf
    # 将最佳验证损失设为正无穷,以便在训练过程中能找到更小的验证损失
    best_epoch = 0
    # 将最佳的训练轮数设为0
    for epoch in range(args.epochs):
        # 循环执行训练过程,总共执行epoch轮
        val_loss = train(epoch, best_val_loss)
        # 对当前模型进行训练,得到当前轮的验证损失
        if val_loss < best_val_loss:
            # 如果当前轮的验证损失小于最佳验证损失,则更新最佳验证损失和最佳训练轮数
            best_val_loss = val_loss
            # 将最佳验证损失更新为当前轮的验证损失
            best_epoch = epoch
            # 将最佳训练轮数更新为当前轮
    print("Optimization Finished!")
    # 输出最佳训练轮数
    print("Best Epoch: {:04d}".format(best_epoch))
    # 检查是否需要保存训练日志文件
    if args.save_dir:
        print("Best Epoch: {:04d}".format(best_epoch), file=log)
        log.flush() # 刷新日志文件缓存

test()
'''
调用test函数测试模型性能,并保存日志。
test函数中的if语句用于判断当前模型是否是图神经网络,并且是否使用了变分自编码器(VAE)进行图结构的学习。
如果是,则在测试时需要计算VAE的损失值并输出,否则只输出KT模型的损失值、AUC值和准确率。
'''
if log is not None:
    print(save_dir)
    log.close()
    '''
    如果有指定保存路径args.save_dir,则在模型训练和测试结束后将最佳epoch的信息保存到日志文件中,
    如果有打开日志文件,则关闭日志文件。
    '''

知秋君
上一篇 2024-07-14 18:02
下一篇 2024-07-14 17:36

相关推荐