recommender 2.0.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -13
- data/README.md +22 -21
- data/lib/recommender/recommendation.rb +59 -40
- data/lib/recommender/version.rb +1 -1
- data/recommender.gemspec +0 -1
- data/spec/test_app/Gemfile.lock +2 -11
- data/spec/test_app/app/models/album.rb +1 -1
- data/spec/test_app/app/models/user.rb +2 -1
- data/spec/test_app/log/development.log +1375 -32221
- data/spec/test_app/tmp/local_secret.txt +1 -1
- metadata +2 -6
- data/spec/test_app/log/test.log +0 -0
- data/spec/test_app/tmp/restart.txt +0 -0
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: f23eac1f93035fe6f01ceb18a5f62ac4062578eed8ea87f7e7ddb0781fb889af
|
4
|
+
data.tar.gz: 66b4a78cc848d0587f1451bff6694eba94653e91ee6a916b1a910c099636ed24
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 1291601deb871fb74aeee99f1936788daaf293757819f7440babe4881a470e500a57b366a7bd8a42d403845113d6ca707e824bd3fac0e97d13d22721635dee21
|
7
|
+
data.tar.gz: 5c2d3e693ef42fa4df000276213dd2e71edd72bce77c44606a6e7fa2f3be435d52f2758d3305ed648d238d7ff163f5f94eb6e13e425b525d304558caebfb5783
|
data/CHANGELOG.md
CHANGED
@@ -1,17 +1,18 @@
|
|
1
|
-
|
1
|
+
# Changelog
|
2
2
|
|
3
|
-
|
3
|
+
## \[2.0.1\] - 2024-07-16
|
4
4
|
|
5
|
-
|
6
|
-
\### Added
|
7
|
-
\- Feature: Similarity based on multiple associations combined with weights.
|
8
|
-
\- Feature: User-item recommendations based on all their items.
|
9
|
-
\- Feature: Recommendations based on numerical ratings.
|
10
|
-
\- Feature: Recommendations based on a weighted mix of various associations.
|
5
|
+
### Added
|
11
6
|
|
12
|
-
|
13
|
-
|
7
|
+
- Feature: Similarity based on multiple associations combined with weights.
|
8
|
+
- Feature: User-item recommendations based on all their items.
|
14
9
|
|
15
|
-
|
16
|
-
|
17
|
-
|
10
|
+
### Changed
|
11
|
+
|
12
|
+
- Internal refactoring to support the new features.
|
13
|
+
|
14
|
+
## \[0.1.0\] - 2024-06-18
|
15
|
+
|
16
|
+
### Added
|
17
|
+
|
18
|
+
- Initial release
|
data/README.md
CHANGED
@@ -8,47 +8,48 @@ The `Recommender` gem is a versatile recommendation engine built for Ruby on Rai
|
|
8
8
|
- **Multiple Association Support:** Compatible with `has_and_belongs_to_many`, `has_many :through`, and `has_many` associations, allowing seamless integration with different data models.
|
9
9
|
- **Customizable Recommendations:** Easily extendable and configurable to fit the specific needs of your application.
|
10
10
|
- **Lightweight and Efficient:** Designed to be efficient and minimalistic, ensuring fast recommendation calculations without heavy overhead.
|
11
|
+
- Feature: Similarity based on multiple associations combined with weights.
|
12
|
+
- Feature: User-item recommendations based on all their items.
|
11
13
|
|
12
|
-
###
|
14
|
+
### Coming soon:
|
13
15
|
|
14
|
-
|
16
|
+
- Feature: Recommendations based on a weighted mix of various associations.
|
15
17
|
|
16
|
-
|
18
|
+
### Installation
|
17
19
|
|
18
|
-
|
20
|
+
Add this line to your application's Gemfile:
|
19
21
|
|
20
22
|
`gem 'recommender'`
|
21
23
|
|
22
24
|
And then execute:
|
23
25
|
|
24
|
-
sh
|
25
|
-
|
26
|
-
Copy code
|
27
|
-
|
28
26
|
`bundle install`
|
29
27
|
|
30
28
|
### Usage
|
31
29
|
|
32
30
|
Include the `Recommender::Recommendation` module in your model and set the association:
|
33
31
|
|
34
|
-
```
|
35
|
-
class
|
36
|
-
|
37
|
-
|
38
|
-
|
32
|
+
```ruby
|
33
|
+
class User < ApplicationRecord
|
34
|
+
include Recommender::Recommendation
|
35
|
+
|
36
|
+
has_many :movie_likes, dependent: :destroy
|
37
|
+
has_many :movies, through: :movie_likes
|
39
38
|
|
40
|
-
|
41
|
-
|
39
|
+
validates :name, presence: true, uniqueness: { case_sensitive: false }
|
40
|
+
|
41
|
+
set_association :movies
|
42
|
+
end
|
42
43
|
```
|
43
44
|
|
44
45
|
Now you can get recommendations for an instance:
|
45
46
|
|
46
|
-
```
|
47
|
-
user = User.find(1)
|
48
|
-
recommendations = user.recommendations(results: 5)
|
49
|
-
recommendations.each do |
|
50
|
-
|
51
|
-
end
|
47
|
+
```ruby
|
48
|
+
user = User.find(1)
|
49
|
+
recommendations = user.recommendations(results: 5)
|
50
|
+
recommendations.each do |recommended_movie, score|
|
51
|
+
puts "#{recommended_movie.name} - Score: #{score}"
|
52
|
+
end
|
52
53
|
```
|
53
54
|
|
54
55
|
### How It Works
|
@@ -10,47 +10,42 @@ module Recommender
|
|
10
10
|
extend ClassMethods
|
11
11
|
end
|
12
12
|
|
13
|
-
|
13
|
+
AssociationMetadata = Struct.new(:join_table, :foreign_key, :association_foreign_key, :reflection_name)
|
14
14
|
|
15
15
|
module ClassMethods
|
16
|
-
attr_accessor :
|
16
|
+
attr_accessor :association_metadata
|
17
17
|
|
18
|
-
def
|
19
|
-
|
20
|
-
|
21
|
-
raise ArgumentError, "Association '#{association_name}' not found" unless reflection
|
18
|
+
def set_association(association_name)
|
19
|
+
reflection = reflect_on_association(association_name.to_sym)
|
20
|
+
raise ArgumentError, "Association '#{association_name}' not found" unless reflection
|
22
21
|
|
23
|
-
|
24
|
-
end
|
22
|
+
@association_metadata ||= build_association_metadata(reflection)
|
25
23
|
end
|
26
24
|
|
27
25
|
private
|
28
26
|
|
29
|
-
def
|
27
|
+
def build_association_metadata(reflection)
|
30
28
|
case reflection
|
31
29
|
when ActiveRecord::Reflection::HasAndBelongsToManyReflection
|
32
|
-
|
30
|
+
AssociationMetadata.new(
|
33
31
|
reflection.join_table,
|
34
32
|
reflection.foreign_key,
|
35
33
|
reflection.association_foreign_key,
|
36
|
-
reflection.name
|
37
|
-
weight
|
34
|
+
reflection.name
|
38
35
|
)
|
39
36
|
when ActiveRecord::Reflection::ThroughReflection
|
40
|
-
|
37
|
+
AssociationMetadata.new(
|
41
38
|
reflection.through_reflection.table_name,
|
42
39
|
reflection.through_reflection.foreign_key,
|
43
40
|
reflection.association_foreign_key,
|
44
|
-
reflection.name
|
45
|
-
weight
|
41
|
+
reflection.name
|
46
42
|
)
|
47
43
|
when ActiveRecord::Reflection::HasManyReflection
|
48
|
-
|
44
|
+
AssociationMetadata.new(
|
49
45
|
reflection.klass.table_name,
|
50
46
|
reflection.foreign_key,
|
51
47
|
reflection.foreign_key,
|
52
48
|
reflection.name,
|
53
|
-
weight
|
54
49
|
)
|
55
50
|
else
|
56
51
|
raise ArgumentError, "Association '#{reflection.name}' is not a supported type"
|
@@ -58,33 +53,57 @@ module Recommender
|
|
58
53
|
end
|
59
54
|
end
|
60
55
|
|
61
|
-
def recommendations(results:
|
56
|
+
def recommendations(results: 10)
|
62
57
|
other_instances = self.class.where.not(id: id)
|
63
|
-
item_recommendations = other_instances.reduce(Hash.new(0)) do |acc, instance|
|
64
|
-
self.class.association_meta_data.each do |meta_data|
|
65
|
-
self_items = send(meta_data.reflection_name).pluck(:id).to_set
|
66
|
-
instance_items = instance.send(meta_data.reflection_name).pluck(:id).to_set
|
67
|
-
common_items = self_items & instance_items
|
68
|
-
|
69
|
-
jaccard_index = common_items.size.to_f / (self_items | instance_items).size
|
70
|
-
dice_sorensen_coefficient = (2.0 * common_items.size) / (self_items.size + instance_items.size)
|
71
|
-
collaborative_weight = common_items.size.to_f / Math.sqrt(self_items.size * instance_items.size)
|
72
|
-
|
73
|
-
# Calculate weighted score for this association
|
74
|
-
association_weighted_score = meta_data.weight * (jaccard_index + dice_sorensen_coefficient + collaborative_weight) / 3.0
|
75
|
-
|
76
|
-
# Recommend items based on this association
|
77
|
-
(instance_items - common_items).each do |item_id|
|
78
|
-
acc[item_id] += association_weighted_score unless self_items.include?(item_id)
|
79
|
-
end
|
80
|
-
end
|
81
58
|
|
82
|
-
|
59
|
+
self_items = associated_items.to_set
|
60
|
+
|
61
|
+
item_recommendations = calculate_recommendations(other_instances, self_items)
|
62
|
+
|
63
|
+
sorted_recommendation_ids = sort_recommendations(item_recommendations).take(results)
|
64
|
+
|
65
|
+
fetch_recommendation_objects(sorted_recommendation_ids, item_recommendations)
|
66
|
+
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def associated_items
|
71
|
+
send(self.class.association_metadata.reflection_name).pluck(self.class.association_metadata.association_foreign_key)
|
72
|
+
end
|
73
|
+
|
74
|
+
def calculate_recommendations(other_instances, self_items)
|
75
|
+
other_instances.each_with_object(Hash.new(0)) do |instance, acc|
|
76
|
+
instance_items = instance.send(self.class.association_metadata.reflection_name).pluck(self.class.association_metadata.association_foreign_key).to_set
|
77
|
+
common_items = instance_items & self_items
|
78
|
+
|
79
|
+
weight = calculate_weight(common_items, instance_items, self_items)
|
80
|
+
|
81
|
+
(instance_items - common_items).each do |item_id|
|
82
|
+
acc[item_id] += weight unless self_items.include?(item_id)
|
83
|
+
end
|
83
84
|
end
|
85
|
+
end
|
86
|
+
|
87
|
+
def calculate_weight(common_items, instance_items, self_items)
|
88
|
+
jaccard_index = common_items.size.to_f / (instance_items | self_items).size
|
89
|
+
dice_sorensen_coefficient = (2.0 * common_items.size) / (instance_items.size + self_items.size)
|
90
|
+
collaborative_weight = common_items.size.to_f / Math.sqrt(instance_items.size * self_items.size)
|
84
91
|
|
85
|
-
|
86
|
-
|
87
|
-
|
92
|
+
(jaccard_index + dice_sorensen_coefficient + collaborative_weight) / 3.0
|
93
|
+
end
|
94
|
+
|
95
|
+
def sort_recommendations(item_recommendations)
|
96
|
+
item_recommendations.keys.sort_by { |id| item_recommendations[id] }.reverse
|
97
|
+
end
|
98
|
+
|
99
|
+
def fetch_recommendation_objects(sorted_recommendation_ids, item_recommendations)
|
100
|
+
association = self.class.reflect_on_association(self.class.association_metadata.reflection_name)
|
101
|
+
association_table = association.klass
|
102
|
+
|
103
|
+
sorted_recommendation_ids.map do |id|
|
104
|
+
value = item_recommendations[id]
|
105
|
+
[association_table.find(id), value.is_a?(Float) && value.nan? ? 0 : value]
|
106
|
+
end
|
88
107
|
end
|
89
108
|
end
|
90
109
|
end
|
data/lib/recommender/version.rb
CHANGED
data/recommender.gemspec
CHANGED
@@ -31,7 +31,6 @@ Gem::Specification.new do |spec|
|
|
31
31
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
32
32
|
spec.require_paths = ["lib"]
|
33
33
|
|
34
|
-
# spec.add_runtime_dependency 'activesupport', '~> 3.0', '>= 3.0.0'
|
35
34
|
spec.add_development_dependency "rspec", "~> 3.9.0"
|
36
35
|
spec.add_development_dependency "pg", "~> 1.1"
|
37
36
|
spec.add_development_dependency "factory_bot_rails", "~> 6.2"
|
data/spec/test_app/Gemfile.lock
CHANGED
@@ -1,11 +1,9 @@
|
|
1
1
|
PATH
|
2
2
|
remote: ../..
|
3
3
|
specs:
|
4
|
-
recommender (0.
|
5
|
-
|
6
|
-
database_cleaner-active_record
|
4
|
+
recommender (2.0.0)
|
5
|
+
database_cleaner-active_record (~> 2.0)
|
7
6
|
faker (~> 2.21)
|
8
|
-
pry-rails
|
9
7
|
rails (~> 7.1.3)
|
10
8
|
|
11
9
|
GEM
|
@@ -89,7 +87,6 @@ GEM
|
|
89
87
|
bigdecimal (3.1.8)
|
90
88
|
bindex (0.8.1)
|
91
89
|
builder (3.3.0)
|
92
|
-
coderay (1.1.3)
|
93
90
|
concurrent-ruby (1.3.3)
|
94
91
|
connection_pool (2.4.1)
|
95
92
|
crass (1.0.6)
|
@@ -122,7 +119,6 @@ GEM
|
|
122
119
|
net-pop
|
123
120
|
net-smtp
|
124
121
|
marcel (1.0.4)
|
125
|
-
method_source (1.1.0)
|
126
122
|
mini_mime (1.1.5)
|
127
123
|
minitest (5.24.0)
|
128
124
|
mutex_m (0.2.0)
|
@@ -143,11 +139,6 @@ GEM
|
|
143
139
|
nokogiri (1.16.6-x86_64-linux)
|
144
140
|
racc (~> 1.4)
|
145
141
|
pg (1.5.6)
|
146
|
-
pry (0.14.2)
|
147
|
-
coderay (~> 1.1)
|
148
|
-
method_source (~> 1.0)
|
149
|
-
pry-rails (0.3.9)
|
150
|
-
pry (>= 0.10.4)
|
151
142
|
psych (5.1.2)
|
152
143
|
stringio
|
153
144
|
puma (6.4.2)
|