
【生成AI】試験勉強のための自動採点アプリを作ってみた
来年度春の高度情報処理技術者試験に受験申込したのでそろそろ勉強し始めないといけないのですが、試験問題は記述問題のため自分で採点していてもどのくらいの点数が実際つくのかわからず不安になります。そこで、模範解答をもとに客観的に採点してくれるアプリを生成AIで実現できないかと思い、試しに作ってみました。

既存のアプリ
自分で作ってみる前に既存のアプリでどんなものがあるのかを調べてみました。
DEEP GRADE
DEEP GRADEというアプリでは、ChatGPTによって自動採点を実現しており、学校や塾の先生の採点工数を減らすことが可能とのことです。
テキストデータだけでなく、手書きデータや音声データも入力可能らしく、音声認識やAI-OCRによって答案を認識し自動採点ができるようです。
まさに私のやりたいことに近いですが、初期費用100万円で月額10万円とのことなのでもう少し簡単にできる方法を考えたほうがよさそうです。
設計
今回私が自動採点したいのはIPAの高度情報処理技術者試験になります。こちらの試験では過去問とその解答解説がPDF形式で公開されております。
これらの問題と模範解答、そして自分の回答を入力とし、回答が正しいかどうかを生成AIに良しなに判断してもらうことができればよさそうです。
というわけで、以下のような機能を持ったアプリケーションを作成します。
模範解答読み取り機能
問題文読み取り機能
問題文と模範解答とユーザーの回答をもとに採点する機能(生成AI)
各機能紹介
模範解答読み取り機能
試験の模範解答はテキスト形式のPDFで用意されているため、下記のMarkItDownというライブラリをもちいてマークダウン形式に変換します。
MarkItDownはPDFに限らずWordやPowerPointなどいろんなファイルをマークダウン形式に変換してくれるライブラリです。これによってPDFの文字を読み取る関数を以下のように作成しています(フロントエンドはstreamlitで作成ています)。
import streamlit as st
import tempfile
from markitdown import MarkItDown
@st.cache_data
def convert_text_to_text(uploaded_file):
file_bytes = uploaded_file.getvalue()
if file_bytes is not None:
try:
with tempfile.NamedTemporaryFile(delete=True) as tmp_file:
tmp_file.write(file_bytes)
tmp_file_path = tmp_file.name
md = MarkItDown()
result = md.convert(tmp_file_path)
return result.text_content
except Exception as e:
print(e)
return None
return None
st.sidebar.header("File Upload")
model_answer_file = st.sidebar.file_uploader("Upload file of model answer", type=["pdf"])
model_answer_text = convert_text_to_text(model_answer_file)
試しに令和2年のPM試験の模範解答を読み取った結果が以下の通りです。

設問1 (1) プロジェクトの承認を全社に伝え協力体制を確立するため
(2) 役員会で工場の生産プロセス DX を今期の最優先案件としたこと
設問2 (1) 全社からプロジェクトへ参加できる体制とするため
(2) メンバが最適化の案を検討する時間を確保できるようにするため
(3) 来期からの横展開に必要な手順を習得してもらうため
設問3 (1) IT とプロセス分析の専門家の支援で進捗の遅れを回復するため
(2) システムが異常の際は自分たちで迅速に対応できるようにするため
表形式が維持されないのは残念ですが、文字は間違えずに読み取れているようです。とりあえずはこれで生成AIが認識しやすくなったと思います。
問題文読み取り機能
問題文のPDFは残念ながらテキスト形式のPDFではなく、すべて画像形式のPDFでした…。そのため前述のMarkItDownを使うことができません(MarkItDownを実行しても空のマークダウンが出力されます)。
そこで、まずPDFを画像化してからOCRで文字を読み取ることとしました。
PDFの画像化にはpdf2imageを用い、OCRにはpytesseractを用いました。
pdf2imageを実行するには別途popplerというツールをインストールする必要があります。
また、pytesseractはGoogleのtesseractというツールのPythonラッパーとなっており、tesseract-ocrのインストールと学習済み日本語モデルをダウンロードする必要があります。
これらを用いて画像形式のPDFの文字を読み取る関数を以下のように作成しました。
import streamlit as st
import tempfile
import pytesseract
from pdf2image import convert_from_path
@st.cache_data
def convert_image_to_text(uploaded_file):
file_bytes = uploaded_file.getvalue()
if file_bytes is not None:
try:
with tempfile.NamedTemporaryFile(delete=True) as tmp_file:
tmp_file.write(file_bytes)
tmp_file_path = tmp_file.name
images = convert_from_path(tmp_file_path)
text = "\n".join([pytesseract.image_to_string(img, lang="jpn") for img in images])
return text
except Exception as e:
print(e)
return None
return None
st.sidebar.header("File Upload")
question_file = st.sidebar.file_uploader("Upload file of exam questions", type=["pdf"])
question_text = convert_image_to_text(question_file)
試しに令和2年のPM試験の問題文を読み取った結果が以下の通りです。

設問1 〔K課長の提案]〕 の〇①プロジェクト憲章の作成について, ①⑪, ⑫に答えよ。
(1) K 課長が, CDO から全社に向けて, 自動化プロジェクトのプロジェクト宣
章を発表することを提案した狙いは何か。30 字以内で述べよ。
(②) K 課長が. プロジェクトの背景に明記することを提案した, ある重要な決
定事項とは何か。35 字以内で述べよ。
設問2 〔K 課長の提案] の②プロジェクトチームの編成について, Q①)て3)に答えよ。
(1) K 課長が, CDO の直下にプロジェクトチームを設置することを提案した狙
いは何か。30 字以内で述べよ。
(②) K 課長が, DX 検討チナームのメンバを専任とすることを提案した狙いは何か。
35 字以内で述べよ。
(③) K 課長が, N 工場からもメンバを選任することを提案した狙いは何か。30
字以内で述べよ
括弧や数字がところどころおかしいですが、文章としては意外と破綻せずに保っていそうです。ただ、問題文中の表や図はうまく読み込めていなさそうでした。また、MarkItDownと違ってOCR処理に結構時間がかかりました(数分くらい)。とりあえず、これで生成AIがうまく読み取ってくれることを願って次に進みます。
問題文と模範解答とユーザーの回答をもとに採点する機能
ここまで来たらもうあとは生成AIに投げるだけです(適当)。
下記のようにプロンプトのフォーマットを作成し、生成AIを呼び出しています。今回はBedrockのClaude3.5 Sonnetを用いました。軽くOne-shot的に例題をプロンプトに記載しています。
import streamlit as st
import boto3
import json
from botocore.exceptions import ClientError
@st.cache_data
def auto_scoring(question, model_answer, answer):
try:
bedrock_runtime = boto3.client(service_name='bedrock-runtime')
model_id = 'anthropic.claude-3-5-sonnet-20240620-v1:0'
system_prompt = "あなたは優秀な試験採点者です。"
max_tokens = 1000
# Prompt with user turn only.
content = f'''
## あなたのタスク
以下の問題文と模範解答をもとに、ユーザーの回答を採点してください。
採点結果は設問ごとの点数(100点満点)とその理由を出力してください。
## 問題文
問1. 以下の設問に回答してください。
設問1: 日本で一番高い山を答えてください。
設問2: 日本で一番広い湖を答えてください。
設問3: 日本で二番目に高い山と日本で二番目に広い湖を答えてください。
## 模範解答
問1.
設問1: 富士山
設問2: 琵琶湖
設問3: 北岳と霞ヶ浦
## ユーザーの回答
問1.
設問1: 富士山
設問2: 霞ヶ浦
設問3: 北岳と浜名湖
## 採点結果
### 問1
- 設問1
- 点数: 100
- 理由: 答えが完全にあっているため
- 設問2
- 点数: 0
- 理由: 答えが完全に間違っているため
- 設問3
- 点数: 50
- 理由: 北岳のみ正解しているため
## 問題文
{question}
## 模範解答
{model_answer}
## ユーザーの回答
{answer}
## 採点結果
'''
user_message = {"role": "user", "content": content}
messages = [user_message]
response = generate_message (bedrock_runtime, model_id, system_prompt, messages, max_tokens)
return response
except ClientError as err:
message=err.response["Error"]["Message"]
print("A client error occured: " +
format(message))
return None
def generate_message(bedrock_runtime, model_id, system_prompt, messages, max_tokens):
body=json.dumps(
{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": max_tokens,
"system": system_prompt,
"messages": messages
}
)
response = bedrock_runtime.invoke_model(body=body, modelId=model_id)
response_body = json.loads(response.get('body').read())
return response_body["content"][0]["text"]
question_text = convert_image_to_text(question_file)
model_answer_text = convert_text_to_text(model_answer_file)
answer = st.text_area("Your answer")
response = auto_scoring(question_text, model_answer_text, answer)
st.sidebar.header("Score")
st.sidebar.markdown(response)
これを用いて令和2年のPM試験の私の解答を自動採点させたところ、以下のようになりました。
# 模範解答
設問1
(1) プロジェクトの承認を全社に伝え協力体制を確立するため
(2) 役員会で工場の生産プロセス DX を今期の最優先案件としたこと
設問2
(1) 全社からプロジェクトへ参加できる体制とするため
(2) メンバが最適化の案を検討する時間を確保できるようにするため
# 私の解答
設問1
(1)トップダウンで方針を周知し現場の協力を得るため。
(2)工場の生産プロセスDXが今期の最優先案件となっている旨。
設問2
(1)予算にかかわる意思決定スピードを速くするため。
(2)技術習得の時間を加味して兼業を解き工数を確保して遅延を挽回するため。
# 採点結果
問1
設問1
(1) 点数: 70点 理由: 「トップダウンで方針を周知し現場の協力を得るため」という回答は、模範解答の「プロジェクトの承認を全社に伝え協力体制を確立するため」とほぼ同じ内容を表現していますが、より具体的ではありません。
(2) 点数: 90点 理由: 模範解答とほぼ同じ内容ですが、「旨」という表現が余分です。
設問2
(1) 点数: 50点 理由: 模範解答の「全社からプロジェクトへ参加できる体制とするため」という意図とは異なる回答になっています。
(2) 点数: 80点 理由: 模範解答と同じ意図を表現していますが、より具体的に書かれています。

採点結果としては、文言が違っていても同等の内容であれば高めの採点になっていたり(設問1(1))、意図の違う答えになっていれば低めについていたりしそうなので(設問2(1))、なんとなくは採点できていそうに見えます。
ただ、設問1(2)で「旨」という表現が余分というだけで10点減点されているところは違和感あるので、もう少し採点基準を定義してあげないといけなさそうです(採点基準はわからいので難しいですが…)。
また、苦労してOCRした問題文はあんまり使われていなさそうなので、もしかしたら模範解答だけを入力しても同じような結果になるかもしれません。
まとめ
このアプリを思い立って2日ほどで作ってみましたが、意外と期待通りに採点してくれてうれしかったです。
ただ、ここから精度を上げていくためにはいろいろな課題がありそうです(採点基準がそもそもわからない、問題文をどこまで認識してくれてるかわからない、など)。
また、Claudeではなくo1などの推論モデルを使ってみたらどんな採点になるのかなども気になります。
とはいえ、そもそもの目的である試験勉強もしていかないといけないのでとりあえずここまでとしておこうと思います…。
作ったコードはこちらになります。