空欄補充問題の自動採点方法を確立する

学士研究:サムネイル画像3 IT研究(学士)

前回で空欄補充問題を自動生成する方法を確立しました。

今回は空欄補充問題を自動採点する方法を確立します。

とはいえ、既に空欄補充問題を自動採点しやすい形式で定義しているため、割と簡単です。

採点方法

以下の採点内容を出力する Python コードを作成しました。

import streamlit as st
import shutil
import os
import re
import zipfile
from sys import exit


def saiten():
	global upload_file1
	global upload_file2
	global upload_file3
	global upload_file4
	global upload_file5

	upload_file1 = st.file_uploader('ans.c (提出ファイル数上限 : 1)'          , type = 'c'  )
	upload_file2 = st.file_uploader('ans.txt (提出ファイル数上限 : 1)'        , type = 'txt')
	upload_file3 = st.file_uploader('prob.c (提出ファイル数上限 : 1)'         , type = 'c'  )
	upload_file4 = st.file_uploader('student_c.zip (提出ファイル数上限 : 1)'  , type = 'zip')
	upload_file5 = st.file_uploader('student_txt.zip (提出ファイル数上限 : 1)', type = 'zip')


	if upload_file1 and upload_file2 and upload_file3 and upload_file4 and upload_file5:

		if st.button("採点を開始"):

			create_dir()
			decode_ans_c()
			decode_ans_txt()
			decode_prob_c()
			decode_student_c()
			decode_student_txt()
			check_student_num()
			
			path1   = "/mount/src/educationapp/marking/input/c_file"
			path2   = "/mount/src/educationapp/marking/input/txt_file"
			path3   = "/mount/src/educationapp/marking/output"

			ans_c   = "/mount/src/educationapp/marking/ans.c"
			ans_txt = "/mount/src/educationapp/marking/ans.txt"
			prob_c  = "/mount/src/educationapp/marking/prob.c"
			
			ans_sum_lines = None

#			ans.cの行数を取得
			with open(ans_c) as myfile:
				ans_sum_lines = sum(1 for line in myfile)

			files         = os.listdir(path1)
			files         = [f for f in files if os.path.isfile(os.path.join(path1, f))]

			for f_name in files:

				c_file   = path1 + '/' + f_name
				txt_file = path2 + '/' + f_name.replace(".c", ".txt")
				output   = path3 + '/' + f_name.replace(".c", ".txt")

				create_output_txt(output)

				dif_code(ans_c, c_file, output, ans_sum_lines)

				dif_exe(ans_txt, txt_file, output)

				blank_mark(ans_c, prob_c, c_file, output)

			print_another_ans(path3)

			print_error_ans(path3)

			create_zip(path3)

			remove_file()



# ディレクトリを作成
def create_dir():

	input    = "/mount/src/educationapp/marking/input"
	c_file   = "/mount/src/educationapp/marking/input/c_file"
	txt_file = "/mount/src/educationapp/marking/input/txt_file"
	output   = "/mount/src/educationapp/marking/output"

	if os.path.exists(input):
		shutil.rmtree(input)

	if os.path.exists(c_file):
		shutil.rmtree(c_file)

	if os.path.exists(txt_file):
		shutil.rmtree(txt_file)

	if os.path.exists(output):
		shutil.rmtree(output)

	os.mkdir(input)
	os.mkdir(c_file)
	os.mkdir(txt_file)
	os.mkdir(output)



# ans.c を byte型 → str型に変更
def decode_ans_c():
	global upload_file1

	if upload_file1.name != "ans.c":
		st.write("エラー ans.c以外のファイルがアップロードされています")
		exit()

	f = open("/mount/src/educationapp/marking/ans.c", 'w', encoding="utf-8", newline='')
	f.write(upload_file1.getvalue().decode('utf-8'))
	f.close()



def decode_ans_txt():
	global upload_file2

	if upload_file2.name != "ans.txt":
		st.write("エラー ans.txt以外のファイルがアップロードされています")
		exit()

	f = open("/mount/src/educationapp/marking/ans.txt", 'w', encoding="utf-8", newline='')
	f.write(upload_file2.getvalue().decode('utf-8'))
	f.close()


def decode_prob_c():
	global upload_file3

	if upload_file3.name != "prob.c":
		st.write("エラー prob.c以外のファイルがアップロードされています")
		exit()

	f = open("/mount/src/educationapp/marking/prob.c", 'w', encoding="utf-8", newline='')
	f.write(upload_file3.getvalue().decode('utf-8'))
	f.close()



def decode_student_c():
	global upload_file4
	global file1_list

	if upload_file4.name != "student_c.zip":
		st.write("エラー student_c.zip以外のファイルがアップロードされています")
		exit()

	with zipfile.ZipFile(upload_file4, 'r') as inputFile:
		inputFile.extractall("/mount/src/educationapp/marking/input/c_file")

	file1_list  = os.listdir("/mount/src/educationapp/marking/input/c_file")
	out1        = re.compile(r'^(100)')
	out2        = re.compile(r'.c')

	for f_name in file1_list:
		
		if out2.search(f_name) == None:
			st.write("エラー zipファイル中のファイルで検出。拡張子が「.c」ではないファイルがアップロードされています(student_c.zipファイル内のファイル拡張子を「.c」に変更してください)")
			exit()

		if out1.search(f_name) == None:
			st.write("エラー zipファイル中のファイルで検出。student_c.zip内のファイルを指定されたファイル名に変更してください")
			exit()



def decode_student_txt():
	global upload_file5
	global file2_list

	if upload_file5.name != "student_txt.zip":
		st.write("エラー student_txt.zip以外のファイルがアップロードされています")
		exit()

	with zipfile.ZipFile(upload_file5, 'r') as inputFile:
		inputFile.extractall("/mount/src/educationapp/marking/input/txt_file")

	file2_list = os.listdir("/mount/src/educationapp/marking/input/txt_file")
	out1       = re.compile(r'^(100)')
	out2       = re.compile(r'.txt')

	for f_name in file2_list:

		if out2.search(f_name) == None:
			st.write("エラー 拡張子が「.txt」ではないファイルがアップロードされています(student_txt.zipファイル内のファイル拡張子を「.txt」に変更してください)")
			exit()

		if out1.search(f_name) == None:
			st.write("エラー student_txt.zip内のファイルを指定されたファイル名に変更してください")
			exit()



def check_student_num():
	global file1_list
	global file2_list

	file1_names = sorted(file1_list)
	file2_names = sorted(file2_list)
	size1       = len(file1_list)
	size2       = len(file2_list)

	if size1 != size2:
		st.write("エラー student_c.zip と student_txt.zip 内のファイル数が一致しません")
		exit()

	for i in range(size1):

		if file1_names[i].replace(".c", '') != file2_names[i].replace(".txt", ''):
			st.write("エラー student_c.zip と student_txt.zip 内のファイル名が一致しない組があります")
			exit()

	file1_list.clear()
	file2_list.clear()



def create_output_txt(file_path):

	f = open(file_path, 'w', encoding = "utf-8")
	f.close()



# 相違点の記録
def dif_code(ans_c, c_file, output, ans_sum_lines):
	global wrong_files
	global error_files

	c_file_sum_lines = None

	with open(c_file) as myfile:
			c_file_sum_lines = sum(1 for line in myfile)

	ans_f    = open(ans_c,  'r', encoding = "utf-8")
	c_file_f = open(c_file, 'r', encoding = "utf-8")
	output_f = open(output, 'w', encoding = "utf-8", newline = '')

	if ans_sum_lines != c_file_sum_lines:
		output_f.write("制約違反のファイル\n\n")
		error_files.append(c_file)
	
	else:

		ans_line    = ans_f.readline()
		c_file_line = c_file_f.readline()
		index       = 1
		flag        = 0

		output_f.write("コードの相違点\n\n")

		while ans_line:

			if ans_line != c_file_line:
				output_f.write("{} : {}\n\n".format(index, ans_line))
				output_f.write("{} : {}\n\n".format(index, c_file_line))
				flag = 1

			ans_line    = ans_f.readline()
			c_file_line = c_file_f.readline()
			index      += 1

		if flag == 0:
			output_f.write("相違点なし\n\n")

		else:
			wrong_files.append(c_file)

	ans_f.close()
	c_file_f.close()
	output_f.close()



# 実行結果の記録
def dif_exe(ans_txt, txt_file, output):
	global wrong_files
	global another_files

	ans_list      = []
	txt_file_list = []

	ans_f         = open(ans_txt , 'r', encoding = "utf-8")
	txt_file_f    = open(txt_file, 'r', encoding = "utf-8")
	output_f      = open(output  , 'a', encoding = "utf-8", newline = '')

	ans_line      = ans_f.readline()
	txt_file_line = txt_file_f.readline()

	output_f.write("模範解答\n\n")

	while ans_line:

		output_f.write(ans_line)
		ans_list.append(ans_line)
		ans_line = ans_f.readline()

	output_f.write("\n\n学習者の解答\n\n")

	while txt_file_line:

		output_f.write(txt_file_line)
		txt_file_list.append(txt_file_line)
		txt_file_line = txt_file_f.readline()

	ans_size = len(ans_list)
	txt_size = len(txt_file_list)

	if ans_size != txt_size:
		output_f.write("\n\n判定 : 不一致")
		return
	
	for i in range(ans_size):

		if ans_list[i] != txt_file_list[i]:
			output_f.write("\n\n判定 : 不一致")
			return

	output_f.write("\n\n判定 : 一致")

	if txt_file.replace("txt", "c") in wrong_files:
		output_f.write(" (別解のファイル)")
		another_files.append(txt_file.replace(".txt", ".c"))

	ans_f.close()
	txt_file_f.close()
	output_f.close()



def blank_mark(ans_c, prob_c, c_file, output):

	ans_list    = []
	prob_list   = []
	c_file_list = []

	ans_f       = open(ans_c , 'r', encoding = "utf-8")
	prob_f      = open(prob_c, 'r', encoding = "utf-8")
	c_file_f    = open(c_file, 'r', encoding = "utf-8")
	output_f    = open(output, 'a', encoding = "utf-8", newline = '')

	ans_line    = ans_f.readline()
	prob_line   = prob_f.readline()
	c_file_line = c_file_f.readline()

	while ans_line:
		ans_list.append(ans_line)
		ans_line = ans_f.readline()

	output_f.write("\n\nスコアレポート\n\n")

	while prob_line:
		prob_list.append(prob_line)
		prob_line = prob_f.readline()

	while c_file_line:
		c_file_list.append(c_file_line)
		c_file_line = c_file_f.readline()

	ans_f.close()
	prob_f.close()
	c_file_f.close()

	prob_size   = len(prob_list)
	c_file_size = len(c_file_list)

	if prob_size != c_file_size:
		return
		
	index   = 1
	count   = 0
	correct = 0
	flag    = 0
		
	for i in range(prob_size):

		ans_line    = ans_list[i].replace(' ', '')
		prob_line   = prob_list[i].replace(' ', '')
		c_file_line = c_file_list[i].replace(' ', '')

#		模範プログラムとソースコードが一致しない場合 → 2通りに場合分け
		if ans_line != c_file_line:
			
#			パターン1 : 空欄の行が一致しない
			if ("/*□□□*/" in prob_line) or ("/*○○○*/" in prob_line):
				output_f.write("{} : ✕\n".format(index))
				count += 1

#			パターン2 : 空欄以外の行が一致しない
			else:
				output_f.write("{} : 制約違反の変更\n".format(index))
				flag = 1

#		模範プログラムとソースコードが一致する場合
		else:

#			空欄の行の場合
			if ("/*□□□*/" in prob_line) or ("/*○○○*/" in prob_line):
				output_f.write("{} : 〇\n".format(index))
				count   += 1
				correct += 1

		index += 1
	
	if flag == 1:
		error_files.append(c_file)

	output_f.write("\nscore : {}点 ({}/{})".format(int((correct/count)*100), correct, count))
	output_f.close()



# 別解のファイルを表示
def print_another_ans(path3):
	global another_files

	f = open(path3 + '/' + "another_ans.txt", 'w', encoding = "utf-8", newline = '')

	with st.expander("別解のファイル一覧"):

		for another_file in another_files:
			st.write(another_file.replace("txt", "c"))
			f.write(another_file.replace("txt", "c"))

	f.close()



# 制約違反のファイルを表示
def print_error_ans(path3):
	global error_files

	f = open(path3 + '/' + "error_ans.txt", 'w', encoding = "utf-8", newline = '')

	with st.expander("制約違反のファイル一覧"):

		for error_file in error_files:
			st.write(error_file)
			f.write(error_file)

	f.close()



# zipファイルを作成
def create_zip(path3):

	shutil.make_archive('student', format = 'zip', root_dir = path3)

	with open("student.zip", "rb") as fp:
		btn = st.download_button(
        label     = "ファイルをダウンロード",
        data      = fp,
        file_name = "student.zip",
        mime      = "application/zip"
    )



# 不要なファイルを削除
def remove_file():

	shutil.rmtree("/mount/src/educationapp/marking/input/c_file")
	shutil.rmtree("/mount/src/educationapp/marking/input/txt_file")
	shutil.rmtree("/mount/src/educationapp/marking/output")

	os.remove("/mount/src/educationapp/marking/ans.c")
	os.remove("/mount/src/educationapp/marking/prob.c")



upload_file1 = None
upload_file2 = None
upload_file3 = None
upload_file4 = None
upload_file5 = None

wrong_files   = []
error_files   = []
another_files = []
空欄補充問題自動生成採点アプリ:空欄補充問題の自動採点方法
空欄補充問題の自動採点方法

やっていることはスライドのとおりです。

以下の項目を出力しています。

  • ファイル名
  • 相違点
  • 実行結果
  • 点数
  • 各空欄の正誤判定

実験

どれくらい機能してるか実験してみました。

実験方法は以下の手順で行いました。

1. プログラミング演習問題の模範プログラムから空欄補充問題を作成

2. 空欄を解答

3. 自動採点

結果

自動採点できなかったものは以下のパターンであり、36個中2個となりました。

  • 別解の可能性がある (a[i]でもa[j]でも同じ実行結果になるパターン)
  • 空欄以外の箇所が変更されている (これは不正とするため採点できなくて良いかも)

9割程度の演習問題が自動採点できました。

課題

別解が絡んでくると完全に自動採点するのは難しいかなと感じました。

編集可能箇所を限定したり1行につき1つの変数しか空欄にしないようにしたりして別解を極力発生させないようにしましたが、「変えが効く」場合はこれでも別解が発生してしまいます。

具体的には以下の場合です。

  • 模範解答は「=」だが「<」でも同じ実行結果になる
  • 模範解答はa[i]だがa[j]でも同じ実行結果になる

他の変数や数式記号で代用できる場合、どのように対応するかが今後の課題になりそうです。

タイトルとURLをコピーしました