Zenn

ハルシネーションの少ない算数・数学instructionデータセットを合成する

2025/03/21に公開

要約

  • Magpieを用いて算数・数学のinstructionを生成
  • LLMがそのまま解いた結果とLLMがPythonで解いた結果を比較し、両者の結果が一致していたデータのみを残す
  • 今回の方法で算数・数学の計算問題のデータを約10万件生成した

https://huggingface.co/datasets/Kendamarron/magpie-easy-math-instruction-88k-qwen2.5-bakeneko-32b-instruct

https://huggingface.co/datasets/Kendamarron/magpie-japanese-math-instruction-17k-qwen2.5-bakeneko-32b-instruct

目的

計算問題を扱うデータセットは数自体が少なく、ライセンスや出典が怪しげなものが多い印象があったので、扱いやすいデータを作れないかよく考えていました。

そんなとき、nvidia/OpenMathInstruct-1でコードインタープリターを用いた応答生成を行っていたのを思い出したので、これを参考にしてハルシネーションの少ない数学instructionデータセットの構築を目指していきます。

実施内容

データセットの構築は以下のような流れで行いました。

  1. Magpieでinstructionを生成する
  2. 生成したinstructionをLLMに自力で解いてもらう
  3. 生成したinstructionを解くためのPythonコードを生成し、その結果を取得する
  4. 2つの結果を比較して、一致している場合は正しい答えであると判定する

Magpieによるinstructionの生成

MagpieはLLMを使ったinstruction合成手法の一つです。

Magpieに関してはaratakoさんのこちらの記事が参考になると思いますので、ぜひ御覧ください

https://zenn.dev/aratako_lm/articles/a5ae43fb2bfbb3

今回はrinna/qwen2.5-bakeneko-32b-instructを使用してinstructionの生成を行いました。

<|im_start|>system
あなたは計算が得意なアシスタントです。ユーザーから与えられた中学生レベルの数学の文章題の答えを提示します。<|im_end|>
<|im_start|>user

この続きをLLMに生成させることで、このあとに続くinstructionが得られます。

<|im_start|>system
あなたは計算が得意なアシスタントです。ユーザーから与えられた中学生レベルの数学の文章題の答えを提示します。<|im_end|>
<|im_start|>user
るビルの高さは120メートルあります。このビルの高さをxとおくと、あるお城の高さは3x+40メートルになります。このお城の高さは具体的に何メートルですか?

今回は5パターンのシステムプロンプトを用意し、それぞれに対してinstructionの生成を行いました。

  • あなたは計算が得意なアシスタントです。ユーザーから与えられた四則演算を用いる問題の答えを提示します。
  • あなたは計算が得意なアシスタントです。ユーザーから与えられた四則演算を用いる文章題の答えを提示します。
  • あなたは計算が得意なアシスタントです。ユーザーから与えられた中学生レベルの計算問題の答えを提示します。
  • あなたは計算が得意なアシスタントです。ユーザーから与えられた中学生レベルの数学の文章題の答えを提示します。
  • あなたは数学が得意な論理的なアシスタントです。ユーザーから与えられた難易度の高い数学の問題をステップバイステップで解きます。

応答の生成

LLMが自力で解くパターン

特に工夫はせずに応答の生成を行いました。

以下のようなプロンプトを設定し、応答を生成します。

<|im_start|>system
あなたは論理的で数学が得意なアシスタントです。<|im_end|>
<|im_start|>user
あるビルの高さは120メートルあります。このビルの高さをxとおくと、あるお城の高さは3x+40メートルになります。このお城の高さは具体的に何メートルですか?<|im_end|>
<|im_start|>assistant

上記のinstructionに対しては以下の応答が生成されました。

ビルの高さが120メートルであると与えられています。この高さを \( x \) とおくと、\( x = 120 \) メートルです。

お城の高さは \( 3x + 40 \) メートルと与えられています。 \( x \) の値を代入して計算します。

\[
3x + 40 = 3 \times 120 + 40
\]

計算は以下のようになります:

\[
3 \times 120 = 360
\]

\[
360 + 40 = 400
\]

したがって、お城の高さは 400 メートルです。

Pythonを使って解くパターン

ここでは以下のようなプロンプトを設定し、「```」が出力されコードブロックが閉じられるまで生成を行いました。

<|im_start|>system
あなたは論理的で数学が得意で優秀なプログラマーです。与えられた問題をPythonを用いて回答します。最後に問題の最終的な答えをprint文で出力します。<|im_end|>
<|im_start|>user
あるビルの高さは120メートルあります。このビルの高さをxとおくと、あるお城の高さは3x+40メートルになります。このお城の高さは具体的に何メートルですか?<|im_end|>
<|im_start|>assistant
```python

これで与えられた問題をPythonで解くコードが生成されます。

# ビルの高さをxとおく
x = 120

# お城の高さの計算式は3x + 40
castle_height = 3 * x + 40

# お城の高さを出力
print(castle_height)

これを以下のコードで実行し、ログに出力されるテキストを取得します。

生成したコードを実行する処理
import json
import subprocess
import tempfile
import os
import time
import sys
import io
from typing import Tuple, Dict, Any, List
from tqdm import tqdm

def execute_code_with_timeout(code: str, timeout: int = 3) -> Tuple[str, str]:
    """
    指定されたPythonコードを安全に実行し、標準出力と最後の出力結果を返す
    
    Args:
        code: 実行するPythonコード
        timeout: 実行タイムアウト秒数
        
    Returns:
        (stdout, last_output)のタプル - 標準出力とその最後の行
    """
            # 一時ファイルを作成(明示的にUTF-8を使用)
    with tempfile.NamedTemporaryFile(suffix='.py', delete=False, mode='w', encoding='utf-8') as temp_file:
        temp_file_path = temp_file.name
        
        # コードの内容にリダイレクト用の関数を追加してcaptureできるようにする
        modified_code = """
import sys
import io
from io import StringIO

# キャプチャ用のクラス
class Capture:
    def __init__(self):
        self.original_stdout = sys.stdout
        self.string_io = StringIO()
    
    def __enter__(self):
        sys.stdout = self.string_io
        return self
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        sys.stdout = self.original_stdout
    
    def get_output(self):
        return self.string_io.getvalue()

# 元のコードを実行して出力をキャプチャ
with Capture() as capture:
{}

# キャプチャした出力を表示
print("__CAPTURED_OUTPUT__")
print(capture.get_output())
""".format('\n'.join(['    ' + line for line in code.split('\n')]))
        
        temp_file.write(modified_code)
    
    try:
        # 子プロセスでスクリプトを実行(エンコーディングを明示的に指定)
        process = subprocess.Popen(
            [sys.executable, temp_file_path],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
            encoding='utf-8',
            errors='replace'
        )
        
        # タイムアウト付きで待機
        try:
            stdout, stderr = process.communicate(timeout=timeout)
            
            # 出力処理
            if stderr:
                return f"エラー: {stderr}", ""
            
            # 出力から結果を抽出
            if "__CAPTURED_OUTPUT__" in stdout:
                parts = stdout.split("__CAPTURED_OUTPUT__")
                if len(parts) > 1:
                    captured_output = parts[1].strip()
                    # 最後の出力行を取得
                    lines = captured_output.strip().split('\n')
                    last_output = lines[-1] if lines else ""
                    return captured_output, last_output
                
            return stdout, stdout.strip().split('\n')[-1] if stdout.strip() else ""
            
        except subprocess.TimeoutExpired:
            process.kill()
            return "タイムアウト: 実行に3秒以上かかりました", ""
            
    except Exception as e:
        return f"実行エラー: {str(e)}", ""
    finally:
        # 一時ファイルを削除
        try:
            os.unlink(temp_file_path)
        except:
            pass

def process_data(data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    """
    データリスト内の各項目のcoding_outputを実行し、結果を追加する
    
    Args:
        data: 処理対象のデータリスト
        
    Returns:
        更新されたデータリスト
    """
    for item in tqdm(data):
        if "coding_output" in item and item["coding_output"]:
            stdout, last_output = execute_code_with_timeout(item["coding_output"])
            # 結果をデータに追加
            item["execution_output"] = stdout
            item["execution_last_output"] = last_output
        else:
            item["execution_output"] = ""
            item["execution_last_output"] = ""
    
    return data

上記の例の場合には、「460」が最後のprintで出力されるので、execution_outputとして460が得られます。

これで2通りの方法で生成した回答が用意できたので、これらを比較して一致しているかどうか判定していきます。

この判定については、問題によって答えが複数あったり表記揺れが起こり得る可能性があるため、LLMによる判定を採用しました。

判定には以下のプロンプトを使用しています。

<|im_start|>system
あなたは論理的で数学が得意な教師です。<|im_end|>
<|im_start|>user
以下の問題に対して、2つの回答の内容が一致しているか採点してください。
2つの回答が合致している場合は1を、合致していない場合は0を出力してください。
採点結果は「Score: 」に続けて出力してください。

<question>
あるビルの高さは120メートルあります。このビルの高さをxとおくと、あるお城の高さは3x+40メートルになります。このお城の高さは具体的に何メートルですか?
</question>

<answer1>
ビルの高さが120メートルであると与えられています。この高さを \( x \) とおくと、\( x = 120 \) メートルです。

お城の高さは \( 3x + 40 \) メートルと与えられています。 \( x \) の値を代入して計算します。

\[
3x + 40 = 3 \times 120 + 40
\]

計算は以下のようになります:

\[
3 \times 120 = 360
\]

\[
360 + 40 = 400
\]

したがって、お城の高さは 400 メートルです。
</answer1>

<answer2>
400
</answer2><|im_end|>
<|im_start|>assistant
Score: 

これで1が生成された場合は2つの回答が一致していると判定しています。

そして、別々の方法で解いた答えが一致しているなら正しい答えなのだろうという仮定のもと、LLMが自力で解いたverの応答をinstructionに対する応答として採用しました。

結果

この方法で完成したデータセットは以下の2つになります。

https://huggingface.co/datasets/Kendamarron/magpie-easy-math-instruction-88k-qwen2.5-bakeneko-32b-instruct

https://huggingface.co/datasets/Kendamarron/magpie-japanese-math-instruction-17k-qwen2.5-bakeneko-32b-instruct

1つ目は比較的カンタンな問題が多く、小中学生レベルの基礎問題が多い印象です。

一方で2つ目はデータ量が少ないものの、微積や極限などが含まれ、1つ目よりはやや難易度が高めの印象があります。

考察

ハルシネーションの発生について

今回の方法はLLMに複数回応答を生成させて多数決を取るような方法ではなく、異なる方法で生成した回答が一致するかどうかで判断しているため、一定の正確性は担保できていると考えています。

とはいえ、この方法でも完全にハルシネーションを防ぐことは難しいと思います。

ですが、OpenMathInstruct-2ではSFTは低品質な応答に対しても堅牢であると主張されており、多少の混入は無視できるレベルだと判断しています。

instructionの難易度について

今回の方法で課題に感じているのは、instructionの難易度についてです。

この方法で最後まで残ったデータのinstructionはLLMが自力でもPythonでも解くことができたinstructionになります。

これはつまりinstruction自体の難易度が低く、簡単だから方法を変えても解くことができるってだけなのではないか、という懸念があります。

これはEvol Instructや応答の複数回生成である程度対策していける気がしますが、他にもなにか良い方法がないか考えていきたいと思います。

まとめ

今回はMagpieで生成した数学系instructionに対して2通りの方法で応答を生成し、結果を比較することでハルシネーションの少ないデータセットの構築を行いました。

同じような方法でコーディングタスクのデータセットを作れないか考えていますが、ファイルの読み書きが必要な場合の対応方法や機械学習等の時間がかかる処理への扱い等、考えることが多そうで前途多難です...

Discussion

ログインするとコメントできます