sqa-bi 0.1.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 +7 -0
- data/.github/workflows/docs.yml +55 -0
- data/.quality/reek_baseline.txt +5 -0
- data/.rubocop.yml +224 -0
- data/CHANGELOG.md +33 -0
- data/CLAUDE.md +128 -0
- data/COMMITS.md +196 -0
- data/LICENSE.txt +21 -0
- data/README.md +229 -0
- data/Rakefile +170 -0
- data/decision_support_techniques.md +391 -0
- data/docs/EXPLORATION.md +128 -0
- data/docs/api/index.md +66 -0
- data/docs/api/likelihood.md +78 -0
- data/docs/api/llm-elicitors.md +119 -0
- data/docs/api/llm-support.md +136 -0
- data/docs/api/posterior.md +106 -0
- data/docs/api/prior.md +81 -0
- data/docs/api/time-series-predictor.md +96 -0
- data/docs/assets/css/custom.css +25 -0
- data/docs/assets/diagrams/architecture.svg +75 -0
- data/docs/assets/diagrams/bayes-pipeline.svg +48 -0
- data/docs/assets/diagrams/kde.svg +52 -0
- data/docs/assets/diagrams/llm-bayes-loop.svg +58 -0
- data/docs/assets/diagrams/provider-resolution.svg +77 -0
- data/docs/assets/js/mathjax.js +18 -0
- data/docs/development.md +164 -0
- data/docs/examples/index.md +198 -0
- data/docs/getting-started/core-concepts.md +119 -0
- data/docs/getting-started/installation.md +112 -0
- data/docs/getting-started/quick-start.md +143 -0
- data/docs/guide/likelihood.md +132 -0
- data/docs/guide/posterior.md +135 -0
- data/docs/guide/predictor.md +191 -0
- data/docs/guide/prior.md +141 -0
- data/docs/guide/tuning.md +156 -0
- data/docs/guide/uncertainty.md +148 -0
- data/docs/index.md +110 -0
- data/docs/llm/index.md +124 -0
- data/docs/llm/likelihood-estimation.md +161 -0
- data/docs/llm/prior-elicitation.md +172 -0
- data/docs/llm/providers.md +184 -0
- data/docs/requirements.txt +8 -0
- data/lib/sqa/bi/likelihood.rb +167 -0
- data/lib/sqa/bi/llm_likelihood_estimator.rb +110 -0
- data/lib/sqa/bi/llm_prior_elicitor.rb +110 -0
- data/lib/sqa/bi/llm_support.rb +299 -0
- data/lib/sqa/bi/posterior.rb +189 -0
- data/lib/sqa/bi/prior.rb +135 -0
- data/lib/sqa/bi/time_series_predictor.rb +219 -0
- data/lib/sqa/bi/version.rb +7 -0
- data/lib/sqa/bi.rb +57 -0
- data/mkdocs.yml +174 -0
- metadata +272 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'net/http'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module SQA
|
|
8
|
+
module BI
|
|
9
|
+
# Shared plumbing for classes that get numbers out of an LLM.
|
|
10
|
+
#
|
|
11
|
+
# LLMs return prose; Bayes' theorem needs clean numeric hashes. Every
|
|
12
|
+
# method here is a module_function so it can be tested in isolation
|
|
13
|
+
# without instantiating anything.
|
|
14
|
+
#
|
|
15
|
+
# Provider strategy — local first:
|
|
16
|
+
# 1. SQA_BI_LLM_PROVIDER env var (lms | apfel | cloud) if set
|
|
17
|
+
# 2. LM Studio via ruby_llm-providers-lms (http://localhost:1234/v1)
|
|
18
|
+
# 3. Apfel (Apple Foundation Models) via ruby_llm-providers-apfel
|
|
19
|
+
# (http://127.0.0.1:11434/v1)
|
|
20
|
+
# 4. cloud fallback through the plain ruby_llm registry
|
|
21
|
+
# SQA_BI_LLM_MODEL overrides the model in every case.
|
|
22
|
+
#
|
|
23
|
+
# The unprefixed BI_LLM_PROVIDER / BI_LLM_MODEL names this library
|
|
24
|
+
# used before it moved into the SQA workspace are still honored as a
|
|
25
|
+
# fallback, so existing shell setups keep working.
|
|
26
|
+
module LlmSupport
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
LMS_DEFAULT_BASE = 'http://localhost:1234/v1'
|
|
30
|
+
APFEL_DEFAULT_BASE = 'http://127.0.0.1:11434/v1'
|
|
31
|
+
CLOUD_DEFAULT_MODEL = 'claude-haiku-4-5'
|
|
32
|
+
|
|
33
|
+
def lms_api_base
|
|
34
|
+
ENV.fetch('LMS_API_BASE', LMS_DEFAULT_BASE)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def apfel_api_base
|
|
38
|
+
ENV.fetch('APFEL_API_BASE', APFEL_DEFAULT_BASE)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Read the first non-blank value among a list of env var names.
|
|
42
|
+
#
|
|
43
|
+
# @param names [Array<String>]
|
|
44
|
+
# @return [String, nil]
|
|
45
|
+
def env_value(*names)
|
|
46
|
+
names.each do |name|
|
|
47
|
+
value = ENV[name]&.strip
|
|
48
|
+
return value unless value.nil? || value.empty?
|
|
49
|
+
end
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Model override from the environment, nil when unset/blank.
|
|
54
|
+
def env_model
|
|
55
|
+
env_value('SQA_BI_LLM_MODEL', 'BI_LLM_MODEL')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Which provider to talk to: explicit env choice, else the first
|
|
59
|
+
# local server that answers, else the cloud.
|
|
60
|
+
#
|
|
61
|
+
# @return [Symbol] :lms, :apfel, or :cloud
|
|
62
|
+
def resolve_provider
|
|
63
|
+
env = env_value('SQA_BI_LLM_PROVIDER', 'BI_LLM_PROVIDER')
|
|
64
|
+
return env.to_sym if env
|
|
65
|
+
return :lms if server_alive?(lms_api_base)
|
|
66
|
+
return :apfel if server_alive?(apfel_api_base)
|
|
67
|
+
|
|
68
|
+
:cloud
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Quick liveness probe against an OpenAI-compatible /models endpoint.
|
|
72
|
+
#
|
|
73
|
+
# @param base [String] API base URL (".../v1")
|
|
74
|
+
# @return [Boolean]
|
|
75
|
+
def server_alive?(base, timeout: 1)
|
|
76
|
+
uri = URI("#{base}/models")
|
|
77
|
+
Net::HTTP.start(uri.host, uri.port, open_timeout: timeout, read_timeout: timeout) do |http|
|
|
78
|
+
http.get(uri.path).code.start_with?('2')
|
|
79
|
+
end
|
|
80
|
+
rescue StandardError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Model ids served right now by a local OpenAI-compatible server.
|
|
85
|
+
#
|
|
86
|
+
# @param base [String] API base URL (".../v1")
|
|
87
|
+
# @return [Array<String>]
|
|
88
|
+
def list_local_models(base)
|
|
89
|
+
uri = URI("#{base}/models")
|
|
90
|
+
body = Net::HTTP.get(uri)
|
|
91
|
+
JSON.parse(body).fetch('data', []).map { |m| m['id'] }
|
|
92
|
+
rescue StandardError
|
|
93
|
+
[]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Pick the best judgment model from a local server's offerings.
|
|
97
|
+
# Qwen models honor JSON/structured prompts most reliably in LM
|
|
98
|
+
# Studio (see ruby_llm-providers-lms README), then gpt-oss; embedding
|
|
99
|
+
# and OCR models are never eligible.
|
|
100
|
+
#
|
|
101
|
+
# @param ids [Array<String>] model ids as listed by the server
|
|
102
|
+
# @return [String, nil]
|
|
103
|
+
def choose_local_model(ids)
|
|
104
|
+
chat_ids = ids.grep_v(/embed|ocr/i)
|
|
105
|
+
chat_ids.find { |id| id.match?(/qwen/i) } ||
|
|
106
|
+
chat_ids.find { |id| id.match?(/gpt-oss/i) } ||
|
|
107
|
+
chat_ids.first
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Build a lazily-required RubyLLM chat so the core math library
|
|
111
|
+
# never depends on ruby_llm being installed.
|
|
112
|
+
#
|
|
113
|
+
# @param model [String, nil] model id; nil resolves per provider
|
|
114
|
+
# @param provider [Symbol, String, nil] :lms, :apfel, :cloud;
|
|
115
|
+
# nil auto-detects via resolve_provider
|
|
116
|
+
# @return [RubyLLM::Chat]
|
|
117
|
+
def build_chat(model = nil, provider: nil)
|
|
118
|
+
require_ruby_llm
|
|
119
|
+
configure_ruby_llm
|
|
120
|
+
provider = (provider || resolve_provider).to_sym
|
|
121
|
+
model ||= env_model
|
|
122
|
+
base = local_api_base(provider)
|
|
123
|
+
|
|
124
|
+
if base
|
|
125
|
+
local_chat(provider, model, base)
|
|
126
|
+
else
|
|
127
|
+
RubyLLM.chat(model: model || CLOUD_DEFAULT_MODEL)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# API base for a local provider, or nil when the provider is a
|
|
132
|
+
# cloud one.
|
|
133
|
+
#
|
|
134
|
+
# @param provider [Symbol] :lms, :apfel, or :cloud
|
|
135
|
+
# @return [String, nil]
|
|
136
|
+
def local_api_base(provider)
|
|
137
|
+
case provider
|
|
138
|
+
when :lms then lms_api_base
|
|
139
|
+
when :apfel then apfel_api_base
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# One-line description of where a chat would be sent.
|
|
144
|
+
#
|
|
145
|
+
# Local-first resolution is silent by design, which means a dead or
|
|
146
|
+
# stopped LM Studio server looks identical to a deliberate cloud
|
|
147
|
+
# run until a provider error surfaces. Callers that care — the demo
|
|
148
|
+
# apps do — can show this first.
|
|
149
|
+
#
|
|
150
|
+
# @param provider [Symbol] :lms, :apfel, or :cloud
|
|
151
|
+
# @param model [String, nil] resolved model id
|
|
152
|
+
# @param base [String, nil] local server base; nil for cloud
|
|
153
|
+
# @return [String]
|
|
154
|
+
def resolution_label(provider, model, base)
|
|
155
|
+
target = model || '(no chat model available)'
|
|
156
|
+
base ? "#{provider} — #{target} at #{base}" : "#{provider} — #{target}"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Where the next chat built with these arguments will actually go.
|
|
160
|
+
# Probes the local servers, so it costs one HTTP round trip.
|
|
161
|
+
#
|
|
162
|
+
# @param model [String, nil] explicit model id, if any
|
|
163
|
+
# @param provider [Symbol, nil] explicit provider, if any
|
|
164
|
+
# @return [String] as formatted by resolution_label
|
|
165
|
+
def current_resolution(model = nil, provider: nil)
|
|
166
|
+
provider = (provider || resolve_provider).to_sym
|
|
167
|
+
base = local_api_base(provider)
|
|
168
|
+
model ||= env_model
|
|
169
|
+
model ||= base ? choose_local_model(list_local_models(base)) : CLOUD_DEFAULT_MODEL
|
|
170
|
+
|
|
171
|
+
resolution_label(provider, model, base)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Require ruby_llm, turning both failure modes into one actionable
|
|
175
|
+
# message. It is an optional dependency, so it may simply be absent;
|
|
176
|
+
# and when the process is running outside the bundle, RubyGems can
|
|
177
|
+
# activate a gem version ruby_llm rejects (json >= 3) long before
|
|
178
|
+
# this require runs. The latter surfaces as Gem::ConflictError, which
|
|
179
|
+
# is itself a LoadError, so one rescue covers both.
|
|
180
|
+
#
|
|
181
|
+
# @raise [SQA::BI::Error] when ruby_llm cannot be loaded
|
|
182
|
+
def require_ruby_llm
|
|
183
|
+
require 'ruby_llm'
|
|
184
|
+
rescue LoadError => e
|
|
185
|
+
raise Error, <<~MESSAGE
|
|
186
|
+
Could not load ruby_llm (#{e.class}: #{e.message}).
|
|
187
|
+
|
|
188
|
+
ruby_llm is an optional dependency of sqa-bi — only the LLM-backed
|
|
189
|
+
prior and likelihood paths need it; the KDE path does not.
|
|
190
|
+
|
|
191
|
+
If it is installed, this usually means the process is running
|
|
192
|
+
outside the bundle. Run it with `bundle exec`, or let direnv set
|
|
193
|
+
BUNDLE_GEMFILE (`asgard dev` / `asgard prod` at the workspace root).
|
|
194
|
+
MESSAGE
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# @param provider [Symbol] :lms or :apfel
|
|
198
|
+
# @param model [String, nil]
|
|
199
|
+
# @param base [String] server API base, used to pick a model when none given
|
|
200
|
+
# @return [RubyLLM::Chat]
|
|
201
|
+
def local_chat(provider, model, base)
|
|
202
|
+
model ||= choose_local_model(list_local_models(base))
|
|
203
|
+
raise Error, "No chat model available from #{provider} server at #{base}" unless model
|
|
204
|
+
|
|
205
|
+
RubyLLM.chat(model:, provider:, assume_model_exists: true)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Register the local provider gems and point RubyLLM at whichever
|
|
209
|
+
# cloud keys exist in the environment. Runs once; safe to call
|
|
210
|
+
# repeatedly.
|
|
211
|
+
def configure_ruby_llm
|
|
212
|
+
return if @ruby_llm_configured
|
|
213
|
+
|
|
214
|
+
require_local_providers
|
|
215
|
+
|
|
216
|
+
RubyLLM.configure do |config|
|
|
217
|
+
config.anthropic_api_key = ENV['ANTHROPIC_API_KEY'] if ENV['ANTHROPIC_API_KEY']
|
|
218
|
+
config.openai_api_key = ENV['OPENAI_API_KEY'] if ENV['OPENAI_API_KEY']
|
|
219
|
+
config.gemini_api_key = ENV['GEMINI_API_KEY'] if ENV['GEMINI_API_KEY']
|
|
220
|
+
config.lms_api_base = lms_api_base if config.respond_to?(:lms_api_base=)
|
|
221
|
+
config.apfel_api_base = apfel_api_base if config.respond_to?(:apfel_api_base=)
|
|
222
|
+
end
|
|
223
|
+
@ruby_llm_configured = true
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The provider gems are optional: their absence only disables the
|
|
227
|
+
# corresponding provider, it never breaks the cloud path.
|
|
228
|
+
def require_local_providers
|
|
229
|
+
%w[ruby_llm/providers/lms ruby_llm/providers/apfel].each do |path|
|
|
230
|
+
require path
|
|
231
|
+
rescue LoadError
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Pull a Hash out of whatever the LLM returned.
|
|
237
|
+
#
|
|
238
|
+
# Accepts an already-parsed Hash (e.g., from structured output or a
|
|
239
|
+
# test double), a raw JSON string, or prose containing a JSON object
|
|
240
|
+
# (possibly inside a ```json fence).
|
|
241
|
+
#
|
|
242
|
+
# @param content [Hash, String, #to_s] LLM response content
|
|
243
|
+
# @return [Hash] parsed JSON object with string keys
|
|
244
|
+
# @raise [SQA::BI::Error] when no JSON object can be found
|
|
245
|
+
def extract_json(content)
|
|
246
|
+
return content if content.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
text = content.to_s
|
|
249
|
+
json = text[/\{.*\}/m]
|
|
250
|
+
raise Error, "No JSON object found in LLM response: #{text.inspect}" unless json
|
|
251
|
+
|
|
252
|
+
JSON.parse(json)
|
|
253
|
+
rescue JSON::ParserError => e
|
|
254
|
+
raise Error, "Malformed JSON from LLM: #{e.message}"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Re-key an LLM-produced hash onto the caller's outcome objects.
|
|
258
|
+
#
|
|
259
|
+
# JSON keys are always strings; outcomes may be integers, symbols,
|
|
260
|
+
# or strings. Matches on +to_s+ equality. Missing outcomes get 0.0.
|
|
261
|
+
#
|
|
262
|
+
# @param raw [Hash] {string_key => Numeric}
|
|
263
|
+
# @param outcomes [Array] the caller's outcome objects
|
|
264
|
+
# @return [Hash] {outcome => Float}
|
|
265
|
+
def rekey_to_outcomes(raw, outcomes)
|
|
266
|
+
by_string = raw.transform_keys(&:to_s)
|
|
267
|
+
outcomes.to_h do |outcome|
|
|
268
|
+
[outcome, by_string.fetch(outcome.to_s, 0.0).to_f]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Normalize weights into a proper probability distribution.
|
|
273
|
+
#
|
|
274
|
+
# Adds a small epsilon before normalizing so no outcome is ever
|
|
275
|
+
# assigned exactly zero probability — a zero prior can never recover
|
|
276
|
+
# no matter how much evidence arrives (Cromwell's rule).
|
|
277
|
+
#
|
|
278
|
+
# @param weights [Hash] {outcome => non-negative Numeric}
|
|
279
|
+
# @param epsilon [Float] floor added to every weight
|
|
280
|
+
# @return [Hash] {outcome => Float} summing to 1.0
|
|
281
|
+
def normalize_distribution(weights, epsilon: 1e-6)
|
|
282
|
+
floored = weights.transform_values { |weight| [weight.to_f, 0.0].max + epsilon }
|
|
283
|
+
total = floored.values.sum
|
|
284
|
+
floored.transform_values { |weight| weight / total }
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Clamp a likelihood into an open unit interval.
|
|
288
|
+
#
|
|
289
|
+
# Keeps LLM overconfidence (0.0 / 1.0 answers) from zeroing out or
|
|
290
|
+
# saturating the posterior in a single update.
|
|
291
|
+
#
|
|
292
|
+
# @param value [Numeric]
|
|
293
|
+
# @return [Float] value clamped to [floor, ceiling]
|
|
294
|
+
def clamp_likelihood(value, floor: 0.001, ceiling: 0.999)
|
|
295
|
+
value.to_f.clamp(floor, ceiling)
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQA
|
|
4
|
+
module BI
|
|
5
|
+
# Computes posterior probability distribution using Bayes' theorem
|
|
6
|
+
#
|
|
7
|
+
# Bayes' Theorem: P(outcome | data) ∝ P(data | outcome) × P(outcome)
|
|
8
|
+
#
|
|
9
|
+
# The Posterior class combines prior beliefs and observed data likelihoods
|
|
10
|
+
# to produce an updated probability distribution over outcomes.
|
|
11
|
+
class Posterior
|
|
12
|
+
attr_reader :prior, :likelihoods, :probabilities
|
|
13
|
+
|
|
14
|
+
# Initialize posterior distribution
|
|
15
|
+
#
|
|
16
|
+
# @param prior [Prior] Prior probability distribution
|
|
17
|
+
# @param likelihoods [Hash] Likelihood values {outcome => P(data|outcome)}
|
|
18
|
+
#
|
|
19
|
+
# @example
|
|
20
|
+
# prior = Prior.new([-2, -1, 0, 1, 2])
|
|
21
|
+
# likelihoods = {-2 => 0.05, -1 => 0.1, 0 => 0.15, 1 => 0.6, 2 => 0.1}
|
|
22
|
+
# posterior = Posterior.new(prior, likelihoods)
|
|
23
|
+
def initialize(prior, likelihoods)
|
|
24
|
+
@prior = prior
|
|
25
|
+
@likelihoods = likelihoods
|
|
26
|
+
@probabilities = compute_posterior
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Get posterior probability for specific outcome
|
|
30
|
+
#
|
|
31
|
+
# @param outcome [Numeric] The outcome to query
|
|
32
|
+
# @return [Float] Posterior probability
|
|
33
|
+
def probability(outcome)
|
|
34
|
+
@probabilities[outcome] || 0.0
|
|
35
|
+
end
|
|
36
|
+
alias [] probability
|
|
37
|
+
|
|
38
|
+
# Get the most likely outcome (MAP estimate)
|
|
39
|
+
#
|
|
40
|
+
# @return [Numeric] Outcome with highest posterior probability
|
|
41
|
+
def max_outcome
|
|
42
|
+
@probabilities.max_by { |_outcome, prob| prob }&.first
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Get top N most likely outcomes
|
|
46
|
+
#
|
|
47
|
+
# @param n [Integer] Number of outcomes to return
|
|
48
|
+
# @return [Array<Array>] Array of [outcome, probability] sorted by probability
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# posterior.top_outcomes(3)
|
|
52
|
+
# # => [[1, 0.6], [0, 0.15], [-1, 0.1]]
|
|
53
|
+
def top_outcomes(n = 3)
|
|
54
|
+
@probabilities.sort_by { |_outcome, prob| -prob }.take(n)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get probability distribution as sorted array
|
|
58
|
+
#
|
|
59
|
+
# @return [Array<Array>] Array of [outcome, probability] sorted by outcome
|
|
60
|
+
def to_a
|
|
61
|
+
@probabilities.sort_by { |outcome, _prob| outcome }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get probability distribution as hash
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash] {outcome => probability}
|
|
67
|
+
def to_h
|
|
68
|
+
@probabilities.dup
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Compute entropy of posterior distribution
|
|
72
|
+
# Higher entropy = more uncertain, Lower entropy = more confident
|
|
73
|
+
#
|
|
74
|
+
# @return [Float] Shannon entropy in bits
|
|
75
|
+
def entropy
|
|
76
|
+
-@probabilities.values.reduce(0.0) do |sum, prob|
|
|
77
|
+
sum + (prob.positive? ? prob * Math.log2(prob) : 0)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Compute Kullback-Leibler divergence from prior to posterior
|
|
82
|
+
# Measures information gain from observing data
|
|
83
|
+
#
|
|
84
|
+
# @return [Float] KL divergence in bits (always non-negative)
|
|
85
|
+
def kl_divergence_from_prior
|
|
86
|
+
@probabilities.reduce(0.0) do |sum, (outcome, posterior_prob)|
|
|
87
|
+
prior_prob = @prior.probability(outcome)
|
|
88
|
+
if posterior_prob.positive? && prior_prob.positive?
|
|
89
|
+
sum + (posterior_prob * Math.log2(posterior_prob / prior_prob))
|
|
90
|
+
else
|
|
91
|
+
sum
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Get confidence level (1 - entropy / max_entropy)
|
|
97
|
+
# Returns value between 0 (uniform, no confidence) and 1 (certain)
|
|
98
|
+
#
|
|
99
|
+
# @return [Float] Confidence level
|
|
100
|
+
def confidence
|
|
101
|
+
max_entropy = Math.log2(@probabilities.size)
|
|
102
|
+
1.0 - (entropy / max_entropy)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Sample an outcome from the posterior distribution
|
|
106
|
+
#
|
|
107
|
+
# @param rng [Random] Random number generator
|
|
108
|
+
# @return [Numeric] Sampled outcome
|
|
109
|
+
def sample(rng: Random.new)
|
|
110
|
+
cumulative = 0.0
|
|
111
|
+
threshold = rng.rand
|
|
112
|
+
|
|
113
|
+
@probabilities.each do |outcome, prob|
|
|
114
|
+
cumulative += prob
|
|
115
|
+
return outcome if cumulative >= threshold
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Fallback (shouldn't reach here if probabilities sum to 1)
|
|
119
|
+
@probabilities.keys.last
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Generate N samples from the posterior distribution
|
|
123
|
+
#
|
|
124
|
+
# @param n [Integer] Number of samples
|
|
125
|
+
# @param rng [Random] Random number generator
|
|
126
|
+
# @return [Array] Array of sampled outcomes
|
|
127
|
+
def samples(n, rng: Random.new)
|
|
128
|
+
Array.new(n) { sample(rng: rng) }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Return formatted string representation
|
|
132
|
+
#
|
|
133
|
+
# @return [String] Formatted probability distribution
|
|
134
|
+
def to_s
|
|
135
|
+
parts = @probabilities.sort.map do |outcome, prob|
|
|
136
|
+
"#{outcome}: #{format('%.3f', prob)}"
|
|
137
|
+
end
|
|
138
|
+
"Posterior(#{parts.join(', ')})"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Return detailed summary with statistics
|
|
142
|
+
#
|
|
143
|
+
# @return [String] Multi-line summary
|
|
144
|
+
def summary
|
|
145
|
+
max_out = max_outcome
|
|
146
|
+
max_prob = probability(max_out)
|
|
147
|
+
|
|
148
|
+
<<~SUMMARY
|
|
149
|
+
Posterior Distribution Summary:
|
|
150
|
+
================================
|
|
151
|
+
Most Likely: #{max_out} (#{format('%.1f%%', max_prob * 100)})
|
|
152
|
+
Confidence: #{format('%.1f%%', confidence * 100)}
|
|
153
|
+
Entropy: #{format('%.3f', entropy)} bits
|
|
154
|
+
KL Divergence from Prior: #{format('%.3f', kl_divergence_from_prior)} bits
|
|
155
|
+
|
|
156
|
+
Probabilities:
|
|
157
|
+
#{to_a.map { |outcome, prob| " #{outcome}: #{format('%.3f', prob)} (#{format('%.1f%%', prob * 100)})" }.join("\n")}
|
|
158
|
+
SUMMARY
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
# Compute posterior using Bayes' theorem
|
|
164
|
+
# P(outcome | data) = P(data | outcome) × P(outcome) / P(data)
|
|
165
|
+
#
|
|
166
|
+
# @return [Hash] Normalized posterior probabilities
|
|
167
|
+
def compute_posterior
|
|
168
|
+
# Compute unnormalized posterior: likelihood × prior
|
|
169
|
+
unnormalized = @prior.outcomes.each_with_object({}) do |outcome, hash|
|
|
170
|
+
likelihood = @likelihoods[outcome] || 0.0
|
|
171
|
+
prior_prob = @prior.probability(outcome)
|
|
172
|
+
hash[outcome] = likelihood * prior_prob
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Normalize to sum to 1
|
|
176
|
+
total = unnormalized.values.sum
|
|
177
|
+
|
|
178
|
+
if total.positive?
|
|
179
|
+
unnormalized.transform_values { |v| v / total }
|
|
180
|
+
else
|
|
181
|
+
# All zeros, fall back to prior
|
|
182
|
+
@prior.outcomes.to_h do |outcome|
|
|
183
|
+
[outcome, @prior.probability(outcome)]
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
data/lib/sqa/bi/prior.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SQA
|
|
4
|
+
module BI
|
|
5
|
+
# Manages prior probability distributions over discrete outcomes
|
|
6
|
+
#
|
|
7
|
+
# The Prior class represents our initial beliefs about outcome probabilities
|
|
8
|
+
# before observing any data. It supports:
|
|
9
|
+
# - Uniform priors (equal probability for all outcomes)
|
|
10
|
+
# - Custom priors (specified probabilities)
|
|
11
|
+
# - Dynamic updating based on historical observations
|
|
12
|
+
class Prior
|
|
13
|
+
attr_reader :outcomes, :probabilities
|
|
14
|
+
|
|
15
|
+
# Initialize a prior distribution
|
|
16
|
+
#
|
|
17
|
+
# @param outcomes [Array] Discrete outcomes (e.g., [-2, -1, 0, 1, 2])
|
|
18
|
+
# @param probabilities [Hash, nil] Optional custom probabilities {outcome => probability}
|
|
19
|
+
# If nil, uses uniform distribution
|
|
20
|
+
#
|
|
21
|
+
# @example Uniform prior
|
|
22
|
+
# prior = Prior.new([-2, -1, 0, 1, 2])
|
|
23
|
+
#
|
|
24
|
+
# @example Custom prior favoring neutral outcome
|
|
25
|
+
# prior = Prior.new([-2, -1, 0, 1, 2], {-2 => 0.1, -1 => 0.15, 0 => 0.5, 1 => 0.15, 2 => 0.1})
|
|
26
|
+
def initialize(outcomes, probabilities = nil)
|
|
27
|
+
@outcomes = outcomes.sort
|
|
28
|
+
|
|
29
|
+
if probabilities
|
|
30
|
+
validate_probabilities!(probabilities)
|
|
31
|
+
@probabilities = probabilities
|
|
32
|
+
else
|
|
33
|
+
# Uniform prior: equal probability for all outcomes
|
|
34
|
+
uniform_prob = 1.0 / outcomes.size
|
|
35
|
+
@probabilities = outcomes.to_h do |outcome|
|
|
36
|
+
[outcome, uniform_prob]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Get probability for a specific outcome
|
|
42
|
+
#
|
|
43
|
+
# @param outcome [Numeric] The outcome to query
|
|
44
|
+
# @return [Float] Probability of the outcome
|
|
45
|
+
def probability(outcome)
|
|
46
|
+
@probabilities[outcome] || 0.0
|
|
47
|
+
end
|
|
48
|
+
alias [] probability
|
|
49
|
+
|
|
50
|
+
# Update prior based on observed outcome frequencies
|
|
51
|
+
#
|
|
52
|
+
# Uses Laplace smoothing (add-1 smoothing) to avoid zero probabilities
|
|
53
|
+
#
|
|
54
|
+
# @param observations [Hash] Observed outcome frequencies {outcome => count}
|
|
55
|
+
# @param smoothing [Float] Laplace smoothing parameter (default: 1.0)
|
|
56
|
+
# @return [Prior] New Prior instance with updated probabilities
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# observations = {-2 => 5, -1 => 10, 0 => 20, 1 => 12, 2 => 3}
|
|
60
|
+
# updated_prior = prior.update_from_observations(observations)
|
|
61
|
+
def update_from_observations(observations, smoothing: 1.0)
|
|
62
|
+
total_count = observations.values.sum
|
|
63
|
+
|
|
64
|
+
new_probabilities = @outcomes.each_with_object({}) do |outcome, hash|
|
|
65
|
+
# Laplace smoothing: (count + smoothing) / (total + smoothing * num_outcomes)
|
|
66
|
+
count = observations[outcome] || 0
|
|
67
|
+
hash[outcome] = (count + smoothing) / (total_count + (smoothing * @outcomes.size))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
self.class.new(@outcomes, new_probabilities)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Combine this prior with another using weighted average
|
|
74
|
+
#
|
|
75
|
+
# @param other_prior [Prior] Another prior distribution
|
|
76
|
+
# @param weight [Float] Weight for this prior (0.0 to 1.0)
|
|
77
|
+
# @return [Prior] New Prior with combined probabilities
|
|
78
|
+
def combine(other_prior, weight: 0.5)
|
|
79
|
+
unless outcomes_compatible?(other_prior)
|
|
80
|
+
raise ArgumentError, "Cannot combine priors with different outcomes"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
new_probabilities = @outcomes.to_h do |outcome|
|
|
84
|
+
[outcome, (weight * probability(outcome)) +
|
|
85
|
+
((1 - weight) * other_prior.probability(outcome))]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
self.class.new(@outcomes, new_probabilities)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Get entropy of the prior distribution
|
|
92
|
+
# Higher entropy = more uncertain/uniform, Lower entropy = more peaked/certain
|
|
93
|
+
#
|
|
94
|
+
# @return [Float] Shannon entropy in bits
|
|
95
|
+
def entropy
|
|
96
|
+
-@probabilities.values.reduce(0.0) do |sum, prob|
|
|
97
|
+
sum + (prob.positive? ? prob * Math.log2(prob) : 0)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Return a human-readable representation
|
|
102
|
+
#
|
|
103
|
+
# @return [String] Formatted probability distribution
|
|
104
|
+
def to_s
|
|
105
|
+
parts = @outcomes.map { |outcome| "#{outcome}: #{format('%.3f', probability(outcome))}" }
|
|
106
|
+
"Prior(#{parts.join(', ')})"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def validate_probabilities!(probabilities)
|
|
112
|
+
# Check all outcomes are present
|
|
113
|
+
missing = @outcomes - probabilities.keys
|
|
114
|
+
unless missing.empty?
|
|
115
|
+
raise ArgumentError, "Missing probabilities for outcomes: #{missing}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check probabilities sum to 1.0 (with small tolerance)
|
|
119
|
+
total = probabilities.values.sum
|
|
120
|
+
unless (total - 1.0).abs < 1e-6
|
|
121
|
+
raise ArgumentError, "Probabilities must sum to 1.0, got #{total}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check all probabilities are non-negative
|
|
125
|
+
negative = probabilities.select { |_k, v| v.negative? }
|
|
126
|
+
return if negative.empty?
|
|
127
|
+
raise ArgumentError, "Probabilities must be non-negative: #{negative}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def outcomes_compatible?(other_prior)
|
|
131
|
+
@outcomes == other_prior.outcomes
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|