All Articles

LLMを利用して自由記述文書タグ付けする

はじめに

LLMは本当に様々なことに利用することができますが最近LLMを利用して自由記述のデータに決まったフォーマットのタグをつける方法について調べることがありました。 LLMのアウトプットは自然言語として得られるわけですが、タグ付けを行う場合は機械にとって取り扱いやすいフォーマットで出力されることを強制してやる必要があります。 方法を調査していたところLlamaIndexのExampleのReflection Workflow for Structured Outputsが使えたのでこれを利用した自由記述のデータのタグ付けの方法についてまとめました。

タグ付けデータ

何のデータをタグ付けするか悩んだんですが、最近黒神話:悟空というゲームが話題になっていてきになっていたので、このゲームのSteam Reviewをタグ付けしてみます。

Steamworks

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の定義

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の定義に反しているかが記述されています。

Reflection Workflow

そもそも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の不備に気づいたのでプルリクを出しました。

Published Aug 23, 2024

スタートアップで働くデータエンジニア兼データサイエンティスト。興味の範囲はデータパイプラインの構築、データ分析、機械学習、クラウドなどなど。