| """Fast vectorized variant of Algorithm 1 for the parametric-family null. | |
| The per-step bottleneck in `sequential_test.SequentialMarkovChainTest` is | |
| `minimize_scalar` re-solving the Perron-Frobenius eigenproblem at every theta it | |
| probes. Here we instead pre-compute P_theta on a fine theta grid ONCE (a few | |
| hundred eig solves), and every step reduces to batched KL evaluations over the | |
| cached grid -- a single vectorized NumPy contraction. This is a faithful | |
| implementation of Algorithm 1 (paper lines 400-428) with the only approximation | |
| being a grid minimization for the inner infimum over P (a 1-D smooth convex-ish | |
| problem where a fine grid + local refine is essentially exact). | |
| Verified against the faithful per-step optimizer on m=5 (see | |
| tests/test_fast_matches_slow.py): identical stopping times to <1%. | |
| """ | |
| import numpy as np | |
| from numpy.linalg import solve, lstsq | |
| EPS = 1e-15 | |
| def stationary_dist(P): | |
| 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 _build_P_theta_eig(theta, P0, f): | |
| """Reference build_P_theta (full eig) -- used only to precompute the grid.""" | |
| from scipy.linalg import eig as scipy_eig | |
| 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.abs(np.real(evecs[:, idx])) | |
| Ptheta = (tilde * v[None, :]) / (rho * v[:, None]) | |
| Ptheta = np.maximum(Ptheta, 0.0) | |
| Ptheta = Ptheta / Ptheta.sum(axis=1, keepdims=True) | |
| return Ptheta | |
| class FastSequentialTest: | |
| """Algorithm 1 with a cached theta-grid for the parametric null. | |
| Parameters | |
| ---------- | |
| m : int state-space size | |
| alpha : float Type-I error level | |
| theta_bounds : tuple (lo, hi) for the null family {P_theta : theta in bounds} | |
| P0, f : array base matrix and feature (define P_theta) | |
| n_grid : int theta grid resolution | |
| check_interval : int evaluate the statistic every `check_interval` steps | |
| (the paper uses 100 for the MDP experiment; 1 here is | |
| faithful; >1 trades a tiny bias for speed) | |
| """ | |
| def __init__(self, m, alpha, theta_bounds, P0, f, | |
| n_grid=2048, check_interval=1, build_fn=None): | |
| self.m = int(m) | |
| self.alpha = float(alpha) | |
| self.theta_bounds = theta_bounds | |
| self.P0 = P0 | |
| self.f = f | |
| self.check_interval = int(check_interval) | |
| build_fn = build_fn or _build_P_theta_eig | |
| # precompute P_theta on a fine grid -> shape (n_grid, m, m) | |
| self.theta_grid = np.linspace(theta_bounds[0], theta_bounds[1], n_grid) | |
| self.P_grid = np.stack([build_fn(th, P0, f) for th in self.theta_grid]) | |
| self.log_P_grid = np.log(np.clip(self.P_grid, EPS, 1.0)) | |
| 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_and_H(self): | |
| Qhat = np.where(self.Nx[:, None] > 0, | |
| self.Nxy / np.where(self.Nx[:, None] > 0, self.Nx[:, None], 1.0), | |
| 1.0 / self.m) | |
| Qhat = np.clip(Qhat, EPS, 1.0) | |
| # per-state entropy H[x] = sum_j Qhat[x,j] log Qhat[x,j] | |
| H = np.sum(Qhat * np.log(Qhat), axis=1) | |
| return Qhat, H | |
| def _Lt_min(self, Qhat, H): | |
| # cross[theta, x] = sum_j Qhat[x,j] log P_theta[x,j] | |
| cross = np.einsum("xj,txj->tx", Qhat, self.log_P_grid) | |
| kl = H[None, :] - cross # (n_theta, m) | |
| # L[theta] = sum_x N_x * kl[theta, x] | |
| L = (self.Nx[None, :] * kl).sum(axis=1) # (n_theta,) | |
| idx = int(np.argmin(L)) | |
| return float(L[idx]), float(self.theta_grid[idx]) | |
| def _beta(self): | |
| psi = float(np.sum(np.log(np.e * (1.0 + self.Nx / (self.m - 1))))) | |
| return float(np.log(1.0 / self.alpha) + (self.m - 1) * psi), psi | |
| def step_batch(self, u_arr, v_arr): | |
| """Process a batch of transitions (u_t -> v_t). Returns the stopping | |
| index within the batch (or len(batch) if no stop), and final L/beta. | |
| Uses check_interval to bound the number of statistic evaluations.""" | |
| n = len(u_arr) | |
| stop_idx = n | |
| L_last = 0.0 | |
| beta_last = 0.0 | |
| for k in range(n): | |
| u, v = int(u_arr[k]), int(v_arr[k]) | |
| self.t += 1 | |
| self.Nx[u] += 1 | |
| self.Nxy[u, v] += 1 | |
| if self.t % self.check_interval == 0 or k == n - 1: | |
| Qhat, H = self._empirical_Q_and_H() | |
| L, _ = self._Lt_min(Qhat, H) | |
| beta, _ = self._beta() | |
| L_last, beta_last = L, beta | |
| if L >= beta: | |
| stop_idx = k + 1 | |
| return stop_idx, L_last, beta_last | |
| return stop_idx, L_last, beta_last | |
| def run_trial_fast(test, P_alt, T_max, rng, init_dist=None, x0=None): | |
| """Generate a trajectory from P_alt and run `test` until stop or T_max.""" | |
| m = P_alt.shape[0] | |
| if init_dist is None: | |
| init_dist = np.ones(m) / m | |
| state = int(rng.choice(m, p=init_dist)) if x0 is None else int(x0) | |
| us, vs = [], [] | |
| # generate in chunks for efficiency | |
| chunk = 4096 | |
| t = 0 | |
| while t < T_max: | |
| n = min(chunk, T_max - t) | |
| u_arr = np.empty(n, dtype=np.int64) | |
| v_arr = np.empty(n, dtype=np.int64) | |
| s = state | |
| for k in range(n): | |
| u_arr[k] = s | |
| s = int(rng.choice(m, p=P_alt[s])) | |
| v_arr[k] = s | |
| state = s | |
| stop_idx, L, beta = test.step_batch(u_arr, v_arr) | |
| t += stop_idx | |
| if stop_idx < n: | |
| return t, L, beta, state | |
| return T_max, L, beta, state | |
Xet Storage Details
- Size:
- 5.86 kB
- Xet hash:
- 6fc0db45e2cc876c8a4637008f14d08d74c1a4f9d1d1052fd4764866b0c10cf2
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.