NumPyを使った協調フィルタリングプログラムをAWS Lambda上で動かせる!そう、Lambda Layersならね。

 現在、プライベートでプロダクト開発を行っています。そのメイン機能がアイテム(商品)のレコメンドになるのですが、このレコメンドはユーザーベースな協調フィルタリングを利用して実装しています。ざっくりいうと「評価の傾向(=嗜好)が似ている人を探して、あなたはまだ評価してないけど似ている人が高評価なものを推薦する」というものになります。

 なるべくサーバーレスに構築しようと考えており、この実装を AWS Lambda 上で動かそうと考えていました。ただ、NumPyを用いたプログラムを Lambda 上で動かすにはひと工夫必要でした。この記事では、この問題と Lambda Layers を用いて解決する手順についてご紹介します。なお、この記事は、 AWS Advent Calendar 2018 - Qiita の6日目枠になります。

レコメンド実装

 まず、実装したレコメンドプログラムについて簡単に紹介します。ちゃんとリファクタリングできていない部分や、機械学習的にもっとこうした方がいい/こうしないとダメという点があるかと思います。ぜひアドバイスいただければありがたいです。

import numpy as np

# ピアソン係数の計算を行う
def pearson_corr(x, y):
    if np.all(x == 0) or np.all(y == 0): #空判定
        return 0.0
    if np.all(x == y):
        return 1.0
    x_diff = x - np.mean(x)
    y_diff = y - np.mean(y)
    return np.dot(x_diff, y_diff) / (np.sqrt(sum(x_diff ** 2)) * np.sqrt(sum(y_diff ** 2)))

# 平準化関数
# 評価の平均をと取り、それぞれの値からマイナスする. ただし、NAの項目については0とする.
# [ 4.0, 4.0, 4.0, 4.0, 4.0, 2.0, 4.0, 4.0, 3.0, 3.0] は、平均が3.6なので
# [ 0.4, 0.4, 0.4, 0.4, 0.4,-1.6, 0.4, 0.4,-0.6,-0.6] となる
def normalize(evaluations):
    normalized_evaluetions = []
    for evaluation in evaluations:
        evaluation_value = evaluation["evaluation"]
        average = np.sum(evaluation_value) / np.count_nonzero(evaluation_value)
        evaluation_value = np.where(evaluation_value != 0, evaluation_value - average, 0)
        normalized_evaluetions.append({
            "id": evaluation["id"],
            "evaluation": evaluation_value
        })
    return normalized_evaluetions

# 類似度の高いユーザーを取得する
def get_top_similarities(my_evaluation, all_evaluations, number = 5):
    similarities = []
    for other_evaluation in all_evaluations:
        ## 自身の評価は含めない
        if other_evaluation["id"] == my_evaluation["id"]:
            continue

        similarity = {
            "id": other_evaluation["id"],
            "similarity": pearson_corr(my_evaluation["evaluation"], other_evaluation["evaluation"]),
            "evaluation": other_evaluation["evaluation"]
        }
        similarities.append(similarity)
    similarities.sort(key=lambda x: x['similarity'], reverse=True)
    return similarities[:number]

def lambda_handler(event, context):
    # TODO: DynamoDBから取得する
    original_evaluations = (
        {"id": 1, "evaluation": np.array([5,4,5,0,4,4,5,0,0,0])},
        {"id": 2, "evaluation": np.array([4,4,4,4,4,2,4,4,3,3])},
        {"id": 3, "evaluation": np.array([4,2,5,4,2,4,5,5,2,0])},
        {"id": 4, "evaluation": np.array([0,4,3,0,0,4,0,4,0,0])},
        {"id": 5, "evaluation": np.array([4,1,5,5,2,3,5,5,5,3])},
        {"id": 6, "evaluation": np.array([5,5,4,0,5,5,3,1,2,4])},
        {"id": 7, "evaluation": np.array([4,0,0,3,2,0,5,4,0,4])},
        {"id": 8, "evaluation": np.array([4,0,0,5,4,0,5,5,0,5])},
        {"id": 9, "evaluation": np.array([1,4,3,0,3,1,0,0,0,0])},
        {"id": 10, "evaluation": np.array([2,3,4,3,5,2,5,5,5,0])},
        {"id": 11, "evaluation": np.array([3,4,3,4,4,2,4,4,3,5])},
        {"id": 12, "evaluation": np.array([4,3,4,5,0,4,5,5,4,4])},
        {"id": 13, "evaluation": np.array([4,4,4,5,5,4,5,5,4,5])},
        {"id": 14, "evaluation": np.array([5,4,4,3,4,3,5,4,4,4])},
        {"id": 15, "evaluation": np.array([2,4,3,4,3,3,4,3,3,4])},
        {"id": 16, "evaluation": np.array([5,4,5,5,5,4,5,5,5,5])},
        {"id": 17, "evaluation": np.array([0,1,4,5,5,2,5,4,1,5])},
        {"id": 18, "evaluation": np.array([0,3,5,0,3,2,5,3,0,2])},
        {"id": 19, "evaluation": np.array([4,5,4,5,0,4,5,4,4,4])},
        {"id": 20, "evaluation": np.array([5,5,4,0,5,5,3,1,1,5])}
    )

    # 自身の評価を平準化する
    my_evaluation = [{"id": 6, "evaluation": np.array([5,5,4,0,5,5,3,1,2,4])}]
    normalized_my_evaluations = normalize(my_evaluation)[0]

    # 他人の評価(evaluations)を平準化する
    normalized_evaluations = normalize(original_evaluations)

    # 類似度が高いユーザー情報を取得する
    top_similarities = get_top_similarities(normalized_my_evaluations, normalized_evaluations)

    print(top_similarities)

ざっくりとした流れは、

  • 自身の評価を平準化(評価の平均を引き、甘口な人と辛口な人の平準化を行う)する
  • 他人の評価も平準化する
  • 類似度の高いユーザー情報を取得する
  • ...

と続いていきます。簡単のために今回は「類似度の高いユーザー情報を取得」までのプログラムを載せています。このプログラムの実行結果は下記のようになります。

$ python3 batch_update_recommend.py 
[{'evaluation': array([ 1.22222222,  1.22222222,  0.22222222,  0.        ,  1.22222222,
        1.22222222, -0.77777778, -2.77777778, -2.77777778,  1.22222222]), 'id': 20, 'similarity': 0.9616481354292185}, {'evaluation': array([-0.33333333,  0.66666667, -0.33333333,  0.66666667,  0.        ,
       -0.33333333,  0.66666667, -0.33333333, -0.33333333, -0.33333333]), 'id': 19, 'similarity': 0.1437612317849918}, {'evaluation': array([ 0.        , -2.55555556,  0.44444444,  1.44444444,  1.44444444,
       -1.55555556,  1.44444444,  0.44444444, -2.55555556,  1.44444444]), 'id': 17, 'similarity': -0.03173047473813088}, {'evaluation': array([-1.4,  1.6,  0.6,  0. ,  0.6, -1.4,  0. ,  0. ,  0. ,  0. ]), 'id': 9, 'similarity': -0.05336760502236146}, {'evaluation': array([-1.3,  0.7, -0.3,  0.7, -0.3, -0.3,  0.7, -0.3, -0.3,  0.7]), 'id': 15, 'similarity': -0.06548295628421143}]

自身の評価が {"id": 6, "evaluation": np.array([5,5,4,0,5,5,3,1,2,4])} で、id=20の人 {"id": 20, "evaluation": np.array([5,5,4,0,5,5,3,1,1,5])} が似ていると判断されました。(id=20の人を敢えて似た嗜好にしています。)まだ磨き込みができていませんが、いったんそれっぽい結果が返ってくることを確認できました。

AWS Lambda 上で動かす上での問題点と既存の解決策

 さて、このプログラムを Lambda 上で動かそうとすると、下記のエラーが発生します。

f:id:ketancho_jp:20181206154857p:plain

よくログを読んでみると NumPy モジュールが見つからないと怒られています。

f:id:ketancho_jp:20181206154952p:plain

Lambda では外部モジュールを呼び出す場合は、モジュールを含めて zip 化する必要があります。詳細は下記の記事をご覧ください。

www.ketancho.net

しかし、NumPy モジュールを含めてモジュールをアップロードしなおしても、結果は変わりませんでした。このエラーを調べてみると、NumPy は Cで書かれたモジュールを含んでおり、そのようなモジュールを Lambda 上で動かす場合は、Amazon Linux 環境でビルドしていないとならないとのことでした。

blog.orikami.nl

下記のように、Amazon Linux でビルドした NumPy モジュールを用意してくれている優しい方もいらっしゃったので試してみたのですが、動くには動くのですがもっといい方法がないのかな..?と思いながらその日の開発をやめました。(私の使い方が悪かったのかもしれませんが、これを利用すると SAM でモジュールを上げる際に1分前後ビルドに時間がかかるようになってしまいました。)ちなみにこの日は 2018/11/28 で、re:Invent 2018 のキーノート初日の日でした。

github.com

Lambda Layers を利用した解決策

 そんなこんなでいい方法がないのか考えながら寝たんですが、朝起きたらキーノート2日目で Lambda Layers が発表されていました。

aws.amazon.com

記事から Lambda Layers の要点を引用させていただくと、

  • サーバレスアプリケーションを開発する際に、複数のLambda関数から共有されるコードを持つことは極めて一般的なことです。Lambda Layersにより、ビジネスロジックの実装を簡素化するために複数の関数追加することまたは標準ライブラリによって使用されるカスタムコードにすることができます。
  • 一般的なコンポーネントを1つのzipファイル作成し、それをLambda Layerとして置くことができます。関数のコードは変更する必要はなく、通常のようにレイヤー内のライブラリーを参照することができます。

という機能になります。さらに、、、

  • お客さまのフィードバックに基づいて、そしてどのようLambda Layersを使うかの例を提示するために、私達は、NumPy/SciPyを含むパブリックレイヤを公開します。

とのことで、なんと NumPy を提供するレイヤを標準で追加してくれてるとのことでした。(淡々と書いてますが、当日は嬉しさと興奮のあまり、布団から飛び起きて検証を始めまたことを白状します😆)

 では、Lambda Layers を用いる手順について紹介します。まず、該当の Labmda Function の画面に遷移し、Layers を選択します。

f:id:ketancho_jp:20181206162402p:plain

Layers 画面で「レイヤーの追加」を選択します。前述の通り、NumPy については標準レイヤーが提供されていますのでそれを選択します。

f:id:ketancho_jp:20181206162414p:plain

f:id:ketancho_jp:20181206163439p:plain

Lambda Function の設定を保存し、実行すると下記のように NumPy を含むプログラムが動作しました!

f:id:ketancho_jp:20181206165747p:plain

私にとってタイムリーで至れり尽くせりなアップデートで大変ありがたかったです。他にも共通で使いたいモジュールがいくつかあるので、今後カスタムレイヤー化していきたいと考えています。

まとめと余談

 re:Invent 2018 で発表された新機能 Lambda Layers を使ってみました。Lambda の Ruby 対応や Lambda Runtime API の方が反響が多そうに見えますが、個人的には今回の Lambda のアップデートで一番嬉しいものになりました。

 Lambda のアップデートではありませんが、今年の re:Invent のキーノート初日に Amazon Personalize というサービスが提供されました。今回構築したレコメンド実装がもしかしたら無駄になるのかも..?と不安と期待を持ちつつ、プレビューが承認されたらまた使ってみたいと思います。