YEckWPoS09 / candidate_code /markov_core.py
DineshAI's picture
Add proof-certified full-scale evidence for Claims 1-5
523fdd5 verified
Raw
History Blame Contribute Delete
13.1 kB
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
from scipy.optimize import minimize_scalar
def stationary(kernel: np.ndarray) -> np.ndarray:
"""Return the unique stationary distribution of an ergodic kernel."""
m = kernel.shape[0]
system = np.vstack((kernel.T - np.eye(m), np.ones(m)))
rhs = np.r_[np.zeros(m), 1.0]
value, *_ = np.linalg.lstsq(system, rhs, rcond=None)
value = np.maximum(value, 0.0)
return value / value.sum()
def row_kl(left: np.ndarray, right: np.ndarray) -> np.ndarray:
if np.any((left > 0) & (right <= 0)):
return np.full(left.shape[0], np.inf)
terms = np.zeros_like(left, dtype=float)
mask = left > 0
terms[mask] = left[mask] * np.log(left[mask] / right[mask])
return terms.sum(axis=1)
def markov_kl(left: np.ndarray, right: np.ndarray) -> float:
return float(stationary(left) @ row_kl(left, right))
def pseudo_spectral_gap(kernel: np.ndarray) -> tuple[float, int, list[dict[str, float]]]:
"""Compute gamma_ps with a finite stopping certificate.
Once k > 1 / best, the universal bound gap(A_k)/k <= 1/k proves that
no untested k can improve the incumbent.
"""
pi = stationary(kernel)
reverse = kernel.T * pi[None, :] / pi[:, None]
p_power = np.eye(len(pi))
r_power = np.eye(len(pi))
best, best_k, rows, k = 0.0, 0, [], 0
while True:
k += 1
p_power = p_power @ kernel
r_power = reverse @ r_power
multiplicative = r_power @ p_power
similarity = np.sqrt(pi)[:, None] * multiplicative / np.sqrt(pi)[None, :]
eigenvalues = np.linalg.eigvalsh((similarity + similarity.T) / 2)
gap = max(0.0, 1.0 - float(np.sort(eigenvalues)[-2]))
candidate = gap / k
rows.append({"k": k, "gap": gap, "gap_over_k": candidate})
if candidate > best:
best, best_k = candidate, k
if best > 0 and k > 1.0 / best:
break
if k >= 10000:
raise RuntimeError("pseudo-spectral-gap certificate did not terminate")
return best, best_k, rows
def poisson_operator(kernel: np.ndarray) -> np.ndarray:
pi = stationary(kernel)
projection = np.ones((len(pi), 1)) @ pi[None, :]
return np.linalg.solve(np.eye(len(pi)) - kernel + projection, np.eye(len(pi)) - projection)
def poisson_solution(kernel: np.ndarray, function: np.ndarray) -> np.ndarray:
return poisson_operator(kernel) @ function
def poisson_bound(kernel: np.ndarray) -> tuple[float, float, float, int]:
pi = stationary(kernel)
gap, best_k, _ = pseudo_spectral_gap(kernel)
if math.isclose(gap, 1.0, abs_tol=1e-12):
paper_constant = 2.0
else:
paper_constant = (
(1.0 - gap) ** (-1.0 / (2.0 * gap))
/ math.sqrt(float(pi.min()))
/ (1.0 - math.sqrt(1.0 - gap))
)
exact_operator_norm = float(np.max(np.sum(np.abs(poisson_operator(kernel)), axis=1)))
return paper_constant, exact_operator_norm, gap, best_k
def empirical_log_likelihood(counts: np.ndarray) -> float:
visits = counts.sum(axis=1)
value = 0.0
for i in range(len(visits)):
if visits[i] <= 0:
continue
mask = counts[i] > 0
value += float(
np.sum(counts[i, mask] * np.log(counts[i, mask] / visits[i]))
)
return value
def xlogx(value: int) -> float:
return 0.0 if value == 0 else value * math.log(value)
def parametric_kernel(base: np.ndarray, feature: np.ndarray, theta: float) -> np.ndarray:
tilted = base * np.exp(theta * feature)[None, :]
values, vectors = np.linalg.eig(tilted)
index = int(np.argmax(values.real))
rho = float(values[index].real)
vector = np.abs(vectors[:, index].real)
vector /= vector.sum()
kernel = tilted * vector[None, :] / (rho * vector[:, None])
kernel = np.maximum(kernel.real, 1e-300)
return kernel / kernel.sum(axis=1, keepdims=True)
@dataclass
class ThetaFamily:
base: np.ndarray
feature: np.ndarray
low: float
high: float
grid_size: int = 401
def __post_init__(self) -> None:
self.grid = np.linspace(self.low, self.high, self.grid_size)
self.kernels = np.stack(
[parametric_kernel(self.base, self.feature, float(x)) for x in self.grid]
)
self.log_kernels = np.log(self.kernels)
def glr(self, counts: np.ndarray, refine: bool = True) -> tuple[float, float]:
empirical = empirical_log_likelihood(counts)
likelihoods = np.einsum("gij,ij->g", self.log_kernels, counts)
index = int(np.argmax(likelihoods))
theta = float(self.grid[index])
null_log_likelihood = float(likelihoods[index])
if refine:
left = float(self.grid[max(0, index - 1)])
right = float(self.grid[min(self.grid_size - 1, index + 1)])
def objective(value: float) -> float:
return -float(np.sum(counts * np.log(parametric_kernel(self.base, self.feature, value))))
result = minimize_scalar(
objective,
bounds=(left, right),
method="bounded",
options={"xatol": 1e-11},
)
if result.success and -float(result.fun) >= null_log_likelihood:
theta = float(result.x)
null_log_likelihood = -float(result.fun)
return max(0.0, empirical - null_log_likelihood), theta
def information_projection(self, alternative: np.ndarray) -> tuple[float, float]:
result = minimize_scalar(
lambda theta: markov_kl(
alternative, parametric_kernel(self.base, self.feature, theta)
),
bounds=(self.low, self.high),
method="bounded",
options={"xatol": 1e-12},
)
if not result.success:
raise RuntimeError(result.message)
return float(result.fun), float(result.x)
def boundary(visits: np.ndarray, log_inverse_error: float, multiplier: float | None = None) -> float:
m = len(visits)
psi = float(np.log(math.e * (1.0 + visits / (m - 1))).sum())
return log_inverse_error + (m - 1 if multiplier is None else multiplier) * psi
def simulate_test(
kernel: np.ndarray,
family: ThetaFamily,
log_inverse_error: float,
seed: int,
horizon: int,
initial_state: int = 0,
boundary_multiplier: float | None = None,
trace: bool = False,
) -> dict:
rng = np.random.default_rng(seed)
state, m = initial_state, len(kernel)
counts = np.zeros((m, m), dtype=np.int64)
trace_rows = []
last_statistic = last_boundary = last_theta = 0.0
for time in range(1, horizon + 1):
next_state = int(rng.choice(m, p=kernel[state]))
counts[state, next_state] += 1
state = next_state
visits = counts.sum(axis=1)
last_boundary = boundary(visits, log_inverse_error, boundary_multiplier)
# A grid gives an upper bound on the true GLR (the continuous null
# likelihood is at least the grid maximum). Refine whenever that bound
# is near the boundary; otherwise it already certifies "continue".
last_statistic, last_theta = family.glr(counts, refine=False)
if last_statistic >= last_boundary - 2.0:
last_statistic, last_theta = family.glr(counts, refine=True)
if trace and (time <= 10 or time % 50 == 0 or last_statistic >= last_boundary):
empirical = np.full((m, m), 1.0 / m)
active = visits > 0
empirical[active] = counts[active] / visits[active, None]
trace_rows.append(
{
"time": time,
"counts": counts.tolist(),
"empirical_kernel": empirical.tolist(),
"visits": visits.tolist(),
"L_t": last_statistic,
"beta_t": last_boundary,
"theta_hat": last_theta,
}
)
if last_statistic >= last_boundary:
return {
"stopped": True,
"stopping_time": time,
"L_t": last_statistic,
"beta_t": last_boundary,
"theta_hat": last_theta,
"counts": counts.tolist(),
"trace": trace_rows,
}
return {
"stopped": False,
"stopping_time": None,
"L_t": last_statistic,
"beta_t": last_boundary,
"theta_hat": last_theta,
"counts": counts.tolist(),
"trace": trace_rows,
}
def simulate_thresholds(
kernel: np.ndarray,
family: ThetaFamily,
log_inverse_errors: tuple[float, ...],
seed: int,
horizon: int,
initial_state: int = 0,
) -> dict[float, int | None]:
"""Record exact first crossings for several Algorithm 1 thresholds."""
levels = sorted(set(float(value) for value in log_inverse_errors))
crossings = {value: None for value in levels}
rng = np.random.default_rng(seed)
state, m = initial_state, len(kernel)
counts = np.zeros((m, m), dtype=np.int64)
grid_likelihoods = np.zeros(family.grid_size)
empirical = 0.0
for time in range(1, horizon + 1):
next_state = int(rng.choice(m, p=kernel[state]))
old_count = int(counts[state, next_state])
old_visit = int(counts[state].sum())
counts[state, next_state] += 1
grid_likelihoods += family.log_kernels[:, state, next_state]
empirical += (
xlogx(old_count + 1)
- xlogx(old_count)
- xlogx(old_visit + 1)
+ xlogx(old_visit)
)
state = next_state
visits = counts.sum(axis=1)
penalty = boundary(visits, 0.0)
pending = [value for value in levels if crossings[value] is None]
statistic = max(0.0, empirical - float(grid_likelihoods.max()))
if statistic >= pending[0] + penalty - 2.0:
statistic, _ = family.glr(counts, refine=True)
for value in pending:
if statistic < value + penalty:
break
crossings[value] = time
if all(value is not None for value in crossings.values()):
break
return crossings
def simulate_parallel_thresholds(
kernel: np.ndarray,
p_family: ThetaFamily,
p_log_inverse_errors: tuple[float, ...],
q_family: ThetaFamily,
q_log_inverse_errors: tuple[float, ...],
seed: int,
horizon: int,
target_side: str,
initial_state: int = 0,
) -> dict[str, dict[float, int | None]]:
"""Run the two composite Algorithm 1 tests on one shared path."""
if target_side not in {"p", "q"}:
raise ValueError("target_side must be 'p' or 'q'")
p_levels = sorted(set(float(value) for value in p_log_inverse_errors))
q_levels = sorted(set(float(value) for value in q_log_inverse_errors))
p_crossings = {value: None for value in p_levels}
q_crossings = {value: None for value in q_levels}
rng = np.random.default_rng(seed)
state, m = initial_state, len(kernel)
counts = np.zeros((m, m), dtype=np.int64)
p_grid_likelihoods = np.zeros(p_family.grid_size)
q_grid_likelihoods = np.zeros(q_family.grid_size)
empirical = 0.0
def update(
family: ThetaFamily,
levels: list[float],
crossings: dict[float, int | None],
grid_likelihoods: np.ndarray,
penalty: float,
time: int,
) -> None:
pending = [value for value in levels if crossings[value] is None]
if not pending:
return
statistic = max(0.0, empirical - float(grid_likelihoods.max()))
if statistic >= pending[0] + penalty - 2.0:
statistic, _ = family.glr(counts, refine=True)
for value in pending:
if statistic < value + penalty:
break
crossings[value] = time
for time in range(1, horizon + 1):
next_state = int(rng.choice(m, p=kernel[state]))
old_count = int(counts[state, next_state])
old_visit = int(counts[state].sum())
counts[state, next_state] += 1
p_grid_likelihoods += p_family.log_kernels[:, state, next_state]
q_grid_likelihoods += q_family.log_kernels[:, state, next_state]
empirical += (
xlogx(old_count + 1)
- xlogx(old_count)
- xlogx(old_visit + 1)
+ xlogx(old_visit)
)
state = next_state
penalty = boundary(counts.sum(axis=1), 0.0)
update(
p_family,
p_levels,
p_crossings,
p_grid_likelihoods,
penalty,
time,
)
update(
q_family,
q_levels,
q_crossings,
q_grid_likelihoods,
penalty,
time,
)
target = p_crossings if target_side == "p" else q_crossings
if all(value is not None for value in target.values()):
break
return {"p": p_crossings, "q": q_crossings}