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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +308 -8
- data/README.md +1416 -39
- data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
- data/lib/skiftet_statistical/bandit/allocator.rb +477 -63
- data/lib/skiftet_statistical/bandit/checks.rb +128 -0
- data/lib/skiftet_statistical/bandit/decay.rb +86 -0
- data/lib/skiftet_statistical/bandit/draw.rb +179 -0
- data/lib/skiftet_statistical/bandit/normal_argmax.rb +209 -0
- data/lib/skiftet_statistical/bandit/pass.rb +94 -0
- data/lib/skiftet_statistical/bandit/posteriors.rb +497 -0
- data/lib/skiftet_statistical/bandit/prior.rb +264 -0
- data/lib/skiftet_statistical/bandit/probabilities.rb +929 -0
- data/lib/skiftet_statistical/bandit/ranking.rb +552 -0
- data/lib/skiftet_statistical/bandit/summary.rb +152 -0
- data/lib/skiftet_statistical/bandit/tallies.rb +297 -0
- data/lib/skiftet_statistical/bandit.rb +3 -3
- data/lib/skiftet_statistical/beta_argmax.rb +526 -0
- data/lib/skiftet_statistical/beta_function.rb +28 -1
- data/lib/skiftet_statistical/cholesky.rb +244 -0
- data/lib/skiftet_statistical/cusum.rb +183 -0
- data/lib/skiftet_statistical/descriptive.rb +3 -3
- data/lib/skiftet_statistical/gauss_legendre.rb +73 -0
- data/lib/skiftet_statistical/hierarchical_rates.rb +450 -0
- data/lib/skiftet_statistical/inputs.rb +214 -0
- data/lib/skiftet_statistical/linear_value.rb +364 -0
- data/lib/skiftet_statistical/numbers.rb +98 -0
- data/lib/skiftet_statistical/prequential.rb +206 -0
- data/lib/skiftet_statistical/shared_credit.rb +811 -0
- data/lib/skiftet_statistical/significance.rb +5 -6
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +43 -4
- 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
|