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,119 @@
1
+ # Core Concepts
2
+
3
+ Four ideas carry the whole library: **outcomes**, **features**, **Bayes'
4
+ theorem**, and **the posterior as the deliverable**.
5
+
6
+ ## Outcomes are discrete and ordered
7
+
8
+ `SQA::BI` predicts over a finite set you choose up front:
9
+
10
+ ```ruby
11
+ outcomes: [-2, -1, 0, 1, 2]
12
+ ```
13
+
14
+ Two properties matter.
15
+
16
+ **Discrete.** Not a continuous value. You are not predicting "+2.7%", you are
17
+ predicting which *band* the move falls in. That is a deliberate reduction: bands
18
+ collect enough observations per class for density estimation to work, and they
19
+ map directly onto decisions ("act" / "wait" / "act the other way").
20
+
21
+ **Ordered.** `-2 < -1 < 0 < 1 < 2` means something. Being wrong by one level is
22
+ a smaller error than being wrong by four. The library does not *enforce* the
23
+ ordering, but the outcome set is sorted on construction and every report prints
24
+ in order, so the structure stays visible.
25
+
26
+ ??? tip "How many levels?"
27
+ Three is the minimum that says anything ("down / flat / up"). Five is the
28
+ common default. Beyond about seven, each level starts starving for
29
+ observations and the KDE estimate gets noisy — you gain resolution on paper
30
+ and lose it in practice. If you need more granularity, get more data first.
31
+
32
+ ## Features are a numeric vector
33
+
34
+ Every observation pairs a feature vector with the outcome that followed:
35
+
36
+ ```ruby
37
+ { features: [1.0, 2.0, 3.0], outcome: 1 }
38
+ ```
39
+
40
+ The vector must be numeric and every vector must have the same length. Beyond
41
+ that the library is indifferent to what the numbers mean.
42
+
43
+ !!! important "Prefer deltas over levels"
44
+ Feed *changes*, not absolute values. A close price of 187.40 tells the model
45
+ nothing transferable; a close price change of +0.8% does. Absolute levels
46
+ make the KDE memorize a price regime it will never see again — the stock
47
+ demo therefore builds every feature as a delta from the previous day.
48
+
49
+ The same logic applies outside markets: rate of change generalizes,
50
+ raw level rarely does.
51
+
52
+ ## Bayes' theorem, in three objects
53
+
54
+ ![Prior × Likelihood → Posterior](../assets/diagrams/bayes-pipeline.svg)
55
+
56
+ $$
57
+ P(\text{outcome} \mid \text{data}) =
58
+ \frac{P(\text{data} \mid \text{outcome}) \times P(\text{outcome})}{P(\text{data})}
59
+ $$
60
+
61
+ Each term is a class:
62
+
63
+ **[`Prior`](../guide/prior.md) — `P(outcome)`**
64
+ : What you believed before seeing this feature vector. Uniform by default.
65
+ Can be set explicitly, learned from observed frequencies with Laplace
66
+ smoothing, or [elicited from an LLM](../llm/prior-elicitation.md).
67
+
68
+ **[`Likelihood`](../guide/likelihood.md) — `P(data | outcome)`**
69
+ : How typical these features are of each outcome, estimated by Gaussian kernel
70
+ density over the observations you trained on. This is where the data enters.
71
+
72
+ **[`Posterior`](../guide/posterior.md) — `P(outcome | data)`**
73
+ : The product, normalized. Your updated belief.
74
+
75
+ `P(data)`, the evidence, is just the normalizing constant — the sum over all
76
+ outcomes. Ruby computes it exactly, every time.
77
+
78
+ ## The posterior is the deliverable
79
+
80
+ Most classifiers hand back a label and discard everything else. Here the
81
+ distribution *is* the result, and it carries its own quality report:
82
+
83
+ | Measure | Range | Reading |
84
+ |---|---|---|
85
+ | `entropy` | 0 → log₂(n) | 0 = certain; log₂(5) ≈ 2.322 = a coin toss over 5 outcomes |
86
+ | `confidence` | 0 → 1 | `1 − entropy / max_entropy`; 0 = uniform, 1 = certain |
87
+ | `kl_divergence_from_prior` | ≥ 0 | bits of information this evidence added |
88
+
89
+ The third one answers a question a label never can: *did this observation
90
+ actually teach me anything?* A KL divergence near zero means the features moved
91
+ your belief hardly at all — the prediction is just your prior in a costume.
92
+
93
+ See [Quantifying Uncertainty](../guide/uncertainty.md) for how to use each.
94
+
95
+ ## How the pieces connect
96
+
97
+ ![SQA::BI architecture](../assets/diagrams/architecture.svg)
98
+
99
+ [`TimeSeriesPredictor`](../guide/predictor.md) is the facade. It owns the
100
+ outcome set and bandwidth, holds a `Prior` and a `Likelihood`, and builds a
101
+ fresh `Posterior` on every `predict`. You can use the three underlying classes
102
+ directly when you want finer control — they have no hidden coupling.
103
+
104
+ ## What this is not
105
+
106
+ **Not a time series model.** Despite the name, `TimeSeriesPredictor` has no
107
+ notion of sequence, autocorrelation, or seasonality. It maps a feature vector to
108
+ a distribution. Any temporal structure you want must be *encoded into the
109
+ features* — a lag, a rolling mean, a momentum delta.
110
+
111
+ **Not multi-horizon.** One predictor answers one question at one horizon. For
112
+ k = 1…10 days you currently need ten predictors, and they will not share
113
+ strength across horizons even though adjacent horizons are strongly correlated.
114
+ That limitation is [a known open thread](../EXPLORATION.md).
115
+
116
+ **Not calibrated by construction.** A Bayesian posterior is coherent, which is
117
+ not the same as calibrated. If your priors are wrong or your features are
118
+ uninformative, the numbers will be confidently wrong in a mathematically
119
+ impeccable way. Backtest before believing.
@@ -0,0 +1,112 @@
1
+ # Installation
2
+
3
+ ## Requirements
4
+
5
+ - **Ruby >= 3.2.0** — the workspace's active development Ruby is 4.0.x via rbenv
6
+ - **No runtime dependencies.** The core math is plain Ruby.
7
+
8
+ `ruby_llm` is needed only for the LLM-backed prior and likelihood paths, and it
9
+ is required *lazily* inside `LlmSupport.build_chat`. The library loads, and
10
+ kernel density estimation works, without it installed.
11
+
12
+ ## As a gem
13
+
14
+ ```bash
15
+ gem install sqa-bi
16
+ ```
17
+
18
+ Or in a `Gemfile`:
19
+
20
+ ```ruby
21
+ gem "sqa-bi"
22
+ ```
23
+
24
+ Then:
25
+
26
+ ```ruby
27
+ require "sqa/bi"
28
+ ```
29
+
30
+ !!! note "The require path is `sqa/bi`, not `sqa-bi`"
31
+ The gem is named `sqa-bi` with a hyphen, matching `sqa-tai`, but the entry
32
+ point is `lib/sqa/bi.rb`. `require "sqa-bi"` raises `LoadError`.
33
+
34
+ ## Optional — the LLM paths
35
+
36
+ To use [`LlmPriorElicitor`](../llm/prior-elicitation.md) or
37
+ [`LlmLikelihoodEstimator`](../llm/likelihood-estimation.md):
38
+
39
+ ```ruby
40
+ gem "ruby_llm", ">= 2.0"
41
+
42
+ # Local providers, required lazily. Their absence disables only the
43
+ # corresponding provider; it never breaks the cloud path or the KDE path.
44
+ gem "ruby_llm-providers-lms", require: false # LM Studio
45
+ gem "ruby_llm-providers-apfel", require: false # Apple Foundation Models
46
+ ```
47
+
48
+ See [Providers and Local Models](../llm/providers.md) for the setup that makes
49
+ local inference actually engage — which takes more than installing these gems.
50
+
51
+ ## Development in the SQA workspace
52
+
53
+ `sqa-bi` lives in the `sqa_project` workspace alongside `sqa`, `sqa-tai`,
54
+ `sqa-cli`, `sqa-advisor`, and the two demo apps.
55
+
56
+ ```bash
57
+ cd ~/sandbox/git_repos/madbomber/projects/sqa_project/sqa-bi
58
+ bundle install
59
+ bundle exec rake test
60
+ ```
61
+
62
+ ### Bundle mode
63
+
64
+ The workspace root `.envrc` reads `.bundle_mode` and exports `BUNDLE_GEMFILE`
65
+ for every repo:
66
+
67
+ | Mode | Gemfile | Resolves |
68
+ |---|---|---|
69
+ | `dev` (default) | `Gemfile.local` | sibling checkouts via `path:` overrides |
70
+ | `prod` | `Gemfile` | released gems only |
71
+
72
+ Switch from the workspace root with `asgard dev` / `asgard prod`; direnv reloads
73
+ every shell at its next prompt.
74
+
75
+ `sqa-bi`'s `Gemfile.local` adds `sqa` and `sqa-tai` from sibling checkouts. It
76
+ does that **only** so the market-facing demo can run — the gem itself must never
77
+ gain a `sqa` dependency, or the dependency graph inverts.
78
+
79
+ ??? info "The `ruby_llm` version conflict, and why it resolves"
80
+ `sqa` depends on `shared_tools`, which depends on `ruby_llm-mcp`, which pins
81
+ `ruby_llm ~> 1.9`. Meanwhile `sqa` itself and both local provider gems
82
+ require `ruby_llm >= 2.0`. That looks unresolvable.
83
+
84
+ Bundler handles it by backing `shared_tools` down to 0.2.3, which has no
85
+ `ruby_llm-mcp` dependency at all — the same resolution `sqa`'s own
86
+ `Gemfile.local.lock` lands on. Verified: `Gemfile.local` here resolves
87
+ `sqa 0.4.0` + `ruby_llm 2.0.0` + `shared_tools 0.2.3`, and the stock demo
88
+ runs end to end under `bundle exec`.
89
+
90
+ If a future `shared_tools` release drops the pin, this note becomes moot.
91
+
92
+ ### Quality gates
93
+
94
+ ```bash
95
+ asgard test # or: bundle exec rake test
96
+ asgard quality # tests + coverage, Flog, Flay, Reek
97
+ asgard rubocop
98
+ ```
99
+
100
+ See [Development](../development.md) for what each gate enforces and which
101
+ smells are deliberately grandfathered.
102
+
103
+ ## Documentation
104
+
105
+ This site is built with MkDocs and the Material theme:
106
+
107
+ ```bash
108
+ mkdocs serve # live reload at http://127.0.0.1:8000
109
+ mkdocs build # static site into site/
110
+ ```
111
+
112
+ `site/` is gitignored.
@@ -0,0 +1,143 @@
1
+ # Quick Start
2
+
3
+ A working predictor, end to end, in one file.
4
+
5
+ ## 1. Decide what you are predicting
6
+
7
+ Outcomes are a small, **ordered** set. Five levels is a good default — enough
8
+ resolution to be useful, few enough that each level collects observations:
9
+
10
+ ```ruby
11
+ require "sqa/bi"
12
+
13
+ OUTCOMES = [-2, -1, 0, 1, 2]
14
+ LABELS = {
15
+ -2 => "strong downtrend",
16
+ -1 => "mild downtrend",
17
+ 0 => "sideways",
18
+ 1 => "mild uptrend",
19
+ 2 => "strong uptrend"
20
+ }
21
+ ```
22
+
23
+ The integers are just keys — any comparable object works — but signed integers
24
+ centered on zero make the ordering obvious and read well in output.
25
+
26
+ ## 2. Build a predictor
27
+
28
+ ```ruby
29
+ predictor = SQA::BI.predictor(outcomes: OUTCOMES, bandwidth: 1.0)
30
+ ```
31
+
32
+ `bandwidth` controls how sharply the likelihood distinguishes nearby feature
33
+ vectors. Start at `1.0` and tune once you have data — see
34
+ [Tuning](../guide/tuning.md).
35
+
36
+ ## 3. Train on history
37
+
38
+ Every observation is a numeric feature vector plus the outcome that followed it.
39
+
40
+ ```ruby
41
+ predictor.train([0.8, 1.9, 3.1], outcome: 1)
42
+ predictor.train([1.0, 2.0, 3.0], outcome: 1)
43
+ predictor.train([1.1, 2.1, 2.9], outcome: 1)
44
+ predictor.train([0.2, 0.1, 0.0], outcome: 0)
45
+ predictor.train([-1.0, -2.0, 0.5], outcome: -2)
46
+ ```
47
+
48
+ Or in bulk:
49
+
50
+ ```ruby
51
+ observations = [
52
+ { features: [0.8, 1.9, 3.1], outcome: 1 },
53
+ { features: [1.0, 2.0, 3.0], outcome: 1 },
54
+ { features: [-1.0, -2.0, 0.5], outcome: -2 }
55
+ ]
56
+ predictor.train_batch(observations)
57
+ ```
58
+
59
+ !!! warning "Feature vectors must all have the same dimension"
60
+ The first `train` call fixes the dimension. Any later vector of a different
61
+ length raises `ArgumentError`. `reset!` clears it.
62
+
63
+ ## 4. Predict
64
+
65
+ ```ruby
66
+ posterior = predictor.predict([1.05, 2.05, 3.0])
67
+ ```
68
+
69
+ You now have a distribution, not an answer:
70
+
71
+ ```ruby
72
+ posterior.max_outcome # => 1
73
+ posterior.probability(1) # => 0.74
74
+ posterior.top_outcomes(3) # => [[1, 0.74], [0, 0.15], [-1, 0.06]]
75
+ posterior.to_h # => {-2 => 0.01, -1 => 0.06, 0 => 0.15, ...}
76
+ posterior.confidence # => 0.41
77
+ posterior.entropy # => 1.37
78
+ posterior.kl_divergence_from_prior # => 0.94
79
+ ```
80
+
81
+ `puts posterior.summary` prints all of it:
82
+
83
+ ```
84
+ Posterior Distribution Summary:
85
+ ================================
86
+ Most Likely: 1 (74.0%)
87
+ Confidence: 41.2%
88
+ Entropy: 1.371 bits
89
+ KL Divergence from Prior: 0.943 bits
90
+
91
+ Probabilities:
92
+ -2: 0.010 (1.0%)
93
+ -1: 0.060 (6.0%)
94
+ 0: 0.150 (15.0%)
95
+ 1: 0.740 (74.0%)
96
+ 2: 0.040 (4.0%)
97
+ ```
98
+
99
+ ## 5. Act on the uncertainty, not just the answer
100
+
101
+ This is the part that makes a posterior worth having:
102
+
103
+ ```ruby
104
+ if posterior.confidence < 0.25
105
+ # Near-uniform. The model does not know. Don't pretend otherwise.
106
+ :abstain
107
+ else
108
+ posterior.max_outcome
109
+ end
110
+ ```
111
+
112
+ Or propagate the whole distribution downstream:
113
+
114
+ ```ruby
115
+ outcomes = posterior.samples(10_000)
116
+ outcomes.tally.sort.to_h
117
+ # => {-2 => 98, -1 => 602, 0 => 1_503, 1 => 7_401, 2 => 396}
118
+ ```
119
+
120
+ ## The whole thing
121
+
122
+ ```ruby
123
+ require "sqa/bi"
124
+
125
+ predictor = SQA::BI.predictor(outcomes: [-2, -1, 0, 1, 2], bandwidth: 1.0)
126
+
127
+ predictor.train_batch([
128
+ { features: [0.8, 1.9, 3.1], outcome: 1 },
129
+ { features: [1.0, 2.0, 3.0], outcome: 1 },
130
+ { features: [1.1, 2.1, 2.9], outcome: 1 },
131
+ { features: [0.2, 0.1, 0.0], outcome: 0 },
132
+ { features: [-1.0, -2.0, 0.5], outcome: -2 }
133
+ ])
134
+
135
+ posterior = predictor.predict([1.05, 2.05, 3.0])
136
+ puts posterior.summary
137
+ ```
138
+
139
+ ## Next
140
+
141
+ - [Core Concepts](core-concepts.md) — what the three pieces actually do
142
+ - [The Predictor](../guide/predictor.md) — the full training and prediction workflow
143
+ - [Examples](../examples/index.md) — five runnable demos, including real market data
@@ -0,0 +1,132 @@
1
+ # The Likelihood
2
+
3
+ `SQA::BI::Likelihood` estimates `P(features | outcome)` — given that an outcome
4
+ occurred, how typical is this feature vector?
5
+
6
+ This is where your data enters the calculation.
7
+
8
+ ## Kernel density estimation
9
+
10
+ ![Gaussian kernel density estimation](../assets/diagrams/kde.svg)
11
+
12
+ There is no fitted model here — no coefficients, no training loop. The
13
+ observations *are* the model. To estimate the density for an outcome:
14
+
15
+ 1. Take every stored observation whose outcome matches.
16
+ 2. Measure the Euclidean distance from each to the query vector.
17
+ 3. Convert each distance to a kernel value — near observations contribute a lot,
18
+ distant ones almost nothing.
19
+ 4. Average them.
20
+
21
+ $$
22
+ P(x \mid \text{outcome}) = \frac{1}{n} \sum_{i=1}^{n} K\!\left(\frac{\lVert x - x_i \rVert}{h}\right)
23
+ \qquad
24
+ K(u) = \frac{1}{\sqrt{2\pi}} e^{-u^2/2}
25
+ $$
26
+
27
+ Across all outcomes the results are normalized to sum to 1.
28
+
29
+ !!! info "This is *non-parametric*, and that cuts both ways"
30
+ Nothing is assumed about the shape of each outcome's feature distribution —
31
+ it can be multimodal, skewed, whatever the data says. The cost is that every
32
+ prediction is O(observations), memory grows linearly with training, and
33
+ sparse regions of feature space give unreliable estimates.
34
+
35
+ ## Using it
36
+
37
+ ```ruby
38
+ observations = [
39
+ { features: [1.0, 2.0, 3.0], outcome: 1 },
40
+ { features: [1.1, 2.1, 2.9], outcome: 1 },
41
+ { features: [-1.0, -2.0, 0.5], outcome: -2 }
42
+ ]
43
+
44
+ likelihood = SQA::BI::Likelihood.new(observations, bandwidth: 1.0)
45
+ likelihood.compute([1.0, 2.0, 3.0], [-2, -1, 0, 1, 2])
46
+ # => {-2 => 0.05, -1 => 0.10, 0 => 0.15, 1 => 0.60, 2 => 0.10}
47
+ ```
48
+
49
+ Add as you go:
50
+
51
+ ```ruby
52
+ likelihood.add_observation([0.9, 1.8, 3.2], 1)
53
+ likelihood.size # => 4
54
+ likelihood.outcome_counts # => {1 => 3, -2 => 1}
55
+ likelihood.empty? # => false
56
+ ```
57
+
58
+ `outcome_counts` is what feeds the predictor's
59
+ [automatic prior update](prior.md#automatic-updating-inside-the-predictor).
60
+
61
+ ## Bandwidth
62
+
63
+ `bandwidth` (`h`) sets the width of every kernel and is the single most
64
+ consequential knob in the library.
65
+
66
+ | Bandwidth | Behavior | Failure mode |
67
+ |---|---|---|
68
+ | Small (0.1–0.3) | Sharp, local, sensitive to fine structure | Overfits; a query far from any observation gets near-zero density everywhere |
69
+ | Medium (0.5–1.0) | The usual working range | — |
70
+ | Large (2.0+) | Heavily smoothed, stable | Underfits; outcomes become indistinguishable and the posterior collapses toward the prior |
71
+
72
+ Changing it rebuilds the estimator from the existing observations — nothing is
73
+ lost:
74
+
75
+ ```ruby
76
+ predictor.bandwidth = 0.5
77
+ ```
78
+
79
+ The right value depends on the **scale of your features**. Percentage changes
80
+ around ±2 want a much smaller bandwidth than raw prices around 200. See
81
+ [Tuning](tuning.md) for how to search for it honestly.
82
+
83
+ ## Edge cases, and what they do
84
+
85
+ ### No observations at all
86
+
87
+ ```ruby
88
+ SQA::BI::Likelihood.new([], bandwidth: 1.0)
89
+ .compute([1.0, 2.0], [-1, 0, 1])
90
+ # => {-1 => 0.333, 0 => 0.333, 1 => 0.333}
91
+ ```
92
+
93
+ Uniform. With no data, the likelihood contributes nothing and the posterior is
94
+ exactly the prior — which is the correct answer, and a useful thing to see in
95
+ output rather than an exception.
96
+
97
+ ### An outcome with no observations
98
+
99
+ `estimate_density` returns `1e-10` rather than `0.0`. Same reasoning as
100
+ [Laplace smoothing on the prior](prior.md#laplace-smoothing): an exact zero is
101
+ unrecoverable. A never-yet-observed outcome should be *very* unlikely, not
102
+ *impossible*.
103
+
104
+ ### Mismatched dimensions
105
+
106
+ ```ruby
107
+ # ArgumentError: Feature vectors must have same dimension
108
+ ```
109
+
110
+ Raised from `euclidean_distance`. `TimeSeriesPredictor` catches this earlier
111
+ with a clearer message, which is one reason to prefer the facade.
112
+
113
+ ## Performance
114
+
115
+ Each `compute` is O(n · d) — every stored observation, every dimension. In
116
+ practice that is fine at the scale this library targets: the stock demo trains
117
+ on 5,391 observations and predicts across 1,348 test days in seconds.
118
+
119
+ If you push into hundreds of thousands of observations, the honest answer is
120
+ that plain KDE is the wrong tool and you want a tree ensemble. That comparison
121
+ is worked through in the [exploration notes](../EXPLORATION.md).
122
+
123
+ ## When your evidence is text
124
+
125
+ KDE needs numbers. For evidence that has no numeric form — a headline, a log
126
+ line, an earnings call — see
127
+ [LLM Likelihood Estimation](../llm/likelihood-estimation.md), which produces the
128
+ same `{outcome => probability}` shape from a different mechanism.
129
+
130
+ ## API
131
+
132
+ See [`SQA::BI::Likelihood`](../api/likelihood.md) for the full method list.
@@ -0,0 +1,135 @@
1
+ # The Posterior
2
+
3
+ `SQA::BI::Posterior` is the result: `P(outcome | features)`, plus everything you
4
+ need to judge how much to trust it.
5
+
6
+ ## Construction
7
+
8
+ ```ruby
9
+ prior = SQA::BI::Prior.new([-2, -1, 0, 1, 2])
10
+ likelihoods = { -2 => 0.05, -1 => 0.10, 0 => 0.15, 1 => 0.60, 2 => 0.10 }
11
+
12
+ posterior = SQA::BI::Posterior.new(prior, likelihoods)
13
+ ```
14
+
15
+ The computation is Bayes' theorem, done once at construction:
16
+
17
+ ```ruby
18
+ unnormalized[outcome] = likelihood[outcome] * prior.probability(outcome)
19
+ posterior = unnormalized / unnormalized.values.sum
20
+ ```
21
+
22
+ `Posterior` is immutable — everything is computed up front and the object is a
23
+ value. Normally `TimeSeriesPredictor#predict` builds it for you.
24
+
25
+ !!! note "If everything multiplies out to zero"
26
+ When the unnormalized total is `0.0` — which can only happen if every
27
+ likelihood is zero for every outcome with nonzero prior — the posterior
28
+ falls back to the prior rather than dividing by zero. You learned nothing,
29
+ so your belief is unchanged. That is the right answer, not an error.
30
+
31
+ ## Reading the distribution
32
+
33
+ ```ruby
34
+ posterior.max_outcome # => 1 MAP estimate
35
+ posterior.probability(1) # => 0.74
36
+ posterior[1] # => 0.74 alias
37
+ posterior.top_outcomes(3) # => [[1, 0.74], [0, 0.15], [-1, 0.06]]
38
+ posterior.to_h # => {-2 => 0.01, ...} keyed by outcome
39
+ posterior.to_a # => [[-2, 0.01], ...] sorted by outcome
40
+ ```
41
+
42
+ `top_outcomes` sorts by probability descending; `to_a` sorts by outcome
43
+ ascending. The distinction matters for ordered outcomes — `to_a` preserves the
44
+ scale, which is what you want when charting.
45
+
46
+ !!! tip "Don't reach for `max_outcome` first"
47
+ It is the least informative thing the object knows. `1` at 74% and `1` at
48
+ 26% are entirely different situations that `max_outcome` reports
49
+ identically. Read `confidence` alongside it, always.
50
+
51
+ ## Uncertainty
52
+
53
+ ```ruby
54
+ posterior.entropy # => 1.371 bits
55
+ posterior.confidence # => 0.412
56
+ posterior.kl_divergence_from_prior # => 0.943 bits
57
+ ```
58
+
59
+ Covered in depth in [Quantifying Uncertainty](uncertainty.md).
60
+
61
+ ## Sampling
62
+
63
+ ```ruby
64
+ posterior.sample # => 1
65
+ posterior.samples(10_000).tally.sort.to_h
66
+ # => {-2 => 98, -1 => 602, 0 => 1_503, 1 => 7_401, 2 => 396}
67
+ ```
68
+
69
+ Inverse-CDF sampling. Pass `rng:` for reproducibility:
70
+
71
+ ```ruby
72
+ posterior.sample(rng: Random.new(42))
73
+ posterior.samples(1_000, rng: Random.new(42))
74
+ ```
75
+
76
+ Sampling is how you carry uncertainty into a calculation that cannot accept a
77
+ distribution — Monte Carlo a P&L, propagate through a downstream simulation,
78
+ generate a spread of scenarios rather than one path.
79
+
80
+ ## Reporting
81
+
82
+ ```ruby
83
+ puts posterior.to_s
84
+ # Posterior(-2: 0.010, -1: 0.060, 0: 0.150, 1: 0.740, 2: 0.040)
85
+
86
+ puts posterior.summary
87
+ ```
88
+
89
+ ```
90
+ Posterior Distribution Summary:
91
+ ================================
92
+ Most Likely: 1 (74.0%)
93
+ Confidence: 41.2%
94
+ Entropy: 1.371 bits
95
+ KL Divergence from Prior: 0.943 bits
96
+
97
+ Probabilities:
98
+ -2: 0.010 (1.0%)
99
+ -1: 0.060 (6.0%)
100
+ 0: 0.150 (15.0%)
101
+ 1: 0.740 (74.0%)
102
+ 2: 0.040 (4.0%)
103
+ ```
104
+
105
+ ## Chaining updates across several pieces of evidence
106
+
107
+ A posterior can become the prior for the next update. This is how
108
+ [LLM likelihood estimation](../llm/likelihood-estimation.md) accumulates
109
+ evidence:
110
+
111
+ ```ruby
112
+ posterior = nil
113
+ prior = SQA::BI::Prior.new(hypotheses.keys)
114
+
115
+ evidence_list.each do |evidence|
116
+ likelihoods = estimator.likelihoods(evidence)
117
+ posterior = SQA::BI::Posterior.new(prior, likelihoods)
118
+ prior = SQA::BI::Prior.new(hypotheses.keys, posterior.to_h)
119
+ end
120
+ ```
121
+
122
+ Each step's `kl_divergence_from_prior` then tells you **which evidence actually
123
+ mattered** — in the diagnosis demo the per-update information gain ranges from
124
+ 0.025 to 0.959 bits, and the two large numbers are exactly the two facts that
125
+ reversed the conclusion.
126
+
127
+ !!! warning "Sequential updating assumes conditional independence"
128
+ Chaining like this treats each piece of evidence as independent given the
129
+ hypothesis. Two log lines that both restate the same underlying fact will
130
+ get counted twice and will overstate your confidence. Deduplicate evidence
131
+ before feeding it in — the arithmetic cannot do it for you.
132
+
133
+ ## API
134
+
135
+ See [`SQA::BI::Posterior`](../api/posterior.md) for the full method list.