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

はじめに
2024年も残すところ後わずかとなりました。一年を振り返って行きたいですが、振り返る気力がありません。そして、振り返ろうにも1年の記憶がほとんどありません。
私は気軽な日記として、書きたいなと思った時だけGoogleKeepに散文を書いて保存しています。
日記以外の雑なメモや買い物リストなんかも雑多に混ざっているので、この中から日記的なやつだけ抽出して感情分析をしてみようというのがこのスクラップの趣旨です。
なぜGoogle Keepで日記を書くのか
Google Keepは起動が早いし、すぐに文章を入力できるので愛用しています。
Notionに移行しようと試してみたこともありますが、起動が遅かったり多機能すぎて気が散ってしまい結果的に失敗しています。
GoogleKeepにどのように日記を書いているかというと、タイトルに日付を入れて、あとは駄文を書き散らかすだけです。ラベル機能で日記と分類したりできますが、面倒で挫折しました。
🤩🥲のように絵文字で気分を記録しようと試みたこともありますが、これも長くは続きませんでした。
やってみる
それではやってみましょう。できるかわからないけど。

Keepのデータをダウンロードする
Google KeepにはAPIがありデータ取得できるようなのですが、
めんどうなのでGoogleのデータ エクスポート機能でデータを全部とってきてそれを使おうと思います。
Googleアカウントのサービスはデータエクスポートすることができます。
Google Keep以外にもGoogle FitやGoogle Mapのロケーション履歴(タイムライン)などさまざまなデータをダウンロードすることができます。
位置的な履歴と日記の感情と紐付けたり、運動量と紐づけたりして分析するのも楽しそうです。
まあ今回はシンプルにKeepだけでいきます。
エクスポートを実行すると数分でダウンロードリンクがGmailに送られてきました。
ZIPをダウンロードし解凍すると、記事ごとにjsonとHTMLが入っていました。
jsonの形式はAPIでの形式とは違いそうで、その構造に関する情報がググっても見つからなかったので、JSONデータからJSON Schemaを生成してみてみます。
Webの変換ツールがあったのでありがたく使わせてもらいます。
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を使って感情分析
参考になりそうな記事を読む
せっかくなので、positive/negativeの分析ではなく違う切り口でやっていきましょう。
なぜなら、私が日記を書く時は大抵ネガティブな気分なので、positive/negativeで分類してもおそらく意味がないからです。
ML.GENERATE_TEXTはGeminiのようなLLMを使うので、柔軟に分類ができそうです。プロンプトでJSONの定義を教えてあげるとそのように出力してくれるっぽいです。
ちょっと欲張りですが、感情ごとに分類+3段階評価 でやってみたいと思います。
あんまり詳細に分解しても、それを分析するのが大変ですが、まあとにかくやってみましょう。
感情の分類方法はいろいろあるみたいです。
ちょっと多い気もしますが、やっていきましょう。
モデルはGemini 1.5 Flashを使います。
Geminiのバージョンのことは全然わからないですが、比較的新しくて安そうです。ChatGPTでいう、gpt4o miniみたいな感じでしょうか。
価格はテキスト$0.00001875 / 1,000 文字
, テキスト出力 $0.000075 / 1,000 文字
です。
テキスト入力(日記本文+プロンプト) 500文字 + テキスト出力 100文字 だったとしたら 約 $0.0017 になりそうです。このぐらいの金額なら大丈夫ですね。
ただ、破産しないように文字の長さと実行回数(再起実行とか)に気をつけていきましょう。
APIを有効にする
外部テーブルを追加する
上記のサービスアカウントに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時間以上を投入してしまっており、こんなことをやるよりよっぽど有意義なことがあったんじゃないかなっていう気分になりました。
来年はうまくやっていきましょう。