교육분야에서의 AI는 Knowledge Tracing를 통해 적용될 수 있습니다. 그 중 Sequential Data를 사용하여 시간의 흐름에 따라 변화하는 지식 수준을 모델링한 Deep Knowledge Tracing을 살펴보겠습니다.
(본 게시글은 Deep Knowledge Tracing(2015) https://papers.nips.cc/paper/2015/hash/bac9162b47c56fc8a4d2a519803d51b3-Abstract.html 을 기반으로 비전공자도 이해할 수 있도록 매우 쉽게 풀어 작성하였습니다.)
1. Knowledge Tracing
Knowledge Tracing(지식 추적)이란 학생(user)의 풀이이력을 바탕으로 아직 풀이하지 않은 미래의 문제에 대해 학생의 수행결과를 예측하도록 시간에 따른 학생의 지식상태를 모델링하는 태스크를 의미합니다. 학생의 풀이이력은 각 문항별 정오답과 해당 문항이 담고 있는 학습개념(Knowledge Concept)을 포함합니다. 또한, 문제 풀이순서에 따라 달라지는 학생의 지식상태를 추적하기 위해 시간 정보 또한 포함되어야 합니다. 다시 정리하면, Knowledge Tracing의 기본목표는 학생이 풀어놓은 문제 풀이결과를 가지고 학생이 미래에 각 문제에 대해 잘 풀어낼 확률을 도출하는 것입니다.
Knowledge Tracing 분야에서는 Bayesian 확률 기반의 BKT(Bayesian Knowledge Tracing), 딥러닝 기반의 DKT(Deep Knowledge Tracing) 등 다양한 모델들이 연구되었습니다. 그 중 DKT에 대해 살펴보도록 하겠습니다.
2. Deep Knowledge Tracing (DKT)
2.1 모델의 입력
딥러닝 모델의 구조는 모델에 입력을 넣어주면 입력에 대한 모델의 내부처리과정을 거쳐 입력값에 대한 모델의 예측값을 내주는 형태입니다. DKT에서는 입력으로 각 풀이이력 별 해당 문항의 정오답을 넣어주어야 하며, 이는 최종적으로 기계가 이해할 수 있는 숫자들의 형식으로 표현되어 모델에 입력되어야 합니다. 그 방법을 예를 들어 설명해보겠습니다.
초등학생이 학습해야 하는 수학과목의 학습개념이 100개라고 가정해보겠습니다. 그리고 각 학생별 지식상태를 학습개념의 단위로 추적하기 위해서는 학생이 어떤 학습개념에 관한 문제를 풀이했는 지, 해당 문제를 맞혔는지에 대한 정보가 포함되어 있는 풀이이력이 필요합니다. 그럼, 그 데이터에 존재할 수 있는 각 학습개념에 대응되는 정오답 세트를 상호작용이라 표현하겠습니다. 각 개념에 대해 학생은 맞히거나 틀리는 두가지의 반응을 보일 수 있기 때문에 초등수학 예시에서의 모든 상호작용의 개수는 총 개념 개수의 2배인 200개이며, 각 상호작용은 1번부터 200번까지 매겨지게 됩니다.
상호작용을 표현하는 방법은 다양하지만 그 중 대표적인 한가지는, 문제를 틀렸다면 1번부터 100번까지, 맞혔다면 101번부터 200번까지로 표현하고, 그 속에서 해당하는 개념에 주어진 순서대로 매칭되는 방법입니다. 예를 들어, “평면도형” 학습개념을 맞혔다는 데이터를 표현해보겠습니다. 맞혔으므로 101번부터 200번 사이, 그 중 “평면도형” 개념은 위 그림에 따르면 100개 개념 중 2번째 개념이므로 해당 상호작용(interaction)은 숫자 102으로 표현할 수 있게 됩니다. 이를 길이 200의 숫자 형식으로 표현하여 기계가 해석하기 쉬운 모습으로 바꿔주면 모델의 입력 준비는 끝이 납니다.
2.2 모델의 구조 및 학습과정
DKT는 RNN(Recurrent Neural Networks) 모델의 형태를 가지고 있습니다. RNN은 시간 순차적 데이터를 학습하는 데 특화된 순환적 구조를 갖는 것이 특징입니다. DKT 논문의 실험에서는 RNN의 일종인 LSTM(Long Short-Term Memory models)을 사용하고 있습니다.
각 시점별로 학생의 개념별 정오답 정보를 담은 입력이 들어가게 되면 해당 입력이 모델의 내부처리과정(히든레이어 등)을 거쳐 각 개념별 맞힐 확률들을 도출하게 됩니다. 이때 모델의 예측값은 0부터 1사이의 확률값으로 표현하여 맞힐 가능성이 높을수록 1에 가깝도록 도출하게 됩니다.
그럼, 이런 모델 구조를 가진 DKT는 어떤 과정으로 학습을 진행하는 지를 살펴보겠습니다. 모델의 내부구조에서는 해당시점에서의 학생의 지식상태가 학습되고, 아직 풀지 않은 다음문제를 학생이 얼마나 잘 풀어낼 것인가를 예측하기 위해 학습을 진행합니다. DKT는 각 시점별로 학생의 지식상태를 추적해 나갑니다. 문제를 풀이한 시간 순서대로 입력값이 들어갈 때마다 DKT는 모든 학습개념별 맞힐 확률을 도출하고, 이 도출된 확률값과 실제 정오답을 비교하며 모델 학습(Training)이 진행됩니다. 모델이 학습을 한다는 것은 모델이 내놓은 예측값과 실제값 간의 차이를 최소화하는 상태를 계속해서 찾는 과정을 말합니다. 그러므로 DKT에서의 학습 또한, 모델이 예측한 특정 문항에 대한 학생의 맞힐 확률이 해당 문항의 실제 정오답과 가깝도록 계속해서 최적의 모델 찾아 나가는 과정을 의미합니다.
그래서 DKT에서는 직전에 모델이 내놓은 개념별 맞힐 확률들과, 현재시점의 문항의 실제 정오답을 비교합니다. 위 그림에서 n번째 시점에서 모델은 학생이 곱셈 개념에 대해 50%, 덧셈 개념에 대해 40%, 뺄셈 개념에 대해 60%의 확률로 맞힐 것이라고 예측했습니다. 실제로 그다음 n+1시점에서 학생은 덧셈 개념의 문제를 틀렸고, 직전에 모델은 덧셈 개념을 40%만큼 맞힐 것이라 예측했기 때문에 실제 값(0%)과 예측값(40%) 간의 차이인 40%만큼을 모델이 학습하게 됩니다. 그 다음 n+2시점에서 학생은 뺄셈 개념의 문제를 맞혔고, 직전에 모델은 뺄셈 개념을 65%만큼 맞힐 것이라 예측했으므로 실제값(100%)과 예측값(65%)간 차이 35%만큼을 학습합니다. 이런식으로 실제로 다음문제에서 학생이 풀이한 정오답과 이전시점에 예측한 확률을 비교하여, 모델이 예측값을 실제정오답과 얼마나 차이나게 내놓았는지를 계산하고 그 차이만큼을 모델은 학습하게 되는 원리입니다. 모든 학습데이터에 대해 DKT가 순차적이고 반복적으로 학습하게 되면, 학생의 현재까지의 지식상태를 기반으로 다음문제에 대한 정오답을 적은 오차로 잘 예측하도록 하는 상태를 갖게 됩니다.
2.3 모델의 성능
본 논문에서는 총 세가지의 데이터를 통해 성능을 측정하였습니다. 분류예측모델의 성능을 측정하기 위해 사용되는 지표인 AUC를 사용하였고, 이는 0에서 1의 값을 가지며 1에 가까울 수록 좋은 성능을 나타냅니다. ASSISTments 데이터 셋에 대해 DKT가 0.86의 성능을 보이며 매우 높은 수준의 성능변화를 끌어냄을 확인할 수 있습니다.
3. 모델 학습 코드
출처 : https://github.com/hcnoh/knowledge-tracing-collection-pytorch
hcnoh 님의 코드를 기반으로 DKT 부분만 빼내어 주피터 노트북으로 바로 실행할 수 있도록 구성하였습니다. 모델 저장, 다른 다양한 모델 코드들을 해당 깃허브에서 확인할 수 있으니 확인하면 좋을 듯합니다 :)
1. Module Import¶
import os
import argparse
import json
import pickle
import torch
from torch.utils.data import DataLoader, random_split
from torch.optim import SGD, Adam
import numpy as np
import pandas as pd
import random
2. Data Preprocessing¶
from torch.utils.data import Dataset
DATASET_DIR = ""
class Preprocessor(Dataset):
def __init__(self, seq_len, dataset_dir=DATASET_DIR) -> None:
super().__init__()
self.dataset_dir = dataset_dir
self.dataset_path = os.path.join(
self.dataset_dir, "skill_builder_data.csv"
)
self.q_seqs, self.r_seqs, self.q_list, self.u_list, self.q2idx, self.u2idx = self.preprocess()
self.num_u = self.u_list.shape[0]
self.num_q = self.q_list.shape[0]
if seq_len:
self.q_seqs, self.r_seqs = \
match_seq_len(self.q_seqs, self.r_seqs, seq_len)
self.len = len(self.q_seqs)
def __getitem__(self, index):
return self.q_seqs[index], self.r_seqs[index]
def __len__(self):
return self.len
def preprocess(self):
df = pd.read_csv(self.dataset_path, encoding = 'unicode_escape').dropna(subset=["skill_name"])\
.drop_duplicates(subset=["order_id", "skill_name"])\
.sort_values(by=["order_id"])
u_list = np.unique(df["user_id"].values)
q_list = np.unique(df["skill_name"].values)
u2idx = {u: idx for idx, u in enumerate(u_list)}
q2idx = {q: idx for idx, q in enumerate(q_list)}
q_seqs = []
r_seqs = []
for u in u_list:
df_u = df[df["user_id"] == u]
q_seq = np.array([q2idx[q] for q in df_u["skill_name"]])
r_seq = df_u["correct"].values
q_seqs.append(q_seq)
r_seqs.append(r_seq)
return q_seqs, r_seqs, q_list, u_list, q2idx, u2idx
from torch.nn.utils.rnn import pad_sequence
if torch.cuda.is_available():
from torch.cuda import FloatTensor
torch.set_default_tensor_type(torch.cuda.FloatTensor)
else:
from torch import FloatTensor
def match_seq_len(q_seqs, r_seqs, seq_len, pad_val=-1):
proc_q_seqs = []
proc_r_seqs = []
for q_seq, r_seq in zip(q_seqs, r_seqs):
i = 0
while i + seq_len + 1 < len(q_seq):
proc_q_seqs.append(q_seq[i:i + seq_len + 1])
proc_r_seqs.append(r_seq[i:i + seq_len + 1])
i += seq_len + 1
proc_q_seqs.append(
np.concatenate(
[
q_seq[i:],
np.array([pad_val] * (i + seq_len + 1 - len(q_seq)))
]
)
)
proc_r_seqs.append(
np.concatenate(
[
r_seq[i:],
np.array([pad_val] * (i + seq_len + 1 - len(q_seq)))
]
)
)
return proc_q_seqs, proc_r_seqs
def collate_fn(batch, pad_val=-1):
q_seqs = []
r_seqs = []
qshft_seqs = []
rshft_seqs = []
for q_seq, r_seq in batch:
q_seqs.append(FloatTensor(q_seq[:-1]))
r_seqs.append(FloatTensor(r_seq[:-1]))
qshft_seqs.append(FloatTensor(q_seq[1:]))
rshft_seqs.append(FloatTensor(r_seq[1:]))
q_seqs = pad_sequence(
q_seqs, batch_first=True, padding_value=pad_val
)
r_seqs = pad_sequence(
r_seqs, batch_first=True, padding_value=pad_val
)
qshft_seqs = pad_sequence(
qshft_seqs, batch_first=True, padding_value=pad_val
)
rshft_seqs = pad_sequence(
rshft_seqs, batch_first=True, padding_value=pad_val
)
mask_seqs = (q_seqs != pad_val) * (qshft_seqs != pad_val)
q_seqs, r_seqs, qshft_seqs, rshft_seqs = \
q_seqs * mask_seqs, r_seqs * mask_seqs, qshft_seqs * mask_seqs, \
rshft_seqs * mask_seqs
return q_seqs, r_seqs, qshft_seqs, rshft_seqs, mask_seqs
dataset = Preprocessor(seq_len=100)
train_size = int(len(dataset) * 0.8)
test_size = len(dataset) - train_size
train_dataset, test_dataset = random_split(
dataset, [train_size, test_size]
)
<ipython-input-4-dc4c155ecec4>:14: DtypeWarning: Columns (17) have mixed types.Specify dtype option on import or set low_memory=False. self.q_seqs, self.r_seqs, self.q_list, self.u_list, self.q2idx, self.u2idx = self.preprocess()
train_loader = DataLoader(
train_dataset, batch_size=256, shuffle=True,
collate_fn=collate_fn
)
test_loader = DataLoader(
test_dataset, batch_size=test_size, shuffle=True,
collate_fn=collate_fn
)
3. Model¶
from torch.nn import Module, Embedding, LSTM, Linear, Dropout
from torch.nn.functional import one_hot, binary_cross_entropy
from sklearn import metrics
class DKT(Module):
def __init__(self, num_q, emb_size, hidden_size):
super().__init__()
self.num_q = num_q
self.emb_size = emb_size
self.hidden_size = hidden_size
self.interaction_emb = Embedding(self.num_q * 2, self.emb_size)
self.lstm_layer = LSTM(
self.emb_size, self.hidden_size, batch_first=True
)
self.out_layer = Linear(self.hidden_size, self.num_q)
self.dropout_layer = Dropout()
def forward(self, q, r):
x = q + self.num_q * r
h, _ = self.lstm_layer(self.interaction_emb(x))
y = self.out_layer(h)
y = self.dropout_layer(y)
y = torch.sigmoid(y)
return y
def train_model(
self, train_loader, test_loader, num_epochs, opt
):
aucs = []
loss_means = []
max_auc = 0
for i in range(1, num_epochs + 1):
loss_mean = []
for data in train_loader:
q, r, qshft, rshft, m = data
self.train()
y = self(q.long(), r.long())
y = (y * one_hot(qshft.long(), self.num_q)).sum(-1)
y = torch.masked_select(y, m)
t = torch.masked_select(rshft, m)
opt.zero_grad()
loss = binary_cross_entropy(y, t)
loss.backward()
opt.step()
loss_mean.append(loss.detach().cpu().numpy())
with torch.no_grad():
for data in test_loader:
q, r, qshft, rshft, m = data
self.eval()
y = self(q.long(), r.long())
y = (y * one_hot(qshft.long(), self.num_q)).sum(-1)
y = torch.masked_select(y, m).detach().cpu()
t = torch.masked_select(rshft, m).detach().cpu()
auc = metrics.roc_auc_score(
y_true=t.numpy(), y_score=y.numpy()
)
pred_binary = np.where(y >= 0.5, 1 , 0)
acc = metrics.accuracy_score(t.numpy(), pred_binary)
loss_mean = np.mean(loss_mean)
print(
"Epoch: {}, AUC: {}, ACC: {}, Loss Mean: {}"
.format(i, auc, acc, loss_mean)
)
aucs.append(auc)
loss_means.append(loss_mean)
4. Model Training(Evaluate per Epoch)¶
batch_size = 256
num_epochs = 30
learning_rate = 0.001
model = DKT(dataset.num_q, emb_size=100, hidden_size=100).to("cpu")
opt = Adam(model.parameters(), learning_rate)
model.train_model(
train_loader, test_loader, num_epochs, opt
)
Epoch: 1, AUC: 0.7255053200769622, ACC: 0.7158798415560796, Loss Mean: 0.6563825011253357 Epoch: 2, AUC: 0.7587186154561868, ACC: 0.7373961560395185, Loss Mean: 0.6227207779884338 Epoch: 3, AUC: 0.7780146698170052, ACC: 0.7496801837209661, Loss Mean: 0.6096892356872559 Epoch: 4, AUC: 0.7897620144223088, ACC: 0.7570012792651162, Loss Mean: 0.603091835975647 Epoch: 5, AUC: 0.7947756850674047, ACC: 0.7612552210970854, Loss Mean: 0.5968748927116394 Epoch: 6, AUC: 0.798899913023305, ACC: 0.7629814583622324, Loss Mean: 0.5935264825820923 Epoch: 7, AUC: 0.8007897563582989, ACC: 0.7644302646383379, Loss Mean: 0.5911917090415955 Epoch: 8, AUC: 0.8028633163032657, ACC: 0.7657403554199226, Loss Mean: 0.5897303223609924 Epoch: 9, AUC: 0.8046535011679443, ACC: 0.7664031072270773, Loss Mean: 0.5883178114891052 Epoch: 10, AUC: 0.8056586120597905, ACC: 0.7675436568486922, Loss Mean: 0.5863074064254761 Epoch: 11, AUC: 0.8069821252696949, ACC: 0.7684221883139902, Loss Mean: 0.5844799280166626 Epoch: 12, AUC: 0.807251845474827, ACC: 0.7681755829903978, Loss Mean: 0.5829126238822937 Epoch: 13, AUC: 0.8082050782622135, ACC: 0.7683913626485411, Loss Mean: 0.5819440484046936 Epoch: 14, AUC: 0.8079643399739838, ACC: 0.7679598033322544, Loss Mean: 0.5807048082351685 Epoch: 15, AUC: 0.8095288333912909, ACC: 0.7688999861284506, Loss Mean: 0.5792787671089172 Epoch: 16, AUC: 0.8100892947167552, ACC: 0.7682988856521941, Loss Mean: 0.5777994394302368 Epoch: 17, AUC: 0.8104198684935033, ACC: 0.7693315454447373, Loss Mean: 0.577787458896637 Epoch: 18, AUC: 0.810345395669994, ACC: 0.7693623711101863, Loss Mean: 0.5767340660095215 Epoch: 19, AUC: 0.8109994281413573, ACC: 0.7692544812811146, Loss Mean: 0.5759090185165405 Epoch: 20, AUC: 0.8113341081637573, ACC: 0.7695781507683297, Loss Mean: 0.5745164155960083 Epoch: 21, AUC: 0.8112710953970624, ACC: 0.7691311786193185, Loss Mean: 0.5732950568199158 Epoch: 22, AUC: 0.8115512989435798, ACC: 0.7694240224410844, Loss Mean: 0.5734812021255493 Epoch: 23, AUC: 0.8104966515121905, ACC: 0.7686996193030317, Loss Mean: 0.5732634663581848 Epoch: 24, AUC: 0.8107582538669259, ACC: 0.7679289776668053, Loss Mean: 0.5708116292953491 Epoch: 25, AUC: 0.8095497038380823, ACC: 0.7699480587537183, Loss Mean: 0.5712732076644897 Epoch: 26, AUC: 0.8111982410394037, ACC: 0.7700251229173409, Loss Mean: 0.5692374110221863 Epoch: 27, AUC: 0.8099790354014564, ACC: 0.7680676931613262, Loss Mean: 0.5691598057746887 Epoch: 28, AUC: 0.8106532114987121, ACC: 0.7685454909757864, Loss Mean: 0.568437397480011 Epoch: 29, AUC: 0.8093373902551219, ACC: 0.7688999861284506, Loss Mean: 0.5666221380233765 Epoch: 30, AUC: 0.8095114114890152, ACC: 0.7687304449684808, Loss Mean: 0.5673268437385559
'AI' 카테고리의 다른 글
Prompt Engineering 은 어떻게 하는걸까? : ChatGPT 활용 예제와 함께 완벽히 정리하기 (기본편) (0) | 2023.06.18 |
---|---|
Matrix Factorization : 개요와 원리부터, 최적화(SGD, ALS)까지 이해하기 (0) | 2023.06.01 |
FM(Factorization Machines)을 활용한 학생별 문항 맞힐확률 예측하기 (0) | 2023.02.26 |
협업필터링(CF)을 통해 유사한 학습개념 및 유사한 수준의 학생 찾기 (0) | 2022.12.24 |
AIEd를 위한 학습풀이이력 공개데이터셋 (1) | 2022.12.24 |