Logdet Method Profiling Across Matrix Sizes

This notebook profiles the runtime of different log-determinant strategies used in bayespecon.

The profiling matrices come from a regular polygon grid generated by the bayespecon.dgp module. Each size n is a grid side length producing an n × n rook-contiguity layout with spatial units; the spatial graph is built from the polygon GeoDataFrame returned by the DGP function.

Methods compared. We restrict the sweep to methods that are actually viable choices in practice. exact, grid_dense, and grid_mc are omitted because they are dominated on cost, accuracy, or both.

Deterministic, exact

  • eigenvalue: precompute eigenvalues of W once; evaluate sum(log(1 - rho * lam_i)) per call.

Grid + interpolation (deterministic)

  • grid_sparse: exact sparse LU evaluated directly on the grid.

  • sparse_spline: sparse LU on a grid plus cubic-spline interpolation.

  • grid_ilu: incomplete-LU (drop-tolerance) factorization on the grid.

Polynomial / spectral approximation

  • chebyshev: Chebyshev polynomial approximation via Clenshaw recurrence (Pace & LeSage 2004).

Stochastic trace estimators for Chebyshev coefficients

These are not separate logdet_method values. They are selected with trace_estimator while keeping logdet_method="chebyshev":

  • trace_estimator="hutchinson": plain Hutchinson Monte Carlo trace estimator.

  • trace_estimator="hutchpp": Hutch++ variance-reduced trace estimator.

  • trace_estimator="xtrace": XTrace leave-one-out variance-reduced trace estimator.

For each matrix size, we report:

  • setup time: build + compile callable logdet function

  • evaluation time: average cost to evaluate at many rho values

import time
from dataclasses import dataclass

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pytensor
import pytensor.tensor as pt
import scipy.sparse as sp
from libpysal import graph

from bayespecon import dgp
from bayespecon._logdet import make_logdet_fn
def make_grid_w(n_side: int) -> np.ndarray:
    """Create a row-standardized rook-contiguity matrix from an n_side x n_side polygon grid.

    Uses ``dgp.simulate_sar`` with ``create_gdf=True`` to generate the polygon
    geometry, then builds a contiguity graph from the returned GeoDataFrame.
    """
    gdf = dgp.simulate_sar(n=n_side, create_gdf=True)
    W = (
        graph.Graph.build_contiguity(gdf, rook=True)
        .transform("r")
        .sparse.toarray()
        .astype(np.float64)
    )
    return W


def compile_logdet_callable(
    W: np.ndarray,
    method: str,
    rho_min: float,
    rho_max: float,
    trace_estimator: str | None = None,
    trace_k: int | None = None,
):
    """Return a compiled callable f(rho) and its setup time in seconds."""
    trace_kwargs = {}
    if trace_estimator is not None:
        trace_kwargs["trace_estimator"] = trace_estimator
    if trace_k is not None:
        trace_kwargs["trace_k"] = trace_k

    t0 = time.perf_counter()
    rho = pt.scalar("rho")
    expr = make_logdet_fn(
        W,
        method=method,
        rho_min=rho_min,
        rho_max=rho_max,
        **trace_kwargs,
    )(rho)
    fn = pytensor.function([rho], expr)
    setup_s = time.perf_counter() - t0
    return fn, setup_s


def bench_eval_seconds(fn, rhos: np.ndarray, repeats: int = 5) -> float:
    """Median per-call evaluation latency in microseconds."""
    run_times = []
    for _ in range(repeats):
        t0 = time.perf_counter()
        for r in rhos:
            _ = fn(float(r))
        elapsed = time.perf_counter() - t0
        run_times.append(elapsed / len(rhos))
    return float(np.median(run_times))
@dataclass
class ProfileConfig:
    # Grid side lengths; obs count = n_side². e.g. 10→100, 20→400, 75→5625.
    sizes: tuple[int, ...] = (10, 20, 25, 40, 50, 75)
    method_specs: tuple[dict, ...] = (
        {
            "label": "eigenvalue",
            "method": "eigenvalue",
            "rho_min": -0.95,
            "rho_max": 0.95,
        },
        {
            "label": "grid_sparse",
            "method": "grid_sparse",
            "rho_min": -0.95,
            "rho_max": 0.95,
        },
        {
            "label": "sparse_spline",
            "method": "sparse_spline",
            "rho_min": 1e-5,
            "rho_max": 0.95,
        },
        {"label": "grid_ilu", "method": "grid_ilu", "rho_min": -0.95, "rho_max": 0.95},
        {
            "label": "chebyshev",
            "method": "chebyshev",
            "rho_min": -0.95,
            "rho_max": 0.95,
        },
        {
            "label": "chebyshev+hutchinson",
            "method": "chebyshev",
            "trace_estimator": "hutchinson",
            "rho_min": -0.95,
            "rho_max": 0.95,
        },
        {
            "label": "chebyshev+hutchpp",
            "method": "chebyshev",
            "trace_estimator": "hutchpp",
            "rho_min": -0.95,
            "rho_max": 0.95,
        },
        {
            "label": "chebyshev+xtrace",
            "method": "chebyshev",
            "trace_estimator": "xtrace",
            "rho_min": -0.95,
            "rho_max": 0.95,
        },
    )
    method_max_n: dict = None
    eval_points: int = 80
    eval_repeats: int = 3
    seed: int = 2026

    def __post_init__(self):
        if self.method_max_n is None:
            self.method_max_n = {spec["label"]: 75 for spec in self.method_specs}


cfg = ProfileConfig()

results = []
skipped = []

for n in cfg.sizes:
    W = make_grid_w(n_side=n)
    print(f"Profiling n_side={n} ({n * n} obs)...")
    for spec in cfg.method_specs:
        label = spec["label"]
        method = spec["method"]
        trace_estimator = spec.get("trace_estimator")
        trace_k = spec.get("trace_k")

        if n > cfg.method_max_n[label]:
            skipped.append(
                {
                    "n_side": n,
                    "n_obs": n * n,
                    "method": label,
                    "reason": "above method_max_n cap",
                }
            )
            continue

        rho_min = spec["rho_min"]
        rho_max = spec["rho_max"]
        rho_grid = np.linspace(rho_min, rho_max, cfg.eval_points)

        try:
            fn, setup_s = compile_logdet_callable(
                W,
                method=method,
                rho_min=rho_min,
                rho_max=rho_max,
                trace_estimator=trace_estimator,
                trace_k=trace_k,
            )
            eval_s = bench_eval_seconds(fn, rho_grid, repeats=cfg.eval_repeats)
            results.append(
                {
                    "n_side": n,
                    "n_obs": n * n,
                    "method": label,
                    "logdet_method": method,
                    "trace_estimator": trace_estimator or "",
                    "trace_k": trace_k,
                    "rho_min": rho_min,
                    "rho_max": rho_max,
                    "setup_ms": 1e3 * setup_s,
                    "eval_us": 1e6 * eval_s,
                }
            )
        except Exception as exc:
            skipped.append(
                {
                    "n_side": n,
                    "n_obs": n * n,
                    "method": label,
                    "reason": f"failed: {type(exc).__name__}: {exc}",
                }
            )

res = pd.DataFrame(results).sort_values(["method", "n_obs"]).reset_index(drop=True)
if not res.empty:
    res["total_ms"] = res["setup_ms"] + (res["eval_us"] * cfg.eval_points / 1e3)

res
Profiling n_side=10 (100 obs)...
Profiling n_side=20 (400 obs)...
Profiling n_side=25 (625 obs)...
Profiling n_side=40 (1600 obs)...
Profiling n_side=50 (2500 obs)...
Profiling n_side=75 (5625 obs)...
n_side n_obs method logdet_method trace_estimator trace_k rho_min rho_max setup_ms eval_us total_ms
0 10 100 chebyshev chebyshev None -0.95000 0.95 669.684859 4.264037 670.025982
1 20 400 chebyshev chebyshev None -0.95000 0.95 717.545191 3.925650 717.859243
2 25 625 chebyshev chebyshev None -0.95000 0.95 796.808445 3.876825 797.118591
3 40 1600 chebyshev chebyshev None -0.95000 0.95 1656.625982 3.866813 1656.935327
4 50 2500 chebyshev chebyshev None -0.95000 0.95 711.763528 4.042950 712.086964
5 75 5625 chebyshev chebyshev None -0.95000 0.95 823.844545 3.889712 824.155722
6 10 100 chebyshev+hutchinson chebyshev hutchinson None -0.95000 0.95 117.965558 3.728600 118.263846
7 20 400 chebyshev+hutchinson chebyshev hutchinson None -0.95000 0.95 152.221339 3.674900 152.515331
8 25 625 chebyshev+hutchinson chebyshev hutchinson None -0.95000 0.95 237.778894 4.072625 238.104704
9 40 1600 chebyshev+hutchinson chebyshev hutchinson None -0.95000 0.95 1094.192769 3.980100 1094.511177
10 50 2500 chebyshev+hutchinson chebyshev hutchinson None -0.95000 0.95 705.078440 3.648988 705.370359
11 75 5625 chebyshev+hutchinson chebyshev hutchinson None -0.95000 0.95 1015.371778 3.612800 1015.660802
12 10 100 chebyshev+hutchpp chebyshev hutchpp None -0.95000 0.95 117.553319 3.904000 117.865639
13 20 400 chebyshev+hutchpp chebyshev hutchpp None -0.95000 0.95 156.301542 3.959700 156.618318
14 25 625 chebyshev+hutchpp chebyshev hutchpp None -0.95000 0.95 235.915653 3.866312 236.224958
15 40 1600 chebyshev+hutchpp chebyshev hutchpp None -0.95000 0.95 1310.044489 3.589263 1310.331630
16 50 2500 chebyshev+hutchpp chebyshev hutchpp None -0.95000 0.95 710.673179 4.166887 711.006530
17 75 5625 chebyshev+hutchpp chebyshev hutchpp None -0.95000 0.95 836.842903 3.617563 837.132308
18 10 100 chebyshev+xtrace chebyshev xtrace None -0.95000 0.95 117.228921 3.726475 117.527039
19 20 400 chebyshev+xtrace chebyshev xtrace None -0.95000 0.95 155.467067 3.628712 155.757364
20 25 625 chebyshev+xtrace chebyshev xtrace None -0.95000 0.95 238.734330 3.880087 239.044737
21 40 1600 chebyshev+xtrace chebyshev xtrace None -0.95000 0.95 1089.756084 4.012150 1090.077056
22 50 2500 chebyshev+xtrace chebyshev xtrace None -0.95000 0.95 772.457377 3.841400 772.764689
23 75 5625 chebyshev+xtrace chebyshev xtrace None -0.95000 0.95 924.115229 4.115938 924.444504
24 10 100 eigenvalue eigenvalue None -0.95000 0.95 2152.242368 5.444687 2152.677943
25 20 400 eigenvalue eigenvalue None -0.95000 0.95 56.674118 7.591663 57.281451
26 25 625 eigenvalue eigenvalue None -0.95000 0.95 142.139204 9.024450 142.861160
27 40 1600 eigenvalue eigenvalue None -0.95000 0.95 982.209312 15.478263 983.447573
28 50 2500 eigenvalue eigenvalue None -0.95000 0.95 3168.656609 21.868000 3170.406049
29 75 5625 eigenvalue eigenvalue None -0.95000 0.95 31465.477253 44.110300 31469.006077
30 10 100 grid_ilu grid_ilu None -0.95000 0.95 92.706133 5.340662 93.133386
31 20 400 grid_ilu grid_ilu None -0.95000 0.95 391.736002 5.123450 392.145878
32 25 625 grid_ilu grid_ilu None -0.95000 0.95 330.363281 5.371950 330.793037
33 40 1600 grid_ilu grid_ilu None -0.95000 0.95 760.063037 5.269050 760.484561
34 50 2500 grid_ilu grid_ilu None -0.95000 0.95 1200.164317 5.360800 1200.593181
35 75 5625 grid_ilu grid_ilu None -0.95000 0.95 2788.304524 5.452950 2788.740760
36 10 100 grid_sparse grid_sparse None -0.95000 0.95 2814.057542 5.537325 2814.500528
37 20 400 grid_sparse grid_sparse None -0.95000 0.95 196.591273 5.566862 197.036622
38 25 625 grid_sparse grid_sparse None -0.95000 0.95 291.414835 5.496500 291.854555
39 40 1600 grid_sparse grid_sparse None -0.95000 0.95 657.673591 5.398363 658.105460
40 50 2500 grid_sparse grid_sparse None -0.95000 0.95 1038.342907 5.359425 1038.771661
41 75 5625 grid_sparse grid_sparse None -0.95000 0.95 2521.185917 5.301850 2521.610065
42 10 100 sparse_spline sparse_spline None 0.00001 0.95 591.301920 5.461463 591.738837
43 20 400 sparse_spline sparse_spline None 0.00001 0.95 52.425322 5.567375 52.870712
44 25 625 sparse_spline sparse_spline None 0.00001 0.95 59.713387 5.390725 60.144645
45 40 1600 sparse_spline sparse_spline None 0.00001 0.95 100.728288 5.079138 101.134619
46 50 2500 sparse_spline sparse_spline None 0.00001 0.95 146.843632 5.324000 147.269552
47 75 5625 sparse_spline sparse_spline None 0.00001 0.95 364.860478 5.352175 365.288652
if res.empty:
    raise RuntimeError("No profiling results were generated.")

# Distinct color + marker per profiled configuration.
method_styles = {
    "eigenvalue": {"color": "#ff7f0e", "marker": "s", "linestyle": "-"},
    "grid_sparse": {"color": "#d62728", "marker": "v", "linestyle": "-"},
    "sparse_spline": {"color": "#9467bd", "marker": "D", "linestyle": "-"},
    "grid_ilu": {"color": "#8c564b", "marker": "P", "linestyle": "-"},
    "chebyshev": {"color": "#000000", "marker": "*", "linestyle": "-"},
    "chebyshev+hutchinson": {"color": "#17becf", "marker": "o", "linestyle": "--"},
    "chebyshev+hutchpp": {"color": "#bcbd22", "marker": "s", "linestyle": "--"},
    "chebyshev+xtrace": {"color": "#7f7f7f", "marker": "^", "linestyle": "--"},
}

fig, axes = plt.subplots(1, 3, figsize=(17, 4.8), constrained_layout=True)

for method, grp in res.groupby("method"):
    grp = grp.sort_values("n_obs")
    style = method_styles.get(method, {"marker": "o"})
    axes[0].plot(grp["n_obs"], grp["setup_ms"], label=method, **style)
    axes[1].plot(grp["n_obs"], grp["eval_us"], label=method, **style)
    axes[2].plot(grp["n_obs"], grp["total_ms"], label=method, **style)

axes[0].set_title("Setup Time vs n")
axes[0].set_xlabel("observations")
axes[0].set_ylabel("setup time (ms)")
axes[0].set_yscale("log")
axes[0].grid(True, alpha=0.3)

axes[1].set_title("Evaluation Time vs n")
axes[1].set_xlabel("observations")
axes[1].set_ylabel("time per rho eval (us)")
axes[1].set_yscale("log")
axes[1].grid(True, alpha=0.3)

axes[2].set_title(f"Total Time vs n (setup + {cfg.eval_points} evals)")
axes[2].set_xlabel("observations")
axes[2].set_ylabel("total time (ms)")
axes[2].set_yscale("log")
axes[2].grid(True, alpha=0.3)

# Single shared legend to the right of the figure so it doesn't crowd the panels.
handles, labels = axes[0].get_legend_handles_labels()
fig.legend(
    handles,
    labels,
    loc="center left",
    bbox_to_anchor=(1.0, 0.5),
    frameon=False,
    title="configuration",
)

plt.show()
../_images/3cbf3c3956841e0802b15c2c6a819958475768c1a5cf16df817d5533dd1615d4.png
summary = res.pivot_table(
    index="n_obs", columns="method", values=["setup_ms", "eval_us", "total_ms"]
).sort_index()
display(summary)

if skipped:
    skipped_df = (
        pd.DataFrame(skipped).sort_values(["n_side", "method"]).reset_index(drop=True)
    )
    print("Skipped combinations (due to safety caps or failures):")
    display(skipped_df)
eval_us setup_ms total_ms
method chebyshev chebyshev+hutchinson chebyshev+hutchpp chebyshev+xtrace eigenvalue grid_ilu grid_sparse sparse_spline chebyshev chebyshev+hutchinson ... grid_sparse sparse_spline chebyshev chebyshev+hutchinson chebyshev+hutchpp chebyshev+xtrace eigenvalue grid_ilu grid_sparse sparse_spline
n_obs
100 4.264037 3.728600 3.904000 3.726475 5.444687 5.340662 5.537325 5.461463 669.684859 117.965558 ... 2814.057542 591.301920 670.025982 118.263846 117.865639 117.527039 2152.677943 93.133386 2814.500528 591.738837
400 3.925650 3.674900 3.959700 3.628712 7.591663 5.123450 5.566862 5.567375 717.545191 152.221339 ... 196.591273 52.425322 717.859243 152.515331 156.618318 155.757364 57.281451 392.145878 197.036622 52.870712
625 3.876825 4.072625 3.866312 3.880087 9.024450 5.371950 5.496500 5.390725 796.808445 237.778894 ... 291.414835 59.713387 797.118591 238.104704 236.224958 239.044737 142.861160 330.793037 291.854555 60.144645
1600 3.866813 3.980100 3.589263 4.012150 15.478263 5.269050 5.398363 5.079138 1656.625982 1094.192769 ... 657.673591 100.728288 1656.935327 1094.511177 1310.331630 1090.077056 983.447573 760.484561 658.105460 101.134619
2500 4.042950 3.648988 4.166887 3.841400 21.868000 5.360800 5.359425 5.324000 711.763528 705.078440 ... 1038.342907 146.843632 712.086964 705.370359 711.006530 772.764689 3170.406049 1200.593181 1038.771661 147.269552
5625 3.889712 3.612800 3.617563 4.115938 44.110300 5.452950 5.301850 5.352175 823.844545 1015.371778 ... 2521.185917 364.860478 824.155722 1015.660802 837.132308 924.444504 31469.006077 2788.740760 2521.610065 365.288652

6 rows × 24 columns

Logdet Approximation Accuracy

This section directly compares how accurately each stochastic method approximates the true log-determinant curve, independent of sampling noise. We compute the exact log|I - ρW| via eigenvalues and compare against each method’s approximation across a dense ρ grid.

from bayespecon._logdet import make_logdet_numpy_fn

# Use the same W from the last profiling run (or regenerate)
W_accuracy = make_grid_w(n_side=25)
W_sp = sp.csr_matrix(W_accuracy)

# Exact logdet via eigenvalues
W_dense = W_sp.toarray()
eigs = np.linalg.eigvals(W_dense)
rho_grid_acc = np.linspace(-0.95, 0.95, 200)
exact_logdet = np.array([np.sum(np.log(1 - r * eigs)) for r in rho_grid_acc])

trace_configs = {
    "chebyshev+hutchinson": "hutchinson",
    "chebyshev+hutchpp": "hutchpp",
    "chebyshev+xtrace": "xtrace",
}

acc_rows = []
for name, estimator in trace_configs.items():
    fn = make_logdet_numpy_fn(
        W_sp,
        None,
        method="chebyshev",
        rho_min=-0.95,
        rho_max=0.95,
        trace_estimator=estimator,
    )
    approx = np.array([fn(r) for r in rho_grid_acc])
    err = np.abs(approx - exact_logdet)
    acc_rows.append(
        {
            "method": name,
            "logdet_method": "chebyshev",
            "trace_estimator": estimator,
            "max_err": err.max(),
            "mean_err": err.mean(),
            "rmse": np.sqrt((err**2).mean()),
        }
    )

acc_df = pd.DataFrame(acc_rows).sort_values("rmse").reset_index(drop=True)
display(acc_df)

fig, ax = plt.subplots(figsize=(10, 5))
for name, estimator in trace_configs.items():
    fn = make_logdet_numpy_fn(
        W_sp,
        None,
        method="chebyshev",
        rho_min=-0.95,
        rho_max=0.95,
        trace_estimator=estimator,
    )
    approx = np.array([fn(r) for r in rho_grid_acc])
    ax.plot(rho_grid_acc, approx - exact_logdet, label=name, alpha=0.7)
ax.axhline(0, color="black", linestyle="--", linewidth=0.8)
ax.set_xlabel("rho")
ax.set_ylabel("approximation error")
ax.set_title("Logdet Approximation Error vs Exact (eigenvalue)")
ax.legend()
ax.grid(True, alpha=0.3)
plt.show()
method logdet_method trace_estimator max_err mean_err rmse
0 chebyshev+hutchinson chebyshev hutchinson 0.004307 0.000746 0.001017
1 chebyshev+hutchpp chebyshev hutchpp 0.004307 0.000746 0.001017
2 chebyshev+xtrace chebyshev xtrace 0.004307 0.000746 0.001017
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/matplotlib/cbook.py:1719: ComplexWarning: Casting complex values to real discards the imaginary part
  return math.isfinite(val)
/home/runner/micromamba/envs/test/lib/python3.14/site-packages/matplotlib/cbook.py:1355: ComplexWarning: Casting complex values to real discards the imaginary part
  return np.asarray(x, float)
../_images/66502225fb7eff70e23a024c06c095c03c8a60cdb86292d8a44e942470560353.png

Coefficient and Fit-Time Comparison Across Logdet Methods

This section uses a regular polygon grid generated by bayespecon.dgp to simulate one SAR dataset, maps the simulated response, and estimates the same SAR model using each logdet_method.

We compare:

  • posterior mean coefficients (rho, beta_0, beta_1, beta_2)

  • total wall-clock time to estimate each model

To keep this section runnable in docs contexts, sampling is intentionally modest.

from bayespecon import SAR


def simulate_sar_data(n_side: int = 25, seed: int = 2026):
    """Simulate SAR data on an n_side x n_side polygon grid using the DGP module."""
    rng = np.random.default_rng(seed)
    beta_true = np.array([1.0, 0.8, -0.5], dtype=np.float64)
    rho_true = 0.35
    sigma_true = 0.7

    gdf = dgp.simulate_sar(
        n=n_side,
        rho=rho_true,
        beta=beta_true,
        sigma=sigma_true,
        rng=rng,
        create_gdf=True,
    )
    # Keep the SAR parameterization consistent with model assumptions.
    W_graph = graph.Graph.build_contiguity(gdf, rook=True).transform("r")
    y = gdf["y"].to_numpy()
    X_cols = [c for c in gdf.columns if c.startswith("X_")]
    X = gdf[X_cols].to_numpy()
    return gdf, y, X, W_graph, rho_true, beta_true


def fit_sar_for_method(
    y,
    X,
    W,
    method: str,
    label: str | None = None,
    trace_estimator: str | None = None,
    trace_k: int | None = None,
    draws: int = 1000,
    tune: int = 1000,
    seed: int = 2026,
):
    """Fit SAR with a specific logdet configuration and return posterior means + runtime."""
    trace_kwargs = {}
    if trace_estimator is not None:
        trace_kwargs["trace_estimator"] = trace_estimator
    if trace_k is not None:
        trace_kwargs["trace_k"] = trace_k

    t0 = time.perf_counter()

    model = SAR(
        y=y,
        X=X,
        W=W,
        logdet_method=method,
        **trace_kwargs,
    )
    idata = model.fit(
        draws=draws,
        tune=tune,
        chains=2,
        cores=1,
        random_seed=seed,
        target_accept=0.95,
        progressbar=False,
        compute_convergence_checks=False,
    )

    elapsed_s = time.perf_counter() - t0
    beta_mean = idata.posterior["beta"].mean(("chain", "draw")).to_numpy()
    rho_mean = float(idata.posterior["rho"].mean(("chain", "draw")).to_numpy())

    return {
        "method": label or method,
        "logdet_method": method,
        "trace_estimator": trace_estimator or "",
        "trace_k": trace_k,
        "total_time_s": elapsed_s,
        "rho": rho_mean,
        "beta_0": float(beta_mean[0]),
        "beta_1": float(beta_mean[1]),
        "beta_2": float(beta_mean[2]),
    }


methods_for_model = [
    {"label": "eigenvalue", "method": "eigenvalue"},
    {"label": "chebyshev", "method": "chebyshev"},
    {
        "label": "chebyshev+hutchinson",
        "method": "chebyshev",
        "trace_estimator": "hutchinson",
    },
    {"label": "chebyshev+hutchpp", "method": "chebyshev", "trace_estimator": "hutchpp"},
    {"label": "chebyshev+xtrace", "method": "chebyshev", "trace_estimator": "xtrace"},
]
gdf_model, y_model, X_model, W_model, rho_true, beta_true = simulate_sar_data(n_side=25)

fig, ax = plt.subplots(1, 1, figsize=(7, 7))
gdf_model.plot(
    column="y", cmap="viridis", legend=True, linewidth=0.15, edgecolor="white", ax=ax
)
ax.set_title("Simulated y on 25x25 polygon grid")
ax.set_axis_off()
plt.show()

model_rows = []
for spec in methods_for_model:
    label = spec["label"]
    print(f"Estimating SAR with {label}...")
    try:
        model_rows.append(
            fit_sar_for_method(
                y_model,
                X_model,
                W_model,
                method=spec["method"],
                label=label,
                trace_estimator=spec.get("trace_estimator"),
                trace_k=spec.get("trace_k"),
            )
        )
    except Exception as exc:
        model_rows.append(
            {
                "method": label,
                "logdet_method": spec["method"],
                "trace_estimator": spec.get("trace_estimator", ""),
                "trace_k": spec.get("trace_k"),
                "total_time_s": np.nan,
                "rho": np.nan,
                "beta_0": np.nan,
                "beta_1": np.nan,
                "beta_2": np.nan,
                "error": f"{type(exc).__name__}: {exc}",
            }
        )

coef_compare = pd.DataFrame(model_rows)
coef_compare = coef_compare.sort_values("total_time_s", na_position="last").reset_index(
    drop=True
)

coef_compare["rho_true"] = rho_true
coef_compare["abs_err_rho"] = (coef_compare["rho"] - rho_true).abs()
for j, btrue in enumerate(beta_true):
    coef_compare[f"beta_{j}_true"] = btrue
    coef_compare[f"abs_err_beta_{j}"] = (coef_compare[f"beta_{j}"] - btrue).abs()

if "eigenvalue" in coef_compare["method"].values:
    base = coef_compare.loc[
        coef_compare["method"] == "eigenvalue", ["rho", "beta_0", "beta_1", "beta_2"]
    ].iloc[0]
    for col in ["rho", "beta_0", "beta_1", "beta_2"]:
        coef_compare[f"delta_vs_eigen_{col}"] = coef_compare[col] - base[col]

coef_compare
../_images/7423a913d5ac047bc4c9b538d77a43c72586f2be136e1f40b0a1cc235afd97ec.png
Estimating SAR with eigenvalue...
Estimating SAR with chebyshev...
Estimating SAR with chebyshev+hutchinson...
Estimating SAR with chebyshev+hutchpp...
Estimating SAR with chebyshev+xtrace...
method logdet_method trace_estimator trace_k total_time_s rho beta_0 beta_1 beta_2 rho_true ... beta_0_true abs_err_beta_0 beta_1_true abs_err_beta_1 beta_2_true abs_err_beta_2 delta_vs_eigen_rho delta_vs_eigen_beta_0 delta_vs_eigen_beta_1 delta_vs_eigen_beta_2
0 chebyshev+hutchpp chebyshev hutchpp None 0.689703 0.379084 0.886479 0.810998 -0.519154 0.35 ... 1.0 0.113521 0.8 0.010998 -0.5 0.019154 -0.002527 0.003286 0.000508 -0.000207
1 chebyshev+xtrace chebyshev xtrace None 0.702193 0.379084 0.886479 0.810998 -0.519154 0.35 ... 1.0 0.113521 0.8 0.010998 -0.5 0.019154 -0.002527 0.003286 0.000508 -0.000207
2 chebyshev+hutchinson chebyshev hutchinson None 0.724165 0.379084 0.886479 0.810998 -0.519154 0.35 ... 1.0 0.113521 0.8 0.010998 -0.5 0.019154 -0.002527 0.003286 0.000508 -0.000207
3 chebyshev chebyshev None 2.836020 0.379084 0.886479 0.810998 -0.519154 0.35 ... 1.0 0.113521 0.8 0.010998 -0.5 0.019154 -0.002527 0.003286 0.000508 -0.000207
4 eigenvalue eigenvalue None 3.490451 0.381611 0.883193 0.810490 -0.518948 0.35 ... 1.0 0.116807 0.8 0.010490 -0.5 0.018948 0.000000 0.000000 0.000000 0.000000

5 rows × 21 columns

Notes

  • exact scales as O(n³) per call and is intentionally capped at small n.

  • eigenvalue carries a one-time O(n³) eigendecomposition cost, then evaluates in O(n) per rho. Strong choice for repeated evaluation at moderate n.

  • grid_dense builds dense LU on a rho grid (O(G·n³) setup) and interpolates with a cubic spline; cheap per call but heavy to build at large n.

  • grid_sparse and sparse_spline are sparse-LU variants. grid_sparse evaluates the grid directly; sparse_spline adds spline interpolation so per-call cost drops to O(1).

  • grid_ilu swaps the exact sparse LU for an incomplete-LU factorization — fastest sparse setup, with accuracy controlled by the drop tolerance.

  • grid_mc combines a nonnegative rho grid with MC trace evaluation — cheaper setup than the deterministic grid but inherits MC variance.

  • chebyshev builds Chebyshev coefficients once, then evaluates in O(m) per call. For small matrices or supplied eigenvalues it uses exact spectral coefficients; for large matrices it uses the selected stochastic trace_estimator.

  • trace_estimator="hutchinson" is the cheapest stochastic Chebyshev coefficient builder; trace_estimator="hutchpp" uses a low-rank sketch for lower variance; trace_estimator="xtrace" uses a leave-one-out correction. All keep logdet_method="chebyshev" and support the symmetric rho range.

  • Parameter ranges: grid_mc and sparse_spline only support nonnegative rho and are profiled on [1e-5, 0.95]. chebyshev and its trace-estimator variants are profiled on the symmetric range [-0.95, 0.95].

  • Adjust ProfileConfig.sizes and method_max_n for deeper stress tests.

import arviz as az
import pandas as pd

# Refit each logdet configuration and compare effective sample sizes for rho.
ess_rows = []
for spec in methods_for_model:
    label = spec["label"]
    trace_kwargs = {}
    if spec.get("trace_estimator") is not None:
        trace_kwargs["trace_estimator"] = spec["trace_estimator"]
    if spec.get("trace_k") is not None:
        trace_kwargs["trace_k"] = spec["trace_k"]

    model = SAR(
        y=y_model,
        X=X_model,
        W=W_model,
        logdet_method=spec["method"],
        priors={"rho_lower": -1, "rho_upper": 1},
        **trace_kwargs,
    )
    idata = model.fit(
        draws=1000,
        tune=1000,
        chains=2,
        cores=-1,
        random_seed=2026,
        target_accept=0.95,
        progressbar=False,
        compute_convergence_checks=False,
    )
    summary = az.summary(idata, var_names=["rho"])
    ess = summary.loc["rho", "ess_bulk"]
    ess_rows.append(
        {
            "method": label,
            "logdet_method": spec["method"],
            "trace_estimator": spec.get("trace_estimator", ""),
            "trace_k": spec.get("trace_k"),
            "ess_rho": ess,
        }
    )
    az.plot_trace(idata, var_names=["rho"], compact=True, legend=False)
    plt.suptitle(label)

ess_df = pd.DataFrame(ess_rows)
display(ess_df)

ess_df.set_index("method")["ess_rho"].plot.bar(
    ylabel="ESS (rho)", title="Effective Sample Size for rho by Logdet Configuration"
)
method logdet_method trace_estimator trace_k ess_rho
0 eigenvalue eigenvalue None 1933.0
1 chebyshev chebyshev None 2136.0
2 chebyshev+hutchinson chebyshev hutchinson None 2136.0
3 chebyshev+hutchpp chebyshev hutchpp None 2136.0
4 chebyshev+xtrace chebyshev xtrace None 2136.0
<Axes: title={'center': 'Effective Sample Size for rho by Logdet Configuration'}, xlabel='method', ylabel='ESS (rho)'>
../_images/2b1997a39630571b8eec1f59ad476ce846848914cc323bcd38daf25677959f35.png ../_images/8b9c899033ad7c01ad689bb249d2432c0e2445589cfc5e4db15e6f7915693827.png ../_images/932d5c15c668b6fbe047d00c24b9b86b01b5727d3b891f7f9982b86fe7a690ba.png ../_images/d6fd4f4557ed6863d65b3970a0f1998d69fdd3153f2763e1ea02bfdc3357924f.png ../_images/6016166edafbe559fa09c72eac162e8c15d92378612ad3885d4ea2e32e2f9872.png

Method selection policy

The auto-selector (logdet_method=None) now exposes a single Chebyshev-family public method:

  • eigenvalue for n 500 (exact, fast per-call evaluation)

  • chebyshev for n > 500 (cached polynomial approximation evaluated by Clenshaw recurrence)

For large matrices where an eigendecomposition is unavailable, chebyshev builds its coefficients from stochastic trace estimates. The estimator is chosen separately with trace_estimator:

  • default: trace_estimator="hutchpp"

  • alternatives: trace_estimator="hutchinson" or trace_estimator="xtrace"

  • optional probe count: trace_k=...

Why Hutch++ is the default trace estimator: in the profiling sweep it lands on the best cost/accuracy frontier for row-standardized contiguity weights, with much lower build cost than XTrace and lower variance than plain Hutchinson.

Manual overrides:

  1. Default for most work. Leave logdet_method=None and let the auto-selector choose. For large sparse problems this resolves to chebyshev with trace_estimator="hutchpp".

  2. Fully deterministic coefficients. Use logdet_method="eigenvalue" when n is small enough for the eigendecomposition to be acceptable.

  3. Exploratory trace sweeps. Use logdet_method="chebyshev" with trace_estimator="hutchinson", "hutchpp", or "xtrace" and record trace_k.

  4. Reporting and publication. Record logdet_method, trace_estimator, trace_k, and rho bounds in every benchmark or model report.