RACCOON TECH BLOG

株式会社ラクーンホールディングス 技術戦略部より、
tipsやノウハウなど技術的な話題を
発信いたします。

AI(PytorchとRNN)を使った商品リコメンドを試す

技術部の野田です。
弊社サービスのスーパーデリバリーに、RNN(*1)を使った商品リコメンドを載せることは出来ないかな?と思い、機械学習初心者がPytorchを使って、プロトタイプを作ったのでまとめます。

今回作成したニューラルネットは、RNNを中心としたもので、
商品IDを埋め込んだベクトルを作成→RNNに入力→全結合層に入力するというモデルです。

このモデルを損失関数を交差エントロピーとして、確率的勾配降下法でパラメータを訓練し、テストデータでの正解率を求め、実際人間が目で見てわかる様に特定の商品IDを入力したら、出力としてどういった商品IDが返ってくるのか、というところまで見ていきたいと思います。
実行可能なColabノートブックも公開していますので、是非記事を読みつつ試してみて下さい。
なお、数学的な解説は行いません。(*2)

(*1) RNN(Recurrent Neural Network)とは、「ある時点のデータが、それ以降に発生するデータに影響を与えるだろう」という考えを元にしたニューラルネットで、商品リコメンドに合ったニューラルネットです。例えば夏にTシャツを買って、その後サンダルを買った場合、Tシャツとサンダルは商品データ同士の関連は薄いけど、時系列で見ると関連が強い様なので、Tシャツを買ったお客さんにサンダルをリコメンドしよう!みたいな感じで使います。

(*2)交差エントロピーや確率的勾配降下法を理解しようと思うと、大学一年生程度の微分、行列の知識が前提になり、それを踏まえて説明する必要があるので、それだけでも数記事必要になってしまいます。ですので、ここではそういった説明は致しません。

1. GoogleColabの準備

GoogleColabだと環境構築が無くて楽なので、GoogleColabを使います。
GoogleColabって何?という方はこちらをご一読ください。

データ量によっては計算に時間がかかるため、ランタイムはGPUを使います。
試される場合は、Colab画面上部のランタイム > ランタイムのタイプを変更からGPUを選択して下さい。

2. Colabノートブック

この記事のソースコードはノートブックとして公開しています。
正直ブログよりもColab上でノートブックを開いたほうが見やすいのでおすすめです。

3. データセットの準備

データセットを準備します。
今回スーパーデリバリーの直近一週間のユーザ毎の購入商品データを使いました。
こちらは外部に公開出来るものではありませんので、この記事を読んで同じことを試してみよう!と思われる方は、
ご自身で同じ様なデータを準備して頂く必要があります。
この記事を読まれた方と私では、同じデータを用意出来ないので当然結果が異なるのですが、
それなりの量のトランザクションデータを元にしていれば、それなりの結果を返すと思います(多分)

データの形式は下記の形を想定しています。
単にカンマで区切られた商品IDが数万行並んだcsvです。
私の場合、直近一週間のユーザ毎の購入商品データを使っており、
1行=1ユーザとして購入した商品IDを左から右に時系列に並べています。

8132, 12, 5214, 123, 834
23, 3412, 973, 2, 145

4. csvをColabにアップする

作成したcsvをproducts.csvという名前にして、Colabにアップします。
Colabのサイドバーにファイルというメニューがあります。
そのメニューを開いて、ドラッグアンドドロップするとアップ出来ます。

5. 必要なライブラリをimportする

import torch
import torch.nn as nn
from torch.utils.data import Dataset, DataLoader
from sklearn.model_selection import train_test_split
import numpy as np
import random

# 乱数のシードを固定
torch.manual_seed(0)

6.商品IDを数値化する

商品IDに文字列を含ませた形にしている会社は結構沢山あると思います。
ニューラルネットでは行列計算を行います。当たり前ですが計算は数値でなければ出来ません。
また、商品IDが数値のみだよという場合もあると思います。
その場合そのままで良さそうなのですが、商品IDの桁数が大きくなると計算量が膨大になってしまいます。
(実際やってみると、いつ計算終わるんだろう…という感じでした。)
こういった理由から、まずは商品IDを連続した数値(以降シーケンスと呼びます)に置き換える機能を実装します。
そして、最終的に商品IDを得るのが目的ですから、シーケンスから商品IDを引く機能も実装します。
要するに商品ID⇔シーケンスを相互に変換出来る辞書クラスを作るということです。

class ProductDictionary():

  def __init__(self, products):
    self.product_sequence = {}
    self.sequence_product = {}
    for product_line in products:
      for product in product_line.split(','):
        if product not in self.product_sequence:
          sequence = len(self.product_sequence) + 1
          self.product_sequence[product] = sequence
          self.sequence_product[sequence] = product

  def get_sequences_by_products(self, products):
    sequences = []
    for product_line in products:
      temp = [self.product_sequence[product] for product in product_line.split(',')]
      sequences.append(temp)
    return sequences

  def get_sequence_count(self):
    return len(self.product_sequence) + 1

「4. csvをColabにアップする」でアップしたcsvを読み取って

with open('products.csv') as f:
    products = f.read().splitlines()

ProductDictionaryクラスに入れて辞書オブジェクトを作ります。

pdic = ProductDictionary(products)

そして辞書を使って、productsをシーケンスに置き換えます。
ついでにシャッフルしておきます。

sequences = pdic.get_sequences_by_products(products)
random.shuffle(sequences)

7. ニューラルネットに入力するデータの準備

RNNはデータの時系列に意味があることを表現するニューラルネットなので、例えば1, 2という順で商品を購入していたユーザが多ければ、1という商品を購入したユーザに2という商品が表示される様な予測が出来てほしいなと思います。
その訓練を行うためシーケンスから1つの入力値と1つの正解値(目標値ともいいます)を作るクラスを実装します。
入力値と正解値をもう少し具体化すると、下記の様な感じです。

例) 1つのシーケンスから3つの入力値と正解値の組み合わせを作る。
1, 2, 3, 4

入力値:1 正解値:2
入力値:2 正解値:3
入力値:3 正解値:4

余談ですが、下記の実装にWINDOW_SIZEという定数があります。
これを例えばWINDOW_SIZE=2に変えると下記の様な組み合わせができます。
この場合、1,2の順で商品を買った人に3を薦めるといった様な直近の購入商品の影響を強く出すことが出来ます。
スライディングウィンドウ法と言うそうです。
ただ今回のデータではWINDOW_SIZE=1の方が結果が良かったので、WINDOW_SIZE=1にしています。

1, 2, 3, 4

入力値:1,2 正解値:3
入力値:2,3 正解値:4

class ProductDataset(Dataset):
  WINDOW_SIZE = 1

  def __init__(self, sequences):
    super().__init__()
    self.x = []
    self.t = []

    for sequence_line in sequences:
      if len(sequence_line) <= self.WINDOW_SIZE: # WINDOW_SIZE以下ならサンプルにしない
        continue
      for i in range(len(sequence_line) - self.WINDOW_SIZE):
        tmp_x = sequence_line[i:i+self.WINDOW_SIZE]
        tmp_t = [sequence_line[i+self.WINDOW_SIZE]] + [0] * (self.WINDOW_SIZE -1)
        if all([i == 0 for i in tmp_x]) or all([i == 0 for i in tmp_t]): # 分割した結果、0ベクトルになってしまう場合はサンプルにしない
          continue
        self.x.append(tmp_x)
        self.t.append(tmp_t)
    self.length = len(self.x)

  def __len__(self):
    return self.length

  def __getitem__(self, sequence):
    return torch.tensor(self.x[sequence]), torch.tensor(self.t[sequence])

シーケンスを入れて、データセットを作ります。
これで全てのシーケンスを1つの入力値と1つの目標値に分割することが出来ました。

dataset = ProductDataset(sequences)
print(len(dataset))

出来上がったデータを訓練用、テスト用の2つに分けておきます。
(今回ハイパーパラメータの調整は行わないので、検証用データは作成しません。)

# 訓練用、テスト用にdatasetを分割
splitted_train, splitted_test = train_test_split(list(range(len(dataset))), test_size=0.4)
train = torch.utils.data.Subset(dataset, splitted_train)
test = torch.utils.data.Subset(dataset, splitted_test)

print(len(train))
print(len(test))

分割したDataSetをDataLoaderに入れて、準備完了です。

BATCH_SIZE = 250
# 訓練用dataloader作成
dataloader = DataLoader(train, batch_size=BATCH_SIZE, shuffle=True, drop_last=True)
# 正解率測定用dataloader作成
train_loader = DataLoader(train, batch_size=1, shuffle=True, drop_last=True)
test_loader = DataLoader(test, batch_size=1, shuffle=True, drop_last=True)

再び余談ですが、このやり方だと、RNNで訓練する1度のバッチに複数のユーザの商品購入履歴が含まれる可能性があります。
RNNは時系列に意味があることを表現するので、このままだと、あるユーザの商品購入履歴が、別のユーザの商品購入履歴に影響してしまいます。
この辺の解決が今回うまく行かなかったので、今後課題として解決して行きたいと思っています。

8. ニューラルネットの実装

冒頭述べた様に、商品ID(シーケンス)を埋め込んだベクトルを作成→RNNに入力→全結合層に入力するというニューラルネットを実装します。
活性化関数はReLU関数を使用しています。pytorchのRNNではデフォルト活性化関数としてtanhが採用されていますが、tanhでは勾配消失が起きてしまい、効率よく学習が進まなかったためです。
ハイパーパラメータ(hidden_size: 隠れ層の次元数、num_layers: RNNの層数、embedding_dim: 埋め込み層の次元数)は適当です。
これらはそこそこの結果を出した数値を設定しています。
本来はハイパーパラメータを自動的に決定出来る仕組みを使うのがいいですね。

class Net(nn.Module):
  HIDDEN_SIZE = 300
  NUM_L = 1
  EMB_DIM = 100

  def __init__(self, sequence_count):
    super().__init__()
    self.sequence_count = sequence_count
    self.hidden = torch.zeros(self.NUM_L, BATCH_SIZE, self.HIDDEN_SIZE).cuda()
    self.emb = nn.Embedding(self.sequence_count, self.EMB_DIM, padding_idx=0)
    self.rnn = nn.RNN(self.EMB_DIM, self.HIDDEN_SIZE, batch_first=True, num_layers=self.NUM_L, nonlinearity='relu')
    self.lin = nn.Linear(self.HIDDEN_SIZE, self.sequence_count)
    self = self.cuda()

  def forward(self, x):
    o = self.emb(x)
    o, self.hidden = self.rnn(o, self.hidden)
    y = self.lin(o)
    return y

  def init_hidden(self, batch_size=BATCH_SIZE):
    self.hidden = torch.zeros(self.NUM_L, batch_size, self.HIDDEN_SIZE).cuda()

オブジェクトにしておきます。
引数は辞書に登録されている商品数(辞書的に言うなら語彙数)です。
埋め込み層の入力と、全結合層の出力に使います。

net = Net(pdic.get_sequence_count())

9. 損失関数と最適化関数の準備

こちらも冒頭述べた様に、損失関数として交差エントロピーを使い、最適化関数として確率的勾配降下法を使います。
学習率、エポックは適当です。こちらも本来は損失が最小になる様な値を自動的に決定するのがいいのですが、今回はそこまでやってません。

class Training():
  EPOCHS = 400

  def __init__(self, net, dataloader):
    self.net = net
    self.dataloader = dataloader
    self.loss_func = nn.CrossEntropyLoss(ignore_index=0)
    self.optimizer = torch.optim.SGD(net.parameters(), lr=0.01)

  def train(self):
    self.net.train()
    for epoch in range(self.EPOCHS):
      for cnt, (x, t) in enumerate(self.dataloader):
        self.optimizer.zero_grad()
        x = x.cuda()
        t = t.cuda()
        self.net.init_hidden()
        y = self.net(x)
        y = y.reshape(-1, self.net.sequence_count)
        t = t.reshape(-1)
        loss = self.loss_func(y, t)
        loss.backward()
        self.optimizer.step()
      print("epoch:", epoch, "\t" , "loss:", loss.item())
    return

10. ニューラルネットを訓練する

損失関数の値をエポック毎に表示しています。
学習が進むにつれて損失関数の値が減っていき、目標値と予測値の差が小さくなって行く様子が見て取れます。
訓練は用意したデータ量によって実行時間が変わります。私の場合は20分程度で訓練が終わります。

training = Training(net, dataloader)
training.train()

epoch: 0 loss: 10.928627014160156
epoch: 1 loss: 10.909346580505371
epoch: 2 loss: 10.894872665405273

epoch: 397 loss: 0.733048677444458
epoch: 398 loss: 0.6150917410850525
epoch: 399 loss: 0.6824182868003845

11. 評価する

訓練したモデルの正解率と、予測結果を取得出来る機能を実装します。

class Evaluation():

  PREDICT_COUNT = 5

  def __init__(self, net, pdic):
    self.net = net
    self.pdic = pdic
    self.net.eval()

  def calc_accuracy(self, data_loader):
    with torch.no_grad():
      total = 0
      correct = 0

      for batch in data_loader:
        x, t = batch
        x = x.cuda()
        t = t.cuda()
        self.net.init_hidden(batch_size=1)
        predicted = self.net(x)
        predicted = predicted.reshape(-1, self.pdic.get_sequence_count())
        probability = torch.softmax(predicted[0], dim=0).cpu().detach().numpy()
        next_product = np.random.choice(self.pdic.get_sequence_count(), p=probability)
        if next_product == 0:
          continue
        next_product = self.pdic.sequence_product[next_product]
        detached_t = t.to('cpu').detach().numpy().copy()
        t_product_sequence = detached_t[0][0]
        if t_product_sequence == 0:
          continue
        t_product = self.pdic.sequence_product[t_product_sequence]

        total += 1
        correct += 1 if next_product == t_product else 0
      accuracy = correct / total
      return accuracy

  def predict(self, products):
    sequence_count = self.pdic.get_sequence_count()

    with torch.no_grad():
      predicted = products
      sequences = self.pdic.get_sequences_by_products([products])

      i = 0
      while i < self.PREDICT_COUNT:
        x = torch.tensor(sequences).cuda()
        self.net.init_hidden(batch_size=1)
        y = self.net(x)
        y = y.reshape(-1, sequence_count)
        probability = torch.softmax(y[0], dim=0).cpu().detach().numpy()
        next_sequence = np.random.choice(sequence_count, p=probability)
        next_product = self.pdic.sequence_product[next_sequence]

        if next_product in predicted:
          continue
        predicted = predicted + ',' + next_product
        sequences = self.pdic.get_sequences_by_products([predicted])
        i+=1
    return predicted

12.正解率を求める

訓練したモデルに訓練データとテストデータを入れて、どの程度正しく予測出来るのかを見ます。
「7.ニューラルネットに入力するデータの準備」の例を借りると、
1という入力値を入力したら、2という正解が得られるか、2という入力値を入力したら、3という正解が得られるか…という具合に、
全ての入力値に対して予測を行い、入力数に対して正解数がどれだけあるかを計算するということです。
結果、訓練データに対しては63%, テストデータに対して21%でした。
テストデータ=未知のデータなので、こちらに良い数字が出てほしいですが、数字的にはそこまで正しい予測は出来ていない様に見えます。

ev = Evaluation(net, pdic)
ev.calc_accuracy(train_loader)

0.6362448515835819

ev.calc_accuracy(test_loader)

0.21638722597413665

13.人間の目で予測結果を見てみる

「12.正解率を求める」の結果があまり良くなかったので、どういう結果が返ってくるのか心配でしたが、
例えばスーパーデリバリーに出展されている企業様の実際の商品IDを入れてみると、比較的近いテイストの商品が出力されました。
テストデータの正解率の21%という結果からすると、もっと異なる結果になるかと思いましたが、人の目で見ると案外悪くないかも?とも思えます。
リコメンドの場合、目標の商品が例えば5商品以内に含まれていれば正解とするなど、正解率の考え方を変えるほうが良いのかもしれません。

ordered_products = '9243757'
ev.predict(ordered_products)

'9243757,7660045,9163123,9182799,9201543,9210146'

入力商品はこちら
9243757
予測商品はこちら
7660045
9163123
9182799
9201543
9210146
※出品状況は時々刻々と変わるため、リンク切れになっている可能性もあります。

14. 今後

今回はシンプルなRNNを実装し、結果を見るところまで進むことが出来ました。
今後はLSTMなどより進んだ時系列データを扱えるニューラルネットを使ってみたり、各ハイパーパラメータの最適化を自動で行ったり、課題として上げている部分を解決しながら良いリコメンドが出来る様にAI開発を進めて行きたいと思います。
また進捗あったらブログ書くと思います。それでは今日はこのへんで!

参考にさせて頂いたサイト・文献

Pytorch turorials
作って試そう! ディープラーニング工作室
ゼロから作るDeep Learning
PyTorch実践入門
TensorFlowとKerasで動かしながら学ぶ ディープラーニングの仕組み

関連記事

運営会社:株式会社ラクーンホールディングス(c)2000 RACCOON HOLDINGS, Inc