Three noisy readings of one unknown, weighted by how well each has done
The anchored estimate treats consensus as the anchor and moves a fixed fraction of the way toward the model. This method treats all three readings symmetrically and lets each one’s own track record decide its weight.
NoteProvenance
Ported from docs/bayesian_signal.py and docs/bayesian.qmd in the buy-side consensus repository, which follow IndependentBayesianRegularizedPredictionSignal in exabel/python/exabel/timeseries/dsl/signals/bayesian_kpi_prediction_signal.py. The reference’s self-check is a golden test here.
The setup
One unknown: \(\theta\), the company’s revenue growth for the coming quarter. Three noisy readings of it, each with its own variance.
Reading
Symbol
Where its variance comes from
Prior
\(\mu_0\)
the company’s own realised growth history
Model
\(x_A\)
the model’s error against realised growth
Consensus
\(x_C\)
consensus’s error, or analyst dispersion
The normal–normal update
Assume each reading is an independent Gaussian observation of \(\theta\). The posterior is proportional to prior times likelihoods, and collecting the exponent as a quadratic in \(\theta\) gives a Gaussian again. Writing \(\tau_i = 1/\sigma_i^2\) for precisions:
Precisions add. Every additional independent source, however weak, reduces computed posterior variance under these assumptions. Predictive accuracy can still worsen when errors are correlated or variances are misspecified.
Weights are precision shares. The coefficient on each source is \(\tau_i / \sum_j \tau_j\), so the three weights sum to one by construction and none can be negative.
Growth space, not level space
The method works on growth over the previous actual, not on revenue levels. Two reasons:
Scale. Revenue levels are non-stationary. Sequential growth is far closer to stationary, which is what makes a variance estimated over history meaningful at all.
The prior needs a sample. A prior on “this company’s growth” can be estimated from its own history. A prior on “this company’s revenue in dollars” cannot, without a trend model.
The inputs, measured point-in-time
history supplies the statistics, each read strictly before the moment being forecast.
The error record per source is the same idea: for each past quarter, what that source said before the actual landed, against what the actual turned out to be.
A source’s estimate for a past quarter has to be the one that stood before that quarter’s actual became knowable — never a later revision. The reference gets this for free from its tuples of quarters; on a panel it has to be anchored, once per quarter. A test asserts that a revision published after the actual is never read.
The prior carries most of the weight here, because eight quarters of steady growth are a much sharper reading than either estimate’s error record. That is the method working as intended, not a bug: a company whose growth is stable is mostly predicted by its own history.
\(\nu = 1.5\) degrees of freedom is far below the Gaussian limit. The posterior variance formula would give a Gaussian band, and a Gaussian band on financial forecast errors is famously too narrow.
Figure 2: A Student-t with 1.5 degrees of freedom against the normal, both scaled to unit interquartile range. A 90% band from the t is much wider in the tails.
Degrading gracefully, and refusing outright
The two are different, and the distinction is the interesting part of the port.
A model whose record is too short to estimate a precision drops out of the blend and the subject is still published on prior and consensus. A model whose value is missing refuses the subject outright: there is nothing to weigh.
pd.DataFrame( [{"reason": reason.value} for reason in PosteriorReason])
reason
0
publishable
1
missing_input
2
insufficient_history
3
no_consensus_precision
4
missing_prediction
5
nonfinite_result
Neither branch is a fallback in the forbidden sense — no unpublishable subject ever gets a substituted number. Both are declared method behaviour, taken from the reference, and both carry tests.
Note what is missing: a fitted_through date. The anchored estimate’s parameters were fitted by regression on a training sample, so they carry the date their training data ended. These are the production DSL’s defaults — chosen, not estimated — so there is nothing to date. They still carry a version, because a published number depends on them.
Same functional form, different estimand. The regression estimates that ratio from the joint distribution of gap and surprise, across companies. The Bayesian computes it from each source’s own error history, per company.
Show the code
ratio = np.logspace(-1, 1.6, 400)curve = pd.DataFrame({"ratio": ratio, "weight": 1/ (1+ ratio)})figure, axis = plt.subplots(figsize=(7.5, 3.8))sns.lineplot(data=curve, x="ratio", y="weight", ax=axis, color=pf.ACCENT)fitted = pfc.STUDY_2025_01.gap_weightaxis.scatter([(1- fitted) / fitted], [fitted], s=60, color=pf.WARM, zorder=3)axis.annotate(f"the anchored estimate's fitted\ngap weight, {fitted}", ((1- fitted) / fitted, fitted), xytext=(12, 22), textcoords="offset points", fontsize=8.5, color=pf.WARM,)axis.set_xscale("log")axis.set(xlabel=r"$\sigma_A^2/\sigma_C^2$, log scale", ylabel="weight on the model")axis.set_title("Two routes to the same functional form")figure.tight_layout()
Figure 3: The weight on the model as a function of the error-variance ratio. The anchored estimate’s fitted gap weight of 0.129 is one point on this curve.
A measured median error of 2.99% for the model against 1.54% for consensus implies a weight near 0.21 if the two errors were independent. The regression says 0.129. The wedge between them is error correlation: both track the same company, so their errors move together, and a naive precision weighting over-weights the model. The independent variant is the DSL’s default anyway, because covariance estimates are materially less stable than scalar variances when two sources are strongly collinear.
What to take away
Precisions add, weights are precision shares. Both facts fall out of the algebra, and both are asserted per subject in the tests.
Growth space is what makes a variance meaningful. Levels are non-stationary.
Point-in-time is harder here than for the anchored estimate. Every error in the record has to be measured against what a source said before the actual landed.
Degrading and refusing are different. A short record drops a source; a missing value refuses the subject.
Settings carry a version but no fitted-through date, because nothing in them was fitted.