レコメンドするモデルをGluonで作ってみる(Matrix Factorization):Amazon SageMaker Advent Calendar 2018

「Matrix Factorization」はレコメンドの基本だって、お婆ちゃんが言ってた。
2018.12.03

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

概要

こんにちは、データインテグレーション部のyoshimです。
この記事は「クラスメソッド Amazon SageMaker Advent Calendar」の3日目の記事となります。

目次

1.やること

Amazonの「誰がどんな動画を見たか」というデータセットを使ってレコメンドするモデルを「Matrix Factorization」で開発してみます。
今回使うデータセットはAmazonが公開してくれており、ソースコードはGitにあります。

既存のビルトインアルゴリズムを使えば、いくらでもレコメンド系のモデルは開発できるのですが、「Gluon」を勉強するいい機会だと思ったのでやってみました。

2.「Gluon」について

まずは簡単に「Gluon」について紹介しようと思います。
こちらのページに、まさに「About Gluon」という項目があるので、そこを引用します。

Based on the the Gluon API specification, the new Gluon library in Apache MXNet provides a clear, concise, and simple API for deep learning. It makes it easy to prototype, build, and train deep learning models without sacrificing training speed. Install the latest version of MXNet to get access to Gluon.

ニューラルネットワークのモデル開発をより手軽にできるようにするためのAPIとのことです。

3.Matrix Factorization

続いて、「Matrix Factorization」についてです。
こちらは解説ブログもたくさん存在するので、あまり深入りしませんが「レコメンドシステム」のとても有名なアルゴリズムです。
現在だと、「NMF」や「Factorization Machines」の方が主流かもしれませんが、レコメンドを考える上での基本思想は似ているので、もしよかったら一度勉強してみるといいかと思います。

ざっくりとした流れを本チュートリアルに記載されていた図を元に説明します。

1.左側のような「ユーザーがどの動画にどのような評価をしたのか」といった1つの行列を用意する
(下記の図で言うと、AさんがXの動画に4.5点、BさんがWの動画に4点として評価している)
2.この行列を2つの行列に分解する。
(下記の図で言うと、Aさん,Bさん達は2つの次元で特徴が表現される。また、動画も2つの次元で特徴を表現されている。何次元に削減するかはハイパーパラメータとして指定する)
3.この行列を2つに分解する際に、「元々持っていた情報量をできるだけ減らさない」ように学習させることで、「ユーザーの特徴」や「動画の特徴」をうまく抽出し、計算できるようになる

4.作業の流れ

全体のソースコードはこちらにある通りなので、要点だけをかいつまんでいきます。

4-1.データの取得&データ内容の確認

まずはS3からデータをダウンロードしてきて、データの中身を確認します。

また、データを確認したのち、今回利用する特徴量を「customer_id」, 「product_id」, 「star_rating」の3つに絞り込み、その後に再度データを確認しています。
ここでは、どれだけデータの偏りを確認しています。

customers = df['customer_id'].value_counts()
products = df['product_id'].value_counts()

quantiles = [0, 0.01, 0.02, 0.03, 0.04, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.95, 0.96, 0.97, 0.98, 0.99, 1]
print('customers\n', customers.quantile(quantiles))
print('products\n', products.quantile(quantiles))

「customers」では、「評価した動画の本数」ごとに「全体に占める割合(上位から計測)」を計算したものです。動画を5本以上評価している会員は全体の5%程度しかいないようです。また、1人で2,704本も動画を評価した人がいるようですね...。

「products」では、同様に「評価された回数」を動画ごとに計測し、上位からその結果を確認しているものです。9人以上から評価されると、上位25%に入るみたいですね。

以上から、「ほとんどの動画はあまり評価されておらず」、また「ほとんどの人が5本未満しか動画を評価していない」ため、下記のような行列を生成する際に「疎な行列」になることが想定されます。

4-2.データの絞り込み

今回は「5本以上動画を評価している会員」、「10本以上評価されている動画」のみを利用してモデルを学習することとします。
これは、「動画評価本数が上位5%の会員」、「被評価回数が上位25%以上の動画」のみを利用する、とも言えます。

なぜこんなことをしているのかと言うと、「行列が疎になり過ぎるのを避ける」ためです。
場合によっては、モデル生成前にこのような「前処理」が必要となります。

customers = customers[customers >= 5]
products = products[products >= 10]

reduced_df = df.merge(pd.DataFrame({'customer_id': customers.index})).merge(pd.DataFrame({'product_id': products.index}))

4-3.モデルの学習

続いて、上記のデータを利用してモデルを学習します。ここでやっと「Gluon」を使います。
まずは、データを変換し、学習時に必要となる「Data Iterator」を定義します。
データの変換は「Pandas DataFrames」から簡単にできます。
また、「Data Iterator」は学習実行時に利用します。

batch_size = 1024

# 「Pandas DataFrames」を「MXNet NDArrays」に変換
train = gluon.data.ArrayDataset(nd.array(train_df['user'].values, dtype=np.float32),
                                nd.array(train_df['item'].values, dtype=np.float32),
                                nd.array(train_df['star_rating'].values, dtype=np.float32))
test  = gluon.data.ArrayDataset(nd.array(test_df['user'].values, dtype=np.float32),
                                nd.array(test_df['item'].values, dtype=np.float32),
                                nd.array(test_df['star_rating'].values, dtype=np.float32))

# 「Iterator」を定義
train_iter = gluon.data.DataLoader(train, shuffle=True, num_workers=4, batch_size=batch_size, last_batch='rollover')
test_iter = gluon.data.DataLoader(train, shuffle=True, num_workers=4, batch_size=batch_size, last_batch='rollover')

続いて、ネットワークを定義します。

最終的な評価値を得るためには「ユーザーの行列」と「動画の行列」を掛け算することになるのですが、そのために下記の3点について理解していることが重要です。

  • Embeddings:INPUTとなる行列を指定した次元の密ベクトルに変換します。今回は「ユーザーの行列」、「動画の行列」をそれぞれ64特徴量に次元削減します。
  • Dropout layers:INPUTとなるノードを「指定した割合で利用しない」、というもの。過学習を防ぐためにする処理。
  • Dense layers:全結合層。活性化関数では「ReLU」をつかっています。
class MFBlock(gluon.HybridBlock):
    def __init__(self, max_users, max_items, num_emb, dropout_p=0.5):
        super(MFBlock, self).__init__()

        self.max_users = max_users
        self.max_items = max_items
        self.dropout_p = dropout_p
        self.num_emb = num_emb

        with self.name_scope():
            self.user_embeddings = gluon.nn.Embedding(max_users, num_emb)
            self.item_embeddings = gluon.nn.Embedding(max_items, num_emb)

            self.dropout_user = gluon.nn.Dropout(dropout_p)
            self.dropout_item = gluon.nn.Dropout(dropout_p)

            self.dense_user   = gluon.nn.Dense(num_emb, activation='relu')
            self.dense_item = gluon.nn.Dense(num_emb, activation='relu')

    def hybrid_forward(self, F, users, items):
        a = self.user_embeddings(users)
        a = self.dense_user(a)

        b = self.item_embeddings(items)
        b = self.dense_item(b)

        predictions = self.dropout_user(a) * self.dropout_item(b)     
        predictions = F.sum(predictions, axis=1)
        return predictions



num_embeddings = 64

net = MFBlock(max_users=customer_index.shape[0], 
              max_items=product_index.shape[0],
              num_emb=num_embeddings,
              dropout_p=0.5)

続いて、パラメータの初期化、ハイパーパラメータの指定、をした後にTrainer」で「パラメータに活性化関数を適用」します。
ここで定義した「Trainer」はこの後、学習実行時にもう一回出てきます。

# Initialize network parameters
ctx = mx.gpu()
net.collect_params().initialize(mx.init.Xavier(magnitude=60),
                                ctx=ctx,
                                force_reinit=True)
net.hybridize()

# Set optimization parameters
opt = 'sgd'
lr = 0.02
momentum = 0.9
wd = 0.

trainer = gluon.Trainer(net.collect_params(),
                        opt,
                        {'learning_rate': lr,
                         'wd': wd,
                         'momentum': momentum})

学習を実行する関数を作成します。
この関数の引数として、「トレーニング・テスト用のIterator」、「ネットワークの定義」、「エポック数」、「学習をGPU上で実行すること」を与えています。
また、先ほど定義した「ハイパーパラメータ」や「Trainer」この関数の中で利用されています。

def execute(train_iter, test_iter, net, epochs, ctx):

    loss_function = gluon.loss.L2Loss()
    for e in range(epochs):

        print("epoch: {}".format(e))

        for i, (user, item, label) in enumerate(train_iter):
                # データをGPU上に展開
                # https://mxnet.incubator.apache.org/api/python/ndarray/ndarray.html#mxnet.ndarray.NDArray.as_in_context
                user = user.as_in_context(ctx)
                item = item.as_in_context(ctx)
                label = label.as_in_context(ctx)

                # 誤差・勾配の計算
                with mx.autograd.record():
                    output = net(user, item)               
                    loss = loss_function(output, label)

                # パラメータに反映    
                loss.backward()
                trainer.step(batch_size) # バッチサイズ単位でパラメータに活性化関数を適用する

        print("EPOCH {}: MSE ON TRAINING and TEST: {}. {}".format(e,
                                                                   eval_net(train_iter, net, ctx, loss_function),
                                                                   eval_net(test_iter, net, ctx, loss_function)))
    print("end of training")
    return net

ここまで長かったですが、学習開始!!!

%%time

epochs = 3

trained_net = execute(train_iter, test_iter, net, epochs, ctx)

4-4.結果の検証

さて、モデルが出来たので、「使えるか否か」を確認します。
下記は2人のユーザーの評価予測値をプロットしたものですが、まあ綺麗に同じような結果が出ているようです。

これは、「ユーザーの特徴が捉えられていない」等が原因かと考えられますが、とりあえずこのモデルはまだまだ至らない点が多い、ということです。
改善点としては、「embeddingの数を大きくする(ユーザー/動画の特徴を捉えるため)」、「学習時に指定したハイパーパラメータを修正する」等が考えられます。

4-5.SageMakerの「MXNet container」で学習させる

上記のモデルにはまだ問題があるので、学習をし直す事とします。
ただ、データ量が多く学習に時間がかかるため、今回は「SageMakerのMXNet container」を利用してみます。

方法はここまで記述したコード内容をちょっと修正した「recommender.py」を準備し、下記のようにエントリーポイントとして指定する事で学習が可能です。

m = MXNet('recommender.py', 
          py_version='py3',
          role=role, 
          train_instance_count=1, 
          train_instance_type="ml.p2.xlarge",
          output_path='s3://{}/{}/output'.format(bucket, prefix),
          hyperparameters={'num_embeddings': 512, 
                           'opt': opt, 
                           'lr': lr, 
                           'momentum': momentum, 
                           'wd': wd,
                           'epochs': 10},
         framework_version='1.1')

m.fit({'train': 's3://{}/{}/train/'.format(bucket, prefix)})

4-6.deploy

上記で学習したモデルは、通常のSDKと同様の手順でデプロイできます。

predictor = m.deploy(initial_instance_count=1, 
                     instance_type='ml.m4.xlarge')

推論するときはこんな感じ。こちらはユーザ、動画を指定して「評価値の予測値」を取得しています。

predictor.serializer = None

predictor.predict(json.dumps({'customer_id': customer_index[customer_index['user'] == 6]['customer_id'].values.tolist(), 
                              'product_id': ['B00KH1O9HW', 'B00M5KODWO']}))

4-7.評価

続いて、上記のモデルを評価しましょう、先ほど同様に「予測値が直感的に納得できるか」、を確認します。
先ほどと同様の2ユーザーの評価点をプロットして相関が減少していることを確認します。

predictions_user7 = []
for array in np.array_split(product_index['product_id'].values, 40):
    predictions_user7 += predictor.predict(json.dumps({'customer_id': customer_index[customer_index['user'] == 7]['customer_id'].values.tolist() * array.shape[0], 
                                                       'product_id': array.tolist()}))
plt.scatter(predictions['prediction'], np.array(predictions_user7))
plt.show()

先ほどよりは、より個人の特徴が捉えられていそうです。

ただ、とはいえまだユーザーの特徴を捉えられているかは不明なので、より詳細に調査を進め、結果によってはモデルを学習し直す必要があるかもしれません。

4-8.モデルの改善

今回はとりあえずモデルを学習し、評価するところまでを対象としたため、「モデルの改善」についてはこれ以上実施しませんが、「ハイパーパラメータチューニング」、「利用する特徴量を増やす」とか色々やり方はあると思います。
また、どうしても好ましい結果が出ない場合は、「使うアルゴリズムを変える」ことも検討した方が良さそうです。

5.まとめ

ディープラーニングベースで「顧客の評価点予測」を実施しました。
「Gluon」を使ったのは始めてだったのですが、結構使いやすいイメージを受けました。

レコメンド系のアルゴリズムも色々あるようですが、とりあえずメジャーなやつから触ってみたい、という方は「Matrix Factorization」や「Factorization machines」あたりから手をつけると勉強も効率的にできそうです。

本日のエントリーは以上になります。
明日はSageMakerの「パイプモード」を使って処理を効率化する方法についてご紹介しようと思います。