Slackメッセージの感情分析

前の記事に引き続き、Slackメッセージの感情分析を行います。

メッセージの収集

まずはSlackメッセージの収集からです。

Slackチャネル上に飛び交うメッセージをログングするボットを作成し、対象チャネルにinviteしてメッセージを収集します。

ボットはhubotで作りました。 ロギングするスクリプトは以下で、これを scripts/ディレクトリに配置しておきます。

=== logger.coffee ===

LOG_DIR = 'log'

fs = require('fs')
util = require('util')
dateFormat = require('dateformat')

unless fs.existsSync(LOG_DIR)
  fs.mkdirSync(LOG_DIR)

module.exports = (robot) ->
  robot.hear /.*/, (res) ->
    msg = res.match
    today = dateFormat(new Date(), "yyyymmdd")
    logfile = util.format('%s/%s.txt', LOG_DIR, today)

    fs.appendFile logfile, msg + "\n", 'utf8', (err) ->
      if err
        console.log(err)

このボットはメッセージをひっそりせっせとログファイルに保存し続けます。:-)

データセットの作成

続いて、ここが最も肝心なデータセットの構築です。

入力メッセージと出力ラベルの組をどうするか?

例えば、次のようにメッセージとリアクションのやり取りがあったとします。

f:id:sanshonoki:20171007074317j:plain

ログファイルには次のように記録されます。

ありがとうございます:angel: :smile:
:+1:

コメントに対するリアクション③(:tada:)は残念ながら記録されません.. ><

また、このボットが複数チャネルでメッセージ収集している場合、

ありがとうございます:angel: :smile:
何か他の話題のメッセージ
:+1:

と①と④の間に他の話題のメッセージが混入してくる場合があります。

なので、①と④を入力メッセージと出力ラベルの組とすることは難しく、①と②を入力とラベルの組としました。

データ数を増やすという意味では (①, ③)や(①, ④)の組み合わせも含めたかったです...

基本的なアルゴリズム
  1. メッセージ部分とラベル部分に分ける

    リアクション文字は :pray::sob:のように :何とか: というようになっています。 正規表現 ':[a-z\+]{1}[\w_-]+:' でこのパターンを取り出します。

    'ありがとうございます'[:angel:, :smile:] に分割します。

    正規表現のテストは https://regex101.com/ が役に立ちました。 f:id:sanshonoki:20171008070639p:plain

  2. MeCabでメッセージ部分を単語に分割する

    'ありがとうございます'['ありがとう', 'ござい', 'ます'] と3つの単語に分割されます

  3. メッセージとラベルの組を作る

    • メッセージ部分: ['ありがとう', 'ござい', 'ます']
    • ラベル部分: [:angel:, :smile:]

    から

    • ['ありがとう', 'ござい', 'ます'] -> :angel:
    • ['ありがとう', 'ござい', 'ます'] -> :smile:

    という2つの組を作ります

ラベルのマージ

最初は メッセージ -> リアクション という予測ができると面白いなと考えていましたが ラベルの数のばらつきが大きいため難しそうでした。

【理由】ラベル数の分布に大きな偏りがあると最も数が多いラベルのみを出力するようにネットワークが学習されてしまいます

なので、ラベルをマージして positive か negative かの2種類になるようにしました。

変換テーブルの例
元ラベル 変換後
f:id:sanshonoki:20171007083023p:plain:w20:smile: positive
f:id:sanshonoki:20171007083804p:plain:w20:tada: positive
f:id:sanshonoki:20171007083835p:plain:w20:sob: negative
f:id:sanshonoki:20171007084420p:plain:w20:scream: negative
f:id:sanshonoki:20171007084454p:plain:w20:bow: (なし)

f:id:sanshonoki:20171007084454p:plain:w40 は "ごめんなさい"(negative)の意味で使われること以外にも "よろしくお願いします" の意味でも使われるので マッピングなし としました。(このようなリアクションは:bow:以外にもいくつかあります)

マージすることで入力とラベルが同じになった組はさらに1つにまとめます。

学習

サンプル数

46449メッセージから学習用と検証用のデータセットを構築しました。

学習データセット(90%) 検証用データセット(10%)
1048 117

それぞれ positive と negative が 半々ずつ含まれています。

positive、negativeに分類できるリアクション絵文字を含む文 となるとかなり少なくなってしまいました。。⊂(¯×¯٥)⊃

パラメータ

前回やったときとパラメータを少し変えましたがそれ以外は同じです。

  • seq_len: 30
  • hidden_unit: 300

結果

5〜10 epoch学習して 65〜70%の正解率となりました。(データ数が少ないせいかばらつき大きいです) 同じラベルを常に出力する あるいは完全にランダムに出力する と 約50% になるはずなので一応は感情を捉えていると思います。

あらためて入力データを眺めてみると、人間が見ても positive なのか negative なのか 分からない入力もたくさんありました..。

判断が難しいメッセージの例
入力メッセージ 正解ラベル
冬 よ 来い positive
経由 で negative
待機 positive
つくら ね ば negative

今回はデータ数が少なくて精度がイマイチというのもありますがSlackコミュニケーションの特性上、文脈が複数に分かれたメッセージとしてやり取りされるのでこのまま単純にデータ数を増やすだけでは精度は改善しないかもしれません。。

しかし、、データ集めるの難しいですね。。

判定ボット

せっかくなのでボットで判定できるようにしました ƪ(•◡•ƪ)"

f:id:sanshonoki:20171008063956j:plain

FloydHubのトライアルで使えるGPU利用枠が激減..

なんと、

FloydHubのトライアルで使えるGPU時間が10/1から大幅に減ってしまったようです... ><

100時間 → 2時間 (◞‸◟)

Our promotional period, during which we offered 100 hours free GPU has already ended. If you signed up during that period, you should still have the credits. The old plans are valid till Oct 1st 2017

Is my free plan changed to 2 hours free GPU according to new free plan? - FloydHub Forum

まだ残り時間あったよな? と、

新しいGPUジョブを動かしてみると、

Error: You do not have enough credits to run this job. Please upgrade your plan or buy a powerup to continue running jobs

無情なエラー(T-T)

100時間のうち80時間ぐらいは残っていたはずなのに0になっていました..。

公式ページによると今のFreeプランは

  • first 2 GPU hours (トライアル特典)
  • 400 GB storage
  • 20 CPU hours / mo.

のようです。

CPUしか動かせないんだったら 自分のMac でいいのではないでしょうか。。

ちなみに、有料プラン は

  • 最初の10時間は 約154円/時間(基本)
  • それ以降は 約77円/時間 (追加分)

のようです。

RNNで感情分析(Sentiment Analysis)

RNN(Recurrent Neural Network)を使って感情分析(Sentiment Analysis)をしてみます。 今回はchainerを使って実装していく中で理解に苦労した点を図にまとめます。

RNNとは何なのか? RNNの基本、については以下の記事が分かりやすいです。

ちなみに、3つ目の記事は おなじみのMNIST文字認識が CNN ではなくRNN で実装されていて興味深かったです。

Sentiment Analysis

感情分析を理解および実装していく上でポイントになるのは以下の2点です。

  1. CNN(画像分類)との違い
  2. ロスの計算

CNN(画像分類)との違い

CNNで画像分類をするときは次のようにバッチサイズ分のデータを1回で入力します。

f:id:sanshonoki:20170929051417p:plain:w450

一方、感情認識では 時間方向の依存関係、つまり文脈を学習するので時間方向に入力していく必要があります。

f:id:sanshonoki:20171002202054p:plain

つまり、バッチサイズ分の文章を取り出し、それを位置ごとに切って文章の長さの回数分ネットワークに入力します。

ちなみに、このように時間方向に展開していくことを unroll や unfold と言うようです。

ロスの計算

感情分析の場合、文章(単語列)に対して1つのラベルがつくので最後の単語の出力(y_n)に対してのみ教師ラベルとのロスを計算します。それ以外の出力は無視します。

f:id:sanshonoki:20171002215028p:plain:w400

chainer のコードだと

if j <  seqlen - 1:
    model.predictor(x)
else:
    accum_loss = model(x, t)
    accum_loss.backward()

あるいは

loss = model(x, t)
if j == seqlen - 1:
    accum_loss += loss 
    accum_loss.backward()

といった形になります。

また、TensorFlowの場合は時間方向にまとめて展開できる dynamic_rnnモジュールを使って以下のように表現できます。 (Embed、LSTM層とFC層が別々になっています。)

f:id:sanshonoki:20171002215824p:plain:w400

感情分析のチートシート的なまとめ図

記憶の定着のためにまとめてみました。( •ᴗ•)

f:id:sanshonoki:20171002215211p:plain

その他のRNN応用でのロスの計算

感情分析以外もRNNの応用はいくつかあり

  • 文章生成
  • 機械翻訳(seq2seq)
  • 画像のキャプショニング

などがあります。

f:id:sanshonoki:20171003210723p:plain:w400

(画像は http://karpathy.github.io/2015/05/21/rnn-effectiveness/ から)

入力と教師ラベルの与え方でよく混乱するのでこれも図にしました。

ポイントは

  • 推論(予測)フェーズと違って出力はロスの計算以外に使わない(推論フェーズでは出力が次の入力になる)
  • 入力(x)を1つずらして教師ラベル(t)を作る

かなと思います。

文章生成(character-level language model)

f:id:sanshonoki:20171002215425p:plain:w350

機械翻訳(seq2seq)

f:id:sanshonoki:20171003211707p:plain

<GO><EOS> は文章の始まりと終わりを表す記号です。

機械翻訳では入力単語列を反転して入れると結果が良いらしいです。 順番に入れると1番目の単語を予測する上で大事な最初の入力単語の情報がデコーダの時点でより失われるのだと勝手に理解しています。

chainerでの実装

データセット

UdacityのDeepLearning nanodegree講座のSentiment-RNNの課題で使ったデータセットを使います。

映画レビューの英語テキストを positive, negative の2クラスに分類するという課題で実装が正しければ数epochの学習の後、おおよそ80%程度の精度が出るはずです。

コード

chainerの昔のptbのサンプルを参考にしつつ実装してみました。

(trainerを使っても実装しようとしましたがなんかうまく動いてくれません。。><)

github.com

動かしたところ80%の精度が出たのでアルゴリズムは大丈夫のようです。ƪ(•◡•ƪ)"

次はこれを使って Slackのメッセージを分類してみようと思います。

画像認識APIと翻訳APIを使って日本語キャプショニング

Microsoft Computer Vision APIのキャプショニングの性能がイケてるらしい。

日本語キャプションには未対応なので Microsoft Translator API と組み合わせて実験してみました。

f:id:sanshonoki:20170906051557j:plain

Serverless Frameworkを使って画像認識と翻訳のAPIを叩くLambdaファンクションを作成し、それをAPI Gatewayでhttpエンドポイントとして公開。 botからはそのhttpエンドポイントを叩いて結果を投稿します。

いくつか画像を試してみる !!

Webからいくつか画像を拾って試してみます。

競馬編

http://www.geocities.jp/sunday_silence1977_2/photo/06arima/arima06 http://www.geocities.jp/sunday_silence1977_2/photo/06arima/arima06

認識結果「大勢の人々の前に立っている人々の集まり」

確かに、その通り。


http://worldsports-c.com/images/page_img/850072bef46c88f6760738608d72dca6.jpg http://worldsports-c.com/images/page_img/850072bef46c88f6760738608d72dca6.jpg |

認識結果「馬の背に乗っている人々のグループ」

正解 :-)


http://sp.jra.jp/beginner/yosou/img/a-4-3/01.png http://sp.jra.jp/beginner/yosou/img/a-4-3/01.png

認識結果「馬に乗っている男」

惜しい。

アニメ編

続いて子どもがハマっていた仮面ライダーエグゼイド。

http://www.toei.co.jp/tv/ex-aid/story/__icsFiles/afieldfile/2017/03/17/1_1.jpg

認識結果「雪の道にスキーに乗っている人々のグループ」

そうか、ひと昔ふた昔前はスキーウェアも派手だったもんな.. と納得。


http://www.toei.co.jp/release/movie/__icsFiles/afieldfile/2017/08/23/artimgpreview.jpg

認識結果「柵の側にスケートボードに乗っている若い少年」

影がスケートボードか?!

ドラマ編

現在放映中で毎週楽しみにしている月9ドラマ、コードブルーから

http://blogs.c.yimg.jp/res/blog-8f-c7/yukki_na0716/folder/769139/92/9384592/img_4?1266915772 http://blogs.c.yimg.jp/res/blog-8f-c7/yukki_na0716/folder/769139/92/9384592/img_4?1266915772

認識結果「窓の前のテーブルに座っている人々のグループ」

主役の二人もグループと言われてしまうと。。 ╮(•ω•)╭


https://cdn.mdpr.jp/photo/images/2c/099/w700c-ez_220ea4338737fc71234305b81c9a56655ce703ebcd4f7d10.jpg https://cdn.mdpr.jp/photo/images/2c/099/w700c-ez_220ea4338737fc71234305b81c9a56655ce703ebcd4f7d10.jpg

認識結果「部屋に立っている人々のグループ」

確かにその通りです。


https://instagram.com/p/BXt1ua5hGBJ/media/?size=l https://instagram.com/p/BXt1ua5hGBJ/media/?size=l

認識結果「カメラのポーズをとる比嘉真奈美ら」

有名人は名前も認識するようですね。嗚呼、その他になってしまった浅利くん… w

キャプショニングの使いみちについて考えてみる

実験してみての感想はエンジニア目線では機械学習でここまで認識できるのか!という驚きです。 一方でユーザー目線ではこのシュールな文章は一体何に使えるんだろう?とも

そんな中、

面白いかも?? と考えついたのは、

自分を客観視するツール

です。ƪ(•◡•ƪ)"

例えば、感情が高ぶって怒っているとき「中年のオジサンが怒って話している」と機械に客観的に言われれば 冷静になれるのではないでしょうか。。

コード

今回、使ったコードはこちらです。 github.com

APIキー

から取得できます。(無料プランあり)

Microsoft Computer Vision APIMicrosoftアカウント が必要で Microsoft Translator API のほうは Azureアカウント が必要です。

無料の範囲は

となっていてちょっとした実験レベルは無料で十分まかなえます。

MicrosoftアカウントでのMicrosoft Computer Vision API の試用は30日間で有効期限が切れてしまいますが Azureアカウントで CognitiveServiceAPIのインスタンスを立てれば継続して使えるようです。(1分あたり20回のレートリミットの制限もなかったです)

実装で参考にしたページ

Python3.6で sls invoke local すると `Error: spawn python3.6 ENOENT`

サーバレスなアプリケーションを構築するためのツール Serverless Framework で Python3系が使えるようになったということを最近知って

dev.classmethod.jp

の記事を参考に使ってみました。

ただ、

sls invoke local とローカルでファンクションを実行すると、

Error: spawn python3.6 ENOENT

が出ちゃう.. ╰(゚x゚​)╯

デプロイした remote で実行すると問題なく動くのだが気持ち悪い…

Webを検索すると同じエラーが出ている人がいたのでこれを参考にやってみる

https://doruby.jp/users/nakamatsu/entries/%60sls-invoke-local%60-%E3%81%99%E3%82%8B%E3%81%A8%60Error–spawn-python3-6-ENOENT%60%E3%81%A8%E8%A8%80%E3%82%8F%E3%82%8C%E3%81%9F

が、これでもうまくいかない… (◞‸◟)

解決方法

結局うまくいった方法はこれでした。

$ brew install python3   # pyenvで3.6をインストールしてもNG。system環境に3.6をインストールする
$ pyenv local system  # パッケージをインストールするときはsystem環境のpythonにして行う
$ pip3 install requests  # pyenvのsystem環境で必要なパッケージ(この場合は requests)をインストール

この3ステップでエラーが出なくなりました。(2番目のステップが抜けてました)

スッキリです。(•̀ᴗ•́)و

ちなみに、パッケージがすでにインストールされていてファンクションを実行するだけのときは pyenv で system環境 にしなくても実行できるようです。

ステッドラーのシャープペンシル

4歳の息子がひらがなの練習をしているとき、たまたま 普通の鉛筆ではなくステッドラーシャープペンシル を使うことがあった。

そしたら、普通の鉛筆だとグー握りしてしまうのにこのシャープペンシルだと自然とちゃんと握れて、またすごく書きやすそうではありませんか ( ☉་☉ )

ステッドラー シャープペンシル 1.3mm 771 https://www.amazon.co.jp/dp/B001OUU32A

1.3mmの太芯シャープペンシル

【特長】・1.3mmと太い芯のシャープペンシルの為、軽い筆圧で筆記することができ安定感があります。

・エルゴノミックコンセプトに基づく三角軸と直径16mmの太軸、 スベリ止め加工の施されたグリップゾーンが特徴です。 ノック部には繰り出し式の字消しを装着しております。

もちろん、自分では書きやすいと思って 1000円 を投じ使っていたわけですが、小さい子どもがサクサク文字を書き出したのを目の当たりにし その価値は十分あったなと実感しました ( •ᴗ•)

同時に、子どもは嘘をつけない正直なユーザー であることを改めて実感

CordovaによるReact.jsアプリのネイティブアプリ化でハマったところ

React.jsで作ったスライドショーアプリをCordovaでネイティブアプリ化しました。ƪ(•◡•ƪ)

f:id:sanshonoki:20170815204337p:plain:w200

github.com

いくつかハマった点があったのでまとめておきます。

なお、アプリ化の手順に関しては以下のサイトが大変参考になりました。♪(・ω・)ノ

qiita.com

Cordovaの導入やコマンドの使い方に関してはこのあたり

ハマった点… ><

react-routerを使ったときに起動ページのパスが違う

対策

各環境ごとに起動ページのパスが違うのでルーティングを複数用意する必要があります。

起動ページのパス
React.jsアプリ /
cordova serve ios /ios/www/index.html
cordova emulate ios /Users/xxx/Library/Developer/CoreSimulator/De…25B7D/CordovaReactSlickExample.app/www/index.html

なので、react-routerを使うときは

<Router>
    <Route exact path="/" component={App} /> <!-- Reactのwebアプリでの確認用 -->
    <Route path="*/index.html" component={App} /> <!-- cordovaアプリ用 -->
</Router>

のように2種類の初期ページ表示用のルーティングをもっておく必要がありました。

public/以下のassetsへのアクセス

これに関連して、React.jsアプリのpublic以下のassetsへの参照も少し工夫する必要がありました。

  1. 初期ページを起動時パス取得用のダミーページ(Homeコンポーネント)にする

     <Router>
         <Route exact path="/" component={Home} />
         <Route path="*/index.html" component={Home} />
     </Router>
    
  2. ダミーページで起動時のパスを取得し、Globalな state に保存する

     class Home extends React.Component {
         constructor(props) {
             super(props)
         }
    
         componentDidMount() {
             // 起動時のパスを取得する
             const root = path.dirname(this.props.location.pathname)
    
             // stateに保存するアクションを呼ぶ
             this.props.setRootPath(root)
         }
    
         render() {
             // 実際の初期ページへリダイレクトする
             return (
                 <Redirect to="/login" />
             )
         }
     }
    
     const mapDispatchToProps = dispatch => {
         return {
             setRootPath: (path) => dispatch(setRoot(path)) // 起動時パスを保存するアクション
         }
     }
     export default connect(null, mapDispatchToProps)(Home)
    
  3. assetsにアクセスするときは起動時パスを考慮した形で行う

    const root = getState().home.root // 起動時のパスをstateから取得
    const url = `${root === '/' ? '' : root}/images/slick/pictures.json`
    fetch(url)
    

Fetch API cannot load file:///android_asset/www/xx/xxx.json. URL scheme “file” is not supported のエラーが出た(Android

対策

fetchは file:// をサポートしてないらしく XMLHttpRequest を使って記述します。

https://github.com/github/fetch/pull/92#issuecomment-140665932

function fetchLocal(url) {
  return new Promise(function(resolve, reject) {
    var xhr = new XMLHttpRequest
    xhr.onload = function() {
      resolve(new Response(xhr.responseText, {status: xhr.status}))
    }
    xhr.onerror = function() {
      reject(new TypeError('Local request failed'))
    }
    xhr.open('GET', url)
    xhr.send(null)
  })
}

fetchをfetchLocalに置き換えればok。

このエラーはiOSのときには出ませんがfetchLocal置き換えの副作用はありません。

Unable to post message to https://www.youtube.com. Recipient has origin file://.のエラーが出た

対策

config.xml に以下を追加

<allow-navigation href="https://*youtube.com/*" />

https://cordova.apache.org/docs/en/latest/reference/cordova-plugin-whitelist/