[NVIDIA DGX Sparkで試すAIペルソナマーケティング]
第1話 デモの概要
第2話 調査対象の抽出
第3話 質問の生成
第4話 回答の生成と収集
第5話 調査結果の可視化
第6話 まとめ
本話の内容
本話では、本デモの核心である、LLMによるアンケートへの回答について説明いたします。LLMへの入力データは、第2話で抽出した調査対象から架空人物を一人づつ取り出し、それに、アンケートの内容を加えて調査票とします。(調査票は調査対象者の人数分生成されることになります。)それらをLLMに入力して、LLMからの出力として、アンケートの回答(各質問毎に選んだ選択肢とコメント)を得ます。
LLM APIエンドポイントの立ち上げとクライアントの設定
第3話で質問を作成したときと同じ方法で、GPT-OSS-120BのAPIエンドポイントを立ち上げます。
import json
import pandas as pd
from openai import AsyncOpenAI, APIError
from survey import Survey, SurveyResponse, SurveyResponseWithInfo, SurveyResponseList
import time
from tqdm.asyncio import tqdm_asyncio
import asyncio
from IPython.display import display, Markdown
# LLM APIエンドポイントの設定
MODEL = "openai/gpt-oss-120b"
BASE_URL = "http://localhost:8000/v1"
CONCURRENCY_LIMIT = 128
# OpenAIクライアントの初期化
client = AsyncOpenAI(
base_url=BASE_URL,
api_key="None"
)
調査票の作成
第3話で作成したアンケート質問と選択肢のデータを読み出します。
# 調査票データの取得
with open("survey.json", encoding="utf-8") as f:
json_str = json.dumps(json.load(f), ensure_ascii=False)
survey_data = Survey.model_validate_json(json_str)
読み出したデータをMarkdown形式に整形します。
# 調査票の作成
questionnaire = "## 質問¥n¥n"
num = 1
for q in survey_data.questions:
questionnaire += f"### Q{num}. {q.question_text}¥n"
for idx, option in enumerate(q.choices):
questionnaire += f" {idx+1}. {option.text}¥n"
questionnaire += "¥n"
num += 1
questionnaire += "各質問に対して、選択肢の番号で回答し、自由記述欄に選択理由を追加してください。¥n"
print(questionnaire)
## 質問
### Q1. 朝の目覚めに、あなたが思い浮かべる“さわやかさ”はどのシーンですか?
1. 海辺の朝日が水平線から顔を出す瞬間
2. 高原の風が葉を揺らす音と共に吹く瞬間
3. 都会のカフェで、窓の外に映るネオンが光る瞬間
4. 秋の森の中で、落ち葉がカサカサと音を立てる瞬間
### Q2. コンビニで新しいスナックを選ぶとき、どのパッケージが最初に手に取られそうですか?
1. 淡いパステルカラーでシンプルなロゴが配置されたデザイン
2. 鮮やかなネオンカラーとダイナミックなパターンが走るデザイン
3. レトロなイラストと温かみのあるトーンでまとめられたデザイン
4. 黒を基調にメタリックなアクセントが入ったモダンなデザイン
### Q3. 友達と週末に新しいお菓子をシェアするとしたら、どんなシチュエーションが一番楽しいですか?
1. 自然の中でハイキングやピクニックをしながら
2. 屋外フェスやライブで音楽に合わせて
3. カフェでゆっくりおしゃべりしながら
4. 自宅でゲームや映画鑑賞をしながら
### Q4. テレビやSNSで有名人が商品を紹介しているとき、あなたが「信頼できる」と感じるのはどんな雰囲気ですか?
1. 自然体で笑いを交えたユーモラスな雰囲気
2. 自分の経験や感情を率直に語る誠実さ
3. 専門的な知識やスキルを活かした説得力
4. 最新トレンドを先取りするクールさ
### Q5. 新しいスナックを食べるとき、どのような音楽が流れていると最もリラックスできますか?
1. 軽快なポップス
2. リズミカルなヒップホップ
3. 落ち着いたインディー・アコースティック
4. エレガントなジャズやクラシック
各質問に対して、選択肢の番号で回答し、自由記述欄に選択理由を追加してください。
第1話で抽出した調査対象者のデータを取得して、これをMarkdown形式に整形する関数を用意します。
# ペルソナデータの取得
persona_df = pd.read_json("personas.jsonl", orient="records", lines=True)
# Pandasデータフレームのシリーズオブジェクトを受け取り、UUID列を除外してMarkdown形式のレポートを生成する関数
def generate_persona_report(ser):
report = "## ペルソナ¥n¥n"
for index, value in ser.items():
if index == "uuid":
continue
report += f"### {index}¥n"
report += f"{value}¥n¥n"
return report
Markdown形式に整形された質問と、Markdown形式に整形された調査対象者データ(6つのペルソナと、16個の属性)を連結して、対象者1名分の調査票とします。
# 調査票のテスト表示
row = persona_df.iloc[0]
persona_report = generate_persona_report(row)
user_prompt = "# グミ菓子新製品に関する消費者調査¥n¥n"
user_prompt += (persona_report + questionnaire)
# display(Markdown(user_prompt))
print(user_prompt)
# グミ菓子新製品に関する消費者調査
## ペルソナ
### professional_persona
伊藤 時生は、実務的なUI/UXデザインを中小企業のデザイン部門でリーダーシップに変換し、Figmaとデザインシステムでチームのスキル向上と柔軟なフィードバックループを構築することに注力している。彼らは、実用性を重視した提案と、協調性を活かしたクライアント調整で、成果志向と創造性のバランスを保っている。
### sports_persona
伊藤 時生は、都市部のカフェ巡りと自転車での写真撮影を組み合わせ、低インパクトながらも継続的なエクササイズルーティンを保っている。彼らは、外向性と協調性を活かし、仲間と同時に走ることを好みつつ、柔軟なスケジュールで運動負荷を調整している。
### arts_persona
伊藤 時生は、手作りの陶芸と木工で触覚的な作品を生み出し、実用的なデザインへの視覚的洞察と結びつけている。彼らは、開放性の中に伝統的な素材尊重と、協力的なワークショップでの対話を通じ、感情の起伏と創作のバランスを探求している。
### travel_persona
伊藤 時生は、街中のカフェでメニューの視覚要素を観察し、週末の自転車で新たな街区を撮影しながら、低予算でも都市探索を実践している。彼らは、外向性と柔軟性を組み合わせ、計画的なルートと即興的な立ち寄りを交互に取り入れ、都市の風景と人間関係の両面に敏感に反応している。
### culinary_persona
伊藤 時生は、カフェのメニュー構成と自作のシンプルレシピで、視覚と味覚の調和を追求し、低コストでも創造的な料理体験を提供している。彼らは、神経症傾向の敏感さから、食材の鮮度と調理手順を細部まで管理し、実用的な料理とデザインの交差点で自己表現を行っている。
### persona
伊藤 時生は、実務的デザインリーダーとして、低予算の手作業と都市自転車撮影を通じ、柔軟な計画と感情管理でチームと自己の成長を同時に追求している。
### cultural_background
関東の都市部で育ち、家族や学校での勤勉さと忍耐が重視された環境を背景に、実務的なデザインへの取り組みを大切にしている。
### skills_and_expertise
ユーザー体験を重視したUI/UXデザイン、Adobe Creative Cloud全般、Figmaでのプロトタイピング、HTML/CSSによる簡易実装、デザインシステムの構築、クライアントとの要件調整とフィードバックループの管理。
### skills_and_expertise_list
["UI/UXデザイン", "Adobe Creative Cloud", "Figmaプロトタイピング", "HTML/CSS実装", "デザインシステム構築", "要件調整・フィードバック管理"]
### hobbies_and_interests
街中のカフェ巡りでインテリアやメニューの視覚要素を観察すること、週末の自転車で都心の風景を撮影しながら構図を学ぶこと、低予算で手作りの陶芸や木工に挑戦し、実感のある成果を楽しむこと。
### hobbies_and_interests_list
["カフェ巡り・インテリア観察", "自転車撮影・風景構図研究", "手作り陶芸・木工"]
### career_goals_and_ambitions
中小企業のデザイン部門でデザインリーダーシップを発揮し、実務的かつユーザー志向のプロダクトを増やすと同時に、チームのスキル向上と働きやすい環境作りに貢献したい。
### sex
男
### age
29
### marital_status
未婚
### education_level
大学卒 文系
### occupation
デザイン 中小
### region
関東地方
### area
東日本
### prefecture
東京都
### country
日本
## 質問
### Q1. 朝の目覚めに、あなたが思い浮かべる“さわやかさ”はどのシーンですか?
1. 海辺の朝日が水平線から顔を出す瞬間
2. 高原の風が葉を揺らす音と共に吹く瞬間
3. 都会のカフェで、窓の外に映るネオンが光る瞬間
4. 秋の森の中で、落ち葉がカサカサと音を立てる瞬間
### Q2. コンビニで新しいスナックを選ぶとき、どのパッケージが最初に手に取られそうですか?
1. 淡いパステルカラーでシンプルなロゴが配置されたデザイン
2. 鮮やかなネオンカラーとダイナミックなパターンが走るデザイン
3. レトロなイラストと温かみのあるトーンでまとめられたデザイン
4. 黒を基調にメタリックなアクセントが入ったモダンなデザイン
### Q3. 友達と週末に新しいお菓子をシェアするとしたら、どんなシチュエーションが一番楽しいですか?
1. 自然の中でハイキングやピクニックをしながら
2. 屋外フェスやライブで音楽に合わせて
3. カフェでゆっくりおしゃべりしながら
4. 自宅でゲームや映画鑑賞をしながら
### Q4. テレビやSNSで有名人が商品を紹介しているとき、あなたが「信頼できる」と感じるのはどんな雰囲気ですか?
1. 自然体で笑いを交えたユーモラスな雰囲気
2. 自分の経験や感情を率直に語る誠実さ
3. 専門的な知識やスキルを活かした説得力
4. 最新トレンドを先取りするクールさ
### Q5. 新しいスナックを食べるとき、どのような音楽が流れていると最もリラックスできますか?
1. 軽快なポップス
2. リズミカルなヒップホップ
3. 落ち着いたインディー・アコースティック
4. エレガントなジャズやクラシック
各質問に対して、選択肢の番号で回答し、自由記述欄に選択理由を追加してください。
LLMへ回答リクエスト
システムプロンプトを以下のように用意します。ユーザープロンプトは上記で作成した調査票です。
system_prompt = """
あなたは一般消費者向け商品のマーケティング専門家です。
主に一般消費者からの意見をヒアリングし、分析する業務を行っています。
与えられたペルソナのつもりになって質問票に回答してください。
"""
LLMへの非同期リクエスト
ところで、LLM推論サーバーの性能に言及する際に、重要な指標として遅延とスループットがあります。遅延は、リクエストを入力してから、推論結果が出力されるまでの時間であり、スループットは単位時間に処理できるトークン数です。
チャットアプリケーションの場合は、遅延が小さいことが特に重要であり、遅延が大きいとユーザビリティが低下します。本デモのようなアプリケーションは、大量のデータを一括処理するので、遅延よりも、スループットが極めて重要です。遅延をある程度犠牲にしてでも、スループットを向上させる方法として、リクエストの並行度を増やすという方法があります。前のリクエストの推論結果が返される前に、後続のリクエストを、設定上限まで次々と推論サーバーへ投入して、複数のリクエストがバッチ処理されたり、GPUが空くことなくジョブが投入されることを期待するものです。
但し、同クラスのハードウェアでも、推論サーバーソフトウェア毎に、対応できるリクエスト並行度には差があります。この点で、本デモで採用したvLLMはとても優秀です。
以下のとおり、指定した調査対象者1名分の調査結果を得る関数を定義します。1を超えるリクエスト並行度に対応するためには、Pythonの非同期I/O機能を利用します。
async def process_row(semaphore, row, system_prompt, retries=3):
async with semaphore:
persona_report = generate_persona_report(row)
user_prompt = "# グミ菓子新製品に関する消費者調査¥n¥n"
user_prompt += (persona_report + questionnaire)
response = None
for attempt in range(retries):
try:
response = await client.responses.parse(
model=MODEL,
input=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
text_format=SurveyResponse,
temperature=0.2,
top_p=0.7,
)
responseWithInfo = SurveyResponseWithInfo(
**response.output_parsed.model_dump(),
persona=persona_report,
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens
)
except APIError as e:
print(f"APIError on attempt {attempt+1} for row {row['uuid']}: {e}")
await asyncio.sleep(2 ** attempt) # Exponential backoff
else:
break
return responseWithInfo
また、第3話で質問を作成したときと同様に、Structured Output機能を利用して、SurveyResponseクラスのオブジェクトとして回答を受け取ります。SurveyResponseクラスとその関連クラスの定義は以下のとおりです。
class ResponseItem(BaseModel):
"""調査票の1問に対する消費者の回答"""
option: int # 質問に対する選択肢の番号
comments: str # 自由記述欄
class SurveyResponse(BaseModel):
"""調査票に対する消費者の回答全体"""
responses: list[ResponseItem]
class SurveyResponseWithInfo(SurveyResponse):
"""調査票に対する消費者の回答全体とペルソナ情報"""
persona: str # 消費者のペルソナ情報
input_tokens: int # LLMへの入力トークン数
output_tokens: int # LLMからの出力トークン数
class SurveyResponseList(BaseModel):
"""調査票回答のリスト"""
survey_responses: list[SurveyResponseWithInfo]
def add_response(self, response: SurveyResponseWithInfo):
self.survey_responses.append(response)
以下のように、セマフォを利用してリクエスト並行度の上限を設定しています。実験によれば、本デモをDGX Spark上で動作させる場合、並行度(CONCURRENCY_LIMIT)は128位の値に設定するのが良いようです。
# 調査の実施
semaphore = asyncio.Semaphore(CONCURRENCY_LIMIT)
responses = await tqdm_asyncio.gather(*[process_row(semaphore, row, system_prompt)
for _, row in persona_df.iterrows()])
responses = [r for r in responses if r is not None] # Noneの結果を除外
print(f"Successfully processed {len(responses)} out of {len(persona_df)} rows.")
リクエスト並行度を上げた効果は以下のとおり確認できました。実行する度に結果は変動するので、参考情報としてご覧ください。
| リクエスト並行度 | 1000リクエスト処理時間 | 総入力トークン数 | 総出力トークン数*1 |
| 128 | 35分12秒 | 1915317 | 717160 |
| 1 | 6時間10分42秒 | 1915317 | 672107 |
[1] 同じプロンプト(本デモでは調査票の内容)を入力しても、実行する度に出力トークン数は多少変動します。
結果の保存
以下のとおり、結果をJSON形式のデータとして、ファイル保存します。
# 調査結果の保存
survey_responses = SurveyResponseList(survey_responses=responses)
survey_res_file_path = f"response{time.strftime('%Y%m%d%H%M%S')}.json"
with open(survey_res_file_path, "w", encoding="utf-8") as f:
json_data =json.loads(survey_responses.model_dump_json(ensure_ascii=False))
json.dump(json_data, f, indent=4, ensure_ascii=False)
print(f"Survey responses saved to {survey_res_file_path}")
上記処理中に処理した総入力トークン数と総出力トークン数は以下のように参照できます。上記で作成した調査票データが同じであれば、総入力トークン数は毎回同じ値になりますが、出力トークン数は毎回多少異なります。
total_input_tokens = sum([r.input_tokens for r in responses])
total_output_tokens = sum([r.output_tokens for r in responses])
print(f"Total input tokens: {total_input_tokens}")
print(f"Total output tokens: {total_output_tokens}")
Total input tokens: 1915317
Total output tokens: 717160
本話のまとめ
LLMにペルソナデータを与えてアンケートに回答させる方法について紹介いたしました。その際、推論サーバーのリクエスト並行度を上げて、スループットを最大化するテクニックについても紹介いたしました。LLMにアンケートを生成させた時と同様に、LLMのStructured Output機能を使うと、回答を容易に構造化データとして保存できるので、この後の、集計処理が楽になります。
集計処理と可視化処理については次話で解説いたします。