| """Independent re-implementation of the sequential Markov-chain test of | |
| Sethi et al. (2026), "Asymptotically Optimal Sequential Testing with Markovian | |
| Data" (ICML 2026, arXiv:2602.17587, OpenReview YEckWPoS09). | |
| Implemented from: | |
| * Algorithm 1 (Section 4, paper text lines 400-428) | |
| * Definition 3.2 (stationary-weighted KL divergence) | |
| * Theorem 3.3 (non-asymptotic instance-dependent lower bound, eq (3)) | |
| * Proposition 3.1 (Poisson-solution bound, constant C_P) | |
| * Definition 2.2 (pseudo-spectral gap, Paulin 2015) | |
| * Appendix G.3 (parametric family construction) | |
| Pure NumPy / SciPy. No dependence on the authors' notebooks. | |
| """ | |
| import numpy as np | |
| from numpy.linalg import solve, lstsq | |
| from scipy.linalg import eig as scipy_eig | |
| from scipy.optimize import minimize_scalar | |
| EPS = 1e-15 | |
| # --------------------------------------------------------------------------- | |
| # Markov-chain primitives | |
| # --------------------------------------------------------------------------- | |
| def stationary_dist(P): | |
| """Stationary distribution pi of row-stochastic P, solving pi^T P = pi^T.""" | |
| m = P.shape[0] | |
| A = np.vstack([P.T - np.eye(m), np.ones(m)]) | |
| b = np.zeros(m + 1) | |
| b[-1] = 1.0 | |
| pi, *_ = lstsq(A, b, rcond=None) | |
| pi = np.maximum(pi, 0.0) | |
| return pi / pi.sum() | |
| def time_reversal(P, pi): | |
| """P* (time reversal): P*(i,j) = P(j,i) * pi(j) / pi(i).""" | |
| return (P.T * pi[None, :]) / np.maximum(pi[:, None], EPS) | |
| def pseudo_spectral_gap(P, pi=None, K=40): | |
| """gamma_ps(P) = max_{k>=1} (1/k) * (1 - lambda_2((P*)^k P^k)) | |
| where lambda_2 is the second-largest eigenvalue (the largest equals 1). | |
| Matches Paulin (2015) Definition 3.4 as quoted in the paper's Definition 2.2. | |
| For the i.i.d. case (rows all equal to pi) this returns 1. | |
| """ | |
| if pi is None: | |
| pi = stationary_dist(P) | |
| Pstar = time_reversal(P, pi) | |
| m = P.shape[0] | |
| best = 0.0 | |
| Pk = np.eye(m) | |
| Pstark = np.eye(m) | |
| for k in range(1, K + 1): | |
| Pk = Pk @ P | |
| Pstark = Pstark @ Pstar | |
| M = Pstark @ Pk | |
| ev = np.sort(np.real(scipy_eig(M, right=False))) | |
| lam2 = ev[-2] # second-largest (largest is ~1) | |
| gap_k = (1.0 / k) * max(0.0, 1.0 - lam2) | |
| if gap_k > best: | |
| best = gap_k | |
| return best | |
| def C_constant(P, pi=None): | |
| """C_P from Proposition 3.1 (paper eq (9)). | |
| || omega_{P,f} ||_inf <= C_P ||f||_inf, with | |
| C_P = 1 / [ (1-gamma_ps)^{1/(2 gamma_ps)} * sqrt(pi*) * (1 - sqrt(1-gamma_ps)) ] | |
| for gamma_ps in (0,1), and C_P = 2 for gamma_ps = 1. | |
| """ | |
| if pi is None: | |
| pi = stationary_dist(P) | |
| pi_star = pi.min() | |
| gamma = pseudo_spectral_gap(P, pi) | |
| if gamma >= 1.0 - 1e-9: | |
| return 2.0 | |
| g = min(gamma, 1.0 - 1e-12) | |
| base = (1.0 - g) ** (1.0 / (2.0 * g)) | |
| return 1.0 / (base * np.sqrt(pi_star) * (1.0 - np.sqrt(1.0 - g))) | |
| # --------------------------------------------------------------------------- | |
| # Information quantities | |
| # --------------------------------------------------------------------------- | |
| def kl_row(q, p, eps=EPS): | |
| """D_KL(q || p) for discrete distributions given as rows.""" | |
| q = np.clip(np.asarray(q, float), eps, 1.0) | |
| p = np.clip(np.asarray(p, float), eps, 1.0) | |
| q = q / q.sum() | |
| p = p / p.sum() | |
| return float(np.sum(q * np.log(q / p))) | |
| def f_P_vector(Q, P): | |
| """f_P(i) = D_KL(Q(i, .) || P(i, .)) for each state i (Theorem 3.3).""" | |
| return np.array([kl_row(Q[i], P[i]) for i in range(Q.shape[0])]) | |
| def D_M(Q, P, pi_Q=None): | |
| """D_M(Q,P) = sum_i pi_i D_KL(Q(i,.) || P(i,.)) (Definition 3.2).""" | |
| if pi_Q is None: | |
| pi_Q = stationary_dist(Q) | |
| f = f_P_vector(Q, P) | |
| return float(pi_Q @ f), f | |
| def poisson_solution(Q, f, pi_Q=None): | |
| """Solution omega to (I - Q) omega = f - (pi.f) 1, normalized by pi.omega = 0 | |
| (paper eq (2)).""" | |
| if pi_Q is None: | |
| pi_Q = stationary_dist(Q) | |
| m = Q.shape[0] | |
| rhs = f - (pi_Q @ f) * np.ones(m) | |
| A = np.eye(m) - Q | |
| A[-1, :] = pi_Q | |
| rhs[-1] = 0.0 | |
| w = solve(A, rhs) | |
| return w | |
| def nonasymptotic_lower_bound(Q, P_set_info, alpha, pi_Q=None): | |
| """Theorem 3.3, eq (3): E[tau] >= log(1/alpha)/D_M^inf - 2 C_Q / pi*. | |
| Returns dict with the bound and all components. `P_set_info` must provide | |
| D_M_inf(Q, P) : float (inf over the null set) | |
| P_star(Q) : ndarray (m,m) (the P attaining the inf) | |
| """ | |
| if pi_Q is None: | |
| pi_Q = stationary_dist(Q) | |
| pi_star = pi_Q.min() | |
| C_Q = C_constant(Q, pi_Q) | |
| D_inf, P_star = P_set_info["D_M_inf"](Q), P_set_info["P_star"](Q) | |
| lb = np.log(1.0 / alpha) / D_inf - 2.0 * C_Q / pi_star | |
| return { | |
| "alpha": alpha, | |
| "D_M_inf": D_inf, | |
| "C_Q": C_Q, | |
| "pi_star": pi_star, | |
| "gamma_ps": pseudo_spectral_gap(Q, pi_Q), | |
| "asymptotic_part": np.log(1.0 / alpha) / D_inf, | |
| "correction": 2.0 * C_Q / pi_star, | |
| "bound": max(0.0, lb), # paper takes max with 0 (line 1467-1473) | |
| "bound_raw": lb, | |
| "P_star": P_star, | |
| } | |
| # --------------------------------------------------------------------------- | |
| # Parametric family P_theta (Appendix G.3) | |
| # --------------------------------------------------------------------------- | |
| def build_P_theta(theta, P0, f): | |
| """P_theta from P0 and feature f via Perron-Frobenius normalization | |
| (paper Appendix G.3). | |
| tilde_theta(i,j) = P0(i,j) exp(theta * f_j); P_theta = tilde * v / (rho * v) | |
| where (rho, v) is the Perron-Frobenius eigenpair of tilde. | |
| """ | |
| tilde = P0 * np.exp(theta * f[None, :]) | |
| evals, evecs = scipy_eig(tilde.T) | |
| idx = int(np.argmax(np.real(evals))) | |
| rho = float(np.real(evals[idx])) | |
| v = np.real(evecs[:, idx]) | |
| v = np.abs(v) | |
| Ptheta = (tilde * v[None, :]) / (rho * v[:, None]) | |
| Ptheta = np.maximum(Ptheta, 0.0) | |
| Ptheta = Ptheta / Ptheta.sum(axis=1, keepdims=True) | |
| return Ptheta, rho, v | |
| def make_parametric_null(theta_bounds, P0, f): | |
| """Returns a dict with the operations the test / lower bound need for the | |
| parametric null P = {P_theta : theta in theta_bounds}.""" | |
| def D_M_inf(Q, pi_Q=None): | |
| if pi_Q is None: | |
| pi_Q = stationary_dist(Q) | |
| def obj(theta): | |
| Ptheta, _, _ = build_P_theta(theta, P0, f) | |
| return float(pi_Q @ f_P_vector(Q, Ptheta)) | |
| res = minimize_scalar(obj, bounds=theta_bounds, method="bounded", | |
| options={"xatol": 1e-10}) | |
| Pstar, _, _ = build_P_theta(res.x, P0, f) | |
| return float(res.fun), res.x, Pstar | |
| return {"D_M_inf": D_M_inf, "theta_bounds": theta_bounds} | |
| # --------------------------------------------------------------------------- | |
| # Algorithm 1: Sequential Markov Chain Test | |
| # --------------------------------------------------------------------------- | |
| class SequentialMarkovChainTest: | |
| """Faithful re-implementation of Algorithm 1 (paper lines 400-428). | |
| The null is a parametric family {P_theta : theta in theta_bounds} and the | |
| statistic L_t is the infimum over the null of the visitation-weighted KL. | |
| """ | |
| def __init__(self, m, alpha, theta_bounds, P0, f, build_P_theta_fn=None): | |
| self.m = int(m) | |
| self.alpha = float(alpha) | |
| self.theta_bounds = theta_bounds | |
| self.P0 = P0 | |
| self.f = f | |
| self.build_P_theta = build_P_theta_fn or build_P_theta | |
| self.reset() | |
| def reset(self): | |
| self.t = 0 | |
| self.Nx = np.zeros(self.m) | |
| self.Nxy = np.zeros((self.m, self.m)) | |
| def empirical_Q(self): | |
| Qhat = np.zeros((self.m, self.m)) | |
| for x in range(self.m): | |
| if self.Nx[x] > 0: | |
| Qhat[x] = self.Nxy[x] / self.Nx[x] | |
| else: | |
| Qhat[x] = np.ones(self.m) / self.m | |
| return Qhat | |
| def compute_psi(self): | |
| # psi_t = sum_x log( e * (1 + N_x / (m-1)) ) (paper line 415) | |
| return float(np.sum(np.log(np.e * (1.0 + self.Nx / (self.m - 1))))) | |
| def compute_beta(self, psi): | |
| return float(np.log(1.0 / self.alpha) + (self.m - 1) * psi) | |
| def _Lt_objective(self, theta, Qhat): | |
| Ptheta, _, _ = self.build_P_theta(theta, self.P0, self.f) | |
| kl = np.sum(Qhat * np.log((Qhat + EPS) / (Ptheta + EPS)), axis=1) | |
| mask = self.Nx > 0 | |
| return float(np.sum(self.Nx[mask] * kl[mask])) | |
| def compute_Lt(self, Qhat): | |
| res = minimize_scalar(self._Lt_objective, bounds=self.theta_bounds, | |
| args=(Qhat,), method="bounded", | |
| options={"xatol": 1e-10}) | |
| return float(res.fun), float(res.x) | |
| def step(self, u, v): | |
| """Process transition (u -> v). Returns (stop, L_t, beta_t, theta_hat).""" | |
| self.t += 1 | |
| self.Nx[u] += 1 | |
| self.Nxy[u, v] += 1 | |
| Qhat = self.empirical_Q() | |
| psi = self.compute_psi() | |
| beta = self.compute_beta(psi) | |
| L, theta_hat = self.compute_Lt(Qhat) | |
| return (L >= beta), L, beta, theta_hat | |
| # --------------------------------------------------------------------------- | |
| # Markov chain simulator | |
| # --------------------------------------------------------------------------- | |
| class MarkovGenerator: | |
| def __init__(self, P, init_dist=None, rng=None): | |
| self.P = P | |
| self.m = P.shape[0] | |
| self.rng = rng if rng is not None else np.random.default_rng() | |
| if init_dist is None: | |
| init_dist = np.ones(self.m) / self.m | |
| self.state = self.rng.choice(self.m, p=init_dist) | |
| def current_state(self): | |
| return self.state | |
| def step(self): | |
| self.state = self.rng.choice(self.m, p=self.P[self.state]) | |
| return self.state | |
| def run_trial(test, gen, T_max, rng=None): | |
| """Run one trial of Algorithm 1 against `gen`. Returns stopping time tau and | |
| the final statistic/threshold at stop.""" | |
| if rng is not None: | |
| gen.rng = rng | |
| t = 0 | |
| x_prev = gen.current_state() | |
| L_last = beta_last = 0.0 | |
| while t < T_max: | |
| x = gen.step() | |
| stop, L, beta, theta = test.step(x_prev, x) | |
| L_last, beta_last = L, beta | |
| if stop: | |
| return t + 1, L, beta, x | |
| x_prev = x | |
| t += 1 | |
| return T_max, L_last, beta_last, x_prev | |
Xet Storage Details
- Size:
- 10.2 kB
- Xet hash:
- 2bb2070ab3c3a0c3265bd459a9bce28ce62853417443054f9c7ee60af3edf060
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.