9月9日、KDNuggetsが「Build an AI Data Analyst That Thinks Like a Senior Analyst」と題した記事を公開した。この記事では、チャットボットが陥りがちな「データ件数を無視した即答」を防ぐため、シニアアナリストの思考プロセスを6段階のパイプラインとしてPythonで実装する手法について詳しく紹介されている。
「1件のデータで最良」と判断する罠
チャットボットに「どのプロモーションをもっと実施すべきか?」と聞くと、即座に答えを返す。しかしその答えは、わずか1件の注文をもとに最良と判断したプロモーションかもしれない。
記事が示したサンプルデータ(online_orders.csv、29行)でも同じ罠が確認できる。平均注文単位数で並べると「プロモーション4」が8.00でトップに立つが、その裏付けは注文1件のみだ。
| promotion_id | n_orders | avg_units_per_order |
|---|---|---|
| 4 | 1 | 8.00 |
| 1 | 12 | 6.42 |
| 2 | 10 | 5.50 |
| 3 | 6 | 5.17 |
この「少数データによる見かけ上の好成績」を見抜くのが、シニアアナリストの本来の仕事だ。記事ではこの判断ロジックをコードとして実装する6段階パイプラインを解説している。
6段階パイプラインの全体像
パイプラインはSeniorAnalystクラスとして実装され、Anthropic APIまたはOpenAI APIのどちらでも動作する。各ステージがLLMへの問い合わせと決定論的なコード検証を組み合わせているのが特徴だ。6つのステージは順に、①ビジネス理解、②仮説生成、③SQLプランニング、④バリデーション、⑤エグゼクティブサマリー、⑥推奨事項の出力となる。④のバリデーションのみLLMを使わず純粋なコードで実装される点が設計上の肝だ。
ステージ1:ビジネス理解(Business Understanding)
質問をテーブルが実際に回答できる形に言い換え、データの粒度と制約をLLMに整理させる。
prompt = f"""You are a senior data analyst. A stakeholder asked: "{question}"
Table: {self.table_name}
Columns: {columns}
Row count: {row_count}
Restate the stakeholder question in terms this table can actually answer.
Return JSON only: {{"restated_question": "...", "grain": "...", "limitations": ["...", "..."]}}"""
クエリを実行する前にサンプルサイズの小ささを問題として明示させる。これはヒントにすぎないが、後続の検証ステージが定量的なチェックとして補完する。
ステージ2:仮説生成(Hypothesis Generation)
テーブルに実在するカラムのみを使った、検証可能な仮説をN個生成させる。このステージはパイプライン全体の中でも特に重要な設計ポイントだ。
一般的なチャットボットは「どのプロモーションの平均注文単位数が最も高いか」という問いをそのままSQLに変換しようとする。しかしシニアアナリストは問い自体を疑う。「最も高い平均」という指標は単独では意味をなさず、「1位と2位の差が20%以上あるか」「その差は統計的に意味のある件数に裏付けられているか」といった形に問いを再構成する。
このステージでLLMに生成させる仮説は、まさにこの再構成済みの問いだ。「プロモーションAはBより平均単位数が有意に高いか」「特定のプロモーションは他より件数が著しく少なくないか」といった、後続のSQLで検証可能な命題として出力させる。仮説がカラム名と紐付いていることをプロンプトで明示的に要求することで、LLMが存在しないカラムを参照するハルシネーションを抑制している。
ステージ3:SQLプランニング
仮説をDuckDB SQLに変換する。DuckDBはサーバー不要でPythonから直接SQLを実行できる組み込み型データベースであり、ローカルのCSVファイルをそのまま対象にできる点がこのパイプラインとの相性が良い。
このとき**n_orders(グループの件数)を必ずSELECTに含めるよう指示**することが設計の核心だ。
prompt = f"""...if the query groups rows, include a COUNT(*) column named n_orders
so the result can be checked for sample size before anyone trusts it."""
LLMが生成したクエリは単純なGROUP BYではなく、CTEとウィンドウ関数を使って1位・2位のプロモーションを同一行に並べて比較する形になった。
WITH promo_sums AS (
SELECT promotion_id, SUM(units_sold) AS total_units, COUNT(*) AS n_orders
FROM online_orders GROUP BY promotion_id
),
ranked AS (
SELECT *, RANK() OVER (ORDER BY total_units DESC) AS rnk FROM promo_sums
)
SELECT
r1.promotion_id AS top_promotion_id,
r1.total_units, r1.n_orders,
r2.promotion_id AS second_promotion_id,
r2.total_units, r2.n_orders,
(r1.total_units - r2.total_units) * 1.0 / r2.total_units AS pct_difference
FROM ranked r1 JOIN ranked r2 ON r2.rnk = 2 WHERE r1.rnk = 1
ステージ4:バリデーション(Validation)——ここだけLLMを使わない
このステージはLLMではなく純粋なコードで実装される。n_ordersが最小サポート閾値(デフォルト3件)を下回るグループにlow_confidenceフラグを立てる。
def validate(self, sql_plan):
result = self.con.execute(sql_plan["sql"]).df()
if "n_orders" in result.columns:
result["low_confidence"] = result["n_orders"] < self.MIN_SUPPORT
else:
result["low_confidence"] = False
return result
「提案」ではなく「強制」として実装するためにコードで書く、という設計判断が明確に述べられている。今回のクエリ結果では、プロモーション1が12件・プロモーション2が10件でともに閾値をクリアし、フラグは立たなかった。
ステージ5:エグゼクティブサマリー
LLMにサマリーを書かせる際、low_confidenceフラグが立った行はヘッドラインに含めないよう明示的に指示する。データに裏付けられた事実のみをサマリーとして出力させることで、件数が少ない結果が「有望な発見」として誤って強調されることを防ぐ。
ステージ6:推奨事項の出力
最終ステージでは、データで支持されたアクションと支持されていないアクションを明確に分けて推奨事項を出力する。「件数が少なすぎるため判断保留」という結論もひとつの推奨として扱う点が、通常のチャットボットとの大きな違いだ。
LLMラッパーの設計
AnthropicとOpenAIの差異はLLMClientクラスに隠蔽されており、パイプライン各ステージはcomplete()メソッドを呼ぶだけでよい。Claude(Anthropic)はレスポンスがテキスト以外のコンテンツブロックを含む場合があるため、type == "text"のブロックを明示的に探す実装になっている。
LLMがJSONをコードフェンス(```json ... ```)で囲んで返す場合に備えたparse_json()ヘルパーも用意されており、正規表現でフェンスを除去してからパースを試みる。
まとめ
このパイプラインの本質は、「サンプルサイズの検証」を提案ではなくコードとして強制する点にある。LLMが担うのは質問の言い換え・仮説生成・SQL生成・サマリー執筆であり、数値の信頼性チェックはコードが行う。役割の分担が明確だ。
29行程度の小規模データでも各グループの件数が少なくなりがちで、むしろこのパイプラインの真価が発揮されやすい。業務で扱うデータが常に大規模とは限らない以上、「件数が少ない結果を自動的に疑う」仕組みをパイプラインに組み込む発想は、実務上の汎用性が高い。
詳細はBuild an AI Data Analyst That Thinks Like a Senior Analystを参照していただきたい。