見出し画像

LangChainを使って自然文の入力を構造化データに変換する実験(2)プロンプトの改善

はじめに

前回、自然文から情報を抽出して構造化データに変換する実験を行った。

なんとなく想定の結果が得られたものの、どうもいまいちしっくりこない感じが否めない。そこで、もうちょいプロンプトを改善できないか、少し試してみた。

実験

前提

前回利用したのは以下のコードである。毎回コード全文を示すのは面倒なので、以降、このコードの中からtemplateの箇所のみを変更して結果を示すこととする。

# 環境変数の準備
import os
os.environ["OPENAI_API_KEY"] = "INPUT_YOUR_OWN_API_KEY"

from langchain.llms import OpenAI, OpenAIChat
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain

# 例示付きプロンプトの設定
template = """入力された文章から必要な情報を抽出し、指定のJSON形式で出力してくだささい。

# 入力例
---
第一次イタリア遠征(第一次対仏大同盟)

外交関係は第一次対仏大同盟、戦役はイタリア戦役 (1796-1797年)を参照
1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって第一次対仏大同盟が結成された。この戦いにおいてフランスの総裁政府は、ライン方面から2個軍、北イタリア方面から1個軍をもってオーストリアを包囲攻略する作戦を企図していた。
1796年3月、イタリア方面軍の司令官に任命されたナポレオン・ボナパルトは攻勢に出る。まず、これまで最前線でフランス軍と対峙してきたサルデーニャ王国をわずか1か月で降伏させ、オーストリア軍の拠点マントヴァを包囲した。オーストリア軍はマントヴァ解放を目指して反撃に出るが、ナポレオンの前にカスティリオーネの戦い(8月5日)、アルコレの戦い(11月15日-17日)、リヴォリの戦い(1797年1月14日)で敗北する。2月2日にマントヴァは開城。オーストリアは停戦を申し入れ、4月18日にレオーベンの和約が成立した。
10月17日、フランスとオーストリアはカンポ・フォルミオの和約を締結。フランスは南ネーデルラントとライン川左岸を併合し、北イタリアにはチザルピーナ共和国などのフランスの衛星国が成立した。オーストリアの脱落で第一次対仏大同盟は崩壊した。
---

# 出力例
---
(
  'event':(
    name:'第一次イタリア遠征',
    'year':1796,
    'related_persons':['ナポレオン・ボナパルト'],
    'details':(
      'place':['ライン方面','北イタリア方面','マントヴァ'],
      'related_nations':['フランス','イギリス','オーストリア','プロイセン','スペイン','サルディーニャ王国'],
      'battle':(
        'name':'カスティリオーネの戦い',
        'date':'1792年8月5日',
      ),
      'battle':(
        'name':'アルコレの戦い',
        'date':'1796年11月15日',
      ),
      'battle'(
        'name':'リヴォリの戦い',
        'date':'1797年1月14日',
      )
    )
  )
)
---

Human: {input}
AI:"""

prompt = PromptTemplate(
    input_variables = ['input'],
    template = template
)

llm = OpenAIChat(model_name="gpt-3.5-turbo",
                 temperature = 0.2)

llm_chain = LLMChain(
    llm = llm,
    verbose = True,
    prompt = prompt
)

# 出力
output = llm_chain.predict(input = 'エジプト遠征(エジプト・シリア戦役)\nフランス軍は、強力な海軍を有し制海権を握っているイギリスに対しては打撃を与えられなかった。そこでナポレオンはイギリスとインドとの連携を絶つため、オスマン帝国領エジプトへの遠征を総裁政府に進言した。1798年5月19日、ナポレオンの率いるエジプト遠征軍はトゥーロン港を出発。途中マルタ島を占領し、7月2日にエジプトのアブキール湾に上陸した。7月21日にはピラミッドの戦いで現地軍に勝利。次いでカイロに入城した。しかし8月1日のナイルの海戦において、ネルソン率いるイギリス艦隊にフランス艦隊は大敗し、ナポレオンはエジプトに孤立してしまう。\n他方、イギリスがマルタ島を占領したことで、海上の通商権を侵害されたデンマーク、スウェーデンと、イギリスの地中海進出に難色を示したロシアがプロイセンと結び、1800年に第二次武装中立同盟を結成する。これに対してイギリスは、1801年、デンマークの首都コペンハーゲンを攻撃した(コペンハーゲンの海戦, 4月2日)。この結果、武装中立同盟は解体し、ロシア、スウェーデンはイギリスと和解、デンマークはフランスに接近していった。')

# 出力結果の整形
output_json = output.replace('(','{').replace(')','}')
print(output_json)

また、毎回入力文を表示するのもめんどうなので、実験に利用した入力はすべて以下の第一次イタリア遠征の説明文とした。

output = llm_chain.predict(input = """第一次イタリア遠征(第一次対仏大同盟)
1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって第一次対仏大同盟が結成された。この戦いにおいてフランスの総裁政府は、ライン方面から2個軍、北イタリア方面から1個軍をもってオーストリアを包囲攻略する作戦を企図していた。
1796年3月、イタリア方面軍の司令官に任命されたナポレオン・ボナパルトは攻勢に出る。まず、これまで最前線でフランス軍と対峙してきたサルデーニャ王国をわずか1か月で降伏させ、オーストリア軍の拠点マントヴァを包囲した。オーストリア軍はマントヴァ解放を目指して反撃に出るが、ナポレオンの前にカスティリオーネの戦い(8月5日)、アルコレの戦い(11月15日-17日)、リヴォリの戦い(1797年1月14日)で敗北する。2月2日にマントヴァは開城。オーストリアは停戦を申し入れ、4月18日にレオーベンの和約が成立した。
10月17日、フランスとオーストリアはカンポ・フォルミオの和約を締結。フランスは南ネーデルラントとライン川左岸を併合し、北イタリアにはチザルピーナ共和国などのフランスの衛星国が成立した。オーストリアの脱落で第一次対仏大同盟は崩壊した。""")

実験1 例示を抽象化する

前回のプロンプトでは、入力例と出力例を直接示す、いわゆるfew-shot-learningの形式をとっていた。しかし、深津式プロンプトを改めて見てみると、例示をしなくとも、出力フォーマットを示しさえすれば、それに応じた出力をしてくれるような印象を受けた。ならば、以下のようにしてみたらどうだろうか?

template = """あなたは入力された文章から必要な情報を抽出し、指定のJSON形式に整形するAIです。以下に示す形式に従い、結果を出力してください。

# 指定のJSON形式
---
(
  'event':(
    name:'$本文のタイトル(入力された文章をもっともよく表すワードを選択)',
    'start_year':$入力された出来事が発生した年を西暦で出力,
    'end_year':$入力された出来事が終結した年を西暦で出力,
    'related_persons':['$関与した人名を出力','$関与した人名を出力'],
    'related_events':(
        'event':(
            'name':'$関連する出来事を出力',
            'summary':'$出力した関連する出来事の概要を50文字以内で出力'
        ),
        'event':(
            'name':'$関連する出来事を出力',
            'summary':'$出力した関連する出来事の概要を50文字以内で出力'
        ),
    ),
    'details':(
      'place':['$関与した地名を出力','$関与した地名を出力'],
      'related_nations':['$関与した国名を出力','$関与した国名を出力'],
      'battle':(
        'name':'$発生した戦闘の名称を出力',
        'start_date':'$戦闘が開始された年月日をxxxx年mm月dd日の形式で出力',
        'end_date':'$戦闘が終了した年月日をxxxx年mm月dd日の形式で出力',
        'result':'$フランス軍の勝敗を出力',
      ),
     'battle':(
        'name':'$発生した戦闘の名称を出力',
        'start_date':'$戦闘が開始された年月日をxxxx年mm月dd日の形式で出力',
        'end_date':'$戦闘が終了した年月日をxxxx年mm月dd日の形式で出力',
        'result':'$フランス軍の勝敗を出力',
      ),
    )
  )
)
---

Human: {input}
AI:"""

related_eventsとbattleは複数発生する可能性があるため、フォーマットには複数表示させている。結果は以下のとおりである。

{
  'event':{
    'name':'第一次イタリア遠征',
    'start_year':1796,
    'end_year':1797,
    'related_persons':['ナポレオン・ボナパルト'],
    'related_events':{
        'event':{
            'name':'第一次対仏大同盟',
            'summary':'1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって結成された同盟'
        },
    },
    'details':{
      'place':['サルデーニャ王国','マントヴァ','カスティリオーネ','アルコレ','リヴォリ'],
      'related_nations':['イギリス','オーストリア','プロイセン','スペイン'],
      'battle':[
        {
          'name':'カスティリオーネの戦い',
          'start_date':'1796年8月5日',
          'end_date':'1796年8月5日',
          'result':'フランス軍の勝利',
        },
        {
          'name':'アルコレの戦い',
          'start_date':'1796年11月15日',
          'end_date':'1796年11月17日',
          'result':'フランス軍の勝利',
        },
        {
          'name':'リヴォリの戦い',
          'start_date':'1797年1月14日',
          'end_date':'1797年1月14日',
          'result':'フランス軍の勝利',
        },
      ],
    }
  }
}

related_eventsのところにレーベンの和約とカンポ・フォルミオの和約が入っていないが、まあ良しとしよう。battleのところ、そういえばリストにしていなかったが、勝手にリストにしてくれていた。ありがたし。

実験2 トークン数を節約する

若干けち臭いが、フォーマットの繰り返しの箇所をなくしてしまったらどうなるのだろうか?勝手に繰り返しをしてくれるのだろうか?以下のように、実験1で設定していたrelated_events、battleの繰り返しをなくしてみた。

template = """あなたは入力された文章から必要な情報を抽出し、指定のJSON形式に整形するAIです。以下に示す形式に従い、結果を出力してください。

# 指定のJSON形式
---
(
  'event':(
    name:'$本文のタイトル(入力された文章をもっともよく表すワードを選択)',
    'start_year':$入力された出来事が発生した年を西暦で出力,
    'end_year':$入力された出来事が終結した年を西暦で出力,
    'related_persons':['$関与した人名を出力','$関与した人名を出力'],
    'related_events':(
        'event':(
            'name':'$関連する出来事を出力',
            'summary':'$出力した関連する出来事の概要を50文字以内で出力'
        ),
    ),
    'details':(
      'place':['$関与した地名を出力','$関与した地名を出力'],
      'related_nations':['$関与した国名を出力','$関与した国名を出力'],
      'battle':(
        'name':'$発生した戦闘の名称を出力',
        'start_date':'$戦闘が開始された年月日をxxxx年mm月dd日の形式で出力',
        'end_date':'$戦闘が終了した年月日をxxxx年mm月dd日の形式で出力',
        'result':'$フランス軍の勝敗を出力',
      ),
    )
  )
)
---

Human: {input}
AI:"""

結果がこちら。

{
  'event':{
    'name':'第一次イタリア遠征',
    'start_year':1796,
    'end_year':1797,
    'related_persons':['ナポレオン・ボナパルト'],
    'related_events':{
        'event':{
            'name':'第一次対仏大同盟',
            'summary':'1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって結成された同盟'
        },
    },
    'details':{
      'place':['サルデーニャ王国','マントヴァ','カスティリオーネ','アルコレ','リヴォリ'],
      'related_nations':['フランス','オーストリア'],
      'battle':{
        'name':'カスティリオーネの戦い',
        'start_date':'1796年8月5日',
        'end_date':'1796年8月5日',
        'result':'フランス軍の勝利',
      },
    }
  }
}

予想通り、繰り返し箇所は明示しないといけないっぽい。じゃあ、もうちょっと抽象的に繰り返し指示を出してみたらどうなるのだろうか?

実験3 繰り返し指示の抽象化

プロンプトは短い方が良い。入力できるトークン数に上限があるからである。というわけで、なるべく端的にしたい。

template = """あなたは入力された文章から必要な情報を抽出し、指定のJSON形式に整形するAIです。以下に示す形式に従い、結果を出力してください。

# 指定のJSON形式
---
(
  'event':(
    name:'$本文のタイトル(入力された文章をもっともよく表すワードを選択)',
    'start_year':$入力された出来事が発生した年を西暦で出力,
    'end_year':$入力された出来事が終結した年を西暦で出力,
    'related_persons':['$関与した人名を出力','$関与した人名を出力'],
    'related_events':(
        'event':(
            'name':'$関連する出来事を出力',
            'summary':'$出力した関連する出来事の概要を50文字以内で出力'
        ),
        $eventが複数ある場合は同じフォーマットで繰り返し
    ),
    'details':(
      'place':['$関与した地名を出力','$関与した地名を出力'],
      'related_nations':['$関与した国名を出力','$関与した国名を出力'],
      'battle':(
        'name':'$発生した戦闘の名称を出力',
        'start_date':'$戦闘が開始された年月日をxxxx年mm月dd日の形式で出力',
        'end_date':'$戦闘が終了した年月日をxxxx年mm月dd日の形式で出力',
        'result':'$フランス軍の勝敗を出力',
      ),
      $battleが複数ある場合は同じフォーマットで繰り返し
    )
  )
)
---

Human: {input}
AI:"""

さあどうだ。

{
  'event':{
    'name':'第一次イタリア遠征',
    'start_year':1796,
    'end_year':1797,
    'related_persons':['ナポレオン・ボナパルト'],
    'related_events':{
        'event':[
            {
                'name':'第一次対仏大同盟',
                'summary':'1792年のフランス革命戦争の勃発により、1793年にイギリス、オーストリア、プロイセン、スペインなどによって結成された。'
            },
            {
                'name':'レオーベンの和約',
                'summary':'1797年4月18日に成立した和約。'
            },
            {
                'name':'カンポ・フォルミオの和約',
                'summary':'1797年10月17日にフランスとオーストリアが締結した和約。'
            }
        ]
    },
    'details':{
      'place':['サルデーニャ王国','マントヴァ','カスティリオーネ','アルコレ','リヴォリ'],
      'related_nations':['イギリス','オーストリア','プロイセン','スペイン'],
      'battle':[
        {
            'name':'カスティリオーネの戦い',
            'start_date':'1796年8月5日',
            'end_date':'1796年8月5日',
            'result':'フランス軍の勝利'
        },
        {
            'name':'アルコレの戦い',
            'start_date':'1796年11月15日',
            'end_date':'1796年11月17日',
            'result':'フランス軍の勝利'
        },
        {
            'name':'リヴォリの戦い',
            'start_date':'1797年1月14日',
            'end_date':'1797年1月14日',
            'result':'フランス軍の勝利'
        }
      ]
    }
  }
}

うおーーーー、素晴らしいじゃないか!ほぼ言うことなしである。

option実験 英語にしてみたらどうなる?

大規模言語モデルに供するデータはやはり英語が一番多いと考えられる。ということは、もしかしたら英語のプロンプト、英語の入力の方がより精度が高くなる、とは考えられないだろうか?
早速やってみよう。

template = """I am an AI that extracts necessary information from the input text and formats it into the specified JSON format. Please output the result in the following format:

# Specified JSON format
---
(
  'event':(
    name:'$The word that best represents the input text (chosen from the input text)',
    'start_year':$The year in which the event occurred, output in AD,
    'end_year':$The year in which the event ended, output in AD,
    'related_persons':['$Name of the person involved','$Name of the person involved'],
    'related_events':(
        'event':(
            'name':'$Related event to output',
            'summary':'$Summary of the related event in 50 characters or less'
        ),
        $If there are multiple events, repeat the same format
    ),
    'details':(
      'place':['$Name of the place involved','$Name of the place involved'],
      'related_nations':['$Name of the country involved','$Name of the country involved'],
      'battle':(
        'name':'$Name of the battle that occurred',
        'start_date':'$The year, month, and day the battle began, output in the format year.month.day',
        'end_date':'$The year, month, and day the battle ended, output in the format year.month.day',
        'result':'$Outcome of the battle for the French army',
      ),
      $If there are multiple battles, repeat the same format
    )
  )
)
---

Human: {input}
AI:"""

プロンプト、並びに入力文はDeepLで訳し、ちょっぴり修正をかけている。入力文は以下の通り。

output = llm_chain.predict(input = """First Italian Expedition (First Grand Alliance against France)
With the outbreak of the French Revolutionary War in 1792, the First Grand Alliance against France was formed in 1793 by Britain, Austria, Prussia, Spain, and others. In this battle, the French presidential government planned to surround and capture Austria with two armies from the Rhine and one from northern Italy.
In March 1796, Napoleon Bonaparte, appointed commander-in-chief of the Italian armies, went on the offensive. First, he surrendered the Kingdom of Sardinia in only one month, which had been confronting the French army on the front line, and besieged Mantova, the stronghold of the Austrian army. The Austrian army launched a counterattack to liberate Mantova, but was defeated by Napoleon at the battles of Castiglione (August 5), Arcole (November 15-17), and Rivoli (January 14, 1797). Austria offered a truce, and the Peace of Leoben was concluded on April 18.
On October 17, France and Austria conclude the Treaty of Campo Formio. France annexed the southern Netherland and the left bank of the Rhine, and French satellite states such as the Republic of Cisalpina were established in northern Italy. The First Grand Alliance with France collapsed with the fall of Austria.""")

そして結果である。

{
  'event': {
    'name': 'First Italian Expedition',
    'start_year': 1796,
    'end_year': 1797,
    'related_persons': ['Napoleon Bonaparte'],
    'related_events': {
      'event': [
        {
          'name': 'French Revolutionary War',
          'summary': 'War between France and various European powers from 1792-1802'
        },
        {
          'name': 'Peace of Leoben',
          'summary': 'Truce between France and Austria signed on April 18, 1797'
        },
        {
          'name': 'Treaty of Campo Formio',
          'summary': 'Treaty between France and Austria signed on October 17, 1797'
        }
      ]
    },
    'details': {
      'place': ['Rhine', 'Mantova'],
      'related_nations': ['Austria', 'Kingdom of Sardinia'],
      'battle': [
        {
          'name': 'Battle of Castiglione',
          'start_date': '1796.08.05',
          'end_date': '',
          'result': 'French victory'
        },
        {
          'name': 'Battle of Arcole',
          'start_date': '1796.11.15',
          'end_date': '1796.11.17',
          'result': 'French victory'
        },
        {
          'name': 'Battle of Rivoli',
          'start_date': '1797.01.14',
          'end_date': '',
          'result': 'French victory'
        }
      ]
    }
  }
}

むー、日本語でやった場合よりも微妙な結果になったぞ?
related_nationsがなんか足りてないし、battleのend_dateも欠けている。これはたまたまなのか、英語訳がおかしかったのか、なんとも言えないが、やや所望の結果とは異なってしまった。残念である。

まとめ

プロンプト内に例示を直接埋め込む形式でも一定のニーズは満たせるが、今回実験したように、フォーマットを抽象化することでも目的が達せられることが分かった。この手法とLangChainのfew-shot-exampleを組み合わせるとよりよくなる可能性がある。

今回はそこまでやっていないが、プロンプトの工夫は有効だということがわかった。
なんにせよ、深津式プロンプトは偉大である。

この記事が気に入ったらサポートをしてみませんか?