強化学習事始め(Open AIのgymを使って手っ取り早く始める)

数式は、一切挟まない解説(と言うか主に実装)です。
強化学習の中でもQ-learningの一番単純な話です。

数式込みでちゃんと理解したい人は以下の記事をお勧めします。
強化学習とは?(What is Reinforcement Learning?)
また、最新の話題も知りたいよって人は以下の2nd editionが良いと思います。
http://webdocs.cs.ualberta.ca/~sutton/book/the-book.html

概要

強化学習とは、環境(Environment)との相互作用を通してエージェントを学習させる手法です。
ポイントは、プログラムの書き手が事細かにエージェントに指示を出さなくても、勝手に学習してくれる点です。

ざっくり書くと、エージェントの行動(action) に対して,状況(state)が変化し、報酬(reward)が支払われ、
エージェントは、試行錯誤の末、将来貰える報酬が最大になるように学習します。

例題(Open AIのFrozenLake)に基づく解説

今回題材として扱うのは、以下のFrozenLake-v0です。
gym.openai.com
環境(env)は、OpenAI側が引き取ってくれるので、エージェントの実装のみに注力できます。

FrozenLake-v0の問題設定

  • ペンギンがいて、碁盤の目状の氷の上からの脱出を目指します。
  • 碁盤の目は、縦4、横4マスです(state(s)は、16パターン)。
  • ペンギンは、上下左右へ1マス移動できます(=action(a)は、4パターン)。

エージェントの学習

  • Q(s,a)は、64パターン(16*4)になります。
    • Q(s,a)は、ある状態(s)の時にとった行動(a)により得られる(将来に渡る)報酬です。
  • ある状態(s)のもと、ある行動(a)をとることで、報酬が得られた場合は、次回もその行動を取りやすくするために、Q(s,a)は増加します。
  • 逆に、ある行動(a)をとった次の状態(s+1)で得られる報酬(Q(s+1,a))が、現在の状態(s)で得られる報酬(Q(s,a))よりも小さいのであれば、Q(s,a)を減じてその行動を慎むようにします。

実装 (ここを読んで頂くのが一番手っ取り早いかも)

# -*- coding: utf-8 -*-
#!/usr/bin/env python

import gym;
import numpy as np;
import matplotlib.pyplot as plt; #結果をグラフ描画しないなら不要

env = gym.make('FrozenLake-v0'); #環境の読み込み
"""
環境(Environment)は、以下の通り。

SFFF       (S: starting point, safe)
FHFH       (F: frozen surface, safe)
FFFH       (H: hole, fall to your doom)
HFFG       (G: goal, where the frisbee is located)
"""

#状態(state)は、16種、行動(action)は、4種 なので、16行4列
Q = np.zeros([env.observation_space.n,
              env.action_space.n]);

lr = 0.7; #学習率(=learning rate) (小さい(0.0)と遅いし、大きい(1.0)と局所解に陥りやすい)
disct= 0.99; #報酬のディスカウント(discount)

history = []; #グラフにプロットするためなので、グラフ描画しないなら不要

#state :s
#action : a
for i in range(2000): #2,000回ほど繰り返す。
    s = env.reset(); #環境を初期化し、初期状態を返却する (本問題では初期状態は、0)
    done = False;
    
    #学習
    for _ in range(300): # 1episode中の試行は、300回まで
        #選び方は、貪欲アルゴリズム
        #ランダム(np.random.randn)がエージェントの試行錯誤を表す。
        #学習が進むとrandomな行動をとることは慎むように(i+1)で割っている。
        a = np.argmax(Q[s,:] + np.random.randn(1,env.action_space.n)*(1.0/(i+1)));

        #試行が終了した場合は、done = True (goalにたどり着いた or 穴に落ちた)
        #ゴールできた場合は、reward = 1.0、その他は0.0
        s_1,reward,done,info = env.step(a); #今回、infoは利用しない

        #Qテーブルの更新 (ここが、学習の肝)
        Q[s,a] = (1-lr) * Q[s,a] + lr * (reward + disct * np.max(Q[s_1,:]));

        """
        以下のように展開した方が理解しやすい(かも知れない)
        Q[s,a] = Q[s,a] + lr * ( reward + disct * np.max(Q[s_1,:]) - Q[s,a]);

        成功していた場合は、rewardが1.0なので、次回もその行動を取りやすいくなるように、Q[s,a]は、加算される
        もし、とった行動(a)により、将来の報酬(Q[s_1,:])が減るようであれば、
        次回は、その行動は取りづらくなるように、Q[s,a]は減じられる
        (ただし、報酬が発生するようなら加算に転じることもある)
        """
 
        s = s_1;
        
        if done == True:
            break;
        
    history.append(reward); #ゴールできていれば1.0。その他は0.0

plt.plot(history);
plt.show();

結果

結果は、以下の通りです。
横軸は、試行回数(episodeの数)で、縦軸は、成功(1)or失敗(0)です。
はじめの方は、失敗ばかりですが、200回あたりからは、失敗はほぼなくなっていることがわかると思います。
また、700回付近で、スランプ(ちょっと失敗している)が見られますが、以降は安定しています。
f:id:marmarossa:20170326230638p:plain

ランダムな要素ありで学習しているので、得られるグラフは毎回異なります。

word2vecで得られたベクトルを主成分分析してみる

概要

前回の記事(以下のリンク)で、word2vecを利用して、単語をベクトルへ変換しました。
その時は、とりあえず、200次元のベクトルとして学習させましたが、どんな感じに学習されているのか(次元数が多すぎたりしないのかなど)興味がわいたので、
主成分分析を行って調べて見ることにします。

marmarossa.hatenablog.com

準備

  • 主成分分析用途
    • pip install sklearn
  • グラフ作成用途
    • pip install pandas
    • pip install matplotlib

主成分分析って何?

元のベクトルが持つ情報をなるべく欠損することなく縮約する手法です。
何が嬉しいかと言うと、例えば10次元のベクトルが2次元や3次元で済めば、保存容量も少なくて済みますし、可視化も簡単です。
詳しい解説は、以下のリンクを参照してください。
http://www.statistics.co.jp/reference/software_R/statR_9_principal.pdf

手順

手順と言うほどの長さではないので、コードを直接読んで頂いた方が早いと思います(コメントもつけてます)。
私は、元のword2vecの次元を200で作成しているので、主成分分析した後の次元も200で揃え、 累積の寄与率を計算しています。
累積の寄与度が1に近くほど元のベクトルから情報の欠損がないことを示しています。

#!/usr/bin/env python
# coding: utf-8

from sklearn.decomposition import PCA
from gensim.models import Word2Vec
import matplotlib
import matplotlib.pyplot as plt
import pandas as pd

# word2vecのモデルを読み込みます。
model_file_name = "jawiki_wl_all.zip_200_1.model"
model = Word2Vec.load_word2vec_format(model_file_name, binary=True)

# 元が200次元あるので、変換先のベクトルも200次元にしておきます。
pca = PCA(n_components = 200);

# word2vecのモデルから取り出した全部の単語ベクトルの配列を引数として、主成分分析を行います。
pca.fit_transform(map(lambda k : model[k], model.vocab.keys()));

ccr = 0.0; #累積寄与率 (0で初期化)

#(1行目) 最終的に得る結果 (idxは添え字(グラフ描画のために1始まり))
#(2行目) 各ratioに対して
#(3行目) ccrへratioを加算して行く
graph_input = [[idx+1,ccr] \
 for idx,ratio in enumerate(pca.explained_variance_ratio_) \
 for ccr in [(lambda x,y:x+y)(ccr,ratio)]]

# pandasとmatplotlibによるグラフ描画
df = pd.DataFrame(graph_input, columns=list('nr'))
df.plot(x=['n'],y=['r'],kind='scatter')
plt.title(u'cumulative contribution ratio', size=16)
plt.show();

結果

結果は、以下の通りです。縦軸が累積寄与率、横軸が主成分(寄与率の高い順)です。
グラフの立ち上がりが急であればあるほど、より少ない次元で表現可能であることを示し、グラフが直線になるほど、情報は均等に分散していることを示します。
25次元くらいまでは、ぐっと立ち上がりますが、その後ゆるやかな増加になっています。
特定の主成分に偏りすぎることなく綺麗に分布しているように思えます。
f:id:marmarossa:20170309105535p:plain

pythonのgensimライブラリを利用して日本語wikipediaの全文からword2vecを学習させるまでの全手順

Word2Vecを計算するまでの全手順を書いておこうと思います。

ご存知の方も多いと思いますが、Word2Vecは、単語をvector化して扱う技術です。
以下の解説がわかりやすいと思います。
Vector Representations of Words  |  TensorFlow

同じような使われ方をする単語同士が近くなります。
例えば、「夏」という単語の近くには、「冬」が配置されます。
さらに、vector化することで、単語の足し引きができるようになります。
例えば、「叔母」ー「女」+「男」=「叔父」となります。

前置きは、これくらいにして、実際に計算するまでの手順に移りたいと思います。

Word2Vecを計算するまでの概要

学習に利用する文章の収集

  • 今回は、wikipediaの全文から学習させます。

フィルタリング

  • wikipediaには、学習には必要のないXMLのコンテナ部分や記号(wiki記法)などが含まれているため削除します。

わかち書き

  • 文章のままでは学習できないので、単語に分割します。

Word2Vecのモデルの学習

  • Pythonのgensimライブラリを利用して学習させます。

手順の詳細

学習に利用する文章の収集

  1. wikipediaの全文(bz2形式)をダウンロードします。
  2. w2vの解析に必要な日本語の部分を取得する。
    • 今回は、日本語からword2vecを学習させるため、wiki記法など通常の日本語には現れない記号などは削除しておいた方が無難です。
    • また、上記のwikipediaのdumpはXML形式で、各ページは、pageタグで区切られています。
    • 例えば、以下のように、pageタグで囲まれた範囲を切り出し、数字を除いた非ascii文字を取り出すと、それっぽい日本語文が得られます。
#!/usr/bin/env python
# -*- coding: utf-8 -*-

#ja.wikipediaのdumpから<page>...</page>の部分の非ascii文字(日本語を期待)を切り出しファイルへ出力する。

import string
import bz2
import sys
import os
import re

def simple_ja_filter(ifn,ofn):
    try :
        MAX_FILE_SIZE = 64 * 1024 * 1024; #1ファイルが大体、数十Mbyteくらいになるようにしておく 
        TEXT_NUM_IN_A_ZIPFILE = 10000;
        table = string.maketrans(
            "".join(map (lambda x : chr(x), #(文字コードから文字へ変換する.)
                         filter (lambda x : x not in range(48,58),#数字(0-9)は除いて、
                                 [i for i in range(32,127)]))), #print可能なascii文字は、
            "".join(chr(127) for i in range(127 - 32 - 10)) #削除する (注:正しくは、del文字に置き換え)
        )

        with bz2.BZ2File(ifn) as bz2_f :
            buf = "";
            is_in_page = False;
            cnt = 0;
            of = None;

            for ln in bz2_f: #1行読み出し
                ln = ln.strip();
                
                if cnt % TEXT_NUM_IN_A_ZIPFILE == 0 or of != None and of.tell() > MAX_FILE_SIZE:
                    if of != None:
                        of.close();

                    of = open(
                        ofn + "_" + str(cnt / TEXT_NUM_IN_A_ZIPFILE + 1).zfill(4) + ".txt",
                        "w"
                    )
                    
                if ln == "<page>": #<page>タグを見つける
                    buf = ""
                    is_in_page = True;
                    continue;
                
                if ln == "</page>": #</page>までを1ページとして扱う
                    is_in_page = False;
                    if len(buf) != 0:
                        of.write(buf)
                    cnt = cnt + 1;
                    
                    if cnt % 10000 == 0: #どれくらい進んだかを出力する
                        print str(cnt) + " articles done";
                    continue;

                if is_in_page: #数字を除いてascii文字を削除する
                    non_ascii = ln.translate(table).replace(chr(127),"");

                    #空の行、数字のみの行は省く
                    if len(non_ascii) != 0 and re.match(r"^[0-9]+$",non_ascii) == None:
                        buf += non_ascii + os.linesep
                    continue;                
                        
            if of != None: #最後に残っている内容を書き出し
                of.close();
    except Exception as e:
        print e

if __name__ == '__main__':
    if len(sys.argv) < 3:
        print ("usage : [wiki dump file name(bz2)] [output file prefix]")
        sys.exit();

    ja_wiki_dump_fn = sys.argv[1]
    out_zip_fn = sys.argv[2]
        
    simple_ja_filter(ja_wiki_dump_fn,out_zip_fn)

分かち書き

  • MeCabを利用して単語に分割します。
    • MeCabのインストールなどは、以前の記事の中に記載しています。
    • 今回は、名詞と動詞、形容詞、形容動詞、副詞だけ取り出して学習させてみることにします。
    • 例えば、以下のコマンドを実行するとモデルの学習に利用する単語列が得られます(mecabに100Mbyte弱割り当てています)。
cat [入力となるテキストファイル名] | mecab -b 100000000 | grep -e 名詞 -e 動詞 -e 形容詞 -e 形容動詞 -e 副詞 | grev -v 助動詞 | cut -f 1  > [結果の出力先]
  • bashなどで、for文を回して、ファイルに出力するのが手っ取り早いと思います。
    • こんな感じ。for i in {1..214}; do time cat jawiki_`printf %04d $i`.txt | mecab -b 100000000 | grep -e 名詞 -e 動詞 -e 形容詞 -e 形容動詞 -e 副詞 | grep -v 助動詞 | cut -f 1 > jawiki_`printf %04d $i`.wl; done

Word2Vecのモデルの学習

  • Gensimのmodelにword2vecもあるのでそれを利用します。
  • 以下のプログラムを走らせると学習し、モデルが得られます。
    • (長いので本文の最後においておきます)
  • ただ、上記のプログラムは大量のメモリを食います。wikipedia全文だと数十Gbyteほど。ですので、AWSのEC2(r4.4xlarge = 122Gbyteのメモリ)を利用します。
  • word2vecの学習のプログラムは、以下の通りです。
    • プログラムのインプットは、分かち書きして得られた複数の文書をzipで固めたものです。
      • zipで固めずに、1つづつファイルを読み込んでも良いのですが、容量を食うのでまとめてしまいました(この辺は好みの問題かと思います)。
#!/usr/bin/env python
# -*- coding: utf-8 -*-

import os
import sys
import pickle
import zipfile
import collections

from gensim.models import Word2Vec

"""
<<入力(引数)>>
引数は、zipfileの名前を期待する。

zipfileの構造は以下を期待する
archive内の各memberは、単語(word)が改行(\n)で区切られている

例:
 archiveの中身(member)は以下の様になっており
  jawiki_word_list_0001
  jawiki_word_list_0002
  ...

 各memberの中身は以下の様担っている
 word_1
 word_2
 word_3
 ...

<<出力>>
wordのリスト
[word,word,word...]

"""
def read_word_list_from_zip(filename):
    try : 
        word_list = []
        with zipfile.ZipFile(filename) as archive:
            for name in archive.namelist():
                print name
                with zipfile.ZipFile.open(archive,name) as zf:
                    for word in zf:
                        word_list.append(word.strip())
        return word_list
    except IOError as ioe:
        print ioe; #File Open失敗

"""
利用する単語の集合を規定する。

今回は、word_listに登場するwordの内、上からvocabulary_size分を利用する単語とする。
(全部利用するならこの処理は要らない)
"""
def build_dic(word_list,vocabulary_size):
    #vocabulary size分の単語を選定する
    counter = collections.Counter(word_list);
    count = [['UNK',-1]]; #'UNK'は、UNKnown wordsで、未知語
    count.extend(counter.most_common(vocabulary_size - 1)); #-1するのは、'UNK'の文

    #辞書の作成
    dictionary = dict();
    for word,_ in count:
        dictionary[word] = len(dictionary);

    #今回は使いませんが、逆引き辞書を作る場合
    #reverse_dictionary = dict(zip(dictionary.values(),dictionary.keys()));
    
    return dictionary

"""
word_list (単語の1次元配列)から利用する単語のみを抜粋し、
sentences (単語の2次元配列)に変換する
"""
def wordlist2sentences(word_list,dic):
    MAX_SENTENCE_LEN = 30;    
    sentences = [];
    sentence = [];
    cnt = 0;
    
    for word in filter(lambda word : word in dic, word_list):
        cnt = cnt + 1;
        if cnt % MAX_SENTENCE_LEN == 0:
            sentences.append(sentence);
            sentence = [];
        sentence.append(word);
    if len(sentence) > 0:
        sentences.append(sentence);

    return sentences;
        
def w2v_learn(sentences,size,min_count):
    model = Word2Vec(
        sentences,
        size=size,
        window=5,
        min_count=min_count,
        workers=4)
    return model;

def output_model_and_dic(w2v_model,fn,size,min_count,dic):
    output_file_name = os.path.basename(fn) + "_" + \
                       str(size) + "_" + \
                       str(min_count) + ".model"

    w2v_model.save_word2vec_format(output_file_name,binary=True)

    '''
    dic_file_name = os.path.basename(zip_file) + ".dic"
    with open(dic_file_name, 'w') as f:
        pickle.dump(dic,f)
    '''

def main(zip_file_name):
    #データの読み出し
    word_list = read_word_list_from_zip(zip_file_name);
    print ("reading done");

    #利用する単語の選定
    dic = build_dic(word_list,100000);
    print ("build dic done")

    #Word2Vec様のインプット生成
    sentences = wordlist2sentences(word_list,dic);

    #Word2Vecのモデルの学習
    print ("start learning")
    W2V_SIZE = 200;
    W2V_MIN_COUNT = 3;
    w2v_model = w2v_learn(sentences,W2V_SIZE,W2V_MIN_COUNT);
    print ("learning done")

    #書き出し
    output_model_and_dic(w2v_model,zip_file_name,W2V_SIZE,W2V_MIN_COUNT,dic);
            
if __name__ == '__main__':
    if len(sys.argv) < 2:
        print ("usage : [zip file]")
        sys.exit();
    zip_file_name = sys.argv[1]

    main(zip_file_name);
EC2でのgensimのインストール
  • Gensimがまだ入っていない人は以下の手順でインストールしてください。
  1. pip自身をアップデートします。
    • sudo pip install --upgrade pip
  2. gcc (g++)をインストールします。
  3. gensimをインストールします。
    • sudo pip install gensim
  • pipが見当たらないという人は、which pip とかで探してみましょう。それでもなければそもそも入ってない様ですので、sudo yum install python-pipを試してみましょう。

遊んでみる

  • word2vecを計算するコードは以下になります。
    • 「夏」に一番近い単語だと、「春」になりました。
    • 「叔母」 - 「女」 + 「男」=「叔父」となっているので、いい感じですね。
#!/usr/bin/env python
# coding: utf-8

from gensim.models import Word2Vec
import json

model_file_name = "jawiki_wl_all.zip_200_1.model"

model = Word2Vec.load_word2vec_format(model_file_name, binary=True)

def most_similar(pos_list,neg_list):
    try :
        return map(lambda t: (t[0].encode('utf-8'),t[1]),
                   model.most_similar(
                       map(lambda w : unicode(w,'utf-8'),pos_list),
                       map(lambda w : unicode(w,'utf-8'),neg_list),
                       topn=10));
    except Exception as e:
        print (str(e))

#'夏'に近い単語を上位10個出力する
print "「夏」に一番近いのは… "
for t in most_similar(['夏'],[]):
    print t[0], t[1]

print ""

print "「叔母」- 「女」+ 「男」 = ?"
for t in most_similar(['叔母','男'],['女']):
    print t[0], t[1]    

あとがき

  • 今回、名詞、動詞や形容詞も放り込みましたが、利用する単語の取捨選択は、要調整です。
  • 単語は、原型の方を選択して、学習させた方が上手くいくと思います。

AWSのlambda上でMeCabを実行する (他のバイナリへも応用可)!!

日本語の解析をする場合、とりあえず形態素解析を実施することになると思います。
手っ取り早く動かすのであれば、ローカルで動作させれば良いですが、サーバ側で処理することも多いかと思います。
AWS lambda + APIGateway で動作させることが出来れば、便利なので、その事前準備として、lamdab上で形態素解析を行うと言う話です。

日本語の形態素解析といえば、MeCabが定番なのではないでしょうか。
MeCab: Yet Another Part-of-Speech and Morphological Analyzer

ただ、mecabc/c++で実装されており、lambdaで動作させるためには、lambdaで利用されているCPUが解釈できるバイナリを準備する必要があります。

lambdaが動作する環境は、AMIとしてAWSが公開しています。
Lambda 実行環境と利用できるライブラリ - AWS Lambda
要は、このAMIからEC2インスタンスを一旦起動し、立ち上げたEC2以上でmecabバイナリへコンパイルし、その成果物をlambdaで利用すれば良いことになります。
また、lambdaでプログラムを実行した場合、/var/task配下で実行されることになります。
今回は、/var/task/mecab配下へmecabバイナリや辞書をおくことにします。

以下に具体的な手順を示します。

  1. lambdaのAMIからEC2インスタンスを起動します。
    • AWSコンソールの EC2のタブからAMIの画面を開き、publicイメージから以下を検索します。
      • amzn-ami-hvm-2016.03.3.x86_64-gp2
  2. ログイン後、取り敢えず環境をアップデートしておきます。
    • sudo yum update
  3. Mecabコンパイルに必要なソフトウェアをインストールします。
    • sudo yum install gcc-c++
    • (iconvは最初から入っているので明示的にインストールする必要はありません。)
  4. MeCabのダウンロード (ここはEC2内でも外でも大丈夫です。適宜DLしてEC2インスタンスへ持っていきましょう。)
    • 本体のコードをダウンロードします。
      • 上記の本家のサイトからダウンロードしてください。
      • mecab-0.XXX.tar がDLできると思います。
    • 辞書(IPA dic)をダウンロードします。
      • 同じく本家のサイトからダウンロードしてください。
      • mecab-ipadic-X.X.X-yyyymmdd.tar がダウンロードできると思います。
  5. MeCabをインストールするためのディレクトリを作成します。
    • /varへ移動 ( cd /var)
    • lambdaが実行される時のフォルダを作成 (sudo mkdir task)
    • mecabをインストールするためのフォルダを作成 (sudo mkdir task/mecab)
      • これで /var/task/mecabというフォルダができました。
  6. MeCabのビルド
    • mecab…tarを解凍します。
      • tar xvf [filename]
    • 解凍後のフォルダ内に移動します。
    • configureを実行します。
      • ./configure --prefix=/var/task/mecab --with-charset=utf8
      • prefixで、インストール先を指定します。
      • with-charsetで、エンコードを指定します。今回は、UTF-8にします。
    • ビルドします。 make と打つだけです。
    • インストールします。 make install で /var/task/mecab配下にコピーされるはずです。
  7. IPAdicのビルド
    • IPAdicのビルドもMeCabと同様です。
    • 差分は、cofigureの時のパラメータです。
      • 辞書は、./configure --prefix=/var/task/mecab --with-charset=utf8 --with-mecab-config=/var/task/mecab/bin/mecab-config でconfigureを実行します。
  8. できたバイナリ(/var/task/mecab配下)を、一旦ローカルのコンピュータへコピーしておきます。
    • ここでは、作業用ディレクトリ内に、mecabというフォルダを作成して一式保存することにします。
  9. 確認します。
    • /var/task/mecab/bin/mecab を実行した後
    • 任意の日本語文字列を入力します。
      • 例えば、「あらゆる現実を全て自分の方へねじ曲げたのだ。」と入力すると、以下の出力が得られます。
あらゆる	連体詞,*,*,*,*,*,あらゆる,アラユル,アラユル
現実	名詞,一般,*,*,*,*,現実,ゲンジツ,ゲンジツ
を	助詞,格助詞,一般,*,*,*,を,ヲ,ヲ
全て	名詞,副詞可能,*,*,*,*,全て,スベテ,スベテ
自分	名詞,一般,*,*,*,*,自分,ジブン,ジブン
の	助詞,連体化,*,*,*,*,の,ノ,ノ
方	名詞,非自立,一般,*,*,*,方,ホウ,ホー
へ	助詞,格助詞,一般,*,*,*,へ,ヘ,エ
ねじ曲げ	動詞,自立,*,*,一段,連用形,ねじ曲げる,ネジマゲ,ネジマゲ
た	助動詞,*,*,*,特殊・タ,基本形,た,タ,タ
の	名詞,非自立,一般,*,*,*,の,ノ,ノ
だ	助動詞,*,*,*,特殊・ダ,基本形,だ,ダ,ダ
。	記号,句点,*,*,*,*,。,。,。
EOS

lambda上での実行

  1. AWS lambda functionを作成します。
  2. 作成したlambda functionのコードをローカルへコピーします。
    • コマンドラインから下記を実行すると、ソースコードをダウンロードするための署名付きURLが発行されます。
      • aws lambda get-function --function-name [作成したlambda関数] --region [作成したリージョン] --profile [利用するプロファイル(defaultを使う場合は略)]
    • 発行されたURL (Code.Locationの中身)をブラウザへコピーするなどしてコードを落とします。
      • 恐らく初回であれば、index.jsというファイルが1つ落ちてくると思います。
  3. index.jsを編集します。
    • サンプルを下の方に置いておきます。
  4. ファイルをlambdaへ転送します。
    • 転送用にzipファイルを作成します。
    • AWSへ転送します。
      • 例:aws lambda update-function-code --function-name [lamdba関数名] --zip-file fileb://src.zip --region ap-northeast-1
    • MeCabの辞書は重たいので、zipファイルで転送するよりも、S3からコピーすることをオススメします。
  5. 実行する
    • 今までの手順が正しくできていると、以下の結果がlambdaのログに吐かれていると思います。
      • 「あらゆる 連体詞,*,*,*,*,*,あらゆる,アラユル,アラユル」
  • 以下にindex.jsのサンプルを置いておきます(後述の注意事項も合わせて参照願います)。
'use strict';
const exec = require('child_process').exec;
const fs = require('fs')
exports.handler = (event, context, callback) => {
    /*
      OSコマンドインジェクションは避けたいので、
      以下のように、変動要素(解析文字列)を渡すのは危険
      const cmd = 'echo "あらゆる" | /var/task/mecab/bin/mecab' 
    */

    fs.writeFileSync("/tmp/input.txt","あらゆる");
    const cmd = '/var/task/mecab/bin/mecab /tmp/input.txt';
    /*
      lambda実行時は、/tmp配下以外は書き込めないので注意
     */
    
    exec(cmd , (err, stdout, stderr) => {
	if (err) {
	    console.error(err);
	    callback(null, "error");
	    return;
	}
	
	console.log(stdout); //ここでmecabの実行結果が表示される。
	callback(null, "done");
    });
};
  • 注意事項

execコマンドは、便利ですが、最終的に sh -c "/var/task/mecab/bin/mecab" と言うように入力がシェルに渡ります。
つまり OS コマンド インジェクションに注意しないといけません。
今回は、固定文字列(「あらゆる」)なので問題ないですが、実際は、任意の文字列を渡したいと思います。
その場合、外部からの入力がコマンドラインのパラメータに渡らないように注意してください。
例えば、形態素解析したい文字列は、ファイルへ書き出しておき(input.txt)、execコマンドで実行するのは、「mecab input.txt 」と言うように固定してしまうことです。
もちろん、正攻法で、サニタイズするのもありです。

mecabを実行しようとしてエラーが出た場合

  • lambdaを実行しようとして、libmecab.so.2 がない(no such file or directory)と言って怒られた場合
    • それは、EC2で、make installした時に、しれっとリンク (ln -s -f libmecab.so.2.0.0 libmecab.so.2) が作成されていたのに、lambdaでは作成していないからです。
      • なので、mecab/lib/libmecab.so.2.0.0 を libmecab.so.2 にrenameしてからlambdaへ持って行くか、mecab実行前にlnでリンクを作成するなどしてください。
      • エラー例:error while loading shared libraries: libmecab.so.2: cannot open shared object file: No such file or directory

AWS: cognitoのuser poolで管理者または開発者が払いだしたIDとパスワードを利用してブラウザからサーバへログインする

今回は、ユーザの登録は管理者がAWSのコンソールなどで実施し、一般ユーザはログインだけするケースです。
ブラウザからのcognitoに接続して認証情報(access token等)を取得することが目的です。

具体的には、以下の「管理者または開発者によって作成されるユーザーの認証フロー」の部分を実装します。
docs.aws.amazon.com

AWSのコンソールを使ってUser Pool の設定をする。

docs.aws.amazon.com
User Poolの設定では、emailの登録を必須にする(orしない)やパスワードの長さや文字種別などを設定します。
(ここは、ドキュメント通りで特に迷わ無いかと思います。)
今回は、emailのみ必須にしました。

User Poolの画面で、ユーザを作成する。

ここは、コンソールの画面の流れに従います。
AWSコンソールで作成した場合は、以下のリンクの「Force Change Password」状態です。
docs.aws.amazon.com

Identity Poolを作成し、User Poolを結びつけます。

ここは、以下のドキュメント通りに実施すれば迷うことは無いかと思います。
ユーザープールをフェデレーテッドアイデンティティと統合する - Amazon Cognito

認証に必要なjavascriptライブラリを集める

基本的には、以下のリンクのステップを実施していくだけです。
Amazon Cognito ID SDK for JavaScript でユーザープールを使用するようにセットアップする - Amazon Cognito

Stanford JavaScript Crypto Library (sjsc.js)は、自前でビルドが必要になります。
sjclをダウンロードしたフォルダに移動して、以下のコマンド(2つ)を実行します。

./configure --with-codecBytes 
make

sjcl.jsが更新されるので、出来上がった新しいsjcl.jsを使用します。

javaが無いと失敗するので、それっぽい警告が出た場合は、javaをインストールしましょう。
homebrewが既にインストールされていれば、以下のコマンドです。

brew cask install java

homebrewが無い場合は、手前味噌ですが、以下の記事のhomebrewのインストールの箇所をご参照ください。
marmarossa.hatenablog.com

また、amazon-cognito-identity.min.js が、初期化で失敗したので、以下からコードを一式持ってきました。
github.com
(細かいですが、ブラウザで動作させるために、importやexportは消さないと動きません。)

amazon-cognito-identity.min.jsそのままだと、なぜか、以下の初期化時に失敗しました…(私だけ?)

new AWSCognito.CognitoIdentityServiceProvider()

ブラウザ側の実装

MFAの設定を省略すると、実装するのは、大きく以下の2ステップです。

  1. ユーザにIDと仮パスワードを入力して貰いcognito側とやり取りする。
  2. 新しいパスワードをcognito側に送信し、「confirmed」状態にする。

AWS側の提供している以下のコード例は、さっぱりしているので、背後で何が起きているのかを付け足します。
docs.aws.amazon.com

まずは、ステップ1のユーザにIDと仮パスワードを入力してもらい、cognitoとやり取りする部分です。

ブラウザとcognitoの間は、 Remote Password (SRP)プロトコル でやり取りしており、実際にはパスワードは送信されません。
(APIには、パスワードを送信するものも用意されていますが、安全な(例えばman in middle攻撃を受け無い)通信路が確保されていることが利用条件です。)

SRP自体は、以下のwikiが詳しいです。
Secure Remote Password protocol - Wikipedia

個別に拾ってきたCognitoUser.jsの中でSRPの全体を制御して、具体的な計算は、AuthenticationHelper.js内で実施しており、
上記のwikiと照らし合わせると大体何をやっているのかが掴めます。

サンプルにすると以下の通りで、冒頭の「管理者または開発者によって作成されるユーザーの認証フロー」のステップだと1から5に相当します。

var poolData = {
    UserPoolId : '{{YOUR USER ID}}',
    ClientId : '{{YOUR Client ID}}',
    Paranoia : 7 //7である必要性はないが、0だと古いIEなどでセキュアでない。
};
var userPool = new CognitoUserPool(poolData);
var userData = {
    Username : '{{username}}', //作成したユーザ名です。
    Pool : userPool
};
var cognitoUser = new CognitoUser(userData);

var authenticationData = {
    Username : '{{username}}',//作成したユーザ名です。
    Password : '{{password}}', //作成したユーザのパスワードです。
};

var authDetails =
    new AuthenticationDetails(authenticationData);

cognitoUser.authenticateUser(authDetails,{
    onFailure : function(err) {
	//何か処理に失敗した場合に呼ばれる。
    },
    
    onSuccess : function(result) {
	//Confirmed状態で認証に成功した場合に呼ばれる。
	//token等はresultに格納されている。
    },
    
    newPasswordRequired: function(userAttributes,requiredAttributes) {
	//Force Change Password状態で、認証に成功した場合に呼ばれる。
	//この後、ユーザから新しいパスワードを受けとり更新する必要がある。
    }
});

次は、ステップ2の新しいパスワードをcognitoへ送る部分です。
注意点として、CognitoUserは、先ほどのインスタンスを使ってください。
CognitoUserクラスは、cognitoとやり取りするときに、内部でSessionを自動付与して送付しており、再度作成し直すと、Sessionは当然クリアされているのでエラーになります。

//第2引数は、追加で設定が必要な属性(誕生日や性別など)がある場合に指定します。
//今回は、emailのみ必須の属性で、管理コンソール側で設定してしまっているのでnullとしています。
cognitoUser.completeNewPasswordChallenge(new_password,null,{
    onFailure : function(err) {
	//なにか失敗した
    },
    onSuccess : function(result) {
	//成功した(confirmedに以降した)
    }
    //MFAを設定する場合は略
});

以上でMFAの設定をしない場合は、無事にトークンが取得できたと思います。

参考

公式のドキュメントは以下の通りです。
http://docs.aws.amazon.com/cognito/latest/developerguide/cognito-dg-pdf.pdf

bowerを使ってangularとbootstrapのウェブアプリ開発環境を秒速で整える方法

(注:mac前提で書いています。windowsのことは良くわからないです…)

急ぐ人のために

bowerとか既に入っているよ、という人は以下のコマンド(1,2)を打った後に、後ろの方についているHTML、js、cssファイルをコピーしてください。以上です。

  1. bower install angular-bootstrap
  2. bower install bootstrap

ファイル構成は、以下を想定しています。

index.html
js/app.js
css/app.css

ゆっくり解説

ここからは、秒速ではなく、1つ1つやっていきます。
流れとしては、homebrew → nodebrew → nodejsとnpm → bower → angularとbootstrapのインストールと言う流れになります。

homebrewについて

homebrewは、apple社が予め入れといてくれなかった、でも自分には必要!!、というソフトウェアのバージョン管理に使います。
今回のケースだと、nodebrewですね!

  • インストール

以下のコマンドをコンソールに打ち込みます。(1行です)

/usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

上のコマンドは、変わっているかも知れないので、本家を確認することをオススメします。
brew.sh

nodebrew について

nodebrewは、nodejs周りのパッケージのバージョン管理ツールです。
nodejsで複数の環境を切り替えながら開発する時に便利です。
普段は、最新版を使いつつ、例えば、AWSのlambda向けの開発時には、バージョン、XX.xでと言うように使うと便利です。(多分)

  • インストール
brew install nodebrew

もしくは、以下のgithubの手順でインストールします。(こちらだとhomebrew不要です。)
github.com

nodejsとnpmについて

nodejsは、サーバサイドのjavascript実行環境です。とは言いつつ便利なのでローカル環境でも使っています。
IOの非同期処理に特化しており、大量のIOを消費するような昨今のサーバにピッタリです(AWSはこれが無いと辛いです)。
npmは、nodejsで使えるライブラリ群を管理してくれます(npm install hogehogeでサクサク入るので便利です)。

  • インストール

nodebrewを最新版にします。

nodebrew selfupdate

nodejsとnpmをインストールします。ここでは、バイナリでインストールしています。
(ホーム直下に、.nodebrew/srcがない場合は作成してください。)

nodebrew use latest

入ったバージョンを確認します。

nodebrew ls

バージョンを指定して使います(下のXは自分の環境に入ったバージョン番号にしてください)。
PATHを通して置かないと(bashだとexport PATH=$HOME/.nodebrew/current/bin:$PATH)して置かないと、nodeとnpmが見つからないので注意してください。

nodebrew use vX.XX.XX

もしくは、以下の本家からインストールします(この場合は、nodebrew不要です)。
Node.js

bowerについて

bowerとは、ウェブのためのパッケージ管理ソフトです。
要は、ブラウザ上でjavascriptを使ってウェブアプリを作成する際のパッケージ管理をしてくれます。

  • インストール

以下のコマンドを実行します。
gオプションをつけているので、環境全体にインストールされます(嫌いな人は外しましょう)。

npm install -g bower

Bower — a package manager for the web

angularについて

angularhは、google社が提供しているウェブアプリ向けのjavascriptライブラリです。
利点をあげると以下のようになるでしょうか。

  1. DOMを直接操作しなくて良い。
  2. 流儀に則って書くだけで、ModelとViewとControllerに勝手に分かれるので、後から見てわかりやすい。
  3. Directiveを使うとHTMLの操作が楽
  4. 独自にインポート機構を持っているので、ファイルに分割する場合に気にすることが少ない。
  • インストール

今回は、bootstrapと一緒に使うので、以下のコマンドを実行します。

bower install angular-bootstrap

github.com
AngularJS — Superheroic JavaScript MVW Framework

bootstrapについて

bootstrapは、twitter社が作成したcss群です。
レスポンシブルなUIを構築するのに便利で、画面を横に12分割して考えます。
2009年頃に、スマホ向けのサイトの作成に苦労した記憶がありますが、bootstrapを使えば解決です。

  • インストール
bower install bootstrap

getbootstrap.com

サンプルファイル

  • HTMLはコチラ (index.html)
<!DOCTYPE html>
<html ng-app="app">
  <head>
    <meta charset="utf-8" />
    <link rel="stylesheet" type="text/css"
	  href="bower_components/angular-bootstrap/ui-bootstrap-csp.css"/>
    <link rel="stylesheet" type="text/css"
	  href="bower_components/bootstrap/dist/css/bootstrap.css"/>
    <link rel="stylesheet" type="text/css"
	  href="css/app.css"/>
    <script type="text/javascript"
	    src="bower_components/angular/angular.min.js" ></script>
    <script type="text/javascript"
	    src="bower_components/angular-bootstrap/ui-bootstrap-tpls.min.js"></script>
    <script type="text/javascript"
	    src="js/app.js" ></script>
  </head>
  <body>
    <div class="container sample" ng-controller="ButtonsCtrl">
      <div class="row">
	<div class="col-lg-8">
	  <button type="button" class="btn btn-primary">
	    hello bootstrap (no action)</button>
	</div>
	<div class="col-lg-4">
	  <button type="button" class="btn btn-primary" ng-click="click_btn()">
	    hello angular</button>
	</div>
      </div>
    </div>
  </body>
</html>
  • jsファイルはこちら (js/app.js)

ui.bootstrapのライブラリを読み込んで使っています。
"app"と言うモジュールにしていますが、ここは適宜変更してください(変更した場合は、html側の修正もお忘れなく)。
"ButtonsCtrl"も同様です。

angular.module('app', ['ui.bootstrap'])
    .controller('ButtonsCtrl', ['$scope',function($scope) {
	$scope.click_btn = function() {
	    alert("DONE!")
	}
    }]);

ぶっちゃけ無くても動きます。

div.sample {
    margin-top : 30px;
}