自分の日記をAmazon Comprehendで感情分析してみた
はじめに
私はプライベートで日記を書いてインターネットに公開しています。たまに日記の内容を読み返すと楽しいものですが、読まなくてもどんな話題について書いていて、どんな感情を抱いていたかというような全体的な傾向がパッと把握できたら面白そうだなとふと思いました。
そこで、この記事ではAmazon Comprehendを使って自分の日記を分析する仕組みを構築してみようと思います。
この記事でやること
- 記事はGitHubにアップロードしています。そこで、GitHubにpushされたらS3にも記事が保存されるようにします。
- Amazon Comprehendは日本語にも対応していますが、英語の方が対応している機能が多いため、Amazon Translateで記事を英語に翻訳します。
- 英語に翻訳した記事に対してComprehendで分析を実行します。
- 削除や再構築を容易にするためにCDKで作成します。
全体的な構成図はこのような感じです。
前提条件
- CDK 2.84.0
- Node.js 18.16.0
CDKプロジェクトの作成
プロジェクトフォルダを作成し、VS Codeで開きます。プロジェクト名はcomprehend-me
にしてみました。
cdk init
を実行します。
cdk init app --language typescript
CDKプロジェクトが作成されました。空の状態ですが、試しにデプロイしてみます。
cdk deploy --profile personal
マネジメントコンソールで見てみると、無事にスタックがデプロイされました。
ここから中身を作っていきます。
S3バケットにファイルがアップロードされたら英語に翻訳されるようにする
ここではS3バケットの作成とS3イベント通知の作成を行います。イベント通知はLambdaを呼び出します。Lambdaの中で、アップロードされたファイルの内容をAmazon Translateを使って翻訳し、再度S3バケットに書き戻すことを行います。
構成図の以下の部分です。
まずはLambda関数の中身を作成します。プロジェクトフォルダ直下にlambda
フォルダを作成し、その中にtranslate-function.ts
ファイルを作成します。
translate-function.ts
ファイルは以下のようになります。私の日記の場合、各記事の最初の6行はメタデータのようなものが書かれているので、本文のみが翻訳されるように最初の6行を削除しています。
翻訳にはTranslateTextCommand
というAPIを使用します。
また、@aws-sdk/client-s3
と@aws-sdk/client-translate
はnpm install
しておきます。
import { S3, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3' import { TranslateClient, TranslateTextCommand } from '@aws-sdk/client-translate' const AWS_REGION = 'ap-northeast-1' const s3 = new S3({ region: AWS_REGION, }) const translate = new TranslateClient({ region: AWS_REGION, }) export const handler = async(event: any) => { const bucket = event.Records[0].s3.bucket.name const key = decodeURI(event.Records[0].s3.object.key) /** * S3の内容を取得 */ const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key, }); const response = await s3.send(getObjectCommand); const body = await response.Body?.transformToString(); if(!body) return /** * 最初の6行を削除 */ const formattedBody = body.split('\n').slice(6).join('\n') /** * 取得した内容を英語に翻訳 */ const translateTextCommand = new TranslateTextCommand({ SourceLanguageCode: "ja", TargetLanguageCode: "en", Text: formattedBody }); const result = await translate.send(translateTextCommand) const translatedText = result.TranslatedText /** * 翻訳結果をS3に書き戻す */ const putObjectCommand = new PutObjectCommand({ Bucket: bucket, Key: key.replace('ja/', 'en/'), Body: translatedText, }); await s3.send(putObjectCommand); }
次に必要なリソースを作成します。lib/comprehend-me-stack.ts
を修正し、以下のリソースを作成します。
- S3バケット
- Lambda関数
- Lambda関数からS3とAmazon Translateを操作するための権限
- イベント通知
コードは以下のようになります。
import * as cdk from 'aws-cdk-lib' import { LambdaDestination } from 'aws-cdk-lib/aws-s3-notifications' import { Bucket, EventType } from 'aws-cdk-lib/aws-s3' import { Construct } from 'constructs' import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs' import { PolicyStatement } from 'aws-cdk-lib/aws-iam' export class ComprehendMeStack extends cdk.Stack { constructor(scope: Construct, id: string, props?: cdk.StackProps) { super(scope, id, props); // S3バケット const s3bucket = new Bucket(this, 'ArticlesForComprehendMe', { bucketName: 'articles-for-comprehend-me', removalPolicy: cdk.RemovalPolicy.DESTROY, autoDeleteObjects: true, }) // Lambda関数:先ほど作成したtranslate-function.tsファイルを指定 const translateFunction = new NodejsFunction(this, 'translate-function', { functionName: 'translate-function', entry: 'lambda/translate-function.ts' }) // LambdaからS3を操作するための権限 translateFunction.addToRolePolicy(new PolicyStatement({ resources: [`${s3bucket.bucketArn}/*`], actions: ['s3:GetObject', 's3:PutObject'], })) // LambdaからTranslateを呼び出すための権限 translateFunction.addToRolePolicy(new PolicyStatement({ resources: ['*'], actions: ['translate:TranslateText'], })) // S3イベント通知 s3bucket.addEventNotification( EventType.OBJECT_CREATED_PUT, new LambdaDestination(translateFunction), { prefix: 'ja/' } ) } }
ここまでの内容を一度デプロイして確認してみます。
デプロイが終わったら、articles-for-comprehend-me
バケットにja
フォルダを作成し、日本語で書かれた適当なテキストファイルをアップロードします。
※最初の6行が削除されるので6行分開けておきます こんにちは。 今は夜の10時です。 少しお腹がすきました…。
en/というフォルダが作成され、中にテキストファイルが保存されています。
ダウンロードして中身を見ると、ちゃんと英語に翻訳されていました。
Hello. It's ten o'clock at night now. I'm a little hungry...
ここまででハマったポイント
ここまで作るのにもかなり苦労しましたので、未来の自分のために個人的にハマったポイントを残しておきます。
- LambdaDestinationの引数に作成したLambdaを指定してもエラーとなる
aws-cdk-lib/aws-s3-notifications
ではなく、aws-cdk-lib/aws-lambda-destinations
のLambdaDestination
をインポートしてしまっていました。
- Lambdaのコンソールから、
s3-put
のテストイベントを使ってコードをテストしていたときに、The bucket you are attempting to access must be addressed using the specified endpoint. Please send all future requests to this endpoint.
というエラーになった
実際のバケットで実行したらうまくいきました。
GetObject
でAccessDenied
エラーになった
GetObject
、PutObject
のときのリソースの指定方法を「バケット名/*」と、最後にアスタリスクをつけるように修正しました。
- 上記を修正しても、日本語のファイルをS3にアップロードすると
GetObject
でAccessDenied
エラーになる
日本語ファイル名をURIエンコードすることで解決しました。
GitHubにpushしたらS3にアップロードされるようにする
続いて、GitHubにpushしたら自動でS3に日記がアップロードされるようにします。ここではCDKのプロジェクトではなく、日記のプロジェクトを変更します。
ちなみに私の日記はAstroというフレームワークで作られています。記事はマークダウン形式で、以下のようなフォルダ構成になっています。(書く頻度にかなりムラがあることがバレてしまいますね)
プロジェクト直下に.github/workflows
というフォルダを作成します。その中にuploadS3.yml
ファイルを作成します。
やりたいことは以下の通りです。
master
ブランチにpushされたら起動する- 変更したマークダウン形式のファイルのみがS3にコピーされるようにする
articles-for-comprehend-me/ja
バケット内に日付で整理された状態で記事が保存されるようにする
日記のフォルダ構成がちょうどyyyy/mm/dd.md
となっているので、記事のファイルパスからこの部分だけを取り出して、S3のパスにそのまま使うことにします。
コードは以下のようになります。
name: Upload diff files on: push: branches: [master] jobs: upload: name: Upload runs-on: ubuntu-latest steps: - name: Get latest code uses: actions/checkout@v3 with: fetch-depth: 0 - name: Upload diff files run: | for file in $(git diff ${{github.event.before}}..${{github.event.after}} --name-only -- '*.md') ; do # ファイルパスのうち年月に関わる部分のみ抽出 ymd=${file##*/post/} path="s3://articles-for-comprehend-me/ja/${ymd}" aws s3 cp $file $path done env: AWS_DEFAULT_REGION: 'ap-northeast-1' AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
次に、GitHubリポジトリの「Settings」⇒「Secrets and variables」⇒「Actions」を開き、「New repository secret」をクリックします。
Nameに「AWS_ACCESS_KEY_ID」、Secretにアクセスキーを入力してシークレットを追加します。同じように「AWS_SECRET_ACCESS_KEY」も追加します。
ここで一度動作確認してみます。いつものように記事を書いて、GitHubにpushします。
少し経って見てみると、S3バケットにen/2023/06/16.md
というファイルが作成されており、push⇒S3にアップロード⇒英語に翻訳という流れがちゃんと機能していることがわかります。
ここまででハマったポイント
ワークフローの作成でもかなりハマりました…。未来の自分へ向けて残しておきます。
No jobs defined in jobs
というエラーでワークフローが動かない
jobs:
の行にインデントが入っていました。インデントを削除して動くようになりました。
- 変更したファイルが取得できない
最初、git diff origin/master...HEAD
など色々試してみましたが変更したファイルが取得できませんでした。ファイルが取得できないのでforループに入らないのは当然ですが、「ループ内のコマンドが動いていない!?」と勘違いし、run:
のコマンドの書き方が間違っているのかとかなり悩みました。最終的に今のコードで取得できるようになりました。
- for文の中で
echo file
してもファイル名が表示されない
基本的なことかもしれませんが、変数の先頭には$をつける必要がありました。
S3バケットに翻訳されたファイルが保存されたらAmazon Comprehendを起動する
ここまでで、日記を書いたらen
フォルダに英語版が保存されるところまでを自動化することができました。
次は、英語の記事をAmazon Comprehendで分析する仕組みを作ります。今回は、en
フォルダに記事がアップロードされたらS3イベント通知でLambdaを呼び出し、LambdaからComprehendを起動します。
構成図の以下の部分です。
CDKのプロジェクトに戻り、まずLambdaを作ります。lambda
フォルダの中にcomprehend-function.ts
ファイルを作成します。
comprehend-function.ts
ファイルは以下のようになります。TranslateのLambdaと同様、@aws-sdk/client-comprehend
をnpm install
しておきます。
Amazon Comprehendには色々な分析がありますが、今回はTargeted Sentiment(ターゲットを絞ったセンチメント)という機能を使います。これはテキスト内に登場するエンティティごとに感情(ポジティブ、ネガティブ、ニュートラル、混在)を識別するというものです。よってBatchDetectTargetedSentimentCommand
というAPIを呼び出します。
Comprehendには他にも以下の分析の種類があるので、もし別の分析を行いたいという場合は対応したAPIを呼び出すことになります。
- エンティティ
- イベント
- キーフレーズ
- 個人を特定できる情報(PII)
- 主要な言語
- センチメント
- ターゲットを絞ったセンチメント
- 構文分析
コードは以下になります。
import { S3, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3' import { ComprehendClient, BatchDetectTargetedSentimentCommand, TargetedSentimentMention } from "@aws-sdk/client-comprehend" const AWS_REGION = "ap-northeast-1" const s3 = new S3({ region: AWS_REGION, }) const comprehend = new ComprehendClient({ region: AWS_REGION, }) export const handler = async(event: any) => { const bucket = event.Records[0].s3.bucket.name const key = event.Records[0].s3.object.key /** * S3の内容を取得 */ const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key, }) const response = await s3.send(getObjectCommand) const body = await response.Body?.transformToString() if(!body) return /** * 取得した内容を分析 */ const command = new BatchDetectTargetedSentimentCommand({ TextList: [ body, ], LanguageCode: "en", }) const result = await comprehend.send(command) if(!result.ResultList) return if(!result.ResultList![0].Entities) return /** * 欲しい部分のみ抽出 */ const formattedResult: { Mentions: TargetedSentimentMention[] } = { Mentions: [] } for(const entity of result.ResultList[0].Entities){ if(!entity.Mentions) continue for(const mention of entity.Mentions){ formattedResult.Mentions.push(mention) } } /** * データをS3に保存 */ const putObjectCommand = new PutObjectCommand({ Bucket: bucket, Key: key.replace('en/', 'output/').replace('.md', '.json'), Body: JSON.stringify(formattedResult), }); await s3.send(putObjectCommand); };
では、このLambdaをS3イベント通知で呼び出せるようにCDKスタックを修正します。
comprehend-me-stack.ts
の既存コードの下に以下のコードを追加します。
// Lambda関数:先ほど作成したcomprehend-function.tsファイルを指定 const comprehendFunction = new NodejsFunction(this, 'comprehend-function', { functionName: 'comprehend-function', entry: 'lambda/comprehend-function.ts' }) // LambdaからS3を操作するための権限 comprehendFunction.addToRolePolicy(new PolicyStatement({ resources: [`${s3bucket.bucketArn}/*`], actions: ['s3:GetObject', 's3:PutObject'], })) // LambdaからTranslateを呼び出すための権限 comprehendFunction.addToRolePolicy(new PolicyStatement({ resources: ['*'], actions: ['comprehend:BatchDetectTargetedSentiment'], })) // S3イベント通知 s3bucket.addEventNotification( EventType.OBJECT_CREATED_PUT, new LambdaDestination(comprehendFunction), { prefix: 'en/' } )
これでCDKをデプロイします。
S3にja/yyyy/mm/dd.md
ファイルをアップロードすると、output/yyyy/mm/dd.json
というファイルが作成されます。
jsonファイルの中身は以下のようになります。BatchDetectTargetedSentimentCommand
のレスポンスのうち、Lambdaで必要なものだけを抜き出したものが出力されていることがわかります。
Mentions
の各配列には、Amazon Comprehendによってエンティティ(Text)ごとの感情(Sentiment)が分析された結果が格納されています。
{ "Mentions": [ { "BeginOffset": 350, "EndOffset": 355, "GroupScore": 1, "MentionSentiment": { "Sentiment": "NEUTRAL", "SentimentScore": { "Mixed": 0, "Negative": 0, "Neutral": 1, "Positive": 0 } }, "Score": 0.9998279809951782, "Text": "diary", "Type": "OTHER" }, { "BeginOffset": 376, "EndOffset": 379, "GroupScore": 1, "MentionSentiment": { "Sentiment": "NEUTRAL", "SentimentScore": { "Mixed": 0.000014999999621068127, "Negative": 0.000014000000192027073, "Neutral": 0.9999709725379944, "Positive": 9.999999974752427e-7 } }, "Score": 0.7567639946937561, "Text": "AWS", "Type": "ORGANIZATION" }, { "BeginOffset": 862, "EndOffset": 867, "GroupScore": 0.5095760226249695, "MentionSentiment": { "Sentiment": "NEUTRAL", "SentimentScore": { "Mixed": 0, "Negative": 0, "Neutral": 1, "Positive": 0 } }, "Score": 0.9593780040740967, "Text": "piano", "Type": "OTHER" }, // 以下続く ] }
これで、日記の内容に対して感情分析する仕組みを作ることができ、当初の目的は達成できました。
あとは日記を書いてデータをどんどん蓄積していけば、いずれ何らかの分析ツールを使って面白い結果が見られそうです。(そのためにももっと書く頻度を増やさなければ…)
おまけ:Athenaでデータをサクッとクエリする
分析用データだけ作って分析をしないのは寂しいですよね。本格的な分析ではありませんが、せっかくなのでAthenaを使ってデータに対して簡単なクエリをしてみます。
その前に、今は記事が1つしかないので、数記事を手動でアップロードしておきます。日記のプロジェクトフォルダ直下でcpコマンドを実行して、5月分と6月分の記事をアップロードします。
aws s3 cp ./src/pages/post/2023/05/ s3://articles-for-comprehend-me/ja/2023/05/ --recursive --profile personal aws s3 cp ./src/pages/post/2023/06/ s3://articles-for-comprehend-me/ja/2023/06/ --recursive --profile personal
Athenaのクエリエディタを開き、「作成」⇒「S3バケットデータ」をクリックします。
テーブルは任意の名前、データベース設定では既存のdefault
データベースを選択します。
入力データセットの場所はjsonファイルのあるoutput
フォルダを指定します。ファイル形式に「JSON」を選択します。
列の詳細です。ここではjsonファイルの構造に合った列を定義します。
今回のjsonファイルの場合は以下のようになります。
その他はデフォルトのままで、「テーブルを作成」をクリックします。
テーブルが作成されました。
作成されたテーブルの中身を見てみます。
select * from sample_table
このようになりました。1行が1ファイルに対応しています。5月と6月分の記事が合わせて9記事なので、9行あります。(書く頻度…)
先頭が[
で始まっていることからもわかるとおり、各行は配列になっています。このままでは1行の中に複数のデータが格納されているので、クエリすることができません。
そこで、ネストされた配列をフラット化します。
ネストされた配列のフラット化 - Amazon Athena
以下のクエリを実行します。
WITH dataset AS ( SELECT mentions as mentions FROM sample_table ) SELECT mention FROM dataset CROSS JOIN UNNEST(mentions) as t(mention)
今度は結果が640件になり、{
から始まるオブジェクトになっていることがわかります。
※Athenaはスキャンしたデータ量に応じて課金されるので、不用意に大きなデータを処理しないように気をつけてください。
この状態で、個別の列を取得するには以下のように書きます。
WITH dataset AS ( SELECT mentions as mentions FROM sample_table ) SELECT mention.text, mention.mentionsentiment.sentiment FROM dataset CROSS JOIN UNNEST(mentions) as t(mention) limit 10
毎回、WITH句を書くのが手間だと思ったら、ビューも作れます。ネスト化を解除するクエリを書いた状態で「作成」⇒「クエリからの表示」をクリックします。
ビュー名に任意の名前を入力します。
これでネストを解除した状態のビューに対して直接クエリできるので、このようにシンプルに書けます。
SELECT mention.text FROM "sample_table_unnest" limit 10
さて、試しにこんなクエリを作ってみました。エンティティと感情の組み合わせで、登場回数が多い順に10件取得します。そのままだと「I」や「you」が多くなってしまうので、PERSON
タイプのエンティティは除外します。また、「#」やURLなど不要なデータも除外しています。
SELECT mention.text, mention.mentionsentiment.sentiment, COUNT(*) FROM "sample_table_unnest" WHERE mention.type <> 'PERSON' AND mention.text not in ('#') AND mention.text not like 'https://%' GROUP BY mention.text, mention.mentionsentiment.sentiment ORDER BY COUNT(*) DESC limit 10
このような結果になりました。
どうやらDTMやsong、pianoなど音楽関係のことを話題にしていたらしいということがわかりました。が、感情はNEUTRALばかりですね。データが少ない段階ではこれといった傾向は見えづらいのかもしれません。
もっとデータが蓄積され、視覚化ツールなどを使えば有用な洞察が得られそうです。
おわりに
長くなってしまいましたが、読んでいただきありがとうございました。
この記事では自分の日記をターゲットにしましたが、Amazon Comprehendの分析は色々なところで使えそうで面白いなと思いました。
ターゲットを絞ったセンチメントを使いたかったので記事を英語にしましたが、Amazon Comprehendには日本語に対応している分析もあるので、それらを使いたい場合は日本語のままでも大丈夫です。
Athenaなど不慣れなサービスもあり苦労しましたがとても勉強になりました。
この記事がどなたかのお役に立てば幸いです。