weightedpicker 0.1.1 → 0.1.2
Sign up to get free protection for your applications and to get access to all the features.
- data/CHANGES +7 -1
- data/Gemfile +5 -4
- data/Rakefile +39 -31
- data/VERSION +1 -1
- data/lib/weightedpicker.rb +117 -112
- data/lib/weightedpicker/tree.rb +95 -91
- data/{spec → test}/a-1b256.yaml +0 -0
- data/{spec → test}/a256b0.yaml +0 -0
- data/{spec → test}/a256b1.yaml +0 -0
- data/{spec → test}/a256b128.yaml +0 -0
- data/{spec → test}/a512b64.yaml +0 -0
- data/{spec → test}/a99999b64.yaml +0 -0
- data/{spec → test}/float.yaml +0 -0
- data/test/helper.rb +17 -0
- data/test/test_tree.rb +120 -0
- data/test/test_weightedpicker.rb +239 -0
- metadata +56 -32
- data/spec/spec_helper.rb +0 -12
- data/spec/tree_spec.rb +0 -123
- data/spec/weightedpicker_spec.rb +0 -238
- data/weightedpicker.gemspec +0 -74
data/CHANGES
CHANGED
@@ -1,6 +1,12 @@
|
|
1
1
|
= weightedpicker changelog
|
2
2
|
|
3
|
-
== Master (for 0.1.
|
3
|
+
== Master (for 0.1.3)
|
4
|
+
|
5
|
+
== Version 0.1.2 [2014-09-01] released
|
6
|
+
Add WeightedPicker::total_weight.
|
7
|
+
Change test method from rspec to test-unit
|
8
|
+
|
9
|
+
== Version 0.1.1 [2013-04-18] released
|
4
10
|
Add bin/weight to deal with weight yaml.
|
5
11
|
Add WeightedPicker::names.
|
6
12
|
Add WeightedPicker::dump_histgram.
|
data/Gemfile
CHANGED
@@ -6,10 +6,11 @@ source "http://rubygems.org"
|
|
6
6
|
# Add dependencies to develop your gem here.
|
7
7
|
# Include everything needed to run rake, tests, features, etc.
|
8
8
|
group :development do
|
9
|
-
gem "rspec", "~> 2.13.0"
|
10
|
-
gem "
|
11
|
-
gem "
|
12
|
-
gem "
|
9
|
+
#gem "rspec", "~> 2.13.0"
|
10
|
+
gem "test-unit", "~> 3.0.1"
|
11
|
+
gem "rdoc", "~> 4.0.1"
|
12
|
+
gem "bundler", "~> 1.7.2"
|
13
|
+
gem "jeweler", "~> 2.0.1"
|
13
14
|
gem "simplecov", ">= 0"
|
14
15
|
#gem "psych", ">= 0"
|
15
16
|
end
|
data/Rakefile
CHANGED
@@ -3,50 +3,58 @@
|
|
3
3
|
require 'rubygems'
|
4
4
|
require 'bundler'
|
5
5
|
begin
|
6
|
-
|
6
|
+
Bundler.setup(:default, :development)
|
7
7
|
rescue Bundler::BundlerError => e
|
8
|
-
|
9
|
-
|
10
|
-
|
8
|
+
$stderr.puts e.message
|
9
|
+
$stderr.puts "Run `bundle install` to install missing gems"
|
10
|
+
exit e.status_code
|
11
11
|
end
|
12
12
|
require 'rake'
|
13
13
|
|
14
14
|
require 'jeweler'
|
15
15
|
Jeweler::Tasks.new do |gem|
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
16
|
+
# gem is a Gem::Specification... see http://docs.rubygems.org/read/chapter/20 for more options
|
17
|
+
gem.name = "weightedpicker"
|
18
|
+
gem.homepage = "http://github.com/ippei94da/weightedpicker"
|
19
|
+
gem.license = "MIT"
|
20
|
+
gem.summary = %Q{Picking one item from list at the rate of its weight.}
|
21
|
+
gem.description = %Q{This library enables to pick out items at the rate of their weight.
|
22
|
+
Weight data is storaged as a YAML file.
|
23
|
+
You can use this library for music player, wallpaper changer, language training.
|
24
|
+
}
|
25
|
+
gem.email = "ippei94da@gmail.com"
|
26
|
+
gem.authors = ["ippei94da"]
|
27
|
+
# dependencies defined in Gemfile
|
28
28
|
end
|
29
29
|
Jeweler::RubygemsDotOrgTasks.new
|
30
30
|
|
31
|
-
require 'rspec/core'
|
32
|
-
require 'rspec/core/rake_task'
|
33
|
-
RSpec::Core::RakeTask.new(:spec) do |spec|
|
34
|
-
|
35
|
-
end
|
31
|
+
#require 'rspec/core'
|
32
|
+
#require 'rspec/core/rake_task'
|
33
|
+
#RSpec::Core::RakeTask.new(:spec) do |spec|
|
34
|
+
# spec.pattern = FileList['spec/**/*_spec.rb']
|
35
|
+
#end
|
36
|
+
#
|
37
|
+
#RSpec::Core::RakeTask.new(:rcov) do |spec|
|
38
|
+
# spec.pattern = 'spec/**/*_spec.rb'
|
39
|
+
# spec.rcov = true
|
40
|
+
#end
|
41
|
+
#
|
42
|
+
#task :default => :spec
|
36
43
|
|
37
|
-
|
38
|
-
|
39
|
-
|
44
|
+
require 'rake/testtask'
|
45
|
+
Rake::TestTask.new(:test) do |test|
|
46
|
+
test.libs << 'lib' << 'test'
|
47
|
+
test.pattern = 'test/**/test_*.rb'
|
48
|
+
test.verbose = true
|
40
49
|
end
|
41
|
-
|
42
|
-
task :default => :spec
|
50
|
+
task :default => :test
|
43
51
|
|
44
52
|
require 'rdoc/task'
|
45
53
|
Rake::RDocTask.new do |rdoc|
|
46
|
-
|
54
|
+
version = File.exist?('VERSION') ? File.read('VERSION') : ""
|
47
55
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
56
|
+
rdoc.rdoc_dir = 'rdoc'
|
57
|
+
rdoc.title = "weightedpicker #{version}"
|
58
|
+
rdoc.rdoc_files.include('README*')
|
59
|
+
rdoc.rdoc_files.include('lib/**/*.rb')
|
52
60
|
end
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.1.
|
1
|
+
0.1.2
|
data/lib/weightedpicker.rb
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
# coding: utf-8
|
1
2
|
require 'yaml'
|
2
3
|
|
3
4
|
class WeightedPicker; end
|
@@ -5,18 +6,18 @@ class WeightedPicker; end
|
|
5
6
|
require 'weightedpicker/tree.rb'
|
6
7
|
|
7
8
|
# TODO
|
8
|
-
#
|
9
|
-
#
|
9
|
+
# initialize.指定したファイル内のデータが WeightedPicker 的に
|
10
|
+
# 解釈できなければ例外。
|
10
11
|
|
11
12
|
#= 概要
|
12
13
|
# 要素群の中から、優先度に応じた重み付きランダムで、
|
13
14
|
# どれか1つを選択する。
|
14
15
|
# たとえば、以下の用途に使える。
|
15
16
|
#* 音楽ファイルの再生(好みのものは多い頻度で、
|
16
|
-
#
|
17
|
+
# そうでないものは少ない頻度で)
|
17
18
|
#* クイズゲームの問題選択
|
18
19
|
#* 画像
|
19
|
-
#
|
20
|
+
# * 写真で壁紙, スライドショー的な用途。
|
20
21
|
#
|
21
22
|
# 対象の要素はファイルであることを前提としない。
|
22
23
|
# 文字列であることが多いかもしれない。
|
@@ -95,124 +96,128 @@ require 'weightedpicker/tree.rb'
|
|
95
96
|
#
|
96
97
|
# ヒストリ関係はこのクラスの外に出した方が良いと判断。
|
97
98
|
class WeightedPicker
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
end
|
114
|
-
|
115
|
-
# Argument 'file' indicates a strage file name for data
|
116
|
-
# which this class manages.
|
117
|
-
# If the 'file' does not exist, this file is used to data strage.
|
118
|
-
#
|
119
|
-
# Argument 'items' is an array of items to be managed.
|
120
|
-
# Not all the items in the 'file' survive.
|
121
|
-
# A and B in the file and B and C in items,
|
122
|
-
# then B and C is the items which this class manage.
|
123
|
-
# A is discarded from record.
|
124
|
-
def self.load_file(filename)
|
125
|
-
weights = YAML.load_file(filename)
|
126
|
-
self.new(weights)
|
127
|
-
end
|
128
|
-
|
129
|
-
def dump(io)
|
130
|
-
YAML.dump(@tree.names_weights, io)
|
131
|
-
end
|
132
|
-
|
133
|
-
def dump_histgram(io)
|
134
|
-
encounters = {}
|
135
|
-
names_weights.each do |key, weight|
|
136
|
-
#val_log2 = (Math::log(weight)/Math::log(2.0)).to_i
|
137
|
-
power = Math::log2(weight).ceil
|
138
|
-
encounters[power] ||= 0
|
139
|
-
encounters[power] += 1
|
99
|
+
MAX_WEIGHT = 2**16
|
100
|
+
INI_WEIGHT = 2** 8
|
101
|
+
MIN_WEIGHT = 2** 0
|
102
|
+
|
103
|
+
HISTGRAM_WIDTH = 50
|
104
|
+
|
105
|
+
class InvalidFilenameError < Exception; end
|
106
|
+
class NoEntryError < Exception; end
|
107
|
+
class NotExistKeyError < Exception; end
|
108
|
+
class InvalidWeightError < Exception; end
|
109
|
+
|
110
|
+
# Initialization.
|
111
|
+
def initialize(data)
|
112
|
+
data = sanity_data(data)
|
113
|
+
@tree = WeightedPicker::Tree.new(data)
|
140
114
|
end
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
115
|
+
|
116
|
+
# Argument 'file' indicates a strage file name for data
|
117
|
+
# which this class manages.
|
118
|
+
# If the 'file' does not exist, this file is used to data strage.
|
119
|
+
#
|
120
|
+
# Argument 'items' is an array of items to be managed.
|
121
|
+
# Not all the items in the 'file' survive.
|
122
|
+
# A and B in the file and B and C in items,
|
123
|
+
# then B and C is the items which this class manage.
|
124
|
+
# A is discarded from record.
|
125
|
+
def self.load_file(filename)
|
126
|
+
weights = YAML.load_file(filename)
|
127
|
+
self.new(weights)
|
146
128
|
end
|
147
|
-
|
148
|
-
|
149
|
-
|
150
|
-
@tree.names_weights
|
151
|
-
end
|
152
|
-
|
153
|
-
def names
|
154
|
-
names_weights.keys
|
155
|
-
end
|
156
|
-
|
157
|
-
# 乱数を利用して優先度で重み付けして要素を選び、要素を返す。
|
158
|
-
# num is only for test. User should not use this argument.
|
159
|
-
def pick
|
160
|
-
@tree.pick
|
161
|
-
end
|
162
|
-
|
163
|
-
#重みを重くする。(優先度が上がる)
|
164
|
-
def weigh(item)
|
165
|
-
@tree.weigh(item)
|
166
|
-
end
|
167
|
-
|
168
|
-
#重みを軽くする。(優先度が下がる)
|
169
|
-
def lighten(item)
|
170
|
-
@tree.lighten(item)
|
171
|
-
end
|
172
|
-
|
173
|
-
# 引数 keys で示したものと、
|
174
|
-
# 内部的に管理しているデータが整合しているかチェックし、
|
175
|
-
# keys に合わせる。
|
176
|
-
# 追加されたデータの重みは、データ内に存在する最大値と
|
177
|
-
# 同じになる。
|
178
|
-
# This affects destructively.
|
179
|
-
def merge(keys)
|
180
|
-
new_weights = {}
|
181
|
-
new_keys = []
|
182
|
-
max = 0
|
183
|
-
data = @tree.names_weights
|
184
|
-
keys.each do |key|
|
185
|
-
new_weights[key] = data[key]
|
186
|
-
|
187
|
-
if data[key] == nil
|
188
|
-
#substitute max among exist values afterward
|
189
|
-
new_keys << key unless data[key]
|
190
|
-
next
|
191
|
-
end
|
192
|
-
|
193
|
-
max = data[key] if max < data[key]
|
129
|
+
|
130
|
+
def dump(io)
|
131
|
+
YAML.dump(@tree.names_weights, io)
|
194
132
|
end
|
195
133
|
|
196
|
-
|
197
|
-
|
198
|
-
|
134
|
+
def dump_histgram(io)
|
135
|
+
encounters = {}
|
136
|
+
names_weights.each do |key, weight|
|
137
|
+
#val_log2 = (Math::log(weight)/Math::log(2.0)).to_i
|
138
|
+
power = Math::log2(weight).ceil
|
139
|
+
encounters[power] ||= 0
|
140
|
+
encounters[power] += 1
|
141
|
+
end
|
142
|
+
max = encounters.values.max
|
143
|
+
0.upto(16) do |power|
|
144
|
+
num = encounters[power] || 0
|
145
|
+
stars = "*" * (HISTGRAM_WIDTH.to_f * num / max).ceil
|
146
|
+
io.printf("%6d(%4d)|#{stars}\n", 2**power, num)
|
147
|
+
end
|
199
148
|
end
|
200
149
|
|
201
|
-
|
150
|
+
def names_weights
|
151
|
+
@tree.names_weights
|
152
|
+
end
|
153
|
+
|
154
|
+
def names
|
155
|
+
names_weights.keys
|
156
|
+
end
|
202
157
|
|
203
|
-
|
204
|
-
|
158
|
+
# 乱数を利用して優先度で重み付けして要素を選び、要素を返す。
|
159
|
+
# num is only for test. User should not use this argument.
|
160
|
+
def pick
|
161
|
+
@tree.pick
|
162
|
+
end
|
163
|
+
|
164
|
+
#重みを重くする。(優先度が上がる)
|
165
|
+
def weigh(item)
|
166
|
+
@tree.weigh(item)
|
167
|
+
end
|
168
|
+
|
169
|
+
#重みを軽くする。(優先度が下がる)
|
170
|
+
def lighten(item)
|
171
|
+
@tree.lighten(item)
|
172
|
+
end
|
173
|
+
|
174
|
+
# 引数 keys で示したものと、
|
175
|
+
# 内部的に管理しているデータが整合しているかチェックし、
|
176
|
+
# keys に合わせる。
|
177
|
+
# 追加されたデータの重みは、データ内に存在する最大値と
|
178
|
+
# 同じになる。
|
179
|
+
# This affects destructively.
|
180
|
+
def merge(keys)
|
181
|
+
new_weights = {}
|
182
|
+
new_keys = []
|
183
|
+
max = 0
|
184
|
+
data = @tree.names_weights
|
185
|
+
keys.each do |key|
|
186
|
+
new_weights[key] = data[key]
|
187
|
+
|
188
|
+
if data[key] == nil
|
189
|
+
#substitute max among exist values afterward
|
190
|
+
new_keys << key unless data[key]
|
191
|
+
next
|
192
|
+
end
|
193
|
+
|
194
|
+
max = data[key] if max < data[key]
|
195
|
+
end
|
196
|
+
|
197
|
+
max = INI_WEIGHT if max < INI_WEIGHT
|
198
|
+
new_keys.each do |key|
|
199
|
+
new_weights[key] = max
|
200
|
+
end
|
201
|
+
|
202
|
+
data = new_weights
|
203
|
+
|
204
|
+
@tree = WeightedPicker::Tree.new(data)
|
205
|
+
end
|
206
|
+
|
207
|
+
def total_weight
|
208
|
+
@tree.total_weight
|
209
|
+
end
|
205
210
|
|
206
|
-
|
211
|
+
private
|
207
212
|
|
208
|
-
|
209
|
-
|
210
|
-
|
211
|
-
|
213
|
+
def sanity_data(data)
|
214
|
+
data.each do |key, val|
|
215
|
+
data[key] = 0 if val < MIN_WEIGHT
|
216
|
+
data[key] = MAX_WEIGHT if MAX_WEIGHT < val
|
212
217
|
|
213
|
-
|
218
|
+
raise InvalidWeightError, "#{val.inspect}, not integer." unless val.is_a? Integer
|
219
|
+
end
|
220
|
+
data
|
214
221
|
end
|
215
|
-
data
|
216
|
-
end
|
217
222
|
|
218
223
|
end
|
data/lib/weightedpicker/tree.rb
CHANGED
@@ -6,118 +6,122 @@
|
|
6
6
|
#
|
7
7
|
class WeightedPicker::Tree
|
8
8
|
|
9
|
-
|
9
|
+
attr_reader :size
|
10
10
|
|
11
|
-
|
11
|
+
class NoEntryError < Exception; end
|
12
12
|
|
13
|
-
|
14
|
-
|
15
|
-
|
13
|
+
#
|
14
|
+
def initialize(data)
|
15
|
+
@size = data.size #for return hash.
|
16
16
|
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
@names = data.keys
|
18
|
+
@weights = []
|
19
|
+
@weights[0] = data.values
|
20
20
|
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
21
|
+
#Fill 0 to 2**n
|
22
|
+
@size.upto ((2 ** depth) - 1) do |i|
|
23
|
+
@weights[0][i] = 0
|
24
|
+
end
|
25
|
+
|
26
|
+
depth.times do |i|
|
27
|
+
@weights[i+1] = []
|
28
|
+
num = @weights[i].size
|
29
|
+
(num - num / 2).times do |j|
|
30
|
+
@weights[i+1] << @weights[i][2*j] + @weights[i][2*j + 1]
|
31
|
+
end
|
32
|
+
end
|
25
33
|
|
26
|
-
|
27
|
-
@weights[i+1] = []
|
28
|
-
num = @weights[i].size
|
29
|
-
(num - num / 2).times do |j|
|
30
|
-
@weights[i+1] << @weights[i][2*j] + @weights[i][2*j + 1]
|
31
|
-
end
|
34
|
+
@weights.reverse!
|
32
35
|
end
|
33
36
|
|
34
|
-
|
35
|
-
|
37
|
+
# Return internal data as a Hash.
|
38
|
+
def names_weights
|
39
|
+
results = {}
|
40
|
+
@size.times do |i|
|
41
|
+
results[@names[i]] = @weights[-1][i]
|
42
|
+
end
|
43
|
+
results
|
44
|
+
end
|
36
45
|
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
46
|
+
def pick
|
47
|
+
raise NoEntryError if @weights[0][0] == 0
|
48
|
+
|
49
|
+
current_index = 0
|
50
|
+
depth.times do |i|
|
51
|
+
next_id0 = 2 * current_index
|
52
|
+
next_id1 = 2 * current_index + 1
|
53
|
+
#puts
|
54
|
+
choise = choose( @weights[i+1][next_id0], @weights[i+1][next_id1])
|
55
|
+
current_index = 2 * current_index + choise
|
56
|
+
end
|
57
|
+
return @names[current_index]
|
42
58
|
end
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
current_index = 2 * current_index + choise
|
59
|
+
|
60
|
+
def weigh(item)
|
61
|
+
raise NoEntryError unless @names.include?(item)
|
62
|
+
id = index(item)
|
63
|
+
old_weight = @weights[-1][id]
|
64
|
+
if (WeightedPicker::MAX_WEIGHT < old_weight * 2)
|
65
|
+
add_weight = WeightedPicker::MAX_WEIGHT - old_weight
|
66
|
+
else
|
67
|
+
add_weight = old_weight
|
68
|
+
end
|
69
|
+
return if add_weight == 0
|
70
|
+
add_ancestors(id, add_weight)
|
56
71
|
end
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
72
|
+
|
73
|
+
def lighten(item)
|
74
|
+
raise NoEntryError unless @names.include?(item)
|
75
|
+
id = index(item)
|
76
|
+
old_weight = @weights[-1][id]
|
77
|
+
if (old_weight / 2 < WeightedPicker::MIN_WEIGHT)
|
78
|
+
add_weight = 0
|
79
|
+
else
|
80
|
+
add_weight = - old_weight / 2
|
81
|
+
end
|
82
|
+
return if add_weight == 0
|
83
|
+
add_ancestors(id, add_weight)
|
68
84
|
end
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
def lighten(item)
|
74
|
-
raise NoEntryError unless @names.include?(item)
|
75
|
-
id = index(item)
|
76
|
-
old_weight = @weights[-1][id]
|
77
|
-
if (old_weight / 2 < WeightedPicker::MIN_WEIGHT)
|
78
|
-
add_weight = 0
|
79
|
-
else
|
80
|
-
add_weight = - old_weight / 2
|
85
|
+
|
86
|
+
def total_weight
|
87
|
+
@weights[0][0]
|
81
88
|
end
|
82
|
-
return if add_weight == 0
|
83
|
-
add_ancestors(id, add_weight)
|
84
|
-
end
|
85
89
|
|
86
|
-
|
90
|
+
private
|
87
91
|
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
92
|
+
def add_ancestors(id, val)
|
93
|
+
(depth+1).times do |d|
|
94
|
+
divisor = 2 ** (depth - d)
|
95
|
+
x = id / divisor
|
96
|
+
@weights[d][x] += val
|
97
|
+
end
|
93
98
|
end
|
94
|
-
end
|
95
99
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
100
|
+
def log2_ceil(num)
|
101
|
+
result = 0
|
102
|
+
while (num > 1)
|
103
|
+
result += 1
|
104
|
+
num -= num/2
|
105
|
+
end
|
106
|
+
result
|
101
107
|
end
|
102
|
-
result
|
103
|
-
end
|
104
108
|
|
105
|
-
|
106
|
-
|
107
|
-
|
109
|
+
def depth
|
110
|
+
log2_ceil(@size)
|
111
|
+
end
|
108
112
|
|
109
|
-
|
110
|
-
|
113
|
+
def choose(num0, num1)
|
114
|
+
sum = num0 + num1
|
111
115
|
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
+
# 0, 1, 2
|
117
|
+
return 0 if rand(sum) < num0
|
118
|
+
return 1
|
119
|
+
end
|
116
120
|
|
117
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
+
def index(item)
|
122
|
+
return @names.index(item)
|
123
|
+
#raise WeightedPicker::Tree::NoEntryError
|
124
|
+
end
|
121
125
|
|
122
126
|
end
|
123
127
|
|