最近以下の本を読んで、なんかしらのデータの特徴をグラフにしてピタゴラスの定理を使って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")
ピタゴラスの定理の詳細はこちら
類似度の高いユーザーが「いいね」した記事を取得する
まず元データが無いとレコメンドすることも出来ないので、以下のような記事があり、それぞれ引数で渡したユーザーによって「いいね」されている状況だとします。
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
おわりに
対象の特徴をグラフの座標に変換して距離で類似度を測定するというのは色々なところで使えそうですね👀
プログラミングと数学、現在の自分の業務ではそこまで利用することも多くなかったのですが、こういうレコメンドとか計算量を気にするような実装等を行うときは、数学的な知識があると良いですね・・・!