True or False? 基于 BERT 學生數學問題誤解檢測
代碼詳見:https://github.com/xiaozhou-alt/Student_Math_Misconception
文章目錄
- True or False? 基于 BERT 學生數學問題誤解檢測
- 一、項目介紹
- 二、文件夾結構
- 三、數據集介紹
- 四、BERT 模型介紹
- 五、項目實現
- 1. 數據預處理
- 2. 自定義數據類
- 3. 多任務 BERT 模型
- 4. 評估指標和早停機制
- 5. 預測和結果處理
- 6. 主函數(開始訓練! 好像開始的有點晚)
- 六、結果展示
一、項目介紹
學生經常被要求解釋他們的數學推理。這些解釋為學生的思維提供了豐富的見解,并經常揭示潛在的誤解(系統性的錯誤思維方式)。
例如,學生經常認為 0.355 大于 0.8,因為他們錯誤地將整數知識應用于小數,推斷 355 大于 8。學生在數學中產生了一系列誤解,有時是因為他們錯誤地將先驗知識應用于新內容,有時是因為他們試圖理解新信息但誤解了它。要了解有關這些定義和框架的更多信息,請參閱 此處的鏈接報告。
將學生的解釋標記為包含潛在的誤解對于診斷反饋很有價值,但既耗時又難以擴展。誤解可能很微妙,具體性各不相同,并隨著學生推理新模式的出現而演變。
這個項目使用基于 BERT 的多任務學習框架,同時解決學生數學解題過程中的 三個關鍵任務:
- 答案正確性分類:判斷學生答案 是否正確(二分類)
- 誤解類型檢測:識別學生在解題中 是否存在誤解(二分類)
- 具體誤解分類:識別學生解題中具體存在的 誤解類型(多分類)
該系統通過聯合學習三個相關任務,實現了知識遷移和模型參數共享,顯著提升了各個任務的性能表現。項目采用 PyTorch 實現,包含完整的訓練流程、早停機制、評估指標計算和結果可視化功能。
項目源于:MAP - Charting Student Math Misunderstandings | Kaggle
數據集下載(包含處理之后的數據集和原始數據集):Student_Math_Misconception (kaggle.com)
訓練完成的模型下載:Student_Math_Misconception(kaggle.com)
二、文件夾結構
Student_Math_Misconception/
├── data/
│ ├── README-data.md
│ ├── class.json # 類別定義文件,包含Category和Misconception的類別
│ ├── data.csv # 原始數據集文件
│ ├── split_report.json # 數據集劃分報告,記錄訓練/驗證集劃分詳情
│ ├── train.csv # 劃分后的訓練集數據
│ └── val.csv # 劃分后的驗證集數據
├── log/ # 日志文件夾
└── output/ # 輸出結果文件夾├── pic/ # 圖片輸出├── sample_predictions.txt # 模型預測結果示例└── training_history.xlsx # 訓練歷史記錄表格
├── README.md
├── data.ipynb # 數據分析和預處理
├── requirements.txt
├── train.py
三、數據集介紹
在 Eedi 上,學生回答診斷問題(DQ),這是一種多項選擇題,具有一個正確答案和三個錯誤答案,稱為干擾。在回答多項選擇后,有時會要求學生提供書面解釋來證明他們選擇的答案是合理的。這些解釋是 MAP 數據集的主要焦點,用于識別和解決學生推理中的潛在誤解。
數據集包含學生解題記錄,每行記錄包含以下關鍵字段:
- [train/val].csv
QuestionId
- 唯一的問題標識符。QuestionText
- 問題的文本。MC_Answer
- 學生選擇的多項選擇題答案。StudentExplanation
- 學生對選擇特定多項選擇答案的解釋。Category
- 對學生的多項選擇題答案與其解釋之間關系的分類(例如,True_Misconception,表示正確的多項選擇題選擇,并附有揭示誤解的解釋)。Misconception
- 學生對答案的解釋中發現的數學誤解。僅當類別包含誤解時才適用,否則為“NA”。
類別定義存儲在class.json
中,包含所有可能的Category
和Misconception
類型標簽。
數據集前幾行展示如下所示:
row_id | QuestionId | QuestionText | MC_Answer | StudentExplanation | Category | Misconception |
---|---|---|---|---|---|---|
000 | 317723177231772 | What fraction of the shape is not shaded? Give your answer in its simplest form. [Image: A triangle split into 9 equal smaller triangles. 6 of them are shaded.] | 13\frac{1}{3}31? | One third is equal to tree nineth | True_Correct | NA |
… | … | … | … | … | … | … |
276327632763 | 317723177231772 | What fraction of the shape is not shaded? Give your answer in its simplest form. [Image: A triangle split into 9 equal smaller triangles. 6 of them are shaded.] | 38\frac{3}{8}83? | $\frac{3}{9} because there is 999 triangles and three are not shaded so the answer =DDD | False_Misconception | Incomplete |
… | … | … | … | … | … | … |
114101141011410 | 317783177831778 | A10=915\frac{A}{10}=\frac{9}{15}10A?=159? What is the value of AAA ? | 444 | 10+5=1510 + 5 = 1510+5=15 and 999 is over 151515 so you have to minus 555 from nine too. | False_Misconception | Additive |
… | … | … | … | … | … | … |
class.json(于 data.ipynb 中處理得到):
{
“Category”: [
“True_Correct”,
…
“False_Correct”
],
“Misconception”: [
“NA”,
“Incomplete”,
…
“Scale”
]
}
四、BERT 模型介紹
BERT(Bidirectional Encoder Representations from Transformers)是由 Google 于2018 年提出的基于 Transformer 架構的預訓練語言模型,標志著自然語言處理領域進入預訓練大模型時代。其核心創新在于通過雙向 Transformer 編碼器 捕捉上下文語義,突破了傳統單向語言模型的限制。BERT 采用 掩碼語言建模(MLM)和 下一句預測(NSP)兩大預訓練任務,在大規模無標注文本(如 Wikipedia)上學習通用語言表示,通過隨機遮蔽 15%15\%15% 的詞匯要求模型還原原始文本,同時判斷句子間的邏輯關系。這種預訓練范式使模型能夠捕獲詞匯、句法和語義的多層次特征,通過微調即可適配文本分類、問答、實體識別等下游任務。
五、項目實現
1. 數據預處理
將原始數據中的問題、答案和解釋組合成單一文本輸入,從CategoryCategoryCategory列提取三個任務的標簽:
- is_correctis\_correctis_correct: 答案是否正確(二分類)
- misconception_typemisconception\_typemisconception_type: 誤解類型(多分類)
- misconception_labelmisconception\_labelmisconception_label: 具體誤解(多分類)
處理缺失值問題,確保所有標簽都有有效值
# 數據預處理
def preprocess_data(df):# 創建輸入文本:問題 + 答案 + 解釋...# 提取任務標簽...# 修復缺失值問題...return df
2. 自定義數據類
- 繼承 PyTorchPyTorchPyTorch 的 Dataset\textbf{Dataset}Dataset 類,實現自定義數據集
- 使用 BERT tokenizer 對文本進行編碼:
- 添加特殊標記[CLS]\textbf{[CLS]}[CLS]和[SEP]\textbf{[SEP]}[SEP]
- 將序列填充/截斷到固定長度
- 返回注意力掩碼以忽略填充部分
- 返回包含輸入 ID、注意力掩碼、三個任務標簽和原始文本的字典
# 自定義數據集
class MathDataset(Dataset):def __init__(self, texts, task1_labels, task2_labels, task3_labels, tokenizer, max_len):...def __len__(self):return len(self.texts) # 返回數據集大小def __getitem__(self, idx):text = str(self.texts[idx]) # 獲取文本# 使用tokenizer編碼文本encoding = self.tokenizer.encode_plus(text,add_special_tokens=True, # 添加[CLS]和[SEP]max_length=self.max_len, # 截斷/填充到最大長度padding='max_length', # 填充到最大長度truncation=True, # 啟用截斷return_attention_mask=True, # 返回注意力掩碼return_tensors='pt', # 返回PyTorch張量)# 返回處理后的樣本return {'input_ids': encoding['input_ids'].flatten(), # 輸入ID'attention_mask': encoding['attention_mask'].flatten(), # 注意力掩碼'task1_labels': torch.tensor(self.task1_labels[idx], dtype=torch.long), # 任務1標簽'task2_labels': torch.tensor(self.task2_labels[idx], dtype=torch.long), # 任務2標簽'task3_labels': torch.tensor(self.task3_labels[idx], dtype=torch.long), # 任務3標簽'text': text # 原始文本(用于后續分析)}
3. 多任務 BERT 模型
使用預訓練的 BERTBERTBERT 模型作為共享編碼器;在 BERTBERTBERT 輸出上添加 dropoutdropoutdropout 層防止過擬合;三個任務共享相同的 BERTBERTBERT 編碼,但使用獨立的分類層;pooler_outputpooler\_outputpooler_output 是[CLS]\textbf{[CLS]}[CLS]標記的表示,通常用于分類任務;模型輸出三個任務的 logitslogitslogits(未歸一化的預測分數)
# 多任務模型
class MultiTaskBert(torch.nn.Module):def __init__(self, num_task1_classes, num_task2_classes, num_task3_classes, model_name='bert-base-uncased'):super().__init__()# 共享的BERT編碼器...# 任務特定的分類層...def forward(self, input_ids, attention_mask):# 獲取共享表示outputs = self.bert(input_ids=input_ids, attention_mask=attention_mask)pooled_output = outputs.pooler_output # 使用[CLS]的表示pooled_output = self.dropout(pooled_output) # 應用dropout# 任務特定輸出...return task1_logits, task2_logits, task3_logits
4. 評估指標和早停機制
因為本次項目開發的模型是針對三個任務而言的,所以針對三個任務擁有 不同 的評估指標:
- 答案正確性分類:判斷學生答案 是否正確(二分類)
- 誤解類型檢測:識別學生在解題中 是否存在誤解(二分類)
- 具體誤解分類:識別學生解題中具體存在的 誤解類型(多分類)
任務1,2:以 準確率 (Accuracy) 作為評估標準:
Accuracy=判斷的正確個數驗證集樣本總數Accuracy = \frac{判斷的正確個數}{驗證集樣本總數} Accuracy=驗證集樣本總數判斷的正確個數?
任務3:以 MAP@3(Mean Average Precision at 3)作為評估標準:
MAP@3=1U∑u=1U∑k=1min?(n,3)P(k)×rel?(k)MAP@3 = \frac{1}{U} \sum_{u=1}^{U} \sum_{k=1}^{\min(n, 3)} P(k) \times \operatorname{rel}(k) MAP@3=U1?u=1∑U?k=1∑min(n,3)?P(k)×rel(k)
其中 UUU 是觀測數,P(k)P(k)P(k) 是截止時的精度 kkk,nnn 是每個觀察提交的預測數,并且 rel(k)rel(k)rel(k) 是一個指標函數,如果排名中的項目 kkk 是相關(正確)標簽,否則為零。
計算每個樣本預測的前 333 個類別中 是否包含真實標簽,如果真實標簽在前 kkk 個位置,精度為1k+1\frac{1}{k+1}k+11?,最終結果是所有樣本精度的 平均值
# MAP@3 計算函數
def map_at_3(y_true, y_pred_probs):U = len(y_true) # 樣本總數ap_sum = 0.0 # 平均精度和for i in range(U):true_label = y_true[i] # 真實標簽top_3 = np.argsort(y_pred_probs[i])[::-1][:3] # 預測概率最高的前3個類別ap = 0.0 # 單個樣本的平均精度# 檢查前3個預測中是否有真實標簽for k in range(min(3, len(top_3))):if top_3[k] == true_label:ap = 1 / (k + 1) # 如果在位置k,則精度為1/(k+1)breakap_sum += ap # 累加平均精度return ap_sum / U # 返回平均MAP@3
早停機制防止模型過擬合,當驗證集指標在指定輪數內沒有顯著提升時停止訓練,保存最佳模型狀態,以便在訓練結束后恢復,min_delta\textbf{min\_delta}min_delta確保改進是實質性的,避免因微小波動而停止
# 早停類
class EarlyStopper:def __init__(self, patience=3, min_delta=0.001):self.patience = patience # 容忍輪數self.min_delta = min_delta # 最小改進值self.counter = 0 # 計數器self.best_metric = -np.inf # 最佳指標值self.best_model_state = None # 最佳模型狀態self.epoch = 0 # 最佳模型所在輪數def check(self, current_metric, model, epoch):# 檢查當前指標是否顯著優于最佳指標if current_metric > self.best_metric + self.min_delta:self.best_metric = current_metric # 更新最佳指標self.counter = 0 # 重置計數器self.best_model_state = model.state_dict().copy() # 保存模型狀態self.epoch = epoch # 記錄最佳輪數return False # 不需要停止else:self.counter += 1 # 增加計數器if self.counter >= self.patience:return True # 需要停止return False # 不需要停止
5. 預測和結果處理
從數據集中隨機選擇樣本進行預測,使用model.eval()model.eval()model.eval()和torch.no_grad()torch.no\_grad()torch.no_grad()確保預測階段高效且節省內存,將數字標簽解碼回原始類別名稱,提高結果可讀性,為 任務1(答案正確性)生成中文標簽,便于理解,返回包含原始文本、真實標簽和預測標簽的字典列表
將預測結果格式化為易讀的文本輸出,清晰展示每個任務的真實值和預測值
# 加載模型并進行預測
def load_and_predict(model, tokenizer, dataset, task2_encoder, task3_encoder, device, num_samples=10):model.eval() # 設置為評估模式# 隨機選擇樣本索引sample_indices = random.sample(range(len(dataset)), min(num_samples, len(dataset)))results = [] # 存儲預測結果for idx in sample_indices:...# 獲取預測結果(取最高概率的類別)...# 獲取真實標簽...# 解碼標簽(將數字標簽轉回原始類別名稱)...# 存儲結果results.append({...})return results# 格式化輸出預測結果
def format_prediction_results(results):formatted = [] # 存儲格式化后的文本行for i, res in enumerate(results):formatted.append(f"樣本 {i+1}:") # 樣本編號formatted.append(f"文本: {res['text']}") # 原始文本formatted.append(f"任務1 (答案是否正確):") # 任務1標題formatted.append(f" 真實: {res['task1_true']} | 預測: {res['task1_pred']}") # 任務1結果formatted.append(f"任務2 (誤解類型):") # 任務2標題formatted.append(f" 真實: {res['task2_true']} | 預測: {res['task2_pred']}") # 任務2結果formatted.append(f"任務3 (具體誤解):") # 任務3標題formatted.append(f" 真實: {res['task3_true']} | 預測: {res['task3_pred']}") # 任務3結果formatted.append("-" * 80) # 分隔線return "\n".join(formatted) # 返回格式化后的字符串
6. 主函數(開始訓練! 好像開始的有點晚)
模型訓練部分直接包含在主函數中,沒有單獨封裝成函數,所以按照代碼的前后順序,此處沒有按照實際的運行順序,而是按照代碼從上到下的順序進行講解 ???
- 加載訓練集和驗證集 CSV 文件,應用預處理函數處理數據
- 確定所有可能的類別,包括訓練集和驗證集中出現的類別
- 為 任務3 添加NANANA類別處理缺失值,使用 LabelEncoderLabelEncoderLabelEncoder 將文本標簽轉換為數字,便于模型處理
def main():# 加載數據train_df = pd.read_csv('/kaggle/input/student-math-misconception/data/train.csv')val_df = pd.read_csv('/kaggle/input/student-math-misconception/data/val.csv')# 預處理train_df = preprocess_data(train_df)val_df = preprocess_data(val_df)# 確保所有類別都已知...# 檢查并添加可能的缺失值if 'NA' not in all_task3_classes:...# 標簽編碼task2_encoder = LabelEncoder()...# 轉換標簽(將類別名稱轉換為數字)train_df['task2_encoded'] = task2_encoder.transform(train_df['misconception_type'])...
- 使用 BERT tokenizer 進行文本編碼,創建訓練集和驗證集的自定義數據集實例
- 使用 DataLoaderDataLoaderDataLoader創建批處理數據加載器:
- 訓練集 洗牌 以提高訓練效果
- 使用多線程加速數據加載
- 驗證集不洗牌以確保結果一致性
ps:shuffle(中洗牌,混亂)。shuffle 在機器學習與深度學習中代表的意思是,將訓練模型的數據集進行打亂的操作。原始的數據,在樣本均衡的情況下可能是按照某種順序進行排列,如前半部分為某一類別的數據,后半部分為另一類別的數據。但經過打亂之后數據的排列就會擁有一定的隨機性,在順序讀取的時候下一次得到的樣本為任何一類型的數據的可能性相同。本項目中問題類型相同的樣本條目就被放到一起,直接讀入就非常不利于模型訓練。
# 初始化tokenizertokenizer = BertTokenizer.from_pretrained('bert-base-uncased')# 創建數據集train_dataset = MathDataset(texts=train_df.text.values,task1_labels=train_df.is_correct.values,task2_labels=train_df.task2_encoded.values,task3_labels=train_df.task3_encoded.values,tokenizer=tokenizer,max_len=MAX_LEN)val_dataset = MathDataset(...)# 創建數據加載器train_loader = DataLoader(train_dataset,batch_size=BATCH_SIZE,shuffle=True, # 訓練集需要洗牌num_workers=NUM_WORKERS # 多線程加載數據)val_loader = DataLoader(...)
- 初始化多任務 BERT 模型,根據類別數量配置輸出層
- 使用 AdamWAdamWAdamW 優化器,這是一種改進的 Adam 優化器
- 設置學習率調度器,使用 線性衰減 策略,為每個任務使用 交叉熵 損失函數
- 初始化早停機制,防止過擬合
- 創建字典記錄訓練過程中的各項指標
# 初始化模型model = MultiTaskBert(num_task1_classes=2, # 任務1:二分類num_task2_classes=len(task2_encoder.classes_), # 任務2類別數num_task3_classes=len(task3_encoder.classes_) # 任務3類別數)model = model.to(DEVICE) # 將模型移至指定設備# 優化器和調度器optimizer = AdamW(model.parameters(), lr=LEARNING_RATE) # AdamW優化器...# 損失函數loss_fn1 = torch.nn.CrossEntropyLoss() # 任務1損失函數...# 初始化早停器early_stopper = EarlyStopper(patience=PATIENCE, min_delta=MIN_DELTA)# 訓練歷史記錄history = {...}
前向傳播計算三個任務的輸出,加權組合三個任務的損失(權重可調整),梯度裁剪防止梯度爆炸,記錄并顯示每個epoch的平均訓練損失
# 訓練循環(添加進度條)for epoch in range(EPOCHS):model.train() # 設置為訓練模式total_loss = 0 # 累計損失# 使用tqdm包裝訓練數據加載器(添加進度條)train_pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{EPOCHS} Training", bar_format='{l_bar}{bar:20}{r_bar}{bar:-20b}',dynamic_ncols=True)for batch in train_pbar:# 準備批數據input_ids = batch['input_ids'].to(DEVICE)attention_mask = batch['attention_mask'].to(DEVICE)task1_labels = batch['task1_labels'].to(DEVICE)task2_labels = batch['task2_labels'].to(DEVICE)task3_labels = batch['task3_labels'].to(DEVICE)optimizer.zero_grad() # 清零梯度# 前向傳播task1_logits, task2_logits, task3_logits = model(input_ids=input_ids,attention_mask=attention_mask)# 計算各任務損失loss1 = loss_fn1(task1_logits, task1_labels)loss2 = loss_fn2(task2_logits, task2_labels)loss3 = loss_fn3(task3_logits, task3_labels)# 加權損失(權重可調整)loss = 0.4 * loss1 + 0.3 * loss2 + 0.3 * loss3total_loss += loss.item() # 累加損失# 反向傳播loss.backward()torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) # 梯度裁剪optimizer.step() # 更新參數scheduler.step() # 更新學習率# 更新進度條顯示當前batch損失train_pbar.set_postfix({'batch_loss': f"{loss.item():.4f}", # 當前批次損失'avg_loss': f"{total_loss / (train_pbar.n + 1):.4f}" # 平均損失})# 計算平均訓練損失avg_train_loss = total_loss / len(train_loader)history['train_loss'].append(avg_train_loss)
六、結果展示
訓練過程輸出如下所示:
訓練 損失 和三個任務的 評估指標 隨訓練輪數(Epoch)的變化如下圖所示:
隨機測試的 101010 個樣本預測結果(此處只展示個別樣本輸出):
樣本 1:
文本: 問題: This is part of a regular polygon. How many sides does it have? [Image: A diagram showing an obtuse angle labelled 144 degrees] 答案: Not enough information 解釋: because we kneed to know the total number of internal degrees.
任務1 (答案是否正確):
真實: 錯誤 | 預測: 錯誤
任務2 (誤解類型):
真實: Misconception | 預測: Misconception
任務3 (具體誤解):
真實: Unknowable | 預測: Unknowable
樣本 2:
文本: 問題: The probability of an event occurring is ( 0.9 ).
Which of the following most accurately describes the likelihood of the event occurring? 答案: Likely 解釋: because it is 0.9 which is close to 1 (certain) on the scale of probability.
任務1 (答案是否正確):
真實: 正確 | 預測: 正確
任務2 (誤解類型):
真實: Correct | 預測: Correct
任務3 (具體誤解):
真實: NA | 預測: NA
如果你喜歡我的文章,不妨給小周一個免費的點贊和關注吧!