初心者がstreamlitを使って採点アプリを作ってみた#3

<!-- 目次部分(リンクになるところ) -->
# 目次
1. [はじめに](#anchor1)
1. [アプリ機能⑦　＝通常問題の採点＝](#anchor2)
1. [アプリ機能⑧　＝長文問題の採点＝](#anchor3)
1. [アプリ機能⑨　＝生徒ごとに集計し、表示＝](#anchor4)
1. [終わりに](#anchor5)

<a id="anchor1"></a>
# はじめに
streamlitを使って、採点アプリを作ってみた記録の第３回目の投稿になります。
前回までの記事が気になった方は

第一回：<https://qiita.com/0dn09g3y726519/items/d78397d3c174f4388687>
第２回：<https://qiita.com/0dn09g3y726519/items/e95bae2595b951ddbecc>

こちらをご覧ください。
下図のような構成のアプリを作っています。

###
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3821078/62a8f101-668e-21a8-4eb6-a02ebee17c33.png)
生徒ごとの回答用紙から回答を読み取り、修正するところまでは前回の記事で話したので、採点部分について機能紹介をしようと思います。

<a id="anchor2"></a>
# アプリ機能⑦　＝通常問題の採点＝
```php
for student in student_data:
    total_score = 0 
    
    for ans in student['answers']:
        if ans['student_answer'] == ans['question']:
            if ans['score'].isdigit():
                total_score += int(ans['score'])
    
    correct_answers = sum(1 for ans in student['answers'] if ans['student_answer'] == ans['question'])
    total_questions = len(student['answers'])
    accuracy = correct_answers / total_questions * 100 if total_questions > 0 else 0
```
ここで読み取った生徒の答えと模範解答を照らし合わせて、あっていたら最初に読み込んだ設問ごとの点数をtotal_scoreに加算していきます。
全問題のうちどれくらいの設問が正解できたのかを割合でも表示したいので、correct_answersで正解設問数を取得して、accuracyで正答率を出しています。

<a id="anchor3"></a>
# アプリ機能⑧　＝長文問題の採点＝
```php
def scoring_LLM(question_answer, student_answer, score):
    prompt = [
        {
            "role": "system",
            "content": "あなたは優れた採点システムです。これから提示される模範解答と生徒の回答に基づいて、生徒の回答に対する点数を正確に算出し、点数のみを返してください。"
        },
        {
            "role": "user",
            "content": "次の問題の模範解答と生徒の回答、そして満点の点数をもとに、採点を行ってください。"
        },
        {
            "role": "user",
            "content": f"模範解答: {question_answer}\n生徒の回答: {student_answer}\n満点: {score}"
        }
    ]
    output = llm(
    f"
\n{prompt}
\n
",
    max_tokens=23,
    stop=["
"],
    echo=True,
    )
    # 抜き出したテキスト
    text = output['choices'][0]['text']
    # 正規表現でプロンプト部分を探してその後の部分を抽出
    match = re.search(r'\[.*?\](.*)', text, re.DOTALL)
    if match:
        response_text = match.group(1).strip()
        # 改行文字を削除
        response_text = response_text.replace('\n', '')
    else:
        print("回答部分が見つかりませんでした。")
        response_text = ""
    return response_text[21:23]
    
for student in student_data:
        
        # LLMに回答を与えて、採点してもらう
        long_context_total_score = 0
        long_context_score = 0
        
        for long_context in student['long_context']:
            long_q = long_context
            long_context_total_score += int(long_q['score'])
            score = utils.scoring_LLM(long_q['question'], long_q['student_answer'], long_q['score'])
            long_context_score += score
            total_score += score
            time.sleep(2)
```
ここで模範解答の文字数が５０文字以上の問題は、LLMに採点してもらうようにしてあります。

<https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/tree/main>
LLMはこちらにあるphi-3というMicrosoftから出された量子化された小さいLLMをローカルに落としてきて、使用しました。こちらモデルのファイルが2.4GBと非常に軽量なので、CPUでも動かすことができ、性能もGPT3.5くらいはあるとのことなので、使用することにしました。（GPT4oに聞くのが一番正しそうですが、API料金が高そうなので止めました）

<https://zenn.dev/acntechjp/articles/734c15ad6d9013>
ローカルLLMをCPUオンリーで動かすために、この記事を参考にLlama.cppというライブラリで動かすようにしました。


プロンプトは特にそこまで工夫できていないです。何回かテストしてみて、このプロンプトであればそれなりに正しそうな点数を出力してくれそうだったので、そこで改善をやめました。
prompt = [{
"role": "system",
"content": "あなたは優れた採点システムです。これから提示される模範解答と生徒の回答に基づいて、生徒の回答に対する点数を正確に算出し、点数のみを返してください。"},
{
"role": "user",
"content": "次の問題の模範解答と生徒の回答、そして満点の点数をもとに、採点を行ってください。"},
{
"role": "user",
"content": f"模範解答: {question_answer}\n生徒の回答: {student_answer}\n満点: {score}"}]

response_text[21:23]としているので、LLMからの出力の最初のほうはいらない情報が常に入っているので、純粋に点数だけを取り出すようにしているためです。
LLMに長文問題の採点をしてもらえたら、その点数をtotal_scoreに加算して、終了です。
time.sleep(2)としているのは、LLMに連続で回答を求めるとエラーを出されることがあったからです。エラーの原因はCPUの使い過ぎとかだと思って、少し休憩を入れるようにしました。
この辺りめっちゃ適当ですみません。

<a id="anchor4"></a>
# アプリ機能⑨　＝生徒ごとに集計し、表示＝
```php
for student in student_data:
    """
    この間はアプリ機能⑧⑨の機能があります。
    """
    # 学生の結果を追加
    student_result = {
        '組': student['class'],
        '番': student['number'],
        '名前': student['name'],
        '合計点数': total_score,
        '文章題以外の正答率': f"{accuracy:.2f}%",
        '文章題の得点率': f"{(long_context_score / long_context_total_score) * 100:.2f}%"
    }
    
    # 各設問ごとの正解不正解を追加
    for ans in (student['answers'], 1):
        question_number = ans['question_number']
        student_result[f'設問{question_number}'] = '正解' if ans['student_answer'] == ans['question'] else '不正解'
    
    results.append(student_result)
      
results_df = pd.DataFrame(results)
st.dataframe(results_df.head(10))

csv = results_df.to_csv(index=False)
st.download_button(label="CSVとしてダウンロード", data=csv, file_name='results.csv', mime='text/csv')

# クラスごとの点数分布を表示
st.subheader('クラスごとの点数分布')
fig, ax = plt.subplots()
sns.histplot(data=results_df, x='合計点数', hue='組', multiple='stack', ax=ax)
ax.set_xlabel('点数')
ax.set_ylabel('生徒数')
ax.set_title('クラスごとの点数分布')
st.pyplot(fig)

# 設問ごとの正解率を計算
question_columns = [col for col in results_df.columns if col.startswith('設問')]
correct_counts = {col: (results_df[col] == '正解').sum() for col in question_columns}
total_counts = len(results_df)
question_accuracy = {col: correct_counts[col] / total_counts * 100 for col in question_columns}

# 正解率をデータフレームに変換
accuracy_df = pd.DataFrame(list(question_accuracy.items()), columns=['設問', '正解率'])

# 設問ごとの正解率を表示
st.subheader('全生徒の設問ごとの正解率')
fig, ax = plt.subplots()
sns.barplot(data=accuracy_df, x='設問', y='正解率', ax=ax)
ax.set_xlabel('設問')
ax.set_ylabel('正解率 (%)')
ax.set_title('全生徒の設問ごとの正解率')
st.pyplot(fig)
```
生徒ごとの採点がすべて終わったので、最後にそれを集計してstudent_resultという辞書型変数にまとめています。それを生徒すべてでまとめた変数がresule_dfであり、それをst.dataframeで表示して画面上で見えるようにしてあります。先生が手元で数字をいじれるようにCSVで出力できるようにdownload_buttonを付けました。
最後にクラスごとの点数分布をseabornのhistplotで表示しているのと、全生徒の設問ごとの正答率もseabornのbarplotで表示しています。この辺りはCSVダウンロードして先生自身で作成されることが多いでしょうが、一応参考程度に見てもらえたらと思い、つけときました。

以上でstreamlitを使って作成した採点アプリの機能の紹介になります。
ここまでお付き合いいただきありがとうございました。

<a id="anchor5"></a>
# 終わりに
今回で作成したアプリの機能紹介は以上になります。
次回からは一番苦労したところであるデプロイについてご紹介したいと思います。
今回もお付き合いいただきありがとうございました。