自分の日記をAmazon Comprehendで感情分析してみた

2023.06.17

はじめに

私はプライベートで日記を書いてインターネットに公開しています。たまに日記の内容を読み返すと楽しいものですが、読まなくてもどんな話題について書いていて、どんな感情を抱いていたかというような全体的な傾向がパッと把握できたら面白そうだなとふと思いました。

そこで、この記事では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-translate

また、@aws-sdk/client-s3@aws-sdk/client-translatenpm 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-destinationsLambdaDestinationをインポートしてしまっていました。

  • 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.というエラーになった

実際のバケットで実行したらうまくいきました。

  • GetObjectAccessDeniedエラーになった

GetObjectPutObjectのときのリソースの指定方法を「バケット名/*」と、最後にアスタリスクをつけるように修正しました。

  • 上記を修正しても、日本語のファイルをS3にアップロードするとGetObjectAccessDeniedエラーになる

日本語ファイル名を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-comprehendnpm installしておきます。

Amazon Comprehendには色々な分析がありますが、今回はTargeted Sentiment(ターゲットを絞ったセンチメント)という機能を使います。これはテキスト内に登場するエンティティごとに感情(ポジティブ、ネガティブ、ニュートラル、混在)を識別するというものです。よってBatchDetectTargetedSentimentCommandというAPIを呼び出します。

@aws-sdk/client-comprehend

Comprehendには他にも以下の分析の種類があるので、もし別の分析を行いたいという場合は対応したAPIを呼び出すことになります。

  • エンティティ
  • イベント
  • キーフレーズ
  • 個人を特定できる情報(PII)
  • 主要な言語
  • センチメント
  • ターゲットを絞ったセンチメント
  • 構文分析

インサイト - Amazon Comprehend

コードは以下になります。

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など不慣れなサービスもあり苦労しましたがとても勉強になりました。

この記事がどなたかのお役に立てば幸いです。