skiftet_statistical 0.2.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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +308 -8
  3. data/README.md +1416 -39
  4. data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
  5. data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
  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.rb +3 -3
  18. data/lib/skiftet_statistical/beta_argmax.rb +526 -0
  19. data/lib/skiftet_statistical/beta_function.rb +28 -1
  20. data/lib/skiftet_statistical/cholesky.rb +244 -0
  21. data/lib/skiftet_statistical/cusum.rb +183 -0
  22. data/lib/skiftet_statistical/descriptive.rb +3 -3
  23. data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
  24. data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
  25. data/lib/skiftet_statistical/inputs.rb +214 -0
  26. data/lib/skiftet_statistical/linear_value.rb +364 -0
  27. data/lib/skiftet_statistical/numbers.rb +98 -0
  28. data/lib/skiftet_statistical/prequential.rb +206 -0
  29. data/lib/skiftet_statistical/shared_credit.rb +811 -0
  30. data/lib/skiftet_statistical/significance.rb +5 -6
  31. data/lib/skiftet_statistical/version.rb +1 -1
  32. data/lib/skiftet_statistical.rb +43 -4
  33. metadata +37 -11
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+
6
+ module SkiftetStatistical
7
+ class Bandit
8
+ # The bounds the bandit's modules share, each written once on a number read first by the library's one number
9
+ # reader (Numbers.read, the input domain), the one reading of a whole number, the one check of an answer, the one
10
+ # lookup in a caller's Hash by a key and the one reading of one by Symbol names. Private to the bandit: Prior,
11
+ # Decay, Tallies, Draw, NormalArgmax, Pass and Posteriors ask these, so a bound reads and words alike wherever it
12
+ # is checked.
13
+ module Checks
14
+ # What `lookup` answers inside `holds?` for a key the Hash does not hold.
15
+ NONE = Object.new.freeze
16
+ private_constant :NONE
17
+
18
+ module_function
19
+
20
+ # The value as a Float, by Numbers.read, when it is >= 0; else "<label> must be >= 0 (got <number>)".
21
+ def nonnegative(given, label)
22
+ number = Numbers.read(given, label)
23
+ raise ArgumentError, "#{label} must be >= 0 (got #{number})" if number.negative?
24
+
25
+ number
26
+ end
27
+
28
+ # The value as a Float, by Numbers.read, when it lies in [0, 1] (a share of a rule, a uniform, a mixing share, a
29
+ # mean, a clip bound of a mean); else "<label> must lie in [0, 1], got <number>", the words BetaArgmax's own
30
+ # [0, 1] bound has used since 0.2.0, so the bound reads alike across the library.
31
+ def unit(given, label)
32
+ number = Numbers.read(given, label)
33
+ raise ArgumentError, "#{label} must lie in [0, 1], got #{number}" unless number.between?(0.0, 1.0)
34
+
35
+ number
36
+ end
37
+
38
+ # The value as a Float, by Numbers.read, when it lies above 0 and below 1 (a credible mass); else "<label> must
39
+ # be a number above 0 and below 1 (got <value>)", a value that is not a number included.
40
+ def open_unit(given, label)
41
+ number = Numbers.read(given, label) { nil }
42
+ return number if number&.positive? && number < 1.0
43
+
44
+ raise ArgumentError, "#{label} must be a number above 0 and below 1 (got #{Inputs.shown(given)})"
45
+ end
46
+
47
+ # The value as it is given when it is an Integer that the library's one number reader reads (an Integer beyond
48
+ # a Float's range is no number, and one beyond Numbers::LARGEST is refused there, in the input domain's words),
49
+ # at least `minimum` when it is given and below `below` when it is given; else "<label> must be an Integer[ of
50
+ # at least <minimum>][ and below <below>] within a Float's range (got <value>)". A count of draws, a seed, a top,
51
+ # a pinned position.
52
+ def integer(given, label, minimum: nil, below: nil)
53
+ number = Integer === given ? Numbers.read(given, label) { nil } : nil
54
+ return given if number && (nil.equal?(minimum) || given >= minimum) && (nil.equal?(below) || given < below)
55
+
56
+ bound = nil.equal?(minimum) ? "" : " of at least #{minimum}"
57
+ bound += "#{nil.equal?(minimum) ? '' : ' and'} below #{below}" unless nil.equal?(below)
58
+ raise ArgumentError, "#{label} must be an Integer#{bound} within a Float's range (got #{Inputs.shown(given)})"
59
+ end
60
+
61
+ # The value as a Float, by Numbers.read, when it is above 0; else "<label> must be above 0 (got <number>)".
62
+ def positive(given, label)
63
+ number = Numbers.read(given, label)
64
+ raise ArgumentError, "#{label} must be above 0 (got #{number})" unless number.positive?
65
+
66
+ number
67
+ end
68
+
69
+ # THE check of an answer, written once, the last guard of every public method of the bandit that answers a
70
+ # number: `answer` (the numbers themselves unless given), as it is, when every number of `numbers` (the answer's
71
+ # numbers, a nil among them a figure the answer leaves out) is finite; else "<inputs> overflow a Float's range
72
+ # (the answer would read <numbers>)", `inputs` naming what was handed in. No sum over fewer than 10**208
73
+ # in-domain inputs and no product the bandit forms of them overflows (Numbers::LARGEST, whose argument names the
74
+ # widest term), so what it refuses is a quotient by a small input that left a Float.
75
+ def finite_answer(numbers, inputs, answer = numbers)
76
+ return answer if numbers.all? { |number| nil.equal?(number) || number.finite? }
77
+
78
+ raise ArgumentError, "#{inputs} overflow a Float's range (the answer would read #{Inputs.shown(numbers)})"
79
+ end
80
+
81
+ # THE lookup in a caller's Hash by a key of the caller's (an opaque key: a mixture's branch or item) or a Symbol
82
+ # (a pass's leader), written once: the value the Hash holds at `key`, or `missing` (nil) when it holds
83
+ # none, by Hash's own fetch (Inputs.fetched: a subclass's fetch or [] is never called, and a default the Hash
84
+ # holds, a value or a block, never read). A key of the caller's has its hash and eql? called by the lookup, one
85
+ # call into the caller's object, asked by the library's one such call (Inputs.asked), `label` naming the Hash.
86
+ def lookup(hash, key, label, missing = nil)
87
+ Inputs.asked(key, "#{label}'s lookup of a key") { Inputs.fetched(hash, key, missing) }
88
+ end
89
+
90
+ # Whether the Hash holds `key` (by `lookup`).
91
+ def holds?(hash, key, label)
92
+ !NONE.equal?(lookup(hash, key, label, NONE))
93
+ end
94
+
95
+ # The value the Hash holds at the Symbol key, else at its String, else nil (`named`, for the one key).
96
+ def fetch(hash, key, label = "a Hash")
97
+ named(hash, [ key ], label).fetch(key, nil)
98
+ end
99
+
100
+ # THE reading of a caller's Hash by Symbol names, written once (a tally's sums, the given uniforms' parts, an
101
+ # other's group and own, a level's strength): { key => value } for each of `keys` (Symbols) the Hash names, its
102
+ # value at the Symbol key, else at the key's String, read from the Hash's pairs (Inputs.pairs, Hash's own to_a,
103
+ # so a default the Hash holds is never read), a String key by its copy (Inputs.string) and a Symbol key as the
104
+ # Symbol it is; every other key is not read, and no method of a key is called. Every value is then looked up in
105
+ # the Hash this builds, never in the caller's by a key of the library's, so a Hash that compares its keys by
106
+ # identity reads exactly as a plain one. Two String keys of one name (an identity Hash can hold both, as can a
107
+ # key whose own hash differs from its String's) are an ArgumentError, "<label> names <key> twice".
108
+ def named(hash, keys, label)
109
+ symbols = {}
110
+ strings = {}
111
+ Inputs.pairs(hash).each do |name, value|
112
+ if Symbol === name
113
+ symbols[name] = value if keys.include?(name)
114
+ elsif String === name
115
+ copy = Inputs.string(name)
116
+ key = keys.find { |candidate| candidate.name == copy }
117
+ next if nil.equal?(key)
118
+ raise ArgumentError, "#{label} names #{key} twice" if strings.key?(key)
119
+
120
+ strings[key] = value
121
+ end
122
+ end
123
+ strings.empty? ? symbols : strings.merge(symbols)
124
+ end
125
+ end
126
+ private_constant :Checks
127
+ end
128
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+ require_relative "checks"
6
+ require_relative "tallies"
7
+
8
+ module SkiftetStatistical
9
+ class Bandit
10
+ # How much an old view weighs: one formula, 2 ** (-age / half_life), so a view one half-life old counts half and
11
+ # a view older than `cap` counts nothing. Age, half-life and cap share one unit of the caller's choosing (hours,
12
+ # days, passes). Pure: no randomness, no state.
13
+ #
14
+ # SkiftetStatistical::Bandit::Decay.weight(72, half_life: 72, cap: 336) # => 0.5
15
+ # SkiftetStatistical::Bandit::Decay.weight(337, half_life: 72, cap: 336) # => 0.0
16
+ #
17
+ # `fold` sums rows that each carry an `age` at that weight: every sum is weighted (views, outcomes and
18
+ # outcome_squares alike, never views alone), so the folded tally is what a yes/no or an amounts posterior reads.
19
+ #
20
+ # rows = [{ age: 0, views: 10, outcomes: 3 }, { age: 72, views: 20, outcomes: 4 }]
21
+ # SkiftetStatistical::Bandit::Decay.fold(rows, half_life: 72, cap: 336)
22
+ # # => { views: 20.0, outcomes: 5.0, outcome_squares: 0.0 }
23
+ #
24
+ # The fold is linear in the rows, so a caller that sums in a database groups its records by age bucket and folds
25
+ # the buckets: the answer is the fold of the records one by one, up to rounding.
26
+ #
27
+ # Rules: every number is read by the library's one number reader (SkiftetStatistical::Numbers: a finite real
28
+ # number of magnitude at most Numbers::LARGEST; a NaN, an infinity and a String, a numeric one included, are not
29
+ # numbers). An age is >= 0 (a negative age is an ArgumentError); the half-life is > 0; the cap is nil (no cap) or
30
+ # >= 0, and it is inclusive (an age at the cap still weighs by the formula, an age above it weighs 0.0). The rows
31
+ # are a list, read by the bandit's one list reader (Tallies.list: any Enumerable but a Hash, read once by its each),
32
+ # each row a tally read by its one tally reader (Tallies.read, Symbol or String keys); its age is required, and a
33
+ # missing or nil views, outcomes or outcome_squares reads 0. Views and outcome_squares are >= 0; outcomes may be
34
+ # below 0, a sum of amounts (the tally reader's one list of sums that may be, Tallies::SIGNED_SUMS). Anything else
35
+ # is an ArgumentError, in a row past the cap too, and every answer passes the bandit's one check of an answer
36
+ # (Checks.finite_answer).
37
+ module Decay
38
+ # The sums `fold` answers, each weighted.
39
+ SUMS = %i[views outcomes outcome_squares].freeze
40
+ # What a row holds: its age and its sums.
41
+ ROW = %i[age views outcomes outcome_squares].freeze
42
+ private_constant :ROW
43
+
44
+ class << self
45
+ # The weight of a view `age` old: 2 ** (-age / half_life), 0.0 when `cap` is given and age is above it.
46
+ def weight(age, half_life:, cap: nil)
47
+ half_life, cap = settings(half_life, cap)
48
+ Checks.finite_answer([ factor(Checks.nonnegative(age, "an age"), half_life, cap) ],
49
+ "the age, half_life and cap").first
50
+ end
51
+
52
+ # { views:, outcomes:, outcome_squares: } summed over `rows`, each row's numbers at its age's weight.
53
+ def fold(rows, half_life:, cap: nil)
54
+ half_life, cap = settings(half_life, cap)
55
+ read = Tallies.list(rows, "rows", "tallies").each_with_index.map do |row, index|
56
+ Tallies.read(row, "rows[#{index}]", ROW, amounts: true)
57
+ end
58
+ weighted = read.each_with_index.map do |row, index|
59
+ age = row[:age]
60
+ raise ArgumentError, "rows[#{index}] needs an age" if nil.equal?(age)
61
+
62
+ [ factor(age, half_life, cap), row ]
63
+ end
64
+ sums = SUMS.to_h { |sum| [ sum, weighted.sum(0.0) { |row_weight, row| row_weight * (row[sum] || 0.0) } ] }
65
+ Checks.finite_answer(sums.values, "the rows", sums)
66
+ end
67
+
68
+ private
69
+
70
+ # The one formula, on a checked age and checked settings.
71
+ def factor(age, half_life, cap)
72
+ return 0.0 if cap && age > cap
73
+
74
+ 2.0**(-age / half_life)
75
+ end
76
+
77
+ def settings(half_life, cap)
78
+ half_life = Checks.positive(half_life, "half_life")
79
+ return [ half_life, nil ] if nil.equal?(cap)
80
+
81
+ [ half_life, Checks.nonnegative(cap, "cap") ]
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require_relative "../numbers"
5
+ require_relative "../inputs"
6
+ require_relative "checks"
7
+
8
+ module SkiftetStatistical
9
+ class Bandit
10
+ # The randomness helpers, each written once: a text becomes a uniform, a list of parts becomes a uniform or a
11
+ # seed, and a uniform picks a key by weight. Pure: the same text gives the same uniform in every process, on every
12
+ # machine, so a caller can key a draw by a visit, a pass or a test and get it back without storing it.
13
+ #
14
+ # SkiftetStatistical::Bandit::Draw.uniform("t") # => 0.8895498694938413
15
+ # SkiftetStatistical::Bandit::Draw.uniform_for("t", "explore") # => 0.856909028643178
16
+ # SkiftetStatistical::Bandit::Draw.seed_for("t", "explore") # => 15807181545831752473
17
+ # SkiftetStatistical::Bandit::Draw.from_weights({ "a" => 1, "b" => 3 }, 0.5) # => "b"
18
+ # SkiftetStatistical::Bandit::Draw.weighted({ "a" => 1, "b" => 3 }, 0.5) # => ["b", 0.75]
19
+ #
20
+ # A uniform is the first 8 bytes of SHA256 of the text, read as one big-endian unsigned Integer, shifted right by
21
+ # 11 and divided by 2**53: a Float in [0, 1) on the 2**53 grid. A seed is those same 8 bytes as the Integer, for
22
+ # Random.new. A list of parts is read as its parts' bytes joined by one NUL byte, nil read as "", a String by its
23
+ # bytes alone and anything else by its to_s (so 7 and "7" are one part), whose String answer is read the same way
24
+ # (Inputs.text: never a String's own methods), a to_s that answers no String being an ArgumentError; a part
25
+ # holding a NUL byte is an ArgumentError, so two different part lists never join to the same text. `uniform` reads
26
+ # its one text the same way (its bytes, nil as ""), with no NUL rule: it has no parts to keep apart. Both readings
27
+ # are the bandit's one reading of a keyed draw's parts (`KeyParts`, below), which the order rule also reads a key
28
+ # by, once, and keeps for a re-pin.
29
+ #
30
+ # The draw by weight takes a Hash of key => weight in input order and a uniform in [0, 1]. A weight that is not a
31
+ # finite number above 0 (0, nil, NaN, an infinity, a negative number, a numeric String) counts 0, and a number
32
+ # beyond the library's input domain (Numbers::LARGEST either side of 0) is an ArgumentError, as every number the
33
+ # library reads is; when every weight counts 0, every key counts 1. The arithmetic is the one 0.1's Softmax policy
34
+ # drew by, step for step: the total is Array#sum of the counted weights (a compensated sum), the target is uniform *
35
+ # total, and the running sum adds the counted weights one by one in input order; the draw answers the first key that
36
+ # counts whose running sum is at or above the target, else (the running sum ending below the target, as a uniform
37
+ # near 1.0 can make it) the last key that counts. A key that counts 0 is never answered, at a uniform of 0.0
38
+ # included. `weighted` answers that key with its share, its counted weight over the counted total, which is the
39
+ # probability that a uniform draw answers it; `from_weights` is `weighted`'s key. Keys are any objects and are
40
+ # answered as given: neither call reads a key, so an app may pass its own records. An empty Hash answers no key
41
+ # (nil, and [nil, nil] from `weighted`). A uniform that is not a number in [0, 1], and counted weights whose sum is
42
+ # not a finite number, are an ArgumentError.
43
+ #
44
+ # A quantile draw reads its uniform on the open grid (`open_uniform`): clamped to [2**-53, 1 - 2**-53], the
45
+ # smallest and the largest uniform above 0 that Random#rand answers, so a quantile that is infinite at 0 or 1 (the
46
+ # Normal's) never reads either end. It is the one such reading, for every quantile draw of the posterior set and
47
+ # for the tail of its Normal interval; a Beta draw reads its uniform as it is (its position is a comparison key,
48
+ # and its value at an end is 0 or 1).
49
+ #
50
+ # SkiftetStatistical::Bandit::Draw.open_uniform(0.0) # => 1.1102230246251565e-16 (2**-53)
51
+ module Draw
52
+ # 2**53: the uniform's grid, the most a Float holds without rounding.
53
+ UNIFORM_SCALE = 9_007_199_254_740_992.0
54
+ # The open grid's ends: 2**-53 and 1 - 2**-53.
55
+ OPEN_LOW = 1.0 / UNIFORM_SCALE
56
+ OPEN_HIGH = 1.0 - OPEN_LOW
57
+ # The byte between two parts.
58
+ SEPARATOR = "\x00".b.freeze
59
+
60
+ class << self
61
+ # The uniform of `text`, in [0, 1).
62
+ def uniform(text)
63
+ Checks.finite_answer([ (head(KeyParts.bytes(text)) >> 11) / UNIFORM_SCALE ], "the text").first
64
+ end
65
+
66
+ # The uniform of the parts joined by NUL, in [0, 1).
67
+ def uniform_for(*parts)
68
+ Checks.finite_answer([ (head(joined(parts)) >> 11) / UNIFORM_SCALE ], "the parts").first
69
+ end
70
+
71
+ # The seed of the parts joined by NUL, an Integer in [0, 2**64), for Random.new.
72
+ def seed_for(*parts)
73
+ Checks.finite_answer([ head(joined(parts)) ], "the parts").first
74
+ end
75
+
76
+ # The uniform (a number in [0, 1], read by the bandit's one [0, 1] bound, Checks.unit) clamped to the open
77
+ # grid [OPEN_LOW, OPEN_HIGH]: the one reading of a uniform a quantile draw takes.
78
+ def open_uniform(uniform)
79
+ Checks.finite_answer([ Checks.unit(uniform, "uniform").clamp(OPEN_LOW, OPEN_HIGH) ], "the uniform").first
80
+ end
81
+
82
+ # The key a uniform draws from `weights`: `weighted`'s key.
83
+ def from_weights(weights, uniform)
84
+ weighted(weights, uniform).first
85
+ end
86
+
87
+ # [key, share]: the key a uniform draws from `weights`, and its counted weight over the counted total.
88
+ def weighted(weights, uniform)
89
+ unless Hash === weights
90
+ raise ArgumentError, "weights must be a Hash of key => weight (got #{Inputs.class_name(weights)})"
91
+ end
92
+
93
+ target = Checks.unit(uniform, "uniform")
94
+ entries = Inputs.pairs(weights)
95
+ keys = entries.map(&:first)
96
+ counts = entries.map { |_key, weight| counted(weight) }
97
+ total = counts.sum
98
+ raise ArgumentError, "the weights must sum to a finite number (got #{total})" unless total.finite?
99
+
100
+ if total.zero?
101
+ counts = Array.new(counts.size, 1.0)
102
+ total = counts.sum
103
+ end
104
+ return [ nil, nil ] if total.zero?
105
+
106
+ target *= total
107
+ running = 0.0
108
+ last = nil
109
+ counts.each_with_index do |weight, index|
110
+ next unless weight.positive?
111
+
112
+ running += weight
113
+ last = index
114
+ break if running >= target
115
+ end
116
+ share = counts[last] / total
117
+ Checks.finite_answer([ share ], "the weights and the uniform", [ keys[last], share ])
118
+ end
119
+
120
+ private
121
+
122
+ # The first 8 bytes of SHA256(text) as one big-endian unsigned Integer.
123
+ def head(text)
124
+ Digest::SHA256.digest(text).unpack1("Q>")
125
+ end
126
+
127
+ # The parts as the one reading of a keyed draw's parts reads them (`KeyParts.read`), joined by NUL.
128
+ def joined(parts)
129
+ KeyParts.read(*parts).join(SEPARATOR)
130
+ end
131
+
132
+ # A weight as the draw counts it: a number (the library's one number reader, Numbers.read, which refuses one
133
+ # beyond the input domain) above 0, else 0.0, what is not a number at all included.
134
+ def counted(weight)
135
+ value = Numbers.read(weight, "a weight") { 0.0 }
136
+ value.positive? ? value : 0.0
137
+ end
138
+ end
139
+ end
140
+
141
+ # THE reading of a keyed draw's parts, written once: Draw's `uniform` reads its text here, `uniform_for` and
142
+ # `seed_for` their parts, and the order rule's key source a key's parts, once, keeping what it read for a re-pin
143
+ # (Order#repin), so a re-pin reads the key as `rank` read it and never asks the caller's parts again. What it
144
+ # answers is library data, frozen binary Strings, which Draw reads again as their own bytes. Private to the bandit.
145
+ module KeyParts
146
+ module_function
147
+
148
+ # The parts, in order, each as a keyed draw reads it: its bytes (`bytes`), refused when they hold a NUL byte
149
+ # (Draw::SEPARATOR, the byte between two parts), each part read once and refused in its turn; a frozen Array of
150
+ # frozen binary Strings. Taken as a splat, so a caller's Array is read by its elements alone, none of its own
151
+ # methods called. Draw.uniform_for(*read(*parts), *more) is Draw.uniform_for(*parts, *more), bit for bit: a part
152
+ # read here reads again as the same bytes.
153
+ def read(*parts)
154
+ parts.map do |part|
155
+ text = bytes(part)
156
+ if text.include?(Draw::SEPARATOR)
157
+ raise ArgumentError, "a part must not hold a NUL byte (got the bytes #{text.inspect})"
158
+ end
159
+
160
+ text
161
+ end.freeze
162
+ end
163
+
164
+ # A part's bytes, a frozen binary String: "" for nil, else the bytes of the library's one reading of a value as
165
+ # text (Inputs.text: a String read by its bytes and encoding alone, by Inputs.string; anything else by its to_s,
166
+ # asked by the library's one call into a caller's object, so a to_s that raises is an ArgumentError, and its
167
+ # String answer read the same way), which must be a String.
168
+ def bytes(part)
169
+ return "".b.freeze if nil.equal?(part)
170
+
171
+ text = Inputs.text(part, "a part")
172
+ return text.b.freeze unless nil.equal?(text)
173
+
174
+ raise ArgumentError, "a part must answer to_s with a String (got #{Inputs.shown(part)})"
175
+ end
176
+ end
177
+ private_constant :KeyParts
178
+ end
179
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../numbers"
4
+ require_relative "../inputs"
5
+ require_relative "../gauss_legendre"
6
+ require_relative "../significance"
7
+ require_relative "../beta_argmax"
8
+ require_relative "checks"
9
+
10
+ module SkiftetStatistical
11
+ class Bandit
12
+ # THE map between two Normals' standardised variables, written once (NormalArgmax's own integrals and panel breaks,
13
+ # and the order odds' points and distribution functions, probabilities.rb): the point at the standard deviate z of
14
+ # a Normal (mean, sd), in the standard units of another (onto_mean, onto_sd), ((mean - onto_mean) + sd * z) /
15
+ # onto_sd, the means' difference formed first, so two near means far from 0 lose no digits to their size, and a
16
+ # finite numerator over a standard deviation reads a Float, or an infinity where the quotient leaves a Float's
17
+ # range, never a NaN. Private to the bandit.
18
+ module Standardised
19
+ module_function
20
+
21
+ def carried(mean, deviation, z, onto_mean, onto_deviation)
22
+ ((mean - onto_mean) + (deviation * z)) / onto_deviation
23
+ end
24
+ end
25
+ private_constant :Standardised
26
+
27
+ # Which of several independent Normal variables is the largest, by BetaArgmax's rule: the probability that each
28
+ # member is the largest, and the expected value of the largest.
29
+ #
30
+ # P(member i is the largest) = integral of phi(z) * product (j != i) of Phi((mu_i + sigma_i z - mu_j) / sigma_j)
31
+ # E[the largest] = U - integral from L to U of the product of every F_j(x) dx
32
+ #
33
+ # (phi and Phi the standard Normal's density and distribution function, F_j member j's distribution function).
34
+ #
35
+ # Each member's probability is its own integral, in its own standardised variable z = (x - mu_i) / sigma_i, so its
36
+ # density is sampled on its own scale however narrow, wide or far from the others it is. The integral runs over z
37
+ # from the standard quantile at BetaArgmax::TAIL to the one at 1 - TAIL, composite Gauss-Legendre at
38
+ # BetaArgmax::NODES nodes a panel, the panels broken at the standard quantiles at BetaArgmax::BREAKS and, inside
39
+ # that range, at the same quantiles of every narrower member, carried into z. On each panel every factor is then
40
+ # smooth: a narrower member's distribution function lies between two of its own quantiles, and a member at least as
41
+ # wide moves by at most the panel's width in its own standard units. Another member's argument is formed as
42
+ # ((mu_i - mu_j) + sigma_i z) / sigma_j, a finite numerator over a standard deviation (the bandit's one map between
43
+ # two Normals' standardised variables, Standardised.carried), so it reads 0 or 1 where the quotient leaves a
44
+ # Float, never a NaN.
45
+ #
46
+ # The error bound. A member's integral leaves out at most TAIL at each end of its own range, and a factor beyond its
47
+ # member's extreme quantiles moves by at most TAIL, which a rule of positive weights reads to within 2 TAIL of the
48
+ # panels' mass; the rest is the Gauss-Legendre error on smooth panels, below SMOOTH_ERROR (1e-12). So each member's
49
+ # integral lies within 2 k TAIL + 1e-12 of its exact probability (k the number of members), and their sum within k
50
+ # times that of 1: a sum further from 1 is refused, as beyond what the rule can integrate. The answers are the
51
+ # integrals over their sum, each within 2 k (k + 1) TAIL + 2e-12 of the exact probability (at most 8.4e-8 for 20
52
+ # members, 1.2e-9 for two), then mixed with epsilon / k, as BetaArgmax's are. A standard deviation below Float::MIN,
53
+ # where a Float holds fewer digits and sigma_i z rounds coarsely, is beyond what the rule can integrate, for both
54
+ # answers alike.
55
+ #
56
+ # E[the largest] reads no density: [L, U] is the members' joint range, from the lowest of their quantiles at TAIL to
57
+ # the highest at 1 - TAIL, the panels broken at every member's quantiles at BREAKS, on x less the largest mean (so a
58
+ # mean near 10**6 loses no digits to its own size). Its integrand is a product of distribution functions, at most 1,
59
+ # and a narrow member's step sits on its own quantile points however close they round, so no member's mass goes
60
+ # unsampled. Leaving out what lies beyond the joint range costs at most 1.4e-11 standard deviations a member at each
61
+ # end (below L the product is at most the lowest member's F, and above U one minus it is at most the sum of the
62
+ # members' upper tails); the rest is a rounding of the members' means relative to the largest.
63
+ #
64
+ # SkiftetStatistical::Bandit::NormalArgmax.probabilities([ [ 0.0, 1.0 ], [ 0.0, 1.0 ] ]) # => [0.5, 0.5]
65
+ # SkiftetStatistical::Bandit::NormalArgmax.expected_maximum([ [ 0.0, 1.0 ], [ 0.0, 1.0 ] ]) # => 1 / sqrt(pi)
66
+ #
67
+ # Members are given as [[mean, standard_deviation], ...], 1 to BetaArgmax::MAX_MEMBERS of them, each pair read by
68
+ # the library's one reading of a pair (Numbers.pair, its numbers in the input domain, Numbers::LARGEST), the
69
+ # standard deviation above 0. Every answer is in the members' order and passes the bandit's one check of an answer
70
+ # (Checks.finite_answer).
71
+ module NormalArgmax
72
+ ROOT_TWO_PI = Math.sqrt(2.0 * Math::PI)
73
+ # The names of a member's two numbers.
74
+ PAIR = [ "mean", "standard deviation" ].freeze
75
+ # The standard Normal's quantiles at BetaArgmax::BREAKS: a member's own panel breaks in its own variable z, the
76
+ # first and the last its range; on the joint range of E[the largest], every member's breaks.
77
+ SCORES = BetaArgmax::BREAKS.map { |level| Significance.normal_quantile(level) }.freeze
78
+ # What the Gauss-Legendre rule may add to a member's integral on smooth panels, beyond the 2 k TAIL above.
79
+ SMOOTH_ERROR = 1e-12
80
+ private_constant :PAIR, :SCORES, :SMOOTH_ERROR
81
+
82
+ class << self
83
+ # P(member i is the largest), mixed with epsilon / k, for every member in order; [1.0] for one member.
84
+ # epsilon lies in [0, 1], read by the bandit's one [0, 1] bound (Checks.unit). The answers lie in [0, 1] and sum
85
+ # to 1.
86
+ def probabilities(params, epsilon: 0.0)
87
+ members = members(params)
88
+ epsilon = Checks.unit(epsilon, "epsilon")
89
+ return [ 1.0 ] if members.size == 1
90
+
91
+ integrals, total = integrable(members, params)
92
+ k = members.size
93
+ Checks.finite_answer(integrals.map { |integral| (epsilon / k) + ((1.0 - epsilon) * integral / total) },
94
+ "the members' parameters")
95
+ end
96
+
97
+ # E[the largest member]; one member's mean exactly. Members `probabilities` cannot integrate are refused alike
98
+ # (the one guard, `integrable`), and an answer beyond a Float's range by the bandit's one check of an answer
99
+ # (Checks.finite_answer).
100
+ def expected_maximum(params)
101
+ members = members(params)
102
+ return Checks.finite_answer([ members.first.first ], "the members' parameters").first if members.size == 1
103
+
104
+ integrable(members, params)
105
+ origin = members.map(&:first).max
106
+ normals = members.map { |mean, deviation| [ mean - origin, deviation ] }
107
+ points = joint_points(normals)
108
+ integral = 0.0
109
+ panels(points) do |x, weight|
110
+ product = 1.0
111
+ normals.each { |mean, deviation| product *= distribution((x - mean) / deviation) }
112
+ integral += weight * product
113
+ end
114
+ Checks.finite_answer([ origin + (points.last - integral) ], "the members' parameters").first
115
+ end
116
+
117
+ private
118
+
119
+ # THE guard of both answers, written once: [each member's own integral, their total] when every standard
120
+ # deviation is at least Float::MIN and the integrals sum to 1 within k (2 k TAIL + SMOOTH_ERROR); else an
121
+ # ArgumentError.
122
+ def integrable(members, params)
123
+ if members.all? { |_mean, deviation| deviation >= Float::MIN }
124
+ integrals = members.each_index.map { |member| own_integral(members, member) }
125
+ total = integrals.sum
126
+ k = members.size
127
+ return [ integrals, total ] if (total - 1.0).abs <= k * ((2 * k * BetaArgmax::TAIL) + SMOOTH_ERROR)
128
+ end
129
+
130
+ raise ArgumentError, "the members' parameters are beyond what the rule can integrate, got #{Inputs.shown(params)}"
131
+ end
132
+
133
+ # The member's own integral of phi(z) * the product of the others' Phi(((mu_i - mu_j) + sigma_i z) / sigma_j)
134
+ # (z carried into each other's standard units by Standardised.carried) over its own range in z; at a node where
135
+ # the product reaches 0, the rest of it is not read.
136
+ def own_integral(members, member)
137
+ mean, deviation = members[member]
138
+ others = members.each_with_index.filter_map { |pair, other| pair unless other == member }
139
+ sum = 0.0
140
+ panels(own_points(members, member)) do |z, weight|
141
+ product = Math.exp(-0.5 * z * z) / ROOT_TWO_PI
142
+ others.each do |other_mean, other_deviation|
143
+ product *= distribution(Standardised.carried(mean, deviation, z, other_mean, other_deviation))
144
+ break if product.zero?
145
+ end
146
+ sum += weight * product
147
+ end
148
+ sum
149
+ end
150
+
151
+ # The member's own breaks in z (SCORES), and every narrower member's quantiles at BREAKS carried into z,
152
+ # ((mu_j - mu_i) + sigma_j * score) / sigma_i (Standardised.carried), where they lie inside its range; sorted,
153
+ # each once.
154
+ def own_points(members, member)
155
+ mean, deviation = members[member]
156
+ low = SCORES.first
157
+ high = SCORES.last
158
+ points = SCORES.dup
159
+ members.each_with_index do |(other_mean, other_deviation), other|
160
+ next if other == member || other_deviation >= deviation
161
+
162
+ SCORES.each do |score|
163
+ z = Standardised.carried(other_mean, other_deviation, score, mean, deviation)
164
+ points << z if z > low && z < high
165
+ end
166
+ end
167
+ points.sort.uniq
168
+ end
169
+
170
+ # Every member's quantiles at BetaArgmax::BREAKS, sorted, each once: the first is L and the last U.
171
+ def joint_points(normals)
172
+ normals.flat_map { |mean, deviation| SCORES.map { |score| mean + (deviation * score) } }.sort.uniq
173
+ end
174
+
175
+ # Yields each node of the composite rule and its weight, the panel's half width folded in.
176
+ def panels(points)
177
+ rule = GaussLegendre.nodes(BetaArgmax::NODES)
178
+ points.each_cons(2) do |left, right|
179
+ half_width = (right - left) / 2.0
180
+ middle = (left + right) / 2.0
181
+ rule.each { |node, weight| yield middle + (half_width * node), weight * half_width }
182
+ end
183
+ end
184
+
185
+ def distribution(score)
186
+ Significance.normal_cdf(score)
187
+ end
188
+
189
+ # The members, read by Array's own to_a (Inputs.elements) and each pair by Numbers.pair.
190
+ def members(params)
191
+ given = Array === params ? Inputs.elements(params) : []
192
+ if given.empty?
193
+ raise ArgumentError, "params must be a non-empty Array of [mean, standard_deviation] pairs, got " \
194
+ "#{Inputs.shown(params)}"
195
+ end
196
+ if given.size > BetaArgmax::MAX_MEMBERS
197
+ raise ArgumentError, "params may hold at most #{BetaArgmax::MAX_MEMBERS} members, got #{given.size}"
198
+ end
199
+
200
+ given.each_with_index.map do |pair, i|
201
+ Numbers.pair(pair, "member #{i}", PAIR) do |number, name, index|
202
+ index == 1 ? Checks.positive(number, name) : number
203
+ end
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end