Zenn
Closed9

OpenAI の Structured Outputs を試す

kun432kun432

公式ドキュメントに従ってやってみる。Colaboratoryで。

事前準備

!pip install openai
!pip freeze | grep -i openai
openai==1.40.1
from google.colab import userdata
import os

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

Structured Output

https://platform.openai.com/docs/guides/structured-outputs/introduction

Pythonで。

from pydantic import BaseModel
from openai import OpenAI

client = OpenAI()

class CalendarEvent(BaseModel):
    name: str
    date: str
    participants: list[str]

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "イベント情報を抽出して。日本語で。"},
        {"role": "user", "content": "アリスとボブは金曜日に科学フェアに行きます。"},
    ],
    response_format=CalendarEvent,
)

event = completion.choices[0].message.parsed
print(event)

OpenAI().beta.chat.completions.parseってのがStructured Outputで出力するためのメソッドで、記述の通りベータっぽい。で、出力時のスキーマをPydandicクラスで定義、それをresponse_formatで渡すと、そのスキーマにあわせて出力される。

name='科学フェア' date='金曜日' participants=['アリス', 'ボブ']

結果は辞書で取り出せる

event.dict()
{'name': '科学フェア', 'date': '金曜日', 'participants': ['アリス', 'ボブ']}

いくつかの例が上げられている

CoT

CoTによる数学の計算

from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint

client = OpenAI()

class Step(BaseModel):
    explanation: str
    output: str

class MathReasoning(BaseModel):
    steps: list[Step]
    final_answer: str

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは親切な数学の家庭教師です。ステップバイステップで、ユーザが問題を解くのを導いてください。"},
        {"role": "user", "content": "8x + 7 = -23 はどうやって解けばいいの?"}
    ],
    response_format=MathReasoning,
)

math_reasoning = completion.choices[0].message.parsed
pprint(math_reasoning.dict())
{'final_answer': 'x = -\\frac{15}{4}',
 'steps': [{'explanation': 'まず、8x + 7 = -23 の方程式を解くためには、x '
                           'の項を片側に、定数項を反対側に分ける必要があります。これを行うために、等式の両側から7を引きます。',
            'output': '8x + 7 - 7 = -23 - 7'},
           {'explanation': '7を引くことで、方程式は8x = -23 - 7になります。',
            'output': '8x = -30'},
           {'explanation': '次に、xを解くために、両側を8で割ります。',
            'output': '8x / 8 = -30 / 8'},
           {'explanation': '左側はxを残し、右側は分数になります。この段階で、分数の単純化が可能です。',
            'output': 'x = -30 / 8'},
           {'explanation': '分数 -30/8 は約分できます。 30と8の最大公約数4を使って約分します。',
            'output': 'x = -15 / 4'},
           {'explanation': 'したがって、方程式 8x + 7 = -23 の解は x = -\\frac{15}{4} である。',
            'output': 'x = -\\frac{15}{4}'}]}

構造化データの抽出

論文を構造化データとして抽出。論文データを持ってくるのが面倒だったので、参考程度に。やってることは冒頭の例と基本的に同じ。

from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint

client = OpenAI()

class ResearchPaperExtraction(BaseModel):
    title: str
    authors: list[str]
    abstract: str
    keywords: list[str]

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは構造化データ抽出のエキスパートである。研究論文から構造化されていないテキストを与えられ、それを与えられた構造に変換しなければならない。"},
        {"role": "user", "content": "[ここに論文のテキストを入れる]"}
    ],
    response_format=ResearchPaperExtraction,
)

research_paper = completion.choices[0].message.parsed
pprint(research_paper.dict())

UIの生成

ユーザー情報を入力するフォームのUIを生成する。なるほど、Pydanticのこういう使い方はぜんぜん頭になかった。

from enum import Enum
from typing import List
from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint

client = OpenAI()

class UIType(str, Enum):
    div = "div"
    button = "button"
    header = "header"
    section = "section"
    field = "field"
    form = "form"

class Attribute(BaseModel):
    name: str
    value: str

class UI(BaseModel):
    type: UIType
    label: str
    children: List["UI"] 
    attributes: List[Attribute]

UI.model_rebuild() # これは再帰型を有効にするために必要

class Response(BaseModel):
    ui: UI

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "あなたは日本語UIジェネレーターAIです。ユーザーの入力をUIに変換します。"},
        {"role": "user", "content": "ユーザーのプロフィールを入力するフォームを作成して。"}
    ],
    response_format=Response,
)

ui = completion.choices[0].message.parsed
pprint(ui.dict())
{'ui': {'attributes': [{'name': 'method', 'value': 'post'},
                       {'name': 'action', 'value': '/submit-profile'}],
        'children': [{'attributes': [{'name': 'type', 'value': 'text'},
                                     {'name': 'placeholder',
                                      'value': '名前を入力してください'}],
                      'children': [],
                      'label': '名前',
                      'type': <UIType.field: 'field'>},
                     {'attributes': [{'name': 'type', 'value': 'email'},
                                     {'name': 'placeholder',
                                      'value': 'メールアドレスを入力してください'}],
                      'children': [],
                      'label': 'メールアドレス',
                      'type': <UIType.field: 'field'>},
                     {'attributes': [{'name': 'type', 'value': 'tel'},
                                     {'name': 'placeholder',
                                      'value': '電話番号を入力してください'}],
                      'children': [],
                      'label': '電話番号',
                      'type': <UIType.field: 'field'>},
                     {'attributes': [{'name': 'type', 'value': 'text'},
                                     {'name': 'placeholder',
                                      'value': '住所を入力してください'}],
                      'children': [],
                      'label': '住所',
                      'type': <UIType.field: 'field'>},
                     {'attributes': [{'name': 'type', 'value': 'submit'}],
                      'children': [],
                      'label': '送信',
                      'type': <UIType.button: 'button'>}],
        'label': 'ユーザープロフィールフォーム',
        'type': <UIType.form: 'form'>}}

モデレーション

入力内容のモデレーション

from enum import Enum
from typing import Optional
from pydantic import BaseModel
from openai import OpenAI
from pprint import pprint

client = OpenAI()

class Category(str, Enum):
    violence = "violence"
    sexual = "sexual"
    self_harm = "self_harm"

class ContentCompliance(BaseModel):
    is_violating: bool
    category: Optional[Category]
    explanation_if_violating: Optional[str]

completion = client.beta.chat.completions.parse(
    model="gpt-4o-2024-08-06",
    messages=[
        {"role": "system", "content": "ユーザーの入力が特定のガイドラインに違反しているかどうかを判断し、違反している場合はそれを説明してください。"},
        {"role": "user", "content": "面接の準備はどうすればいいでしょうか?"}
    ],
    response_format=ContentCompliance,
)

compliance = completion.choices[0].message.parsed
pprint(compliance.dict())
{'category': None, 'explanation_if_violating': None, 'is_violating': False}
kun432kun432

JSONモードとの違い

  • Structured Outputは指定されたスキーマに必ず従って出力される。JSONモードはJSONで出力するがスキーマは保証されない。
  • Structured Outputは、現時点で最新のgpt-4o、gpt-4o-mini 以降のモデルでしか使えない
  • Function Callingで使う場合、JSONモードは常に有効になるが、Structured Outputは関数定義にstrict: trueが定義された場合のみ
  • Parallel Function Callingは、JSONモードではサポート、Structured Outputではサポートされていない

Structured Outputs の使い分け

Structured Outputsは2つの使い方がある

  • function callingで使う
  • response_formatパラメータで使う

どちらを使うべきか?はざっくり以下とまとめられていた

  • モデルをシステム内のツール、関数、データなどに接続する場合は、function callingを使用する。
  • モデルがユーザーに応答するときの出力を構造化したいのであれば、構造化されたresponse_formatを使うべき

JSONモードだとフォーマットは破綻しないがスキーマが期待通りにはならない、function callingの場合は本来的にはツールとの連携がメインの目的だが、スキーマを指定できるので、これを使って出力制御だけに使う、みたいなやり方があったのが、単に出力制御が目的だけならresponse_formatパラメータを使えばいいという感じ。

kun432kun432

Function Callingで使う場合はこちら

https://platform.openai.com/docs/guides/function-calling

単に関数定義に"strict": True をつけるだけっぽい。

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_delivery_date",
            "description": "顧客の注文の配達日を取得する。例えば、顧客から「私の荷物はどこですか」と尋ねられたときなど、配達日を知る必要がある場合に呼ぶ。",
            "parameters": {
                "type": "object",
                "properties": {
                    "order_id": {
                        "type": "string",
                        "description": "顧客の注文ID",
                    },
                },
                "required": ["order_id"],
                "additionalProperties": False,
            },
        },
        "strict": True,
    }
]

SDKの場合はresponse_formatでスキーマを指定すると、内部で"strict": Trueを付与してる模様。

kun432kun432

やればやるほど、Pydanticをきちんとやっておく必要性を痛感している。前にGetting Startedやってみただけ、あとは雰囲気で使ってるだけ・・・改めてやってみるつもり。

kun432kun432

そういえばストリーミングを確認していなかった。

https://platform.openai.com/docs/guides/structured-outputs/introduction?context=without_parse&lang=python#streaming

from typing import List
from pydantic import BaseModel
from openai import OpenAI

class EntitiesModel(BaseModel):
    attributes: List[str]
    colors: List[str]
    animals: List[str]

client = OpenAI()

with client.beta.chat.completions.stream(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "system",
            "content": "入力テキストからエンティティを抽出して。"},
        {
            "role": "user",
            "content": "茶色いキツネは青い瞳の怠け者の犬を飛び越える。",
        },
    ],
    response_format=EntitiesModel,
) as stream:
    for event in stream:
        if event.type == "content.delta":
            if event.parsed is not None:
                # パースされたデータをJSONで出力
                print("パースされた差分コンテンツ:", event.parsed)
        elif event.type == "content.done":
            print("コンテンツ生成終了")
        elif event.type == "error":
            print("ストリームでエラーが発生しました:", event.error)

final_completion = stream.get_final_completion()
print("最終結果:")
pprint(final_completion.choices[0].message.parsed.model_dump())
出力
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {'attributes': []}
パースされた差分コンテンツ: {'attributes': []}
パースされた差分コンテンツ: {'attributes': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': []}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ', '犬']}
パースされた差分コンテンツ: {'attributes': [], 'colors': ['茶色', '青'], 'animals': ['キツネ', '犬']}
コンテンツ生成終了
最終結果:
{'animals': ['キツネ', '犬'], 'attributes': [], 'colors': ['茶色', '青']}

チャットのレスポンスに感情を追加した出力フォーマットを指定してストリーミングを試してみた。

from typing import List
from pydantic import BaseModel
from openai import OpenAI
from enum import Enum


class Sentiment(str, Enum):
    happy = "happy"
    angry = "angry"
    sad = "sad"
    fun = "fun"


class ResponseWithSentiment(BaseModel):
    message: List[str]
    sentiment: Sentiment


client = OpenAI()

with client.beta.chat.completions.stream(
    model="gpt-4o-mini",
    messages=[
        {
            "role": "system",
            "content": "あなたは親切な日本語のアシスタントです。"},
        {
            "role": "user",
            "content": "おはよう。。。昨日は競馬ボロ負けしちゃったよ。。。。",
        },
    ],
    response_format=ResponseWithSentiment,
) as stream:
    for event in stream:
        if event.type == "content.delta":
            if event.parsed is not None:
                # パースされたデータをJSONで出力
                print("パースされた差分コンテンツ:", event.parsed)
        elif event.type == "content.done":
            print("コンテンツ終了")
        elif event.type == "error":
            print("ストリームでエラーが発生しました:", event.error)

final_completion = stream.get_final_completion()
print("最終結果:")
pprint(final_completion.choices[0].message.parsed.model_dump())
出力
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': []}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。']}
パースされた差分コンテンツ: {'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。'], 'sentiment': 'sad'}
コンテンツ終了
最終結果:
{'message': ['おはようございます!', '競馬で負けてしまったのは残念ですね。'],
 'sentiment': <Sentiment.sad: 'sad'>}

文字列をストリーミングで扱いたい場合は、フォーマット定義する際にリストで指定するのが良さそう。文字列で定義するとこうなる。

(snip)
class ResponseWithSentiment(BaseModel):
    message: str
    sentiment: Sentiment
(snip)
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。'}
パースされた差分コンテンツ: {'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。', 'sentiment': 'sad'}
コンテンツ終了
最終結果:
{'message': 'それは残念でしたね。競馬は時に予測が難しいですから、次はうまくいくことを願っています!また挑戦して楽しんでくださいね。',
 'sentiment': <Sentiment.sad: 'sad'>}

ストリームの意味があまりない感じになる。

kun432kun432

余談

OpenAI SDKだと上記の通りストリーミングでもvalidなJSONオブジェクトになるんだけども、LiteLLMで試してみたら、ただのJSON「文字列」のストリーミング、つまりストリーミング途中のレスポンスはvalid-JSONではないので、ストリーミングの意味がない。

このスクラップは2024/08/08にクローズされました
ログインするとコメントできます