LLMは本当に様々なことに利用することができますが最近LLMを利用して自由記述のデータに決まったフォーマットのタグをつける方法について調べることがありました。 LLMのアウトプットは自然言語として得られるわけですが、タグ付けを行う場合は機械にとって取り扱いやすいフォーマットで出力されることを強制してやる必要があります。 方法を調査していたところLlamaIndexのExampleのReflection Workflow for Structured Outputsが使えたのでこれを利用した自由記述のデータのタグ付けの方法についてまとめました。
何のデータをタグ付けするか悩んだんですが、最近黒神話:悟空というゲームが話題になっていてきになっていたので、このゲームのSteam Reviewをタグ付けしてみます。
SteamのReview情報はSteamworksのAPIを使用することで取得することができます。 Review取得のためのAPIの使用はこのDocumentに書かれています。 今回はこのAPIでSteamのReviewを取得しました。
コードは以下の通りです。
import requests
appid = "2358720"
api_url = f"https://store.steampowered.com/appreviews/{appid}"
r = requests.get(api_url, params={"json": 1, "num_per_page": 100})
reviews = r.json()["reviews"]
試しにReviewの内容を確認してみると以下のようになっていました。
import pprint
pprint.pprint(reviews[0]["review"])
('Honestly I kept my expectations low because it seemed too good to be true '
'especially being the first game from the studio but god damn they nailed it. '
'The combat I would say is more akin to something like God of War rather than '
'a soulslike. It feels great to play, very responsive, a lot of different '
'ways to play. The graphics are fantastic and the game runs well, have not '
'had any crashes so far.')
Tagを定義するためにPydanticを利用します。 PydanticはData Validationに使われるLibraryです。 PydanticはFastAPIでやり取りされるDataの型の定義に使われるため使ったことがある方も多いと思いますが、データ処理の際にデータが期待している型であることを保障するために使っても便利です。
ゲームを評価する際の項目は様々ですが今回はこの評価項目を使うことにしました。
from pydantic import BaseModel
from enum import Enum
class Scenario(Enum):
good = "good"
bad = "bad"
neutral = "neutral"
unknown = "unknown"
class Gameplay(Enum):
good = "good"
bad = "bad"
neutral = "neutral"
unknown = "unknown"
class Graphics(Enum):
good = "good"
bad = "bad"
neutral = "neutral"
unknown = "unknown"
class Music(Enum):
good = "good"
bad = "bad"
neutral = "neutral"
unknown = "unknown"
class ReviewSummary(BaseModel):
scenario: Scenario
gameplay: Gameplay
graphics: Graphics
music: Music
PydanticのModelにはmodel_validate_json
というMethodが存在しこれを使うと与えたstringが定義したModelとして解釈できるか判別することができます。
例えば定義通りのInputを与えると
ReviewSummary.model_validate_json('{"scenario": "good", "gameplay": "good", "graphics": "good", "music": "unknown"}')
ReviewSummary.model_validate_json('{"scenario": "good", "gameplay": "good", "graphics": "good", "music": "unknown"}')
このように正常に処理が行われますが、次のように定義から外れたInputを与えると
ReviewSummary.model_validate_json('{"scenario": "great", "gameplay": "good", "graphics": "good", "music": "unknown"}')
---------------------------------------------------------------------------
ValidationError Traceback (most recent call last)
Cell In[11], line 1
----> 1 ReviewSummary.model_validate_json('{\"scenario\": \"great\", \"gameplay\": \"good\", \"graphics\": \"good\", \"music\": \"unknown\"}')
File ~/work5/steam-review-analysis/.venv/lib/python3.12/site-packages/pydantic/main.py:597, in BaseModel.model_validate_json(cls, json_data, strict, context)
595 # `__tracebackhide__` tells pytest and some other tools to omit this function from tracebacks
596 __tracebackhide__ = True
--> 597 return cls.__pydantic_validator__.validate_json(json_data, strict=strict, context=context)
ValidationError: 1 validation error for ReviewSummary
scenario
Input should be 'good', 'bad', 'neutral' or 'unknown' [type=enum, input_value='great', input_type=str]
For further information visit https://errors.pydantic.dev/2.8/v/enum"
このようにErrorが発生します。そしてこのError MessageにはInputのどこがModelの定義に反しているかが記述されています。
そもそもLLMのOutputは自然言語であるためjsonとして解釈できるOutputが得られることは保障されません。 そのうえでプラグイン的にOutputの型を強制するためにReflection Workflowを使い、LLMのOutputが要求する型に従うまでLLMに修正させるWorkflowを構築します。
前SectionでPydanticを使うとStringがModelの定義に従っているかをValidationして、従っていない場合はどこが間違っているかがError Messageとして得られることを確認しました。
Reflection WorkflowではLLMのOutputをValidationし、問題なければそのまま返し、問題があればError情報を添えてLLMに送り返しValidationが成功するまでLoopします。
from llama_index.core.workflow import Event
from llama_index.core.workflow import (
Workflow,
StartEvent,
StopEvent,
Context,
step,
)
from llama_index.llms.groq import Groq
from llama_index.llms.ollama import Ollama
class ExtractionDone(Event):
output: str
review: str
class ValidationErrorEvent(Event):
error: str
wrong_output: str
review: str
EXTRACTION_PROMPT = """
The game review is below:
---------------------
{review}
---------------------
Given the review and not prior knowledge, create a JSON object from the information in the review.
The JSON object must follow the JSON schema:
{schema}
"""
REFLECTION_PROMPT = """
You already created this output previously:
---------------------
{wrong_answer}
---------------------
This caused the JSON decode error: {error}
Try again, the response must contain only valid JSON code. Do not add any sentence before or after the JSON object.
Do not repeat the schema.
"""
class ReflectionWorkflow(Workflow):
max_retries: int = 3
@step(pass_context=True)
async def extract(
self, ctx: Context, ev: StartEvent | ValidationErrorEvent
) -> StopEvent | ExtractionDone:
current_retries = ctx.data.get("retries", 0)
if current_retries >= self.max_retries:
return StopEvent(result="Max retries reached")
else:
ctx.data["retries"] = current_retries + 1
if isinstance(ev, StartEvent):
review = ev.get("review")
if not review:
return StopEvent(result="Please provide some text in input")
reflection_prompt = ""
elif isinstance(ev, ValidationErrorEvent):
review = ev.review
reflection_prompt = REFLECTION_PROMPT.format(
wrong_answer=ev.wrong_output, error=ev.error
)
#llm = Ollama(model="llama3.1", request_timeout=30)
llm = Groq(model="llama3-8b-8192", request_timeout=30)
prompt = EXTRACTION_PROMPT.format(
review=review, schema=ReviewSummary.schema_json()
)
if reflection_prompt:
prompt += reflection_prompt
output = await llm.acomplete(prompt)
time.sleep(2)
return ExtractionDone(output=str(output), review=review)
@step()
async def validate(
self, ev: ExtractionDone
) -> StopEvent | ValidationErrorEvent:
try:
ReviewSummary.model_validate_json(ev.output)
except Exception as e:
print("Validation failed, retrying...")
return ValidationErrorEvent(
error=str(e), wrong_output=ev.output, review=ev.review
)
return StopEvent(result=ev.output)
Workflowはこのように実行します。
w = ReflectionWorkflow(timeout=120, verbose=True)
ret = await w.run(
review=reviews[0]["review"]
)
print(ret)
すると以下のようなOutputを得ることができます。
{
"scenario": "good",
"gameplay": "good",
"graphics": "good",
"music": "unknown"
}
Pydanticで定義したModel通りになっていますし、元文章と見比べると文章の内容道理の評価になっているように見えます。
この処理を取得したReview100件すべてについて行ってみましょう。
summary_list = []
for review in reviews:
w = ReflectionWorkflow(timeout=120, verbose=True)
ret = await w.run(
review=review["review"]
)
summary_list.append(ret)
summary = pd.DataFrame([json.loads(i) for i in summary_list])
処理結果についてそれぞれの評価項目の内容をカウントしてみましょう。
summary["scenario"].value_counts()
scenario
good 68
bad 14
neutral 12
unknown 6
Name: count, dtype: int64
summary["gameplay"].value_counts()
gameplay
good 72
bad 16
neutral 8
unknown 4
Name: count, dtype: int64
summary["graphics"].value_counts()
graphics
good 81
unknown 8
bad 7
neutral 4
Name: count, dtype: int64
summary["music"].value_counts()
music
unknown 78
good 21
neutral 1
Name: count, dtype: int64
今回取得したReviewについてはシナリオ、ゲーム性、グラフィックについてはポジティブな評価が多く、音楽については言及が少ないということが分かりました。 総合的にはやはりおもしろいゲームなのではないかという気がしてきます。
ちなみに今回はLLMにGroqでホスティングされているLama3を利用しました。Groqを選んだのは手軽に使えて無料で早いからです。ここは自前でOllamaを立ててもいいですしChatGPTのAPIを使用するとかでもOKです。
Groqを使う場合はサービスにサインアップしてアクセスキーを発行し、環境変数 GROQ_API_KEY
に設定しておく必要があります。
自由記述文書をLLMを使用して解析し、あらかじめ指定した型でタグ付けする方法をまとめました。 この手のセンティメント分析はLLM登場まえだと人間がルールベースのアルゴリズムを考えたり、機械学習アルゴリズムを設計して学習する必要がありました。
今回見た通りLLMを利用すると評価項目は人手で設定しましたがそれ以外はLLMにお任せでデータを処理しました。さらに言えば評価項目でさえLLMに相談して決めることだってできます。 LLMのおかげで自由記述の文書を機械的に分類するのが本当に簡単になったんだなということを実感できました。
今回の検証を行う際、Exampleの不備に気づいたのでプルリクを出しました。