├── .circleci └── config.yml ├── .document ├── .gitignore ├── Gemfile ├── LICENSE.txt ├── README.rdoc ├── Rakefile ├── VERSION ├── coletivo.gemspec ├── lib ├── coletivo.rb ├── coletivo │ ├── models │ │ ├── person.rb │ │ ├── person_rating.rb │ │ └── recommendable.rb │ ├── rails │ │ ├── active_record.rb │ │ └── engine.rb │ └── similarity │ │ ├── base_strategy.rb │ │ ├── engine.rb │ │ ├── euclidean_distance_strategy.rb │ │ └── pearson_correlation_strategy.rb └── generators │ └── coletivo │ ├── coletivo_generator.rb │ └── templates │ └── person_ratings_migration.rb └── test ├── coletivo_test.rb ├── db ├── schema.rb └── test.db ├── helper.rb ├── models ├── person_rating_test.rb ├── person_test.rb └── recommendable_test.rb └── models_helper.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Ruby CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-ruby/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: circleci/ruby:2.4.1-node-browsers 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/postgres:9.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "Gemfile.lock" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: 30 | name: install dependencies 31 | command: | 32 | bundle install --jobs=4 --retry=3 --path vendor/bundle 33 | 34 | - save_cache: 35 | paths: 36 | - ./vendor/bundle 37 | key: v1-dependencies-{{ checksum "Gemfile.lock" }} 38 | 39 | # Database setup 40 | - run: bundle exec rake db:create 41 | - run: bundle exec rake db:schema:load 42 | 43 | # run tests! 44 | - run: 45 | name: run tests 46 | command: | 47 | mkdir /tmp/test-results 48 | TEST_FILES="$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)" 49 | 50 | bundle exec rspec --format progress \ 51 | --format RspecJunitFormatter \ 52 | --out /tmp/test-results/rspec.xml \ 53 | --format progress \ 54 | $TEST_FILES 55 | 56 | # collect reports 57 | - store_test_results: 58 | path: /tmp/test-results 59 | - store_artifacts: 60 | path: /tmp/test-results 61 | destination: test-results 62 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | 3 | # rdoc generated 4 | rdoc 5 | 6 | # yard generated 7 | doc 8 | .yardoc 9 | 10 | # bundler 11 | .bundle 12 | 13 | # jeweler generated 14 | pkg 15 | 16 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 17 | # 18 | # * Create a file at ~/.gitignore 19 | # * Include files you want ignored 20 | # * Run: git config --global core.excludesfile ~/.gitignore 21 | # 22 | # After doing this, these files will be ignored in all your git projects, 23 | # saving you from having to 'pollute' every project you touch with them 24 | # 25 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 26 | # 27 | # For MacOS: 28 | # 29 | #.DS_Store 30 | 31 | # For TextMate 32 | #*.tmproj 33 | #tmtags 34 | 35 | # For emacs: 36 | #*~ 37 | #\#* 38 | #.\#* 39 | 40 | # For vim: 41 | #*.swp 42 | 43 | # For redcar: 44 | #.redcar 45 | 46 | # For rubinius: 47 | #*.rbc 48 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gem "rails", ">= 3.0.7" 4 | 5 | group :development do 6 | gem "shoulda", "~> 3.0.0.beta2" 7 | gem "bundler", "~> 1.15.4" 8 | gem "jeweler", "~> 1.6.2" 9 | gem "turn" 10 | gem "sqlite3" 11 | gem "rdoc" 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Diógenes Falcão 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = coletivo 2 | 3 | A simple Rails 3 recommendations engine. 4 | Coletivo uses {Euclidean Distance}[http://en.wikipedia.org/wiki/Euclidean_distance] or {Pearson's Correlation Coefficient}[http://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient] to calculate the similarity between persons and their preferences. 5 | 6 | == Installation: 7 | 8 | sudo gem install coletivo 9 | rails g coletivo 10 | rake db:migrate 11 | 12 | == Usage: 13 | 14 | At your Rails model that represents a person (can be an _User_, _Member_, or something like that): 15 | 16 | class User < ActiveRecord::Base 17 | has_own_preferences 18 | 19 | # ... 20 | end 21 | 22 | So, a person can rate things: 23 | 24 | current_user = User.create(:name => 'Diogenes') 25 | movie = Movie.create(:name => 'The Tourist', :year => 2010) 26 | 27 | current_user.rate!(movie, 4.5) 28 | 29 | And after a lot of ratings... *recommendations*: 30 | 31 | Movie.find_recommendations_for(current_user) # => movies and more movies... 32 | 33 | By default, the similarity strategy used is Euclidean Distance, but you can change that passing the _strategy_ option: 34 | Movie.find_recommendations_for(current_user, :strategy => :pearson) 35 | 36 | 37 | == Contributing to coletivo 38 | 39 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet 40 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it 41 | * Fork the project 42 | * Start a feature/bugfix branch 43 | * Commit and push until you are happy with your contribution 44 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 45 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 46 | 47 | == Copyright 48 | 49 | Copyright (c) 2011 Diógenes Falcão. See LICENSE.txt for 50 | further details. 51 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'jeweler' 15 | Jeweler::Tasks.new do |gem| 16 | # gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options 17 | gem.name = "coletivo" 18 | gem.homepage = "http://github.com/diogenes/coletivo" 19 | gem.license = "MIT" 20 | gem.summary = %Q{A simple Rails 3 recommendations engine} 21 | gem.description = %Q{A simple Rails 3 recommendations engine} 22 | gem.email = "diogenes.araujo@gmail.com" 23 | gem.authors = ["Diógenes Falcão"] 24 | gem.files = Dir["{lib}/**/*"] 25 | # dependencies defined in Gemfile 26 | end 27 | Jeweler::RubygemsDotOrgTasks.new 28 | 29 | require 'rake/testtask' 30 | Rake::TestTask.new(:test) do |test| 31 | test.libs << 'lib' << 'test' 32 | test.pattern = 'test/**/*_test.rb' 33 | test.verbose = true 34 | end 35 | 36 | task :default => :test 37 | 38 | require 'rake/rdoctask' 39 | Rake::RDocTask.new do |rdoc| 40 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 41 | 42 | rdoc.rdoc_dir = 'rdoc' 43 | rdoc.title = "coletivo #{version}" 44 | rdoc.rdoc_files.include('README*') 45 | rdoc.rdoc_files.include('lib/**/*.rb') 46 | end 47 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.0.3 -------------------------------------------------------------------------------- /coletivo.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | 6 | Gem::Specification.new do |s| 7 | s.name = %q{coletivo} 8 | s.version = "0.0.3" 9 | 10 | s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version= 11 | s.authors = ["Di\303\263genes Falc\303\243o"] 12 | s.date = %q{2011-10-31} 13 | s.description = %q{A simple Rails 3 recommendations engine} 14 | s.email = %q{diogenes.araujo@gmail.com} 15 | s.extra_rdoc_files = [ 16 | "LICENSE.txt", 17 | "README.rdoc" 18 | ] 19 | s.files = [ 20 | "lib/coletivo.rb", 21 | "lib/coletivo/models/person.rb", 22 | "lib/coletivo/models/person_rating.rb", 23 | "lib/coletivo/models/recommendable.rb", 24 | "lib/coletivo/rails/active_record.rb", 25 | "lib/coletivo/rails/engine.rb", 26 | "lib/coletivo/similarity/base_strategy.rb", 27 | "lib/coletivo/similarity/engine.rb", 28 | "lib/coletivo/similarity/euclidean_distance_strategy.rb", 29 | "lib/coletivo/similarity/pearson_correlation_strategy.rb", 30 | "lib/generators/coletivo/coletivo_generator.rb", 31 | "lib/generators/coletivo/templates/person_ratings_migration.rb" 32 | ] 33 | s.homepage = %q{http://github.com/diogenes/coletivo} 34 | s.licenses = ["MIT"] 35 | s.require_paths = ["lib"] 36 | s.rubygems_version = %q{1.3.7} 37 | s.summary = %q{A simple Rails 3 recommendations engine} 38 | 39 | if s.respond_to? :specification_version then 40 | current_version = Gem::Specification::CURRENT_SPECIFICATION_VERSION 41 | s.specification_version = 3 42 | 43 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 44 | s.add_runtime_dependency(%q, [">= 3.0.7"]) 45 | s.add_development_dependency(%q, ["~> 3.0.0.beta2"]) 46 | s.add_development_dependency(%q, ["~> 1.0.14"]) 47 | s.add_development_dependency(%q, ["~> 1.6.2"]) 48 | s.add_development_dependency(%q, [">= 0"]) 49 | s.add_development_dependency(%q, [">= 0"]) 50 | else 51 | s.add_dependency(%q, [">= 3.0.7"]) 52 | s.add_dependency(%q, ["~> 3.0.0.beta2"]) 53 | s.add_dependency(%q, ["~> 1.0.14"]) 54 | s.add_dependency(%q, ["~> 1.6.2"]) 55 | s.add_dependency(%q, [">= 0"]) 56 | s.add_dependency(%q, [">= 0"]) 57 | end 58 | else 59 | s.add_dependency(%q, [">= 3.0.7"]) 60 | s.add_dependency(%q, ["~> 3.0.0.beta2"]) 61 | s.add_dependency(%q, ["~> 1.0.14"]) 62 | s.add_dependency(%q, ["~> 1.6.2"]) 63 | s.add_dependency(%q, [">= 0"]) 64 | s.add_dependency(%q, [">= 0"]) 65 | end 66 | end 67 | 68 | -------------------------------------------------------------------------------- /lib/coletivo.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | require 'active_model' 3 | require 'active_record' 4 | require 'active_support' 5 | 6 | module Coletivo 7 | module Models 8 | autoload :Recommendable, 'coletivo/models/recommendable' 9 | autoload :Person, 'coletivo/models/person' 10 | autoload :PersonRating, 'coletivo/models/person_rating' 11 | end 12 | 13 | module Similarity 14 | NO_SIMILARITY = -1.0..0.49 15 | SIMILAR = 0.5..0.99 16 | IDENTICAL = 1.0 17 | 18 | autoload :BaseStrategy, 'coletivo/similarity/base_strategy' 19 | autoload :EuclideanDistanceStrategy, 'coletivo/similarity/euclidean_distance_strategy' 20 | autoload :PearsonCorrelationStrategy, 'coletivo/similarity/pearson_correlation_strategy' 21 | autoload :Engine, 'coletivo/similarity/engine' 22 | end 23 | 24 | module Config 25 | mattr_accessor :ratings_container 26 | 27 | # Defaults 28 | self.ratings_container = Coletivo::Models::PersonRating 29 | end 30 | 31 | if defined?(Rails) 32 | require 'coletivo/rails/engine' 33 | require 'coletivo/rails/active_record' 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/coletivo/models/person.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Models 3 | module Person 4 | def self.included(base) 5 | base.extend ClassMethods 6 | end 7 | 8 | module ClassMethods 9 | # TODO: has_own_preferences doc. 10 | def has_own_preferences(options = {}) 11 | self.send :include, InstanceMethods 12 | end 13 | end # ClassMethods 14 | 15 | module InstanceMethods 16 | def rate!(rateable, weight) 17 | Coletivo::Config.ratings_container.create!({ 18 | :person => self, 19 | :rateable => rateable, 20 | :weight => weight 21 | }) 22 | end 23 | end # InstanceMethods 24 | 25 | end # Person 26 | end # Models 27 | end 28 | -------------------------------------------------------------------------------- /lib/coletivo/models/person_rating.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Models 3 | class PersonRating < ActiveRecord::Base 4 | belongs_to :person, :polymorphic => true 5 | belongs_to :rateable, :polymorphic => true 6 | 7 | validates :person, :rateable, :weight, :presence => true 8 | 9 | def self.find_for_recommendation(person, rateable_type) 10 | where(:rateable_type => rateable_type.to_s) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/coletivo/models/recommendable.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Models 3 | module Recommendable 4 | def self.included(base) 5 | base.extend ClassMethods 6 | base.send :include, InstanceMethods 7 | end 8 | 9 | module ClassMethods 10 | def find_recommendations_for(person, options = {}) 11 | preferences = options[:preferences] ||= 12 | load_preferences_for_recommendation(person) 13 | top = predict_highest_ratings(person, preferences, options) 14 | ids = top.collect(&:last) 15 | 16 | where(:id => ids).limit(options[:limit]).all 17 | end 18 | 19 | def map_ratings_to_preferences(ratings) 20 | #TODO: (???) Item based mapping. 21 | key, subkey = :person_id, :rateable_id 22 | preferences = {} 23 | 24 | ratings.each do |rating| 25 | p = preferences[rating.send(key)] ||= {} 26 | p[rating.send(subkey)] = rating.weight 27 | end 28 | 29 | preferences 30 | end 31 | 32 | def load_preferences_for_recommendation(person) 33 | r = Coletivo::Config.ratings_container\ 34 | .find_for_recommendation(person, self) 35 | 36 | map_ratings_to_preferences(r) 37 | end 38 | 39 | private 40 | 41 | def predict_highest_ratings(person, people_preferences, options) 42 | data = {} 43 | people_preferences.each do |other, other_prefs| 44 | next if other == person 45 | 46 | sim = person.similarity_with(other, options) 47 | next if sim <= 0 48 | 49 | other_prefs.each do |item, weight| 50 | unless people_preferences[person.id].keys.include?(item) 51 | data[item] ||= {:total_similarity => 0.0, :weighted_mean => 0.0} 52 | data[item][:total_similarity] += sim 53 | data[item][:weighted_mean] += weight * sim 54 | end 55 | end 56 | end 57 | 58 | # e.g: [[5.35, "movie_2"], [2.0, "movie_4"]] 59 | guessed_rating_and_id = Proc.new do |item, item_data| 60 | [item_data[:weighted_mean] / item_data[:total_similarity], item] 61 | end 62 | 63 | # DESC sorting by weighted mean of ratings 64 | data.collect(&guessed_rating_and_id).sort_by(&:first).reverse 65 | end 66 | end 67 | 68 | module InstanceMethods 69 | def similarity_with(other_id, options = {}) 70 | p = options[:preferences] || 71 | self.class.load_preferences_for_recommendation(self) 72 | 73 | Coletivo::Similarity::Engine\ 74 | .similarity_between(self.id, other_id, p, options) 75 | end 76 | end 77 | end # Recommendable 78 | end # Models 79 | end 80 | -------------------------------------------------------------------------------- /lib/coletivo/rails/active_record.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.send :include, Coletivo::Models::Person 2 | ActiveRecord::Base.send :include, Coletivo::Models::Recommendable 3 | -------------------------------------------------------------------------------- /lib/coletivo/rails/engine.rb: -------------------------------------------------------------------------------- 1 | require 'coletivo' 2 | 3 | module Coletivo 4 | class Engine < Rails::Engine 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /lib/coletivo/similarity/base_strategy.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Similarity 3 | class BaseStrategy 4 | attr_accessor :preferences 5 | 6 | def similarity_between(one, other) 7 | raise "The #similarity_between was not implemented in #{self.class}" 8 | end 9 | 10 | def train_with(people_preferences) 11 | @preferences = people_preferences 12 | end 13 | 14 | protected 15 | 16 | def shared_items_between(one, other) 17 | return [] unless preferences[one] && preferences[other] 18 | 19 | preferences[one].keys.select { |item| 20 | preferences[other].keys.include? item 21 | } 22 | end 23 | end 24 | end # Similarity 25 | end # Coletivo 26 | -------------------------------------------------------------------------------- /lib/coletivo/similarity/engine.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Similarity 3 | class Engine 4 | def self.similarity_between(one, other, preferences, options = {}) 5 | strategy = load_strategy options[:strategy] 6 | strategy.train_with(preferences) 7 | 8 | strategy.similarity_between(one, other) 9 | end 10 | 11 | protected 12 | 13 | def self.load_strategy(key) 14 | if :pearson == key 15 | Coletivo::Similarity::PearsonCorrelationStrategy.new 16 | else 17 | Coletivo::Similarity::EuclideanDistanceStrategy.new 18 | end 19 | end 20 | end # Engine 21 | end # Similarity 22 | end 23 | -------------------------------------------------------------------------------- /lib/coletivo/similarity/euclidean_distance_strategy.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Similarity 3 | class EuclideanDistanceStrategy < BaseStrategy 4 | def similarity_between(one, other) 5 | shared = shared_items_between(one, other) 6 | 7 | return 0 if shared.empty? 8 | 9 | sum_of_squares = shared.inject(0.0) { |sum, item| 10 | sum + (preferences[one][item] - preferences[other][item]) ** 2 11 | } 12 | 13 | 1 / (1 + sum_of_squares) 14 | end 15 | end 16 | end 17 | end 18 | 19 | -------------------------------------------------------------------------------- /lib/coletivo/similarity/pearson_correlation_strategy.rb: -------------------------------------------------------------------------------- 1 | module Coletivo 2 | module Similarity 3 | class PearsonCorrelationStrategy < BaseStrategy 4 | def similarity_between(one, other) 5 | shared = shared_items_between(one, other) 6 | prefs_one = preferences[one] 7 | prefs_other = preferences[other] 8 | 9 | return 0 if shared.empty? 10 | 11 | sum_prefs_one = sum_prefs_other = sum_squares_one = \ 12 | sum_squares_other = p_sum = 0.0 13 | 14 | shared.each { |item| 15 | sum_prefs_one += prefs_one[item] 16 | sum_prefs_other += prefs_other[item] 17 | sum_squares_one += prefs_one[item] ** 2 18 | sum_squares_other += prefs_other[item] ** 2 19 | p_sum += prefs_one[item] * prefs_other[item] 20 | } 21 | 22 | total_shared = shared.size 23 | 24 | numerator = p_sum - (sum_prefs_one * sum_prefs_other / total_shared) 25 | 26 | den_one = sum_squares_one - (sum_prefs_one ** 2) / total_shared 27 | den_other = sum_squares_other - (sum_prefs_other ** 2) / total_shared 28 | 29 | denominator = Math.sqrt(den_one * den_other) 30 | 31 | denominator == 0 ? 0 : numerator / denominator 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/generators/coletivo/coletivo_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | require 'rails/generators/migration' 3 | 4 | class ColetivoGenerator < Rails::Generators::Base 5 | include Rails::Generators::Migration 6 | 7 | def self.source_root 8 | File.join(File.dirname(__FILE__), 'templates') 9 | end 10 | 11 | # Implement the required interface for Rails::Generators::Migration. 12 | def self.next_migration_number(dirname) #:nodoc: 13 | if ActiveRecord::Base.timestamped_migrations 14 | Time.now.utc.strftime("%Y%m%d%H%M%S") 15 | else 16 | "%.3d" % (current_migration_number(dirname) + 1) 17 | end 18 | end 19 | 20 | def create_migration_file 21 | migration_template 'person_ratings_migration.rb', 22 | 'db/migrate/create_person_ratings.rb' 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/generators/coletivo/templates/person_ratings_migration.rb: -------------------------------------------------------------------------------- 1 | class CreatePersonRatings < ActiveRecord::Migration 2 | def self.up 3 | create_table :person_ratings do |t| 4 | t.integer :person_id 5 | t.string :person_type 6 | 7 | t.integer :rateable_id 8 | t.string :rateable_type 9 | 10 | t.decimal :weight, :precision => 5, :scale => 2 11 | 12 | t.timestamps 13 | end 14 | 15 | add_index :person_ratings, :rateable_type, :unique => false 16 | end 17 | 18 | def self.down 19 | drop_table :person_ratings 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/coletivo_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | 3 | class ColetivoTest < Test::Unit::TestCase 4 | should "be able to change the ratings container class" do 5 | config = Coletivo::Config.dup 6 | config.ratings_container = Object 7 | 8 | assert_equal Object, config.ratings_container 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /test/db/schema.rb: -------------------------------------------------------------------------------- 1 | require 'generators/coletivo/templates/person_ratings_migration' 2 | 3 | ActiveRecord::Schema.define(:version => 1) do 4 | CreatePersonRatings.up 5 | 6 | create_table :users do |t| 7 | t.string :name 8 | t.string :email 9 | 10 | t.timestamps 11 | end 12 | 13 | create_table :movies do |t| 14 | t.string :name 15 | 16 | t.timestamps 17 | end 18 | 19 | create_table :actors do |t| 20 | t.string :name 21 | 22 | t.timestamps 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/db/test.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/diogenes/coletivo/cec01c4a9939051e578b422f2c4939f9031c6250/test/db/test.db -------------------------------------------------------------------------------- /test/helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'bundler' 3 | begin 4 | Bundler.setup(:default, :development) 5 | rescue Bundler::BundlerError => e 6 | $stderr.puts e.message 7 | $stderr.puts "Run `bundle install` to install missing gems" 8 | exit e.status_code 9 | end 10 | 11 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 12 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 13 | require 'coletivo' 14 | 15 | require 'test/unit' 16 | require 'turn' 17 | require 'shoulda' 18 | 19 | ActiveRecord::Base.establish_connection({ 20 | :adapter => 'sqlite3', 21 | :database => ':memory:' 22 | }) 23 | ActiveRecord::Migration.verbose = false 24 | 25 | require 'db/schema' 26 | 27 | class Test::Unit::TestCase 28 | end 29 | -------------------------------------------------------------------------------- /test/models/person_rating_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'models_helper' 3 | 4 | class PersonRatingTest < Test::Unit::TestCase 5 | subject { Coletivo::Models::PersonRating.new } 6 | 7 | should validate_presence_of(:person) 8 | should validate_presence_of(:rateable) 9 | should validate_presence_of(:weight) 10 | 11 | context "#find_for_recommendation" do 12 | subject { Coletivo::Models::PersonRating } 13 | 14 | should "list only ratings of the type to recommend" do 15 | user = User.create(:name => 'A Good User') 16 | movie = Movie.create(:name => 'The Tourist') 17 | actress = Actor.create(:name => 'Angelina Jolie') 18 | 19 | user.rate!(movie, 5.0) 20 | user.rate!(actress, 10.0) # :-) 21 | 22 | recommendations = subject.find_for_recommendation(user, Movie) 23 | 24 | assert_equal 1, recommendations.size 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/models/person_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'models_helper' 3 | 4 | class PersonTest < Test::Unit::TestCase 5 | def setup 6 | super 7 | @person = User.create(:name => 'Uber Geek') 8 | end 9 | 10 | should "be able to rate an object" do 11 | movie = Movie.create(:name => 'Lovely Movie') 12 | @person.rate!(movie, 1) 13 | 14 | assert_equal 1, ratings_container.all.size 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/models/recommendable_test.rb: -------------------------------------------------------------------------------- 1 | require 'helper' 2 | require 'models_helper' 3 | 4 | class RecommendableTest < Test::Unit::TestCase 5 | def setup 6 | super 7 | 8 | @person1 = User.create(:name => 'Person 1') 9 | @person2 = User.create(:name => 'Person 2') 10 | end 11 | 12 | [:euclidean, :pearson].each do |strategy| 13 | context "using #{strategy.to_s.upcase}" do 14 | should "matches perfect similarity when preferences are identical" do 15 | m1 = Movie.create(:name => 'Movie 1') 16 | m2 = Movie.create(:name => 'Movie 2') 17 | 18 | p = { 19 | @person1.id => {m1.id => 2.5, m2.id => 1.0}, 20 | @person2.id => {m1.id => 2.5, m2.id => 1.0} 21 | } 22 | 23 | sim = similarity_between(@person1, @person2, p, strategy) 24 | 25 | assert_equal Coletivo::Similarity::IDENTICAL, sim 26 | end 27 | 28 | should "matches no similarity when preferences are very different" do 29 | m1 = Movie.create(:name => 'Movie 1') 30 | m2 = Movie.create(:name => 'Movie 2') 31 | 32 | p = { 33 | @person1.id => {m1.id => 1.0, m2.id => 10.0}, 34 | @person2.id => {m1.id => 10.0, m2.id => 1.0} 35 | } 36 | 37 | sim = similarity_between(@person1, @person2, p, strategy) 38 | 39 | assert Coletivo::Similarity::NO_SIMILARITY.include?(sim) 40 | end 41 | 42 | should "matches similarity when preferences are similar" do 43 | m1 = Movie.create(:name => 'Movie 1') 44 | m2 = Movie.create(:name => 'Movie 2') 45 | 46 | p = { 47 | @person1.id => {m1.id => 3.0, m2.id => 5.0}, 48 | @person2.id => {m1.id => 4.0, m2.id => 5.0} 49 | } 50 | 51 | sim = similarity_between(@person1, @person2, p, strategy) 52 | 53 | assert Coletivo::Similarity::SIMILAR.include?(sim) || 54 | Coletivo::Similarity::IDENTICAL == sim 55 | end 56 | 57 | should "recommend items for a person - sorted by better ratings" do 58 | person3 = User.create(:name => 'Person 3') 59 | 60 | m1 = Movie.create(:name => 'Movie 1') 61 | m2 = Movie.create(:name => 'Movie 2') 62 | m3 = Movie.create(:name => 'Movie 3') 63 | m4 = Movie.create(:name => 'Movie 4') 64 | 65 | p = { 66 | @person1.id => {m1.id => 2.0, m3.id => 1.0}, 67 | @person2.id => {m1.id => 1.5, m2.id => 4.7, m3.id => 1.5, m4.id => 2.5}, 68 | person3.id => {m1.id => 2.5, m2.id => 6.0, m3.id => 0.5, m4.id => 1.5} 69 | } 70 | 71 | recommendations = Movie.find_recommendations_for(@person1, 72 | :preferences => p, :strategy => strategy) 73 | 74 | assert recommendations.index(m2) < recommendations.index(m4) 75 | end 76 | 77 | should "be able to recommend a limited number of items" do 78 | person3 = User.create(:name => 'Person 3') 79 | 80 | m1 = Movie.create(:name => 'Movie 1') 81 | m2 = Movie.create(:name => 'Movie 2') 82 | m3 = Movie.create(:name => 'Movie 3') 83 | m4 = Movie.create(:name => 'Movie 4') 84 | 85 | p = { 86 | @person1.id => {m1.id => 2.0, m3.id => 1.0}, 87 | @person2.id => {m1.id => 1.5, m2.id => 4.7, m3.id => 1.5, m4.id => 2.5}, 88 | person3.id => {m1.id => 2.5, m2.id => 6.0, m3.id => 0.5, m4.id => 1.5} 89 | } 90 | 91 | assert_equal 1, Movie.find_recommendations_for(@person1, 92 | :preferences => p, :strategy => strategy, :limit => 1).size 93 | end 94 | end 95 | end 96 | 97 | def similarity_between(one, other, preferences, strategy) 98 | one.similarity_with(other.id, :preferences => preferences, 99 | :strategy => strategy) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/models_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coletivo' 2 | 3 | class User < ActiveRecord::Base 4 | has_own_preferences 5 | end 6 | 7 | class Movie < ActiveRecord::Base 8 | end 9 | 10 | class Actor < ActiveRecord::Base 11 | end 12 | 13 | def ratings_container 14 | Coletivo::Config.ratings_container 15 | end 16 | 17 | class Test::Unit::TestCase 18 | def setup 19 | truncate! :person_ratings, :users, :movies, :actors 20 | end 21 | 22 | private 23 | 24 | def truncate!(*tables) 25 | [*tables].each do |t| 26 | ActiveRecord::Base.connection.execute("DELETE FROM #{t}") 27 | end 28 | end 29 | end 30 | --------------------------------------------------------------------------------