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,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