From 92e37c0c1e93c8dce76fd847b219586d12d5cabb Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 2 Feb 2015 00:06:14 +0700 Subject: [PATCH 1/2] Extracted similarity calculation logic into a separate class --- lib/recommendable/helpers/calculations.rb | 39 +--------- .../helpers/calculations/similarity.rb | 72 +++++++++++++++++++ 2 files changed, 75 insertions(+), 36 deletions(-) create mode 100644 lib/recommendable/helpers/calculations/similarity.rb diff --git a/lib/recommendable/helpers/calculations.rb b/lib/recommendable/helpers/calculations.rb index 60105f2..fd3bf77 100644 --- a/lib/recommendable/helpers/calculations.rb +++ b/lib/recommendable/helpers/calculations.rb @@ -1,3 +1,5 @@ +require_relative 'calculations/similarity' + module Recommendable module Helpers module Calculations @@ -12,42 +14,7 @@ class << self # @return [Float] the numeric similarity between this user and the passed user # @note Similarity values are asymmetrical. `Calculations.similarity_between(user_id, other_user_id)` will not necessarily equal `Calculations.similarity_between(other_user_id, user_id)` def similarity_between(user_id, other_user_id) - user_id = user_id.to_s - other_user_id = other_user_id.to_s - - similarity = liked_count = disliked_count = 0 - in_common = Recommendable.config.ratable_classes.each do |klass| - liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id) - other_liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, other_user_id) - disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id) - other_disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, other_user_id) - - results = Recommendable.redis.pipelined do - # Agreements - Recommendable.redis.sinter(liked_set, other_liked_set) - Recommendable.redis.sinter(disliked_set, other_disliked_set) - - # Disagreements - Recommendable.redis.sinter(liked_set, other_disliked_set) - Recommendable.redis.sinter(disliked_set, other_liked_set) - - Recommendable.redis.scard(liked_set) - Recommendable.redis.scard(disliked_set) - end - - # Agreements - similarity += results[0].size - similarity += results[1].size - - # Disagreements - similarity -= results[2].size - similarity -= results[3].size - - liked_count += results[4] - disliked_count += results[5] - end - - similarity / (liked_count + disliked_count).to_f + Similarity.new(user_id, other_user_id).calculate end # Used internally to update the similarity values between this user and all diff --git a/lib/recommendable/helpers/calculations/similarity.rb b/lib/recommendable/helpers/calculations/similarity.rb new file mode 100644 index 0000000..a435e7a --- /dev/null +++ b/lib/recommendable/helpers/calculations/similarity.rb @@ -0,0 +1,72 @@ +module Recommendable + module Helpers + module Calculations + class Similarity + attr_reader :user_id, :other_user_id + + def initialize(user_id, other_user_id) + @user_id = user_id.to_s + @other_user_id = other_user_id.to_s + @similarity = 0 + @liked_count = 0 + @disliked_count = 0 + end + + def calculate + Recommendable.config.ratable_classes.each do |klass| + sets = liked_and_disliked_sets(klass) + results = agreements_and_disagreements(*sets) + count_agreements_and_disagreements(results) + count_liked_and_disliked(results) + end + + @similarity /= (@liked_count + @disliked_count).to_f + end + + private + + def agreements_and_disagreements(liked_set, other_liked_set, disliked_set, other_disliked_set) + Recommendable.redis.pipelined do + # Agreements + Recommendable.redis.sinter(liked_set, other_liked_set) + Recommendable.redis.sinter(disliked_set, other_disliked_set) + + # Disagreements + Recommendable.redis.sinter(liked_set, other_disliked_set) + Recommendable.redis.sinter(disliked_set, other_liked_set) + + Recommendable.redis.scard(liked_set) + Recommendable.redis.scard(disliked_set) + end + end + + def count_agreements_and_disagreements(results) + add_agreements(results) + subtract_disagreements(results) + end + + def add_agreements(results) + @similarity += results[0].size + results[1].size + end + + def subtract_disagreements(results) + @similarity -= results[2].size + results[3].size + end + + def count_liked_and_disliked(results) + @liked_count += results[4] + @disliked_count += results[5] + end + + def liked_and_disliked_sets(klass) + liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, user_id) + other_liked_set = Recommendable::Helpers::RedisKeyMapper.liked_set_for(klass, other_user_id) + disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, user_id) + other_disliked_set = Recommendable::Helpers::RedisKeyMapper.disliked_set_for(klass, other_user_id) + + return [liked_set, other_liked_set, disliked_set, other_disliked_set] + end + end + end + end +end From ff74ac2a79b6c32c63d16b4828d651b1f162800b Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 2 Feb 2015 02:55:14 +0700 Subject: [PATCH 2/2] Test for Calculations::Similarity --- .../similarity_calculation_test.rb | 59 +++++++++++++++++++ .../helpers/calculations_test.rb | 24 -------- 2 files changed, 59 insertions(+), 24 deletions(-) create mode 100644 test/recommendable/helpers/calculations/similarity_calculation_test.rb diff --git a/test/recommendable/helpers/calculations/similarity_calculation_test.rb b/test/recommendable/helpers/calculations/similarity_calculation_test.rb new file mode 100644 index 0000000..92b6cee --- /dev/null +++ b/test/recommendable/helpers/calculations/similarity_calculation_test.rb @@ -0,0 +1,59 @@ +$LOAD_PATH.unshift File.expand_path('../../test', __FILE__) +require 'test_helper' + +class SimilarityCalculationTest < MiniTest::Unit::TestCase + def setup + @user = Factory(:user) + 5.times { |x| instance_variable_set(:"@user#{x+1}", Factory(:user)) } + 5.times { |x| instance_variable_set(:"@movie#{x+1}", Factory(:movie)) } + 5.upto(9) { |x| instance_variable_set(:"@movie#{x+1}", Factory(:documentary)) } + 10.times { |x| instance_variable_set(:"@book#{x+1}", Factory(:book)) } + + like(@user, [@movie1, @movie2, @movie3, @book4, @book5, @book6]) + dislike(@user, [@book1, @book2, @book3, @movie4, @movie5, @movie6]) + + # @user.similarity_with(@user1) should == 1.0 + like(@user1, [@movie1, @movie2, @movie3, @book4, @book5, @book6, @book7, @book8, @movie9, @movie10]) + dislike(@user1, [@book1, @book2, @book3, @movie4, @movie5, @movie6, @movie7, @movie8, @book9, @book10]) + + # @user.similarity_with(@user2) should == 0.25 + like(@user2, [@movie1, @movie2, @movie3, @book4, @book5, @book6]) + like(@user2, [@book1, @book2, @book3]) + + # @user.similarity_with(@user3) should == 0.0 + like(@user3, [@movie1, @movie2, @movie3]) + like(@user3, [@book1, @book2, @book3]) + + # @user.similarity_with(@user4) should == -0.25 + like(@user4, [@movie1, @movie2, @movie3]) + like(@user4, [@book1, @book2, @book3, @movie4, @movie5, @movie6]) + + # @user.similarity_with(@user5) should == -1.0 + dislike(@user5, [@movie1, @movie2, @movie3, @book4, @book5, @book6]) + like(@user5, [@book1, @book2, @book3, @movie4, @movie5, @movie6]) + end + + def test_similarity_between_calculates_correctly + assert_equal similarity(@user.id, @user1.id), 1.0 + assert_equal similarity(@user.id, @user2.id), 0.25 + assert_equal similarity(@user.id, @user3.id), 0 + assert_equal similarity(@user.id, @user4.id), -0.25 + assert_equal similarity(@user.id, @user5.id), -1.0 + end + + def teardown + Recommendable.redis.flushdb + end + + def similarity(user_id, other_user_id) + Recommendable::Helpers::Calculations::Similarity.new(user_id, other_user_id).calculate + end + + def like(user, collection) + collection.each { |obj| user.like(obj) } + end + + def dislike(user, collection) + collection.each { |obj| user.dislike(obj) } + end +end diff --git a/test/recommendable/helpers/calculations_test.rb b/test/recommendable/helpers/calculations_test.rb index 584a1b8..1bd9d3d 100644 --- a/test/recommendable/helpers/calculations_test.rb +++ b/test/recommendable/helpers/calculations_test.rb @@ -15,30 +15,6 @@ def setup # @user.similarity_with(@user1) should == 1.0 [@movie1, @movie2, @movie3, @book4, @book5, @book6, @book7, @book8, @movie9, @movie10].each { |obj| @user1.like(obj) } [@book1, @book2, @book3, @movie4, @movie5, @movie6, @movie7, @movie8, @book9, @book10].each { |obj| @user1.dislike(obj) } - - # @user.similarity_with(@user2) should == 0.25 - [@movie1, @movie2, @movie3, @book4, @book5, @book6].each { |obj| @user2.like(obj) } - [@book1, @book2, @book3].each { |obj| @user2.like(obj) } - - # @user.similarity_with(@user3) should == 0.0 - [@movie1, @movie2, @movie3].each { |obj| @user3.like(obj) } - [@book1, @book2, @book3].each { |obj| @user3.like(obj) } - - # @user.similarity_with(@user4) should == -0.25 - [@movie1, @movie2, @movie3].each { |obj| @user4.like(obj) } - [@book1, @book2, @book3, @movie4, @movie5, @movie6].each { |obj| @user4.like(obj) } - - # @user.similarity_with(@user5) should == -1.0 - [@movie1, @movie2, @movie3, @book4, @book5, @book6].each { |obj| @user5.dislike(obj) } - [@book1, @book2, @book3, @movie4, @movie5, @movie6].each { |obj| @user5.like(obj) } - end - - def test_similarity_between_calculates_correctly - assert_equal Recommendable::Helpers::Calculations.similarity_between(@user.id, @user1.id), 1.0 - assert_equal Recommendable::Helpers::Calculations.similarity_between(@user.id, @user2.id), 0.25 - assert_equal Recommendable::Helpers::Calculations.similarity_between(@user.id, @user3.id), 0 - assert_equal Recommendable::Helpers::Calculations.similarity_between(@user.id, @user4.id), -0.25 - assert_equal Recommendable::Helpers::Calculations.similarity_between(@user.id, @user5.id), -1.0 end def test_update_recommendations_ignores_rated_items