最近以下の本を読んで、なんかしらのデータの特徴をグラフにしてピタゴラスの定理を使って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 } }
ユーザーの座標を元に類似度を算出する
この値をもとにピタゴラスの定理を用いてレコメンドしたいユーザーの座標から、それぞれのユーザーの座標への距離を算出することで、ユーザーの類似度を算出することが出来ます。
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
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 } }
target = users[0]
users.select { |user| user != target }.map { |user| { id: user.id, distance: target.distance(to: user).value } }
この結果から対象のユーザーと類似度が高い上位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] }
recommends = news.select do |news|
ids.any? { |id| news.liked_user_ids.include?(id) }
end
最終的なコード
最終的なコードを以下に記載しておきます。
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
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)
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