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

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

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

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