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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/docs.yml +55 -0
  3. data/.quality/reek_baseline.txt +5 -0
  4. data/.rubocop.yml +224 -0
  5. data/CHANGELOG.md +33 -0
  6. data/CLAUDE.md +128 -0
  7. data/COMMITS.md +196 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +229 -0
  10. data/Rakefile +170 -0
  11. data/decision_support_techniques.md +391 -0
  12. data/docs/EXPLORATION.md +128 -0
  13. data/docs/api/index.md +66 -0
  14. data/docs/api/likelihood.md +78 -0
  15. data/docs/api/llm-elicitors.md +119 -0
  16. data/docs/api/llm-support.md +136 -0
  17. data/docs/api/posterior.md +106 -0
  18. data/docs/api/prior.md +81 -0
  19. data/docs/api/time-series-predictor.md +96 -0
  20. data/docs/assets/css/custom.css +25 -0
  21. data/docs/assets/diagrams/architecture.svg +75 -0
  22. data/docs/assets/diagrams/bayes-pipeline.svg +48 -0
  23. data/docs/assets/diagrams/kde.svg +52 -0
  24. data/docs/assets/diagrams/llm-bayes-loop.svg +58 -0
  25. data/docs/assets/diagrams/provider-resolution.svg +77 -0
  26. data/docs/assets/js/mathjax.js +18 -0
  27. data/docs/development.md +164 -0
  28. data/docs/examples/index.md +198 -0
  29. data/docs/getting-started/core-concepts.md +119 -0
  30. data/docs/getting-started/installation.md +112 -0
  31. data/docs/getting-started/quick-start.md +143 -0
  32. data/docs/guide/likelihood.md +132 -0
  33. data/docs/guide/posterior.md +135 -0
  34. data/docs/guide/predictor.md +191 -0
  35. data/docs/guide/prior.md +141 -0
  36. data/docs/guide/tuning.md +156 -0
  37. data/docs/guide/uncertainty.md +148 -0
  38. data/docs/index.md +110 -0
  39. data/docs/llm/index.md +124 -0
  40. data/docs/llm/likelihood-estimation.md +161 -0
  41. data/docs/llm/prior-elicitation.md +172 -0
  42. data/docs/llm/providers.md +184 -0
  43. data/docs/requirements.txt +8 -0
  44. data/lib/sqa/bi/likelihood.rb +167 -0
  45. data/lib/sqa/bi/llm_likelihood_estimator.rb +110 -0
  46. data/lib/sqa/bi/llm_prior_elicitor.rb +110 -0
  47. data/lib/sqa/bi/llm_support.rb +299 -0
  48. data/lib/sqa/bi/posterior.rb +189 -0
  49. data/lib/sqa/bi/prior.rb +135 -0
  50. data/lib/sqa/bi/time_series_predictor.rb +219 -0
  51. data/lib/sqa/bi/version.rb +7 -0
  52. data/lib/sqa/bi.rb +57 -0
  53. data/mkdocs.yml +174 -0
  54. metadata +272 -0
@@ -0,0 +1,191 @@
1
+ # The Predictor
2
+
3
+ `SQA::BI::TimeSeriesPredictor` is the facade over
4
+ [`Prior`](prior.md), [`Likelihood`](likelihood.md) and
5
+ [`Posterior`](posterior.md). Most code should use it rather than the three
6
+ directly.
7
+
8
+ ## Construction
9
+
10
+ ```ruby
11
+ predictor = SQA::BI::TimeSeriesPredictor.new(
12
+ outcomes: [-2, -1, 0, 1, 2],
13
+ bandwidth: 1.0,
14
+ prior_probabilities: nil, # nil = uniform
15
+ update_prior: true
16
+ )
17
+ ```
18
+
19
+ Or the shorthand, which takes the same keywords:
20
+
21
+ ```ruby
22
+ predictor = SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 1.0)
23
+ ```
24
+
25
+ | Parameter | Default | Effect |
26
+ |---|---|---|
27
+ | `outcomes:` | required | the discrete set; sorted on construction |
28
+ | `bandwidth:` | `1.0` | KDE kernel width — see [Tuning](tuning.md) |
29
+ | `prior_probabilities:` | `nil` | explicit prior; `nil` means uniform |
30
+ | `update_prior:` | `true` | relearn the prior from observed frequencies as you train |
31
+
32
+ ## Training
33
+
34
+ ```ruby
35
+ predictor.train([1.0, 2.0, 3.0], outcome: 1)
36
+ ```
37
+
38
+ Returns `self`, so it chains:
39
+
40
+ ```ruby
41
+ predictor
42
+ .train([1.0, 2.0, 3.0], outcome: 1)
43
+ .train([1.1, 2.1, 2.9], outcome: 1)
44
+ .train([-1.0, -2.0, 0.5], outcome: -2)
45
+ ```
46
+
47
+ In bulk:
48
+
49
+ ```ruby
50
+ predictor.train_batch([
51
+ { features: [1.0, 2.0, 3.0], outcome: 1 },
52
+ { features: [1.1, 2.1, 2.9], outcome: 1 }
53
+ ])
54
+ ```
55
+
56
+ ### Validation
57
+
58
+ Two checks run on every `train`:
59
+
60
+ **Features** must be an `Array` of `Numeric`, and must match the dimension
61
+ established by the first call:
62
+
63
+ ```
64
+ ArgumentError: Features must be array of numbers
65
+ ArgumentError: Feature dimension mismatch: expected 3, got 4
66
+ ```
67
+
68
+ **Outcome** must be in the declared set:
69
+
70
+ ```
71
+ ArgumentError: Invalid outcome: 7. Must be one of [-2, -1, 0, 1, 2]
72
+ ```
73
+
74
+ That second check is worth more than it looks — a typo'd outcome would
75
+ otherwise create a silent phantom class that the model can never predict and you
76
+ would never notice.
77
+
78
+ ## Predicting
79
+
80
+ ```ruby
81
+ posterior = predictor.predict([1.05, 2.05, 3.0]) # => Posterior
82
+ outcome = predictor.predict_outcome([1.05, 2.05, 3.0]) # => 1 (MAP only)
83
+ probs = predictor.predict_proba([1.05, 2.05, 3.0]) # => Hash
84
+ ```
85
+
86
+ `predict` is the one to reach for. The other two throw away the uncertainty,
87
+ which is the main thing this library exists to give you.
88
+
89
+ ## Inspecting state
90
+
91
+ ```ruby
92
+ predictor.trained? # => true
93
+ predictor.training_size # => 5391
94
+ predictor.training_distribution # => {-2 => 47, -1 => 1_305, 0 => 1_297, ...}
95
+ predictor.outcomes # => [-2, -1, 0, 1, 2]
96
+ predictor.feature_dimension # => 5
97
+ puts predictor.summary
98
+ ```
99
+
100
+ ```
101
+ TimeSeriesPredictor Summary:
102
+ =============================
103
+ Outcomes: [-2, -1, 0, 1, 2]
104
+ Feature Dimension: 5
105
+ Training Samples: 5391
106
+ Bandwidth: 0.5
107
+ Update Prior: true
108
+
109
+ Prior Distribution:
110
+ Prior(-2: 0.009, -1: 0.242, 0: 0.241, 1: 0.245, 2: 0.007)
111
+ Prior Entropy: 1.893 bits
112
+
113
+ Training Distribution:
114
+ -2: 47
115
+ -1: 1305
116
+ ...
117
+ ```
118
+
119
+ !!! tip "Read `training_distribution` before trusting anything"
120
+ Badly imbalanced classes are the most common reason a model looks broken.
121
+ In the stock demo the extreme bands (`-2`, `+2`) hold under 1% of samples
122
+ each — the KDE simply has too few neighbours there to estimate a density,
123
+ and those outcomes are effectively never predicted. That is visible
124
+ immediately in this hash, and invisible in an accuracy score.
125
+
126
+ ## Resetting and retuning
127
+
128
+ ```ruby
129
+ predictor.reset! # clears observations AND prior
130
+ predictor.reset!(keep_prior: true) # clears observations, keeps the prior
131
+ predictor.bandwidth = 0.5 # rebuilds the KDE, keeps observations
132
+ ```
133
+
134
+ `bandwidth=` is what makes a bandwidth sweep cheap — the observations survive,
135
+ only the estimator is rebuilt. See [Tuning](tuning.md).
136
+
137
+ ## A realistic workflow
138
+
139
+ ```ruby
140
+ require "sqa/bi"
141
+
142
+ BANDS = [
143
+ [-Float::INFINITY, -0.05, -2],
144
+ [-0.05, -0.005, -1],
145
+ [-0.005, 0.005, 0],
146
+ [ 0.005, 0.05, 1],
147
+ [ 0.05, Float::INFINITY, 2]
148
+ ]
149
+
150
+ def categorize(change)
151
+ BANDS.find { |low, high, _| change > low && change <= high }.last
152
+ end
153
+
154
+ predictor = SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 0.5)
155
+
156
+ # Features are deltas; the target is the NEXT period's move.
157
+ rows.each_cons(2) do |today, tomorrow|
158
+ predictor.train(
159
+ [today[:close_delta], today[:volume_delta], today[:rsi_delta]],
160
+ outcome: categorize(tomorrow[:close_change])
161
+ )
162
+ end
163
+
164
+ posterior = predictor.predict([latest[:close_delta],
165
+ latest[:volume_delta],
166
+ latest[:rsi_delta]])
167
+
168
+ if posterior.confidence > 0.3
169
+ puts "Signal: #{posterior.max_outcome} at #{(posterior.probability(posterior.max_outcome) * 100).round(1)}%"
170
+ else
171
+ puts "No signal — posterior is near uniform"
172
+ end
173
+ ```
174
+
175
+ !!! danger "Train on the past, test on the future"
176
+ Split chronologically, never randomly. A random split lets tomorrow's
177
+ observations inform today's prediction, and the resulting accuracy is
178
+ fiction. The stock demo splits 80/20 by date for exactly this reason.
179
+
180
+ ## Known limitation — one horizon per predictor
181
+
182
+ A predictor answers one question at one horizon. Predicting k = 1…10 days ahead
183
+ means ten predictors, and they will not share strength even though `P(up at day
184
+ 3)` and `P(up at day 4)` are strongly correlated. Extending
185
+ `TimeSeriesPredictor` to handle multiple horizons jointly is
186
+ [an open thread](../EXPLORATION.md).
187
+
188
+ ## API
189
+
190
+ See [`SQA::BI::TimeSeriesPredictor`](../api/time-series-predictor.md) for the
191
+ full method list.
@@ -0,0 +1,141 @@
1
+ # The Prior
2
+
3
+ `SQA::BI::Prior` holds `P(outcome)` — what you believe before looking at the
4
+ current feature vector.
5
+
6
+ ## Constructing one
7
+
8
+ ### Uniform
9
+
10
+ ```ruby
11
+ prior = SQA::BI::Prior.new([-2, -1, 0, 1, 2])
12
+ prior.probability(0) # => 0.2
13
+ prior.entropy # => 2.322 (maximum for 5 outcomes)
14
+ ```
15
+
16
+ Every outcome gets `1 / n`. This is the honest starting point when you know
17
+ nothing, and it is what `TimeSeriesPredictor` uses by default.
18
+
19
+ ### Custom
20
+
21
+ ```ruby
22
+ prior = SQA::BI::Prior.new(
23
+ [-2, -1, 0, 1, 2],
24
+ { -2 => 0.10, -1 => 0.15, 0 => 0.50, 1 => 0.15, 2 => 0.10 }
25
+ )
26
+ ```
27
+
28
+ Validation is strict and happens at construction:
29
+
30
+ | Condition | Result |
31
+ |---|---|
32
+ | An outcome has no probability | `ArgumentError: Missing probabilities for outcomes: [...]` |
33
+ | Probabilities don't sum to 1.0 (±1e-6) | `ArgumentError: Probabilities must sum to 1.0, got 0.97` |
34
+ | Any probability is negative | `ArgumentError: Probabilities must be non-negative` |
35
+
36
+ Outcomes are sorted on construction, so `to_s` and every downstream report read
37
+ in order regardless of the hash's insertion order.
38
+
39
+ ## Learning from observations
40
+
41
+ ```ruby
42
+ observations = { -2 => 5, -1 => 10, 0 => 20, 1 => 12, 2 => 3 }
43
+ updated = prior.update_from_observations(observations)
44
+ ```
45
+
46
+ This returns a **new** `Prior` — the original is untouched. Every method on this
47
+ class is non-mutating, which makes priors safe to share and trivial to test.
48
+
49
+ ### Laplace smoothing
50
+
51
+ The update is not a raw frequency count:
52
+
53
+ $$
54
+ P(\text{outcome}) = \frac{\text{count} + \alpha}{\text{total} + \alpha n}
55
+ $$
56
+
57
+ with `α = 1.0` by default, adjustable via `smoothing:`.
58
+
59
+ !!! quote "Why add-one smoothing is not optional"
60
+ A raw frequency of zero produces a prior probability of zero — and a zero
61
+ prior can **never recover**, no matter how much contradicting evidence
62
+ arrives later. The posterior multiplies prior by likelihood, and anything
63
+ times zero is zero.
64
+
65
+ This is Cromwell's rule, and it is the same reason
66
+ [LLM likelihoods are clamped](../llm/likelihood-estimation.md) away from 0
67
+ and 1. Smoothing buys the model the ability to change its mind.
68
+
69
+ Raising `smoothing:` pulls the prior toward uniform, which is what you want when
70
+ counts are small and you don't trust them yet:
71
+
72
+ ```ruby
73
+ prior.update_from_observations(observations, smoothing: 5.0)
74
+ ```
75
+
76
+ ## Combining priors
77
+
78
+ ```ruby
79
+ market_view = SQA::BI::Prior.new(outcomes, { ... })
80
+ analyst_view = SQA::BI::Prior.new(outcomes, { ... })
81
+
82
+ blended = market_view.combine(analyst_view, weight: 0.7)
83
+ ```
84
+
85
+ A weighted average: `weight` applies to the receiver, `1 - weight` to the
86
+ argument. Both priors must span the same outcomes or it raises
87
+ `ArgumentError: Cannot combine priors with different outcomes`.
88
+
89
+ This is the natural place to mix a data-driven prior with an
90
+ [LLM-elicited one](../llm/prior-elicitation.md) rather than choosing between
91
+ them.
92
+
93
+ ## Entropy
94
+
95
+ ```ruby
96
+ uniform = SQA::BI::Prior.new([-2, -1, 0, 1, 2])
97
+ uniform.entropy # => 2.322 maximally uncertain
98
+
99
+ peaked = SQA::BI::Prior.new([-2, -1, 0, 1, 2],
100
+ { -2 => 0.0, -1 => 0.0, 0 => 1.0, 1 => 0.0, 2 => 0.0 })
101
+ peaked.entropy # => 0.0 completely certain
102
+ ```
103
+
104
+ Shannon entropy in bits. Useful as a sanity check on an elicited prior: if an
105
+ LLM hands back something with entropy near zero, it has claimed certainty it
106
+ cannot possibly have, and you should distrust it.
107
+
108
+ ## Automatic updating inside the predictor
109
+
110
+ `TimeSeriesPredictor` updates its prior from observed frequencies as you train,
111
+ but only once there is enough data to be worth it:
112
+
113
+ ```ruby
114
+ if @update_prior && @likelihood.size >= @outcomes.size
115
+ @prior = @prior.update_from_observations(@likelihood.outcome_counts)
116
+ end
117
+ ```
118
+
119
+ The `size >= outcomes.size` guard prevents the first two or three observations
120
+ from stamping a lopsided prior onto the model. Disable the behavior entirely
121
+ when you want a fixed prior — which is exactly what you want after eliciting
122
+ one:
123
+
124
+ ```ruby
125
+ predictor = SQA::BI::TimeSeriesPredictor.new(
126
+ outcomes: OUTCOMES,
127
+ prior_probabilities: elicited.probabilities,
128
+ update_prior: false
129
+ )
130
+ ```
131
+
132
+ !!! note "The prior fades on its own"
133
+ With `update_prior: true` and growing data, the learned prior converges on
134
+ the empirical base rate. And regardless of the prior, as observations
135
+ accumulate the likelihood term increasingly dominates the product. A prior
136
+ matters most when data is scarce — which is precisely when you have the
137
+ least to go on and need it most.
138
+
139
+ ## API
140
+
141
+ See [`SQA::BI::Prior`](../api/prior.md) for the full method list.
@@ -0,0 +1,156 @@
1
+ # Tuning and Modeling Choices
2
+
3
+ Four decisions determine whether this works: **bandwidth**, **outcome bands**,
4
+ **feature design**, and **how you evaluate**. Only the first is a parameter; the
5
+ other three are modeling, and they matter more.
6
+
7
+ ## Bandwidth
8
+
9
+ The kernel width `h`. It is scale-dependent, so there is no universally good
10
+ value — only a value appropriate to *your* features.
11
+
12
+ ### Sweeping it
13
+
14
+ Because `bandwidth=` rebuilds the estimator from existing observations, a sweep
15
+ is cheap:
16
+
17
+ ```ruby
18
+ predictor = SQA::BI.predictor(outcomes: OUTCOMES, bandwidth: 1.0)
19
+ predictor.train_batch(training_set)
20
+
21
+ [0.1, 0.25, 0.5, 1.0, 2.0, 4.0].each do |h|
22
+ predictor.bandwidth = h
23
+
24
+ correct = test_set.count do |row|
25
+ predictor.predict_outcome(row[:features]) == row[:outcome]
26
+ end
27
+
28
+ mean_confidence = test_set.sum { predictor.predict(_1[:features]).confidence } / test_set.size
29
+
30
+ puts format("h=%-5s accuracy=%.3f mean confidence=%.3f",
31
+ h, correct.to_f / test_set.size, mean_confidence)
32
+ end
33
+ ```
34
+
35
+ Watch both columns. Accuracy alone will happily pick a bandwidth that is
36
+ confidently wrong.
37
+
38
+ ### Reading the failure modes
39
+
40
+ | Symptom | Likely cause |
41
+ |---|---|
42
+ | Every posterior is near-uniform | `h` too large — outcomes have smeared together |
43
+ | Confidence is high but accuracy is at chance | `h` too small — memorizing training points |
44
+ | One outcome dominates every prediction | class imbalance, not bandwidth |
45
+ | Predictions barely move from the prior | features carry no signal — check `kl_divergence_from_prior` |
46
+
47
+ That last row is worth checking before touching `h` at all. If mean KL
48
+ divergence across your test set is near zero, no bandwidth will save you; the
49
+ features are the problem.
50
+
51
+ ### A starting heuristic
52
+
53
+ Scale `h` to the typical spread of your features. If each feature is a
54
+ percentage change mostly within ±2, start near `0.5`. If they are raw prices in
55
+ the hundreds, either normalize them — strongly preferred — or start `h` in the
56
+ tens.
57
+
58
+ !!! tip "Normalize instead of compensating"
59
+ A single bandwidth applies to a Euclidean distance across *all* dimensions
60
+ at once. If one feature ranges over ±200 and another over ±0.5, the first
61
+ completely dominates the distance and the second may as well not exist.
62
+ Standardize features to comparable scales and the bandwidth becomes a single
63
+ meaningful knob instead of a compromise.
64
+
65
+ ## Outcome bands
66
+
67
+ Where you cut the continuous target into discrete bands decides what the model
68
+ can learn.
69
+
70
+ ```ruby
71
+ BANDS = [
72
+ [-Float::INFINITY, -0.05, -2], # big down
73
+ [-0.05, -0.005, -1], # small down
74
+ [-0.005, 0.005, 0], # sideways
75
+ [ 0.005, 0.05, 1], # small up
76
+ [ 0.05, Float::INFINITY, 2] # big up
77
+ ]
78
+ ```
79
+
80
+ Two competing pressures:
81
+
82
+ **Wide bands** collect plenty of observations per class, so density estimation
83
+ is stable — but the prediction says little ("it went up somewhat").
84
+
85
+ **Narrow bands** carry more information per prediction — but starve. In the
86
+ stock demo the extreme bands hold under 1% of samples each, and the model
87
+ essentially never predicts them. Check `training_distribution` and treat any
88
+ class under ~5% as decorative.
89
+
90
+ !!! note "Asymmetric bands are legitimate"
91
+ Nothing requires symmetry. If a 2% drop costs you more than a 2% gain earns,
92
+ cut the downside more finely. The bands should encode the decisions you
93
+ actually face, not statistical tidiness.
94
+
95
+ ## Feature design
96
+
97
+ This is where the leverage is.
98
+
99
+ **Use deltas, not levels.** A price of 187.40 is not transferable; a change of
100
+ +0.8% is. Absolute levels make the KDE memorize a regime that will not recur.
101
+
102
+ **Keep the dimension modest.** Every added dimension dilutes Euclidean distance
103
+ — the curse of dimensionality bites KDE hard. Three to six informative features
104
+ beat twenty mediocre ones.
105
+
106
+ **Drop redundant features.** Two features that move together add distance
107
+ without adding information, and effectively double-weight whatever they both
108
+ measure.
109
+
110
+ **Check that each feature moves the posterior.** Add one, measure mean KL
111
+ divergence over a held-out set, keep it only if the number rises.
112
+
113
+ ```ruby
114
+ def mean_information_gain(predictor, test_set)
115
+ test_set.sum { predictor.predict(_1[:features]).kl_divergence_from_prior } / test_set.size
116
+ end
117
+ ```
118
+
119
+ ## Evaluation
120
+
121
+ **Split chronologically.** Always. A random split leaks the future into the
122
+ past and produces accuracy that does not survive contact with reality.
123
+
124
+ **Compare against the base rate, not zero.** If 40% of days are "small up",
125
+ 40% accuracy is worthless. The honest baseline is always-predict-the-majority.
126
+
127
+ **Look at the confusion matrix.** Accuracy hides the shape of the errors. The
128
+ stock demo prints one, and it shows the model collapsing onto the middle three
129
+ bands — visible instantly in the matrix, invisible in a single number.
130
+
131
+ **Check calibration.** Bucket predictions by confidence and measure accuracy per
132
+ bucket. If they do not rise together, the confidence number is noise.
133
+
134
+ !!! danger "The failure mode this library makes easy"
135
+ A Bayesian posterior is internally coherent by construction. That coherence
136
+ is seductive: the numbers look principled, the entropy is precise, the
137
+ summary reads like a real report. None of it establishes that the model
138
+ predicts anything. Backtest first, believe second.
139
+
140
+ ## Prior selection
141
+
142
+ With `update_prior: true` (the default) the prior converges on the empirical
143
+ base rate as data accumulates, and you can mostly leave it alone.
144
+
145
+ Set an explicit prior when:
146
+
147
+ - **You are cold-starting.** Few observations, but you know something about the
148
+ domain. Consider [LLM elicitation](../llm/prior-elicitation.md).
149
+ - **The regime just changed.** The historical base rate is actively misleading —
150
+ learning the prior from pre-change data will fight you.
151
+ - **You are testing prior sensitivity.** Run the same features under several
152
+ priors; if the posterior swings wildly, your evidence is weak and you should
153
+ say so rather than picking the prior you like.
154
+
155
+ When you supply an elicited prior, set `update_prior: false` — otherwise
156
+ training will quietly overwrite the knowledge you just injected.
@@ -0,0 +1,148 @@
1
+ # Quantifying Uncertainty
2
+
3
+ The reason to compute a posterior instead of a label is that the posterior knows
4
+ how sure it is. Three measures, each answering a different question.
5
+
6
+ ## Entropy — how spread out is the belief?
7
+
8
+ ```ruby
9
+ posterior.entropy # => 1.371 bits
10
+ ```
11
+
12
+ $$
13
+ H = -\sum_{i} p_i \log_2 p_i
14
+ $$
15
+
16
+ | Distribution over 5 outcomes | Entropy |
17
+ |---|---|
18
+ | Certain — `{1 => 1.0}` | 0.000 |
19
+ | Confident — `{1 => 0.74, ...}` | ≈ 1.37 |
20
+ | Uniform — all `0.2` | 2.322 |
21
+
22
+ The maximum is `log₂(n)`: **2.322** for five outcomes, **1.585** for three.
23
+ Entropy is absolute, in bits, and comparable only across distributions with the
24
+ same number of outcomes.
25
+
26
+ ## Confidence — the normalized version
27
+
28
+ ```ruby
29
+ posterior.confidence # => 0.412
30
+ ```
31
+
32
+ $$
33
+ \text{confidence} = 1 - \frac{H}{\log_2 n}
34
+ $$
35
+
36
+ Always in `[0, 1]`, regardless of how many outcomes you have, which makes it the
37
+ right thing to threshold on:
38
+
39
+ ```ruby
40
+ if posterior.confidence < 0.25
41
+ :abstain
42
+ else
43
+ posterior.max_outcome
44
+ end
45
+ ```
46
+
47
+ !!! warning "Confidence is not accuracy"
48
+ A confidence of 0.9 means the distribution is *sharp*, not that it is
49
+ *right*. A model with a badly wrong prior, or features that encode a
50
+ spurious correlation, will be sharply and consistently wrong. Confidence
51
+ measures internal coherence; only a backtest measures correctness.
52
+
53
+ ??? tip "Calibrating a threshold"
54
+ Don't pick 0.25 because it appears above. Bucket your backtest predictions
55
+ by confidence and measure accuracy per bucket:
56
+
57
+ ```ruby
58
+ results.group_by { |r| (r[:confidence] * 10).floor / 10.0 }
59
+ .transform_values { |rs| rs.count { _1[:correct] } / rs.size.to_f }
60
+ ```
61
+
62
+ If accuracy doesn't rise with confidence, the confidence number is not
63
+ carrying information and thresholding on it will not help.
64
+
65
+ ## KL divergence — how much did this evidence teach me?
66
+
67
+ ```ruby
68
+ posterior.kl_divergence_from_prior # => 0.943 bits
69
+ ```
70
+
71
+ $$
72
+ D_{KL}(\text{posterior} \parallel \text{prior}) =
73
+ \sum_i p_i \log_2 \frac{p_i}{q_i}
74
+ $$
75
+
76
+ Always ≥ 0. Zero means the posterior equals the prior — the features moved your
77
+ belief not at all.
78
+
79
+ This is the measure with no equivalent in a plain classifier, and it answers a
80
+ genuinely different question. A prediction can be *confident* while teaching you
81
+ *nothing*, if the prior was already confident:
82
+
83
+ | Confidence | KL divergence | Reading |
84
+ |---|---|---|
85
+ | High | High | Sharp belief, driven by the evidence — the good case |
86
+ | High | Low | Sharp, but it is just your prior talking |
87
+ | Low | High | Evidence moved you a lot, toward uncertainty — informative! |
88
+ | Low | Low | Nothing happened |
89
+
90
+ That third row is the underrated one. Evidence that *destroys* a confident prior
91
+ is highly informative even though the result is uncertain.
92
+
93
+ ### Finding which evidence mattered
94
+
95
+ When [chaining updates](posterior.md#chaining-updates-across-several-pieces-of-evidence),
96
+ record the KL divergence at each step:
97
+
98
+ ```ruby
99
+ evidence_list.each do |evidence|
100
+ likelihoods = estimator.likelihoods(evidence)
101
+ posterior = SQA::BI::Posterior.new(prior, likelihoods)
102
+
103
+ puts "#{evidence}: #{posterior.kl_divergence_from_prior.round(3)} bits"
104
+
105
+ prior = SQA::BI::Prior.new(hypotheses.keys, posterior.to_h)
106
+ end
107
+ ```
108
+
109
+ In the [diagnosis demo](../llm/likelihood-estimation.md) this prints per-update
110
+ gains from 0.025 to 0.959 bits — and the large values land precisely on the two
111
+ facts that reversed the conclusion. The engine tells you not just what it
112
+ believes but *why*.
113
+
114
+ ## Sampling — carrying uncertainty downstream
115
+
116
+ ```ruby
117
+ posterior.samples(10_000, rng: Random.new(42))
118
+ ```
119
+
120
+ Use this when the next stage of your pipeline cannot accept a distribution.
121
+ Instead of collapsing to `max_outcome` and pretending, sample repeatedly and
122
+ look at the spread of results:
123
+
124
+ ```ruby
125
+ returns = posterior.samples(10_000).map { |outcome| simulate_pnl(outcome) }
126
+ returns.sum / returns.size # expected value
127
+ returns.sort[(returns.size * 0.05).to_i] # 5th percentile — the downside
128
+ ```
129
+
130
+ The point estimate would have given you the first number and hidden the second.
131
+
132
+ ## Reading all of it at once
133
+
134
+ ```ruby
135
+ puts posterior.summary
136
+ ```
137
+
138
+ ```
139
+ Most Likely: 1 (74.0%)
140
+ Confidence: 41.2%
141
+ Entropy: 1.371 bits
142
+ KL Divergence from Prior: 0.943 bits
143
+ ```
144
+
145
+ A confidence of 41% next to a 74% top outcome is not a contradiction — 74% of
146
+ the mass is on one outcome, but the remaining 26% is spread widely enough to
147
+ keep overall entropy substantial. Both numbers are telling the truth about
148
+ different aspects of the same distribution.