Can post-processing improve a forecast?

A practical workflow, followed by a six-period Apple comparison

An existing forecasting system stays in place. It produces a forecast; this notebook adds a post-processing step before publication, then measures whether that step improves accuracy when the outcome arrives.

This notebook loads the same real ADC export as the detailed model walkthrough. It first demonstrates a current publication call, then replays six historical forecasting dates with illustrative plots. The historical model is reconstructed: this is a conditional comparison within one export vintage, not a verified live out-of-sample trial.

1. Where it fits in the normal workflow

flowchart LR
    A[Existing model<br>produces forecast] --> B[Post-processing<br>read inputs as of now]
    C[Consensus and<br>realised history] --> B
    B --> D[Publish adjusted estimate<br>or record refusal]
    D --> E[Outcome arrives later]
    E --> F[Compare errors<br>before changing policy]

  • Keep the model: The library does not retrain it or read its raw predictors.
  • Match the target: Model, consensus, and actual must describe the same company, fiscal period, quantity, and units.
  • Preserve observations: Keep forecast revisions and actual availability timestamps. Overwriting history prevents a defensible evaluation.
  • Choose one method: Anchoring and the Bayesian posterior are alternatives. Applying one after the other is not the workflow demonstrated here.
  • Separate learning and publication: Anchoring uses frozen coefficients. Bayesian moments come from outcomes already known at each reading moment.

2. Load a forecast and its history

Run just install, select .venv/bin/python, and run this notebook from docs/. POSTFORECAST_EXPORT can point at another export with universe.parquet and signals/. Missing exports stop execution; no synthetic substitute is used.

The default is the ADC Core export used in the companion walkthrough. Its file hashes and model IDs are displayed there. The source’s date-only availability values are interpreted as UTC midnight, not as verified intraday delivery times.

import os
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import tutorial_support as examples
from IPython.display import display as show

import postforecast as pf

examples.set_style()
export_root = Path(
    os.environ.get(
        "POSTFORECAST_EXPORT",
        "~/repos/adc-models/data/customers-371/line-items/190/2026-09-20",
    )
).expanduser()
ticker = "AAPL"
universe = pd.read_parquet(export_root / "universe.parquet")
company = universe.loc[universe.ticker.eq(ticker) & universe.country.eq("US")].squeeze()
frames = []
for signal, source in {
    "model_prediction": "live_model",
    "model_pit_backtest": "historical_model",
    "consensus": "consensus",
    "actual": "actual",
}.items():
    path = export_root / "signals" / f"signal_type={signal}" / "data.parquet"
    frames.append(
        pd.read_parquet(path, filters=[("entity_id", "==", company.entity_id)]).assign(
            source=source
        )
    )
raw = pd.concat(frames, ignore_index=True)
panel = pf.validate_estimates(
    raw.rename(
        columns={
            "entity_id": "entity",
            "period_end_date": "period",
        }
    ).assign(
        target="revenue",
        period_end=pd.to_datetime(raw.period_end_date, utc=True),
        known_at=pd.to_datetime(raw.known_at, utc=True),
    )
)
keys = ["entity", "period", "target", "period_end"]
examples.format_dates(
    panel.groupby("source", as_index=False).agg(
        rows=("value", "size"),
        latest_available=("known_at", "max"),
    )
)
source rows latest_available
0 actual 17104 2026-09-13
1 consensus 10014 2026-09-16
2 historical_model 339 2026-09-07
3 live_model 22 2026-09-20

3. Publish after the existing model has run

Use an explicit reading time and target period. These calls belong in the application after it obtains the model’s prediction and before it publishes a number.

  • Anchoring: publish_anchored returns the adjusted level, expected surprise, and publication verdict. The shipped Core coefficients illustrate a previously fitted policy; deploying them for a changed model vintage requires validation.
  • Bayesian: bayesian_kpi_predictions reads live predictions and learns precisions from the separate historical-model source. Minimum history and uncertainty settings are explicit in its settings object.
  • Caller responsibility: The anchored method checks source ages. The Bayesian API does not inherit those age limits; the application must choose its own freshness policy before using it in production.
parameters = pf.CORE_2025_01
settings = pf.BayesianPredictionSettings(
    version="forecast-improvement-example-v1",
    series="predictions",
    minimum_observations=3,
    values=("prediction", "prediction-lower", "prediction-upper"),
)
publication_time = pd.Timestamp("2026-09-22", tz="UTC")
publication_period = "2026-09-26"
requested = panel.loc[panel.period.eq(publication_period), keys].drop_duplicates()
anchored_now = pf.publish_anchored(
    panel,
    publication_time,
    forecast_source="live_model",
    parameters=parameters,
    subjects=requested,
    fields=("anchored_level", "expected_surprise"),
)
bayesian_now = pf.bayesian_kpi_predictions(
    panel,
    publication_time,
    forecast_source="live_model",
    backtest_source="historical_model",
    settings=settings,
)
show(anchored_now[["period", "anchored_level", "eligible", "eligibility_reason"]])
show(
    bayesian_now.loc[
        bayesian_now.period.eq(publication_period),
        ["period", "prediction", "prediction-lower", "prediction-upper", "eligible"],
    ]
)
period anchored_level eligible eligibility_reason
0 2026-09-26 1.154643e+11 True publishable
period prediction prediction-lower prediction-upper eligible
0 2026-09-26 1.136502e+11 7.209368e+10 1.594956e+11 True

Do not publish merely because a cell contains a number. Check eligible, retain eligibility_reason, and store the full settings or parameter payload. Preserve the raw model and consensus beside the adjusted output so future accuracy can be evaluated. A current estimate has no known error yet.

4. Define the comparison before measuring results

The historical replay uses a fixed schedule, rather than selecting whichever forecast date looks best after seeing the outcome.

  • Company: Apple, the same company as the current-publication example.
  • Target window: Fiscal period ends from January 2025 through June 2026.
  • Forecast date: Fourteen calendar days after each fiscal period end. This is an explicit tutorial policy, not a recommended universal horizon.
  • Training boundary: Each calculation receives only observations strictly before its forecast date. Each target’s outcome must still be unknown then.
  • Anchoring coefficients: Frozen through 2024-12-31; not refitted on the evaluation periods. No coefficient is selected using the results below.
  • Bayesian history: Expands as earlier outcomes become available. Every historical replay uses the reconstructed model as its forecast source.
  • Scoring actual: The first actual present in this export for each period. This may differ from the original release if the export backfills history.
  • Comparison sample: Only periods with finite outputs from all four methods enter the paired error comparison. Coverage and refusals are shown first.

No market returns, trading costs, or stock-price reactions enter this accuracy exercise. The calculation belongs in the notebook; evaluation is not added to the postforecast library.

actuals = (
    panel.loc[panel.source.eq("actual")]
    .sort_values("known_at")
    .drop_duplicates(["entity", "period", "target"])
    .sort_values("period_end")
)
targets = actuals.loc[
    actuals.period_end.between(
        pd.Timestamp("2025-01-01", tz="UTC"),
        pd.Timestamp("2026-06-30", tz="UTC"),
    )
].copy()
targets["forecast_date"] = targets.period_end + pd.Timedelta(days=14)
np.testing.assert_equal(
    bool(targets.forecast_date.lt(targets.known_at).all()), desired=True
)
np.testing.assert_equal(
    bool(
        targets.forecast_date.gt(
            pd.Timestamp(parameters.fitted_through, tz="UTC")
        ).all()
    ),
    desired=True,
)
examples.format_dates(
    targets[["period", "forecast_date", "known_at"]].rename(
        columns={"known_at": "outcome_available"},
    )
)
period forecast_date outcome_available
24236 2025-03-29 2025-04-12 2025-05-01
24452 2025-06-28 2025-07-12 2025-07-31
24690 2025-09-27 2025-10-11 2025-10-30
24946 2025-12-27 2026-01-10 2026-01-29
25189 2026-03-28 2026-04-11 2026-04-30
25436 2026-06-27 2026-07-11 2026-07-30

5. Replay the same post-processing step

The loop reads the target’s actual only when recording the result for scoring. That actual never enters the panel supplied to either method. The historical model is used here because live predictions in this export cover current periods rather than the whole evaluation window.

records = []
for target in targets.itertuples():
    cutoff = target.forecast_date
    known = panel.loc[panel.known_at.lt(cutoff)]
    requested = panel.loc[panel.period.eq(target.period), keys].drop_duplicates()
    prepared = pf.prepare_anchored_subjects(
        known,
        cutoff,
        forecast_source="historical_model",
        subjects=requested,
    )
    anchored = (
        pf.AnchoredEstimate(
            forecast_source="forecast",
            parameters=parameters,
        )
        .fit(prepared)
        .apply(prepared)
        .iloc[0]
    )
    posterior_rows = pf.bayesian_kpi_predictions(
        known,
        cutoff,
        forecast_source="historical_model",
        settings=settings,
    )
    posterior = posterior_rows.set_index("period").reindex([target.period]).iloc[0]
    records.append(
        {
            "period": target.period,
            "forecast_date": cutoff,
            "actual_known_at": target.known_at,
            "Actual": target.value,
            "Raw model": anchored.forecast,
            "Consensus": anchored.consensus,
            "Anchored": anchored.anchored_level,
            "Bayesian": posterior["prediction"],
            "anchored_eligible": anchored.eligible,
            "anchored_reason": anchored.eligibility_reason,
            "bayesian_eligible": posterior.eligible,
            "bayesian_reason": posterior.eligibility_reason,
        }
    )
results = pd.DataFrame(records).set_index("period")
methods = ["Raw model", "Consensus", "Anchored", "Bayesian"]
finite = np.isfinite(results[methods]).all(axis=1)
paired = results.loc[
    finite
    & results.anchored_eligible.eq(other=True)
    & results.bayesian_eligible.eq(other=True)
]
show(
    results[
        ["anchored_eligible", "anchored_reason", "bayesian_eligible", "bayesian_reason"]
    ]
)
print(f"Paired comparison: {len(paired)} of {len(results)} target periods.")
if paired.empty:
    message = "No common eligible periods. Inspect coverage before comparing accuracy."
    raise ValueError(message)
anchored_eligible anchored_reason bayesian_eligible bayesian_reason
period
2025-03-29 True publishable True eligible
2025-06-28 True publishable True eligible
2025-09-27 True publishable True eligible
2025-12-27 True publishable True eligible
2026-03-28 True publishable True eligible
2026-06-27 True publishable True eligible
Paired comparison: 6 of 6 target periods.

6. See how adjustment changes one forecast

The first evaluation period is selected by date, before looking at its error. The later actual is drawn only as an evaluation reference. In a real forecast run, that vertical line would not yet exist.

Read the arrows: Moving toward consensus helps if the raw model’s deviation is mostly error. It hurts if that deviation contains information that consensus misses. Anchoring can also move beyond consensus through its intercept and revision contribution.

first = paired.iloc[0]
fig, ax = plt.subplots(figsize=(10, 4))
for position, method in enumerate(methods):
    value = first[method] / 1e9
    ax.scatter(value, position, s=70, zorder=3)
    if method in {"Anchored", "Bayesian"}:
        ax.annotate(
            "",
            xy=(value, position),
            xytext=(first["Raw model"] / 1e9, position),
            arrowprops={"arrowstyle": "->", "color": examples.ACCENT, "lw": 2},
        )
ax.axvline(first.Actual / 1e9, color="black", linestyle="--", label="Later actual")
ax.set(
    yticks=range(len(methods)),
    yticklabels=methods,
    xlabel="Revenue (export units, billions)",
    title=f"{ticker}: {paired.index[0]}",
)
ax.legend()
plt.show()
Arrows from the raw forecast to adjusted estimates and the later actual.
Figure 1: The first evaluation period: where did each adjustment move?

7. Measure improvement over the whole selected window

For forecast \(F\) and actual \(A\), percentage error is \(100(F-A)/A\). The outcome denominators here are positive revenue values.

  • Mean absolute percentage error (MAPE): Average absolute error as a percentage of actual revenue; lower is better.
  • Mean signed percentage error: Average bias; positive means overprediction. Errors can cancel, so a small bias alone does not imply accuracy.
  • Improvement versus raw: Raw model MAPE minus adjusted MAPE, in percentage points. A positive value means lower average error in this paired sample.
  • Consensus baseline: An adjusted forecast should also be judged against using consensus alone. Beating a weak raw model is an incomplete result.
np.testing.assert_equal(bool(paired.Actual.gt(0).all()), desired=True)
errors = paired[methods].sub(paired.Actual, axis=0).div(paired.Actual, axis=0) * 100
absolute_errors = errors.abs()
summary = pd.DataFrame(
    {
        "Periods": len(paired),
        "MAPE (%)": absolute_errors.mean(),
        "Mean signed error (%)": errors.mean(),
    }
)
summary["Improvement vs raw (pp)"] = (
    summary.loc["Raw model", "MAPE (%)"] - summary["MAPE (%)"]
)
summary.round(3)
Periods MAPE (%) Mean signed error (%) Improvement vs raw (pp)
Raw model 6 5.420 -1.617 0.000
Consensus 6 2.486 -2.486 2.934
Anchored 6 1.520 -1.520 3.900
Bayesian 6 2.763 -1.941 2.657
fig, ax = plt.subplots(figsize=(9, 4))
values = summary["MAPE (%)"]
bars = ax.bar(
    values.index,
    values,
    color=[examples.MUTED, examples.WARM, examples.ACCENT, "#55a868"],
)
ax.bar_label(bars, fmt="%.2f%%", padding=4)
ax.set(ylabel="Mean absolute percentage error (%)", ylim=(0, values.max() * 1.2))
plt.show()
Bars comparing raw, consensus, anchored, and Bayesian MAPE.
Figure 2: Average error on exactly the same target periods. Lower is better.

8. Find the periods where it hurts

An average can hide a bad individual forecast. This chart shows the change in absolute percentage error for each target period. Positive bars mean improvement; negative bars mean the adjustment was worse than the raw model.

A method that helps on average still needs monitoring. The six observations here are too few to establish stable performance across companies or regimes.

improvement = absolute_errors[["Consensus", "Anchored", "Bayesian"]].rsub(
    absolute_errors["Raw model"],
    axis=0,
)
fig, ax = plt.subplots(figsize=(11, 4.5))
improvement.plot.bar(ax=ax, color=[examples.WARM, examples.ACCENT, "#55a868"])
ax.axhline(0, color="black", linewidth=1)
ax.set(
    xlabel="Fiscal period end", ylabel="Reduction in absolute error (percentage points)"
)
ax.tick_params(axis="x", rotation=30)
ax.legend(title="")
fig.tight_layout()
plt.show()
Per-period improvement for consensus, anchoring, and Bayesian estimates.
Figure 3: Positive means less error than the raw model; negative means more.
worse = improvement.lt(0).sum().rename("Periods worse than raw")
show(worse.to_frame())
print(f"Raw model MAPE: {summary.loc['Raw model', 'MAPE (%)']:.2f}%")
print(f"Anchored MAPE: {summary.loc['Anchored', 'MAPE (%)']:.2f}%")
print(f"Bayesian MAPE: {summary.loc['Bayesian', 'MAPE (%)']:.2f}%")
print(f"Consensus MAPE: {summary.loc['Consensus', 'MAPE (%)']:.2f}%")
Periods worse than raw
Consensus 2
Anchored 1
Bayesian 1
Raw model MAPE: 5.42%
Anchored MAPE: 1.52%
Bayesian MAPE: 2.76%
Consensus MAPE: 2.49%

For the saved default export, both adjusted methods beat the raw model on average, while the Bayesian result has more average error than consensus alone. Both adjustments also hurt on at least one individual period. The tables and plots above are the computed evidence; changing the input can change that conclusion.

9. Check the time boundary

The first target’s outcome and all later observations are removed in the replay. The library’s own as-of read should give exactly the same posterior when given the complete panel instead. This check would fail if future rows changed that calculation. It does not test whether the upstream model selection was historically available; that remains an export-provenance limitation.

first_target = targets.iloc[0]
full_read = pf.bayesian_kpi_predictions(
    panel,
    first_target.forecast_date,
    forecast_source="historical_model",
    settings=settings,
)
limited_read = pf.bayesian_kpi_predictions(
    panel.loc[panel.known_at.lt(first_target.forecast_date)],
    first_target.forecast_date,
    forecast_source="historical_model",
    settings=settings,
)
pd.testing.assert_frame_equal(full_read, limited_read)
np.testing.assert_allclose(
    improvement["Anchored"].mean(),
    summary.loc["Anchored", "Improvement vs raw (pp)"],
)
print(
    "Checks passed: outcomes follow forecasts; future rows do not change the read; error attribution agrees."
)
Checks passed: outcomes follow forecasts; future rows do not change the read; error attribution agrees.

10. Turn the exercise into a normal operating process

  1. Capture: Append each newly produced model forecast and the matching consensus with their true availability timestamps.
  2. Post-process: At a scheduled reading moment, run the chosen method and retain its verdict, parameter payload, input snapshot, and software revision.
  3. Publish: Release eligible results under a stable policy. Preserve refusals and use any alternative publication policy explicitly, not as a silent fallback.
  4. Score later: Append the first reported outcome when available, then compare raw, adjusted, and consensus errors on matching targets and horizons.
  5. Monitor coverage: Track failures and source freshness alongside accuracy. Lower error obtained only by declining difficult forecasts is not a like-for-like win.
  6. Recalibrate deliberately: Fit new coefficients or choose settings on a training/validation window. Freeze them, version them, and assess later untouched observations before replacing the published policy.

What would justify adoption? Consistent improvements on untouched future observations, useful coverage, and acceptable errors in the cases that matter to the application. Include consensus as a baseline and evaluate interval coverage separately if publishing uncertainty bands.

What this example establishes: A working integration and a reproducible within-export comparison. It cannot establish live predictive improvement because the historical model vintage was reconstructed and the sample is small.

Continue with the detailed real-model calculations, anchoring methodology, or Bayesian posterior. The terminology introduction defines the data and method terms.