ChatGPTでクイズ問題の難易度を推定してみる

最近流行りのChatGPTを使って、クイズの問題の難易度を推定してみたい。

現状だと、競技クイズの問題の難易度管理は作問者の感覚に依存する形になっているが、ChatGPTで自動化できれば客観的な指標として使いやすいかもしれない。

# 手法

サンプルとして、以下の10問を使う：

1. 日本で一番高い山は何でしょう？（富士山）
2. 世界で一番高い山は何でしょう？"（エベレスト）
3. 日本で2番目に高い山は何でしょう？"（北岳）
4. 世界で2番目に高い山は何でしょう？"（K2）
5. アンデス山脈の最高峰は何でしょう？"（アコンカグア）
6. ピレネー山脈の最高峰は何でしょう？"（アネト山）
7. アパラチア山脈の最高峰は何でしょう？"（ミッチェル山）
8. ドラケンスバーグ山脈の最高峰は何でしょう？"（タバナントレニャナ山）
9. ドイツの最高峰は何でしょう？"（ツークシュピッツェ）
10. パラオの最高峰は何でしょう？"（ゲルチェレチュース山）

この問題の選定は、同じジャンルに絞ることで問題間の比較をしやすいようにすること、問題文に含まれる情報を絞ること、正答率が高いものから低いものまでいろいろなパターンが含まれるようにすること、を意識して選んだ。

手順は以下の通り：

ChatGPTのAPIに、以下の形式のプロンプトを投げる：
```
これから述べるクイズの問題を、日本人の大学生〜大学院生に出題した時、予想される正答率を、「予想正答率: 10±2%」の形で書いてください。問題:{question} 解答:{answer}
```
モデルはgpt-3.5-turboを用いた。

レスポンスをいい感じにパースして正答率の数字を取得する。

これをループで100回繰り返す。レスポンスのタイムアウトやパースエラーを除いて、取得できた数字から、重み付き平均を取る。±の値を標準誤差だと思って、逆二乗の値を重みとする。statsmodels.stats.weightstats.DescrStatsWを使ってmeanとstd_meanを計算する。

# 結果

| question | answer | mean | sigma |
|:---|:---|---:|---:|
| 日本で一番高い山は何でしょう？ | 富士山 | 91.740 | 0.885 |
| 世界で一番高い山は何でしょう？ | エベレスト | 88.424 | 1.237 |
| 日本で2番目に高い山は何でしょう？ | 北岳 | 33.230 | 8.080 |
| 世界で2番目に高い山は何でしょう？| K2 | 15.537 | 3.092 |
| アンデス山脈の最高峰は何でしょう？ | アコンカグア | 23.370 | 7.068 |
| ピレネー山脈の最高峰は何でしょう？ | アネト山 | 8.256 | 1.371 |
| アパラチア山脈の最高峰は何でしょう？ | ミッチェル山 | 9.075 | 1.341 |
| ドラケンスバーグ山脈の最高峰は何でしょう？ | タバナントレニャナ山 | 6.346 | 0.774 |
| ドイツの最高峰は何でしょう？ | ツークシュピッツェ | 17.223 | 6.200 |
| パラオの最高峰は何でしょう？ | ゲルチェレチュース山 | 6.053 | 0.891 |

![hist_富士山.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/436b03f0-6c4d-197c-724a-c7f269ee304d.png)
![hist_エベレスト.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/f011ffd9-8ce6-5fb8-6a49-756567ba68d9.png)
![hist_北岳.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/9ff2f4aa-381e-2223-d8dd-7b25e7e824b3.png)
![hist_K2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/b7fb6fa1-f4fd-0da3-4bcb-31e42ab5a93d.png)
![hist_アコンカグア.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/62ff5637-fd0c-9d77-de45-208082f08cd3.png)
![hist_アネト山.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/91203d9b-c0f7-9e68-1f28-2029889eff9c.png)
![hist_ミッチェル山.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/10753d16-c05a-1cdf-6547-9190f1e01713.png)
![hist_タバナントレニャナ山.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/8f284e25-fd4b-b41f-ca42-02e371f3093b.png)
![hist_ツークシュピッツェ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/c214f79f-3eb8-54cd-c7ca-5a542590d5cb.png)
![hist_ゲルチェレチュース山.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1051185/4b29e0cd-adc2-a12f-597c-dbb75ecbedf0.png)

# 考察

大雑把に、正答率が高そうなものが高い数字に、低そうなものが低い数字にはなった。個人的な感覚としては、K2は若干過小評価な気がするし、ツークシュピッツェは過大評価な気がする。

ヒストグラムを見た感じ、多くの問題では1箇所際立ったピークがある形だが、北岳の問題のように山がはっきりしないものも見られた。北岳・アコンカグア・ツークシュピッツェの問題は推定値のsigmaが少し大きめになっていて、こうしたChatGPTの推定のばらつきを反映していると思われる。

1問あたり100回ChatGPTのAPIを呼ぶことになるので、大規模な問題群に一律適用するにはちょっと重いかもしれないが、特定個人の感覚に依存しない難易度推定手法として一考の余地はありそうに思う。

# Appendix

apiを呼んだり重み付き平均を取るのに使ったpythonのコードを貼っておく：

```python
import datetime
import logging
import os
import sys

import numpy as np
import openai
import pandas as pd
from statsmodels.stats.weightstats import DescrStatsW

dt_now = datetime.datetime.now()

logger = logging.getLogger(__name__)

streamHandler = logging.StreamHandler(sys.stdout)
formatter = logging.Formatter(
    '%(asctime)s - %(name)s - %(levelname)s - %(message)s')
streamHandler.setFormatter(formatter)
logger.addHandler(streamHandler)

fileHandler = logging.FileHandler(
    f'log/log_{dt_now.strftime("%Y%m%d-%H%M%S")}.log')
fileHandler.setFormatter(formatter)
logger.addHandler(fileHandler)

logger.setLevel(logging.DEBUG)

openai.api_key = os.environ.get('OPENAI_SECRET', '')


def generate_prompt(question, answer):
    return f'これから述べるクイズの問題を、日本人の大学生〜大学院生に出題した時、予想される正答率を、「予想正答率: 10±2%」の形で書いてください。問題:{question} 解答:{answer}'


def query_openai(prompt, timeout=10):
    response = openai.ChatCompletion.create(
        model="gpt-3.5-turbo",
        messages=[
            {"role": "user", "content": prompt}
        ],
        timeout=timeout,
        request_timeout=timeout
    )
    return response['choices'][0]['message']['content']


def parse_openai_res(res_openai):
    data = res_openai.replace('予想正答率: ', '').replace('%', '').split('±')
    logger.debug(f'{data = }')
    m = float(data[0])
    s = float(data[1])
    return m, s


def calculate_mean_errors(df):
    d = DescrStatsW(df['mean'], weights=1/(df['sigma']**2))
    m = d.mean
    s = d.std_mean
    return m, s


def estimate_difficulty(question, answer, N=100):

    res = []

    for i in range(N):
        logger.info(f'loop {i}')

        try:
            prompt = generate_prompt(question, answer)
            res_openai = query_openai(prompt)
            logger.info(f'{res_openai = }')

            m, s = parse_openai_res(res_openai)
            res.append((m, s))
        except Exception as e:
            logger.warning(e)
            continue

    df = pd.DataFrame(res, columns=['mean', 'sigma'])
    df.to_csv(f'res/res_{answer}.csv', encoding='utf_8_sig')

    m, s = calculate_mean_errors(df)

    return m, s


def main():
    questions = pd.read_csv('questions.csv')
    logger.debug(questions)

    questions['mean'] = 0.0
    questions['sigma'] = 0.0
    for index, row in questions.iterrows():
        question = row['question']
        answer = row['answer']
        logger.info(f'{question} ({answer})')

        m, s = estimate_difficulty(question, answer)
        logger.info(f'{m:.3f} ± {s:.3f}')
        questions.iat[index, 2] = m
        questions.iat[index, 3] = s

    questions.to_csv('res.csv', encoding='utf_8_sig')

    logger.info('Completed!')


if __name__ == '__main__':
    main()
```