| """Orthogonal polynomial utilities for building multiscale systems.""" |
| from __future__ import annotations |
|
|
| from typing import Callable, Tuple |
|
|
| import jax |
| from jax import jit, vmap, lax |
| import jax.numpy as jnp |
| import numpy as np |
| from numpy.polynomial.legendre import legval as np_legval |
| from scipy.special import eval_legendre |
|
|
|
|
| SystemParams = Tuple[np.ndarray, ...] |
| EvalFn = Callable[..., np.ndarray] |
|
|
|
|
| def get_system_params( |
| measure: str, |
| n: int, |
| m: int | None = None, |
| *, |
| multiscale: bool = False, |
| implicit: bool = False, |
| **measure_args, |
| ) -> Tuple[SystemParams, EvalFn, float]: |
| if multiscale: |
| if m is None: |
| raise ValueError("Multiscale systems require `m`") |
| return _get_multiscale_system_params( |
| measure, n, m, implicit=implicit, **measure_args |
| ) |
| return _transition(measure, n, implicit=implicit, **measure_args) |
|
|
|
|
| def get_output_vector(measure: str, n: int, fourier_overlap: float = 0.99) -> np.ndarray: |
| if measure == "legt": |
| return np.sqrt(2 * np.arange(n) + 1.0) * np.power(-1, np.arange(n)) |
| if measure == "fout": |
| B = np.zeros(n) |
| m = 2 * np.pi * np.arange(n // 2) |
| B[0::2] = np.sqrt(2.0) * np.cos(m * fourier_overlap) |
| B[1::2] = np.sqrt(2.0) * np.sin(m * fourier_overlap) |
| B[0] = 1.0 |
| return B |
| raise NotImplementedError(measure) |
|
|
|
|
| def _transition( |
| measure: str, |
| N: int, |
| *, |
| implicit: bool = False, |
| **measure_args, |
| ) -> Tuple[SystemParams, EvalFn, float]: |
| if measure == "lagt": |
| if implicit: |
| raise ValueError("Laguerre systems are not implemented in implicit form") |
| b = measure_args.get("beta", 1.0) |
| A = np.eye(N) / 2 - np.tril(np.ones((N, N))) |
| B = b * np.ones((N, 1)) |
| eval_func = legval |
| tmx_max = 1.0 |
| elif measure == "fout": |
| freqs = 2 * np.arange(N // 2) |
| d = np.stack([np.zeros(N // 2), freqs], axis=-1).reshape(-1)[1:] |
| A = np.pi * (-np.diag(d, 1) + np.diag(d, -1)) |
| B = np.zeros(N) |
| B[0::2] = np.sqrt(2.0) |
| B[0] = 1.0 |
| A = A - 2 * B[:, None] * B[None, :] |
| B = 2 * B[:, None] |
| eval_func = fourier_val |
| tmx_max = 1.0 |
| elif measure == "legt": |
| Q = np.arange(N, dtype=np.float64) |
| R = (2 * Q + 1) ** 0.5 |
| if implicit: |
| a_func = get_implicit_legt_A(N) |
| b = R |
| params = (a_func, b) |
| else: |
| j, i = np.meshgrid(Q, Q) |
| A = R[:, None] * np.where(i < j, (-1.0) ** (i - j), 1) * R[None, :] |
| B = R[:, None] |
| A = -A |
| params = (A, B.flatten()) |
| eval_func = legval |
| tmx_max = 1.0 |
| elif measure == "legs": |
| if implicit: |
| raise ValueError("Scaled Legendre systems do not support implicit mode") |
| q = np.arange(N, dtype=np.float64) |
| col, row = np.meshgrid(q, q) |
| r = 2 * q + 1 |
| M = -(np.where(row >= col, r, 0) - np.diag(q)) |
| T = np.sqrt(np.diag(2 * q + 1)) |
| A = T @ M @ np.linalg.inv(T) |
| B = np.diag(T)[:, None] |
| eval_func = legsval |
| tmx_max = 5.0 |
| params = (A, B.flatten()) |
| else: |
| raise NotImplementedError(measure) |
|
|
| if not implicit and measure != "legt": |
| params = (A, B.flatten()) |
|
|
| return params, eval_func, tmx_max |
|
|
|
|
| def _get_multiscale_system_params( |
| measure: str, |
| n: int, |
| m: int, |
| *, |
| implicit: bool = False, |
| **measure_args, |
| ) -> Tuple[SystemParams, EvalFn, float]: |
| params, eval_func, tmx_max = _transition( |
| measure, n, implicit=implicit, **measure_args |
| ) |
|
|
| if implicit: |
| a_func, B = params |
| m_func = get_implicit_M(m) |
| implicit_A = vmap(a_func, in_axes=1, out_axes=1) |
| implicit_M = vmap(m_func, in_axes=1, out_axes=1) |
| B = B.reshape(-1, 1) |
| B = np.block([B / 2, B / (2 * np.sqrt(3)), np.zeros((n, m - 2))]) |
| params = (implicit_A, B, implicit_M) |
| else: |
| A, B = params |
| M = make_m_mat(m) |
| B = B.reshape(-1, 1) |
| B = np.block([B / 2, B / (2 * np.sqrt(3)), np.zeros((n, m - 2))]) |
| params = (A, B, M) |
|
|
| def ms_eval_func(tmx, taus, c): |
| coeffs = legval(taus, c * np.power(-1, np.arange(c.shape[1]))[None]).T |
| return eval_func(tmx, coeffs) |
|
|
| return params, ms_eval_func, tmx_max * 2 |
|
|
|
|
| def get_L_vector(m: int, tau: float) -> np.ndarray: |
| res = np.zeros(m) |
| vec = np.sqrt(2 * np.arange(m) + 1.0) |
| for i in range(m): |
| oh = np.zeros(m) |
| oh[i] = 1.0 |
| res[i] = np_legval(2 * tau - 1, vec * oh) |
| return res |
|
|
|
|
| def make_m_mat(m: int) -> np.ndarray: |
| out = np.eye(m) |
| idx = np.arange(m - 1) |
| vec = np.arange(1, m) / np.sqrt( |
| np.arange(1, 2 * m - 1, 2) * np.arange(3, 2 * m + 1, 2) |
| ) |
| out[idx, idx + 1] = vec |
| out[idx + 1, idx] = vec |
| return out / 2 |
|
|
|
|
| def get_implicit_M(m: int): |
| vec = jnp.arange(1, m) / jnp.sqrt( |
| jnp.arange(1, 2 * m - 1, 2) * jnp.arange(3, 2 * m + 1, 2) |
| ) |
|
|
| @jit |
| def matvec(v): |
| out = v[:] |
| out = out.at[1:].add(vec * v[:-1]) |
| out = out.at[:-1].add(vec * v[1:]) |
| return out / 2 |
|
|
| return matvec |
|
|
|
|
| def get_implicit_legt_A(n: int): |
| if n % 2 != 0: |
| raise ValueError("Implicit Legendre systems require an even order") |
| if n <= 1: |
| raise ValueError("n must be greater than 1") |
| sqrt_filter = jnp.sqrt(1.0 + 2.0 * jnp.arange(n)) |
| alt_filter = (-1) ** jnp.arange(n) |
|
|
| @jit |
| def matvec(v): |
| temp = v * sqrt_filter |
| alt_cum_sum = jnp.cumsum(temp * alt_filter) |
| result = jnp.cumsum(temp) + (alt_cum_sum[-1] - alt_cum_sum) * alt_filter |
| return -result * sqrt_filter |
|
|
| return matvec |
|
|
|
|
| def legval(x: np.ndarray, c: np.ndarray) -> np.ndarray: |
| """ |
| Orthonormal Legendre polynomial evaluation |
| |
| x: input values between 0 and 1. |
| Zero represents the present. |
| One represents the timescale **in the past**, as far back as the system can remember. |
| c: coefficient array |
| """ |
| poly_len = c.shape[-1] |
| batch_shape = c.shape[:-1] |
| idx = np.arange(poly_len) |
|
|
| eval_matrix = eval_legendre(idx[:, None], 2 * x - 1).T |
| eval_matrix *= np.sqrt(2 * idx + 1) * (-1) ** idx |
| eval_matrix = eval_matrix[None, ...] |
| result = eval_matrix * c[..., None, :] |
| return np.sum(result, axis=-1).reshape(batch_shape + (len(x),)) |
|
|
|
|
| def fourier_val(x_vals: np.ndarray, coeffs: np.ndarray) -> np.ndarray: |
| N = coeffs.shape[-1] // 2 |
| x_vals = x_vals[:, np.newaxis] |
| m = np.arange(N)[None] |
| two_pi_mx = 2 * np.pi * m * x_vals |
| terms = np.sqrt(2.0) * np.exp(1j * two_pi_mx) |
| terms[:, :1] /= np.sqrt(2.0) |
| cos_terms = terms.real |
| sin_terms = terms.imag |
|
|
| cosine_coeffs = coeffs[..., 2 * m] |
| sine_coeffs = coeffs[..., 2 * m + 1] |
| fourier_sum = np.sum(cosine_coeffs * cos_terms + sine_coeffs * sin_terms, axis=-1) |
| return fourier_sum |
|
|
|
|
| def legsval(x: np.ndarray, c: np.ndarray) -> np.ndarray: |
| poly_len = c.shape[-1] |
| batch_shape = c.shape[:-1] |
| idx = np.arange(poly_len) |
|
|
| eval_matrix = eval_legendre(idx[:, None], 1 - 2 * np.exp(-x)).T |
| eval_matrix *= np.sqrt(2 * idx + 1) * (-1) ** idx |
| eval_matrix = eval_matrix[None, ...] |
| result = eval_matrix * c[..., None, :] |
| return np.sum(result, axis=-1).reshape(batch_shape + (len(x),)) |
|
|
|
|
| def _legendre_series_eval_single_z(z: jnp.ndarray, c: jnp.ndarray) -> jnp.ndarray: |
| """ |
| Evaluate sum_k c[k] * sqrt(2k+1) * (-1)^k * P_k(z) for a single scalar z. |
| |
| z: scalar in [-1, 1] (ideally) |
| c: shape (N,) |
| returns: scalar |
| """ |
| N = c.shape[0] |
|
|
| k = jnp.arange(N, dtype=c.dtype) |
| weights = jnp.sqrt(2.0 * k + 1.0) * jnp.power(-1.0, k) |
| a = c * weights |
|
|
| |
| def case_N1(): |
| return a[0] |
|
|
| |
| def case_N2(): |
| return a[0] + a[1] * z |
|
|
| |
| def case_Nge3(): |
| P0 = jnp.ones_like(z) |
| P1 = z |
| out0 = a[0] * P0 + a[1] * P1 |
|
|
| |
| ks = jnp.arange(1, N - 1, dtype=jnp.int32) |
|
|
| def step(carry, k_int): |
| Pkm1, Pk, out = carry |
| kf = k_int.astype(c.dtype) |
| Pkp1 = ((2.0 * kf + 1.0) * z * Pk - kf * Pkm1) / (kf + 1.0) |
| out = out + a[k_int + 1] * Pkp1 |
| return (Pk, Pkp1, out), None |
|
|
| (_, _, out), _ = lax.scan(step, (P0, P1, out0), ks) |
| return out |
|
|
| return lax.cond( |
| N == 1, |
| case_N1, |
| lambda: lax.cond(N == 2, case_N2, case_Nge3), |
| ) |
|
|
| |
| _eval_over_z = jax.vmap(_legendre_series_eval_single_z, in_axes=(0, None), out_axes=0) |
|
|
| def legval_jax(x: jnp.ndarray, c: jnp.ndarray) -> jnp.ndarray: |
| """ |
| JAX version of :func:`legval`. |
| |
| x: shape (T,), ideally in [0,1] |
| c: shape (..., N) |
| returns: shape (..., T) |
| """ |
| |
| z = 2.0 * x - 1.0 |
|
|
| |
| batch_shape = c.shape[:-1] |
| N = c.shape[-1] |
| c2 = c.reshape((-1, N)) |
|
|
| def eval_one_coeff_row(ci): |
| return _eval_over_z(z, ci) |
|
|
| y2 = jax.vmap(eval_one_coeff_row, in_axes=0, out_axes=0)(c2) |
| return y2.reshape(batch_shape + (x.shape[0],)) |
|
|
| def legsval_jax(x: jnp.ndarray, c: jnp.ndarray) -> jnp.ndarray: |
| """ |
| JAX version of :func:`legsval`. |
| |
| x: shape (T,), typically x>=0 |
| c: shape (..., N) |
| returns: shape (..., T) |
| """ |
| |
| |
| z = -1.0 - 2.0 * jnp.expm1(-x) |
|
|
| batch_shape = c.shape[:-1] |
| N = c.shape[-1] |
| c2 = c.reshape((-1, N)) |
|
|
| def eval_one_coeff_row(ci): |
| return _eval_over_z(z, ci) |
|
|
| y2 = jax.vmap(eval_one_coeff_row, in_axes=0, out_axes=0)(c2) |
| return y2.reshape(batch_shape + (x.shape[0],)) |
|
|
|
|
|
|
| __all__ = [ |
| "EvalFn", |
| "SystemParams", |
| "fourier_val", |
| "get_L_vector", |
| "get_output_vector", |
| "get_system_params", |
| "get_implicit_M", |
| "get_implicit_legt_A", |
| "legval", |
| "legsval", |
| "legval_jax", |
| "legsval_jax", |
| "make_m_mat", |
| ] |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| |
| key = jax.random.PRNGKey(0) |
| T = 50 |
| N = 32 |
| batch_shape = (4, 3) |
|
|
| |
| |
| |
| |
| key, sub = jax.random.split(key) |
| x_leg = jax.random.uniform(sub, (T,), minval=0.0, maxval=1.0) |
|
|
| |
| key, sub = jax.random.split(key) |
| x_legs = jax.random.exponential(sub, (T,)) |
|
|
| |
| key, sub = jax.random.split(key) |
| c = jax.random.normal(sub, batch_shape + (N,)) |
|
|
| |
| x_leg_np = np.array(x_leg) |
| x_legs_np = np.array(x_legs) |
| c_np = np.array(c) |
|
|
| |
| |
| |
| y_leg_np = legval(x_leg_np, c_np) |
| y_leg_jax = legval_jax(x_leg, c) |
|
|
| y_legs_np = legsval(x_legs_np, c_np) |
| y_legs_jax = legsval_jax(x_legs, c) |
|
|
| |
| |
| |
| leg_abs_err = np.max(np.abs(y_leg_np - np.array(y_leg_jax))) |
| leg_rel_err = leg_abs_err / (np.max(np.abs(y_leg_np)) + 1e-12) |
|
|
| legs_abs_err = np.max(np.abs(y_legs_np - np.array(y_legs_jax))) |
| legs_rel_err = legs_abs_err / (np.max(np.abs(y_legs_np)) + 1e-12) |
|
|
| |
| |
| |
| print("legval vs legval_jax") |
| print(f" max abs error: {leg_abs_err:.3e}") |
| print(f" max rel error: {leg_rel_err:.3e}") |
|
|
| print("\nlegsval vs legsval_jax") |
| print(f" max abs error: {legs_abs_err:.3e}") |
| print(f" max rel error: {legs_rel_err:.3e}") |
|
|