Recommendable

A recommendation engine using Likes and Dislikes for your Ruby/Redis application.

View the Project on GitHub davidcelis/recommendable

Recommendable

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.

Requirements

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.

Installation

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.

Usage

In your ONE model that will be receiving recommendations:

class User
  recommends :movies, :books, :minerals, :other_things

  # ...
end

A note on the dynamic finders

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')

Liking

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'>]

Disliking

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

Bookmarking

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)

Hiding

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...

Removing from sets

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

Recommendations

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">, ...]

The "best" of your Recommendable models

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">]

Callbacks

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.

Manually generating Recommendations

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.

Installing Redis

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.

Mac OS X

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

Linux

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.

Why not stars?

I'll let Randall Munroe of XKCD take this one for me:

I got lost and wandered into the world's creepiest cemetery, where the headstones just had names and star ratings. Freaked me out. When I got home I tried to leave the cemetery a bad review on Yelp, but as my hand hovered over the 'one star' button I felt this distant chill ...

Contributing to recommendable

Once you've made your great commits:

  1. Fork recommendable
  2. Create a feature branch
  3. Write your code (and tests please)
  4. Push to your branch's origin
  5. Create a Pull Request from your branch
  6. That's it!

Links

Copyright

Copyright © 2012 David Celis. See LICENSE.txt for further details.