Closed13

2024年の振り返りのためにBigQuery MLでGoogle Keepの日記を感情分析する

つざきつざき

はじめに

2024年も残すところ後わずかとなりました。一年を振り返って行きたいですが、振り返る気力がありません。そして、振り返ろうにも1年の記憶がほとんどありません。
私は気軽な日記として、書きたいなと思った時だけGoogleKeepに散文を書いて保存しています。
日記以外の雑なメモや買い物リストなんかも雑多に混ざっているので、この中から日記的なやつだけ抽出して感情分析をしてみようというのがこのスクラップの趣旨です。

なぜGoogle Keepで日記を書くのか

Google Keepは起動が早いし、すぐに文章を入力できるので愛用しています。
Notionに移行しようと試してみたこともありますが、起動が遅かったり多機能すぎて気が散ってしまい結果的に失敗しています。
GoogleKeepにどのように日記を書いているかというと、タイトルに日付を入れて、あとは駄文を書き散らかすだけです。ラベル機能で日記と分類したりできますが、面倒で挫折しました。
🤩🥲のように絵文字で気分を記録しようと試みたこともありますが、これも長くは続きませんでした。

やってみる

それではやってみましょう。できるかわからないけど。

つざきつざき

Keepのデータをダウンロードする

Google KeepにはAPIがありデータ取得できるようなのですが、
めんどうなのでGoogleのデータ エクスポート機能でデータを全部とってきてそれを使おうと思います。

https://support.google.com/accounts/answer/3024190?sjid=6094655395184554144-AP

Googleアカウントのサービスはデータエクスポートすることができます。
Google Keep以外にもGoogle FitやGoogle Mapのロケーション履歴(タイムライン)などさまざまなデータをダウンロードすることができます。
位置的な履歴と日記の感情と紐付けたり、運動量と紐づけたりして分析するのも楽しそうです。
まあ今回はシンプルにKeepだけでいきます。

エクスポートを実行すると数分でダウンロードリンクがGmailに送られてきました。

ZIPをダウンロードし解凍すると、記事ごとにjsonとHTMLが入っていました。
jsonの形式はAPIでの形式とは違いそうで、その構造に関する情報がググっても見つからなかったので、JSONデータからJSON Schemaを生成してみてみます。

Webの変換ツールがあったのでありがたく使わせてもらいます。
https://seostudio.tools/ja/json-to-json-schema

Google KeepのJson schema

{
    "$schema": "http://json-schema.org/draft-07/schema#",
    "title": "Generated schema for Root",
    "type": "object",
    "properties": {
        "color": {
            "type": "string"
        },
        "isTrashed": {
            "type": "boolean"
        },
        "isPinned": {
            "type": "boolean"
        },
        "isArchived": {
            "type": "boolean"
        },
        "textContent": {
            "type": "string"
        },
        "title": {
            "type": "string"
        },
        "userEditedTimestampUsec": {
            "type": "number"
        },
        "createdTimestampUsec": {
            "type": "number"
        },
        "textContentHtml": {
            "type": "string"
        }
    },
    "required": [
        "color",
        "isTrashed",
        "isPinned",
        "isArchived",
        "textContent",
        "title",
        "userEditedTimestampUsec",
        "createdTimestampUsec",
        "textContentHtml"
    ]
}

今回必要なプロパティは、タイトルと本文と作成日なので
titleとtextContentとcreatedTimestampUsec です。
削除したやつも一応除外しときたいので
isTrashedもほしいですね
isArchived はアーカイブ機能を僕は使ってないので無視します。

つざきつざき

今回はとりあえずBigQueryに入れれば何とかなるでしょうと考えているので
これらのJSONデータをBigQueyに入れていきます。感情分析はやったことないですが、ネットの記事をちらほらみた気がするのでできると思います。そんなにお金もかからないでしょう(楽観)。

BigQueryにJSONをいれる

入れる方法はいくつかあるかなーと思いますが、今回はどうしようかな。
ChatGPTに聞いたらGCSにいれてBQコマンドでロードするのが楽そうだったのでこれをやっていきます。

つざきつざき

GCPプロジェクトを作る

GCPプロジェクトを作ります。
最近GCPで遊んでいたら課金で爆死したので、今回はマジで気をつけながらやっていきます。
作ったら消す。作ったら消す。絶対に。

プロジェクトは名前をつけて作るだけです

GCSバケットを作る

これも名前をつけて作るだけです。全部デフォルトいきます。リージョンは米国の方が安かったので米国にします。
公開アクセス禁止にするか聞かれたので禁止でいきましょう。私の陰鬱な日記や個人情報をインターネットに晒してしまうと人生が普通に終わってしまいます。

つざきつざき

GCSにアップロード

jsonファイルをGCSにアップロードします。
ローカルではKeepというフォルダにファイルが入っているので、このフォルダごとアップロードします。
.htmlのファイルも混ざってますが気にせず投入します。

bq load

BQに投入前にデータセットを作ります。
今回はmy_datasetというデータセットを作りました。

BQにjsonを入れていきます。
実行しようと思ったらスキーマがないと怒られたので、自動検知できるオプションをつけます。

bq load \
--autodetect \
--source_format=NEWLINE_DELIMITED_JSON \
my_dataset.raw_keep_entry \
"gs://y-tsuzaki-analyze-keep/Keep/*.json"

うーん、スキーマの自動検出ができてないみたいでエラーになってしまった

tsuzaki92@cloudshell:~ (flowing-capsule-445508-d1)$ bq load \
--autodetect \
--source_format=NEWLINE_DELIMITED_JSON \
my_dataset.raw_keep_entry \
"gs://y-tsuzaki-analyze-keep/Keep/*.json"
Waiting on bqjob_r39990fa72f6e7b7c_00000193ed9fad63_1 ... (0s) Current status: DONE   
BigQuery error in load operation: Error processing job 'flowing-capsule-445508-d1:bqjob_r39990fa72f6e7b7c_00000193ed9fad63_1': Error while
reading data, error message: JSON table encountered too many errors, giving up. Rows: 1; errors: 1. Please look into the errors[]
collection for more details. File: gs://y-tsuzaki-analyze-keep/Keep/2023-10-31T14_42_19.705+09_00.json
Failure details:
- Error while reading data, error message: JSON processing
encountered too many errors, giving up. Rows: 64; errors: 1; max
bad: 0; error percent: 0
- gs://y-tsuzaki-analyze-
keep/Keep/2023-10-31T14_42_19.705+09_00.json: Error while reading
data, error message: JSON parsing error in row starting at position
0: No such field: labels. File: gs://y-tsuzaki-analyze-
keep/Keep/2023-10-31T14_42_19.705+09_00.json

--source_format=NEWLINE_DELIMITED_JSON が怪しいのでみてみる
改行区切りでオブジェクトが入っているJSONファイルを期待しているみたいだ。
今回のファイルは1ファイル1オブジェクトに分解されていて、これを読み込むオプションはないっぽい。
1ファイル1オブジェクトしかなかったから、1ファイルだけでスキーマの自動検知したら、他のファイルのJSONに未知のプロパティがあって落ちたということだろう。

複数のJSONファイルを一つにがっちゃんこする必要がありそうだ。

tsuzaki92@cloudshell:~ (flowing-capsule-445508-d1)$ mkdir json_files
tsuzaki92@cloudshell:~ (flowing-capsule-445508-d1)$ gsutil cp gs://y-tsuzaki-analyze-keep/Keep/*.json ./json_files/
tsuzaki92@cloudshell:~ (flowing-capsule-445508-d1)$ cat ./json_files/*.json | jq -c '.' > combined.ndjson
tsuzaki92@cloudshell:~ (flowing-capsule-445508-d1)$ gsutil cp combined.ndjson gs://y-tsuzaki-analyze-keep/Keep_combined/combined.ndjson

combined.ndjson をこしらえたので、改めてこれをBigQueryにどっこいしょしていく

bq load \
--autodetect \
--source_format=NEWLINE_DELIMITED_JSON \
my_dataset.raw_keep_entry \
"gs://y-tsuzaki-analyze-keep/Keep_combined/combined.ndjson"

やったぜ

652行あるので、大体全部入ってそうです。
整えるとこんな感じ。

もうここまでで心が折れそうですが、日記かそうじゃないのをラベルづけしていきます。

つざきつざき

BQからスプシにエクスポートしてシュッと日記かどうかを表すラベル(フラグ)をつけていく
2024年だけなら100件ちょっとなのでヨユーヨユー

ほんで、BQに追加する。詳細は省略。

で、Keepの記事データとJOINして日記だけとりだす

with import_entry as (
  SELECT
    createdTimestampUsec,
    TIMESTAMP_MICROS(createdTimestampUsec) AS created_at,
    title,
    textContent,
    isTrashed
  FROM
    `flowing-capsule-445508-d1.my_dataset.raw_keep_entry`
  ORDER BY
    createdTimestampUsec
), import_label_data as (
  select 
    createdTimestampUsec,
    is_daiary
  from 
    `flowing-capsule-445508-d1.my_dataset.label_data`
  where is_daiary is not null
),
filter_by_daiary_label as (
  select
    import_entry.*
  from
    import_entry
  inner join
    import_label_data
    on import_entry.createdTimestampUsec = import_label_data.createdTimestampUsec
)
select * from filter_by_daiary_label order by createdTimestampUsec

91件日記があるようだ。
クエリをVIEWとして保存する。名前はint_keep_daiaryにする。
JOINしてるからstagingとも言いづらいし、intermediate層ということでint_とつけた。(それも微妙だけど)

よしこれで分析の土台はできただろう。

つざきつざき

ML.GENERATE_TEXTを使って感情分析

参考になりそうな記事を読む
https://cloud.google.com/blog/ja/products/data-analytics/using-gemini-in-bigquery-for-sentiment-analysis
https://dev.classmethod.jp/articles/bigquery-japanese-text-gen-sentiment-analysis-ml-generate_text-text-bison/

せっかくなので、positive/negativeの分析ではなく違う切り口でやっていきましょう。
なぜなら、私が日記を書く時は大抵ネガティブな気分なので、positive/negativeで分類してもおそらく意味がないからです。
ML.GENERATE_TEXTはGeminiのようなLLMを使うので、柔軟に分類ができそうです。プロンプトでJSONの定義を教えてあげるとそのように出力してくれるっぽいです。

ちょっと欲張りですが、感情ごとに分類+3段階評価 でやってみたいと思います。
あんまり詳細に分解しても、それを分析するのが大変ですが、まあとにかくやってみましょう。

感情の分類方法はいろいろあるみたいです。
https://ja.wikipedia.org/wiki/感情の分類
今回は、なんとなくダーウィンの「悲しみ・幸福・怒り・軽蔑・嫌悪・恐怖・驚き」の7パターンでいきます。
ちょっと多い気もしますが、やっていきましょう。

モデルはGemini 1.5 Flashを使います。
Geminiのバージョンのことは全然わからないですが、比較的新しくて安そうです。ChatGPTでいう、gpt4o miniみたいな感じでしょうか。

価格はテキスト$0.00001875 / 1,000 文字, テキスト出力 $0.000075 / 1,000 文字です。
テキスト入力(日記本文+プロンプト) 500文字 + テキスト出力 100文字 だったとしたら 約 $0.0017 になりそうです。このぐらいの金額なら大丈夫ですね。
ただ、破産しないように文字の長さと実行回数(再起実行とか)に気をつけていきましょう。

APIを有効にする
https://cloud.google.com/bigquery/docs/transcribe?hl=ja#before_you_begin

外部テーブルを追加する

上記のサービスアカウントにVertex AIユーザーの権限を追加する

リモートモデルを作る

CREATE OR REPLACE MODEL `flowing-capsule-445508-d1.my_dataset.my_gemini15_flash`
REMOTE WITH CONNECTION `flowing-capsule-445508-d1.us-west1.ml_connection`
OPTIONS (ENDPOINT = 'gemini-1.5-flash');

my_gemini15_flashというモデルができました。

接続を試してみましょう シンプルに「Hello Gemini」という文字列をML.GENERATE_TEXTに食わせてみます。

SELECT *
FROM
 ML.GENERATE_TEXT(
   MODEL my_dataset.my_gemini15_flash,
   (select "Hello Gemini!" AS prompt),
   STRUCT(0.8 AS temperature));

ml_generate_text_resultという列に、結果データが入っています。
絵文字付きの気さくな挨拶が返ってきました。

ようやくやっていく土台が整った(2度目)

つざきつざき

プロンプトを考える

ChatGPTに考えてもらいました。
英語の方がToken数が少ないかと思って英語にしています。
また、出力を定めたJSON形式で行うような指示をしています。

You are an AI analyst responsible for analyzing emotions in diary entries.

Evaluate the following diary entry for the presence of the following seven emotions:
- Sadness
- Happiness
- Anger
- Contempt
- Disgust
- Fear
- Surprise

Evaluation Criteria:  
Each emotion is rated on the following three levels:  
- 0: Barely present  
- 1: Slightly present  
- 2: Strongly present  

Output Format:  
Return the result strictly in the following JSON format:

{
  "sadness": 0,
  "happiness": 1,
  "anger": 0,
  "contempt": 0,
  "disgust": 0,
  "fear": 1,
  "surprise": 2
}

- Keys must be in lowercase English.  
- Values must be integers: 0, 1, or 2.  
- Do not include any additional text or comments outside the JSON format.

Diary Entry:  
{diary_entry}
つざきつざき

感情分析をLLMにやらせてテーブルを作る

プロンプトができたのでそれ使ってML.GENERATE_TEXTに食わせていきます。
単なるSELECT文だとせっかく生成したデータを永続化できないので、CREATE TABLE文で結果をテーブルとして保存します。
今回はint_keep_daiary_with_llmというテーブルを作ります。

CREATE OR REPLACE TABLE `flowing-capsule-445508-d1`.`my_dataset`.`int_keep_daiary_with_llm` AS
WITH
  BASE_PROMPT AS (
  SELECT
    'You are an AI analyst responsible for analyzing emotions in diary entries.\n\nEvaluate the following diary entry for the presence of the following seven emotions:\n- Sadness\n- Happiness\n- Anger\n- Contempt\n- Disgust\n- Fear\n- Surprise\n\nEvaluation Criteria:  \nEach emotion is rated on the following three levels:  \n- 0: Barely present  \n- 1: Slightly present  \n- 2: Strongly present  \n\nOutput Format:  \nReturn the result strictly in the following JSON format:\n\n{\n  "sadness": 0,\n  "happiness": 1,\n  "anger": 0,\n  "contempt": 0,\n  "disgust": 0,\n  "fear": 1,\n  "surprise": 2\n}\n\n- Keys must be in lowercase English.  \n- Values must be integers: 0, 1, or 2.  \n- Do not include any additional text or comments outside the JSON format.\n\n## Diary Entry:  \n' AS prompt_text ),
  import_daiary AS (
  SELECT
    *,
  FROM
    `flowing-capsule-445508-d1.my_dataset.int_keep_daiary` ),
  cut_content AS (
  SELECT
    import_daiary.*,
    LENGTH(textContent) AS len,
    SUBSTRING(textContent, 1, 400) AS textContent_cut,
  FROM
    import_daiary ),
  add_prompt AS (
  SELECT
    source.*,
    CONCAT(BASE_PROMPT.prompt_text, textContent_cut) AS prompt,
  FROM
    cut_content AS SOURCE,
    BASE_PROMPT
    -- NOTE: SAFE RUN
    -- LIMIT 2
  ),
  generate_text as (
    SELECT *
    FROM
    ML.GENERATE_TEXT(
      MODEL my_dataset.my_gemini15_flash,
      (select 
        createdTimestampUsec,
        title,
        textContent,
        prompt from add_prompt),
      STRUCT(0.8 AS temperature, true as flatten_json_output))
  )


SELECT
  *
FROM
  generate_text

flatten_json_output というオプションを指定することで、結果をjson形式で取りやすくすることができます。

AI破産しないことを祈りつつ実行ボタンを押します。

キター!4分ちょいでテーブルができました。

スクショで見える範囲では感情分析の結果が全部0になっちゃってますが、ちゃんと1とか2が値として入っているのもありました。

ようやく土台が整ったので(3回目)、いよいよ分析をやっていきましょう

つざきつざき

生成した感情分析データをグラフにする

生成AIを使って7つの感情ごとに3段階評価で分析を行いました。
データとしては以下のように入っています。

{
  "sadness": 0,
  "happiness": 1,
  "anger": 0,
  "contempt": 0,
  "disgust": 0,
  "fear": 1,
  "surprise": 2
}

さて、このデータを使って何か有益なインサイトを得たいのですが、分析については素人なので、とりあえずなんかグラフ化してみようと思います。

分析用のテーブルを整える

JSONだと使いづらいので分解して、ビューを作ります。
感情の度合いを0,1,2の3段階で分解しましたが、こういうカテゴリー値をどう扱ったらいいかわからないので、1以上ならtrueとなるis_sad ようなフラグもつけました。

with base as (
SELECT 
  createdTimestampUsec,
  TIMESTAMP_TRUNC(timestamp_micros(createdTimestampUsec), MINUTE) as created_at,
  title,
  textContent,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.sadness') as INT64) AS sadness,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.happiness') as INT64) AS happiness,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.anger') as INT64) AS anger,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.contempt') as INT64) AS contempt,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.disgust') as INT64) AS disgust,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.fear') as INT64) AS fear,
  cast(JSON_VALUE(ml_generate_text_llm_result, '$.surprise') as INT64) AS surprise,

FROM
  `flowing-capsule-445508-d1.my_dataset.int_keep_daiary_with_llm`
),
add_flag as (
  select base.*,
  (sadness > 0) as is_sad,
  (happiness > 0) as is_happy,
  (anger > 0) as is_angry,
  (contempt > 0) as is_contempt,
  (disgust > 0) as is_disgusted,
  (fear > 0) as is_afraid,
  (surprise > 0) as is_surprised,
  from base
)
select * from add_flag

さて、ようやく土台が整いました。(4回目)

つざきつざき

生成データが歯抜けじゃないか!

グラフ化しようとしていたら、作ったbool値のフラグにnullが入っていることに気づきました。
調べてみると20件nullになっていて、元テーブルを確認すると
ml_generate_text_status列に
A retryable error occurred: RESOURCE_EXHAUSTED error from remote service/endpoint.
というエラーが表示されています。

さすがに、もうええでしょ

と思いつつも、せっかくなのでやっていきましょう(よくない完璧主義)

エラーの原因はよくわかりませんが、リトライで治りそうな雰囲気です。
今回は20件だけなので、テーブルの作り直しを行わず、エラーになっている部分だけSELECT文で再実行し、結果を適当なテーブルに保存して、UPDATEで対象レコードだけ上書きしていきます。

(省略)

できました。1件だけエラーではないのに結果が空なものがありましたが、コンプライアンス違反と判断されて消えてしまったようです。まあいいでしょう。

つざきつざき

Looker Studioでグラフ化するための準備

グラフ化しやすいように最終的に次のようなテーブルを作りました。
日記の投稿日時と、「悲しみ・幸福・怒り・軽蔑・嫌悪・恐怖・驚き」の7種類の感情有無をboolで表現しています。

(正直このテーブルもなんだかグラフ化しづらかったので、もしかしたら1レコード1感情になるように加工した方がよかったかもしれません。)

さて、ようやく分析の土台が整いましたね(?)

つざきつざき

Looker Studioで日記を感情分析した結果を見る

投稿数を見る

まずは、感情分析のまえに純粋に投稿数を見てみます。

2024年は91件の日記を書いていることがわかります。月平均7.6件です。月平均も表示したかったですが、力不足で出せませんでした。(どうやってだすん?)
月ごとの遷移をみると6月が最も少なくて、10月が最も多く、全体的には増加傾向にありそうです。
6月なにしてたかな?と思ってカレンダーを見てみましたが、PHPカンファレンス福岡にLTしにいったり、映画MAD Maxフェリオサを観に行ったりしてました。まあいい感じに楽しくやってて日記書くのを忘れていたんだと思います。
やばい、まだ感情分析してないのに個人的な話を書きすぎた。

感情別にみてみる(年間)

年間を通してどの感情が多かったのか見てみます。

なんかあんまりいい感じのグラフじゃないですが、
今年 一番多かった感情は「悲しみ(63)」で次が「恐怖(56)」でした。

うーん、なんか照れますね。
まあそうだろうなという結果です。
前年の分析をしてないので比較はできませんが、私がネガティブなのは平常運転なので今年が特別に悲しいことがあったわけではないはず、、、

逆に一番少なかったのは「軽蔑(18)」で次が「嫌悪(19)」でした。
まあ確かに、あんまり他人を軽蔑したり嫌悪するような日記は書いてない気がします。自分に対する軽蔑憎悪はまあまあありそうだけど。
あと単に他の「悲しみ」「幸福」とかと比べてメジャーな感情じゃないのでLLMでの検知が難しかったのかもしれないです。

感情別にみてみる(月間)

グラフが複雑でなんかもうわかんないですね。
可視化の仕方がよくないからなのかもしれませんが、感情の分類が多すぎるので、一部ピックアップしてみてみます。「悲しみ」「幸福」にだけ着目して見てみます。

うーん、なんともいえないグラフになりました。
濃い青が「悲しみ」が検知された日記の数で、薄い青が「幸福」が検知された日記の数です。それぞれどちらにもカウントされるケースもありえます。

トレンドとしては「悲しみ」が増加傾向にあるといえるかもしれないし、ただのばらつきかもしれません。そういえばジムに行く回数が昔は週1以上(全盛期はほぼ毎日)でしたがここ数ヶ月は月1くらいに減ってしまったのでそれが関係しているかもしれないし、因果関係が逆かもしれないです。
Google Mapのロケーション履歴を使ってジムに行く頻度と感情の関係性を見ていくのも面白そうです。

まとめ

Google Keepの日記を抽出してBigQueryでGemini 1.5-flashモデルで感情分析をしてみました!
今回は欲張って感情を7種類、3段階で評価しましたが、分析段階では(スキル的と気力的に)この情報量をあまり活用できませんでした。
また、データが溜まればより面白い発見ができそうだなという可能性を感じたので、来年はたくさん日記を書いて行きたいです。
Google fitやGoogle Mapのデータを使って運動量や睡眠時間、移動回数なんかと組み合わせて分析するのもまた面白そうです。

今回の振り返り

今回、2024年の振り返りをやるにあたって休日の8時間以上を投入してしまっており、こんなことをやるよりよっぽど有意義なことがあったんじゃないかなっていう気分になりました。
来年はうまくやっていきましょう。

このスクラップは2024/12/22にクローズされました