A recommendation engine using Likes and Dislikes for your Ruby/Redis application.
Recommendable is a gem that allows you to quickly add a recommendation engine for Likes and Dislikes to your Ruby application using my version of Jaccardian similarity and memory-based collaborative filtering.
Bundling one of the queueing systems above is highly recommended to avoid having to manually refresh users' recommendations. If running on Rails 4, the built-in queueing system is supported. If you bundle Sidekiq, Resque, or DelayedJob, Recommendable will use your bundled queueing system instead. If bundling Sidekiq, you should also include 'sidekiq-middleware' in your Gemfile to ensure that a user will not get enqueued more than once at a time. If bundling Resque, you should include 'resque-loner' for this. As far as I know, there is no current way to avoid duplicate jobs in DelayedJob.
Add the following to your application's Gemfile
:
gem 'recommendable'
After bundling, you should configure Recommendable. Do this somewhere after you've required it, but before it's actually used. For example, Rails users would create an initializer (config/initializers/recommendable.rb
):
require 'redis'
Recommendable.configure do |config|
# Recommendable's connection to Redis
config.redis = Redis.new(:host => 'localhost', :port => 6379, :db => 0)
# A prefix for all keys Recommendable uses
config.redis_namespace = :recommendable
# Whether or not to automatically enqueue users to have their recommendations
# refreshed after they like/dislike an item
config.auto_enqueue = true
# The name of the queue that background jobs will be placed in
config.queue_name = :recommendable
# The number of nearest neighbors (k-NN) to check when updating
# recommendations for a user. Set to `nil` if you want to check all
# other users as opposed to a subset of the nearest ones.
config.nearest_neighbors = nil
end
The values listed above are the defaults. I recommend playing around with the nearest_neighbors
setting. A higher value will provide more accurate recommendations at the cost of more time spent generating them. Find your balance.
In your ONE model that will be receiving recommendations:
class User
recommends :movies, :books, :minerals, :other_things
# ...
end
Keep in mind that, aside from similar_raters
, all of the dynamically defined finders return a Relation/Criteria. This means that, assuming your ORM's queries are chainable, you can keep that chain going.
>> current_user.liked_movies.limit(10)
>> current_user.bookmarked_books.where(:author => "Cormac McCarthy")
>> current_user.disliked_movies.joins(:cast_members).where('cast_members.name = Kim Kardashian')
>> current_user.hidden_minerals.order('density DESC')
>> current_user.recommended_movies.where('year < 2010')
>> book.liked_by.order('age DESC').limit(20)
>> movie.disliked_by.where('age > 18')
Your users should now be able to like
your recommendable objects:
>> user.like(movie)
=> true
>> user.likes?(movie)
=> true
>> user.rated?(movie)
=> true # also true if user.dislikes?(movie)
>> user.liked_movies
=> [#<Movie id: 23, name: "2001: A Space Odyssey">]
>> user.liked_movie_ids
=> ["23"]
>> user.like(book)
=> true
>> user.likes
=> [#<Movie id: 23, name: "2001: A Space Odyssey">, #<Book id: 42, title: "100 Years of Solitude">]
>> user.likes_count
=> 2
>> user.liked_movies_count
=> 1
>> user.likes_in_common_with(friend)
=> [#<Movie id: 23, name: "2001: A Space Odyssey">, #<Book id: 42, title: "100 Years of Solitude">]
>> user.liked_movies_in_common_with(friend)
=> [#<Movie id: 23, name: "2001: A Space Odyssey">]
>> movie.liked_by_count
=> 2
>> movie.liked_by
=> [#<User username: 'davidbowman'>, #<User username: 'frankpoole'>]
Identical to Liking. Just replace the verb.
>> user.dislike(movie)
>> user.dislikes?(movie)
>> user.disliked_movies
>> user.disliked_movie_ids
>> user.dislikes
>> user.dislikes_count
>> user.disliked_movies_count
>> user.dislikes_in_common_with(friend)
>> user.disliked_movies_in_common_with(friend)
>> movie.disliked_by_count
>> movie.disliked_by
This is a system for users to keep track of items in a "save for later" kind of way. Users can bookmark any item that they have not hidden, including items they've liked or disliked. Bookmarked items will be removed from a user's recommendations.
>> user.bookmark(movie)
>> user.bookmarks?(movie)
>> user.rated?(movie) # false unless user liked/disliked movie
>> user.bookmarked_movies
>> user.bookmarked_movie_ids
>> user.bookmarks
>> user.bookmarks_count
>> user.bookmarked_movies_count
>> user.bookmarks_in_common_with(friend
>> user.bookmarked_movies_in_common_with(friend)
Aside from abstaining to rate, this is the closest Recommendable will ever get to a "neutral" voting option. Hiding items provides you with a way to let users tell you what they couldn't care less about.
>> user.hide(movie)
>> user.hides?(movie)
>> user.rated?(movie) # false
>> user.hidden_movies
>> user.hidden_movie_ids
>> user.hiding
>> user.hidden_count
>> user.hidden_movies_count
>> user.hiding_in_common_with(friend)
>> user.hidden_movies_in_common_with(friend)
Use this as you will, but hidden items can not be liked, disliked, or bookmarked. They are removed from recommendations and completely ignored by Recommendable unless removed from a user's set of hidden items. Speaking of which...
Each of the actions above has an opposite "un" action to remove items from a list:
>> user.unlike(movie)
=> true
>> user.undislike(book)
=> true
>> user.unbookmark(mineral)
=> true
>> user.unhide(other_thing)
=> true
And here we are. Assuming your queues are being processed, users should begin receiving recommendations as soon as they have rated items that other users have also rated.
>> friend.like(Movie.where(:name => "2001: A Space Odyssey").first)
>> friend.like(Book.where(:title => "A Clockwork Orange").first)
>> friend.like(Book.where(:title => "Brave New World").first)
>> friend.like(Book.where(:title => "One Flew Over the Cuckoo's Next").first)
>> user.like(Book.where(:title => "A Clockwork Orange").first)
=> [#<User username: "frankpoole">, #<User username: "davidbowman">, ...]
>> user.recommended_books # Defaults to 10 recommendations
=> [#<Book title: "Brave New World">, #<Book title: "One Flew Over the Cuckoo's Nest">]
>> user.similar_raters # Defaults to 10 similar users
=> [#<
>> user.recommended_movies(10, 30) # 10 Recommendations, offset by 30 (i.e. page 4)
=> [#<Movie name: "A Clockwork Orange">, #<Movie name: "Chinatown">, ...]
>> user.similar_raters(25, 50) # 25 similar users, offset by 50 (i.e. page 3)
=> [#<User username: "frankpoole">, #<User username: "davidbowman">, ...]
Recommendable does some maths in the background to calculate which of your Recommended objects, based on likes and dislikes, are the best of the best. This algorithm is similar to the algorithm Reddit uses to sort the "best" comments. Using the .top
query method on your Recommendable models will return the coolest stuff you have to offer sorted by awesomeness:
>> Movie.top
=> #<Movie name: "2001: A Space Odyssey">
>> Movie.top(3)
=> [#<Movie name: "2001: A Space Odyssey">, #<Movie name: "A Clockwork Orange">, #<Movie name: "The Shining">]
Recommendable uses apotonick/hooks to implement before/after callbacks for liking, disliking, and more.
class User < ActiveRecord::Base
has_one :feed
recommends :movies
after_like :update_feed
def update_feed(obj)
feed.update "liked #{obj.name}"
end
end
You can define before/after hooks for (un)like
, (un)dislike
, (un)bookmark
, and (un)hide
. Each hook takes the recommendable object as an argument and can be a block or a method name.
If you don't wish to bundle a queueing system but, instead, want to manually update similarity values and recommendations, you can do so via the Calculations
helper module. For instance:
Recommendable::Helpers::Calculations.update_similarities_for(user.id)
Recommendable::Helpers::Calculations.update_recommendations_for(user.id)
It's always recommended that you update the similarities first; otherwise, the recommendations may not change.
Recommendable requires Redis to deliver recommendations. The collaborative filtering logic is based almost entirely on set math, and Redis is blazing fast for this. NOTE: Your redis database MUST be persistent.
For Mac OS X users, homebrew is by far the easiest way to install Redis. Make sure to read the caveats after installation!
$ brew install redis
For Linux users, there is a package on apt-get.
$ sudo apt-get install redis-server
$ redis-server
Redis will now be running on localhost:6379. After a second, you can hit ctrl-\
to detach and keep Redis running in the background.
I'll let Randall Munroe of XKCD take this one for me:
Once you've made your great commits:
git clone git://github.com/davidcelis/recommendable.git
Copyright © 2012 David Celis. See LICENSE.txt for further details.