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,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.
|
data/docs/guide/prior.md
ADDED
|
@@ -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.
|