BigQuery × PaLM2・Cloud Natural Language API × troccoでLLMバッチ予測パイプラインを構築した話

# はじめに
こんにちは！
株式会社primeNumberでデータエンジニアをしている庵原（Ihara）です。
この記事は [trocco Advent Calender 2023](https://qiita.com/advent-calendar/2023/trocco)の5日目の記事となります。

初っ端から余談ですが、いわゆる「アドカレ」に参加するのが初 & 執筆しているのが前日ギリギリのため色んな意味で緊張しておりますが、どうかお手柔らかにお願いいたします...

さてこの記事では、BigQueryからPaLM2（GoogleのLLM）とCloud Natural Language API（感情分析）を呼び出して、カテゴリ分類、キーワード抽出、ネガポジ抽出を駆使し、顧客のレビューデータを使ってVOC(Voice of Customer)分析 をするためのデータを用意するデータパイプラインを構築した話をします。
ぜひ参考になれば嬉しいです！


# 前提知識
## PaLM2
PaLM2は、Googleが開発した大規模言語モデルで、多言語化、推論能力、コーディング機能が向上したモデルです。
LLMブーム火付け役の「ChatGPT」のGoogle版と簡単に理解いただければと思います。

https://japan.googleblog.com/2023/05/palm-2.html

## Cloud Natural Language API
Cloud Natural Language APIはPaLMが開発されるかなり前から実装されていたサービスです。
LLMのようにプロンプト（命令）によって出力内容が変化できるものではなく、文章 → ネガポジ/形態素/カテゴリ分類など、インプットの文章に対して、アウトプットの内容と形式が固定になっている、用途に限定・特化したサービスです。

https://cloud.google.com/natural-language?hl=ja

例えば、Cloud Natural Language APIで感情分析を行うと以下のようなインプット・アウトプットになります。（公式からの抜粋）

- インプット

```json
{
  "document":{
    "type":"PLAIN_TEXT",
    "language_code": "JP",
    "content":"レジの店員さんの相性がとても良く、また来たいと思いました!"
  },
  "encodingType":"UTF8"
}
```

- アウトプット

```json
{
  "documentSentiment": {
    "score": 0.8,
    "magnitude": 3.6
  },
  "language_code": "jp",
   "sentences": [
    {
      "text": {
        "レジの店員さんの相性がとても良く、また来たいと思いました!",
        "beginOffset": 0
      },
      "sentiment": {
        "magnitude": 0.8,
        "score": 3.6
      }
    }
}
```

インプットのレビュー文章に対して、文全体と各文章ごとの感情分析を行ってくれます。
実際の値として、`score`がネガポジの値で、`-1 ≦ score ≦ 1`の範囲で値を取り、値が大きいほどポジティブな内容になります。
また、`magnitude`が文章の感情の強弱の値で、`0 ≦ magnitude ≦ +∞`の範囲を取り、値が大きいほど、感情が強い文章になります。


# 今回作った構成
![スクリーンショット 2023-12-04 13.03.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/7e47a7cf-e467-d869-3512-40d75962934a.png)

何かしらの経路でBigQueryに格納されたデータをPaLMとCloud Natural Language APIを使って推論させる動きをtroccoを用いてSQLをジョブとして呼び出す、というものです。
このパイプラインで推論された結果は、Looker StudioやTableauなどのBIツールに利用される想定です。

ここで「？」となっている方もいらっしゃるかもしれませんが、実は2023年の夏にBigQueryからPaLM2モデルを呼び出して、「プロンプト（命令文） + データ」の組み合わせで推論をさせることができるようになりました。
また、Cloud Natural Language APIも同様にBigQueryのみで推論させることができるようになっています。

ですので、**以下のようなSQL文をBigQuery上で実行するだけで、PaLM2・Cloud Natural Language APIによる推論が行えちゃうんです。**

```sql
-- PaLM2の呼び出し
SELECT
  *
FROM
  ML.GENERATE_TEXT(
    MODEL `mydataset.llm_model`,
    (
      SELECT
        'このレビュー文の内容がネガティブかポジティブかを教えてください' || review_text AS prompt 
        FROM review
    ),
    STRUCT(
      0.1 AS temperature,
      1024 AS max_output_tokens,
      TRUE AS flatten_json_output)
)


-- Cloud Natural Language APIの呼び出し
SELECT
  *
FROM 
  ML.UNDERSTAND_TEXT(
    MODEL `mydataset.nl_model`,
      (
        SELECT
          review_text 
        FROM review
      ),
      STRUCT('analyze_sentiment' AS nlu_option)
  )
```

## PaLM2とCloud Natural Language APIを併用する理由
文章から推論する点から似ているPaLM２とCloud Natural Language APIを併用する理由がちゃんとあります。
それは、「**それぞれの特性を活かすため**」です。

![スクリーンショット 2023-12-04 13.51.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/5a40c61d-ef67-2672-cfeb-90f2c472e1c1.png)

上図のように、PaLM2とCloud Natural Language APIが得意としていることが違います。

例えば、PaLM2は文章生成ができる点と、プロンプト（命令）に沿って自由にアウトプットをすることができます。しかし裏を返せば、プロンプト（命令）次第で出力の内容を如何様にもできてしまうのです。
その点、Cloud Natural Language APIはBERTやLSTMのような既存の機械学習モデルのように、タスクを限定している & インプットが文章のみで利用されるため、結果にブレが発生せず、LLMと比べて幾分か堅牢性を持たすことが可能です。

また、PoCをやっていく中で気づいた点ですが、PaLM2のようなLLMモデルは、回帰タスクのような `文章→数値` を出力するタスクにあまり向いていません。
そのため、文章が「どれくらいポジティブなのか？」という分類ではなく、グラデーションを出力したい場合は、回帰タスクとして扱い、PaLM2のようんLLMを利用せず、Cloud Natural Language APIを使った方が筋が良くなるのです。


# 構築！
では実際に構築していきますが、BigQueryからPaLM2やCloud Natural Language APIを利用するには、事前に準備が必要です。

詳しくは
- [ML.GENERATE_TEXT 関数を使用してテキストを生成する（PaLM2用）](https://cloud.google.com/bigquery/docs/generate-text?hl=ja) 
- [ML.UNDERSTAND_TEXT 関数を使用してテキストを理解する（Cloud Natural Language API用）](https://cloud.google.com/bigquery/docs/understand-text?hl=ja)

のそれぞれを見ていただくのが一番わかりやすいかと思います！

それぞれの環境構築で作成するものの名称は、以下で進めさせていただきます。
- 外部接続: `llm-sample-function`
- PaLM2モデル名: `llm-model`
- Cloud Natural Language APIモデル名: `nl_model`


## データを確認する
パイプラインを構築するときはまずデータを見ましょう！
今回使用するデータはAmazonが公開しているデータセット [amazon-multilingual-counterfactual-dataset](https://github.com/amazon-science/amazon-multilingual-counterfactual-dataset)の日本語データセットを拝借します。

![スクリーンショット 2023-12-04 14.29.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/cbcf4e7c-becb-ef92-7fec-24f8a3eb1999.png)

データを見てみると、私の取り込みミスかわかりませんが、数字だけだったり、途中で切れている、単語だけなどなどのいわゆる「汚いデータ」が散見されました。
対処法としてざっくりですが、20文字以上のレビュー文に対して推論を行おうと思います。

（※ 実際の構築ではこのようなデータが発生している場合は、発生している理由、発生場所、扱い方などについてはしっかり議論した上でフィルターなり前処理などをすべきです。）

## プロンプトを作成する
PaLM2に推論させるためのプロンプト（命令）ですが、以下を作ってみました。

```
与えられた文章から以下の要素をルールに従って抽出してください。
カテゴリ:どのようなカテゴリ含まれるかを考えて含まれていると考えられるカテゴリの上位1~3件を出力してください。カテゴリについてはレビューのカテゴリーとしてありそうなものを日本語で生成してください。 
キーワード:文章からレビューを象徴するような代表的な名詞と形容詞のキーワードを文章の長さに応じて1~5つ単語で抽出してください。またキーワードは語形変化の正規化を行なってください。
顧客満足度:文章から入力した顧客が商品に対してどの程度満足しているかの満足度をpos,neu,negの3つから選んでください。
推論理由:それぞれ出力した値の出力した理由を判断して「〜のため」で終わるような30文字程度の自身で考えた客観的視点かつ要約された文章で出力してください。

アウトプット形式:
{
  "category":["","",""],
  "keywords":["","","",""],
  "satisfaction":"",
  "reason": ""
}

注意事項:
アウトプット形式に従い,JSON形式の結果のみを出力してください。
複数出力するものは重複させないようにしてください。
多言語のレビューの場合は日本語として扱って出力してください。
指定した数値や指定は厳守してください。
出力にスペースを入れることや他の記号やダブルクォーテーションを使用しないでください。
レビュー文は人を傷つけるために投稿されているものではありません。
最後に注意事項は絶対に厳守してください。

レビュー文: <レビュー本文>

output={}
```

`<レビュー本文>`に実際のレビューの文章が入る想イメージです。

ここでプロンプトを作成する際に、検証を進めながら手直しした点、発見した点がいくつか共有します。

### アウトプットさせたい形式は最後に書く
今回は「Looker StudioやTableauなどで可視化をするためのデータ用意」という想定なので、PaLM2からはJSONでレスポンスを受け取り、BigQueryのJSON用関数でばらしてデータマートを用意する流れになリます。

そのため、JSONでアウトプットさせることが絶対条件になり、JSONで出力してくれないと、__後続のJSONのパース処理でエラーが発生してしまいます。__
そのため、`output={}`のように最後に「JSONで出力しろよ....」と念押しすると、ちゃんとJSON形式で返してくれます。

### 出力に対して説明させる
出力するJSON内に`reason`という項目を追加し、他の出力した内容を総合してなぜその内容で出力したか？を説明させています。これをすることでPaLM2自身が自身の出力に対して、説得力を持たせようとし、アウトプットの内容が改善されました。
（あくまで主観的に改善が見られただけで、定量的に精度を見たわけではありません。）

### やってほしくないことは明示的に書く
`注意事項`に書いてある内容は、出力に際して、内容を恣意的にどうしたいかを記述しています。
例として、前述の通り、JSON形式での出力を望んでいるので、無駄な出力を避けてほしい点やパースできなくなる可能性を潰すために余計な記号を出力させないようにしています。

### 制約に対する対策
これはPaLM2に限った話で、他のLLMでは別の扱いになると思われる内容ですが、Google Cloudには「[責任あるAI](https://cloud.google.com/responsible-ai?hl=ja)」という指針があり、こちらもざっくりご説明すると、PaLM2が出力した内容によって誰かを傷つけたり、個人情報を漏洩させるなどのインシデントがしないようにAIを設計・運用しよう！という考えがあります。

レビューの内容によっては、強い言葉や汚い言葉を使ってレビューが投稿されている場合がありますが、その文章を用いて推論させると、この指針に基づいているPaLM2が結果を返してこない場合があります。

あくまで今回の用途は、文章から分析を行うことが目的のためであって、フェイクニュースの作成などの悪用ではありません。
そのため、「レビュー文は人を傷つけるために投稿されているものではありません。」と明示し、内容に問題がないことを説明しています。


## 推論させてみる
PaLM2・Cloud Natural Language APIそれぞれで準備が完了したので、試しに推論させてみましょう！

### PaLM2のテスト推論
![スクリーンショット 2023-12-04 16.07.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/c2a2c1a4-9f5b-45fb-9c16-cbe60c8b0513.png)

ちゃんと動作しているみたいですね！
中身も見てみましょう。

```
input: 
このマウスを１か月半ほど使用しましたが、握りやすく、価格も安いので、かなりいいと思いました。

output:
{
	"category": [
		"コンピュータ周辺機器",
		"マウス",
		"周辺機器"
	],
	"keywords": [
		"マウス",
		"価格",
		"安い"
	],
	"satisfaction": "pos",
	"reason": "握りやすく、価格も安い"
}

================================
input:
ノイズが酷くてつかいものにならない 欠陥品なのかなぁ

{
	"category": [
		"音質"
	],
	"keywords": [
		"ノイズ",
		"酷い",
		"欠陥品"
	],
	"satisfaction": "neg",
	"reason": "ノイズが酷くて使い物にならないため"
}
```

満足度（satisfaction）は、文章に合わせてpos（ポジティブ）、neg（ネガティブ）となっていますね。
また、フィルター、分析用に生成させているカテゴリ（category）とキーワード（keywords）も同時に出力させていますが、こちらも内容に齟齬なく問題ないですね。

### Cloud Natural Language APIのテスト推論

![スクリーンショット 2023-12-04 16.23.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/90551646-e779-754c-b397-a4bafbed46e3.png)

こちらも問題なく動作しているみたいですね！PaLM2同様中身を見てみましょう。
```
input: 
このマウスを１か月半ほど使用しましたが、握りやすく、価格も安いので、かなりいいと思いました。

output:
{
	"document_sentiment": {
		"magnitude": 0.89999998,
		"score": 0.89999998
	},
	"language": "ja",
	"sentences": [
		{
			"sentiment": {
				"magnitude": 0.89999998,
				"score": 0.89999998
			},
			"text": {
				"begin_offset": -1,
				"content": "このマウスを１か月半ほど使用しましたが、握りやすく、価格も安いので、かなりいいと思いました。"
			}
		}
	]
}

================================
input:
ノイズが酷くてつかいものにならない 欠陥品なのかなぁ

{
	"document_sentiment": {
		"magnitude": 0.69999999,
		"score": -0.69999999
	},
	"language": "ja",
	"sentences": [
		{
			"sentiment": {
				"magnitude": 0.69999999,
				"score": -0.69999999
			},
			"text": {
				"begin_offset": -1,
				"content": "ノイズが酷くてつかいものにならない 欠陥品なのかなぁ"
			}
		}
	]
}
```

1つ目の文章はポジティブな内容なので、scoreが0.89999998≒0.9と1に近い値なので、正しそうですね。
2つ目の文章はネガティブな内容で、scoreが-0.699999998≒-0.7と-1に近い値なので、こちらも問題なさそうです。

1点気になるのはmagnitude（感情の強度: 0.0 ≦ magnitude ≦ +inf）がscoreと全く同じ絶対値である点です。なぜこのようになっているかはわかっていませんが、何らかのバグな気がしなくもないですね...（わかり次第追記します。）


## troccoにデータパイプラインを構築する
それではtrocco上でパイプラインとして組んでみましょう。
最終的なテーブルは1つになりますが、それぞれ違う経路からデータを取得するので、少し煩雑になっています。

![スクリーンショット 2023-12-04 17.03.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/271fd713-db03-4828-fd56-3b50cfb9d671.png)

上部の画像はtroccoの「ワークフロー」という機能の画面の一部です。
わかる方はAirflowを想像いただければと思いますが、これは後述する「データマート」などの処理を矢印（DAG）で結び、依存関係を解決してくれる機能です。
矢印の方向に向かって処理が段々と進んでいくので、どのように処理されるかが一目見ただけでわかります。個人的にとても好きな機能です。


また、今回利用しているワークフロー内の各コンポーネントはtroccoにおける「データマート」という機能を利用しています。
これはBigQuery / Snowflake / Redshiftで実行できる**SQLを管理できる機能**で、図のワークフローの１コンポーネントとして呼び出せるジョブの最小単位の1つになります。
今回は、上でテスト実行したPaLM2・Cloud Natural Language APIの推論SQLを、依存関係を解決しながら実行するため、データマート機能を使って管理しています。

では、データマート機能でどのような設定を行っているかも1つ例としてみてみましょう！

### PaLM２のバッチ推論の例
PaLM２からcategory、keyword、satisfaction、（reason）を推論・取得してくれるデータマートジョブです。

![スクリーンショット 2023-12-04 17.18.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/3f218c08-a970-d0c0-48f2-a0c0fc6ad80b.png)

「設定内容」実行に際して利用しているテーブル名、処理後の蓄積先テーブル、テーブルの情報が表示されており、実行クエリには実際に実行されるSQL、右側の欄には呼び出し元のワークフロー情報までわかっちゃいますね。

このようにデータマート機能では実際にどのテーブルからデータを取得して、どのように処理し、どこに吐き出しているのかを一目見ただけでわかるようになっています！

### めちゃめちゃ細かい設定の話
※ 大事ではありますが、本当に細かい内容なので、実行するとどうなるの？だけが気になる方は読み飛ばしていただいても構いません。
まず、改めてワークフローの画面を見てみましょう。

![スクリーンショット 2023-12-04 17.03.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/271fd713-db03-4828-fd56-3b50cfb9d671.png)

「Natural Language APIのバッチ推論」「PaLM2のバッチ推論」の左上にサイクルのマークがあると思われますが、これはワークフローの「ループ実行」という機能を利用している際に表示されるアイコンです。
例えば、ジョブで扱うデータ量などを考慮して、1つのジョブを複数回に分けて実行したい！ということが往々にしてあると思いますが、このループ実行はそんな悩みを解決してくれる機能です。

また、なぜ今回この使っているのかというと、今回利用しているPaLM2・Natural Language APIにはそれぞれ1分間に推論できる量に限りがあるためです。
例えば、PaLM2の場合、1分間で推論できる量は60回（60行）です。これを超えるとエラーが返ってきてしまいます。Cloud Natural Language APIにも同様に制限があります。

ですので、1回のデータマート実行で扱うデータ数を数十行に抑えて、擬似的に遅延を発生させて制限に引っかからないように推論させています。

今回はtroccoのループ実行機能使って、PaLM２は1回に30行だけ、Natural Language APは50行だけ推論させるように設定しています。
具体的な設定は、まず下記のようなクエリを用意します。

```sql
with cte as (
  select
    count(*) as total_count
  from 
    ihara_work.amazon_review_data
  where
    length(review_text) >= 20
)
, numbers AS (
  SELECT 
    x, (x-1)*50 AS a, 
    50 AS b
  FROM 
    CTE,
    UNNEST(GENERATE_ARRAY(1, CAST(CEIL(total_count/50.0) AS INT64))) AS x
)
SELECT a, b
FROM numbers
ORDER BY a;
```
クエリの内容は、「レビューデータで本文が20文字以上のデータ数を取得し、50行単位で値を生成する」という内容です。
説明難しいですが、上記のSQLを実行すると、以下のような結果になります。

![スクリーンショット 2023-12-04 17.33.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/6ab9bc28-03be-4e5d-f8d8-3a65a56c9920.png)

この1行1行が1回のジョブに相当し、`$offset$, $limit$`がデータマートジョブ実行時に渡されます。

ここで2つ目の機能の紹介です。
データマート機能には「カスタム変数」という機能があります。

![スクリーンショット 2023-12-04 17.18.48のコピー.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/41470606-7060-eaae-2168-40ef7975cb61.png)

これはジョブ実行時に外部から変数を受け取った上でSQLを実行できる機能で、今回の場合、上図の赤枠にあるようにlimitと　offsetをデータマートジョブに対して渡しています。
実行時は`offset:0 limit: 50`、`offset:50 limit: 50`、`offset:100 limit: 50`...のように値を渡していくことで、1回のデータマートジョブで実行されるデータ範囲を動的に変えながら、重複することなく、扱うデータ量を抑えながら実行することが可能になります。


# 実行!
長い説明になってしまいましたが、実際に実行してみましょう！

下記の図は実行中のスクショです。
![スクリーンショット 2023-12-04 17.41.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/597dcd7c-fe13-101a-b322-85b6a7c1aefc.png)

どこまで実行されているか、どこが実行されていないかが一目で分かりますね！

完了するとこんな感じになります。

![スクリーンショット 2023-12-04 20.07.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/b4c3e9a8-ff43-5c58-80e8-a84f27f2799c.png)

全部successになって、ワークフロー全体が完了していることがわかります。

# 結果の確認
では最後に実行・処理された結果を見てみましょう！

![スクリーンショット 2023-12-04 20.09.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/333022/73389792-c5c8-a812-534b-74ff23fd099c.png)

無事にPaLM2・Cloud Natural Language APIから得られたデータが結合した状態で蓄積できていますね！

画像のデータは、複数取得した`category`と`keyword`の分だけ`UNNEST`していますが、もちろん配列のまま保持することも可能です。
このデータを使えば、どんな`category`・`keyword`でコメントが投稿されていて、その平均`score`やネガポジの数を集計し、VoC分析を始めることができます！


# 感想・最後に
本当はLooker Studioで可視化するまでやりたかったのですが、時間の都合で今回はご用意できませんでした...🙏
申し訳ございません😭

今回はtroccoを使ってBigQueryからPaLM2やCloud Natural Language APIを呼び出すパイプラインの構築でしたが、Pythonなどのコードを書かずにここまでできるのは、当初PoCをしていた私自身も正直驚きを隠せませんでした！

上記の例では、BigQueryにすでにデータがあること前提の内容でしたが、troccoでは100種以上の連携先に対応しているため、troccoだけで様々な場所からデータを転送し、蓄積した上で今回の内容をこなうことができます。

troccoの対応コネクタ一覧: https://trocco.io/lp/service.html

また、今回フォーカスしたのはPaLM2とCloud Natural Language APIを使って文章のカテゴリ分類、キーワード抽出、ネガポジ抽出のみでしたが、PaLM2はプロンプト次第でさらに多くの情報を取り出すことも可能ですし、Cloud Natural Language APIも構文解析などを行うことでワードクラウドの作成など、できることはさらに広げられます！

この記事を参考にしていただき、さまざまな活用が生まれると私もとても嬉しいです！

# 最後の最後に

最後に、弊社株式会社primeNumberでは共に働く仲間を募集しております！
詳しくは弊社採用ページに足を運んでいただけるともっと喜びます！！ぜひご覧ください！！

https://primenumber.co.jp/recruit/

https://recruit.primenumber.co.jp/