skiftet_statistical 0.1.0 → 0.4.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +346 -2
  3. data/README.md +1618 -22
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
  6. data/lib/skiftet_statistical/bandit/checks.rb +128 -0
  7. data/lib/skiftet_statistical/bandit/decay.rb +86 -0
  8. data/lib/skiftet_statistical/bandit/draw.rb +179 -0
  9. data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
  10. data/lib/skiftet_statistical/bandit/pass.rb +94 -0
  11. data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
  12. data/lib/skiftet_statistical/bandit/prior.rb +264 -0
  13. data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
  14. data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
  15. data/lib/skiftet_statistical/bandit/summary.rb +152 -0
  16. data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
  17. data/lib/skiftet_statistical/bandit/weighted_posterior.rb +65 -0
  18. data/lib/skiftet_statistical/bandit/win_hold.rb +86 -0
  19. data/lib/skiftet_statistical/bandit.rb +3 -3
  20. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  21. data/lib/skiftet_statistical/beta_function.rb +153 -0
  22. data/lib/skiftet_statistical/cholesky.rb +244 -0
  23. data/lib/skiftet_statistical/cusum.rb +183 -0
  24. data/lib/skiftet_statistical/descriptive.rb +3 -3
  25. data/lib/skiftet_statistical/gamma.rb +97 -0
  26. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  27. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  28. data/lib/skiftet_statistical/inputs.rb +214 -0
  29. data/lib/skiftet_statistical/joint_beta.rb +63 -0
  30. data/lib/skiftet_statistical/linear_value.rb +364 -0
  31. data/lib/skiftet_statistical/numbers.rb +98 -0
  32. data/lib/skiftet_statistical/prequential.rb +206 -0
  33. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  34. data/lib/skiftet_statistical/significance.rb +65 -5
  35. data/lib/skiftet_statistical/version.rb +1 -1
  36. data/lib/skiftet_statistical.rb +57 -5
  37. metadata +43 -11
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+ require_relative "checks"
6
+
7
+ module SkiftetStatistical
8
+ class Bandit
9
+ # One allocation pass with a hold on its win: probe, then crown on the same draws only when the hold allows. The
10
+ # caller's block is the allocation (Allocator.call or Allocator.from_summary, or any rule answering a Hash); the
11
+ # pass decides how often it runs and with which flag, and hands it its randomness.
12
+ #
13
+ # hold = SkiftetStatistical::Bandit::WinHold.new(leading_arm: "a", leading_since: now - 90_000, now: now,
14
+ # last_pass_at: now - 600)
15
+ # SkiftetStatistical::Bandit::Pass.call(status: "running", hold: hold) do |hold_satisfied, rng|
16
+ # SkiftetStatistical::Bandit::Allocator.call(arms: %i[control a], tallies: tallies, status: "running",
17
+ # winner_arm: nil, previous_weights: weights, rng: rng,
18
+ # draws: 20_000, hold_satisfied: hold_satisfied)
19
+ # end
20
+ # # => the crown's answer (weights, phase: :won, stats, winner: :a, result_changed, leader: :a) and ready: true
21
+ #
22
+ # The rule:
23
+ # - the probe: the block is yielded (false, Random.new(seed)), so a leader gets the step, never the won split;
24
+ # - ready: the status reads "running" (a String by its bytes, anything else by its to_s, as the Allocator reads
25
+ # it, once, before the first yield: Inputs.text, never a String's own ==), the
26
+ # probe's Hash names a leader under :leader (nil for none; read by the bandit's one lookup in a caller's Hash,
27
+ # Checks.lookup, so a default the Hash holds is not read) and `hold.satisfied_for?(leader)` answers true; the
28
+ # hold is asked only then, once, about that leader;
29
+ # - the crown: only when `crown` is true and the pass is ready, the block is yielded again (true, a fresh
30
+ # Random.new(seed)), so the probe and the crown read the same draws and only the decision changes;
31
+ # - the answer: the last yield's Hash merged with `ready:` (true or false) by Hash's own merge, nothing else. A paused or won status
32
+ # takes the block's own path at the probe and is never ready. The caller computes its next hold itself (for a
33
+ # WinHold, `after_pass` with the answer's leader).
34
+ #
35
+ # With `crown: false` a ready leader keeps the probe's answer (its step toward its P(best)), and `ready:` says it
36
+ # would have been crowned, so the caller can crown it by its own hand.
37
+ #
38
+ # `seed` defaults to Random.new_seed, drawn once a pass; a given seed is honoured, so a pass is repeatable. The
39
+ # checks (ArgumentError, before the first yield): a block is given, a status that is no String answers to_s
40
+ # without raising (the library's one call into a caller's object, Inputs.asked), the hold answers
41
+ # `satisfied_for?` and the seed is an
42
+ # Integer within a Float's range (the bandit's one reading of a whole number, Checks.integer, as `top` and the
43
+ # odds' `draws` and `seed` are read); each yield must answer a Hash (ArgumentError otherwise), checked when it is
44
+ # answered. The block and the hold's satisfied_for? are the caller's own rules: what they raise reaches the caller
45
+ # as raised.
46
+ module Pass
47
+ class << self
48
+ # The probe, then the crown when `crown` and ready: the last yield's Hash plus { ready: true | false }.
49
+ def call(status:, hold:, crown: true, seed: Random.new_seed, &allocation)
50
+ running = check(status, hold, seed, allocation)
51
+ probe = run(allocation, false, seed)
52
+ ready = ready?(running, Checks.lookup(probe, :leader, "the block's Hash"), hold)
53
+ answer = crown && ready ? run(allocation, true, seed) : probe
54
+ Inputs.merged(answer, { ready: ready })
55
+ end
56
+
57
+ private
58
+
59
+ # Ready: a running status, a probe leader, and the hold satisfied for that leader (asked only then).
60
+ def ready?(running, leader, hold)
61
+ return false unless running && !nil.equal?(leader)
62
+
63
+ hold.satisfied_for?(leader) ? true : false
64
+ end
65
+
66
+ # One yield, with a fresh Random.new(seed): the block's Hash.
67
+ def run(allocation, hold_satisfied, seed)
68
+ answer = allocation.call(hold_satisfied, Random.new(seed))
69
+ return answer if Hash === answer
70
+
71
+ raise ArgumentError, "a pass's block must answer a Hash (got #{Inputs.class_name(answer)})"
72
+ end
73
+
74
+ # The checks, before the first yield; answers whether the status reads "running": the library's one reading of
75
+ # a value as text (Inputs.text: a String status read by its bytes and encoding alone, by Inputs.string; any
76
+ # other by its to_s, asked by the library's one call into a caller's object, so a to_s that raises is an
77
+ # ArgumentError, its String answer read the same way), whose copy is the String "running" (so a String's own
78
+ # == or any other of its methods is never called).
79
+ def check(status, hold, seed, allocation)
80
+ raise ArgumentError, "a pass needs a block: the allocation it runs" if allocation.nil?
81
+ unless Inputs.responds?(status, :to_s, "a status")
82
+ raise ArgumentError, "a status must answer to_s (got #{Inputs.shown(status)})"
83
+ end
84
+ text = Inputs.text(status, "a status")
85
+ unless Inputs.responds?(hold, :satisfied_for?, "a hold")
86
+ raise ArgumentError, "a hold must answer satisfied_for? (got #{Inputs.class_name(hold)})"
87
+ end
88
+ Checks.integer(seed, "a seed")
89
+ !nil.equal?(text) && text == "running"
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,497 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+ require_relative "../sampler"
6
+ require_relative "../beta_argmax"
7
+ require_relative "checks"
8
+ require_relative "tallies"
9
+ require_relative "prior"
10
+ require_relative "draw"
11
+ require_relative "normal_argmax"
12
+
13
+ module SkiftetStatistical
14
+ class Bandit
15
+ # A posterior set: items (non-blank Strings, in input order, which is the tie order everywhere), each with a
16
+ # posterior of one kind and its views. Yes/no tallies become Beta pairs [alpha, beta] (`yes_no`), amount tallies
17
+ # Normal pairs [mean, standard deviation] for the mean per view (`amounts`), and pairs may be given as they are
18
+ # (`beta`, `normal`). Any number of items, none included; only the exact odds have limits (EXACT_PICK_MAX,
19
+ # EXACT_ORDER_MAX).
20
+ #
21
+ # posteriors = SkiftetStatistical::Bandit::Posteriors
22
+ # set = posteriors.yes_no({ "a" => { views: 40, outcomes: 8 }, "b" => { views: 10, outcomes: 1 } })
23
+ # set.parameters # => { "a" => [9.0, 33.0], "b" => [2.0, 10.0] }
24
+ # set.greedy # => "a", the largest posterior mean
25
+ # set.values(rng: Random.new(1)) # => one posterior draw per item, in order
26
+ #
27
+ # A tally is a Hash with Symbol or String keys, read by the bandit's one tally reader (Tallies.read), each sum by
28
+ # the library's one number reader (SkiftetStatistical::Numbers: a finite real number of magnitude at most
29
+ # Numbers::LARGEST; a String, a numeric one included, is not a number); a nil tally or a missing sum reads 0,
30
+ # whatever default the Hash holds (Checks.named reads a tally by its pairs, never by a default), and a tally that
31
+ # compares its keys by identity reads as a plain one.
32
+ # Yes/no: `views` and `outcomes` (>= 0, fractional allowed, outcomes clamped to the views), optionally
33
+ # `weighted_views` (sum 1/p), `weighted_outcomes` (sum y/p) and `weighted_views_squared` (sum 1/p^2), each >= 0. The
34
+ # tally is weighted when weighted_views is above 0 and weighted_outcomes is given: the weighted outcomes are
35
+ # rescaled to n, the Kish size (sum w)^2 / sum w^2 (never above the views) when the squared sum is above 0, else the
36
+ # views. Amounts: `views`, `outcomes` (the sum of amounts, any sign) and `outcome_squares`, optionally the weighted
37
+ # sums and `weighted_outcome_squares` (sum x^2/p), weighted only when all three weighted sums are given and
38
+ # weighted_views is above 0. A set's `views` are each tally's own views; the Kish size never replaces them.
39
+ #
40
+ # Priors: `yes_no` takes nil (Beta(1, 1)), one pair [alpha, beta] (each a number >= 0) for every item, or a Hash
41
+ # naming each item's pair; `amounts` takes one Prior::Amount or a Hash naming each item's (there is no flat amounts
42
+ # prior, so nil and a pair are refused), each Amount's numbers read again by its own checks (Tallies.amount_prior).
43
+ # A Hash of priors or views naming a name that is not an item is refused; a Hash of priors must name every item; an
44
+ # item missing from `views` reads 0. Such a Hash, and the given uniforms' priority and draw, is read by its pairs,
45
+ # each key mapped to its item (`item_values`), so one that compares its keys by identity reads as a plain one, and
46
+ # two keys naming one item are refused.
47
+ #
48
+ # Limits, checked when the set is built: every pair is read by the library's one reading of a pair (Numbers.pair),
49
+ # so every parameter, given or computed from a tally, is a number in the input domain (Numbers::LARGEST either side
50
+ # of 0); a Beta pair's two parameters are above 0; a Normal's standard deviation is above 0 (its mean any number in
51
+ # the domain). A pair a tally and a prior compute passes the bandit's one check of an answer first
52
+ # (Checks.finite_answer, naming the item's tally and prior). A Beta pair whose total is above LARGEST_TOTAL is
53
+ # scaled down to it, its mean kept, in `parameters`, the one pair every draw and every odds call reads: the
54
+ # quantile draws and the RNG draws (`values` and the RNG source of `rank` and `pick`), every odds call (exact or by
55
+ # its stated Monte Carlo), `summary`'s P(best) and loss, and `greedy`; the pairs as given feed `summary`'s means and
56
+ # intervals alone. A scaled pair never sums above LARGEST_TOTAL, so a set built from another set's `parameters`
57
+ # keeps them bit for bit. A parameter above 0 but below BetaArgmax::SMALLEST_PARAMETER builds: each computation
58
+ # that cannot read it refuses it on the pairs it reads (the quantile draws and the odds, exact or counted, on
59
+ # `parameters`, `summary`'s interval on the pair as given), and the RNG draws take it. An item name that is not a
60
+ # String (a Symbol, say), is blank, is given twice, holds the same bytes as an earlier name (in another encoding)
61
+ # or holds a NUL byte (the byte a keyed draw joins its parts with) is an ArgumentError, as is anything else a
62
+ # builder cannot read: a keyed draw reads a name by its bytes, so every item's bytes are its own.
63
+ #
64
+ # For the files that reopen this class, private: `given_parameters` ({ item => the pair as given }),
65
+ # `mean_as_given(item)` (the posterior mean on the pair as given), `sampler_draw(sampler, item)` (one draw of the
66
+ # item from a Sampler on its scaled pair, as [the key the order rule compares it by, the value it answers]),
67
+ # `normal_draw(mean, deviation, z)` (THE Normal draw at a standard deviate z, [its exact key, its value], for every
68
+ # source), `mean_of(pair)` (the posterior mean of a pair: alpha / (alpha + beta), or the Normal's mean),
69
+ # `contenders(may_lead, free)` (the items that may lead), `listed_items(names, label, free, none)` (the one reading
70
+ # of a list of item names, each list read once: [the items of `free` it names, or `none`; the items it read], which
71
+ # `contenders`, the order rule's `may_lead` and `pinned`, and `summary` share), `indexed_item(index, name, label)`
72
+ # (the one refusal of a name that is not an item, on the one lookup `item_position(index, name, label)`, both mixed
73
+ # into the class and its instances, as is `item_values(given, items, index, label, strangers:)`, the one reading of
74
+ # a caller's Hash keyed by item names into a Hash keyed by the set's own Strings), `eligible?(item, min_views)` (the
75
+ # one eligibility test), `largest_draw(among, maximum:)` (the one exact P(largest), and E[max], by kind on the
76
+ # scaled pairs), `checked_sampler(rng)` and `refuse_below_smallest_parameter(among, pairs:, computing:)` (GB6's one
77
+ # refusal, before anything is computed, over `among` on the pairs the computation reads, `parameters` unless told,
78
+ # its words naming what is computed), and the @item_index ivar ({ item => index }).
79
+ class Posteriors
80
+ # THE reading of item names a caller gives, written once for both readers of them: a Hash keyed by item names
81
+ # (`item_values`: a builder's priors or views, before the set is built, and the given uniforms' priority and
82
+ # draw) and a set's list of names (`listed_items`: the rule's `may_lead` and `pinned`, `summary`'s `among`), each
83
+ # name looked up among the items by one lookup (`item_position`) and refused by one refusal (`indexed_item`).
84
+ # Mixed into the class and its instances alike, so neither reaches into the other's private methods.
85
+ module ItemNames
86
+ private
87
+
88
+ # index[name] when the Hash `index`, keyed by the items, holds the name; else an ArgumentError, "<label>:
89
+ # <name> is not an item". The one refusal of a name that is not an item, on the one lookup (`item_position`).
90
+ def indexed_item(index, name, label)
91
+ found = item_position(index, name, label)
92
+ return found unless found.nil?
93
+
94
+ raise ArgumentError, "#{label}: #{Inputs.shown(name)} is not an item"
95
+ end
96
+
97
+ # index[name] when the Hash `index`, keyed by the items, holds the name; else nil. THE lookup of a caller's
98
+ # name among the items, written once. A String name is read by the library's one reading of a caller's String
99
+ # (Inputs.string) and its copy looked up, so none of its own methods is called. Any other name is looked up
100
+ # as given when it can be (the library's one reading, Inputs.name?: it answers hash, eql? and inspect, its hash
101
+ # an Integer; a BasicObject, say, cannot, and the items are Strings, so no such name is one), the lookup calling
102
+ # its hash and eql?, one call into the caller's object (the library's one such call, Inputs.asked): a name whose
103
+ # hash or eql? raises is an ArgumentError naming `label`.
104
+ def item_position(index, name, label)
105
+ copy = Inputs.string(name)
106
+ return index[copy] unless nil.equal?(copy)
107
+
108
+ Inputs.name?(name, label) ? Inputs.asked(name, "#{label}: a name's lookup") { index[name] } : nil
109
+ end
110
+
111
+ # THE reading of a caller's Hash keyed by item names, written once (a builder's priors and views, the given
112
+ # uniforms' priority and draw): { item => value }, a Hash the library builds, keyed by the set's own item
113
+ # Strings (`items`, at the positions `index` holds), from the caller's pairs (Inputs.pairs: Hash's own to_a),
114
+ # each key mapped to its item by the one lookup (`item_position`). Every value is then looked up in this Hash,
115
+ # never in the caller's by a key of the library's, so a Hash that compares its keys by identity
116
+ # (compare_by_identity) reads exactly as a plain one, and a key is read by one reading wherever it is checked
117
+ # and looked up. A key that names no item is refused by the one refusal (`indexed_item`), or, with `strangers:
118
+ # :skip`, not read; two keys that name one item (a Hash that compares by identity, or a key whose own hash
119
+ # differs from its String's, can hold both) are an ArgumentError, "<label> names the item <item> twice".
120
+ def item_values(given, items, index, label, strangers: :refuse)
121
+ read = {}
122
+ Inputs.pairs(given).each do |name, value|
123
+ position = strangers == :skip ? item_position(index, name, label) : indexed_item(index, name, label)
124
+ next if position.nil?
125
+
126
+ item = items[position]
127
+ raise ArgumentError, "#{label} names the item #{item.inspect} twice" if read.key?(item)
128
+
129
+ read[item] = value
130
+ end
131
+ read
132
+ end
133
+ end
134
+ private_constant :ItemNames
135
+ include ItemNames
136
+ extend ItemNames
137
+
138
+ # The most contenders whose pick odds are computed exactly.
139
+ EXACT_PICK_MAX = 20
140
+ # The most free positions whose order and position odds are computed exactly.
141
+ EXACT_ORDER_MAX = 10
142
+ # A Beta pair whose total is above this is scaled down to it, its mean kept, when the set is built.
143
+ LARGEST_TOTAL = 1_000_000.0
144
+
145
+ YES_NO_SUMS = %i[views outcomes weighted_views weighted_outcomes weighted_views_squared].freeze
146
+ AMOUNT_SUMS = %i[views outcomes outcome_squares weighted_views weighted_outcomes weighted_outcome_squares
147
+ weighted_views_squared].freeze
148
+ # The names of a pair's two numbers, by kind.
149
+ PAIR_NAMES = { beta: %w[alpha beta].freeze, normal: [ "mean", "standard deviation" ].freeze }.freeze
150
+ # Half of LARGEST_TOTAL, against which half a pair's total is judged (half of each parameter, summed).
151
+ HALF_LARGEST_TOTAL = LARGEST_TOTAL * 0.5
152
+ # The smallest Float above 0: a scaled parameter never reads less, so a scaled pair stays above 0.
153
+ SMALLEST_SCALED_PARAMETER = 0.0.next_float
154
+ private_constant :YES_NO_SUMS, :AMOUNT_SUMS, :PAIR_NAMES, :HALF_LARGEST_TOTAL, :SMALLEST_SCALED_PARAMETER
155
+
156
+ # items: [String], in input order; kind: :beta or :normal; parameters: { item => pair }, a Beta pair above
157
+ # LARGEST_TOTAL scaled to it; views: { item => Float }. All frozen.
158
+ attr_reader :items, :kind, :parameters, :views
159
+
160
+ class << self
161
+ # Beta posteriors from yes/no tallies, { item => tally }, under `priors` (nil, one pair, or { item => pair }).
162
+ def yes_no(items, priors: nil)
163
+ entries = named_entries(items, "items")
164
+ prior_of = prior_per_item(nil.equal?(priors) ? Prior.flat : priors, entries, "priors") do |given, label|
165
+ prior_pair(given, label)
166
+ end
167
+ views = {}
168
+ pairs = entries.to_h do |item, tally|
169
+ label = "item #{item.inspect}'s tally"
170
+ sums = Tallies.read(tally, label, YES_NO_SUMS)
171
+ weighted, squared = Tallies.counted(sums[:weighted_views], [ sums[:weighted_outcomes] ],
172
+ sums[:weighted_views_squared])
173
+ views[item] = sums[:views] || 0.0
174
+ pair = Tallies.yes_no(views: views[item], outcomes: sums[:outcomes] || 0.0,
175
+ weighted_views: weighted ? sums[:weighted_views] : nil,
176
+ weighted_outcomes: weighted ? sums[:weighted_outcomes] : nil,
177
+ weighted_views_squared: squared ? sums[:weighted_views_squared] : nil,
178
+ prior: prior_of.fetch(item))
179
+ [ item, Checks.finite_answer(pair, "item #{item.inspect}'s tally and prior") ]
180
+ end
181
+ new(:beta, pairs, views)
182
+ end
183
+
184
+ # Normal posteriors for the mean per view from amount tallies, { item => tally }, under `priors` (one
185
+ # Prior::Amount, or { item => Prior::Amount }).
186
+ def amounts(items, priors:)
187
+ entries = named_entries(items, "items")
188
+ prior_of = prior_per_item(priors, entries, "priors") { |given, label| Tallies.amount_prior(given, label) }
189
+ views = {}
190
+ pairs = entries.to_h do |item, tally|
191
+ sums = Tallies.read(tally, "item #{item.inspect}'s tally", AMOUNT_SUMS, amounts: true)
192
+ weighted, squared = Tallies.counted(sums[:weighted_views],
193
+ [ sums[:weighted_outcomes], sums[:weighted_outcome_squares] ],
194
+ sums[:weighted_views_squared])
195
+ views[item] = sums[:views] || 0.0
196
+ pair = Tallies.amounts(views: views[item], outcomes: sums[:outcomes] || 0.0,
197
+ outcome_squares: sums[:outcome_squares] || 0.0,
198
+ weighted_views: weighted ? sums[:weighted_views] : nil,
199
+ weighted_outcomes: weighted ? sums[:weighted_outcomes] : nil,
200
+ weighted_outcome_squares: weighted ? sums[:weighted_outcome_squares] : nil,
201
+ weighted_views_squared: squared ? sums[:weighted_views_squared] : nil,
202
+ prior: prior_of.fetch(item))
203
+ [ item, Checks.finite_answer(pair, "item #{item.inspect}'s tally and prior") ]
204
+ end
205
+ new(:normal, pairs, views)
206
+ end
207
+
208
+ # Beta posteriors given as pairs, { item => [alpha, beta] }, with views { item => Float } when given.
209
+ def beta(pairs, views: nil)
210
+ from_pairs(:beta, pairs, views)
211
+ end
212
+
213
+ # Normal posteriors given as pairs, { item => [mean, standard_deviation] }, with views when given.
214
+ def normal(pairs, views: nil)
215
+ from_pairs(:normal, pairs, views)
216
+ end
217
+
218
+ private
219
+
220
+ def from_pairs(kind, pairs, views)
221
+ entries = named_entries(pairs, "pairs")
222
+ read = entries.to_h { |item, pair| [ item, pair_numbers(kind, pair, "item #{item.inspect}") ] }
223
+ new(kind, read, given_views(views, entries))
224
+ end
225
+
226
+ # [[item, value], ...] in input order, each item name checked: a String, holding no NUL byte, not blank, not
227
+ # given twice, and holding bytes no earlier name holds (a keyed draw reads a name by its bytes, so two names of
228
+ # one byte string in different encodings would draw alike). Answers frozen names.
229
+ def named_entries(entries, label)
230
+ unless Hash === entries
231
+ raise ArgumentError, "#{label} must be a Hash of item => value (got #{Inputs.class_name(entries)})"
232
+ end
233
+
234
+ by_bytes = {}
235
+ Inputs.pairs(entries).map do |name, value|
236
+ item = checked_item_name(name)
237
+ earlier = by_bytes[item.b]
238
+ refuse_same_bytes(earlier, item) unless earlier.nil?
239
+
240
+ by_bytes[item.b] = item
241
+ [ item, value ]
242
+ end
243
+ end
244
+
245
+ def refuse_same_bytes(earlier, item)
246
+ raise ArgumentError, "the item #{item.inspect} is given twice" if earlier == item
247
+
248
+ raise ArgumentError, "the items #{earlier.inspect} (#{earlier.encoding}) and #{item.inspect} " \
249
+ "(#{item.encoding}) hold the same bytes, which a keyed draw cannot tell apart"
250
+ end
251
+
252
+ # The name as a frozen plain String, read by the library's one reading of a caller's String (Inputs.string: a
253
+ # copy by String's own b and encoding, so none of the name's own methods is called, a subclass's or a singleton
254
+ # method's), when it holds no NUL byte and is not blank.
255
+ def checked_item_name(name)
256
+ copy = Inputs.string(name)
257
+ raise ArgumentError, "an item name must be a String (got #{Inputs.shown(name)})" if nil.equal?(copy)
258
+
259
+ if copy.b.include?(Draw::SEPARATOR)
260
+ raise ArgumentError, "an item name must not hold a NUL byte, which a keyed draw joins its parts with " \
261
+ "(got #{copy.inspect})"
262
+ end
263
+ raise ArgumentError, "an item name must not be blank (got #{copy.inspect})" if blank_name?(copy)
264
+
265
+ copy
266
+ end
267
+
268
+ # Empty or white space only (Unicode white space in a readable text, ASCII white space in any other bytes).
269
+ def blank_name?(name)
270
+ text = name.encoding.ascii_compatible? && name.valid_encoding? ? name : name.b
271
+ text.match?(/\A[[:space:]]*\z/)
272
+ end
273
+
274
+ # { item => prior }: one prior for every item (read once), or a Hash naming every item's and nothing else, read
275
+ # by the one reading of a caller's Hash keyed by item names (`item_values`).
276
+ def prior_per_item(given, entries, label)
277
+ items = entries.map(&:first)
278
+ unless Hash === given
279
+ shared = yield(given, label)
280
+ return items.to_h { |item| [ item, shared ] }
281
+ end
282
+
283
+ named = item_values(given, items, items.each_with_index.to_h, label)
284
+ missing = items.find { |item| !named.key?(item) }
285
+ raise ArgumentError, "#{label} names no prior for the item #{missing.inspect}" unless missing.nil?
286
+
287
+ items.to_h { |item| [ item, yield(named.fetch(item), "#{label}[#{item.inspect}]") ] }
288
+ end
289
+
290
+ # A yes/no prior, by the library's one reading of a pair (Numbers.pair): two numbers, each >= 0 (a 0 is taken
291
+ # here, as WeightedPosterior.beta takes it; Prior's own pairs are above 0).
292
+ def prior_pair(given, label)
293
+ Numbers.pair(given, label, PAIR_NAMES.fetch(:beta)) { |number, name| Checks.nonnegative(number, name) }
294
+ end
295
+
296
+ # A pair's two numbers as Floats, by the library's one reading of a pair (Numbers.pair), named by kind.
297
+ def pair_numbers(kind, pair, label)
298
+ Numbers.pair(pair, label, PAIR_NAMES.fetch(kind))
299
+ end
300
+
301
+ # { item => Float }: nil reads 0 for every item; a Hash names only items, each views >= 0, an item it does not
302
+ # name reading 0 (read by the one reading of a caller's Hash keyed by item names, `item_values`, from its pairs,
303
+ # so a default the Hash holds is never read).
304
+ def given_views(views, entries)
305
+ items = entries.map(&:first)
306
+ return items.to_h { |item| [ item, 0.0 ] } if nil.equal?(views)
307
+ raise ArgumentError, "views must be a Hash of item => views (got #{Inputs.class_name(views)})" unless Hash === views
308
+
309
+ named = item_values(views, items, items.each_with_index.to_h, "views")
310
+ items.to_h do |item|
311
+ given = named.fetch(item, nil)
312
+ next [ item, 0.0 ] if nil.equal?(given)
313
+
314
+ [ item, Checks.nonnegative(given, "item #{item.inspect}'s views") ]
315
+ end
316
+ end
317
+ end
318
+ private_class_method :new
319
+
320
+ # kind :beta or :normal; given { item => [Float, Float] } with checked names, in input order; views
321
+ # { item => Float }. Each pair is checked here, once, and a Beta pair above LARGEST_TOTAL scaled.
322
+ def initialize(kind, given, views)
323
+ @kind = kind
324
+ @given_parameters = given.to_h { |item, pair| [ item, checked_pair(item, pair).freeze ] }.freeze
325
+ @parameters = @given_parameters.transform_values { |pair| scaled_to_largest_total(pair) }.freeze
326
+ @items = @given_parameters.keys.freeze
327
+ @views = @items.to_h { |item| [ item, views.fetch(item) ] }.freeze
328
+ @item_index = @items.each_with_index.to_h.freeze
329
+ Checks.finite_answer(@given_parameters.values.flatten + @parameters.values.flatten + @views.values,
330
+ "the parameters and views")
331
+ end
332
+
333
+ # One posterior draw per item, in item order, on the scaled pairs (`parameters`, the pairs every draw and every
334
+ # odds call read): Sampler#beta, or mean + sd * Sampler#gaussian, from `rng` and nothing else from it. Takes any
335
+ # parameter above 0.
336
+ def values(rng:)
337
+ sampler = checked_sampler(rng)
338
+ drawn = @items.map { |item| sampler_draw(sampler, item).last }
339
+ Checks.finite_answer(drawn, "the parameters and the rng's answers")
340
+ end
341
+
342
+ # The contender with the largest posterior mean (ties to the earlier item); nil for an empty set. `may_lead`
343
+ # names the items that may lead (nil: all; when it names no item, all). The order rule's one choice of a first
344
+ # largest (`rank_first`, ranking.rb) on the means of the scaled pairs (`parameters`, the pairs the keyed draws
345
+ # and every odds call read, so a pick, its odds and the greedy item read one pair), so for Betas it is
346
+ # BetaArgmax.greedy's rule on `parameters`: the first largest alpha / (alpha + beta), by the same quotient and
347
+ # the same strict comparison, for a set of any size. Above LARGEST_TOTAL the scaled mean may differ from the
348
+ # mean as given by a rounding, so two means within a rounding may order differently on the two pairs.
349
+ def greedy(may_lead: nil)
350
+ candidates = contenders(may_lead)
351
+ rank_first(candidates, candidates.to_h { |item| [ item, mean_of(@parameters.fetch(item)) ] }, :>)
352
+ end
353
+
354
+ private
355
+
356
+ # { item => pair as given }, frozen; `parameters` differs from it only for a Beta pair above LARGEST_TOTAL.
357
+ attr_reader :given_parameters
358
+
359
+ # The posterior mean on the pair as given (`mean_of`).
360
+ def mean_as_given(item)
361
+ mean_of(@given_parameters.fetch(item))
362
+ end
363
+
364
+ # THE posterior mean of a pair, written once: alpha / (alpha + beta) (BetaArgmax.greedy's quotient; both
365
+ # parameters lie in the input domain, so the total never overflows), or the Normal's mean.
366
+ def mean_of(pair)
367
+ first, second = pair
368
+ @kind == :beta ? first / (first + second) : first
369
+ end
370
+
371
+ # THE draw of an item from `sampler`, written once for every RNG draw (`values`, the order rule's RNG source and
372
+ # the odds' Monte Carlo), on its scaled pair (`parameters`), as [the key the order rule compares it by, the value
373
+ # it answers]: a Beta Sampler#beta, its key its value; a Normal mean + sd * Sampler#gaussian (`normal_draw`).
374
+ def sampler_draw(sampler, item)
375
+ first, second = @parameters.fetch(item)
376
+ return normal_draw(first, second, sampler.gaussian) unless @kind == :beta
377
+
378
+ value = sampler.beta(first, second)
379
+ [ value, value ]
380
+ end
381
+
382
+ # THE Normal draw at the standard deviate z, written once for every source (a quantile draw's z is the standard
383
+ # quantile of its uniform, an RNG draw's Sampler#gaussian): [its key, its value]. The value is mean + sd * z
384
+ # rounded to a Float, the value a draw answers; the key is that sum exact, formed by the bandit's one exact
385
+ # arithmetic on Floats (Tallies.exact, Tallies.exact_sum: a Float is an exact multiple of a power of 2) and held
386
+ # as one Integer on a common power of 2 (Tallies.exact_key), so two draws compare by the exact sign of (mean_i -
387
+ # mean_j) + (sd_i z_i - sd_j z_j), never by their rounded values: where a standard deviation lies at or below a
388
+ # rounding of its mean, the values of two draws round together or out of order, while their keys order them as
389
+ # the posteriors do.
390
+ def normal_draw(mean, deviation, z)
391
+ exact = Tallies.exact_sum(Tallies.exact(mean), Tallies.exact(deviation, z))
392
+ [ Tallies.exact_key(exact), mean + (deviation * z) ]
393
+ end
394
+
395
+ # The items of `free` (in their order) that may lead: those `may_lead` names (nil names all); when it names none
396
+ # of them, all of `free`. A `may_lead` name that is not an item is an ArgumentError.
397
+ def contenders(may_lead, free = @items)
398
+ return free if nil.equal?(may_lead)
399
+
400
+ listed_items(may_lead, "may_lead", free, free).first
401
+ end
402
+
403
+ # THE reading of a list of item names, written once (`contenders`, the order rule's `may_lead` and `pinned`, and
404
+ # `summary`'s `among` read through it). Answers [chosen, read]: chosen the items of `free` (in their order) that
405
+ # the list names, read as a set (a name given twice counts once), or `none` when it names none of them, the one
406
+ # thing its readers differ in; read the items it read, as the set's own Strings in the list's order (a name given
407
+ # twice listed twice), in a frozen Array of the set's own. The list is read by the bandit's one list reader
408
+ # (Tallies.list: any Enumerable but a Hash, read once, by one pass of its each, so a list that answers
409
+ # differently on a second pass, a lazy list or a list whose to_a answers something else is read by that one
410
+ # pass), and only that Array is read and kept, never the caller's object. A `names` that is not a list, or a
411
+ # name that is not an item (the one refusal, `indexed_item`), is an ArgumentError naming `label`.
412
+ def listed_items(names, label, free, none)
413
+ read = Tallies.list(names, label, "items").map { |name| @items[indexed_item(@item_index, name, label)] }.freeze
414
+ named = read.to_h { |item| [ item, true ] }
415
+ chosen = free.select { |item| named.key?(item) }
416
+ [ chosen.empty? ? none : chosen, read ]
417
+ end
418
+
419
+ # THE eligibility test, written once for the order rule and the pick odds: the item has at least `min_views` views
420
+ # (it may take the explored head, and a head that has them leads).
421
+ def eligible?(item, min_views)
422
+ @views.fetch(item) >= min_views
423
+ end
424
+
425
+ # THE P(largest) by kind, written once for the pick odds, `summary` and the order odds: for the items of `among`
426
+ # (1 to EXACT_PICK_MAX, in its order) on the scaled pairs (`parameters`), [P(the item's draw is the largest) for
427
+ # each, and with `maximum: true` E[the largest draw], else nil], by BetaArgmax or NormalArgmax as the set's kind
428
+ # is :beta or :normal, each by quadrature within its rule's stated bound (BetaArgmax's TAIL a member,
429
+ # NormalArgmax's 2 k (k + 1) TAIL + 2e-12).
430
+ def largest_draw(among, maximum: false)
431
+ pairs = among.map { |item| @parameters.fetch(item) }
432
+ argmax = @kind == :beta ? BetaArgmax : NormalArgmax
433
+ [ argmax.probabilities(pairs), maximum ? argmax.expected_maximum(pairs) : nil ]
434
+ end
435
+
436
+ # A Sampler (0.3.0's, as written) on `rng`, which must answer rand (the library's one reading of an rng,
437
+ # Inputs.rng: Kernel#respond_to?, so an object that answers rand only through method_missing is refused), wrapped
438
+ # by Inputs.checked so that each of its answers is checked, as a Float in [0, 1), when it is drawn.
439
+ def checked_sampler(rng)
440
+ Sampler.new(Inputs.checked(rng))
441
+ end
442
+
443
+ # THE refusal of 01 GB6, written once for every computation that cannot read a Beta parameter below
444
+ # BetaArgmax::SMALLEST_PARAMETER, each refusing on the pairs it reads (`pairs`: `parameters` unless told), its
445
+ # words naming what it computes (`computing`): the keyed and given-uniform draws of `rank` and `pick` (whatever
446
+ # branch the uniforms choose, the shuffled one included) and every exact probability (the pick odds, the order
447
+ # and position odds, `summary`'s P(best) and loss) on the scaled pairs, over the free items (every item but the
448
+ # pinned ones, whichever of them may lead or are named, so one set is refused or taken alike by every such call
449
+ # under one pinning; for `summary`, every item whenever `among` names one); and `summary`'s interval on the
450
+ # pairs as given, over every item, on every path. An ArgumentError, before anything is computed, when an item of
451
+ # `among` holds a parameter below it there; a parameter at it is taken. A Normal set has no such bound, and the
452
+ # RNG draws and `values` never ask.
453
+ def refuse_below_smallest_parameter(among = @items, pairs: @parameters,
454
+ computing: "a quantile draw or an exact probability on the scaled pairs")
455
+ return unless @kind == :beta
456
+
457
+ smallest = BetaArgmax::SMALLEST_PARAMETER
458
+ among.each do |item|
459
+ pair = pairs.fetch(item)
460
+ next if pair.min >= smallest
461
+
462
+ raise ArgumentError, "item #{item.inspect}'s Beta parameters #{pair.inspect} must be at least #{smallest} " \
463
+ "for #{computing}"
464
+ end
465
+ end
466
+
467
+ # A pair as given or computed, checked by the library's one reading of a pair (Numbers.pair): numbers in the input
468
+ # domain, as Floats; a Beta's both above 0; a Normal's standard deviation above 0.
469
+ def checked_pair(item, pair)
470
+ Numbers.pair(pair, "item #{item.inspect}", PAIR_NAMES.fetch(@kind)) do |number, name, index|
471
+ @kind == :beta || index == 1 ? Checks.positive(number, name) : number
472
+ end
473
+ end
474
+
475
+ # A Beta pair whose total is above LARGEST_TOTAL, scaled down to it with its mean kept; every other pair as it
476
+ # is. Half the total (half of each parameter, summed) is judged against half of LARGEST_TOTAL, and the scale is
477
+ # LARGEST_TOTAL / total to the bit. The
478
+ # scaled pair never sums above LARGEST_TOTAL, so a set built from these parameters keeps them as they are: when
479
+ # the two products round to a sum above it, the larger becomes LARGEST_TOTAL less the smaller (a sum of exactly
480
+ # LARGEST_TOTAL; the mean moves by at most a rounding). A product that rounds to 0 reads the smallest Float above 0.
481
+ def scaled_to_largest_total(pair)
482
+ return pair unless @kind == :beta
483
+
484
+ alpha, beta = pair
485
+ half_total = (alpha * 0.5) + (beta * 0.5)
486
+ return pair unless half_total > HALF_LARGEST_TOTAL
487
+
488
+ scale = HALF_LARGEST_TOTAL / half_total
489
+ alpha = [ alpha * scale, SMALLEST_SCALED_PARAMETER ].max
490
+ beta = [ beta * scale, SMALLEST_SCALED_PARAMETER ].max
491
+ return [ alpha, beta ].freeze unless alpha + beta > LARGEST_TOTAL
492
+
493
+ (alpha < beta ? [ alpha, LARGEST_TOTAL - alpha ] : [ LARGEST_TOTAL - beta, beta ]).freeze
494
+ end
495
+ end
496
+ end
497
+ end