Validation: Detectability, Hindcast, SBC, and Posterior Predictive Checks

This notebook asks four questions about the Neptune inverse problem:

  1. Detectability — before we validate any particular Neptune fit, is the Uranus anomaly itself large enough and structured enough to rule out noise? This is the precondition that motivates solving an inverse problem at all.
  2. Hindcast validation — fit on 1781..cutoff-1, predict the cutoff..1846 holdout. This is the most important scientific check: does an early Neptune fit actually generalize out of sample?
  3. Simulation-based calibration (SBC) — draw synthetic problems from a narrow prior, fit each one, and check whether posterior intervals behave sensibly. The checked-in run is intentionally lightweight and demonstrative, not statistically rigorous.
  4. Posterior predictive checks — draw posterior samples, replicate the Uranus longitude series, and compare the implied residual structure against what was observed.

Runtime notes for a fresh run: - detectability: under a second - hindcast: about 30 seconds - SBC: a few minutes - posterior predictive (synthetic): under a minute - posterior predictive (JPL): optional, cache-guarded

Setup

The notebook works from either the repository root or the docs/ directory. We keep cache artifacts under data/cache/validation/ and rebuild the lightweight trial objects from cached parquet files when present.

Code
import logging
from pathlib import Path

import emcee
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from IPython.display import display

from discoverneptune.bayesian import run_mcmc_fixed_sigma
from discoverneptune.data import PLANET_IDS, fetch_planet_vectors
from discoverneptune.plot_style import (
    apply_style,
    dual_render,
    fig_size,
    palette,
    truth_line,
)
from discoverneptune.plotting import (
    plot_hindcast_1846_error,
    plot_hindcast_rms,
    plot_posterior_predictive_bands,
    plot_posterior_predictive_summaries,
    plot_sbc_coverage,
)
from discoverneptune.simulation import (
    MASS_NEPTUNE_APPROX,
    NeptuneCandidate,
    StateVector,
)
from discoverneptune.solver import solve
from discoverneptune.synthetic import build_synthetic_problem
from discoverneptune.validation import (
    HindcastTrial,
    SBCTrial,
    hindcast_trials_to_frame,
    run_hindcast_experiment,
    run_posterior_predictive_check,
    run_sbc_experiment,
    sbc_trials_to_frame,
    summarize_sbc_trials,
)

apply_style()


logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-8s  %(name)s  %(message)s",
    datefmt="%H:%M:%S",
)
logger = logging.getLogger("validation_nb")

cwd = Path.cwd()
candidate_repo_roots = [cwd, cwd.parent]
repo_root = next(
    (candidate for candidate in candidate_repo_roots if (candidate / "pyproject.toml").exists()),
    None,
)
if repo_root is None:
    raise FileNotFoundError(
        "Could not find the repository root from the current working directory."
    )

CACHE_DIR = repo_root / "data" / "cache" / "validation"
CACHE_DIR.mkdir(parents=True, exist_ok=True)

def _sv_from_df(df: pd.DataFrame) -> StateVector:
    return StateVector(
        x=float(df["x"].to_numpy()[0]),
        y=float(df["y"].to_numpy()[0]),
        z=float(df["z"].to_numpy()[0]),
        vx=float(df["vx"].to_numpy()[0]),
        vy=float(df["vy"].to_numpy()[0]),
        vz=float(df["vz"].to_numpy()[0]),
    )

def _hindcast_trial_from_row(row: pd.Series) -> HindcastTrial:
    return HindcastTrial(
        mode=str(row["mode"]),
        cutoff_year=int(row["cutoff_year"]),
        n_fit=int(row["n_fit"]),
        n_holdout=int(row["n_holdout"]),
        in_sample_rms_arcsec=float(row["in_sample_rms_arcsec"]),
        holdout_rms_arcsec=float(row["holdout_rms_arcsec"]),
        holdout_max_abs_arcsec=float(row["holdout_max_abs_arcsec"]),
        longitude_1846_error_deg=float(row["longitude_1846_error_deg"]),
        best_params=np.array(
            [row["best_mass"], row["best_a"], row["best_e"], row["best_l_rad"]],
            dtype=float,
        ),
        best_sse=float(row["best_sse"]),
        converged=bool(row["converged"]),
    )

def _sbc_trial_from_row(row: pd.Series) -> SBCTrial:
    residual_model = str(row.get("residual_model", "iid"))
    params = ["mass", "a", "e", "l_rad"]
    if residual_model == "ar1":
        params += ["sigma_rad", "rho"]
    return SBCTrial(
        noise_arcsec=float(row["noise_arcsec"]),
        truth_params=np.array([row[f"truth_{name}"] for name in params], dtype=float),
        mle_params=np.array(
            [row[f"mle_{name}"] for name in params[:4]], dtype=float
        ),
        converged=bool(row["converged"]),
        rank_by_param={name: int(row[f"rank_{name}"]) for name in params},
        coverage_by_level={
            name: {
                0.5: bool(row[f"cov_{name}_50"]),
                0.8: bool(row[f"cov_{name}_80"]),
                0.95: bool(row[f"cov_{name}_95"]),
            }
            for name in params
        },
        interval_width_by_level={
            name: {
                0.5: float(row[f"width_{name}_50"]),
                0.8: float(row[f"width_{name}_80"]),
                0.95: float(row[f"width_{name}_95"]),
            }
            for name in params
        },
        max_rhat=float(row["max_rhat"]),
        min_ess=float(row["min_ess"]),
        residual_model=residual_model,
    )

Detectability: Is the Anomaly Real?

Before validating any particular Neptune fit, we should verify the Uranus anomaly is statistically distinguishable from noise. If it were not, our whole inverse problem would be fitting sky-position noise.

We simulate Uranus’s longitude under a no-Neptune 4-body model (Sun + Jupiter + Saturn + Uranus, all pinned at JPL truth at 1781-01-01), compare to the bundled annual JPL-derived proxy longitudes, and ask two questions of the residual sequence:

  1. Is the RMS larger than plausible measurement noise? We score \(\chi^2_\mathrm{red} = \sum r_i^2 / (\sigma^2 (N - k))\) with \(\sigma = 1\,\mathrm{arcsec}\) as an illustrative reference scale and \(k = 0\) free parameters. This is not a likelihood for the historical measurement process. A value near 1 would mean the no-Neptune model is consistent with noise; values far above 1 rule it out.
  2. Are the residuals structured, not random? Lag-1 autocorrelation is near 0 for white noise (std \(\approx 1/\sqrt{N} \approx 0.12\) for \(N=66\)). A value near \(+1\) means a smoothly drifting signal — the fingerprint of a missing perturbing body.
Code
from discoverneptune.data import find_bundled_data_dir
from discoverneptune.simulation import simulate_uranus_longitudes_no_neptune

ARCSEC_PER_RAD = 3600.0 * 180.0 / np.pi

data_dir = find_bundled_data_dir()
obs = pd.read_csv(data_dir / 'uranus_observations_1781_1846.csv')
svs = pd.read_csv(data_dir / 'outer_planet_state_vectors_1781_01_01.csv')
state = {
    row.planet: StateVector(row.x, row.y, row.z, row.vx, row.vy, row.vz)
    for row in svs.itertuples(index=False)
}

jd_times = obs['datetime_jd'].to_numpy()
obs_lon = obs['longitude_rad'].to_numpy()
start_jd = float(jd_times[0])

sim_lon = simulate_uranus_longitudes_no_neptune(
    jd_times, start_jd, state['jupiter'], state['saturn'], state['uranus']
)

# Angle-wrapped residuals in arcsec
wrapped = ((obs_lon - sim_lon + np.pi) % (2 * np.pi)) - np.pi
residuals_arcsec = wrapped * ARCSEC_PER_RAD

sigma_assumed_arcsec = 1.0
n_obs = len(residuals_arcsec)
chi2_red = float(np.sum(residuals_arcsec**2)) / (sigma_assumed_arcsec**2 * n_obs)

r = residuals_arcsec - residuals_arcsec.mean()
lag1_autocorr = float(np.sum(r[:-1] * r[1:]) / np.sum(r**2))
white_noise_std = 1.0 / np.sqrt(n_obs)

years = 1781.0 + (jd_times - start_jd) / 365.25

def _build_no_neptune(theme="light"):
    apply_style(theme)
    pal = palette(theme)
    fig, ax = plt.subplots(figsize=fig_size("wide"))
    ax.plot(years, residuals_arcsec, 'o-', lw=0.8, ms=3.0, color=pal.before)
    truth_line(ax, 0, theme=theme)
    ax.set_xlabel('Year')
    ax.set_ylabel('Uranus residual (arcsec)')
    ax.set_title('No-Neptune residuals — annual JPL-derived proxy')
    return fig

display(dual_render(_build_no_neptune, alt="Uranus longitude residuals without Neptune perturbation"))

print(f'No-Neptune residuals over {n_obs} JPL-derived proxy points (1781–1846):')
print(f'  RMS                       = {np.sqrt(np.mean(residuals_arcsec**2)):.1f} arcsec')
print(f'  Peak-to-peak              = {np.ptp(residuals_arcsec):.1f} arcsec')
print(f'  chi2_red (sigma=1 arcsec) = {chi2_red:.0f}   (expected ~1 under noise-only)')
print(f'  Lag-1 autocorrelation     = {lag1_autocorr:+.3f}  (expected ~0 +/- {white_noise_std:.2f} under white noise)')
No-Neptune residuals over 66 JPL-derived proxy points (1781–1846):
  RMS                       = 31.5 arcsec
  Peak-to-peak              = 131.4 arcsec
  chi2_red (sigma=1 arcsec) = 994   (expected ~1 under noise-only)
  Lag-1 autocorrelation     = +0.900  (expected ~0 +/- 0.12 under white noise)

Hindcast Validation

We fit the simplified Neptune model on an early prefix of the synthetic observation window and then score its predictions on the held-out suffix. The key question is how quickly the holdout RMS and 1846-position error improve as more years accumulate.

Code
TRUE_NEPTUNE = NeptuneCandidate(
    mass_solar=MASS_NEPTUNE_APPROX,
    a=30.07,
    e=0.009,
    l_rad=np.radians(132.0),
)
HINDCAST_CACHE = CACHE_DIR / "hindcast_synthetic.parquet"

if HINDCAST_CACHE.exists():
    logger.info("Loading cached hindcast results from %s", HINDCAST_CACHE)
    hindcast_df = pd.read_parquet(HINDCAST_CACHE)
else:
    logger.info("Building synthetic benchmark problem...")
    problem = build_synthetic_problem(TRUE_NEPTUNE)
    logger.info("Running hindcast experiment...")
    hindcast_trials = run_hindcast_experiment(
        jd_times=problem["jd_times"],
        truth_longs=problem["truth_longs"],
        start_jd=problem["start_jd"],
        jupiter_sv=problem["jupiter_sv"],
        saturn_sv=problem["saturn_sv"],
        uranus_sv=problem["uranus_sv"],
        cutoff_years=[1800, 1810, 1820, 1830, 1840],
        mode="synthetic",
        solve_kwargs={"de_maxiter": 30, "de_popsize": 5, "seed": 42},
    )
    hindcast_df = hindcast_trials_to_frame(hindcast_trials)
    hindcast_df.to_parquet(HINDCAST_CACHE)

hindcast_trials = [_hindcast_trial_from_row(row) for _, row in hindcast_df.iterrows()]
problem = build_synthetic_problem(TRUE_NEPTUNE)
hindcast_df

display(dual_render(plot_hindcast_rms, hindcast_trials, alt="Hindcast RMS vs cutoff year"))
display(dual_render(plot_hindcast_1846_error, hindcast_trials, alt="1846 longitude error vs training cutoff year"))

Simulation-Based Calibration

SBC repeatedly simulates synthetic problems from a narrow prior near the true Neptune regime, reruns the usual solve + MCMC pipeline, and checks whether posterior intervals cover the truth at roughly the advertised rates. The notebook run below is lightweight: it uses one fixed IID noise level plus a two-replicate AR(1) path check. These surfaces exercise both calibration paths but are not statistically definitive.

Code
noise_levels = [2.0]
sbc_frames = []
sbc_trials = []

for noise_arcsec in noise_levels:
    cache_path = CACHE_DIR / f"sbc_noise_{noise_arcsec:g}.parquet"
    if cache_path.exists():
        logger.info("Loading cached SBC results from %s", cache_path)
        noise_df = pd.read_parquet(cache_path)
    else:
        logger.info(
            "Running SBC experiment for noise=%s arcsec (4 replicates)...",
            noise_arcsec,
        )
        noise_trials = run_sbc_experiment(
            n_replicates=4,
            noise_arcsec=noise_arcsec,
            mcmc_steps=400,
            mcmc_walkers=12,
            de_maxiter=20,
            de_popsize=4,
            seed=42,
        )
        noise_df = sbc_trials_to_frame(noise_trials)
        noise_df.to_parquet(cache_path)
    sbc_frames.append(noise_df)
    sbc_trials.extend(_sbc_trial_from_row(row) for _, row in noise_df.iterrows())

ar1_cache_path = CACHE_DIR / "sbc_ar1.parquet"
if ar1_cache_path.exists():
    logger.info("Loading cached AR(1) SBC results from %s", ar1_cache_path)
    ar1_df = pd.read_parquet(ar1_cache_path)
else:
    logger.info("Running AR(1) SBC path check (2 replicates)...")
    ar1_trials = run_sbc_experiment(
        n_replicates=2,
        noise_arcsec=None,
        residual_model="ar1",
        mcmc_steps=400,
        mcmc_walkers=16,
        de_maxiter=20,
        de_popsize=4,
        seed=84,
    )
    ar1_df = sbc_trials_to_frame(ar1_trials)
    ar1_df.to_parquet(ar1_cache_path)
ar1_sbc_trials = [
    _sbc_trial_from_row(row) for _, row in ar1_df.iterrows()
]

sbc_df = pd.concat(sbc_frames, ignore_index=True)
sbc_summary = summarize_sbc_trials(sbc_trials)
ar1_sbc_summary = summarize_sbc_trials(ar1_sbc_trials)
print(sbc_summary.to_string(index=False))
print("\nAR(1) path-check summary:")
print(ar1_sbc_summary.to_string(index=False))

display(dual_render(plot_sbc_coverage, sbc_summary, alt="SBC coverage diagnostic"))
 noise_arcsec residual_model param  n_trials  converged_rate  median_rank  cov_50  width_median_50  cov_80  width_median_80  cov_95  width_median_95
          2.0            iid  mass         4            0.75         25.0    0.25         0.000004    0.50         0.000007    0.75         0.000010
          2.0            iid     a         4            0.75        106.0    0.25         0.425149    0.50         0.777652    0.75         1.015523
          2.0            iid     e         4            0.75         97.0    0.25         0.016982    0.75         0.030114    0.75         0.040490
          2.0            iid l_rad         4            0.75         79.5    0.25         0.031818    0.75         0.063402    0.75         0.090931

AR(1) path-check summary:
 noise_arcsec residual_model     param  n_trials  converged_rate  median_rank  cov_50  width_median_50  cov_80  width_median_80  cov_95  width_median_95
    23.728707            ar1      mass         2             0.5        235.5     1.0         0.000008     1.0         0.000013     1.0         0.000017
    23.728707            ar1         a         2             0.5        240.5     0.0         0.590719     1.0         0.926313     1.0         1.126001
    23.728707            ar1         e         2             0.5        416.5     0.0         0.007001     0.0         0.016871     0.5         0.031345
    23.728707            ar1     l_rad         2             0.5        260.0     0.5         0.113946     1.0         0.241623     1.0         0.356670
    23.728707            ar1 sigma_rad         2             0.5        366.5     0.0         0.000025     1.0         0.000051     1.0         0.000073
    23.728707            ar1       rho         2             0.5        381.5     0.0         0.158170     1.0         0.283142     1.0         0.421525

Posterior Predictive Checks

Posterior predictive checks ask what the fitted model says Uranus residuals should look like under repeated draws from the posterior. In synthetic mode the residual band should stay narrow and centered near zero; in JPL mode the same machinery exposes the residual structure that the simplified 4-parameter model cannot capture.

Code
SYNTHETIC_SIGMA_ARCSEC = 2.0
synthetic_sigma_rad = np.radians(SYNTHETIC_SIGMA_ARCSEC / 3600.0)
synthetic_rng = np.random.default_rng(42)
synthetic_truth_longs = problem["truth_longs"] + synthetic_rng.normal(
    0.0, synthetic_sigma_rad, size=problem["truth_longs"].shape
)
SYNTHETIC_POSTERIOR_CACHE = CACHE_DIR / "synthetic_posterior_iid_sigma2.npy"

if SYNTHETIC_POSTERIOR_CACHE.exists():
    logger.info(
        "Loading cached synthetic posterior from %s", SYNTHETIC_POSTERIOR_CACHE
    )
    synthetic_samples = np.load(SYNTHETIC_POSTERIOR_CACHE)
else:
    logger.info("Running synthetic MLE + MCMC for posterior predictive check...")
    synthetic_mle = solve(
        jd_times=problem["jd_times"],
        truth_longs=synthetic_truth_longs,
        start_jd=problem["start_jd"],
        jupiter_sv=problem["jupiter_sv"],
        saturn_sv=problem["saturn_sv"],
        uranus_sv=problem["uranus_sv"],
        de_maxiter=30,
        de_popsize=5,
        seed=42,
    )
    synthetic_mcmc = run_mcmc_fixed_sigma(
        mle_result=synthetic_mle,
        jd_times=problem["jd_times"],
        truth_longs=synthetic_truth_longs,
        start_jd=problem["start_jd"],
        jupiter_sv=problem["jupiter_sv"],
        saturn_sv=problem["saturn_sv"],
        uranus_sv=problem["uranus_sv"],
        sigma_rad=synthetic_sigma_rad,
        n_walkers=16,
        n_steps=800,
        seed=42,
        progress=False,
    )
    synthetic_samples = np.column_stack(
        [
            synthetic_mcmc.flat_samples,
            np.full(len(synthetic_mcmc.flat_samples), synthetic_sigma_rad),
        ]
    )
    np.save(SYNTHETIC_POSTERIOR_CACHE, synthetic_samples)

synthetic_ppc = run_posterior_predictive_check(
    flat_samples=synthetic_samples,
    jd_times=problem["jd_times"],
    truth_longs=synthetic_truth_longs,
    start_jd=problem["start_jd"],
    jupiter_sv=problem["jupiter_sv"],
    saturn_sv=problem["saturn_sv"],
    uranus_sv=problem["uranus_sv"],
    mode="synthetic",
    max_draws=100,
)

display(dual_render(plot_posterior_predictive_bands, synthetic_ppc, alt="Posterior predictive bands — synthetic mode"))
display(dual_render(plot_posterior_predictive_summaries, synthetic_ppc, alt="Posterior predictive summaries — synthetic mode"))
print("Synthetic PPC tail probabilities:", synthetic_ppc.tail_probabilities)

# Exercise the six-column AR(1) PPC path independently of optional JPL caches.
# The fixed rho is a path check, not a fitted synthetic posterior.
SYNTHETIC_AR1_RHO = 0.8
synthetic_ar1_samples = np.column_stack(
    [synthetic_samples, np.full(len(synthetic_samples), SYNTHETIC_AR1_RHO)]
)
synthetic_ar1_ppc = run_posterior_predictive_check(
    flat_samples=synthetic_ar1_samples,
    jd_times=problem["jd_times"],
    truth_longs=synthetic_truth_longs,
    start_jd=problem["start_jd"],
    jupiter_sv=problem["jupiter_sv"],
    saturn_sv=problem["saturn_sv"],
    uranus_sv=problem["uranus_sv"],
    mode="synthetic",
    residual_model="ar1",
    max_draws=25,
)
print("Synthetic AR(1) PPC path check:", synthetic_ar1_ppc.tail_probabilities)
Synthetic PPC tail probabilities: {'rms_arcsec': 1.0, 'max_abs_arcsec': 0.89, 'late_window_rms_arcsec': 0.7, 'drift_slope_arcsec_per_year': 0.48, 'lag1_autocorrelation': 0.49}
Synthetic AR(1) PPC path check: {'rms_arcsec': 0.76, 'max_abs_arcsec': 0.68, 'late_window_rms_arcsec': 0.64, 'drift_slope_arcsec_per_year': 0.88, 'lag1_autocorrelation': 1.0}

JPL mode (optional, cache-guarded)

This cell reuses the flat-prior AR(1) HDF posterior produced by bayesian.ipynb when that cache is available. It uses the usual JPL Uranus/Jupiter/Saturn state-vector path, then feeds the thinned six-parameter posterior draws through the same predictive machinery as the synthetic case. The output states explicitly when the cache is absent.

Code
JPL_POSTERIOR_CACHE = repo_root / "data/cache/mcmc/bayesian_jpl_flat.h5"

if JPL_POSTERIOR_CACHE.exists():
    logger.info("Loading cached JPL posterior from %s", JPL_POSTERIOR_CACHE)
    jpl_backend = emcee.backends.HDFBackend(JPL_POSTERIOR_CACHE, read_only=True)
    jpl_autocorr = jpl_backend.get_autocorr_time(quiet=True)
    jpl_burn_in = int(2 * np.max(jpl_autocorr))
    jpl_thin = max(1, int(0.5 * np.min(jpl_autocorr)))
    jpl_samples = jpl_backend.get_chain(
        discard=jpl_burn_in, thin=jpl_thin, flat=True
    )

    df_uranus = fetch_planet_vectors(PLANET_IDS["uranus"], use_cache=True)
    df_jupiter = fetch_planet_vectors(PLANET_IDS["jupiter"], use_cache=True)
    df_saturn = fetch_planet_vectors(PLANET_IDS["saturn"], use_cache=True)

    jpl_ppc = run_posterior_predictive_check(
        flat_samples=jpl_samples,
        jd_times=df_uranus["datetime_jd"].to_numpy(dtype=float),
        truth_longs=df_uranus["longitude_rad"].to_numpy(dtype=float),
        start_jd=float(df_uranus["datetime_jd"].to_numpy(dtype=float)[0]),
        jupiter_sv=_sv_from_df(df_jupiter),
        saturn_sv=_sv_from_df(df_saturn),
        uranus_sv=_sv_from_df(df_uranus),
        mode="jpl",
        residual_model="ar1",
        max_draws=100,
    )

    display(dual_render(plot_posterior_predictive_bands, jpl_ppc, alt="Posterior predictive bands — JPL mode"))
    display(dual_render(plot_posterior_predictive_summaries, jpl_ppc, alt="Posterior predictive summaries — JPL mode"))
    print("JPL PPC tail probabilities:", jpl_ppc.tail_probabilities)
else:
    print("No cached JPL posterior samples; skipping JPL predictive check.")
    print(
        "Execute docs/bayesian.ipynb first to generate "
        "data/cache/mcmc/bayesian_jpl_flat.h5."
    )
JPL PPC tail probabilities: {'rms_arcsec': 0.21, 'max_abs_arcsec': 0.19, 'late_window_rms_arcsec': 0.16, 'drift_slope_arcsec_per_year': 0.07, 'lag1_autocorrelation': 0.0}

Interpretation

Detectability proxy. Relative to the illustrative 1-arcsecond scale, the no-Neptune JPL-derived proxy residuals have a large chi2_red and lag-1 autocorrelation near +1. This demonstrates that omitting Neptune creates a strong, structured signal in the computational benchmark. It is not evidence about the original historical measurement errors; those require the separately sourced historical residual series.

Hindcast. The interesting quantity is the holdout error: if a fit learned a real Neptune-like signal, holdout RMS and the 1846-position error should improve as later cutoff years include more of the synthetic benchmark interval.

SBC. With only a handful of IID replicates and two AR(1) path-check replicates, empirical coverage is noisy. The point is to execute both calibration mechanisms and produce reusable result surfaces, not to claim definitive frequentist coverage.

Posterior predictive. The upper-tail probability reports P(T_rep >= T_obs). The committed synthetic RMS value of 1.0 means every replicated RMS exceeded its paired observed RMS. Here the fitted residual RMS is 1.67 arcsec against a fixed 2 arcsec replication scale, so the extreme value can reflect conditioning on an over-fit point estimate; it is not confirmation of calibration or proof of model over-dispersion. The synthetic AR(1) result is explicitly an implementation path check with a fixed rho, not a posterior inference. The cached JPL run did execute here: its lag-1 upper-tail probability is 0.00 and its absolute-drift probability is 0.07, so even the fitted AR(1) discrepancy fails to reproduce important residual structure. Those low tail probabilities reinforce, rather than resolve, the boundary-sensitive model-mismatch warning. JPL results should be interpreted only when the matching cached Bayesian posterior was loaded and the cell ran.