CCCMKホールディングス TECH Labの Tech Blog

TECH Labスタッフによる格闘記録やマーケティング界隈についての記事など

Tree of Thoughts(ToT)でなぞなぞ作りを試してみた話。

こんにちは、CCCMKホールディングスTECH LABの三浦です。

手書きの手紙を送ることが最近減ってきたのですが、敬老の日には手書きの手紙を送ったりします。そもそも手書きで文章を書くこと自体が減ってきたので、この漢字ってこの書き方でいいんだっけ・・・?と、簡単な漢字なのに不安になってしまうことが何回かありました。時々は意識して手書きで文章を書くようにした方がいいかな、と感じました。

前回Prompt Engineeringのテクニック、"Tree of Thoughts(ToT)"について、論文を読んで調べたことをまとめてみました。

techblog.cccmk.co.jp

ToTを自分でも試してみたい、ということで、今回はToTを実行するプログラムを実装してLLMsに簡単ななぞなぞを作ってもらう、ということにチャレンジしてみました。

意外と難しいなぞなぞ作り

今回使用するLLMsはAzure OpenAIの"gpt-35-turbo-16k"です。

以前興味があってLLMsになぞなぞを作ってもらうことを試したことがあるのですが、単純にプロンプトを与えるだけだとあまり上手くいきませんでした。例えば以下のような方法です。

from langchain.chat_models import AzureChatOpenAI

llm = AzureChatOpenAI(
        deployment_name="gpt-35-turbo-16k",
        temperature=0.5)
PROMPT = """面白い日本語のなぞなぞを1つ作ってください。答えも書いてください。"""
print(llm.predict(PROMPT))

以下のような結果が出力されます。

なぞなぞ:「電車に乗っているとき、窓の外にいる人が『モノレール』と叫んでいました。なぜでしょう?」
答え:その人は「モノレール」ではなく、「もうネコいる」(もう猫いる)と叫んでいたからです。

これはなぞなぞとは言い難ですね・・・。上手く行かない要因は色々ありそうですが、1つのなぞなぞを作るためには実はいくつものステップを踏まないといけないことが関係しているのではないか、と考えられます。

実際私がなぞなぞを考える時、たとえば以下の様に段階を踏んで考えると思います。

  • なぞなぞの難易度を決めるため、誰向けのなぞなぞを作るかを考える。
  • 歴史や食べ物など、なぞなぞのテーマを考える。
  • なぞなぞの答えを考える。
  • その答えに合うなぞなぞの問題文を考える。

なぞなぞを作ることは、LLMsにとって実は結構難しいタスクなのかもしれません。

ToTを活用したなぞなぞ作りフロー

ToTを活用することで、なぞなぞの品質をどれだけ上げられるかを試してみました。まずなぞなぞ作りをToTのフレームワークに落とし込んで次のような処理の流れを考えてみました。

  • なぞなぞ作りのためのプランをLLMsに複数生成させる
  • 生成したプランの中で特に面白いプランをLLMsに投票させる
  • 複数回実行した投票結果に従って面白いプランを複数選択する
  • 選択したプランごとに複数個のなぞなぞを生成させる
  • 生成したなぞなぞの中で特に面白いなぞなぞをLLMsに投票させる
  • 複数回実行した投票結果に従って、最も面白いなぞなぞを決定し出力する

図にすると以下の様になります。

なぞなぞ作りのためのTree of Thoughts(ToT)のフロー

大まかにいえば、"生成→投票→選択"の流れを2回繰り返すイメージです。

実装についての説明

参考にした情報

ではどのようにこの流れを実装していくのかについて、コードを交えながら説明していきます。なお今回作成したコードはToTのOfficialのRepositoryの実装を参考にしています。

github.com

OfficialのRepositoryでは"game24", "crossword", "text"の3つのタスクを想定したToTの実装がされており、そのうち"text"向けの実装を主に参考にしています。"text"はそれぞれ作成に当たり条件が与えられた4つの短い段落で構成される、統一感のある文章を生成する、というタスクです。

ベースとなるプロンプト

まず今回の一連の処理でLLMsに渡すプロンプトのベースを2つ用意します。まず1つ目はプランとなぞなぞを生成するためのプロンプトのベースです。

GENERATE_PROMPT="""面白い日本語のなぞなぞを作ってください。
最初にいくつかのステップで構成されるプランを考えて、プランに従ってなぞなぞを作ってください。アウトプットの形式は以下のようにしてください。

プラン:
面白いなぞなぞを作るためのプランを書いてください。

なぞなぞ:
なぞなぞを1つ書いてください。答えも書いてください。

"""

このプロンプトをプラン、なぞなぞの両方の生成に使用します。プランを生成する時はなぞなぞまで生成する必要がないため、"なぞなぞ:"というワードをstopワードとして指定し、なぞなぞの生成まで進まないようにします。

2つ目のプロンプトのベースは、生成されたテキストの候補から一番良いものを選択させるプロンプトのベースです。

VOTE_PROMPT="""以下の選択肢の中から特に面白い選択肢を選んでください。選択肢を選ぶ際には様々な観点で面白さを判断してください。判断した結果は"最も面白い選択肢は{s}"の形式で最後の一行に出力し、sには選択肢番号を示す整数にしてください。

"""

このプロンプトによって"最も面白い選択肢は1"のような文字列が生成されます。この文字列に対し、正規表現操作用のPythonのライブラリreを使って選択肢番号だけ取得します。

生成用関数

プランやなぞなぞを生成する関数を定義します。この関数はプランの生成もなぞなぞの生成も出来るようにしており、プランの生成をする時はllm.predict()の引数stopに"なぞなぞ:"を指定してプランの生成までに留めるようにします。一方なぞなぞの生成の時は投票によって決まったプランをGENERATE_PROMPTの末に結合し、そこから先のなぞなぞ部分だけを生成させるようにします。

def generate(generate_num, stop=None, plan=""):
  """
  プランおよびなぞなぞを生成する関数。
  
  Parameters
  ----------
  generate_num: 生成する数
  stop: stopワード。プラン生成時に指定する。
  plan: なぞなぞ生成時に設定するプラン。
  """
  if stop:
    samples = [llm.predict(GENERATE_PROMPT, stop=[stop]).rstrip() for _ in range(generate_num)]
  else:
    samples = [llm.predict(GENERATE_PROMPT + plan).rstrip() for _ in range(generate_num)]
  return samples

選択肢の生成と投票用の関数

generate()で生成されたプランやなぞなぞに選択肢番号を付与して選択肢のフォーマットにし、もっとも良い選択肢を投票させる関数を定義します。

まず選択肢生成用の関数です。これは与えられた選択肢のリストの先頭に"選択肢1:"のように選択肢番号を付与します。選択肢番号は1から開始するため、enumerate()の第二引数には1をセットしています。

def generate_choices(samples):
  """
  生成したプランおよびなぞなぞを選択肢形式リストにして返す。
  
  Parameters
  ----------
  samples: 選択肢にするプランおよびなぞなぞリスト
  """
  choices = [f"選択肢{i}:" + sample for i, sample in enumerate(samples, 1)]
  return choices

そしてこのリストに対して複数回投票を行わせ、それぞれの選択肢ごとの投票数を求めます。関数は以下の様にしました。

def vote(choices, voter_num):
  """
  与えられた選択肢に対しvoter_num回投票を実行し、
  選択肢ごとに投票数を集計して返す。

  Parameters
  ----------
  choices: 選択肢のリスト
  voter_num: 投票回数
  """
  vote_results = [0] * len(choices)
  pattern = r".*最も面白い選択肢は.*(\d+).*"
  choices_str = "\n".join(choices)
  
  for _ in range(voter_num):
    prompt = VOTE_PROMPT + choices_str
    vote_result = llm.predict(prompt)
    m = re.match(pattern, vote_result, re.DOTALL)
    if m:
      # 正規表現の\d+に当たる部分の数値を取得する。
      choice_id = int(m.groups()[0]) - 1
      vote_results[choice_id] += 1
  return  vote_results

全体を組み立てる

これまで定義した関数を使って、全体の処理を組み立てていきます。パラメータになるのはプラン/なぞなぞの生成数generate_numと投票回数voter_numです。ToTでは選択肢の評価の後、次のステップで使用する選択肢を複数個選ぶことが出来ますが、今回は最も投票数が多かった選択肢1つだけを使用するようにします。

import collections
import itertools
import re

from langchain.chat_models import AzureChatOpenAI
import numpy as np

llm = AzureChatOpenAI(
        deployment_name="gpt-35-turbo-16k",
        temperature=0.5)

generate_num = 5
voter_num = 15

#Step1
print("step 1")

# プランの生成
plan_list = generate(generate_num, "なぞなぞ:")

# 投票によるプランの評価
choice_list = generate_choices(plan_list)
vote_result = vote(choice_list, voter_num)

# 投票結果に基づくプランの選択
# 今回は最も投票数が多いものを選ぶ
best_plan = plan_list[np.argmax(vote_result)]
print("best plan")
print(best_plan)

#Step2
print("step 2")

# なぞなぞの生成
nazo_list = generate(generate_num,None,best_plan)

# 生成したなぞなぞのリストへの展開
nazo_list = [d.split("なぞなぞ:")[-1] for d in nazo_list]

# 投票によるなぞなぞの評価
choice_list = generate_choices(nazo_list)
vote_result = vote(choice_list, voter_num)

best_nazo = nazo_list[np.argmax(vote_result)]
print("best nazonazo")
print(best_nazo)

実行結果

先述のコードを実行して出力された結果は以下の様になりました。

best plan
プラン:
1. ジャンルを選ぶ: まず、なぞなぞのジャンルを選びます。例えば、動物、食べ物、自然など。
2. テーマを決める: 選んだジャンルから具体的なテーマを選びます。例えば、動物のテーマなら「犬」や「猫」など。
3. ヒントを考える: テーマに関連するヒントを考えます。ヒントは答えを導くための手がかりとなります。
4. 問題文を作成する: ヒントを使って面白い問題文を作成します。問題文は答えを当てるための質問や謎となります。
step 2
best nazonazo

ジャンル: 動物
テーマ: 猫
ヒント: 夜行性である / 魚が好物である / しっぽがある
問題文: 夜に活動し、魚が大好きな動物で、しっぽが特徴的なのは何でしょうか?

答え: 猫

最終的に生成されたなぞなぞは、"夜に活動し、魚が大好きな動物で、しっぽが特徴的なのは何でしょうか?"で、答えは""です。先ほどのなぞなぞに比べれば大分なぞなぞらしくなったと言えるのではないでしょうか?

さらに何回か実行し、生成されたなぞなぞの一部を以下に掲載します。

  • "テーブルの上にあるのに食べられないものは何でしょう?!"→"コンピューター"
  • "夜になると元気になる、何でしょう?"→"猫"
  • "どんな風にも吹かれないものは何ですか?"→"陰"

一番最後の問題は目の付け所が面白いかも・・・と感じました。

generate_numvoter_numを変えると・・・?

generate_numvoter_numを変えると生成されるなぞなぞはどのように変化するか見てみます。

generate_num=10, vorter_num=20

  • "ライオン、ゾウ、キリンの3匹が会議をしていました。会議のテーマは何でしょう?"→"ジャングルの王者を決めることです。"
  • "足が4本あるけど、犬じゃないものは何?"→"テーブル"
  • "何の動物が一番お金持ちでしょう?"→"リッチョウ(リッチ+鳥)"

パラメータの値を増やすと、若干不思議ななぞなぞが増えた感じがします。

generate_num=20, vorter_num=30

  • "黒くて大きな耳、長い鼻、そして象のように見えるけれど、実は象ではない動物は何でしょう?"→"アリさん"
  • "何が飛び跳ねながら歩いているのか?"→"カンガルー"
  • "食べても食べても増えるものは何でしょう?"→"穴"

最後の問題はアレンジすると面白いなぞなぞになりそうです。

パラメータの値を増やすと、やや独特ななぞなぞが増えたかな、という気がします。LLMsのパラメータのtemperatureを変えたり、ベースのプロンプトをアレンジしたりするともっと色々変わるのでは、と考えています。

まとめ

ということで、今回は前回論文を読んで調べたTree of Thoughtsについて、Officialの実装を参考にしながらなぞなぞ作りを通して実際に動かしてみた話をご紹介しました。たしかに単発のプロンプトで作った時に比べると、ToTを使った方がちゃんとなぞなぞの形になったものが生成されていることが分かりました。色々なタスクに応用できるテクニックだと思いますので、他のタスクでもToTを試していきたいと思いました。