pytorchでNeural Collaborative Filtering(その2 データローダー準備)

書いてる理由

  • レコメンド * deep learningやりたい
  • まずは有名どころを真似てみる

参考

Neural Collaborative Filtering github.com

概要

レコメンドをdeep learingを使ってやりたい。
Neural Collaborative Filteringの論文をベースにpytorchで組まれているコードがあったので、それを真似して書いて動作を確認する。
コードは、[1]Generalized Matrix Factorizationと[2]Malti Layer Perceptronとこれらを組み合わせた[3]Neural matrix factorizationがあり、まず[1]を確認。前回、movielensのデータの前処理をやったので今回はDataloader。

コード

github.com

コード解説

前回は、movielensのデータにuserIdとitemIdを0~の連番で以下のように整形するところまでやったので、今度はこれを学習に利用できるようにDataloaderにしていく。

userId itemId rating ts
0 0 5 978300760
0 1 3 978302109
0 2 3 978301968
0 3 4 978300275
0 4 5 978824291

データローダー作成

データローダーはclass SampleGenerator(object):で作成する。
一個一個の動きを見ていく。

class SampleGenerator(object):
    def __init__(self, ratings):
        self.ratings = ratings
        self.preprocess_ratings = self._binarize(ratings)        # ratingの二値化
        self.user_pool = set(self.ratings['userId'].unique())    # ユーザーの集合体の作成
        self.item_pool = set(self.ratings['itemId'].unique())    # アイテムの集合体の作成
        self.negatives = self._sample_negative(ratings)  # 反応していないitemIdと99個のランダムに抽出されたitemIdをくっつける
        self.train_ratings, self.test_ratings = self._split_train_test(self.preprocess_ratings)  # 最新のレビューがテスト/それ以外がtrain

レーティングの二値化

        self.preprocess_ratings = self._binarize(ratings)  # ratingを全て1に変換

    def _binarize(self, ratings):
        ratings = deepcopy(ratings)
        ratings['rating'][ratings['rating'] > 0] = 1.0

        return ratings

今のデータは、以下のようにratingに1~5の数字が入っている。

userId itemId rating ts
0 0 5 978300760
0 1 3 978302109
0 2 3 978301968
0 3 4 978300275
0 4 5 978824291

これを、ratings['rating'][ratings['rating'] > 0] ==1にすることで、0より大きい数字を全て1に置き換える。

userId itemId rating ts
0 0 1 978300760
0 1 1 978302109
0 2 1 978301968
0 3 1 978300275
0 4 1 978824291

ユーザーとアイテムの集合体の作成

        self.user_pool = set(self.ratings['userId'].unique())       # ユーザーの集合体の作成
        self.item_pool = set(self.ratings['itemId'].unique())       # アイテムの集合体の作成

特に解説なし。set()でユニークな集合体が作れる。

レーティングしていないアイテム情報の追加

    def _sample_negative(self, ratings):
        interact_status = ratings.groupby('userId')['itemId'].apply(set).reset_index().rename(
            columns={'itemId': 'interacted_items'})  # interacted_itemsにレビューしたitemIdの集合体が入る
        interact_status['negative_items'] = interact_status['interacted_items'].apply(lambda x: self.item_pool - x)
        interact_status['negative_samples'] = interact_status['negative_items'].apply(lambda x: random.sample(x, 99))

        return interact_status[['userId', 'negative_items', 'negative_samples']]

        self.negatives = self._sample_negative(ratings)            # 反応していないitemIdと99個のランダムに抽出されたitemIdをくっつける

レビューデータの特性上、レビューをした情報しか残っておらず、レビューしていないという情報がない。
そのためレビューしていない他のアイテムからランダムにレビューしていない情報をくっつける。

interact_status = ratings.groupby('userId')['itemId'].apply(set).reset_index().rename(columns={'itemId': 'interacted_items'})で、以下のようにユーザーごとにレビューしたアイテムの集合体を作る。

userId interacted_items
0 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,...
1 {0, 18, 20, 42, 47, 48, 52, 53, 54, 55, 56, 57...
2 {128, 4, 5, 22, 166, 168, 41, 44, 175, 176, 17...
3 {139, 26, 156, 43, 44, 48, 63, 64, 208, 209, 2...
4 {3, 4, 9, 18, 27, 38, 39, 43, 48, 51, 59, 62, ...

interact_status['negative_items'] = interact_status['interacted_items'].apply(lambda x: self.item_pool - x)でアイテム全体の集合から、レビューしたアイテムの集合を引いてレビューしていないアイテムの集合を作る。
interact_status['negative_samples'] = interact_status['negative_items'].apply(lambda x: random.sample(x, 99))でその中から99個ランダムにサンプリングする。

この二つのカラムをくっつけて、レビューしていない全体のアイテムとランダムに99個サンプリングしたデータセットが出来上がる。

userId negative_items negative_samples
0 {53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 6... [2562, 3103, 2665, 959, 744, 3274, 1986, 1644,...
1 {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14... [2248, 491, 1770, 473, 2819, 3539, 3532, 3352,...
2 {0, 1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 13, 14, 1... [1172, 1474, 1877, 457, 2621, 3420, 1932, 2147...
3 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13,... [1495, 905, 3473, 345, 828, 2121, 591, 3442, 1...
4 {0, 1, 2, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, ... [1264, 1112, 2071, 80, 2295, 3159, 1122, 424, ...

学習用とテスト用に分割

    def _split_train_test(self, ratings):
        ratings['rank_latest'] = ratings.groupby(['userId'])['ts'].rank(method='first', ascending=False)
        test = ratings[ratings['rank_latest'] == 1]
        train = ratings[ratings['rank_latest'] > 1]

        return train[['userId', 'itemId', 'rating']], test[['userId', 'itemId', 'rating']]

        self.train_ratings, self.test_ratings = self._split_train_test(self.preprocess_ratings)  # 最新のレビューがテスト/それ以外がtrain

ratings['rank_latest'] = ratings.groupby(['userId'])['ts'].rank(method='first', ascending=False)でratingsにタイムスタンプ順に連番を降る。
test = ratings[ratings['rank_latest'] == 1]train = ratings[ratings['rank_latest'] > 1]で最新の一つをtestにそれ以外をtrainにする。
userIdとitemIdとratingを持ったtrainとtestを返却。

以上で、データセットの前処理完了。
次は学習中の検証用データの準備。

検証用データの準備

class SampleGenerator(object):
    [中略]
    @property
    def evaluate_data(self):
        test_ratings = pd.merge(self.test_ratings, self.negatives[['userId', 'negative_samples']], on='userId')
        test_users, test_items, negative_users, negative_items = [], [], [], []
        for row in test_ratings.itertuples():
            test_users.append(int(row.userId))
            test_items.append(int(row.itemId))
            for i in range(len(row.negative_samples)):
                negative_users.append(int(row.userId))
                negative_items.append(int(row.negative_samples[i]))

        return [torch.LongTensor(test_users), torch.LongTensor(test_items),
                torch.LongTensor(negative_users), torch.LongTensor(negative_items)]


    eval_data = sample_generator.evaluate_data  # テストデータの反応あり、なしデータをTensor型で取得

test_ratings = pd.merge(self.test_ratings, self.negatives[['userId', 'negative_samples']], on='userId')で先ほど作成した、テスト用のデータセットと、レビューしていないデータを結合する。
結合したデータは以下の形。

userId itemId rating negative_samples
0 25 1 [2562, 3103, 2665, 959, 744, 3274, 1986, 1644,...
1 66 1 [2248, 491, 1770, 473, 2819, 3539, 3532, 3352,...
2 207 1 [1172, 1474, 1877, 457, 2621, 3420, 1932, 2147...
3 208 1 [1495, 905, 3473, 345, 828, 2121, 591, 3442, 1...
4 222 1 [1264, 1112, 2071, 80, 2295, 3159, 1122, 424, ...

これをfor文を回して、それぞれuser_list, item_list, negative_user_list, negative_item_listに格納していく。
user_listとitem_listはレビューしたuserIdとitemIdが対になっていて、ユーザーがレビューした最新の1アイテムだけなので、長さは6040。
negativeは、1ユーザーにつき99個のサンプリングなので6040 * 99 = 597960個ずつが入る。
この4つをpytorchで扱えるように、torch.LongTensor()でreturn。

ここまでで、データの準備と整形が完了したので、次はネットワークの準備。

[補足]データ準備と検証データ用意のコード全体

class SampleGenerator(object):
    def __init__(self, ratings):
        self.ratings = ratings
        self.preprocess_ratings = self._binarize(ratings)            # ratingを全て1に変換
        self.user_pool = set(self.ratings['userId'].unique())       # ユーザーの集合体の作成
        self.item_pool = set(self.ratings['itemId'].unique())       # アイテムの集合体の作成
        self.negatives = self._sample_negative(ratings)            # 反応していないitemIdと99個のランダムに抽出されたitemIdをくっつける
        self.train_ratings, self.test_ratings = self._split_train_test(self.preprocess_ratings)  # 最新のレビューがテスト/それ以外がtrain

    def _binarize(self, ratings):
        ratings = deepcopy(ratings)
        ratings['rating'][ratings['rating'] > 0] = 1.0

        return ratings

    def _sample_negative(self, ratings):
        interact_status = ratings.groupby('userId')['itemId'].apply(set).reset_index().rename(
            columns={'itemId': 'interacted_items'})  # interacted_itemsにレビューしたitemIdの集合体が入る
        interact_status['negative_items'] = interact_status['interacted_items'].apply(lambda x: self.item_pool - x)
        interact_status['negative_samples'] = interact_status['negative_items'].apply(lambda x: random.sample(x, 99))

        return interact_status[['userId', 'negative_items', 'negative_samples']]

    def _split_train_test(self, ratings):
        ratings['rank_latest'] = ratings.groupby(['userId'])['ts'].rank(method='first', ascending=False)
        test = ratings[ratings['rank_latest'] == 1]
        train = ratings[ratings['rank_latest'] > 1]

        return train[['userId', 'itemId', 'rating']], test[['userId', 'itemId', 'rating']]

    def instance_a_train_loader(self, num_negatives, batch_size):
        users, items, ratings = [], [], []
        train_ratings = pd.merge(self.train_ratings, self.negatives[['userId', 'negative_items']], on='userId')
        train_ratings['negatives'] = train_ratings['negative_items'].apply(lambda x: random.sample(x, num_negatives))
        for row in train_ratings.itertuples():
            users.append(int(row.userId))
            items.append(int(row.itemId))
            ratings.append(float(row.rating))
            for i in range(num_negatives):
                users.append(int(row.userId))
                items.append(int(row.negatives[i]))
                ratings.append(float(0))  # negative samples get 0 rating
        dataset = UserItemRatingDataset(user_tensor=torch.LongTensor(users),
                                        item_tensor=torch.LongTensor(items),
                                        target_tensor=torch.FloatTensor(ratings))
        return DataLoader(dataset, batch_size=batch_size, shuffle=True)

    @property
    def evaluate_data(self):
        test_ratings = pd.merge(self.test_ratings, self.negatives[['userId', 'negative_samples']], on='userId')
        test_users, test_items, negative_users, negative_items = [], [], [], []
        for row in test_ratings.itertuples():
            test_users.append(int(row.userId))
            test_items.append(int(row.itemId))
            for i in range(len(row.negative_samples)):
                negative_users.append(int(row.userId))
                negative_items.append(int(row.negative_samples[i]))

        return [torch.LongTensor(test_users), torch.LongTensor(test_items),
                torch.LongTensor(negative_users), torch.LongTensor(negative_items)]

def main ():
    # make dataloader
    sample_generator = SampleGenerator(ratings=ml1m_rating)
    eval_data = sample_generator.evaluate_data  # テストデータの反応あり、なしデータをTensor型で取得