ChatGPT で抽出 & フォーマットのタスクで安定して出力させるテクニック

この記事は [前回の記事：ChatGPT で抽出 & フォーマットのタスクをするときのテクニック
](https://qiita.com/ogatango/items/3ef5e7e87f66ed971411)の続きです。

# ChatGPT で抽出 & フォーマットのタスクで安定して出力させるテクニック

スクレイピングしたテキストから特定の文字列を抽出するときに、正規表現等を使う代わりに ChatGPT を使うととても簡単に実装できてしまうことに感動しました。
前回の記事である程度テクニックがわかりましたが、少量のデータに対してしか使っていなかったため、どれくらいの確率でうまくいくのかはわかりませんでした。
今回は ChatGPT でに入力するテキストを追加し、更に検証した際に分かったことを書きます。
もう少し詳しい背景については[前々回の記事：スクレイピングした文字列を ChatGPT で簡単にフォーマットする](https://qiita.com/ogatango/items/df7b42e05a5852c665d0)を参照してください。

### なぜこの記事を書こうと思ったか
「ChatGPT で〜してみた」とか「ChatGPT でこんなことができる」という記事はたくさんありますが、単発でたまたまうまくいったり失敗した例になっているものが多いように思えます。
もっと踏み込んでどんな prompt とテキストの組み合わせの場合に、どれくらいの確率で意図したものになるか検証した記事があればいいのになあという思いがあり、まずは自分で書いてみることにしました。


## テクニックを使う前の課題

テクニックを使う前は次のような点に困っていました。

### キーがなくなる

該当する情報がないときにそのキーが落ちてしまうことがありました。少なくとも僕の使用するプログラムでは大した問題にはなりませんが、意図しない出力になってしまいました。

### JSON 以外の情報が付与される

例えば該当する情報がないときに、親切に `program には該当する情報がなかったため抽出していません` といったテキストが JSON のあとに出てくることがありました。これではパースすることができなくなります。

### 元のテキストを改変する

演奏者名などの漢字が微妙に変わって出力されることがありました。

### 該当する情報がないときの出力が安定しない

該当する情報がないときは安定して `null` になってほしいのですが、 json の入れ子になる部分で `[]` になったり {"player_name": null, "role": null}` になってしまいます。

## テクニックを使った指示
以下で説明するポイントに気をつけると上記の問題をおおむね解決できました。
次のようなプログラムが一番安定した結果を作ってくれています。


```python
# ポイント1
command = """次のフォーマットで値を抽出せよ。
{
  "player":[{"player_name": 演奏者名, "role": 楽器名もしくは役割}],
  "program": [{"composer": 作曲者, "music": 曲名}],
  "title": コンサートのタイトル,
  "concert_date": コンサートの開催日でフォーマットは 0000-00-00,
  "open_time": コンサートの開演時間でフォーマットは 00:00,
  "genre": コンサートのジャンル。次のジャンルから選択。[classic,game,jazz,anime,movie,pops,ballet,musical],
  "format": コンサートの演奏形態。次から選択。[orchestra,chorus,solo,chambermusic,brassband,opera,band],
  "price":{チケット種別: チケット料金で単位を付けない数値型}
}
キーは必ず含ませる。
JSON以外の情報は削除する。
player,program,title,priceには元のテキストに含まれる文字列だけを値として使う。
該当する情報がない場合 null にする。
"""

# ポイント2
empty_response = """{
    "player": null,
    "program": null,
    "title": null,
    "concert_date": null,
    "open_time": null,
    "genre": null,
    "format": null,
    "price": null
}"""

ask = """
日時
2023年3月18日（土） 15:00開演
料金
友の会 優待価格
A 5,800円
一般価格
A 6,300円　B 5,300円　C 4,200円　BOX 7,400円

指揮／カーチュン・ウォン
ヴァイオリン／パトリツィア・コパチンスカヤ
曲目／ハルトマン：葬送協奏曲
　　　ラヴェル：ツィガーヌ
　　　ベルリオーズ：幻想交響曲 作品14
"""
answer = """{
    "player":[
        {"player_name": "パトリツィア・コパチンスカヤ", "role": "ヴァイオリン"},
        {"player_name": "カーチュン・ウォン", "role": "指揮"}
        ],
    "program": [{"composer": "ハルトマン", "music": "葬送協奏曲"}, {"composer": "ラヴェル", "music": "ツィガーヌ"}, {"composer": "ベルリオーズ", "music": "幻想交響曲 作品14"}],
    "title": null,
    "concert_date": "2023-03-18",
    "open_time": "15:00",
    "genre": "classic",
    "format": "orchestra",
    "price":{
        "友の会 優待価格 A": 5800,
        "一般価格 A": 6300,
        "一般価格 B": 5300,
        "一般価格 C": 4200,
        "一般価格 BOX": 7400
    }
}"""


response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    temperature=0, # ポイント3
    messages=[
        {
            "role": "system",
            "content": command,
        },
        {
            "role": "user",
            "content": "",
        },
        {
            "role": "assistant",
            "content": empty_response,
        },
        {
            "role": "user",
            "content": ask1,
        },
        {
            "role": "assistant",
            "content": answer1,
        },
        {
            "role": "user",
            "content": text,
        },
    ],
)
```

## ポイント 1

出力したいフォーマットを直接指定しています。

```python
command = """次のフォーマットで値を抽出せよ。
{
  "player":[{"player_name": 演奏者名, "role": 楽器名もしくは役割}],
  "program": [{"composer": 作曲者, "music": 曲名}],
  "title": コンサートのタイトル,
  "concert_date": コンサートの開催日でフォーマットは 0000-00-00,
  "open_time": コンサートの開演時間でフォーマットは 00:00,
  "genre": コンサートのジャンル。次のジャンルから選択。[classic,game,jazz,anime,movie,pops,ballet,musical],
  "format": コンサートの演奏形態。次から選択。[orchestra,chorus,solo,chambermusic,brassband,opera,band],
  "price":{チケット種別: チケット料金で単位を付けない数値型}
}
キーは必ず含ませる。
JSON以外の情報は削除する。
player,program,title,priceには元のテキストに含まれる文字列だけを値として使う。
該当する情報がない場合 null にする。
```

- キーとなる文字列についてはダブルクオートで囲い、抽出した値を代入してほしいところには日本語でその内容を指定しています。
- JSON 全体の細かい指示を末尾に加えています。
  - 「キーを必ず含ませる」という指示がない場合、該当する情報がないときにキーがなくなります。
  - 「JSON 以外の情報は削除する」という指示がない場合、該当する情報がないときにその理由を足してくることがあります。
  - 「player,program,title,price には元のテキストに含まれる文字列だけを値として使う」という指示がない場合、テキストが微妙に改変されることがあります。
  - 「該当する情報がない場合 null にする」という指示で該当する情報がない場合の値を指定しています。

このように指定したいフォーマット + 補足の指示 で prompt を与えると、指定したフォーマット通りに出力しやすくなりました。また人間としても prompt が読みやすいというメリットがあります。

## ポイント 2

抽出する情報が全く無いときに返すべき JSON を与えます。

```python
empty_response = """{
    "player": null,
    "program": null,
    "title": null,
    "concert_date": null,
    "open_time": null,
    "genre": null,
    "format": null,
    "price": null
}"""
# ~略~
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    temperature=0, # ポイント3
    messages=[
# ~略~
        {
            "role": "user",
            "content": "",
        },
        {
            "role": "assistant",
            "content": empty_response,
        },
    ]
# ~略~
```

このように具体例を与えることでより正確に null を返しやすくになりました。この指示の有無でどれくらいの変わるかは後ほど紹介します。
なおこの方法を使うにあたってこちらの記事を参考にさせていただきました！

https://qiita.com/naosugi1987/items/1e5349424e4ed878c84d

## ポイント３

```python
response = openai.ChatCompletion.create(
    model="gpt-3.5-turbo",
    temperature=0, # ポイント3
# ~略~
    )
```

temperature に 0 を指定しました。

[公式ドキュメント](https://platform.openai.com/docs/api-reference/completions/create) には次のように書いてあります。

```
What sampling temperature to use, between 0 and 2. Higher values like 0.8 will make the output more random, while lower values like 0.2 will make it more focused and deterministic.

We generally recommend altering this or top_p but not both.
```

数値が高いほどランダム性がますようです。
今回は決まったフォーマットにしたいので temperature は 0 に設定するのが妥当でしょう。

なお top_p というパラメータもあり、こちらは次のように書いてあります。

```
An alternative to sampling with temperature, called nucleus sampling, where the model considers the results of the tokens with top_p probability mass. So 0.1 means only the tokens comprising the top 10% probability mass are considered.

We generally recommend altering this or temperature but not both.
```

probability mass 上位 top_p からなるトークンだけを考慮するらしいです。temperature とどちらかしか設定しないことを推奨されています。
top_p でも検証しましたが、今回のタスクとしては temperature を 0 にするのが妥当と考え、 top_p は使用しません。

## 各ポイントの有無による検証結果

temperature や top_p, 空のテキストを用いた assistant があるかないかで、該当する情報がないときの出力がどれくらいうまくいくかを検証しました。

今回の目的は空の出力
具体的な検証内容は以下の通りです。
player と program の出力について、

- うまくいったとみなす値
  - `null`
- 失敗したとみなす値
    - `[]`
    - `[{"player_name": null, "role": null}]`
    - `[{"composer": null, "music": null}]`
- 計算の対象外の値
    - 上記以外の値

結果は以下のとおりです。

| temperature | top_p      | 空のテキストの場合の assistant | うまくいった確率 |
| ----------- | ---------- | ------------------------------ | ---------------- |
| 0           | default(1) | あり                           | 105/107= 98%     |
| 0           | default(1) | なし                           | 135/148 = 91%    |
| 0           | 0.1        | あり                           | 106/107 = 99%    |
| default(1)  | 0.1        | あり                           | 106/108 = 98%    |

ポイント 2 で説明した空のテキストの場合の assistant があるかないかでうまく出力する確率が大きく変わっています。該当する情報がない場合の例を与えるという方法は不完全ながらもうまく出力する確率を上げてくれるようです。
結果的に temperature と top_p を両方とも指定する場合が一番うまくいっていますが、公式ドキュメントで併用しないように書かれているため、実際には一番上の行のやり方を採用します。

## まとめ
簡単な検証ですが、該当する情報がない場合の例を assistant に与えることで出力が安定することがわかりました。
もっと本格的にデータを増やしたりプロンプトの組み合わせを試してみたい気持ちもありますが、手間と金がかかるためとりあえずこれくらいで記事を書きました。
コメントで要望があれば検証するかもしれません。