ユーザーレビューを感情分析するBotについて

この記事はJXAdvent Calendarの17日目の記事です。 こんにちは、以前秩父での開発合宿の記事を書いたぬまっち(@nuMatch) です。
弊社のSlackにはユーザーからのコメントが届くチャンネルがあります。
その内訳は

Android-Review      : Google Playに投稿されるユーザーレビューコメント
iOS-Review          : iTunes Storeに投稿されるユーザーレビューコメント

Android-Feedback    :
iOS-Feedback        : NewsDigestの「ご意見・ご要望」画面から投稿されるフィードバックコメント

の4種類で、日々開発の参考にさせて頂いております。

頂いたご意見の中には当然好意的な意見と否定的な意見両方があるのですが、
その度合いを数値として残しておければ分析の役に立つのではと思い、Botを作成したので記事として公開します。

(そしてAndroid開発以外に手を出すのも、初めての挑戦です)

今回作るBotにやらせてみたいのは
- 好意的な意見と否定的な意見の比率は施策によって遷移するのかを分析したい - Slackチャンネルで流れていくだけでは勿体無いので、コメントを蓄積してしたい

の二点になります。

SlackBotをHerokuにデプロイさせた理由

という事でBotを作ろうと思ったのですが、正直自分はAndroid開発以外のことは皆無でして、どこからどう始めたらいいのか分かりません。 そこで考えたのは今想定されるユースケースと自分の環境から最適解を見つけ出していこう、というものでした。

  • 言語選定 ここは弊社のサーバサイドエンジニアに知見を伺いたい事情もあったので、 Python を選択しました。
    その道のエキスパートが沢山いらっしゃるので、ここは相談しやすさを重視です。

  • SlackBot 前述の通り、コメントはSlackに流れてくるのですからBotはSlackから効率良く取得できるものでありたいです。 Python3系をサポートしていることから、Slackbotライブラリがチョイスされました。

  • Heroku 作ったBotはデプロイしないことには動いてくれません。当たり前ですがいつまでもローカル環境で動かしていては自宅の電気代も勿体無いです。 ここはCTOに相談したところHerokuを推して頂きました。PaaSの中で選ぶならAWSの知見が社内に溜まっているとは思いましたが、
    「デプロイ以外の細かなセキュリティ周りの設定だとかで躓く可能性が高い、初めての体験はなるべく躓かないほうが良い」
    という助言を頂いたところで、今回のBotは Heroku環境 で動作させる事が確定です。

感情分析をどう扱うのか(APIの選定)

次はある意味心臓部にあたる、感情分析をどうするか、という点について。
Google感情解析API(CLOUD NATURAL LANGUAGE API)というGoogle Cloud Platformのサービスがあります。 簡単に説明すると、事前に用意された機械学習モデルにREST APIでアクセスする事が出来て、感情分析結果を取得する事が出来るものです。

  • 感情分析の範囲
    0.1 ~ 1.0 ポジティブ
    0.0 ニュートラル
    -0.1 ~ -1.0 ネガティブ

コメントを投げると上記の範囲でレスポンスを返却してくれるので、ネガポジの度合いを絶対値で判断する事が出来ます。

ユーザーコメントが届く度にコメントをAPIに乗せて投げてあげれば、感情分析はGoogle Cloud Platform側で担保する。これでレビュー解析Botは形になりそうです。

大まかな流れ

f:id:numatch-jx:20181217173133p:plain

①レビューコメントの投稿

デプロイしたBotをレビューが投稿されるSlackチャンネルに招待し、 コメントの通知を受け取れるようにデコーダを実装させます。

from slackbot.bot import listen_to

@listen_to(r'.*')
def mention_func(message):
    body = message.body

SlackBotライブラリに用意されている@listen_toデコーダで投稿されたMessage全体を受け取ります。 その中からbodyを部分を取り出して感情分析にかけたいユーザーコメントを取得します。

  • Androidレビュー/フィードバック
  • iOSレビュー/フィードバック

の4パターンのコメントが投稿されるので、取得したbodyの内容で判別の上、適切な形で取り出したうえでGoogle感情解析APIに投稿します。

②コメントの感情分析 / ③ネガポジスコアの返却

Google Cloud PlatformにてGoogle Natural Language API(感情分析API)の登録を完了させるとAPIキーを発行する事が出来ます。
Heroku側でダッシュボード内でAPIキーを環境変数として登録しておく事で、Botがデプロイ環境で感情分析APIを扱うことが出来るようになります。

環境変数の登録
f:id:numatch-jx:20181216202549j:plain
from google.cloud import language

# 環境変数からAPIキーを取得
info = json.loads(os.environ.get('環境変数に登録したKey'))
credentials = service_account.Credentials.from_service_account_info(info)
client = language.LanguageServiceClient(credentials=credentials)

# 感情分析APIにリクエストを投げる
sentiment = client.analyze_sentiment(document=document).document_sentiment

sentiment.scoreで感情分析のスコアを取り出すことが出来ます。

④スコアの投稿

スコアは-1.0 ~ 1.0の範囲で返却されますが、端数になりがちなので小数点以下で四捨五入しておきます。

ounded_score = round(sentiment.score, 3) 

message.reply()で投稿されたSlackメッセージに対してスレッド返信を行えます。 また、スコアが正の値ならポジティブ、負の値ならネガティブなので対応した絵文字でSlackのユーザーコメントにリアクションをしてみます。

message.reply('NegaPosi Score: {}'.format(rounded_score), in_thread=True)
message.react(negaposi(rounded_score))

解析結果

ポジティブ ネガティブ
f:id:numatch-jx:20181217150910j:plain f:id:numatch-jx:20181217150938p:plain
  • ポジティブコメント
    「地震情報が素早い世界中の情報が分かる」はポジティブな意見そうですが、実際のスコアが0.7と判定されており、感情分析結果としてもポジティブと捉えられています。
  • ネガティブコメント 「天気予報は出ないのですか?」は残念ながらネガティブそうです。スコアを確認すると-0.6ですね。APIのレスポンスでもネガティブとして判断されているようです。

  • 絵文字とスレッド 絵文字がリアクションとして投稿されるので、スレッドを開かなくてもネガポジの解析結果を判断する事が出来ます。 スレッド返信されておりSlack上でスコアを確認することも出来て良さそうです!

データベース登録と、その結果

最初にBotを作ろうとした理由のうち、

  • 好意的な意見と否定的な意見の比率は施策によって遷移するのかを分析したい

は実現が出来たので、

  • Slackチャンネルで流れていくだけでは勿体無いので、コメントを蓄積してしたい

も実現するべく、次はデータベースへのInsert機能をつけたいと思います。 PostgreSQLはHerokuが公式でサポートしているので都合が良さそうです。

Add-onsでHerokuにPostgreSQLを追加するとダッシュボードから確認出来るようになります。

Herokuが自動的に環境変数に登録してくれる
f:id:numatch-jx:20181216202758j:plain

PostgreSQLのDBに接続するためのパスワード等は、Herokuの環境変数へと自動で登録されるのでラクチンです。

  • 保存しておきたい4つのプロパティ

今後スコアを分析するの必要そうなプロパティは事前に考えておきました。
下記の4つの値を保持しておくと、今後役立ちそうです。

comment : 感情分析にかけたユーザーコメント
score : ネガポジスコア
type : OSの種別・レビュー/フィードバックの種別
created_at : Insert日時 

後からレビュー分析に使えるようなColumn情報を定義してCREATE TABLE(今回のテーブル名はreviews)しておきます。 PythonからDB接続するためにはドライバが必要になるので、psycopg2をimportしたうえでコネクションオブジェクトの取得を行います。

def get_connection():
    dsn = os.environ.get('Herokuが登録してくれた環境変数Key')
    return psycopg2.connect(dsn)

コネクションオブジェクトからDBカーソルを取得したら、必要なプロパティに対してInsertを実行します。

def insert_review(comment, score, type):
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute('INSERT INTO reviews (comment,score,type,created_at) VALUES (%s,%s,%s,%s)',
                        (comment, score, type, datetime.now().strftime("%Y/%m/%d %H:%M:%S")))
            conn.commit()

レビューコメントや感情分析スコアが無事にDB保存されている事を確認できたら、今回の目標は達成となります!

感情分析スコアDB
f:id:numatch-jx:20181217151025j:plain

作ってみて

今回、ユーザーレビューを感情分析してSlackに投稿する/DBに保存する機能のBotを作りました。 Android開発以外は門外漢で通ってきた自分ですが、質問に快くレスをくれた弊社の開発チームのおかげで無事にデプロイまで漕ぎ着ける事が出来たので感謝しかありません。 こういったチャレンジに取り組みやすい環境が弊社には揃っていると思います。

ところですが、Botに愛着を持ってもらう為に Cook-Tool-Reviewと名付けました。(個人的にですが)

なんでCook-Toolなのか。 それは弊社開発チームのプロジェクトは基本的にお菓子の名前で呼ばれている事から発想しました。 お菓子の調理を手伝う調理器具みたいな役割、Botがそんな風に役に立ったらいいなって気持ちを込めて名付けました。 今後もサービス開発の助けになるようなCook-Tool-Botを増やしていければいいなって思います。

サーバーレスなシステムをプラットフォームに依存せず作る 〜 #devboost 登壇に寄せて〜

この記事は Serverless Advent Calendar 2018JX通信社 Advent Calendar 2018の16日目です。

12月15日、Developers Boostにて「なぜサーバーレス『と』Dockerなのか 〜インフラ運用を最小化するサービス開発〜」というタイトルで発表させていただきました。本稿はその補足記事です。

※ 登壇資料中にもありますが、今回は サーバーレス=FaaS(AWS Lambda など)として扱います 。また、記事中の例示はすべて AWS Lambda のものです。

JX通信社の NewsDigest では、サーバーのデプロイ環境として、Docker クラスターやサーバーレスを活用 しています。お互い同列なデプロイ環境として考えて、ケースバイケースで使い分け ているような感じです。

サーバーレスや Docker の本番利用の事例も増えてきていると思いますが、サーバーレスかコンテナのどちらかしか使わないのではなく、どちらもメリット・デメリットある中で「どのように使い分けるのか」という判断が重要です。また、特にサーバーレスに関しては 「チームでシステム開発するには」 という視点を忘れずに持っておくべきだと考えています。

サーバーレスのチーム開発

チームでシステム開発をする際に、だいたい次のものは必要なのではないでしょうか。

  • プログラムを書くこと(ここは当たり前ですね)
  • 簡単にローカル環境が作れること
  • 自動テスト
  • 簡単にデプロイできること
  • CI/CD
  • 監視

サーバーレスを「ただのデプロイ先」として考えると当然、サーバーレスであっても、全く同じものが必要です。

f:id:yamitzky:20181215183218p:plain

サーバーレスに依存しないサーバーレスバッチ

サーバーレスを「ただのデプロイ先」として扱うには、アプリケーションの作り方も特定のプラットフォームに依存せず動くように工夫する必要があります。

まずは、次のようなコードを見てみましょう。次のプログラムは、CloudWatch Events で指定された text を標準出力する Lambda 関数です。

def lambda_handler(event, context):
    # CloudWatch Eventにて、JSONパラメータを設定 {"text": "text to show"}
    print(event['text'])

当然ながら、このシステムは、Lambda 上でしかろくに動きません。このような場合、次のようにリファクタリングをします。

import argparse

def show(text: str):
    print(text)

def lambda_handler(event, context):
    # CloudWatch Eventにて、JSONパラメータを設定 {"text": "text to show"}
    show(event['text'])

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('text')
    args = parser.parse_args()
    show(args.text)

こうすると、このシステムは 「サーバーレス環境でも動くだけの、ただの CLI プログラム」 として抽象化されます! ただの CLI プログラムなので、ただの CLI プログラムとして、テストをしたりすれば良いということになります。手元で検証するときも python main.py hogehoge と実行すれば、ただの CLI プログラムとして適切に実行されます。

サーバーレスに依存しないサーバーレス API

上記の例はバッチのようなシステムの用途の想定ですが、API についても同様です。

awsgiZappa を使うと、普通の Python の API が AWS Lambda にデプロイできる API に早変わり します。次の例は、ただの Flask 製 API としても、サーバーレスな API としてもデプロイできます。

import awsgi
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/')
def index():
    return jsonify(status=200, message='OK')

def lambda_handler(event, context):
    return awsgi.response(app, event, context)

このような形であれば、テストも「ただの Flask API」としてテストすれば良いですし、環境構築等も同様です。

同様の「サーバーレスじゃない API をサーバーレスでも動くようにするライブラリ」は Node(express)Go などでもあるみたいです。

これらのライブラリは、「今あるものをサーバーレスに移行する」というモチベーションが多いような気もしますが、個人的には新規プロジェクトでも積極的に使っています。

デメリットとしては、

  • 余分なライブラリを介するので遅くなるかもしれない
  • 「エンドポイントごとに関数をわける」みたいな設計にはならない

というのがあったりするので一長一短ですが、serverless フレームワークに乗せるにしても「プラットフォームに依存せず動く」という方針はキープしておくと良いように思います。

サーバーレスのテスト

あまり特別なことはしていませんが、サーバーレスではマネージドサービスが頻出するので、dynamodb-locallocalstack のようなモックをよく活用しています。

ただしこのやり方は Twelve-Factor App の開発/本番一致に反していますし、モックサービスは完璧ではありません。「本物のマネージド・サービスを使うべきだ」という意見もあると思います(一長一短ですね)。

また、t_wada さんによる「外部に依存したコードもテストで駆動する」も参考になると思います(Alexaスキルをテスト駆動開発しているものです)。

speakerdeck.com

サーバーレスなプロジェクトのローカル環境例

発表では docker-compose を使うとさらっと述べたのですが、サーバーレスなプロジェクトも、Docker クラスターへデプロイするプロジェクトも、基本的に docker-compose を使って環境構築がさらっとできるようになっています。JX 通信社のアクティブなリポジトリだとほぼ全て docker-compose.yml が用意されています。

例えば、次のように定義します。

version: '3.5'
services:
  app:
    build: .
    command: python -m flask run --host 0.0.0.0
    volumes:
      - .:/usr/src/app
    depends_on:
      - dynamodb

  dynamodb:
    image: amazon/dynamodb-local

こうしておくと、「ただの DynamoDB を使う一般的なプロジェクト」として扱うことができますし、サーバーレス環境にデプロイする際に必要な Liunx 下でのライブラリなども取れて便利です。dynamodb-admin みたいな、GUI のものも入れておくと便利です。

余談1:デプロイ

デプロイについては、

を使ってるケースが多いです。Apexも使ってましたが、最近は使ってないです。今から始めるならserverlessとかも良いと思います(参考:サーバーレスのメリット&本質を、AWS Lambdaを使って理解しよう)。

余談2:始め方

懇親会では「どうやって始めたの?」という質問も聞かれました。

サーバーレスもDockerクラスターも、まずは新規のプロジェクトで試しに本番運用してみて「良さそう」となってから横展開しました。サーバーレスは tech.jxpress.net でも1章割いて紹介したログ基盤が使い始めたプロジェクトです。

まとめ

伝えたかったのは、

  • IaaS/VPS を直接運用している場合は、サーバーレス化/Docker化するとインフラ管理が楽になる
  • 「サーバーレス vs コンテナ」じゃなくて、どっちも併用して使い分けたい
  • サーバーレスであっても「チーム開発」の視点を持ちたい
  • そのために プラットフォームに依存しないように作りましょう (本稿)

といったところです。

今回は貴重な登壇の機会をいただき、デブストオーガナイザーの近藤さん、スタディスト北野さん、ありがとうございました!

また、発表では拙いところもあったと思いますが、何かわからないことがあれば Twitter 上で @yamitzky までメンションを飛ばしていただければと思います。

www.wantedly.com

TDDトリプロをやってみた話

この記事はJX通信社Advent Calendarの11日目です。

前回にアドベントカレンダーの4日目の記事を書いたJX通信社でNewsDigestというiOS版アプリの開発担当として参画してますmoaibleです。

弊社では月に一度開発メンバーを集めて勉強会を実施する時間が設けられています。

11月に勉強会の幹事を担当した際にTDDトリプロをやってみたところ、思ったより盛り上がったのでその紹介をしていきます。

そもそもTDDとは?

f:id:moapp:20181211182810p:plain
tdd cycle

「TDD = Test Driven Development = テスト駆動開発」のことですね。

色んな方が普及してる開発手法なのもあって名前を聞くことも少なくないと思いますが、改めて簡単にやり方を説明しますと、

  1. テストコードを書いて失敗する状態にする
  2. 1のテストコードを失敗から成功する状態にする
  3. 2のコードがテストが成功する状態を保ったまま整理する

これを上記図の内容を借りると、

レッド → グリーン → リファクタリング

これを繰り返しながら動作もするし品質が保たれたコードを組み立てていく、という開発手法になります。

この記事ではTDDのこのサイクルを利用してちょっとしたゲーム形式っぽくみんなでTDDをやってみた話をします。

TDDトリプロ

「TDDしながらトリオでプログラミングしてみた」をこの記事ではTDDトリプロと勝手に呼びます。

TDDトリプロでは、TDDのサイクルである

レッド → グリーン → リファクタリング

のレッドとグリーン・リファクタリングをフェーズで分けます。

つまり、

  1. レッドフェーズ
    • テストが失敗するテストコードを書く
  2. グリーン・リファクタリングフェーズ
    • レッドフェーズで書いたテストコードを成功させリファクタリングまで済ませる

ということですね

あと2フェーズだけだと3人いてもクルクル回るだけで終わるので単純作業感が出てしまいます

それが嫌だったので更に制約を設けて、

ロール 役割
ライター フェーズでコードを書く人
オペレーター フェーズでコードを書く人に指示を出す人
オブザーバー ただ二人を傍観するだけの人

というような3つのロールを回しながら3人で1つのPCを操作しながら進めていく、というルールにで実際にやってもらいました

f:id:moapp:20181211183222p:plain
tdd group torio

(実際にトリオでグループ毎に分かれた図)

ただ始めると問題になるのが、身についてるプログラミング言語だったりテストフレームワークだったりキーボードはJISなのか英字なのか...etcのように人によって違う部分って色々あると思います

そこで、緩和してくれるTDDに特化したwebサービスを今回は利用してみました

Cyber dojo

http://cyber-dojo.org/cyber-dojo.org

f:id:moapp:20181211183434p:plain

こちらがcyber-dojoと言われる、ブラウザ上でテストコード込みで実装を進められることができるサービスです、ひとまず開いたら「I'm on my own」から入場してみましょう

f:id:moapp:20181211183549p:plain

豊富に用意されている各種言語、テストフレームワーク、お題を選ぶことができます

今回の例ではひとまず「Python + pytest」の開発環境で 「FizzBuzz」のお題に挑戦してみます

実際にTDDでテストを回してみる

f:id:moapp:20181211183807p:plain

まずはデフォルトで用意されてる状態のままテストを実行してみたいので、「test」を押してみます

f:id:moapp:20181211183838p:plain

そうすると実際に実行されて・・・

f:id:moapp:20181211184729p:plain

無事失敗しましたね(?)これでレッドフェーズはクリアしました

次の人に交代して今度はグリーン・リファクタリングフェーズに入ります

失敗してるテストコードをみてみます

import hiker

def test_life_the_universe_and_everything():
    '''a simple example to start you off'''
    douglas = hiker.Hiker()
    assert douglas.answer() == 42

テストケースとして42であるべき、と求められていますが実装の方はというと

class Hiker:

    def answer(self):
        return 6 * 9

はい、「6 * 9 = 42」ではないので失敗することが分かりますね!

これを正しいコードに直して再度testを実行してみます

f:id:moapp:20181211183949p:plain

今度は通りましたね!🎊

この例だとリファクタリングできるほどコード量もないのでグリーン・リファクタリングフェーズはクリアになりました

じゃあ次にPCを操作する人がレッドフェーズとして失敗するテストコードを書いて...というのを繰り返す、というのがこの勉強会でやってみたTDDトリプロの全容になります

やってみて分かったこと

勉強会はいつも2時間ぐらい取ってるのですが最初のお題で出したFizzBuzzが終わったら違うお題に挑戦してみたり、言語を変えてもう一回やってみようっていうグループもあったりして誰かが退屈するってことがなく無事終わることができました

当たり前なんですが、みんな今まで過ごしてきた環境も違うし経験も違います

これって業務にも通ずる話で当たり前なのですが、持ってる物が違う中でみんなで協力して物事を解決まで進めるってことが体感できるって意味でも良い時間が生まれたのかな、って所感でした

f:id:moapp:20181211184253j:plain

おわりに

いかがでしたでしょうか

是非この記事を見て、もしやってみたいって思ったらTDDトリプロ気軽に試してみてください 😃

CNNを使った分類問題の判断根拠(画像編)

この記事は JX通信社Advent Calendar の 12 日目です。

FASTALERT チーム機械学習エンジニアの mapler です。FASTALERT の機械学習とサーバーサイドの開発をしています。

FASTALERT(ファストアラート)は、SNSから事件・事故・災害等の緊急情報を検知し、配信する緊急情報配信サービスです。その処理の中でも画像認識は欠かせない存在です。

今回お話しするのは CNN (Convolutional Neural Network,または畳み込みニューラルネットワーク) というニューラルネットのモデルです。CNN は行列の空間情報を捉えるため、特に画像認識分野では非常に有効な手法です。

convolution
from Performing Convolution Operations

CNN の解釈性

ニューラルネットワークはとても有効な一方、その根拠が解釈しにくいとよく言われています。FASTALERT でも、ニュースの価値を判別するアルゴリズムを改善するためにモデルの判定結果を解釈することは重要です。

CNN もしくは深層学習の解釈性について、icoxfog417さんディープラーニングの判断根拠を理解する手法 でたくさんの研究や手法が紹介されています。

今回は Grad-CAM という判定根拠の可視化方法について実験してみようと思います。

Grad-CAM の仕組み

p
from Introduction to Deep Learning: What Are Convolutional Neural Networks?

上の図に、入力画像の受容野(Receptive field)がいくつかの Convolution + ReLU + Pooling 層を通って、一次元ベクトルに Flatten される直前まで、位置が変わらないことがわかります。(左上の部分が複数の Convlution 層を通った後の出力でも左上にあります)

A number of previous works have asserted that deeper representations in a CNN capture higher-level visual constructs [5, 35]. Furthermore, convolutional features naturally retain spatial information which is lost in fully-connected layers, so we can expect the last convolutional layers to have the best compromise between high-level semantics and detailed spatial information.

from: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization

(拙訳)たくさんの研究によって、より深い層ではより高度、豊富な特徴が捉えられると示されています。しかし、Flatten によって、空間情報は分類の fully-connected 層で失われます。最後の CNN 層は分類特徴と空間情報を両方持つ、可視化に最も利用できる層になります。

Grad-CAM はこの最後の CNN 層の勾配を利用して、どのニューラルのどの部分が出力のどの分類に一番貢献したかを計算します。

CAM
CAM from Learning Deep Features for Discriminative Localization

上の図は CAM という Grad-CAM が登場する前の CNN 根拠可視化手法です。

CAM は Grad-CAM と異なり、勾配を利用するのではなく、CNN 層の後の Fully-Connected 層と一つの GAP(Global Average Pooling)に入れ替えています。この GAP は(豊富な特徴情報を持っている)最後の CNN の出力の特徴図(Feature Map)を Pooling して、分類のクラスとマッピングします(Class Activation Mapping)。

上の図で犬(Australian terrier)を示す w_2(赤い四角)と w_n(緑の四角)の特徴図の重みは w_1(青い四角)より強いのがわかります。(逆に人間を判定する場合、w_1 の重みは強くなるでしょう。)

f:id:maplerme:20181212104555p:plain
from: Learning Deep Features for Discriminative Localization
こうやって重み w を付けて特徴図の加重合計(Weighted Sum)の結果、できた図は Class Activation Map(CAM)となります。 一方、Grad-CAM は GAP 層の入れ替え不要で、逆伝播の時の勾配を利用して、特徴図の重みを実現しています。
f:id:maplerme:20181212104717p:plain
from: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization
上の式の \alpha^{c}_k は CNN の出力特徴図(Feature Map)A^{k} がクラス c に判定される確率の y^{c} に対する偏微分、もしくは勾配(gradient)となります。

ここの勾配はニューラルネットワークの逆伝播で計算され、特徴図 A^{k}_{ij} の中の i、j 位置のピクセルの変化に対し、クラス c に判定される確率の影響を表しています。そして、この勾配はちょうど CAM の重みと同じになることを論文の中でも証明しています。(Grad-CAM は汎用化した CAM だと論文の作者は言ってます。)

f:id:maplerme:20181212104824p:plain
from: Grad-CAM: Visual Explanations from Deep Networks via Gradient-based Localization
CAM と同様に、重みの α と特徴図の A を加重合計して、重みの代わりに勾配(Gradient)を使った CAM (すなわち Grad-CAM)ができます。ちなみに、クラス判定にマイナス影響をする部分を"非表示"にするため、活性化関数 ReLU をかけています。

grad-cam
Grad-CAM Flow from http://gradcam.cloudcv.org/

Grad-CAM を火災画像で試してみる

モデルの作成:

学習データ:

火災 の画像約5000枚。
火災ではない 画像同じ約5000枚。

モデル:

f:id:maplerme:20181212105046p:plain
ResNet34 from https://arxiv.org/abs/1512.03385 (rotated)

ResNet34 の ImageNet の Pre-Train モデルを利用して、三番目のブロックから再学習します。最初の出力層は二項分類にします。

(学習バッチは PyTorch で実装していますが、省略させていただきます。)

評価

学習結果は以下になります

>> print(metrics.classification_report(gts, predict_labels))
(threshold = 0.5)
              precision    recall  f1-score   support

       False       0.78      0.57      0.66      5605
        True       0.66      0.84      0.74      5605

Confusion Matrix: f:id:maplerme:20181212105332p:plain

火災ではない画像が間違えて火災に判定されたことが結構多いです。True/False どっちも良い精度とは言えません。これから Grad-CAM でどこで間違えているのかを可視化してみましょう。

Grad-CAM を実装

PyTorch で実装となります。

class GradCAM:
    def __init__(self, model, feature_layer):
        self.model = model
        self.feature_layer = feature_layer
        self.model.eval()
        self.feature_grad = None
        self.feature_map = None
        self.hooks = []

        # 最終層逆伝播時の勾配を記録する
        def save_feature_grad(module, in_grad, out_grad):
            self.feature_grad = out_grad[0]
        self.hooks.append(self.feature_layer.register_backward_hook(save_feature_grad))

        # 最終層の出力 Feature Map を記録する
        def save_feature_map(module, inp, outp):
            self.feature_map = outp[0]
        self.hooks.append(self.feature_layer.register_forward_hook(save_feature_map))

    def forward(self, x):
        return self.model(x)

    def backward_on_target(self, output, target):
        self.model.zero_grad()
        one_hot_output = torch.zeros([1, output.size()[-1]])
        one_hot_output[0][target] = 1
        output.backward(gradient=one_hot_output, retain_graph=True)

    def clear_hook(self):
        for hook in self.hooks:
            hook.remove()

こちらは PyTorch の register_forward_hookregister_backward_hook メソッドで最終の CNN 層の出力(Feature Map)と逆伝播時の勾配(Gradient)を記録します。

画像を Grad-CAM に入れて可視化までの実装

まずはモデルをロードする。

image_model_path = "./fire.model"
image_model_save_point = torch.load(image_model_path)
image_model = models.resnet34(pretrained=False, num_classes=2)  # モデルを定義
image_model.load_state_dict(image_model_save_point['state_dict'])  # 保存したパラメータをモデルにロードする
image_model.eval()
id_to_label = {
    0: 'other',
    1: 'fire'
}

Grad-CAM class にモデルを代入するかたちになります。

grad_cam = GradCAM(model=image_model, feature_layer=list(image_model.layer4.modules())[-1])

PyTorch の ResNet モデルの layer4 は最後のブロックで、その最後の module (最終の CNN 層)を取得して、GradCAMの feature_layer に渡します。

画像を開いて前処理:

from PIL import Image
from torchvision.transforms.functional import to_pil_image

VISUALIZE_SIZE = (224, 224)  # 可視化する時に使うサイズ。PyTorch ResNet の Pre-Train モデルのデフォルト入力サイズを使います

normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])

image_transform = transforms.Compose([
        transforms.Resize(VISUALIZE_SIZE),
        transforms.ToTensor(),
        normalize])

path = "./fire.jpg"
image = Image.open(path)
image.thumbnail(VISUALIZE_SIZE, Image.ANTIALIAS)
display(image)

# save image origin size
image_orig_size = image.size # (W, H)

img_tensor = image_transform(image)
img_tensor = img_tensor.unsqueeze(0)

画像を Grad-CAM に入れる

model_output = grad_cam.forward(img_tensor)
target = model_output.argmax(1).item()

予測された class を取得して、逆伝播にいれる

grad_cam.backward_on_target(model_output, target)

最終層の勾配と出力を取得して、hooks をクリア

import numpy as np
# Get feature gradient
feature_grad = grad_cam.feature_grad.data.numpy()[0]
# Get weights from gradient
weights = np.mean(feature_grad, axis=(1, 2))  # Take averages for each gradient
# Get features outputs
feature_map = grad_cam.feature_map.data.numpy()
grad_cam.clear_hook()

勾配(重み weights)と出力の特徴図(Feature Map)の加重合計で CAM を算出して、ReLU を通します

# Get cam
cam = np.sum((weights * feature_map.T), axis=2).T
cam = np.maximum(cam, 0)  # apply ReLU to cam

CAM を可視化するために、resize して正規化

import cv2
cam = cv2.resize(cam, VISUALIZE_SIZE)
cam = (cam - np.min(cam)) / (np.max(cam) - np.min(cam))  # Normalize between 0-1
cam = np.uint8(cam * 255)  # Scale between 0-255 to visualize

元画像に CAM を合成

activation_heatmap = np.expand_dims(cam, axis=0).transpose(1,2,0)
org_img = np.asarray(image.resize(VISUALIZE_SIZE))
img_with_heatmap = np.multiply(np.float32(activation_heatmap), np.float32(org_img))
img_with_heatmap = img_with_heatmap / np.max(img_with_heatmap)
org_img = cv2.resize(org_img, image_orig_size)

可視化

import matplotlib.pyplot as plt
plt.figure(figsize=(20,10))
plt.subplot(1,2,1)
plt.imshow(org_img)
plt.subplot(1,2,2)
plt.imshow(cv2.resize(np.uint8(255 * img_with_heatmap), image_orig_size))
plt.show()

実際の画像を入れてみる

Flickr にある Commercial use allowed の火災写真をモデルに入れてみます。

まずは正解例から:

f:id:maplerme:20181212105805p:plain
Image Source: https://flic.kr/p/Pf9dW3
(other: 0.0100, fire: 0.9900)

この写真は 0.99 で正しく火災写真と判定されました。Grad-CAM の結果も正しく火災の場所を特定できていると思います。(消防員も特定してほしかった、、、後、この写真は火災訓練っぽいので、本当は 火災ではない が正解かもしれません。)

もう一つ正解例:

f:id:maplerme:20181212105840p:plain
Image Source: https://flic.kr/p/M9wgsU
(other: 0.2345, fire: 0.7655)

モデルは煙をうまく見つけています。

悪い例をも見てみましょう:

f:id:maplerme:20181212105855p:plain
Image Source: https://flic.kr/p/29d3vTz
(other: 0.7869, fire: 0.2131)

Grad-CAM は正しそうに炎に特定したが、判定結果をみたら 0.7869 で other と判定されました。つまり、学習したモデルはこの炎で写真は火災写真ではないと判定しました、、、(たしかに炎がすごすぎで、フェイクっぽいかもしれないですね)

ちなみに強制で 火災 にしてみたらどうなるでしょうか?

# target = model_output.argmax(1).item()  # 予測値をコメントアウトして target を 1 に指定して逆伝播させる
target = 1
grad_cam.backward_on_target(model_output, target)

f:id:maplerme:20181212105936p:plain (other: 0.7869, fire: 0.2131)

消防車の部分を見ていました!

※ 上記のソースコードは https://github.com/mapler/gradcam-pytorch においてあります。

まとめ

今回は Grad-CAM という手法で CNN が画像のどこを見て判定しているかを可視化してみました。Grad-CAM を利用したモデルを可視化することによって、モデルが何を学習したか、何を学習不足なのかがわかるので、実業務の中でモデルのチューニング、学習データの選別などの領域で活用できます。

FASTALERT が扱うような SNS の投稿には、一般的に、画像だけでなくテキストも含まれていますが、このような自然言語の分類タスクに関しても TextCNN などの CNN を使った先行研究が存在しています。次回は、GradCAM を使った TextCNN の可視化を紹介したいと思います。

References


JX通信社で一緒に働いてくださる機械学習エンジニアを絶賛募集中です。
アプリエンジニア、サーバサイドエンジニアも募集しています。
まずは話を聞くだけでも構いませんので、気軽にご連絡ください

www.wantedly.com

Dockerイメージを簡単に作成できるjibの紹介

この記事はJX通信社Advent Calendarの9日目です。

Androidエンジニアのsakebookです。NewsDigestというアプリを開発しています。

普段は業務でKotlinばかり触る自分が紹介するのは「jib」というツールです。

github.com

jib

jibはJavaのアプリケーションをOCI Image Formatに則った形式でDockerイメージにしてくれるツールです。

jibを使うことで利用者は既存のJavaアプリケーションを少しの作業でDockerイメージにできます。

jib自体を簡単に使うためにMavenとGradleのプラグインが合わせて公開されています。

今回はGradleプラグインでの利用方法の紹介をします。

導入

既存のアプリケーションにプラグインを追加するだけで利用できます。

  • build.gradle
plugins {
  ...
  id 'com.google.cloud.tools.jib' version '0.10.1'
}

ターミナルで次のように実行すればDockerイメージが作成されます。

$ ./gradlew jibDockerBuild

Dockerfileの生成のタスクもあったんですが、0.10.1で削除されました

デフォルトで利用されるベースイメージはDistrolessと呼ばれるものを利用します。

これはExecutableなDockerイメージで、shellすらもないイメージです。そのため、jibで生成されるイメージは必要最低限のものになり、軽量なものになります。

Registryに登録

プラグインでは作成したイメージをRegistryに登録することもサポートしています。Credential Helperにも対応しており、設定していると次のようにシンプルに書けます。

  • build.gradle
jib.to.image = 'gcr.io/REPLACE-WITH-YOUR-GCP-PROJECT/image-built-with-jib'

直接認証する場合の例は次のようになります。

  • build.gradle
jib {
    to {
        image = "registry.hub.docker.com/XXXXXXX/YYYYYYYY"
        auth {
            username = USERNAME
            password = PASSWORD
        }
    }
}

DockerHubの USERNAMEPASSWORD は何らかの方法で渡します。 gradle.properties とかが簡単です。

  • gradle.properties
USERNAME=YOUR_USERNAME
PASSWORD=YOUR_PASSWORD

次のコマンドを実行すると指定したRegistryにイメージがPushされます。デフォルトだとDockerHubです。

$ ./gradlew jib

ファイル作成が手間だったら引数に渡すこともできます。

$ ./gradlew jib \
    -Djib.to.auth.username=YOUR_USERNAME \
    -Djib.to.auth.password=YOUR_PASSWORD

使ってみた

普段から使っているKotlinでDockerイメージが動かせるということなので、少し欲しかった機能を作りました。

弊社ではGitLabをホスティングしてます。GitLab CIでDockerが動くので、今回欲しかった機能はそこで動かすことにしました。

github.com

放置されてるissueが無いように定期的に呼びかけてくれるやつです。

GitLab CIでScheduling Pipelinesという機能があるのでそちらで上記のDockerイメージを動かします。

  • .gitlab-ci.yml
services:
  - docker:dind

job:on-schedule:
  only:
    - schedules
  script:
    - docker run -i --rm 
      -e DUE_DATE_REMINDER_HOST=$DUE_DATE_REMINDER_HOST
      -e DUE_DATE_REMINDER_PROJECT_ID=$DUE_DATE_REMINDER_PROJECT_ID
      -e DUE_DATE_REMINDER_GITLAB_TOKEN=$DUE_DATE_REMINDER_GITLAB_TOKEN
      -e DUE_DATE_REMINDER_LIMIT=$DUE_DATE_REMINDER_LIMIT
      -e DUE_DATE_REMINDER_SLACK_WEB_HOOK_URL
      sakebook/gitlab-issue-reminder

直接Dockerイメージを指定すると、ExecutableなDockerイメージだからか2度動いてしまう問題があったのでDocker in Dockerの構成で動かしました。このあたりはプラグインでDockerイメージを生成するときの設定か、ymlの書き方で解決できそうな気もしています。

動くとこんな感じでお尻を叩いてくれます。

f:id:sakebook:20181208140517p:plain
過ぎてるもののほうが多い

まとめ

ExcecutableなDockerイメージの生成を、ほぼ設定無しでできるのは便利です。JVMでありがちなクラスパス周りとかメイン関数のパスの指定とかも諸々をよしなにしてくれます。

さらにjibの開発は活発なので、今後にも期待できます。issueを上げてもすぐに対応してくれました。 この記事を読んだ人にとって、何かアプリケーション作成の選択肢が増えると幸いです。

参考

Introducing Jib — build Java Docker images better / Google Cloud Platform Blog