Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby: 対象の特徴を座標化し類似度を算出してレコメンド機能っぽいものを作ってみるMEMO

最近以下の本を読んで、なんかしらのデータの特徴をグラフにしてピタゴラスの定理を使って2点間の距離を算出すると、簡単なレコメンドシステムを作れることを勉強したので、実際にピュアなRubyのコードで作ってみました。自分の学びのためにも流れをメモしておきます✍

作るもの

今回は以下のようなNEWSサイトを想定し、類似ユーザーの「いいね」をもとに記事をレコメンドするような機能を作ってみようと思います。

  • ユーザーは登録時に年代、性別、好きなジャンルを入力する
  • ユーザーは記事を閲覧出来る
  • ユーザーは記事を閲覧を気にったら「いいね」出来る
  • ユーザーに対して記事をレコメンドする。条件は以下の通り
    • 対象ユーザーと類似度が高い上位2名のユーザーが「いいね」している 記事

実際のNewsとUserのモデルのコードは以下のようなものを想定してます。

class News
  GENRE = { sports: 1, entertainment: 2, politics: 3, medical: 4, incident: 5 }

  def initialize(id:, title:, genre:)
    @id = id
    @title = title
    @genre = genre
    @liked_user_ids = []
  end

  attr_reader :id, :title, :liked_user_ids

  def like(user_id:)
    @liked_user_ids << user_id
  end
end

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = favorite_genre
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre
end

ユーザーの特徴を座標に変換する

まずはユーザーの特徴を座標に変換します。

今回はユーザーが登録時に入力した年代、性別、好きなジャンルをもとに座標を算出するcoordinatesメソッドを定義します。 ※年代については10代、20代というような形で行うように10で割った値を設定しています。好きなジャンルは優先度を上げるために1.2掛けして重みを増しています。

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = favorite_genre
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre

  def coordinates
    [sex.to_i, (age / 10).to_i, (favorite_genre * 1.2)]
  end
end

これを使うことによって、以下のように各ユーザーの特徴を3次元の座標に変換することが出来ました。

users = [
  User.new(id: 1, name: "Tom", sex: 1, age: 20, favorite_genre: :sports),
  User.new(id: 2, name: "Jesica", sex: 2, age: 15, favorite_genre: :entertainment),
  User.new(id: 3, name: "Bob", sex: 1, age: 30, favorite_genre: :medical),
  User.new(id: 4, name: "Terry", sex: 1, age: 40, favorite_genre: :sports),
  User.new(id: 5, name: "Ami", sex: 2, age: 25, favorite_genre: :incident)
]

users.map { |user| { id: user.id, coordinates: user.coordinates } }
#=>
# [{:id=>1, :coordinates=>[1, 2, 1.2]},
# {:id=>2, :coordinates=>[2, 1, 2.4]},
# {:id=>3, :coordinates=>[1, 3, 4.8]},
# {:id=>4, :coordinates=>[1, 4, 1.2]},
# {:id=>5, :coordinates=>[2, 2, 6.0]}]

ユーザーの座標を元に類似度を算出する

この値をもとにピタゴラスの定理を用いてレコメンドしたいユーザーの座標から、それぞれのユーザーの座標への距離を算出することで、ユーザーの類似度を算出することが出来ます。

User側からはどういうロジックで距離を算出するかは気にしたくないので、2点間の距離を算出するために以下のClassを用意しました。 後に上位2名を取得することを考慮して、Comparableを使用して直接Distanceのオブジェクトを比較出来るようにしています。

require 'complex'

class Distance
  include Comparable

  def initialize(from:, to:)
    @from = from
    @to = to
  end

  attr_reader :from, :to

  def value
    @value ||= euclidean_distance(from: from, to: to)
  end

  def <=>(other)
    value - other.value
  end

  private

  # ピタゴラスの定理を用いて2点間の距離(ユークリッド距離)を算出
  def euclidean_distance(from:, to:)
    sum = from.each.with_index.inject(0) { | sum, (v, i) | sum += (v - to[i])**2 }
    Math.sqrt(sum)
  end
end

そして上記のClassを使ってユーザー同士の距離を測定出来るようにします。

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = News::GENRE[favorite_genre].to_i
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre

  def distance(to:)
    Distance.new(from: coordinates, to: to.coordinates)
  end

  def coordinates
    [sex.to_i, (age / 10).to_i, (favorite_genre * 1.2)]
  end
end

対象ユーザーからその他のユーザーの距離を測定してみた結果が以下の通りです。 ※今回はユーザー(d: 1, name: "Tom")をレコメンドを提示するユーザーとしました。

users = [
  User.new(id: 1, name: "Tom", sex: 1, age: 20, favorite_genre: :sports),
  User.new(id: 2, name: "Jesica", sex: 2, age: 15, favorite_genre: :entertainment),
  User.new(id: 3, name: "Bob", sex: 1, age: 30, favorite_genre: :medical),
  User.new(id: 4, name: "Terry", sex: 1, age: 40, favorite_genre: :sports),
  User.new(id: 5, name: "Ami", sex: 2, age: 25, favorite_genre: :incident)
]

users.map { |user| { id: user.id, coordinates: user.coordinates } }
#=>
# [{:id=>1, :coordinates=>[1, 2, 1.2]},
# {:id=>2, :coordinates=>[2, 1, 2.4]},
# {:id=>3, :coordinates=>[1, 3, 4.8]},
# {:id=>4, :coordinates=>[1, 4, 1.2]},
# {:id=>5, :coordinates=>[2, 2, 6.0]}]

target = users[0]
users.select { |user| user != target }.map { |user| { id: user.id, distance: target.distance(to: user).value } }
#=>
# [{:id=>2, :distance=>1.8547236990991407},
# {:id=>3,  :distance=>3.7363083384538807},
# {:id=>4,  :distance=>2.0},
# {:id=>5,  :distance=>4.903060268852505}]

この結果から対象のユーザーと類似度が高い上位2名のユーザーは以下であることが分かります。

  • ユーザー(id: 2, name: "Jesica")
  • ユーザー(id: 4, name: "Terry")

ピタゴラスの定理の詳細はこちら

ja.wikipedia.org

類似度の高いユーザーが「いいね」した記事を取得する

まず元データが無いとレコメンドすることも出来ないので、以下のような記事があり、それぞれ引数で渡したユーザーによって「いいね」されている状況だとします。

news = [
  News.new(id: 1, title: "念願の優勝!", genre: :sports),
  News.new(id: 2, title: "あの人がCMに?", genre: :entertainment),
  News.new(id: 3, title: "ついに逮捕!?", genre: :incident),
  News.new(id: 4, title: "期待の新薬誕生か?", genre: :medical),
  News.new(id: 5, title: "ついにあの条約が結ばれる", genre: :politics),
]

news[0].like(user_id: 1)
news[1].like(user_id: 2)
news[1].like(user_id: 3)
news[2].like(user_id: 1)
news[2].like(user_id: 5)
news[3].like(user_id: 4)

ここまでくればあとは単純で以下のような形で算出した類似度が高い上位2名を取得して、そのユーザーが「いいね」している記事を取得すればOKです🙆‍♂️

ids = distances.sort_by {|v| v[:distance] }.first(2).map { |v| v[:id] }
# => [2, 4]

recommends = news.select do |news|
  ids.any? { |id| news.liked_user_ids.include?(id) }
end
# =>
# [#<News:0x00007fe3339ab280
#  @genre=:entertainment,
#  @id=2,
#  @liked_user_ids=[2, 3],
#  @title="あの人がCMに?">,
# #<News:0x00007fe3339aaf38
#  @genre=:medical,
#  @id=4,
#  @liked_user_ids=[4],
#  @title="期待の新薬誕生か?">]

最終的なコード

最終的なコードを以下に記載しておきます。

require 'complex'

class News
  GENRE = { sports: 1, entertainment: 2, politics: 3, medical: 4, incident: 5 }

  def initialize(id:, title:, genre:)
    @id = id
    @title = title
    @genre = genre
    @liked_user_ids = []
  end

  attr_reader :id, :title, :liked_user_ids

  def like(user_id:)
    @liked_user_ids << user_id
  end
end

class Distance
  include Comparable

  def initialize(from:, to:)
    @from = from
    @to = to
  end

  attr_reader :from, :to

  def value
    @value ||= euclidean_distance(from: from, to: to)
  end

  def <=>(other)
    value - other.value
  end

  private

  def euclidean_distance(from:, to:)
    sum = from.each.with_index.inject(0) { | sum, (v, i) | sum += (v - to[i])**2 }
    Math.sqrt(sum)
  end
end

class User
  def initialize(id:, name:, sex:, age:, favorite_genre: )
    @id = id
    @name = name
    @sex = sex
    @age = age
    @favorite_genre = News::GENRE[favorite_genre].to_i
  end

  attr_reader :id, :name, :sex, :age, :favorite_genre

  def distance(to:)
    Distance.new(from: coordinates, to: to.coordinates)
  end

  def coordinates
    [sex.to_i, (age / 10).to_i, (favorite_genre * 1.2)]
  end
end

# Seed

users = [
  User.new(id: 1, name: "Tom", sex: 1, age: 20, favorite_genre: :sports),
  User.new(id: 2, name: "Jesica", sex: 2, age: 15, favorite_genre: :entertainment),
  User.new(id: 3, name: "Bob", sex: 1, age: 30, favorite_genre: :medical),
  User.new(id: 4, name: "Terry", sex: 1, age: 40, favorite_genre: :sports),
  User.new(id: 5, name: "Ami", sex: 2, age: 25, favorite_genre: :incident)
]

news = [
  News.new(id: 1, title: "念願の優勝!", genre: :sports),
  News.new(id: 2, title: "あの人がCMに?", genre: :entertainment),
  News.new(id: 3, title: "ついに逮捕!?", genre: :incident),
  News.new(id: 4, title: "期待の新薬誕生か?", genre: :medical),
  News.new(id: 5, title: "ついにあの条約が結ばれる", genre: :politics),
]

news[0].like(user_id: 1)
news[1].like(user_id: 2)
news[1].like(user_id: 3)
news[2].like(user_id: 1)
news[2].like(user_id: 5)
news[3].like(user_id: 4)


# Reccomend
target = users[0]
other_users = users.select { |user| user != target }
distances = other_users.map { |user| { id: user.id, distance: target.distance(to: user) } }
source_user_ids = distances.sort_by {|v| v[:distance] }.first(2).map { |v| v[:id] }
recommends = news.select { |news| ids.any? { |id| news.liked_user_ids.include?(id) } }
pp recommends

おわりに

対象の特徴をグラフの座標に変換して距離で類似度を測定するというのは色々なところで使えそうですね👀

プログラミングと数学、現在の自分の業務ではそこまで利用することも多くなかったのですが、こういうレコメンドとか計算量を気にするような実装等を行うときは、数学的な知識があると良いですね・・・!

参考

qiita.com

www.xmisao.com

rubytips86.hatenablog.com