The Bayesian posterior

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.

The setup

One unknown: \(\theta\), the company’s revenue growth for the coming quarter. Three noisy readings of it, each with its own variance \(\sigma_j^2\) and precision \(\tau_j = 1/\sigma_j^2\). Symbols follow the notation page.

Reading Symbol Precision Where its variance comes from
Prior \(\mu_0\) \(\tau_0\) the company’s own realised growth history
Model \(x_M\) \(\tau_M\) the model’s error against realised growth
Consensus \(x_C\) \(\tau_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 (Gelman et al. 2013). The posterior growth \(\hat\theta\) is the precision-weighted mean, and its variance \(\sigma_\theta^2\) is one over the total precision \(T\):

\[ \hat\theta = \frac{\mu_0\tau_0 + x_M\tau_M + x_C\tau_C}{T}, \qquad \sigma_\theta^2 = \operatorname{Var}(\theta \mid \text{data}) = \frac{1}{T}, \qquad T = \tau_0 + \tau_M + \tau_C \]

Two structural facts follow immediately:

  • 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 weight on each reading is \(w_j = \tau_j / T\) (Bates and Granger 1969), 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 \(y_{t-1}\), not on revenue levels: the model reading is \(x_M = m/y_{t-1} - 1\) and the consensus reading \(x_C = c/y_{t-1} - 1\). 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.

Show the code
AS_OF = pd.Timestamp("2026-05-10", tz="UTC")

estimates = pf.validate_estimates(examples.quarterly_history())
estimates.groupby("source").value.describe()[["count", "min", "max"]]
count min max
source
actual 8 568 762.9
consensus 8 585.1 798.8
model 8 598.6 845.3
Show the code
prior = pf.growth_prior(estimates, AS_OF)
prior.tail(1)[
    [
        "previous_actual",
        "prior_growth_mean",
        "prior_growth_variance",
        "realised_growth_count",
    ]
]
previous_actual prior_growth_mean prior_growth_variance realised_growth_count
8 762.9 0.0430518 0.0000183461 7

The error record per source is the same idea: for each past quarter, what that source said before the selected actual became known, against that actual. Preparation uses the latest actual known at the cutoff; select first releases upstream if required.

Show the code
errors = pf.growth_error_variance(estimates, AS_OF, "model").merge(
    pf.growth_error_variance(estimates, AS_OF, "consensus"),
    on=pf.subject_index(prior),
)
errors.tail(1)[["model_growth_error_variance", "consensus_growth_error_variance"]]
model_growth_error_variance consensus_growth_error_variance
8 0.000161513 0.000202727

This is where a look-ahead would hide

A source’s estimate for a past quarter has to be the one that stood before that quarter’s selected actual became knowable. First-release behavior requires selecting first actuals before preparation. On a panel that read has to be anchored, once per quarter. A test asserts that a revision published after the actual is never read.

Assembling the subject and running the method

Show the code
subjects = pf.prepare_bayesian_subjects(
    estimates,
    AS_OF,
    forecast_source="model",
    settings=BAYESIAN_DEFAULTS,
)
method = BayesianPosterior(forecast_source="model", settings=BAYESIAN_DEFAULTS)
published = method.fit(subjects).apply(subjects)
row = published.dropna(subset=["posterior_level"]).iloc[-1]
pd.DataFrame(
    [
        {
            "quantity": name,
            "precision": row[f"precision_{name}"],
            "weight": row[f"weight_{name}"],
        }
        for name in ("prior", "model", "consensus")
    ]
)
quantity precision weight
0 prior 54,507.5 0.830506
1 model 6,191.45 0.0943364
2 consensus 4,932.73 0.0751578
Show the code
pd.DataFrame(
    [
        {"field": "posterior_growth", "value": f"{row.posterior_growth:+.4%}"},
        {
            "field": "posterior_growth_stdev",
            "value": f"{row.posterior_growth_stdev:.6f}",
        },
        {"field": "posterior_level", "value": f"{row.posterior_level:,.4f}"},
        {"field": "posterior_lower", "value": f"{row.posterior_lower:,.4f}"},
        {"field": "posterior_upper", "value": f"{row.posterior_upper:,.4f}"},
        {"field": "settings_version", "value": row.settings_version},
    ]
)
field value
0 posterior_growth +4.9481%
1 posterior_growth_stdev 0.003903
2 posterior_level 800.6488
3 posterior_lower 771.7952
4 posterior_upper 832.4804
5 settings_version bayesian-defaults

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.

Show the code
readings = pd.DataFrame(
    [
        {
            "source": "prior",
            "growth": row.prior_growth_mean,
            "weight": row.weight_prior,
        },
        {
            "source": "model",
            "growth": row.model / row.previous_actual - 1,
            "weight": row.weight_model,
        },
        {
            "source": "consensus",
            "growth": row.consensus / row.previous_actual - 1,
            "weight": row.weight_consensus,
        },
    ]
)

figure, axes = plt.subplots(1, 2, figsize=(11, 3.6))
sns.barplot(data=readings, x="source", y="weight", ax=axes[0], color=examples.ACCENT)
axes[0].set(title="Weight is precision share", ylabel="weight", xlabel="")

sns.scatterplot(
    data=readings, x="growth", y=0, hue="source", s=150, ax=axes[1], zorder=3
)
axes[1].axvline(
    row.posterior_growth, color=examples.WARM, lw=1.6, ls="--", label="posterior"
)
axes[1].set(ylim=(-0.5, 0.5), yticks=[], ylabel="", xlabel="growth")
axes[1].set_title("Where the posterior lands")
axes[1].legend(loc="upper left", fontsize=8)
sns.despine(ax=axes[1], left=True)
figure.tight_layout()
Figure 1: The three readings, their weights, and where the posterior lands between them.

The interval is deliberately heavy-tailed

The band is a Student-\(t\) interval, not a Gaussian one:

Show the code
pd.Series(
    {
        "interval_width": BAYESIAN_DEFAULTS.interval_width,
        "interval_df": BAYESIAN_DEFAULTS.interval_df,
        "interval_loc": BAYESIAN_DEFAULTS.interval_loc,
        "interval_scale": BAYESIAN_DEFAULTS.interval_scale,
    }
)
interval_width    0.9
interval_df       1.5
interval_loc      0.5
interval_scale   2.75
dtype: float64

\(\nu = 1.5\) degrees of freedom is far below the Gaussian limit, \(\nu\to\infty\). The posterior variance formula would give a Gaussian band, and a Gaussian band on financial forecast errors is famously too narrow (Mandelbrot 1963).

Show the code
from scipy import stats

grid = np.linspace(-6, 6, 600)
heavy, normal = stats.t(df=BAYESIAN_DEFAULTS.interval_df), stats.norm()
scale_t, scale_n = heavy.ppf(0.75), normal.ppf(0.75)

figure, axis = plt.subplots(figsize=(7.5, 3.4))
axis.plot(
    grid,
    heavy.pdf(grid * scale_t) * scale_t,
    color=examples.ACCENT,
    label=r"Student-$t$, $\nu$ = 1.5",
)
axis.plot(
    grid,
    normal.pdf(grid * scale_n) * scale_n,
    color=examples.GREY,
    ls="--",
    label="normal",
)
for distribution, scale, colour in (
    (heavy, scale_t, examples.ACCENT),
    (normal, scale_n, examples.GREY),
):
    quantile = distribution.ppf(0.95) / scale
    axis.axvline(quantile, color=colour, lw=1, ls=":")
    axis.annotate(
        f"95th pct {quantile:.2f}", (quantile, 0.24), fontsize=8.5, color=colour
    )
axis.set(xlabel="standardised forecast error", ylabel="density")
axis.set_title("Why the interval uses a heavy-tailed distribution")
axis.legend()
figure.tight_layout()
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.

Show the code
short_record = subjects.assign(model_growth_error_variance=float("nan"))
degraded = method.fit(short_record).apply(short_record)
degraded_row = degraded.dropna(subset=["posterior_level"]).iloc[-1]

pd.DataFrame(
    [
        {
            "case": "full record",
            "model contributes": True,
            "published": True,
            "growth": f"{row.posterior_growth:+.4%}",
        },
        {
            "case": "model track record too short",
            "model contributes": False,
            "published": bool(degraded_row[pf.ELIGIBLE_COLUMN]),
            "growth": f"{degraded_row.posterior_growth:+.4%}",
        },
    ]
)
case model contributes published growth
0 full record True True +4.9481%
1 model track record too short False True +4.3384%

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, with an interval. Its model precision and weight remain missing. A model whose value is missing refuses the subject outright: there is nothing to weigh.

Show the code
missing_model = subjects.assign(model=float("nan"))
refused = method.fit(missing_model).apply(missing_model)
refused[[pf.ELIGIBLE_COLUMN, pf.ELIGIBILITY_REASON_COLUMN]].tail(1)
eligible eligibility_reason
8 False missing_prediction

Every refusal reason:

Show the code
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, and both carry tests.

Settings and calibration provenance

Show the code
BAYESIAN_DEFAULTS.model_dump(mode="json")
{'minimum_observations': 3,
 'include_model': True,
 'consensus_stdev_variance_multiplier': 1.0,
 'consensus_count_exponent': None,
 'consensus_bias_lambda': 0.0,
 'consensus_bias_process_noise': 1.0,
 'consensus_bias_measurement_noise': 0.5,
 'winsorize_fraction': None,
 'interval_width': 0.9,
 'interval_df': 1.5,
 'interval_loc': 0.5,
 'interval_scale': 2.75,
 'dispersion_weighted': False,
 'preset': None,
 'fitted_through': None,
 'version': 'bayesian-defaults'}
  • This configuration: BAYESIAN_DEFAULTS preserves the source signal’s chosen compatibility settings, with fitted_through=None.
  • Research presets: Other configurations carry fitted interval shapes and support a fitted-through date. The research cutoff is currently unknown; see training provenance.
  • Audit: Retain the complete settings, not just their version. A null training date does not establish that a historical evaluation is out of sample.
Show the code
patient = BayesianSettings(
    **{
        **BAYESIAN_DEFAULTS.model_dump(),
        "minimum_observations": 6,
        "version": "patient-demo",
    }
)
pd.DataFrame(
    [
        {
            "settings": settings.version,
            "minimum_observations": settings.minimum_observations,
            "declared minimum_history": BayesianPosterior(
                forecast_source="model", settings=settings
            ).requirements.minimum_history,
        }
        for settings in (BAYESIAN_DEFAULTS, patient)
    ]
)
settings minimum_observations declared minimum_history
0 bayesian-defaults 3 3
1 patient-demo 6 6

The declared requirement follows the setting, so a pipeline assembled with patient settings advertises what it actually needs.

How the two methods relate

Drop the prior from the blend. The remaining two-source update can be written as:

\[ \hat\theta = x_C + \underbrace{\frac{\sigma_C^2}{\sigma_M^2 + \sigma_C^2}}_{w_M}\,(x_M - x_C) \]

For comparison, also set the anchored intercept and revision contribution to zero. The simplified anchored level is \(\hat y = c + b_\Delta (m - c)\). Only under these simplifications do the two share a functional form, with

\[ b_\Delta \longleftrightarrow w_M = \frac{1}{1 + \sigma_M^2/\sigma_C^2} \]

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=examples.ACCENT)
fitted = pf.ANCHORING_2025_01.gap_weight
axis.scatter([(1 - fitted) / fitted], [fitted], s=60, color=examples.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=examples.WARM,
)
axis.set_xscale("log")
axis.set(xlabel=r"$\sigma_M^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. Error correlation can contribute to this difference: both track the same company, so their errors move together, and a naive precision weighting over-weights the model (Clemen and Winkler 1985). The independent variant is the default anyway, because covariance estimates are materially less stable than scalar variances when two sources are strongly collinear (Smith and Wallis 2009).

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 and optional training date. Fitted calibration needs its own provenance, independently of the per-company history.

References

Bates, J. M., and C. W. J. Granger. 1969. “The Combination of Forecasts.” Operational Research Quarterly 20 (4): 451–68. https://doi.org/10.2307/3008764.
Clemen, Robert T., and Robert L. Winkler. 1985. “Limits for the Precision and Value of Information from Dependent Sources.” Operations Research 33 (2): 427–42. https://doi.org/10.1287/opre.33.2.427.
Gelman, Andrew, John B. Carlin, Hal S. Stern, David B. Dunson, Aki Vehtari, and Donald B. Rubin. 2013. Bayesian Data Analysis. 3rd ed. Chapman; Hall/CRC. https://doi.org/10.1201/b16018.
Mandelbrot, Benoit. 1963. “The Variation of Certain Speculative Prices.” The Journal of Business 36 (4): 394–419. https://doi.org/10.1086/294632.
Smith, Jeremy, and Kenneth F. Wallis. 2009. “A Simple Explanation of the Forecast Combination Puzzle.” Oxford Bulletin of Economics and Statistics 71 (3): 331–55. https://doi.org/10.1111/j.1468-0084.2008.00541.x.