contex-aware knowledge tracing——AKT
作为目前较真实且auc较高的的知识追踪模型,来看看它的代码结构,方便你我去实现和修改。原理、创新点请看论文,本文只作代码分析:
目的
AKT在assistment15(0.782)数据上比DKT(0.731)要高很多,同样只使用技能信息的情况下:
我的任务就是看为什么auc高这么多,文章没有具体消融试验,但可以从一部分看出

对技能和交互先encoder一次,在assist15得到5%的提升

使用了衰减机制,在assist15得到5%的提升。
问题出现了,究竟是encoder起的作用,还是衰减机制起的作用。使其达到0.7228
我估计是这里的AKT-NR-pos是相对于AKT-NR-raw的基础上在去掉衰减机制,替换成position embedding。
也可以看到AKT-NR-raw为0.7332,而AKT-NR-pos为0.7271。所以就算CCF-A也不不公平地比较。不是严格按照:每个对比实验只有一个组件不同。
结论:应该是encoder起的作用大一点。
数据集
作者处理成 一个样本行数据:(id是连续)
- 学生id,学生真实索引
- 题目id
- 技能id
- 答对答错
样本以学生为单位,分成训练集,验证集,测试集。(60%,20%,20%)
如果嫌数据量太少,把验证集归到训练集,因为代码要跑验证部分,避免修改结构,把测试集赋给验证集。
main.py 总体
- 加载数据的类,分DATA和PID_DATA,有的数据集没有技能信息
- 设置种子数seed
- params.train_set,5折交叉验证,有5个训练集
- train_q_data, train_qa_data, train_pid = dat.load_data(train_data_path)与DKVMN一样。而DKT不需要seq_len-1(不预测第一题)
- best_epoch = train_one_dataset()训练模型,记录最好的epoch
- test_one_dataset(best_epoch )使用最好的epoch预测测试集
train_one_dataset训练步骤
- 加载模型,在utils.py文件里,return model = AKT()
- 设置optimizer,包括模型参数、学习率
- for idx in range(params.max_iter)里面执行train函数。参数包括模型、数据,优化器,超参数
- 验证集auc提升时,保存模型、优化器。设置提前终止条件。
- f_save_log.write()记录结果
test_one_dataset测试步骤
- 加载模型及最优epoch的模型参数
- 执行一次test函数。参数没有优化器
- 运行完之后又把模型参数给删除
load_data.py
class DATA(object):
其中len(Q[len(Q)-1]) == 0,这一步是怕字符串根据分隔符分割之后,列表的最后一个元素为空字符。如:"1,2,3,"这样分割就会出现4个元素。
for lineID, line in enumerate(f_data):
记录student_id,第几个学生(样本)
记录Q,回答的技能
记录A,回答的情况
# 处理数据
1.一个学生截断成多个学生(题目数多于200道就认为新的样本),这里没有限制样本最小题目数
2.答题情况 = 2倍技能数,前半部代表答错,后半部代表答对
3.q_dataArray = np.zeros((len(q_data), self.seqlen)) 固定输入数据的大小,有数据的地方就填充,没有就为0
4.返回 q_dataArray, qa_dataArray, np.asarray(idx_data),idx_data是学生的id,模型应该没有用到的,只是用来保证return3个数据。
前两个是二维【batch,seq_len】 idx_data是一维【batch】
class PID_DATA(object):
只不过多处理了一行:题目信息
q_data.append(question_sequence) 技能信息
qa_data.append(answer_sequence) 答题情况
p_data.append(problem_sequence) 问题信息
return q_dataArray, qa_dataArray, p_dataArray
run.py
数据值域:id从1开始,0的位置为padding
train函数
- Shuffle the data,pid_flag是否有题目信息
- 数据包括input_q、input_pid、(input_qa、target)同一个,输入网络和训练目标
- target = (target - 1) / params.n_question,np.floor(target),使padding值为-1,非padding值为0或1
- loss, pred, true_ct = net(),这里的loss是用来反向传播
- nopadding_index = np.flatnonzero(target >= -0.9),根据索引就能筛选出要的预测值:pred[nopadding_index]
- 训练完全部数据后对all_pred,all_target计算auc、acc、loss(对全部数据算二值交叉熵)
test函数,区别在于
net.eval()
with torch.no_grad():
akt.py
模型部分不解释,就是self-attention、注意力衰减,只分析输入部分
参数部分
parser.add_argument('--d_model', type=int, default=256,
help='Transformer d_model shape')
parser.add_argument('--d_ff', type=int, default=1024,
help='Transformer d_ff shape')
parser.add_argument('--dropout', type=float,
default=0.05, help='Dropout rate')
parser.add_argument('--n_block', type=int, default=1,
help='number of blocks')
parser.add_argument('--n_head', type=int, default=8,
help='number of heads in multihead attention')
parser.add_argument('--kq_same', type=int, default=1) #
模型参数:
final_fc_dim=512
self.separate_qa 默认False,即交互信息=问题信息 拼接 对错信息(0或1)
self.n_pid 题目数 = 16891
self.n_question 技能数 = 110
嵌入部分
如果带有题目信息,如果看作者注释会有点杂乱
self.difficult_param = nn.Embedding(self.n_pid+1, 1) # 问题难度
self.q_embed_diff = nn.Embedding(self.n_question+1, embed_l) # 技能变体
self.qa_embed_diff = nn.Embedding(2 * self.n_question + 1, embed_l) # 交互变体
self.q_embed = nn.Embedding(self.n_question+1, embed_l) # 技能嵌入
self.qa_embed = nn.Embedding(2, embed_l) # 对错嵌入
def forward(self, q_data, qa_data, target, pid_data=None):
q_embed_data = self.q_embed(q_data) # 技能嵌入 ct
qa_data = (qa_data-q_data)//self.n_question # 0 或 1
qa_embed_data = self.qa_embed(qa_data)+q_embed_data # 对错嵌入 rt + ct => 交互嵌入 at
# 如果没有题目信息,上面q_embed_data、qa_embed_data 就是模型所要的
# 加入题目信息后,q_embed_data、qa_embed_data要更新
q_embed_diff_data = self.q_embed_diff(q_data) # 技能变体 d_ct
pid_embed_data = self.difficult_param(pid_data) # 问题难度 u 标量
# ct + d_ct * u ==> X(题目信息)= 技能嵌入 + 技能变体 * 问题难度
q_embed_data = q_embed_data + pid_embed_data * q_embed_diff_data
# 不符合:因为上面定义该embedding层为2倍技能数,而qa_data属于0或1 ————>应该修改self.qa_embed_diff(2, embed_l)
qa_embed_diff_data = self.qa_embed_diff(qa_data) # 对错嵌入 rt*
# at + u * ( rt* + d_ct) ==> Y(交互信息) = 交互嵌入 + 问题难度 * ( 对错嵌入 + 技能变体)
qa_embed_data = qa_embed_data + pid_embed_data * (qa_embed_diff_data+q_embed_diff_data)
c_reg_loss = (pid_embed_data ** 2.).sum() * self.l2 # 对 难度系数 正则化
最后目的还是为得到q_embed_data,qa_embed_data 。
模型部分
self.model = Architecture()
—Architecture:3个TransformerLayer,(mask=1,qkv=Y,apply_pos=True)(mask=1,qkv=X,apply_pos=False) (mask=0,qk=X,v=Y,apply_pos=True)apply_pos表示有FFN,mask=1,可以看当前,zero_pad=False;mask=0,只能看过去,zero_pad=True。
------TransformerLayer:MultiHeadAttention、LN、Linear
-----------MultiHeadAttention:(kq_same=1,所以QK共用一个Linear映射)self-attention(多头拼接后在通过一层全连接)
---------------self-attention( Monotonic Attention Mechanism)
self.out = nn.Sequential(3个全连接)
—output拼接question:2*256——>final_fc_dim:512——>256——>1
x=np.triu(np.ones((1, 1, 5, 5)), k=0).astype('uint8')
[[[[1 1 1 1 1]
[0 1 1 1 1]
[0 0 1 1 1]
[0 0 0 1 1]
[0 0 0 0 1]]]]
x=np.triu(np.ones((1, 1, 5, 5)), k=1).astype('uint8')
[[[[0 1 1 1 1]
[0 0 1 1 1]
[0 0 0 1 1]
[0 0 0 0 1]
[0 0 0 0 0]]]]
utils.py
get_file_name_identifier函数,模型不同,需要的参数不同,存储的文件名不同。比如:params.model = ‘akt_pid’
load_model函数,根据params.model = ‘akt_pid’,是否带有题目信息,模型根据params.n_pid,是否为题目信息创建Embedding,和对题目信息与技能信息进行拼接。
try_makedirs函数,创建目录os.makedirs(path_)
Monotonic Attention Mechanism
最后,也是最详细地讲一下最有数学功底的部分,怎么修改/调整注意力机制的,因为self-attention的注意力矩阵就是两个矩阵相乘,怎么在这个基础上做文章。

def attention(q, k, v, d_k, mask, dropout, zero_pad, gamma=None):
"""
This is called by Multi-head atention object to find the values.
"""
# 这里q,k共享全连接,所以是相同的
scores = torch.matmul(q, k.transpose(-2, -1)) / \
math.sqrt(d_k) # BS, 8, seqlen, seqlen
bs, head, seqlen = scores.size(0), scores.size(1), scores.size(2)
# 还没mask的注意力矩阵
x1 = torch.arange(seqlen).expand(seqlen, -1).to(device)
x2 = x1.transpose(0, 1).contiguous()
# x2 [[0 0 0] x1-x2[[0 1 2]
# [1 1 1] [-1 0 1]
# [2 2 2]] [-2 -1 0]]
with torch.no_grad():
# False的地方(未来信息),赋无穷小
scores_ = scores.masked_fill(mask == 0, -1e32)
scores_ = F.softmax(scores_, dim=-1) # BS,8,seqlen,seqlen
scores_ = scores_ * mask.float().to(device)
# scores_ 未来信息已为0
distcum_scores = torch.cumsum(scores_, dim=-1) # bs, 8, sl, sl #cumsum:按维度dim进行累加
# 红色部分,当前个step,对角线位置,全部权重之和
disttotal_scores = torch.sum(
scores_, dim=-1, keepdim=True) # bs, 8, sl, 1
# 绝对距离,|t-θ| 蓝色部分
position_effect = torch.abs(
x1-x2)[None, None, :, :].type(torch.FloatTensor).to(device) # 1, 1, seqlen, seqlen
# bs, 8, sl, sl positive distance
# disttotal_scores-distcum_scores,绿色部分
# 假设step=5,观察第2个词时,总和减去前2个词,剩下就是第2个词之后的权重和
# 语义:观察第2个词,要考虑它之后的词是否重要
dist_scores = torch.clamp(
(disttotal_scores-distcum_scores)*position_effect, min=0.)
# dist_scores 距离计算完成
dist_scores = dist_scores.sqrt().detach() #切断反向传播
m = nn.Softplus() #激活函数log(1+e^x)
gamma = -1. * m(gamma).unsqueeze(0) # 1,8,1,1
# Now after do exp(gamma*distance) and then clamp to 1e-5 to 1e5
total_effect = torch.clamp(torch.clamp(
(dist_scores*gamma).exp(), min=1e-5), max=1e5)
# e的-x次方小于1 衰减机制
scores = scores * total_effect
# 怕泄露 在一次mask未来信息
scores.masked_fill_(mask == 0, -1e32)
scores = F.softmax(scores, dim=-1) # BS,8,seqlen,seqlen
if zero_pad:
# 该情况,包括对角线以上为False/0。
# 第一行softmax之后就是平均值,非0,要把第一行变为全0
pad_zero = torch.zeros(bs, head, 1, seqlen).to(device)
scores = torch.cat([pad_zero, scores[:, :, 1:, :]], dim=2)
scores = dropout(scores) # dropout在乘以values
output = torch.matmul(scores, v)
return output
