Tutorial: Easy Rails recommendations with acts_as_recommendable

Following up on Alex MacCaw’s post on collaborative filtering. The plugin we recently released acts_as_recommendable allows Rails developers to quickly add some user-driven recommendations of items to their latest great millionaire-making startup. At Made By Many we’ve been developing some great niche social-media Ruby On Rails sites recently with New Bamboo and Headshift. The new edge of social media is in the maths, commenting and rating is so old-school, it’s what you do with that data that counts.

This is going to be a tutorial for simple integration of acts_as_recommendable to recommend your users some books.

Lets create the Rails application and install a few essential plugins..


rails bookstore
cd bookstore
./script/plugin install http://ennerchi.googlecode.com/svn/trunk/plugins/jrails
./script/plugin install git://github.com/technoweenie/restful-authentication.git
./script/plugin install git://github.com/maccman/acts_as_recommendable.git

We are going to use a bit of jquery later, hence the use of jrails, and we are going to need to have users hence restful-authentication.

We need to enable restful-authentication and set up our books scaffold

./script/generate authenticated user sessions
./script/generate scaffold book title:string
./script/generate model user_book book_id:integer user_id:integer
rake db:migrate

Now our simple bookstore will use the UserBook model as the join between users and books which act_as_recommendable will use to find book recommendations for our users based on what they and others have bought.

class Book < ActiveRecord::Base
 has_many :user_books
 has_many :users, :through => :user_books
 def bought_by_user?(user)
  rtn = false
  rtn = user.books.include?(self) if user
 end
end

class UserBook < ActiveRecord::Base
 belongs_to :book
 belongs_to :user
end


class User < ActiveRecord::Base
 has_many :user_books
 has_many :books, :through => :user_books
 acts_as_recommendable :books, :through => :user_books
 def buy_book(book)
  books << book
  self.save
 end

Thats it. Well we could do with a bit of an interface. Lets add an AJAX buy to the controller and a way to display the recommendations.

class BooksController < ApplicationController
 def buy
  @book = Book.find(params[:id])
  self.current_user.buy_book(@book) unless self.current_user.nil?
  respond_to do |format|
   format.js {render :partial => “bought”, :locals => {:book =>@book} }
   format.xml { head :ok }
  end
 end
 def recommended
  unless self.current_user.nil?
   @books = self.current_user.recommended_books
   respond_to do |format|
    format.html # recommended.html.erb
    format.xml { render :xml => @books }
   end
  end
 end

The we can just add our bought partial _bought.html.erb

<div id="book_<%=book.id%>"><em>you bought this</em></div>

and _buyit.html.erb

<div id="book_<%=book.id%>"><em><%=link_to_remote 'Buy This', :update => "book_#{book.id}", :url => buy_book_path(book)%></em></div>

And our recommended view

<h1>Recommended Books For You</h1>
<table border="0">
<tbody>
<tr>
<th>Title</th>
</tr>
<% for book in @books %>
<tr>
<td><%=h book.title %></td>
</tr>
<% end %>

</tbody></table>
<%= link_to 'View all books', books_path %>

Change index.html.erb

<h1>Listing books</h1>
<%=link_to('recommended for you', recommended_books_path()) unless self.current_user.nil?%>
<table border="0">
<tbody>
<tr>
<th>Title</th>
</tr>
<% for book in @books %>
<tr>
<td><%=h book.title %></td>
<td><%if book.bought_by_user?(self.current_user)%>
<%= render :partial => "bought", :locals => {:book => book} %>
<%else%>
<%= render :partial => "buyit", :locals => {:book => book} %>
<%end%></td>
</tr>
<% end %>

</tbody></table>

Change the routes

map.resources :books, :member => {:buy => :post}, :collection => {:recommended => :get}

And we are good to go. Start the server and go to http://localhost:3000/signup and create 3 or 4 users. Now create 15 or so some books and have those users buy a few. After you have a small dataset of conflicting purchases you will be able to go to recommendations page and get your users some books.

Of course there is more. What if you wanted to recommend items based on a rating the user has given rather than just a direct link. Well by adding a rating attribute to the join table acts_as_recommendable can do that.

acts_as_recommendable :books, :through => :user_books, :score => :rating

Using this method you can make the join table precalculate the quantity of the relationship between the user and the item based on many factors, such as the rating, buying and wishlists.

But what about performance? Well that’s the big problem. At the moment this acts_as_recommendable setup is doing user-based recommendations which require loading all the data before running it through the algorithm. As the dataset increases this will slow down hugely. So acts_as_recommendable lets you move to item-based recommendations which uses a cached similarity matrix between items, then at runtime applies a user’s preferences to it.

acts_as_recommendable :books, :through => :user_books, :score => :rating, :use_dataset => true

This dataset is stored in the RailsCache and can you can then use a batch rake task to update the similarity matrix offline on a regular basis.

acts_as_recommendable is still in alpha but we are hoping we can use it in a few gigs and see how it works for a production site. As a postscript, Laurie at New Bamboo says ActsAs is old school, so we are thinking about renaming it recommend_me. What do you think?