【Next.js×Rails API】レコメンド機能「あなたにおすすめのガジェット」を協調フィルタリングを使って実装する

# 概要
- Next.js×Rails APIのSPA構成で、ガジェットの情報交換ができるSNSを構築している
- ログインユーザーに対して「あなたにおすすめのガジェット」をレコメンドする機能を実装したので、備忘も兼ねてまとめる
- 最終的なコードは[こちら](#実装)

# 環境
- Backend
    - ruby 3.0.2
    - rails 6.1.4 (APIモード)
- Frontend
    - react 18.2.0
    - next 13.1.6


# 基本的な機能
当SNSでは、ユーザーがそれぞれ自前のガジェット情報を投稿し、それに対して他のユーザーが
- いいね
- ブックマーク
- レビューリクエスト
- コメント

といったアクションをすることで、交流が可能となっている。

### 投稿されたガジェットのイメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/cef77574-f32a-8d97-401e-03e7c2346c95.png)

### アクションについて補足
#### いいねとブックマークの違い
ブックマークはストックされ、マイページで一覧を確認できる。
いいねはストックされない。

#### レビューリクエストとは
ガジェット登録は基本情報（名前・カテゴリ）のみでも可能としているため、レビューが無い場合がある。そのようなガジェットについて、登録者にレビューを書いてもらえるようリクエストする。



# レコメンド機能要件
ログインユーザーが関心のある未発見のガジェットを提案する


## レコメンドアルゴリズムの種類
> 参考

https://aicross.co.jp/aix-lab/blog/recommendation-engine-2/

https://toukei-lab.com/recommend-algorithm

レコメンドのアルゴリズムには様々な種類がある。

### ルールベース
事前に任意のルールを設定し、それに従ったレコメンドを行う。
- メリット
    - おすすめをコントロールできる。
    - 実装難易度が比較的低い。
- デメリット
    - 実際のユーザーの趣味嗜好を反映するものではない。
    - ルールの設定と調整が必要。  
             
### コンテンツベース
コンテンツに事前に属性を設定しておき、類似性の高いものをレコメンドする。
- メリット
    - 興味・関心のあるガジェットの提案は可能。
- デメリット
    - 実際のユーザーの趣味嗜好を反映するものではない。
    - 同じような提案が多くなり、新しい発見は得にくい。
    - ガジェット投稿時に、属性等を細かく設定してもらう必要があり、投稿のハードルが上がる。
        - ユーザーに登録してもらえない場合は、こちらで要素を抽出・分析する必要がある。
    
### パーソナライズドレコメンド
ペルソナを設定し、趣味嗜好に合わせたレコメンドを行う。
- メリット
    - おおまかに興味・関心のあるガジェットの提案は可能と思われる。
- デメリット
    - 実際のユーザーの趣味嗜好を反映するものではない。
    - ユーザー登録時に、属性等を細かく設定してもらう必要があり、登録のハードルが上がる。
        - ユーザーに登録してもらえない場合は、こちらで要素を抽出・分析する必要がある。
    - ペルソナの設定と、それに合わせた提案パターンをあらかじめ作成し、調整していく必要がある。
    
### 協調フィルタリング
#### アイテムベース
ユーザーの行動分析から算出したアイテムの類似度を基にレコメンドを行う。
- メリット
    - ユーザーの趣味嗜好を反映した提案ができる
    - 新しい発見が期待できる
    - ロジックは比較的単純
    - アクションしたか・していないかだけで計算が可能（数値評価は不要）なため、ユーザー体験を損ねない
- デメリット
    - データの蓄積が少ないと精度が低くなる（コールドスタート問題）

#### ユーザーベース
ユーザー同士の類似度を算出し、類似度の高いユーザーの関心を基にレコメンドを行う。
- メリット
    - ユーザーの趣味嗜好を反映した提案ができる
    - 新しい発見が期待できる
    - math、statsampleといったrubyライブラリを利用すれば比較的簡単に実装できそう？
- デメリット
    - データの蓄積が少ないと精度が低くなる（コールドスタート問題）
    - ユーザーの類似度を算出するためには、各アクションの数値化が必要となり、ユーザー体験を損ねる恐れがある（いいね等のたびにスコアをつける、となると、気軽にいいね等のアクションを取りにくくなる）
    - ロジックは難しい（ユークリッド距離やピアソンの相関係数等）
                

## アルゴリズム選定
ユーザー体験を損ねず、「ログインユーザーが関心のある未発見のガジェットを提案する」という要件を満たす、アイテムベースの協調フィルタリングを採用することとする。

### 具体的なレコメンドのイメージ
アイテムベースの協調フィルタリングでは、
> 商品aとbを同時に購入したユーザーが多い場合、aを購入したユーザーにbを勧める

といった、「セットで購入される商品間の関連性」を利用するが、ここでは「購入」を各アクション（いいね、ブックマーク、レビューリクエスト、コメント）に置き換え、

> ガジェットaとbにいいねしているユーザーが多い場合、aにいいねしたユーザーにbを勧める

といった形でレコメンドを行うものとする。

# シミュレーション

> 参考

https://www.stat.go.jp/naruhodo/15_episode/toukeigaku/recommend.html

こちらのクロス集計のプロセスを当てはめてシミュレーションをしてみる。

## 前提
A〜Dのユーザーが存在し、a〜eのガジェットについてアクションをしている場合を想定。

## アクション：いいね
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/c5da5cc3-4339-05ee-4afa-5af1ac284606.png)


※セットガジェット・・・軸ガジェットと一緒にアクションされたガジェット
※関連度・・・そのままおすすめ度となる

### （１）セットでアクションされるガジェットの関連性（アクション履歴）
それぞれのユーザーが、どのガジェットにいいねしているかを集計。
例）ユーザーAは、ガジェットa,c,d,eにいいねしている。

### （２）同一ユーザーがアクションしたガジェットの組み合わせの回数
軸ガジェットにいいねしたユーザーが、他にいいねしたガジェットの回数を集計。
例）
（１）を、ガジェットaを軸として見てみると、
ユーザーAは、ガジェットaにいいねしており、c,d,eにもいいねしている。
ユーザーBは、ガジェットaにいいねしていないため集計外。
ユーザーCは、ガジェットaにいいねしており、cにもいいねしている。
ユーザーDは、ガジェットaにいいねしており、b,c,dにもいいねしている。

以上から、軸ガジェットaをいいね している場合、
同時にbにいいねしている：1回
同時にcにいいねしている：3回
同時にdにいいねしている：2回
同時にeにいいねしている：1回
回数の合計：7回

と集計できる。


### （３）組み合わせの総和からセットでアクションされるガジェットの割合
総和に対する割合を集計。
例）軸ガジェットをaとすると、
b：1 / 7 * 100 = 14%
c：3 / 7 * 100 = 43%
d：2 / 7 * 100 = 29%
e：1 / 7 * 100 = 14%
となる。

### （４）テーブル化
（３）の集計結果をテーブル化。
例）軸ガジェットをaとすると、関連度が高い順に、
c：43%
d：29%
b：14%
e：14%
となる。


### いいね 単体でのおすすめ度
軸ガジェットをaとすると、ここまでの集計により、いいね単体でのおすすめ度は、
c>d>b=e
であることが計算できた。


## アクション：全て（いいね、ブックマーク、レビューリクエスト、コメント）
いいねに加えて、他のアクションも同様に集計を行う。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/845bf848-bbfd-c996-443b-8103d0a3955a.png)

これらをそのまま合計すると、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/8722c2a9-0852-b6c7-0d2b-403703d27882.png)
このような集計となる。

ここで、精度向上を目的とし、各アクションに重みを設定する。
重みは、各アクションの回数にかけることで、重みが大きいほど総和に対する割合が増加し、おすすめ度に与える影響が大きくなるものとする。

- 単純ないいねよりも、ストックできるブックマークの方が、関心の度合いが高い
- その他アクションも、いいねよりは関心の度合いが高い

とみなし、各アクションの重みは下記の設定とする。

- いいね：1
- ブックマーク：2
- レビューリクエスト：2
- コメント：2


これらを、各アクションの回数にかけると、下記の集計結果となり、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/13a81d09-de6c-9f02-774b-2e3897352dfa.png)

最終的なおすすめ度は下記の通りとなる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/8be5c392-deae-9ee1-db2e-81ac1f3fcbd0.png)


### 全アクション合計でのおすすめ度
軸ガジェットをaとすると、ここまでの集計により、全アクション合計でのおすすめ度は、
c>d>b>e
であることが計算できた。

複数のアクションを合計することで、いいね単体の時よりも、おすすめ度の差をより明確に算出することができている。

# 実装
ここまででシミュレーションした内容を実装していく。

## 詳細要件
- ログイン後のトップページに、「あなたにおすすめのガジェット」一覧を表示する
- おすすめ候補は、アイテムベースの協調フィルタリングを利用して算出する。
- アクション毎に重みを設定し、おすすめ度の算出時に考慮する（いいね:1,ブックマーク:2の場合、いいねよりブックマークの方が関連性が高いとみなす）
- おすすめの起点（軸ガジェット）とするガジェットは、「ログインユーザーが最も関心のあるガジェット」とする
- 関心の度合いは、ガジェットに対するアクションの総数と、アクション実行日時を加味して算出する
- 既にアクションしたガジェットと、自身が登録したガジェットはおすすめ対象外とする
- おすすめ対象ガジェットが複数ある場合は、おすすめ度の高い順に並べて表示する

## 実装
ロジックはGadgetモデルに集約している。

```ruby:routes.rb
Rails.application.routes.draw do
  namespace :api, format: 'json' do
    namespace :v1 do
      resources :users do
        member do
          get 'recommended_gadgets', to: 'gadgets#recommend'
        end
      end
    end
  end
end

```

```ruby:gadgets_controller.rb
      def recommend
        # ログインユーザーへのおすすめガジェット情報
        @gadgets = Gadget.recommend_gadgets(User.find(params[:id]))

        render json: { gadgets: @gadgets },
               include: %i[user gadget_likes gadget_bookmarks review_requests]
      end
```

```ruby:models/各アクション.rb
# いいね
class GadgetLike < ApplicationRecord
  belongs_to :user
  belongs_to :gadget
end
# ブックマーク
class GadgetBookmark < ApplicationRecord
  belongs_to :user
  belongs_to :gadget
end
# レビューリクエスト
class ReviewRequest < ApplicationRecord
  belongs_to :user
  belongs_to :gadget
end
# コメント
class Comment < ApplicationRecord
  belongs_to :user
  belongs_to :gadget
end
```

```ruby:models/gadget.rb

  # おすすめガジェット情報を返す
  def self.recommend_gadgets(user)
    # ユーザーが最も関心のあるガジェットを特定
    most_interested_gadget_id = determine_most_interested_gadget(user)

    # 対象ガジェットが存在しない場合は空データを返して処理を終了する
    return Gadget.where(id: most_interested_gadget_id) if most_interested_gadget_id.nil?

    # 関連ガジェットのスコアを計算
    relation_scores = calculate_related_gadgets_scores(most_interested_gadget_id, user)

    # スコア順にガジェットIDをソート
    sorted_gadget_ids = sort_gadget_ids_by_scores(relation_scores, user)

    Gadget.where(id: sorted_gadget_ids).order(Arel.sql("FIELD(id, #{sorted_gadget_ids.join(',')})"))
  end

  # ユーザーがアクションを取ったガジェットIDと日時を取得
  def self.fetch_actions_arrays(user)
    actions_arrays = [
      GadgetLike.where(user_id: user),
      GadgetBookmark.where(user_id: user),
      ReviewRequest.where(user_id: user),
      Comment.where(user_id: user)
    ].map { |action| action.pluck(:gadget_id, :created_at) }

    actions_arrays
  end

  # ユーザーが最も関心のあるガジェットを特定
  def self.determine_most_interested_gadget(user)
    actions_arrays = fetch_actions_arrays(user)

    # ガジェットID毎に最新のアクション日時を取得
    id_info_map = {}
    actions_arrays.each do |array|
      array.each do |id, time|
        id_info = id_info_map[id] ||= { interaction_level: 0, latest_time: nil }

        id_info[:interaction_level] += 1
        id_info[:latest_time] = time if id_info[:latest_time].nil? || time > id_info[:latest_time]
      end
    end

    # 同一ガジェットへ行なったアクションの最大数を取得（例：あるガジェットにいいねとブックマークをした→interaction_levelは2）
    max_interaction_level_info = id_info_map.max_by { |_id, info| info[:interaction_level] }
    max_interaction_level = max_interaction_level_info&.last&.fetch(:interaction_level, 0)

    # 基本的にはinteraction_levelが2以上のガジェットを関心ありとみなす
    # ※2以上であれば、levelより日時が新しいことを優先する
    # ※level1のガジェットしかない場合は、level1のみ対象とする
    base_level = max_interaction_level == 1 ? 1 : 2

    # 基準の中で、アクションを起こしたのが最も新しいガジェットを、最も関心があるガジェットとみなす
    most_interested_gadget_id = id_info_map.select { |_id, info| info[:interaction_level] >= base_level }
                                           .max_by { |_id, info| info[:latest_time] }
                                           &.first
    most_interested_gadget_id
  end

  # 関連ガジェットのスコアを計算
  def self.calculate_related_gadgets_scores(most_interested_gadget_id, user)
    # アクション・ガジェット毎のスコアを計算
    related_gadgets_count_like = calculate_related_gadget_count(GadgetLike, most_interested_gadget_id, user, 1)
    related_gadgets_count_bookmark = calculate_related_gadget_count(GadgetBookmark, most_interested_gadget_id, user, 2)
    related_gadgets_count_request = calculate_related_gadget_count(ReviewRequest, most_interested_gadget_id, user, 2)
    related_gadgets_count_comment = calculate_related_gadget_count(Comment, most_interested_gadget_id, user, 2)

    related_gadgets_actions_count = [
      related_gadgets_count_like,
      related_gadgets_count_bookmark,
      related_gadgets_count_request,
      related_gadgets_count_comment
    ]

    # ガジェット毎の総アクション数を計算
    related_gadgets_count_sum = related_gadgets_actions_count.each_with_object({}) do |hash, result|
      hash.each do |key, value|
        result[key] ||= 0
        result[key] += value
      end
    end

    # 全アクション数の合計値
    total_count = related_gadgets_count_sum.values.sum
    # 各ガジェットへのアクション数が、全体のアクション数に占める割合を、おすすめガジェットの関連度スコアとする
    relation_scores = related_gadgets_count_sum.transform_values { |count| (count.to_f / total_count * 100).round(2) }
    relation_scores
  end

  # 対象ガジェットに関連するユーザーが別のガジェットに行ったアクション数を計算する
  def self.calculate_related_gadget_count(model, most_interested_gadget_id, user, weight)
    # 対象ガジェットにアクションしたユーザーIDを配列で取得
    users_ids = model.where(gadget_id: most_interested_gadget_id)
                     .where.not(user_id: user)
                     .pluck(:user_id)

    # 対象ガジェットにアクションしたユーザーが、他にアクションしている全てのガジェットをIDごとに集計
    related_gadgets_count = model.where(user_id: users_ids)
                                 .reorder(nil) # デフォルトのソートを解除
                                 .group(:gadget_id)
                                 .count
    # 対象ガジェットは集計から除外
    related_gadgets_count.delete(most_interested_gadget_id)

    # 各アクション毎に設定した重みを加算する（例：いいねよりもブックマークの方が、関連度により影響を与えるものとする）
    related_gadgets_count.transform_values { |value| value * weight }
  end

  # スコア順にガジェットIDをソート
  def self.sort_gadget_ids_by_scores(relation_scores, user)
    # スコア順にガジェットIDをソート
    sorted_gadget_ids = relation_scores.keys.sort_by { |id| -relation_scores[id] }

    # おすすめする必要のないガジェットIDを特定
    ignored_gadget_ids = fetch_interested_gadget_ids(user) # 既にアクション済みのガジェットID
    ignored_gadget_ids.concat(Gadget.where(user_id: user.id).pluck(:id)) # ログインユーザー自身のガジェットIDを追加

    # 既に関心のあるガジェットと自分のガジェットはおすすめ対象から除外
    sorted_gadget_ids - ignored_gadget_ids.uniq
  end

  # ユーザーがアクションを取ったガジェットIDの取得
  def self.fetch_interested_gadget_ids(user)
    actions_arrays = fetch_actions_arrays(user)
    actions_arrays.flatten(1).map { |item| item[0] }.uniq
  end
```

パフォーマンスやおすすめ精度等、検討と改善の余地は多くあるものの、ひとまずは以上で要件を満たすレコメンドができるようになった。

## 補足解説

### 処理の流れについて
おすすめ度をデータベース等で保持すると、保持テーブルのメンテナンス（各アクション実行時に都度更新するのか？定期的にデータを走査して整理するのか？等）が発生すると思われるため、この実装方法では、都度算出する方式としている。

そのため、前述のクロス集計のプロセスとは一部異なり、
- 「ログインユーザーが最も関心のあるガジェット」を、おすすめの起点（軸ガジェット）として特定する
- 特定した対象ガジェットに関連するセットガジェットをピンポイントで探し、おすすめ度を計算する
- おすすめ度順に取得して返す

といった流れとなっている。

### 「ログインユーザーが最も関心のあるガジェット」の対象条件について
interaction_levelが2以上（同一ガジェットに２種類以上のアクションをしている）のガジェットの中で、アクション日時が最も新しいものを、「ログインユーザーが最も関心のあるガジェット」として特定するようにしているが、これは、
- interaction_levelが2以上であれば、それなりに関心があるとみなせるはず
- また、「2以上」という条件がなく、単純に「アクション数が多い順」+「日時が新しい順」とすると、軸ガジェットが固定化されてしまう懸念がある
例）過去にinteraction_level4のガジェットfがあったが、直近で閲覧したガジェットについては、interaction_level3以下のアクションしかしていない、といった場合、ガジェットfに関連するおすすめしか表示されなくなってしまう

といった理由から。

### スコアの計算処理について
calculate_related_gadgets_scoresの処理がややこしいので、シミュレーション時の表を添えて補足する。

以下の補足では、
- userが新規ユーザーEで、ガジェットaにのみいいねしており、軸ガジェットはaとなっている
- その他のユーザーとアクション状況は、前段のシミュレーションと同様
- ユーザーのIDは、A：1　B：2　C：3　D：4　
- ガジェットのIDは、a：111　b：222　c：333　d：444　e：555　

とする。

#### calculate_related_gadget_count
##### 対象ガジェットにアクションしたユーザーIDを配列で取得

```ruby
    # 対象ガジェットにアクションしたユーザーIDを配列で取得
    users_ids = model.where(gadget_id: most_interested_gadget_id)
                     .where.not(user_id: user)
                     .pluck(:user_id)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/7eeb2d9c-3f06-f6e1-db5b-baeece8dfc49.png)


users_idsには、ガジェットaにいいねしている、ユーザーA,C,DのIDが入る。
```console
users_ids
=> [1, 3, 4]
```
##### 対象ガジェットにアクションしたユーザーが、他にアクションしている全てのガジェットをIDごとに集計

```ruby
    # 対象ガジェットにアクションしたユーザーが、他にアクションしている全てのガジェットをIDごとに集計
    related_gadgets_count = GadgetLike.where(user_id: users_ids)
                                 .reorder(nil) # デフォルトのソートを解除
                                 .group(:gadget_id)
                                 .count
    # 対象ガジェットは集計から除外
    related_gadgets_count.delete(most_interested_gadget_id)
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/89bbc91e-079d-b555-560d-7ad5c5f198f6.png)


related_gadgets_countには、ユーザーA,C,Dが他にいいねしているガジェットb,c,d,eとその回数が入る。
```console
related_gadgets_count
=> {333=>3, 444=>2, 555=>1, 222=>1}
```

#### calculate_related_gadgets_scores
いいね以外も計算し、related_gadgets_actions_countにまとめる。
```ruby
related_gadgets_actions_count
=> [{333=>3, 444=>2, 555=>1, 222=>1}, {333=>2, 444=>2, 555=>2, 222=>2}, {222=>2, 333=>2, 555=>2}, {222=>2, 444=>4, 333=>2}]
```

##### ガジェット毎の総アクション数を計算
```ruby
    # ガジェット毎の総アクション数を計算
    related_gadgets_count_sum = related_gadgets_actions_count.each_with_object({}) do |hash, result|
      hash.each do |key, value|
        result[key] ||= 0
        result[key] += value
      end
    end
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/835648ae-a7f1-4240-57cb-d5b30137c63e.png)
related_gadgets_count_sumには、ガジェット毎のアクション数合計が入る。
```console
related_gadgets_count_sum
=> {333=>9, 444=>8, 555=>5, 222=>7}
```

##### 全アクション数の合計値
```ruby
    # 全アクション数の合計値
    total_count = related_gadgets_count_sum.values.sum
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/d2527d2d-7ff7-bb91-3578-a505ce4ec953.png)

```console
total_count
=> 29
```

##### 各ガジェットへのアクション数が、全体のアクション数に占める割合を、おすすめガジェットの関連度スコアとする
```ruby
    # 各ガジェットへのアクション数が、全体のアクション数に占める割合を、おすすめガジェットの関連度スコアとする
    relation_scores = related_gadgets_count_sum.transform_values { |count| (count.to_f / total_count * 100).round(2) }
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/948830/35aa599b-ceb9-72d9-eceb-450528a8e348.png)

relation_scoresには、最終的なセットガジェット毎の関連度が入る。
```console
relation_scores
=> {333=>31.03, 444=>27.59, 555=>17.24, 222=>24.14}
```


# 最後に
より良い方法や間違い等ありましたらご指摘いただけますと幸いです！