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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +346 -2
- data/README.md +1618 -22
- data/lib/skiftet_statistical/additive_contrasts.rb +894 -0
- data/lib/skiftet_statistical/bandit/allocator.rb +688 -0
- 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/weighted_posterior.rb +65 -0
- data/lib/skiftet_statistical/bandit/win_hold.rb +86 -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 +153 -0
- 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/gamma.rb +97 -0
- 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/joint_beta.rb +63 -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 +65 -5
- data/lib/skiftet_statistical/version.rb +1 -1
- data/lib/skiftet_statistical.rb +57 -5
- metadata +43 -11
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "significance"
|
|
4
|
+
|
|
5
|
+
module SkiftetStatistical
|
|
6
|
+
# The prequential log score (Dawid 1984): how many bits an outcome cost a predictor that gave it probability p,
|
|
7
|
+
# -log2 p, scored on outcomes it had not seen. Several depths of one predictor, each deeper than the one before, are
|
|
8
|
+
# compared on the same events, and the gain of a depth is the bits per event it saves over the depth before it.
|
|
9
|
+
#
|
|
10
|
+
# rows = [ { cluster: 1, log_probs: { shallow: Math.log(0.25), deep: Math.log(0.5) } },
|
|
11
|
+
# { cluster: 1, log_probs: { shallow: Math.log(0.5), deep: Math.log(0.5) } },
|
|
12
|
+
# { cluster: 2, log_probs: { shallow: Math.log(0.5), deep: Math.log(0.25) } },
|
|
13
|
+
# { cluster: 3, log_probs: { shallow: Math.log(0.125), deep: Math.log(0.5) } } ]
|
|
14
|
+
# SkiftetStatistical::Prequential.score(rows, depth: :deep) # => 1.25
|
|
15
|
+
# SkiftetStatistical::Prequential.gains(rows, depths: %i[shallow deep])
|
|
16
|
+
# # => { deep: { bits: 0.5, se: 0.6495, events: 4, clusters: 3 } }
|
|
17
|
+
#
|
|
18
|
+
# Events of one cluster move together, so the standard error comes from the spread between clusters and not between
|
|
19
|
+
# events (Liang and Zeger 1986). With s_g and n_g a cluster's total and count, and G clusters,
|
|
20
|
+
#
|
|
21
|
+
# mean = sum s_g / sum n_g
|
|
22
|
+
# se = sqrt(G / (G - 1) * sum (s_g - mean * n_g)^2) / sum n_g
|
|
23
|
+
#
|
|
24
|
+
# the residuals taken about the pooled mean, so a cluster weighs by its events. `clustered_mean` answers that mean with
|
|
25
|
+
# its normal interval for a caller that stores per-cluster totals and no events; `gains` adds its rows up to the same
|
|
26
|
+
# totals and goes through the same arithmetic, so the two cannot differ. Under 2 clusters there is no spread to read:
|
|
27
|
+
# the mean stands, its standard error and bounds are nil. With no event at all the mean is nil too.
|
|
28
|
+
#
|
|
29
|
+
# A log probability is a number at most 0. A probability of 0 has no finite log: such an outcome costs a predictor
|
|
30
|
+
# infinitely many bits, so the caller smooths it first and -Infinity is refused like any other non-finite number. A
|
|
31
|
+
# value above 0 is refused too (it is no log of a probability, and it is what a flipped sign looks like). Depths are
|
|
32
|
+
# named by Symbols or Strings, each once, and answered as given; a row's fields and depths are read by either spelling
|
|
33
|
+
# and each must be there once, a depth nobody asked for is not read. A cluster is any Hash key but nil. Every
|
|
34
|
+
# number is a finite real number, finite as a Float too (a NaN, an infinity, a Complex, an Integer beyond a Float's
|
|
35
|
+
# range and a non-number are refused alike), a count is above 0, and a total a Float cannot hold is refused rather
|
|
36
|
+
# than answered as NaN or infinity. Every refusal is an ArgumentError.
|
|
37
|
+
module Prequential
|
|
38
|
+
LOG2 = Math.log(2.0)
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# The mean of -log2 p at one depth over `rows`, any Enumerable of `{ log_probs: { depth => Float } }`, read once;
|
|
42
|
+
# nil with no rows.
|
|
43
|
+
def score(rows, depth:)
|
|
44
|
+
names = read_names([ depth ])
|
|
45
|
+
# The rows are read once into their log probabilities and counted by that list's size, never by a counter
|
|
46
|
+
# inside sum's block: Ruby 3.4.12's Array#sum may call its block more than once for an element.
|
|
47
|
+
logs = read_rows(rows).map { |row| read_log_probs(row, names).first }.to_a
|
|
48
|
+
return nil if logs.empty?
|
|
49
|
+
|
|
50
|
+
finite((0.0 - (logs.sum(0.0) / logs.size)) / LOG2)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Per depth after the first, its gain over the depth before it in the order given: `{ depth => { bits:, se:,
|
|
54
|
+
# events:, clusters: } }`, bits the mean gain per event and se its cluster-robust standard error. `rows` is any
|
|
55
|
+
# Enumerable of `{ cluster:, log_probs: { depth => Float } }`, read once.
|
|
56
|
+
def gains(rows, depths:)
|
|
57
|
+
names = read_names(depths)
|
|
58
|
+
after = names.drop(1)
|
|
59
|
+
counts = Hash.new(0)
|
|
60
|
+
totals = after.map { Hash.new(0.0) }
|
|
61
|
+
|
|
62
|
+
read_rows(rows).each do |row|
|
|
63
|
+
logs = read_log_probs(row, names)
|
|
64
|
+
cluster = read_cluster(row)
|
|
65
|
+
counts[cluster] += 1
|
|
66
|
+
totals.each_with_index { |total, i| total[cluster] += (logs[i + 1] - logs[i]) / LOG2 }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
after.zip(totals).to_h do |depth, total|
|
|
70
|
+
pooled = moments(total, counts)
|
|
71
|
+
[ depth, { bits: pooled[:mean], se: pooled[:se], events: pooled[:n], clusters: pooled[:clusters] } ]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The mean of per-cluster totals `sums` over per-cluster `counts`, on one key set, with its cluster-robust normal
|
|
76
|
+
# interval at `level`: `{ mean:, lower:, upper:, n:, clusters: }`, n the summed counts as given.
|
|
77
|
+
def clustered_mean(sums:, counts:, level: 0.95)
|
|
78
|
+
level = read_level(level)
|
|
79
|
+
pooled = moments(*read_clusters(sums, counts))
|
|
80
|
+
lower, upper = bounds(pooled[:mean], pooled[:se], level)
|
|
81
|
+
{ mean: pooled[:mean], lower: lower, upper: upper, n: pooled[:n], clusters: pooled[:clusters] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# The one arithmetic: `totals` and `counts` hold the same keys, the totals Floats. A cluster's total and count
|
|
87
|
+
# meet by key, never by position.
|
|
88
|
+
def moments(totals, counts)
|
|
89
|
+
size = totals.size
|
|
90
|
+
n = counts.values.sum
|
|
91
|
+
return { mean: nil, se: nil, n: n, clusters: size } if size.zero?
|
|
92
|
+
|
|
93
|
+
events = number(n, "the summed counts")
|
|
94
|
+
mean = finite(totals.values.sum / events)
|
|
95
|
+
{ mean: mean, se: standard_error(totals, counts, mean, events), n: n, clusters: size }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def standard_error(totals, counts, mean, events)
|
|
99
|
+
size = totals.size
|
|
100
|
+
return nil if size < 2
|
|
101
|
+
|
|
102
|
+
squares = totals.sum do |cluster, total|
|
|
103
|
+
residual = total - (mean * counts.fetch(cluster).to_f)
|
|
104
|
+
residual * residual
|
|
105
|
+
end
|
|
106
|
+
finite(Math.sqrt(size.to_f / (size - 1) * squares) / events)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The lower tail's quantile, so that a level just under 1 still reads a finite z: its upper tail rounds to 1.
|
|
110
|
+
def bounds(mean, se, level)
|
|
111
|
+
return [ nil, nil ] if se.nil?
|
|
112
|
+
|
|
113
|
+
half_width = finite(-Significance.normal_quantile((1.0 - level) / 2.0) * se)
|
|
114
|
+
[ finite(mean - half_width), finite(mean + half_width) ]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def read_level(value)
|
|
118
|
+
level = number(value, "level")
|
|
119
|
+
raise ArgumentError, "level must be strictly between 0 and 1, got #{value.inspect}" unless level > 0.0 && level < 1.0
|
|
120
|
+
|
|
121
|
+
level
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# [totals as Floats, counts as given], checked.
|
|
125
|
+
def read_clusters(sums, counts)
|
|
126
|
+
raise ArgumentError, "sums must be a Hash of cluster => total, got #{sums.class}" unless sums.is_a?(Hash)
|
|
127
|
+
raise ArgumentError, "counts must be a Hash of cluster => count, got #{counts.class}" unless counts.is_a?(Hash)
|
|
128
|
+
unless sums.size == counts.size && sums.each_key.all? { |cluster| counts.key?(cluster) }
|
|
129
|
+
raise ArgumentError, "sums and counts must name the same clusters"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
sums.each_key { |cluster| cluster_key(cluster) }
|
|
133
|
+
counts.each do |cluster, count|
|
|
134
|
+
label = "the count of cluster #{cluster.inspect}"
|
|
135
|
+
raise ArgumentError, "#{label} must be above 0, got #{count.inspect}" unless number(count, label).positive?
|
|
136
|
+
end
|
|
137
|
+
[ sums.to_h { |cluster, total| [ cluster, number(total, "the total of cluster #{cluster.inspect}") ] }, counts ]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def read_rows(rows)
|
|
141
|
+
return rows if rows.is_a?(Enumerable)
|
|
142
|
+
|
|
143
|
+
raise ArgumentError, "rows must be an Enumerable of rows, got #{rows.class}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The names as given, checked: an Array of one or more Symbols or Strings, each named once.
|
|
147
|
+
def read_names(list)
|
|
148
|
+
raise ArgumentError, "depths must be an Array of names, got #{list.class}" unless list.is_a?(Array)
|
|
149
|
+
raise ArgumentError, "depths must name at least one" if list.empty?
|
|
150
|
+
|
|
151
|
+
list.each do |name|
|
|
152
|
+
raise ArgumentError, "a depth must be a Symbol or a String, got #{name.inspect}" unless name.is_a?(Symbol) || name.is_a?(String)
|
|
153
|
+
end
|
|
154
|
+
raise ArgumentError, "depths must each be named once, got #{list.inspect}" unless list.map(&:to_s).uniq.size == list.size
|
|
155
|
+
|
|
156
|
+
list
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# One rule for both doors: a cluster is any Hash key but nil, which reads as a key nobody filled in.
|
|
160
|
+
def cluster_key(cluster)
|
|
161
|
+
raise ArgumentError, "a cluster must not be nil" if cluster.nil?
|
|
162
|
+
|
|
163
|
+
cluster
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def read_cluster(row)
|
|
167
|
+
cluster_key(entry(row, :cluster, "a row"))
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# The row's log probabilities at `names`, as Floats, in that order.
|
|
171
|
+
def read_log_probs(row, names)
|
|
172
|
+
logs = entry(row, :log_probs, "a row")
|
|
173
|
+
names.map do |name|
|
|
174
|
+
log_prob = number(entry(logs, name, "a row's log_probs"), "the log probability at #{name.inspect}")
|
|
175
|
+
raise ArgumentError, "the log probability at #{name.inspect} must be at most 0, got #{log_prob}" if log_prob.positive?
|
|
176
|
+
|
|
177
|
+
log_prob
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# The one entry of `hash` that `name` spells, by Symbol or String.
|
|
182
|
+
def entry(hash, name, label)
|
|
183
|
+
raise ArgumentError, "#{label} must be a Hash, got #{hash.class}" unless hash.is_a?(Hash)
|
|
184
|
+
|
|
185
|
+
found = [ name.to_sym, name.to_s ].select { |key| hash.key?(key) }
|
|
186
|
+
raise ArgumentError, "#{label} must hold #{name.inspect} once" unless found.size == 1
|
|
187
|
+
|
|
188
|
+
hash[found.first]
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# A finite real number, finite as a Float too (an Integer or Rational beyond a Float's range is not), as a Float.
|
|
192
|
+
def number(value, label)
|
|
193
|
+
number = value.to_f if value.is_a?(Numeric) && value.real? && value.finite?
|
|
194
|
+
return number if number&.finite?
|
|
195
|
+
|
|
196
|
+
raise ArgumentError, "#{label} must be a finite number, got #{value.inspect}"
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def finite(value)
|
|
200
|
+
return value if value.finite?
|
|
201
|
+
|
|
202
|
+
raise ArgumentError, "the totals are beyond a Float's range"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|