weighted_list_rank 0.5.3 → 0.7.0
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 +21 -0
- data/Gemfile.lock +39 -32
- data/README.md +74 -0
- data/lib/weighted_list_rank/context.rb +41 -18
- data/lib/weighted_list_rank/strategies/exponential.rb +107 -49
- data/lib/weighted_list_rank/strategy.rb +17 -0
- data/lib/weighted_list_rank/version.rb +1 -1
- metadata +3 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0e8758848cc7ed9090ee9348afeb2efd6fe7d27506b00a2dd997ffce53959615
|
|
4
|
+
data.tar.gz: ff68f02846e841ac26fb3863d50b1309543c2d1e8368024ff01e7de44041cb23
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf99a1e72f8dc738541b1002ef1ddb9d590a198ac957aa59e2ee570b4dcf82d9702f78291459ad2d9f98250482a7b421809e3a0aa557f445454b0545c914e43d
|
|
7
|
+
data.tar.gz: d9f13250eac45ebe3466c8020a16a5f290fa693174d574d64e35f17010cf0624993c0d575d53d319b7b21510a9a8e022af625b4bdedf39f9c7b64d4952c77e7c
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
## [0.7.0] - 2026-08-23
|
|
2
|
+
### Performance
|
|
3
|
+
- Ranking a list is now linear in the number of items instead of quadratic. `Exponential#calculate_score` recomputed the ranked-item count and the total exponential factor for every item, even though both are constant across a list. They are now computed once per list.
|
|
4
|
+
- Measured on synthetic data shaped like a real catalogue: 700 lists / ~19k entries went from 226ms to 13ms (17x). The gain grows with the size of your longest list — a single 1,600-item list went from 217ms to 1.4ms (158x).
|
|
5
|
+
- Ranking output is unchanged. Scores, `score_details`, and ordering are bit-for-bit identical to 0.6.0, verified across four data profiles and a 3,000-case edge-case matrix.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `Strategy#calculate_scores(list)`, which returns an array of scores positionally matching `list.items`. This is now the entry point `RankingContext` uses. The default implementation delegates to `calculate_score` for each item, so existing custom strategies keep working unchanged. Strategies with per-list invariants should override it — see "Writing a Custom Strategy" in the README.
|
|
9
|
+
- `warn_on_invalid_position` option on the `Exponential` strategy, defaulting to `false`.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
- Items whose position exceeds the number of items in their list are still clamped to the last position, but this is no longer reported by default. Previously every such item printed a warning to `stdout`, which was noisy inside an application. Enable `warn_on_invalid_position: true` to get a single `stderr` warning per affected list.
|
|
13
|
+
- Updated all development dependencies to their latest versions.
|
|
14
|
+
|
|
15
|
+
## [0.6.0] - 2025-06-29
|
|
16
|
+
- Added `list_count_penalties` feature to `RankingContext` to allow penalizing items based on the number of lists they appear on. This is useful for de-emphasizing items that are not widely represented across your data.
|
|
17
|
+
- The feature accepts a hash mapping list counts to penalty percentages (e.g., `{1 => 0.50, 2 => 0.25}` to penalize items on 1 list by 50% and items on 2 lists by 25%).
|
|
18
|
+
- Penalties are applied to the total score after all list scores are aggregated but before sorting.
|
|
19
|
+
- This feature is fully backwards compatible and stacks with individual item penalties.
|
|
20
|
+
- Updated all dependencies to their latest versions.
|
|
21
|
+
|
|
1
22
|
## [0.5.3] - 2024-08-22
|
|
2
23
|
- Fixed an issue in the `Exponential` strategy where items with positions higher than the total number of items in a list could cause errors. Now, such items are treated as if they were in the last position, and a warning is logged.
|
|
3
24
|
- Added a new test case to verify the handling of items with positions exceeding the list size.
|
data/Gemfile.lock
CHANGED
|
@@ -1,62 +1,69 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
weighted_list_rank (0.
|
|
4
|
+
weighted_list_rank (0.7.0)
|
|
5
5
|
|
|
6
6
|
GEM
|
|
7
7
|
remote: https://rubygems.org/
|
|
8
8
|
specs:
|
|
9
|
-
ast (2.4.
|
|
10
|
-
json (2.
|
|
11
|
-
language_server-protocol (3.17.0.
|
|
9
|
+
ast (2.4.3)
|
|
10
|
+
json (2.12.2)
|
|
11
|
+
language_server-protocol (3.17.0.6)
|
|
12
12
|
lint_roller (1.1.0)
|
|
13
|
-
minitest (5.
|
|
14
|
-
parallel (1.
|
|
15
|
-
parser (3.3.
|
|
13
|
+
minitest (5.27.0)
|
|
14
|
+
parallel (1.27.0)
|
|
15
|
+
parser (3.3.12.0)
|
|
16
16
|
ast (~> 2.4.1)
|
|
17
17
|
racc
|
|
18
|
+
prism (1.4.0)
|
|
18
19
|
racc (1.8.1)
|
|
19
20
|
rainbow (3.1.1)
|
|
20
|
-
rake (13.2
|
|
21
|
-
regexp_parser (2.
|
|
22
|
-
|
|
23
|
-
rubocop (1.65.1)
|
|
21
|
+
rake (13.4.2)
|
|
22
|
+
regexp_parser (2.10.0)
|
|
23
|
+
rubocop (1.75.8)
|
|
24
24
|
json (~> 2.3)
|
|
25
|
-
language_server-protocol (
|
|
25
|
+
language_server-protocol (~> 3.17.0.2)
|
|
26
|
+
lint_roller (~> 1.1.0)
|
|
26
27
|
parallel (~> 1.10)
|
|
27
28
|
parser (>= 3.3.0.2)
|
|
28
29
|
rainbow (>= 2.2.2, < 4.0)
|
|
29
|
-
regexp_parser (>= 2.
|
|
30
|
-
|
|
31
|
-
rubocop-ast (>= 1.31.1, < 2.0)
|
|
30
|
+
regexp_parser (>= 2.9.3, < 3.0)
|
|
31
|
+
rubocop-ast (>= 1.44.0, < 2.0)
|
|
32
32
|
ruby-progressbar (~> 1.7)
|
|
33
|
-
unicode-display_width (>= 2.4.0, <
|
|
34
|
-
rubocop-ast (1.
|
|
35
|
-
parser (>= 3.3.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
rubocop (>= 1.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
rubocop (
|
|
33
|
+
unicode-display_width (>= 2.4.0, < 4.0)
|
|
34
|
+
rubocop-ast (1.45.1)
|
|
35
|
+
parser (>= 3.3.7.2)
|
|
36
|
+
prism (~> 1.4)
|
|
37
|
+
rubocop-minitest (0.40.0)
|
|
38
|
+
lint_roller (~> 1.1)
|
|
39
|
+
rubocop (>= 1.75.0, < 2.0)
|
|
40
|
+
rubocop-ast (>= 1.38.0, < 2.0)
|
|
41
|
+
rubocop-performance (1.25.0)
|
|
42
|
+
lint_roller (~> 1.1)
|
|
43
|
+
rubocop (>= 1.75.0, < 2.0)
|
|
44
|
+
rubocop-ast (>= 1.38.0, < 2.0)
|
|
45
|
+
rubocop-rake (0.7.1)
|
|
46
|
+
lint_roller (~> 1.1)
|
|
47
|
+
rubocop (>= 1.72.1)
|
|
44
48
|
ruby-progressbar (1.13.0)
|
|
45
|
-
standard (1.
|
|
49
|
+
standard (1.50.0)
|
|
46
50
|
language_server-protocol (~> 3.17.0.2)
|
|
47
51
|
lint_roller (~> 1.0)
|
|
48
|
-
rubocop (~> 1.
|
|
52
|
+
rubocop (~> 1.75.5)
|
|
49
53
|
standard-custom (~> 1.0.0)
|
|
50
|
-
standard-performance (~> 1.
|
|
54
|
+
standard-performance (~> 1.8)
|
|
51
55
|
standard-custom (1.0.2)
|
|
52
56
|
lint_roller (~> 1.0)
|
|
53
57
|
rubocop (~> 1.50)
|
|
54
|
-
standard-performance (1.
|
|
58
|
+
standard-performance (1.8.0)
|
|
55
59
|
lint_roller (~> 1.1)
|
|
56
|
-
rubocop-performance (~> 1.
|
|
57
|
-
unicode-display_width (2.
|
|
60
|
+
rubocop-performance (~> 1.25.0)
|
|
61
|
+
unicode-display_width (3.2.0)
|
|
62
|
+
unicode-emoji (~> 4.1)
|
|
63
|
+
unicode-emoji (4.2.0)
|
|
58
64
|
|
|
59
65
|
PLATFORMS
|
|
66
|
+
arm64-darwin-24
|
|
60
67
|
x86_64-linux
|
|
61
68
|
|
|
62
69
|
DEPENDENCIES
|
data/README.md
CHANGED
|
@@ -204,6 +204,80 @@ ranked_items.each do |item|
|
|
|
204
204
|
end
|
|
205
205
|
```
|
|
206
206
|
|
|
207
|
+
### Penalizing Items by Number of Lists
|
|
208
|
+
You can optionally penalize items that appear on only a small number of lists. This is useful for de-emphasizing items that are not widely represented across your data. To use this feature, pass a `list_count_penalties` hash to the `RankingContext` constructor, where the keys are the number of lists and the values are the penalty percentages (as a float between 0 and 1).
|
|
209
|
+
|
|
210
|
+
For example, to penalize items that appear on only 1 list by 50%, and items on 2 lists by 25%:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
list_count_penalties = {1 => 0.50, 2 => 0.25}
|
|
214
|
+
ranking_context = WeightedListRank::RankingContext.new(
|
|
215
|
+
WeightedListRank::Strategies::Exponential.new,
|
|
216
|
+
list_count_penalties: list_count_penalties
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
ranked_items = ranking_context.rank([list1, list2, list3])
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
- Items that appear on only 1 list will have their total score multiplied by 0.5 (50% penalty).
|
|
223
|
+
- Items that appear on only 2 lists will have their total score multiplied by 0.75 (25% penalty).
|
|
224
|
+
- Items that appear on more lists will not be penalized unless specified in the hash.
|
|
225
|
+
|
|
226
|
+
#### How it works
|
|
227
|
+
- The penalty is applied to the total score of each item after all list scores are aggregated, but before sorting.
|
|
228
|
+
- If an item appears on a number of lists not present in the hash, no penalty is applied.
|
|
229
|
+
- This feature is fully backwards compatible: if you do not provide `list_count_penalties`, no penalties are applied.
|
|
230
|
+
- This penalty stacks with individual item penalties (e.g., `score_penalty` on an item).
|
|
231
|
+
|
|
232
|
+
### Handling Invalid Positions
|
|
233
|
+
If a list contains an item whose `position` is greater than the number of items in that list, the position is clamped to the last position. This is silent by default, so the gem stays quiet inside an application.
|
|
234
|
+
|
|
235
|
+
While investigating your data you can opt into a warning. It is written to `stderr`, and at most one is emitted per affected list no matter how many items are involved:
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
strategy = WeightedListRank::Strategies::Exponential.new(warn_on_invalid_position: true)
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
```
|
|
242
|
+
WeightedListRank: list 42 has 3 item(s) with a position greater than the number of items in the list (25); clamping to 25.
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Writing a Custom Strategy
|
|
246
|
+
A strategy needs to implement `calculate_score(list, item)`:
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
class MyStrategy < WeightedListRank::Strategy
|
|
250
|
+
def calculate_score(list, item)
|
|
251
|
+
list.weight * (1.0 / item.position)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`RankingContext` scores a list through `calculate_scores(list)`, which returns an array of scores positionally matching `list.items`. The default implementation calls `calculate_score` once per item, so implementing only the single-item method is enough to get a working strategy.
|
|
257
|
+
|
|
258
|
+
If your scoring depends on values that are constant across the list — a total, a count, a normalizing factor — override `calculate_scores` and compute them once. Recomputing per-list values inside `calculate_score` makes ranking quadratic in list length, which is what the built-in `Exponential` strategy used to do:
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
class MyStrategy < WeightedListRank::Strategy
|
|
262
|
+
def calculate_scores(list)
|
|
263
|
+
items = list.items
|
|
264
|
+
total = items.sum { |i| i.position || 0 } # computed once for the whole list
|
|
265
|
+
|
|
266
|
+
items.map { |item| score_for(list, item, total) }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def calculate_score(list, item)
|
|
270
|
+
score_for(list, item, list.items.sum { |i| i.position || 0 })
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def score_for(list, item, total)
|
|
276
|
+
list.weight * (item.position.to_f / total)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
207
281
|
## Development
|
|
208
282
|
|
|
209
283
|
After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
@@ -3,12 +3,15 @@ module WeightedListRank
|
|
|
3
3
|
# It aggregates scores for each item across all lists, based on the provided strategy.
|
|
4
4
|
class RankingContext
|
|
5
5
|
# @strategy: The strategy used for calculating scores.
|
|
6
|
-
|
|
6
|
+
# @list_count_penalties: Hash mapping list counts to penalty percentages (e.g., {1 => 0.50, 2 => 0.25})
|
|
7
|
+
attr_reader :strategy, :list_count_penalties
|
|
7
8
|
|
|
8
|
-
# Initializes a new RankingContext with an optional ranking strategy.
|
|
9
|
+
# Initializes a new RankingContext with an optional ranking strategy and list count penalties.
|
|
9
10
|
# @param strategy [Strategy] the strategy to use for ranking items, defaults to Strategies::Exponential.
|
|
10
|
-
|
|
11
|
+
# @param list_count_penalties [Hash] hash mapping list counts to penalty percentages, defaults to empty hash.
|
|
12
|
+
def initialize(strategy = Strategies::Exponential.new, list_count_penalties: {})
|
|
11
13
|
@strategy = strategy
|
|
14
|
+
@list_count_penalties = list_count_penalties
|
|
12
15
|
end
|
|
13
16
|
|
|
14
17
|
# Ranks items across multiple lists according to the strategy's score calculation.
|
|
@@ -16,32 +19,52 @@ module WeightedListRank
|
|
|
16
19
|
# @return [Array<Hash>] a sorted array of item scores, with each item's details including ID, score details, and total score.
|
|
17
20
|
def rank(lists)
|
|
18
21
|
items = {}
|
|
22
|
+
|
|
23
|
+
# Strategies subclassing Strategy always have this; the fallback covers
|
|
24
|
+
# duck-typed strategies that only define #calculate_score.
|
|
25
|
+
batch_capable = strategy.respond_to?(:calculate_scores)
|
|
26
|
+
|
|
19
27
|
lists.each do |list|
|
|
20
|
-
list.items
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
list_items = list.items
|
|
29
|
+
scores = if batch_capable
|
|
30
|
+
strategy.calculate_scores(list)
|
|
31
|
+
else
|
|
32
|
+
list_items.map { |item| strategy.calculate_score(list, item) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
list_id = list.id
|
|
36
|
+
weight = list.weight
|
|
37
|
+
|
|
38
|
+
list_items.each_with_index do |item, index|
|
|
39
|
+
score = scores[index]
|
|
40
|
+
details = (items[item.id] ||= {list_details: [], total_score: 0})
|
|
41
|
+
details[:list_details] << {list_id: list_id, score: score, weight: weight, score_penalty: item.score_penalty}
|
|
42
|
+
details[:total_score] += score
|
|
30
43
|
end
|
|
31
44
|
end
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
|
|
46
|
+
apply_penalties = !list_count_penalties.empty?
|
|
47
|
+
|
|
48
|
+
# Convert hash to a formatted array
|
|
49
|
+
formatted_items = items.map do |id, details|
|
|
50
|
+
list_details = details[:list_details]
|
|
51
|
+
total_score = details[:total_score]
|
|
52
|
+
|
|
53
|
+
# Apply list count penalties if configured
|
|
54
|
+
if apply_penalties && (penalty = list_count_penalties[list_details.length])
|
|
55
|
+
total_score *= (1 - penalty)
|
|
56
|
+
end
|
|
57
|
+
|
|
35
58
|
{
|
|
36
59
|
id: id,
|
|
37
60
|
# Sort the score_details array by score in descending order before including it
|
|
38
|
-
score_details:
|
|
39
|
-
total_score:
|
|
61
|
+
score_details: list_details.sort_by { |detail| -detail[:score] },
|
|
62
|
+
total_score: total_score
|
|
40
63
|
}
|
|
41
64
|
end
|
|
42
65
|
|
|
43
66
|
# Sort the array by total_score in descending order
|
|
44
|
-
|
|
67
|
+
formatted_items.sort_by { |item| -item[:total_score] }
|
|
45
68
|
end
|
|
46
69
|
end
|
|
47
70
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module WeightedListRank
|
|
2
2
|
module Strategies
|
|
3
3
|
class Exponential < WeightedListRank::Strategy
|
|
4
|
-
attr_reader :exponent, :bonus_pool_percentage, :average_list_length, :include_unranked_items
|
|
4
|
+
attr_reader :exponent, :bonus_pool_percentage, :average_list_length, :include_unranked_items,
|
|
5
|
+
:warn_on_invalid_position
|
|
5
6
|
|
|
6
7
|
# Initializes the Exponential strategy with optional parameters for exponent,
|
|
7
8
|
# bonus pool percentage, average list length, and whether to include unranked items in the bonus pool.
|
|
@@ -13,87 +14,144 @@ module WeightedListRank
|
|
|
13
14
|
# defaults to nil.
|
|
14
15
|
# @param include_unranked_items [Boolean] whether to include unranked items in the bonus pool calculation,
|
|
15
16
|
# defaults to false for backward compatibility.
|
|
16
|
-
|
|
17
|
+
# @param warn_on_invalid_position [Boolean] whether to emit a warning when a list contains items whose
|
|
18
|
+
# position exceeds the number of items in the list. Such positions are always clamped to the last position;
|
|
19
|
+
# this only controls whether that is reported. Defaults to false, so scoring is silent.
|
|
20
|
+
def initialize(exponent: 1.5, bonus_pool_percentage: 1.0, average_list_length: nil,
|
|
21
|
+
include_unranked_items: false, warn_on_invalid_position: false)
|
|
17
22
|
@exponent = exponent
|
|
18
23
|
@bonus_pool_percentage = bonus_pool_percentage
|
|
19
24
|
@average_list_length = average_list_length
|
|
20
25
|
@include_unranked_items = include_unranked_items
|
|
26
|
+
@warn_on_invalid_position = warn_on_invalid_position
|
|
21
27
|
end
|
|
22
28
|
|
|
23
|
-
# Calculates the
|
|
29
|
+
# Calculates the scores for every item in a list in one pass.
|
|
30
|
+
#
|
|
31
|
+
# The number of ranked items, the adjusted bonus pool, and the total
|
|
32
|
+
# exponential factor are all constant across the list, so they are
|
|
33
|
+
# computed once here instead of once per item. Scoring a list is
|
|
34
|
+
# therefore linear in the number of items.
|
|
35
|
+
#
|
|
36
|
+
# @param list [WeightedListRank::List] the list whose items should be scored.
|
|
37
|
+
# @return [Array<Float>] scores positionally matching +list.items+.
|
|
38
|
+
def calculate_scores(list)
|
|
39
|
+
items = list.items
|
|
40
|
+
total_items = items.count
|
|
41
|
+
return [] if total_items.zero?
|
|
42
|
+
|
|
43
|
+
num_ranked_items = count_ranked(items)
|
|
44
|
+
adjusted_bonus_pool = adjusted_bonus_pool_for(list, total_items)
|
|
45
|
+
|
|
46
|
+
# Only lists containing ranked items need this, and it is the most
|
|
47
|
+
# expensive value to produce, so it is computed on first use.
|
|
48
|
+
total_exponential_factor = nil
|
|
49
|
+
invalid_positions = 0
|
|
50
|
+
|
|
51
|
+
scores = items.map do |item|
|
|
52
|
+
position = item.position
|
|
53
|
+
|
|
54
|
+
if position.nil?
|
|
55
|
+
score = list.weight
|
|
56
|
+
if num_ranked_items.zero? && include_unranked_items
|
|
57
|
+
score += adjusted_bonus_pool / total_items
|
|
58
|
+
end
|
|
59
|
+
else
|
|
60
|
+
if position > total_items
|
|
61
|
+
invalid_positions += 1
|
|
62
|
+
position = total_items
|
|
63
|
+
end
|
|
64
|
+
total_exponential_factor ||= exponential_factor_sum(total_items)
|
|
65
|
+
exponential_factor = (total_items + 1 - position)**exponent
|
|
66
|
+
score = list.weight + (exponential_factor / total_exponential_factor) * adjusted_bonus_pool
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
floor(apply_penalty(score, item.score_penalty))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
report_invalid_positions(list, invalid_positions, total_items)
|
|
73
|
+
scores
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Calculates the score of a single item within a list based on its rank position, the total number of items,
|
|
24
77
|
# and the list's weight, using an exponential formula. The bonus pool for score adjustments is determined
|
|
25
78
|
# by the specified bonus pool percentage of the list's total weight, adjusted by the average list length.
|
|
26
79
|
#
|
|
27
80
|
# If +include_unranked_items+ is true, unranked items will also receive a portion of the bonus pool.
|
|
28
81
|
# Ranked items will receive an exponential bonus, while unranked items will split the remaining bonus pool evenly.
|
|
29
82
|
#
|
|
83
|
+
# Scoring a whole list is cheaper through {#calculate_scores}, which shares the per-list work across items.
|
|
84
|
+
#
|
|
30
85
|
# @param list [WeightedListRank::List] the list containing the item being scored.
|
|
31
86
|
# @param item [WeightedListRank::Item] the item for which to calculate the score.
|
|
32
87
|
#
|
|
33
88
|
# @return [Float] the calculated score for the item, adjusted by the list's weight, the specified exponent,
|
|
34
89
|
# and the bonus pool percentage.
|
|
35
90
|
def calculate_score(list, item)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
total_items = list.items.count
|
|
41
|
-
|
|
42
|
-
# Separate ranked and unranked items
|
|
43
|
-
ranked_items = list.items.select { |i| i.position }
|
|
44
|
-
num_ranked_items = ranked_items.count
|
|
91
|
+
items = list.items
|
|
92
|
+
total_items = items.count
|
|
93
|
+
num_ranked_items = count_ranked(items)
|
|
94
|
+
position = item.position
|
|
45
95
|
|
|
46
|
-
if
|
|
47
|
-
# If there are ranked items, unranked items get no bonus, only the list's weight
|
|
96
|
+
if position.nil?
|
|
48
97
|
score = list.weight
|
|
98
|
+
if num_ranked_items.zero? && include_unranked_items
|
|
99
|
+
score += adjusted_bonus_pool_for(list, total_items) / total_items
|
|
100
|
+
end
|
|
49
101
|
else
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
# Adjust the bonus pool based on the average list length
|
|
54
|
-
adjusted_bonus_pool = if average_list_length && average_list_length > 0
|
|
55
|
-
total_bonus_pool * (total_items / average_list_length.to_f)
|
|
56
|
-
else
|
|
57
|
-
total_bonus_pool
|
|
102
|
+
if position > total_items
|
|
103
|
+
report_invalid_positions(list, 1, total_items)
|
|
104
|
+
position = total_items
|
|
58
105
|
end
|
|
106
|
+
exponential_factor = (total_items + 1 - position)**exponent
|
|
107
|
+
score = list.weight +
|
|
108
|
+
(exponential_factor / exponential_factor_sum(total_items)) * adjusted_bonus_pool_for(list, total_items)
|
|
109
|
+
end
|
|
59
110
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
if include_unranked_items && num_ranked_items == 0
|
|
63
|
-
unranked_bonus = adjusted_bonus_pool / total_items
|
|
64
|
-
score += unranked_bonus
|
|
65
|
-
end
|
|
66
|
-
else
|
|
67
|
-
# Check if the item's position is higher than the total number of items
|
|
68
|
-
if item.position > total_items
|
|
69
|
-
puts "Warning: Item position (#{item.position}) is higher than the total number of items (#{total_items}) in the list. Using total items as position."
|
|
70
|
-
item_position = total_items
|
|
71
|
-
else
|
|
72
|
-
item_position = item.position
|
|
73
|
-
end
|
|
111
|
+
floor(apply_penalty(score, item.score_penalty))
|
|
112
|
+
end
|
|
74
113
|
|
|
75
|
-
|
|
76
|
-
exponential_factor = (total_items + 1 - item_position)**exponent
|
|
77
|
-
total_exponential_factor = (1..total_items).sum { |pos| (total_items + 1 - pos)**exponent }
|
|
114
|
+
private
|
|
78
115
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
116
|
+
def count_ranked(items)
|
|
117
|
+
count = 0
|
|
118
|
+
items.each { |item| count += 1 if item.position }
|
|
119
|
+
count
|
|
120
|
+
end
|
|
84
121
|
|
|
85
|
-
|
|
86
|
-
|
|
122
|
+
def adjusted_bonus_pool_for(list, total_items)
|
|
123
|
+
total_bonus_pool = list.weight * bonus_pool_percentage
|
|
87
124
|
|
|
88
|
-
|
|
89
|
-
|
|
125
|
+
if average_list_length && average_list_length > 0
|
|
126
|
+
total_bonus_pool * (total_items / average_list_length.to_f)
|
|
127
|
+
else
|
|
128
|
+
total_bonus_pool
|
|
129
|
+
end
|
|
90
130
|
end
|
|
91
131
|
|
|
92
|
-
|
|
132
|
+
# The sum of every item's exponential factor, used to normalize each
|
|
133
|
+
# item's share of the bonus pool. Constant for a given list length.
|
|
134
|
+
def exponential_factor_sum(total_items)
|
|
135
|
+
(1..total_items).sum { |pos| (total_items + 1 - pos)**exponent }
|
|
136
|
+
end
|
|
93
137
|
|
|
94
138
|
def apply_penalty(score, penalty)
|
|
95
139
|
penalty ? score * (1 - penalty) : score
|
|
96
140
|
end
|
|
141
|
+
|
|
142
|
+
# Ensure the score is not less than 1.
|
|
143
|
+
def floor(score)
|
|
144
|
+
(score < 1) ? 1 : score
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Emits at most one warning per list, and only when explicitly enabled.
|
|
148
|
+
def report_invalid_positions(list, count, total_items)
|
|
149
|
+
return unless warn_on_invalid_position
|
|
150
|
+
return if count.zero?
|
|
151
|
+
|
|
152
|
+
warn "WeightedListRank: list #{list.id} has #{count} item(s) with a position greater than the " \
|
|
153
|
+
"number of items in the list (#{total_items}); clamping to #{total_items}."
|
|
154
|
+
end
|
|
97
155
|
end
|
|
98
156
|
end
|
|
99
157
|
end
|
|
@@ -7,5 +7,22 @@ module WeightedListRank
|
|
|
7
7
|
def calculate_score(list, item)
|
|
8
8
|
raise NotImplementedError
|
|
9
9
|
end
|
|
10
|
+
|
|
11
|
+
# Calculates the scores for every item in a list, returned in the same order
|
|
12
|
+
# as +list.items+.
|
|
13
|
+
#
|
|
14
|
+
# This is the entry point {RankingContext} uses. The default implementation
|
|
15
|
+
# simply delegates to {#calculate_score} for each item, so strategies that
|
|
16
|
+
# implement only the single-item API continue to work unchanged.
|
|
17
|
+
#
|
|
18
|
+
# Strategies whose scoring depends on values that are constant across the
|
|
19
|
+
# list (a total, a count, a normalizing factor) should override this and
|
|
20
|
+
# compute those values once, rather than recomputing them for every item.
|
|
21
|
+
#
|
|
22
|
+
# @param list [List] the list whose items should be scored.
|
|
23
|
+
# @return [Array<Numeric>] scores positionally matching +list.items+.
|
|
24
|
+
def calculate_scores(list)
|
|
25
|
+
list.items.map { |item| calculate_score(list, item) }
|
|
26
|
+
end
|
|
10
27
|
end
|
|
11
28
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: weighted_list_rank
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shane Sherman
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: rubocop
|
|
@@ -97,7 +96,6 @@ metadata:
|
|
|
97
96
|
homepage_uri: https://github.com/ssherman/weighted_list_rank
|
|
98
97
|
source_code_uri: https://github.com/ssherman/weighted_list_rank
|
|
99
98
|
changelog_uri: https://github.com/ssherman/weighted_list_rank/CHANGELOG.md
|
|
100
|
-
post_install_message:
|
|
101
99
|
rdoc_options: []
|
|
102
100
|
require_paths:
|
|
103
101
|
- lib
|
|
@@ -112,8 +110,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
112
110
|
- !ruby/object:Gem::Version
|
|
113
111
|
version: '0'
|
|
114
112
|
requirements: []
|
|
115
|
-
rubygems_version:
|
|
116
|
-
signing_key:
|
|
113
|
+
rubygems_version: 4.0.16
|
|
117
114
|
specification_version: 4
|
|
118
115
|
summary: generate ranks of items from weighted lists
|
|
119
116
|
test_files: []
|