From 22ad8e980c65a57179d151828e229cd4aab9e2fd Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 04:49:22 +0200 Subject: [PATCH 01/10] Pure E/B as a fixed-quadrature operator with exact covariance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pure-E/B estimator is one data-independent matrix K = (I_6 ⊗ P)·M on the fine ξ± grid: the Schneider (2022) transform with fixed Gauss-Legendre weights (cosmo-numba get_pure_EB_operator), evaluated at the fine nodes inside the reporting range with [tmin, tmax] at the fine-grid extent, then averaged into the reporting bins with pair-count weights. Its covariance is K C_ξ Kᵀ, exact for an analytic or a jackknife ξ± covariance; `npatch` travels with the results (None for analytic) and sets the Hartlap factor of every χ² built on them, with the combined ξ+/ξ− χ² debiased over its full length. calculate_pure_eb_correlation works from fine-grid arrays; the reported ξ±, θ and variances are the same pair-count average, so ξ± = E ± B + amb holds bin by bin. The adaptive pointwise path, the Monte-Carlo covariance and the separate pure-E/B jackknife are removed. sacc_io gains get_xi_npairs; cosmo-numba is pinned to the fork commit carrying the operator. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 14 +- src/sp_validation/b_modes.py | 423 +++++++++--------- src/sp_validation/cosmo_val/core.py | 7 +- src/sp_validation/cosmo_val/pure_eb.py | 160 +++---- src/sp_validation/sacc_io.py | 9 +- src/sp_validation/tests/conftest.py | 7 +- .../tests/data/pure_eb_xi_fixture.npz | Bin 16564 -> 20818 bytes src/sp_validation/tests/test_b_modes.py | 285 +++++++----- src/sp_validation/tests/test_cosmo_val.py | 61 ++- src/sp_validation/tests/test_sacc_io.py | 1 + uv.lock | 6 +- 11 files changed, 493 insertions(+), 480 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 35c9e6ce..b398749c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,13 +38,13 @@ dependencies = [ # get_theo_c_ell / get_theo_xi / PLANCK18 in cs_util.cosmo, which land via # CosmoStat/cs_util#76 — so this goes green once #76 merges into develop. "cs_util @ git+https://github.com/CosmoStat/cs_util.git@develop", - # Fast numba B-mode kernels (Schneider et al. 2022): the Schneider E/B split - # and COSEBIS live here, imported in b_modes.py. Tracks aguinot/cosmo-numba - # main (not published on PyPI). main carries the numpy-2 FFT fix via its - # rocket-fft dependency (which teaches numba's nopython mode to handle - # np.fft), and declares numba/numpy/rocket-fft from its requirements.txt so - # those constraints reach the resolver. - "cosmo-numba @ git+https://github.com/aguinot/cosmo-numba.git@main", + # Fast numba B-mode kernels (Schneider et al. 2022): the fixed-quadrature + # pure-E/B operator (schneider2022_operator) and COSEBIS live here, imported + # in b_modes.py. Pinned to a commit on cailmdaley/cosmo-numba, which carries + # the operator on top of aguinot/cosmo-numba main (not published on PyPI): + # main's numpy-2 FFT fix via rocket-fft, and its numba/numpy/rocket-fft + # requirements, which reach the resolver. + "cosmo-numba @ git+https://github.com/cailmdaley/cosmo-numba.git@d78a189d9af75a9c113fef7647102a1bad0fd452", "emcee", # numba is the load-bearing pin of this whole environment: its numpy ceiling # (numba 0.66 -> numpy<2.5) is what keeps the resolver from drifting numpy diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index fdc2ddf8..039c111d 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -1,8 +1,9 @@ """ B-mode analysis functions for weak lensing validation. -This module contains pure E/B mode decomposition, COSEBIs analysis, -and semi-analytical covariance calculations extracted from CosmologyValidation. +Pure E/B modes (Schneider et al. 2022) as a fixed linear operator on the fine +ξ± grid with their exact covariance, COSEBIs, and the χ²/PTE and plotting +helpers shared by both. """ import warnings @@ -11,19 +12,12 @@ import numpy as np import seaborn as sns import tqdm -import treecorr -from cs_util.cosmo import get_theo_xi from mpl_toolkits.axes_grid1 import make_axes_locatable -from scipy import sparse, stats +from scipy import stats _EB_KEYS = ("xip_E", "xim_E", "xip_B", "xim_B", "xip_amb", "xim_amb") -def _eb_vector(modes): - """Pure-E/B modes concatenated in ``_EB_KEYS`` order, the covariance layout.""" - return np.concatenate([modes[k] for k in _EB_KEYS]) - - def find_conservative_scale_cut_key(results, requested_scale_cut): """ Find scale cut key that conservatively fits within requested range. @@ -125,215 +119,216 @@ def correlation_from_covariance(covariance): return covariance / np.outer(stdev, stdev) -def calculate_pure_eb_correlation( - gg, - gg_int, - var_method="jackknife", - cov_path_int=None, - cosmo_cov=None, - n_samples=1000, - z_dist=None, -): +def hartlap_factor(npatch, dof): + """Hartlap (2007) debiasing of an inverse covariance. + + ``(N - p - 2) / (N - 1)`` for a jackknife covariance from ``N = npatch`` + patches inverted over ``p = dof`` data points; exactly 1 for an analytic + covariance, which ``npatch=None`` denotes. """ - Calculate pure E/B modes from correlation function objects. + return 1.0 if npatch is None else (npatch - dof - 2) / (npatch - 1) - Parameters - ---------- - gg : treecorr.GGCorrelation - Correlation function for reporting binning (coarser binning for final results) - gg_int : treecorr.GGCorrelation - Correlation function for integration binning (fine binning for numerical - integration) - var_method : str, optional - Variance method ("jackknife" or "bootstrap") - cov_path_int : str, optional - Path to integration covariance matrix for semi-analytical calculation - cosmo_cov : pyccl.Cosmology, optional - Cosmology for theoretical predictions in semi-analytical covariance - n_samples : int, optional - Number of Monte Carlo samples for semi-analytical covariance - z_dist : 2D array, optional - Redshift distribution; - z_dist[:, 0] = z, z_dist[:, 1] = n(z) - Returns - ------- - dict - Dictionary containing pure E/B mode results and covariance - """ - # Calculate min_sep and max_sep from gg object - min_sep, max_sep = gg.left_edges[0], gg.right_edges[-1] - - def pure_EB(corrs): - gg, gg_int = corrs - return pure_eb_from_xi( - theta_report=gg.meanr, - xip_report=gg.xip, - xim_report=gg.xim, - theta_int=gg_int.meanr, - xip_int=gg_int.xip, - xim_int=gg_int.xim, - tmin=min_sep, - tmax=max_sep, - ) +def _npairs_binning_matrix(theta_int, npairs_int, left_edges, right_edges): + """Pair-count weighted average from the fine grid into the reporting bins. - # The results dict is self-describing: the grids it was measured on travel - # with the modes, so every consumer downstream works from values alone. - results = { - "theta": gg.meanr, - "left_edges": gg.left_edges, - "right_edges": gg.right_edges, - "xip": gg.xip, - "xim": gg.xim, - "var_xip": gg.varxip, - "var_xim": gg.varxim, - "theta_int": gg_int.meanr, - "xip_int": gg_int.xip, - "xim_int": gg_int.xim, - "n_eff": n_samples if cov_path_int is not None else gg.npatch1, + Row ``i`` of the ``(n_report, n_fine)`` result weights the fine nodes whose + ``theta_int`` falls in reporting bin ``i`` by their pair counts (Asgari et + al. 2019, Appendix A) and sums to one. Nodes outside the reporting range + or with no pairs get zero weight. + """ + theta_int = np.asarray(theta_int, dtype=float) + npairs_int = np.asarray(npairs_int, dtype=float) + if npairs_int.shape != theta_int.shape: + raise ValueError("npairs_int must have one entry per integration bin") + n_report = len(left_edges) + rows = np.digitize(theta_int, np.append(left_edges, right_edges[-1])) - 1 + inside = (rows >= 0) & (rows < n_report) & (npairs_int > 0) + binning = np.zeros((n_report, theta_int.size)) + binning[rows[inside], np.flatnonzero(inside)] = npairs_int[inside] + weight = binning.sum(axis=1) + if np.any(weight == 0): + empty = np.flatnonzero(weight == 0).tolist() + raise ValueError(f"reporting bins {empty} hold no integration-grid pairs") + return binning / weight[:, None] + + +def _fixed_quadrature_operator(theta_eval, theta_int): + """Rows of the Schneider (2022) transform at ``theta_eval``, ``_EB_KEYS`` order. + + The single call into cosmo_numba. ``[tmin, tmax]`` is the extent of the + integration grid, so every evaluation node keeps interpolation support on + both sides. Returns the ``(6 * n_eval, 2 * n_fine)`` stack of the six + matrices acting on ``[xi_+; xi_-]``. + """ + from cosmo_numba.B_modes.schneider2022_operator import get_pure_EB_operator + + operator = get_pure_EB_operator( + theta_eval=theta_eval, + theta=theta_int, + tmin=theta_int[0] * (1 - 1e-9), + tmax=theta_int[-1] * (1 + 1e-9), + outputs=_EB_KEYS, + ) + # A row whose support holds fewer than interp_order + 1 nodes is NaN. + invalid = { + key: int(np.count_nonzero(~valid)) + for key, valid in operator["valid"].items() + if not valid.all() } - results.update(pure_EB([gg, gg_int])) - - if cov_path_int is not None: - if z_dist is None or cosmo_cov is None: - raise ValueError( - "semi-analytical covariance needs both z_dist and cosmo_cov" - ) - cov, eb_samples = pure_eb_covariance_mc( - theta=gg.meanr, - left_edges=gg.left_edges, - right_edges=gg.right_edges, - theta_int=gg_int.meanr, - cov_int=np.loadtxt(cov_path_int), - z=z_dist[:, 0], - nz=z_dist[:, 1], - cosmo=cosmo_cov, - n_samples=n_samples, - ) - results.update({"cov": cov, "eb_samples": eb_samples}) - else: - # Use existing treecorr covariance estimation - results["cov"] = treecorr.estimate_multi_cov( - [gg, gg_int], - var_method, - func=lambda x: _eb_vector(pure_EB(x)), - cross_patch_weight="match" if var_method == "jackknife" else None, + if invalid: + raise ValueError( + "pure-E/B operator rows are under-determined (evaluation nodes too " + f"close to the integration-grid edge): {invalid}" ) + return np.vstack([operator["matrices"][key] for key in _EB_KEYS]) - # Validate covariance matrix - try: - np.linalg.cholesky(results["cov"]) - except np.linalg.LinAlgError: - warnings.warn( - "E/B mode covariance matrix is not positive definite. " - "Chi-squared statistics may be unreliable.", - UserWarning, - ) - return results +def pure_eb_operator(theta_int, npairs_int, left_edges, right_edges): + """The pure-E/B estimator as one matrix on the fine ξ± grid. + The Schneider et al. (2022) transform is evaluated with fixed-quadrature + weights at the fine-grid nodes inside the reporting range, integrating over + the whole fine grid, and the six pure modes are then averaged into the + reporting bins with pair-count weights. Both steps are linear and + data-independent, so the estimator is ``K = (I_6 ⊗ P) · M`` and -def pure_eb_from_xi( - theta_report, xip_report, xim_report, theta_int, xip_int, xim_int, tmin, tmax -): - """Pure-E/B correlation functions from ξ± arrays through the pipeline kernel. + [xip_E; xim_E; xip_B; xim_B; xip_amb; xim_amb] = K @ [xip_int; xim_int] - The one place this module calls cosmo_numba's Schneider (2022) transform. + with ``K`` of shape ``(6 * n_report, 2 * n_fine)``. - ``tmin``/``tmax`` are the reporting correlation's TreeCorr *bin edges* - (``gg.left_edges[0]`` / ``gg.right_edges[-1]``). The reporting grid must be a - strict sub-range of the integration grid: a reporting point on the - integration boundary has no interior support and comes back NaN. + Parameters + ---------- + theta_int : array_like + Fine (integration) grid, ascending and log-spaced — TreeCorr ``meanr``. + The transform's ``[tmin, tmax]`` is its extent, so it must reach + beyond the reporting range on both sides. + npairs_int : array_like + Pair counts on the fine grid, the averaging weights. + left_edges, right_edges : array_like + Reporting-bin edges. Returns ------- - dict - Keyed by ``_EB_KEYS`` (xip_E, xim_E, xip_B, xim_B, xip_amb, xim_amb). + operator : numpy.ndarray + ``K``, shape ``(6 * n_report, 2 * n_fine)``. + binning : numpy.ndarray + ``P``, the ``(n_report, n_fine)`` pair-count average. """ - from cosmo_numba.B_modes.schneider2022 import get_pure_EB_modes - - modes = get_pure_EB_modes( - theta=np.asarray(theta_report), - xip=np.asarray(xip_report), - xim=np.asarray(xim_report), - theta_int=np.asarray(theta_int), - xip_int=np.asarray(xip_int), - xim_int=np.asarray(xim_int), - tmin=tmin, - tmax=tmax, - parallel=True, + theta_int = np.asarray(theta_int, dtype=float) + binning = _npairs_binning_matrix(theta_int, npairs_int, left_edges, right_edges) + nodes = np.flatnonzero(binning.any(axis=0)) + transform = _fixed_quadrature_operator(theta_int[nodes], theta_int) + n_nodes = nodes.size + operator = np.vstack( + [ + binning[:, nodes] @ transform[i * n_nodes : (i + 1) * n_nodes] + for i in range(len(_EB_KEYS)) + ] ) - return dict(zip(_EB_KEYS, (np.asarray(m) for m in modes))) + return operator, binning -def pure_eb_covariance_mc( - *, - theta, +def calculate_pure_eb_correlation( + theta_int, + xip_int, + xim_int, + npairs_int, + cov_xi, left_edges, right_edges, - theta_int, - cov_int, - z, - nz, - cosmo, - n_samples=1000, + *, + npatch=None, ): - """Pure-E/B covariance by Monte Carlo through the same kernel as the modes. + """Pure E/B modes and their exact covariance from fine-grid ξ±. + + The modes are :func:`pure_eb_operator` applied to ``[xip_int; xim_int]``, + and the covariance is ``K C_xi Kᵀ`` — exact for whatever ξ± covariance is + supplied, analytic or jackknife. ``npatch`` records which: the jackknife + patch count behind ``cov_xi``, or ``None`` for an analytic covariance. It + travels in the results and sets the Hartlap factor of every χ² built on + them (:func:`hartlap_factor`). - ξ± draws come from ``cov_int``, a ξ± covariance on the integration grid, - around the theory mean for ``(z, nz)`` under ``cosmo``; each draw is binned - down to the reporting grid and pushed through :func:`pure_eb_from_xi`. The - covariance of the transformed draws is the result, so it depends on the - covariance model and the grids, never on the measured data vector. + The reporting-bin ``theta``, ``xip``/``xim`` and their variances are the + same pair-count average of the fine grid, so ``xi_± = E ± B + amb`` holds + bin by bin. - Returns ``(cov, eb_samples)`` — the covariance in ``_EB_KEYS`` order and - the draws behind it. + Parameters + ---------- + theta_int, xip_int, xim_int, npairs_int : array_like + Fine-grid ``meanr``, ξ±, and pair counts. + cov_xi : array_like + ``(2 n_fine, 2 n_fine)`` covariance of ``[xip_int; xim_int]``. + left_edges, right_edges : array_like + Reporting-bin edges. + npatch : int, optional + Jackknife patch count behind ``cov_xi``; ``None`` if it is analytic. + + Returns + ------- + dict + The six ``_EB_KEYS`` mode arrays, ``cov`` (in ``_EB_KEYS`` block + order), ``npatch``, the reporting grid (``theta``, ``left_edges``, + ``right_edges``, ``xip``, ``xim``, ``var_xip``, ``var_xim``) and the + fine-grid inputs (``theta_int``, ``xip_int``, ``xim_int``, + ``npairs_int``). """ - theta, theta_int = np.asarray(theta), np.asarray(theta_int) - nbins_int = len(theta_int) - - # Each reporting bin averages the integration bins that fall inside it. - reporting_bin_edges = np.concatenate([left_edges, [right_edges[-1]]]) - bin_indices = np.digitize(theta_int, reporting_bin_edges) - 1 - valid_mask = (bin_indices >= 0) & (bin_indices < len(theta)) - row_indices, col_indices = (bin_indices[valid_mask], np.where(valid_mask)[0]) - binning_matrix = sparse.csr_matrix( - (np.ones(len(row_indices)), (row_indices, col_indices)), - shape=(len(theta), nbins_int), + if npatch is not None and npatch < 2: + raise ValueError(f"a jackknife covariance needs npatch > 1, not {npatch}") + theta_int, xip_int, xim_int = ( + np.asarray(a, dtype=float) for a in (theta_int, xip_int, xim_int) ) - row_sums = np.array(binning_matrix.sum(axis=1)).flatten() - binning_matrix = sparse.diags(1 / row_sums) @ binning_matrix - - # One n(z) gives one tracer pair: get_theo_xi's single (xi+, xi-) entry. - (xi_pm,) = get_theo_xi( - theta=theta_int, z=z, nz=nz, backend="ccl", cosmo=cosmo - ).values() - mean_int = np.concatenate(xi_pm) - samples_int = np.random.multivariate_normal(mean_int, cov_int, size=n_samples) - samples_int_xip, samples_int_xim = ( - samples_int[:, :nbins_int], - samples_int[:, nbins_int:], - ) - samples_rep_xip = (binning_matrix @ samples_int_xip.T).T - samples_rep_xim = (binning_matrix @ samples_int_xim.T).T - - def eb_draw(i): - modes = pure_eb_from_xi( - theta_report=theta, - xip_report=samples_rep_xip[i], - xim_report=samples_rep_xim[i], - theta_int=theta_int, - xip_int=samples_int_xip[i], - xim_int=samples_int_xim[i], - tmin=left_edges[0], - tmax=right_edges[-1], + cov_xi = np.asarray(cov_xi, dtype=float) + operator, binning = pure_eb_operator(theta_int, npairs_int, left_edges, right_edges) + if cov_xi.shape != (operator.shape[1],) * 2: + raise ValueError( + f"cov_xi has shape {cov_xi.shape}; the fine grid needs " + f"{(operator.shape[1],) * 2}" ) - return _eb_vector(modes) - eb_samples = np.array( - [eb_draw(i) for i in tqdm.tqdm(range(n_samples), desc="MC samples")] + n_report = len(left_edges) + modes = operator @ np.concatenate([xip_int, xim_int]) + n_fine = theta_int.size + var_xip, var_xim = ( + np.einsum("ij,jk,ik->i", binning, block, binning) + for block in (cov_xi[:n_fine, :n_fine], cov_xi[n_fine:, n_fine:]) ) - return np.cov(eb_samples.T), eb_samples + results = { + "theta": binning @ theta_int, + "left_edges": np.asarray(left_edges, dtype=float), + "right_edges": np.asarray(right_edges, dtype=float), + "xip": binning @ xip_int, + "xim": binning @ xim_int, + "var_xip": var_xip, + "var_xim": var_xim, + "theta_int": theta_int, + "xip_int": xip_int, + "xim_int": xim_int, + "npairs_int": np.asarray(npairs_int, dtype=float), + "cov": operator @ cov_xi @ operator.T, + "npatch": npatch, + } + for i, key in enumerate(_EB_KEYS): + results[key] = modes[i * n_report : (i + 1) * n_report] + + # The B-mode block is what every χ² inverts; the ambiguous blocks are + # ill-conditioned by construction. + b_block = slice(2 * n_report, 4 * n_report) + try: + np.linalg.cholesky(results["cov"][b_block, b_block]) + except np.linalg.LinAlgError: + warnings.warn( + "B-mode covariance is not positive definite. " + "Chi-squared statistics may be unreliable.", + UserWarning, + ) + + return results + + +def covariance_label(npatch): + """How a pure-E/B covariance was made, from its ``npatch`` record.""" + return "analytic" if npatch is None else f"jackknife ({npatch} patches)" def calculate_cosebis(gg, nmodes=10, scale_cuts=None, cov_path=None): @@ -477,8 +472,9 @@ def calculate_eb_statistics(results): ---------- results : dict Pure E/B results: the six mode arrays, the ``cov`` block, the reporting - ``theta``, and ``n_eff`` — the realisation count behind the covariance - (jackknife patches or MC draws), which sets the Hartlap debiasing + ``theta``, and ``npatch`` — the jackknife patch count behind the + covariance, or ``None`` for an analytic one — which sets the Hartlap + factor (:func:`hartlap_factor`) Returns ------- @@ -486,7 +482,7 @@ def calculate_eb_statistics(results): Updated results dictionary with PTE matrices and statistics """ nbins = len(results["theta"]) - n_eff = results["n_eff"] + npatch = results["npatch"] # Extract covariance blocks and standard deviations cov = results["cov"] @@ -508,7 +504,7 @@ def calculate_eb_statistics(results): for start_bin, stop_bin in combinations: nbins_eff = stop_bin - start_bin - hartlap_factor = (n_eff - nbins_eff - 2) / (n_eff - 1) + hartlap = hartlap_factor(npatch, nbins_eff) # Individual B-mode chi-squared calculations data_slices = [results[f"{xi}_B"][start_bin:stop_bin] for xi in ["xip", "xim"]] @@ -517,7 +513,7 @@ def calculate_eb_statistics(results): for xi in ["xip", "xim"] ] chi2_values = [ - hartlap_factor * (data @ np.linalg.solve(cov, data)) + hartlap * (data @ np.linalg.solve(cov, data)) for data, cov in zip(data_slices, cov_slices) ] @@ -536,7 +532,7 @@ def calculate_eb_statistics(results): cov_combined = np.block( [[cov_xip_block, cov_cross_block], [cov_cross_block.T, cov_xim_block]] ) - chi2_combined = hartlap_factor * ( + chi2_combined = hartlap_factor(npatch, 2 * nbins_eff) * ( data_combined @ np.linalg.solve(cov_combined, data_combined) ) pte_combined[start_bin, stop_bin - 1] = stats.chi2.sf( @@ -557,9 +553,10 @@ def plot_integration_vs_reporting(results, output_path, version): Parameters ---------- results : dict - Pure E/B results carrying both grids (``theta``/``xip``/``xim`` and the - ``theta_int``/``xip_int``/``xim_int`` counterparts), plus the reporting - ``var_xip``/``var_xim`` the error bars use + Pure E/B results carrying the fine grid (``theta_int``/``xip_int``/ + ``xim_int``) and its pair-count average into the reporting bins + (``theta``/``xip``/``xim``, with the ``var_xip``/``var_xim`` the error + bars use) output_path : str Output file path for the plot version : str @@ -713,15 +710,7 @@ def plot_pure_eb_correlations( # Calculate combined chi-squared with Hartlap factor nbins_eff = len(xip_B_data) + len(xim_B_data) - - # Determine effective number of samples for Hartlap correction - if "eb_samples" in results: # Semi-analytical case - n_eff = results["eb_samples"].shape[0] - else: # Jackknife case - n_eff = results["n_eff"] - - hartlap_factor = (n_eff - nbins_eff - 2) / (n_eff - 1) - chi2_combined = hartlap_factor * ( + chi2_combined = hartlap_factor(results["npatch"], nbins_eff) * ( data_combined.T @ np.linalg.solve(cov_combined, data_combined) ) combined_pte = stats.chi2.sf(chi2_combined, nbins_eff) @@ -1157,7 +1146,7 @@ def plot_pte_2d_heatmaps( plt.savefig(output_path, dpi=300, bbox_inches="tight") -def plot_eb_covariance_matrix(cov_matrix, var_method, output_path, version): +def plot_eb_covariance_matrix(cov_matrix, label, output_path, version): """ Plot E/B mode covariance matrix as correlation matrix. @@ -1165,8 +1154,8 @@ def plot_eb_covariance_matrix(cov_matrix, var_method, output_path, version): ---------- cov_matrix : numpy.ndarray Covariance matrix from E/B mode analysis - var_method : str - Variance method used for the analysis + label : str + How the covariance was made (:func:`covariance_label`) output_path : str Output file path for the plot version : str @@ -1198,7 +1187,7 @@ def plot_eb_covariance_matrix(cov_matrix, var_method, output_path, version): divider = make_axes_locatable(ax) cax = divider.append_axes("right", size="5%", pad=0.1) plt.colorbar(im, cax=cax) - ax.set_title(f"{version}: {var_method} correlation matrix") + ax.set_title(f"{version}: {label} correlation matrix") plt.savefig(output_path, dpi=300, bbox_inches="tight") @@ -1280,13 +1269,9 @@ def save_pure_eb_results(results, output_path): for key, matrix in results.get("pte_matrices", {}).items(): save_dict[f"pte_matrices_{key}"] = matrix - # Metadata - save_dict["n_eff"] = np.array(results["n_eff"]) - if "eb_samples" in results: - save_dict["var_method"] = np.array("semi-analytic") - save_dict["n_samples"] = np.array(results["eb_samples"].shape[0]) - else: - save_dict["var_method"] = np.array("jackknife") + # The jackknife patch count, stored only for a jackknife covariance. + if results["npatch"] is not None: + save_dict["npatch"] = np.array(results["npatch"]) np.savez(output_path, **save_dict) print(f"Saved pure E/B results to {output_path}") diff --git a/src/sp_validation/cosmo_val/core.py b/src/sp_validation/cosmo_val/core.py index e4957afe..422c3a9c 100644 --- a/src/sp_validation/cosmo_val/core.py +++ b/src/sp_validation/cosmo_val/core.py @@ -11,6 +11,7 @@ from ..b_modes import ( _get_pte_from_scale_cut, + covariance_label, find_conservative_scale_cut_key, ) from ..statistics import chi2_and_pte @@ -608,11 +609,7 @@ def summarize_bmodes(self, fiducial_scale_cut=(12, 83), versions=None): ) except (KeyError, RuntimeError): pass - cov_methods.add( - "semi-analytic" - if "eb_samples" in res - else f"jackknife ({res['n_eff']} patches)" - ) + cov_methods.add(covariance_label(res["npatch"])) # COSEBIs PTE from stored results if ver in self._cosebis_results: diff --git a/src/sp_validation/cosmo_val/pure_eb.py b/src/sp_validation/cosmo_val/pure_eb.py index 90157456..4ac05aeb 100644 --- a/src/sp_validation/cosmo_val/pure_eb.py +++ b/src/sp_validation/cosmo_val/pure_eb.py @@ -10,6 +10,8 @@ from ..b_modes import ( calculate_eb_statistics, calculate_pure_eb_correlation, + covariance_label, + log_bin_edges, plot_eb_covariance_matrix, plot_integration_vs_reporting, plot_pte_2d_heatmaps, @@ -28,113 +30,68 @@ def calculate_pure_eb( min_sep_int=0.08, max_sep_int=300, nbins_int=1000, - npatch=256, - var_method="jackknife", + npatch=None, cov_path_int=None, - cosmo_cov=None, - n_samples=1000, ): """ Calculate the pure E/B modes for the given catalog version. - The class instance's treecorr_config will be used for the "reporting" binning - by default, but any kwargs passed to this function will overwrite the defaults. + + ξ± is measured on the fine integration grid only; the reporting + binning (the instance's treecorr_config unless overridden) enters as + bin edges, into which :func:`~sp_validation.b_modes.pure_eb_operator` + averages the modes. Parameters ---------- version : str The catalog version to compute the pure E/B modes for. - min_sep : float, optional - Minimum separation for the reporting binning. Defaults to the value in - self.treecorr_config if not provided. - max_sep : float, optional - Maximum separation for the reporting binning. Defaults to the value in - self.treecorr_config if not provided. - nbins : int, optional - Number of bins for the reporting binning. Defaults to the value in - self.treecorr_config if not provided. - min_sep_int : float, optional - Minimum separation for the integration binning. Defaults to 0.08. - max_sep_int : float, optional - Maximum separation for the integration binning. Defaults to 300. - nbins_int : int, optional - Number of bins for the integration binning. Defaults to 1000. + min_sep, max_sep, nbins : float, float, int, optional + Reporting binning. Default to the values in self.treecorr_config. + min_sep_int, max_sep_int, nbins_int : float, float, int, optional + Integration binning (default: 0.08-300 arcmin, 1000 bins). It must + extend beyond the reporting range on both sides. npatch : int, optional - Number of patches for the jackknife or bootstrap resampling. Defaults to - the value in self.npatch if not provided. - var_method : str, optional - Variance estimation method. Defaults to "jackknife". + Jackknife patch count. Defaults to self.npatch. cov_path_int : str, optional - Path to the covariance matrix for the reporting binning. Replaces the - treecorr covariance matrix if provided, meaning that var_method has no - effect on the results although it is still passed to - CosmologyValidation.calculate_2pcf. - cosmo_cov : pyccl.Cosmology, optional - Cosmology object to use for theoretical xi+/xi- predictions in the - semi-analytical covariance calculation. Defaults to self.cosmo if not - provided. - n_samples : int, optional - Number of Monte Carlo samples for semi-analytical covariance propagation. - Defaults to 1000. + Analytic ξ± covariance on the integration grid. Without it the + covariance is the jackknife of the integration-grid ξ±. Returns ------- dict - A dictionary containing the following keys: - - - "xip_E": Pure E-mode correlation function for xi+. - - "xim_E": Pure E-mode correlation function for xi-. - - "xip_B": Pure B-mode correlation function for xi+. - - "xim_B": Pure B-mode correlation function for xi-. - - "xip_amb": Ambiguity mode for xi+. - - "xim_amb": Ambiguity mode for xi-. - - "cov": Covariance matrix for the pure E/B modes. - - "theta", "left_edges", "right_edges": Reporting-grid bin centres - and edges. - - "xip", "xim", "var_xip", "var_xim": Reporting-grid xi+/xi- and - their variances. - - "theta_int", "xip_int", "xim_int": Integration-grid xi+/xi-. - - "n_eff": Realisation count behind "cov" (jackknife patches or - MC draws), which sets the Hartlap debiasing. - - "eb_samples": (only when using semi-analytical covariance) Semi-analytic - EB samples used for covariance calculation. Shape: (n_samples, 6*nbins) - - Notes - ----- - - A shared patch file is used for the reporting and integration binning, - and is created if it does not exist. + The results of :func:`~sp_validation.b_modes.calculate_pure_eb_correlation`: + the six pure-mode arrays, their covariance ``cov`` and its + ``npatch`` record (``None`` for an analytic covariance), the + reporting grid and the integration-grid ξ±. """ self.print_start(f"Computing {version} pure E/B") - # Set up parameters with defaults - npatch = npatch or self.npatch - - # Create TreeCorr configurations - treecorr_config = self._binning(min_sep, max_sep, nbins) - treecorr_config_int = self._binning(min_sep_int, max_sep_int, nbins_int) - - # Calculate correlation functions - gg = self.calculate_2pcf(version, npatch=npatch, **treecorr_config) - gg_int = self.calculate_2pcf(version, npatch=npatch, **treecorr_config_int) - - # Get redshift distribution if using analytic covariance - z_dist = ( - np.column_stack(self.get_redshift(version)) - if cov_path_int is not None - else None + reporting = self._binning(min_sep, max_sep, nbins) + left_edges, right_edges = log_bin_edges( + reporting["min_sep"], reporting["max_sep"], reporting["nbins"] ) - - # Delegate to b_modes module - results = calculate_pure_eb_correlation( - gg=gg, - gg_int=gg_int, - var_method=var_method, - cov_path_int=cov_path_int, - cosmo_cov=cosmo_cov, - n_samples=n_samples, - z_dist=z_dist, + gg_int = self.calculate_2pcf( + version, + npatch=npatch, + **self._binning(min_sep_int, max_sep_int, nbins_int), ) - return results + if cov_path_int is not None: + cov_xi, npatch = np.loadtxt(cov_path_int), None + else: + cov_xi = gg_int.estimate_cov("jackknife", cross_patch_weight="match") + npatch = gg_int.npatch1 + + return calculate_pure_eb_correlation( + gg_int.meanr, + gg_int.xip, + gg_int.xim, + gg_int.npairs, + cov_xi, + left_edges, + right_edges, + npatch=npatch, + ) def plot_pure_eb( self, @@ -149,12 +106,8 @@ def plot_pure_eb( max_sep_int=300, nbins_int=1000, npatch=None, - var_method="jackknife", cov_path_int=None, - cosmo_cov=None, - n_samples=1000, results=None, - **kwargs, ): """ Generate comprehensive pure E/B mode analysis plots. @@ -182,29 +135,21 @@ def plot_pure_eb( (default: 0.08-300 arcmin, 1000 bins) npatch : int, optional Number of patches for jackknife covariance. Uses self.npatch if None. - var_method : str - Variance method ("jackknife" or "semi-analytic"). - Automatically set to "semi-analytic" when cov_path_int is provided. cov_path_int : str, optional - Path to integration covariance matrix for semi-analytical calculation - cosmo_cov : pyccl.Cosmology, optional - Cosmology for theoretical predictions in semi-analytical covariance - n_samples : int - Number of Monte Carlo samples for semi-analytical covariance (default: 1000) + Analytic ξ± covariance on the integration grid; the jackknife is + used without it. results : dict or list, optional Precalculated results to avoid recomputation. Can be a single results dict for one version, or a list of results dicts for multiple versions. If None (default), results will be calculated using calculate_pure_eb. - **kwargs : dict - Additional arguments passed to calculate_eb_statistics Notes ----- This function orchestrates the full E/B mode analysis workflow: - Uses instance configuration as defaults for unspecified parameters - - Automatically switches to analytical variance when theoretical - covariance provided + - Uses the analytic covariance when cov_path_int is given, the + jackknife otherwise - Generates standardized output file naming based on all analysis parameters - Delegates individual plot generation to specialized functions in @@ -215,9 +160,7 @@ def plot_pure_eb( output_dir = output_dir or self.cc["paths"]["output"] npatch = npatch or self.npatch - # Override var_method to analytic when cov_path_int is provided - if cov_path_int is not None: - var_method = "semi-analytic" + var_method = "jackknife" if cov_path_int is None else "analytic" # Use treecorr_config defaults for reporting scale binning min_sep = min_sep or self.treecorr_config["min_sep"] @@ -266,14 +209,11 @@ def plot_pure_eb( max_sep_int=max_sep_int, nbins_int=nbins_int, npatch=npatch, - var_method=var_method, cov_path_int=cov_path_int, - cosmo_cov=cosmo_cov, - n_samples=n_samples, ) # Calculate E/B statistics for all bin combinations - version_results = calculate_eb_statistics(version_results, **kwargs) + version_results = calculate_eb_statistics(version_results) # Integration vs Reporting comparison plot plot_integration_vs_reporting( @@ -303,7 +243,7 @@ def plot_pure_eb( # Covariance matrix plot plot_eb_covariance_matrix( version_results["cov"], - var_method, + covariance_label(version_results["npatch"]), out_stub + "_covariance.png", version, ) diff --git a/src/sp_validation/sacc_io.py b/src/sp_validation/sacc_io.py index 99a0efe2..fc1f9bbb 100644 --- a/src/sp_validation/sacc_io.py +++ b/src/sp_validation/sacc_io.py @@ -115,8 +115,8 @@ } # PURE_TYPES key order is the insertion order of the six pure-EB blocks — # matches b_modes._EB_KEYS, whose order is the [xip_E; xim_E; xip_B; xim_B; -# xip_amb; xim_amb] layout of the treecorr/MC pure-EB covariance -# (b_modes.calculate_eb_statistics, ~L392). +# xip_amb; xim_amb] block layout of the pure-EB covariance +# (b_modes.calculate_pure_eb_correlation). PURE_KEYS = tuple(PURE_TYPES) RHO_PLUS = "psf_rho{k}_xi_plus" @@ -580,6 +580,11 @@ def get_xi(s, bins, *, grid): return _get_pm(s, XI_PLUS, XI_MINUS, _pair(bins), grid=grid) +def get_xi_npairs(s, bins, *, grid): + """Return the TreeCorr pair counts stored with :func:`add_xi`'s ξ+ points.""" + return _tag(s, XI_PLUS, _pair(bins), "npairs", grid=grid) + + def get_pseudo_cl(s, bins): """Return ``(ell_eff, cl_ee, cl_bb, cl_eb, window)`` for one tracer pair. diff --git a/src/sp_validation/tests/conftest.py b/src/sp_validation/tests/conftest.py index 40424120..30f4edd9 100644 --- a/src/sp_validation/tests/conftest.py +++ b/src/sp_validation/tests/conftest.py @@ -10,10 +10,11 @@ @pytest.fixture def pure_eb_xi(): - """Committed ξ± of the synthetic coherent-shear catalogue. + """Committed fine-grid ξ± of the synthetic coherent-shear catalogue. - Exact-binning reporting [15, 70]′ in 6 bins and integration [1, 300]′ in - 600 bins, keyed by ``b_modes.pure_eb_from_xi``'s parameters. + Exact-binning integration grid [1, 300]′ in 600 bins with its pair counts, + and the edges of a [15, 70]′ reporting grid in 6 bins, keyed by + ``b_modes.calculate_pure_eb_correlation``'s parameters. """ with np.load(PURE_EB_XI) as npz: return {k: (v.item() if v.ndim == 0 else v) for k, v in npz.items()} diff --git a/src/sp_validation/tests/data/pure_eb_xi_fixture.npz b/src/sp_validation/tests/data/pure_eb_xi_fixture.npz index fa67b006d5fae626006bc7670bcafba9e7558242..755bccd18d8099d916343bc6cf7152b6f2c2b19f 100644 GIT binary patch literal 20818 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 a!hg>aduuA{e{!g|{`qe0#h#u2sQ(YE3`Z3J delta 14561 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 diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index 20a9aa67..ef4a1230 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -2,8 +2,8 @@ This module pins the numeric behavior of the pure E/B-mode helpers in ``sp_validation.b_modes`` against fixed, deterministic inputs (seeded RNG, -hand-built arrays and one committed ξ± fixture — no cluster data, no catalogue -files). +hand-built arrays and one committed fine-grid ξ± fixture — no cluster data, no +catalogue files). Every pinned literal was produced by an actual run of the estimator inside the container; a future refactor that changes the numbers must fail. @@ -49,9 +49,8 @@ def _grid_gg(): def _eb_inputs(): """Fixed seeded input for calculate_eb_statistics. - nbins=4, npatch=50 so the Hartlap factor (n_eff - nbins_eff - 2)/(n_eff-1) - is well-defined and strictly positive for every scale-cut combination. - n_eff is the jackknife patch count, as it is for a jackknife covariance. + nbins=4, npatch=50 so the Hartlap factor (npatch - p - 2)/(npatch - 1) is + well-defined and strictly positive for every scale-cut combination. The covariance is built SPD via A @ A.T + I; the B-mode vectors are O(1) so the chi-squared (and hence PTE) lands in a meaningful range rather than being saturated at 1.0. @@ -64,7 +63,7 @@ def _eb_inputs(): xim_B = rng.standard_normal(nbins) return { "theta": np.geomspace(1.0, 100.0, nbins), - "n_eff": npatch, + "npatch": npatch, "cov": cov, "xip_B": xip_B, "xim_B": xim_B, @@ -236,8 +235,9 @@ def test_calculate_eb_statistics_pte_matrices(): """Pin representative PTE-matrix entries from the full 2D E/B analysis. Inputs are fixed (seed 12345, nbins=4, npatch=50, SPD cov = A@A.T + I, - O(1) B-mode vectors). The Hartlap correction uses n_eff = 50, the patch - count behind a jackknife covariance. For each of xip_B, xim_B and combined we pin the + O(1) B-mode vectors). The Hartlap correction uses npatch = 50 over the + length of the inverted vector (2x for combined). For each of xip_B, xim_B + and combined we pin the full-range entry [0, nbins-1] (start=0, stop=nbins) and an interior entry [0, 2] (start=0, stop=3). These chi2->sf PTE values are deterministic functions of the seeded input. @@ -253,12 +253,12 @@ def test_calculate_eb_statistics_pte_matrices(): # Full-range entries [0, nbins-1]. npt.assert_allclose(pm["xip_B"][0, nbins - 1], 0.9985059590347458, rtol=1e-9) npt.assert_allclose(pm["xim_B"][0, nbins - 1], 0.9979174764123961, rtol=1e-9) - npt.assert_allclose(pm["combined"][0, nbins - 1], 0.9999930883595443, rtol=1e-9) + npt.assert_allclose(pm["combined"][0, nbins - 1], 0.9999952393605003, rtol=1e-9) # Interior entries [0, 2] (start_bin=0, stop_bin=3). npt.assert_allclose(pm["xip_B"][0, 2], 0.9991074524059739, rtol=1e-9) npt.assert_allclose(pm["xim_B"][0, 2], 0.9896253892931961, rtol=1e-9) - npt.assert_allclose(pm["combined"][0, 2], 0.9999497648089674, rtol=1e-9) + npt.assert_allclose(pm["combined"][0, 2], 0.9999590178816327, rtol=1e-9) # Structural pins: off the valid upper triangle the matrices are NaN. for key in ("xip_B", "xim_B", "combined"): @@ -268,6 +268,28 @@ def test_calculate_eb_statistics_pte_matrices(): assert np.all(np.isfinite(np.diag(m))) # single-bin cuts are valid +def test_calculate_eb_statistics_analytic_covariance_skips_hartlap(): + """An analytic covariance (npatch None) gives the plain χ² PTE. + + The full-range χ² is data·C⁻¹·data with no factor, so its PTE is pinned + directly against scipy; the jackknife PTE on the same input differs. + """ + from scipy import stats + + results, nbins = _eb_inputs() + results["npatch"] = None + pm = b_modes.calculate_eb_statistics(results)["pte_matrices"] + + cov_B = results["cov_xip_B"] + chi2 = results["xip_B"] @ np.linalg.solve(cov_B, results["xip_B"]) + npt.assert_allclose(pm["xip_B"][0, nbins - 1], stats.chi2.sf(chi2, nbins)) + npt.assert_allclose(pm["combined"][0, nbins - 1], 0.9999894806723678, rtol=1e-9) + + jackknife, _ = _eb_inputs() + pm_jk = b_modes.calculate_eb_statistics(jackknife)["pte_matrices"] + assert pm_jk["xip_B"][0, nbins - 1] != pm["xip_B"][0, nbins - 1] + + def test_calculate_eb_statistics_has_teeth(): """Teeth for #4: a 10x-louder B-mode signal must drop the full-range PTE. @@ -295,77 +317,173 @@ def test_calculate_eb_statistics_has_teeth(): # --------------------------------------------------------------------------- -# 5. pure_eb_from_xi on committed ξ± (the transform pin) +# 5. The pure-E/B operator on committed fine-grid ξ± # --------------------------------------------------------------------------- -# pure_eb_from_xi(**fixture); regenerated only when the transform is meant to move. +# calculate_pure_eb_correlation(**fixture) modes; regenerated only when the +# estimator is meant to move. _PURE_EB_PINS = { "xip_E": [ - -2.9831529669542025e-06, - -1.5008524620265777e-05, - 3.221623968725757e-07, - 1.1797672310858565e-05, - 5.715510692557323e-06, - 8.825804523824443e-07, + 0.000126760298350234, + 0.00011658330412818888, + 0.00011288505914719275, + 0.0001134165787303342, + 9.413529992584958e-05, + 8.504614832216474e-05, ], "xim_E": [ - -4.737558091773235e-05, - -0.00010853189443993388, - -9.094825175032069e-05, - -5.826599101284694e-05, - -4.646405415748759e-05, - -1.9978028925333273e-05, + 7.835778225465925e-06, + -3.2941169447535557e-08, + 1.258394911549768e-06, + 5.9168056533555394e-06, + -5.7480349079948186e-06, + 5.3046595852565625e-06, ], "xip_B": [ - 1.7069121242262332e-05, - 3.059889782373755e-05, - -4.8805399253844115e-06, - -6.999262696335271e-06, - -1.2672006989728095e-05, - -1.214149138979614e-06, + -5.883756312983891e-05, + -5.4151696474192125e-05, + -7.033237297030058e-05, + -6.463826834039258e-05, + -6.439549548932501e-05, + -5.781563569551224e-05, ], "xim_B": [ - -0.00011478091634539627, - -5.445112002141066e-05, - -3.100806652947907e-05, - -1.0940424256759085e-05, - -5.755185146643215e-06, - -1.628217762504557e-06, + -1.238817586945211e-05, + 1.138850799409577e-06, + 3.7912186858520153e-06, + 9.138091322053387e-06, + 5.4865624360288735e-06, + 4.457794156886124e-06, ], "xip_amb": [ - 0.00014017621792612224, - 0.0001378482153667787, - 0.0001339573019551001, - 0.00012745126271361765, - 0.00011662385105911986, - 9.851844704443032e-05, + 8.984508946714618e-05, + 8.879513749174612e-05, + 8.704203334235309e-05, + 8.410988894408256e-05, + 7.923019633199604e-05, + 7.107159007248638e-05, ], "xim_amb": [ - -4.389203999135455e-05, - 5.279277664928643e-05, - 5.800339397836051e-05, - 4.4242350610114584e-05, - 2.9902912946755567e-05, - 1.912262132836568e-05, + 1.5110099529260723e-06, + 9.05896609235364e-07, + 5.429748754312615e-07, + 3.250845464381953e-07, + 1.9494837233251687e-07, + 1.1676980700188147e-07, ], } -def test_pure_eb_from_xi_reproduces_pins_on_committed_xi(pure_eb_xi): - """The pure-E/B transform of the committed ξ± reproduces its pins. +def _spd(n, seed): + """A seeded SPD matrix standing in for a ξ± covariance.""" + A = np.random.default_rng(seed).standard_normal((n, n)) + return A @ A.T / n + np.eye(n) + - With ξ± frozen, these pins move only when the transform does. rtol=1e-6 is - far above the 1e-12 reduction-order noise across thread counts. +def test_pure_eb_reproduces_pins_on_committed_xi(pure_eb_xi): + """The pure-E/B estimator on the committed ξ± reproduces its pins. + + With ξ± frozen, these pins move only when the estimator does. The operator + is deterministic linear algebra, so rtol=1e-8 leaves room only for BLAS + summation order. """ - modes = b_modes.pure_eb_from_xi(**pure_eb_xi) + n_fine = len(pure_eb_xi["theta_int"]) + results = b_modes.calculate_pure_eb_correlation( + **pure_eb_xi, cov_xi=np.eye(2 * n_fine) + ) for key in b_modes._EB_KEYS: - npt.assert_allclose(modes[key], _PURE_EB_PINS[key], rtol=1e-6, err_msg=key) + npt.assert_allclose(results[key], _PURE_EB_PINS[key], rtol=1e-8, err_msg=key) - # Teeth: widening the integration interval by 1% leaves the pins. - moved = b_modes.pure_eb_from_xi(**{**pure_eb_xi, "tmax": 1.01 * pure_eb_xi["tmax"]}) + # Teeth: uniform rather than pair-count weights leave the pins. + moved = b_modes.calculate_pure_eb_correlation( + **{**pure_eb_xi, "npairs_int": np.ones(n_fine)}, cov_xi=np.eye(2 * n_fine) + ) assert not np.allclose(moved["xip_E"], _PURE_EB_PINS["xip_E"], rtol=1e-6, atol=0) +def test_pure_eb_modes_sum_to_the_averaged_xi(pure_eb_xi): + """ξ± = E ± B + amb holds in every reporting bin. + + It holds at each fine node by construction of the decomposition, and the + modes and the reported ξ± are the same pair-count average of those nodes. + """ + n_fine = len(pure_eb_xi["theta_int"]) + r = b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=np.eye(2 * n_fine)) + scale = np.abs(r["xip"]).max() + npt.assert_allclose( + r["xip"], r["xip_E"] + r["xip_B"] + r["xip_amb"], rtol=0, atol=1e-12 * scale + ) + npt.assert_allclose( + r["xim"], r["xim_E"] - r["xim_B"] + r["xim_amb"], rtol=0, atol=1e-12 * scale + ) + + +def test_pure_eb_covariance_is_the_operator_sandwich(pure_eb_xi): + """``cov`` is K C Kᵀ for the supplied ξ± covariance, and records npatch. + + The reported ξ± variances are the same pair-count average pushed through + the ξ+ and ξ− blocks of C. + """ + n_fine = len(pure_eb_xi["theta_int"]) + cov_xi = _spd(2 * n_fine, seed=7) + r = b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=40) + K, P = b_modes.pure_eb_operator( + pure_eb_xi["theta_int"], + pure_eb_xi["npairs_int"], + pure_eb_xi["left_edges"], + pure_eb_xi["right_edges"], + ) + npt.assert_allclose(r["cov"], K @ cov_xi @ K.T, rtol=1e-12) + npt.assert_allclose(r["cov"], r["cov"].T, rtol=1e-12) + npt.assert_allclose(r["var_xip"], np.diag(P @ cov_xi[:n_fine, :n_fine] @ P.T)) + npt.assert_allclose(r["var_xim"], np.diag(P @ cov_xi[n_fine:, n_fine:] @ P.T)) + assert r["npatch"] == 40 + + with pytest.raises(ValueError, match="npatch > 1"): + b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=1) + + +def test_pure_eb_binning_is_a_pair_count_average(): + """Each reporting row averages its fine nodes with pair-count weights. + + Rows sum to one; nodes outside the reporting range or without pairs carry + no weight; equal pair counts give the plain mean; an empty bin raises. + """ + theta = np.geomspace(1.0, 100.0, 40) + npairs = np.arange(1.0, 41.0) + npairs[20] = 0.0 + left, right = b_modes.log_bin_edges(2.0, 50.0, 4) + P = b_modes._npairs_binning_matrix(theta, npairs, left, right) + + npt.assert_allclose(P.sum(axis=1), 1.0) + assert np.all(P[:, (theta < 2.0) | (theta >= 50.0)] == 0) + assert np.all(P[:, 20] == 0) + inside = (theta >= left[1]) & (theta < right[1]) & (npairs > 0) + npt.assert_allclose(P[1, inside], npairs[inside] / npairs[inside].sum()) + + flat = b_modes._npairs_binning_matrix(theta, np.ones(40), left, right) + inside = (theta >= left[0]) & (theta < right[0]) + npt.assert_allclose(flat[0, inside], 1.0 / inside.sum()) + + with pytest.raises(ValueError, match="hold no integration-grid pairs"): + b_modes._npairs_binning_matrix(theta, np.zeros(40), left, right) + + +def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi): + """Reporting bins that reach the fine grid's floor raise, never return NaN. + + The ξ− integrals over [tmin, t] have fewer than interp_order + 1 nodes for + the first few fine nodes, so those operator rows are under-determined. + """ + theta_int = pure_eb_xi["theta_int"] + with pytest.raises(ValueError, match="under-determined"): + b_modes.pure_eb_operator( + theta_int, + pure_eb_xi["npairs_int"], + *b_modes.log_bin_edges(theta_int[0], 70.0, 6), + ) + + # --------------------------------------------------------------------------- # 6. Grid edges and the COSEBIs covariance seam # --------------------------------------------------------------------------- @@ -477,8 +595,8 @@ def test_pure_eb_npz_carries_what_the_summary_reads(tmp_path): """The .npz keys cv_summarize_bmodes reads are the ones the writer emits. The two live in different rules, so the contract between them — the PTE - matrices under ``pte_matrices_{stat}`` and the realisation count under - ``n_eff`` — is pinned here rather than discovered on a cluster run. + matrices under ``pte_matrices_{stat}`` and the jackknife patch count under + ``npatch`` — is pinned here rather than discovered on a cluster run. """ results, nbins = _eb_inputs() results.update( @@ -493,7 +611,7 @@ def test_pure_eb_npz_carries_what_the_summary_reads(tmp_path): for stat in ("xip_B", "xim_B", "combined"): assert f"pte_matrices_{stat}" in saved assert saved[f"pte_matrices_{stat}"].shape == (nbins, nbins) - assert saved["n_eff"] == results["n_eff"] + assert saved["npatch"] == results["npatch"] npt.assert_allclose(saved["theta"], results["theta"]) for key in b_modes._EB_KEYS: assert key in saved @@ -505,52 +623,3 @@ def test_pure_eb_npz_carries_what_the_summary_reads(tmp_path): saved["pte_matrices_xip_B"], edges, (1.0, 100.0) ) assert np.isfinite(pte) - - -def test_pure_eb_covariance_mc_draws_around_the_theory_mean(monkeypatch): - """The MC draws centre on cs_util's theory ξ±, binned to the reporting grid. - - ``get_theo_xi`` returns ``{pair: (ξ+, ξ−)}``; one n(z) is one pair. With a - zero covariance every draw is the theory mean, so a stub kernel that echoes - its reporting-grid ξ± pins the unpack, the [ξ+; ξ−] order and the binning. - """ - nrep = 4 - left, right = b_modes.log_bin_edges(2.0, 50.0, nrep) - theta = np.sqrt(left * right) - theta_int = np.geomspace(1.0, 100.0, 40) - xip_th, xim_th = theta_int.copy(), 2 * theta_int - - monkeypatch.setattr( - b_modes, "get_theo_xi", lambda **kw: {"W0xW0": (xip_th, xim_th)} - ) - - def _echo(theta, theta_int, xip, xim, xip_int, xim_int, tmin, tmax, parallel): - zeros = np.zeros_like(xip) - return xip, xim, zeros, zeros, zeros, zeros - - module = types.ModuleType("cosmo_numba.B_modes.schneider2022") - module.get_pure_EB_modes = _echo - monkeypatch.setitem( - __import__("sys").modules, "cosmo_numba.B_modes.schneider2022", module - ) - - cov, eb_samples = b_modes.pure_eb_covariance_mc( - theta=theta, - left_edges=left, - right_edges=right, - theta_int=theta_int, - cov_int=np.zeros((2 * len(theta_int), 2 * len(theta_int))), - z=np.linspace(0.01, 2.0, 50), - nz=np.ones(50), - cosmo=None, - n_samples=3, - ) - - inside = [(theta_int >= lo) & (theta_int < hi) for lo, hi in zip(left, right)] - expected_xip = np.array([xip_th[m].mean() for m in inside]) - expected_xim = np.array([xim_th[m].mean() for m in inside]) - assert eb_samples.shape == (3, 6 * nrep) - for draw in eb_samples: - npt.assert_allclose(draw[:nrep], expected_xip) - npt.assert_allclose(draw[nrep : 2 * nrep], expected_xim) - npt.assert_allclose(cov, 0.0, atol=1e-20) diff --git a/src/sp_validation/tests/test_cosmo_val.py b/src/sp_validation/tests/test_cosmo_val.py index 54aed4a5..d8ddbf46 100644 --- a/src/sp_validation/tests/test_cosmo_val.py +++ b/src/sp_validation/tests/test_cosmo_val.py @@ -581,17 +581,13 @@ def test_calculate_scale_dependent_leakage_runs_on_synthetic_catalog( assert hasattr(res, "C_sys_p") and hasattr(res, "C_sys_m") def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi): - """calculate_pure_eb carries ξ± through cosmo_numba's pure-E/B split. + """calculate_pure_eb measures the fine ξ± and pushes it through the operator. - The ξ± it measures equal the committed ``pure_eb_xi``, its modes are - ``pure_eb_from_xi`` of those ξ± and edges, and every reporting bin is - finite. ``test_b_modes`` pins the transform itself on the same ξ±, so a - failure names the step that moved: measurement, wiring or transform. - - Finiteness: the Schneider (2022) integrals are near-singular where a - reporting bin meets the integration boundary, so the integration grid - [1, 300]′ brackets the reporting grid [15, 70]′ on both ends and is fine - (600 bins); about 80 integration bins NaN the edge bins. + The ξ± it measures equal the committed ``pure_eb_xi`` (``test_b_modes`` + pins the operator on the same ξ±, so a failure names the step that + moved), and the jackknife covariance of the modes is the jackknife ξ± + covariance through the operator: for a linear estimator that equals + TreeCorr's per-patch jackknife of the modes themselves. ξ±: exact binning (bin_slop = angle_slop = 0) makes ξ± a plain pair sum, independent of the tree and so of the jackknife patches, whose k-means @@ -599,6 +595,8 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) """ pytest.importorskip("treecorr") pytest.importorskip("cosmo_numba") + import treecorr + from sp_validation import b_modes # Coherent shear -> smooth xi+/-, so the pure-E/B integral is well-posed. @@ -627,14 +625,15 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) ) measured = { - "theta_report": results["theta"], - "xip_report": results["xip"], - "xim_report": results["xim"], - "theta_int": results["theta_int"], - "xip_int": results["xip_int"], - "xim_int": results["xim_int"], - "tmin": results["left_edges"][0], - "tmax": results["right_edges"][-1], + key: results[key] + for key in ( + "theta_int", + "xip_int", + "xim_int", + "npairs_int", + "left_edges", + "right_edges", + ) } # Regenerate the fixture with np.savez(conftest.PURE_EB_XI, **measured). for key, value in measured.items(): @@ -642,14 +641,30 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) value, pure_eb_xi[key], rtol=1e-10, atol=0, err_msg=key ) - modes = b_modes.pure_eb_from_xi(**measured) - for key in b_modes._EB_KEYS: + operator, _ = b_modes.pure_eb_operator( + *(measured[k] for k in ("theta_int", "npairs_int")), + measured["left_edges"], + measured["right_edges"], + ) + modes = operator @ np.concatenate([measured["xip_int"], measured["xim_int"]]) + for i, key in enumerate(b_modes._EB_KEYS): vec = np.asarray(results[key]) assert vec.shape == (nbins,) assert np.all(np.isfinite(vec)), f"{key} not finite" - np.testing.assert_allclose(vec, modes[key], rtol=1e-10, err_msg=key) + np.testing.assert_allclose( + vec, modes[i * nbins : (i + 1) * nbins], rtol=1e-12, err_msg=key + ) - # Jackknife covariance over the 6 stats (xip/xim x E/B/amb) x nbins. cov = np.asarray(results["cov"]) assert cov.shape == (6 * nbins, 6 * nbins) - assert results["n_eff"] == npatch + assert results["npatch"] == npatch + gg_int = cv.cat_ggs[version] + jackknife_of_modes = treecorr.estimate_multi_cov( + [gg_int], + "jackknife", + func=lambda corrs: operator @ np.concatenate([corrs[0].xip, corrs[0].xim]), + cross_patch_weight="match", + ) + np.testing.assert_allclose( + cov, jackknife_of_modes, rtol=0, atol=1e-10 * np.abs(cov).max() + ) diff --git a/src/sp_validation/tests/test_sacc_io.py b/src/sp_validation/tests/test_sacc_io.py index 8f8564ae..064e8103 100644 --- a/src/sp_validation/tests/test_sacc_io.py +++ b/src/sp_validation/tests/test_sacc_io.py @@ -100,6 +100,7 @@ def test_xi_roundtrip(tmp_path): assert np.array_equal(th, theta) assert np.array_equal(p, xip) assert np.array_equal(m, xim) + assert np.array_equal(sio.get_xi_npairs(s2, (0, 0), grid="reporting"), npairs) # extra tags survive idx = s2.indices(sio.XI_PLUS, ("source_0", "source_0"), grid="reporting") tags = s2.data[idx[0]].tags diff --git a/uv.lock b/uv.lock index 94341fce..9a027424 100644 --- a/uv.lock +++ b/uv.lock @@ -588,8 +588,8 @@ wheels = [ [[package]] name = "cosmo-numba" -version = "1.0.1.dev6+g188d272c6" -source = { git = "https://github.com/aguinot/cosmo-numba.git?rev=main#188d272c67d6d699d7cff7a7a53ded8dec759bb0" } +version = "0.1.dev113+gd78a189d9" +source = { git = "https://github.com/cailmdaley/cosmo-numba.git?rev=d78a189d9af75a9c113fef7647102a1bad0fd452#d78a189d9af75a9c113fef7647102a1bad0fd452" } dependencies = [ { name = "mpmath" }, { name = "numba" }, @@ -3821,7 +3821,7 @@ requires-dist = [ { name = "camb", specifier = ">=2.0" }, { name = "clmm" }, { name = "colorama" }, - { name = "cosmo-numba", git = "https://github.com/aguinot/cosmo-numba.git?rev=main" }, + { name = "cosmo-numba", git = "https://github.com/cailmdaley/cosmo-numba.git?rev=d78a189d9af75a9c113fef7647102a1bad0fd452" }, { name = "cosmology", marker = "extra == 'glass'", specifier = "==2022.10.9" }, { name = "cosmosis", marker = "extra == 'workflow'", specifier = ">=3.25" }, { name = "cryptography" }, From 49097caec0b271e072da875fa60ef38352f222fb Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 04:51:18 +0200 Subject: [PATCH 02/10] cv_pure_eb: modes and exact covariance from the integration part MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule reads only the integration-grid ξ± part (with its pair counts) and the CosmoCov ξ± covariance on that grid, and calls calculate_pure_eb_correlation. The Monte-Carlo parameters and the reporting-part input go; the covariance takes seconds, so the rule's threads and runtime shrink. The summary labels the covariance from the npz's npatch record. Co-Authored-By: Claude Opus 5.5 --- papers/cosmo_val/config/config.yaml | 4 +- workflow/rules/cosmo_val.smk | 19 +++--- workflow/scripts/cv_pure_eb.py | 80 ++++++++----------------- workflow/scripts/cv_summarize_bmodes.py | 9 ++- workflow/tests/test_dag.py | 2 +- 5 files changed, 42 insertions(+), 72 deletions(-) diff --git a/papers/cosmo_val/config/config.yaml b/papers/cosmo_val/config/config.yaml index 8501bfbc..5b33999f 100644 --- a/papers/cosmo_val/config/config.yaml +++ b/papers/cosmo_val/config/config.yaml @@ -57,7 +57,7 @@ cosmo_val: # C_l^BB column belongs in the standard B-mode summary. include_pseudo_cl: true - # Theory cosmology for pseudo-Cl / semi-analytic covariance (astropy Planck18 + # Theory cosmology for the pseudo-Cl covariance (astropy Planck18 # + CAMB nonlinear settings, matching the original run_cosmo_val.py driver). cosmo_params: Omega_m: 0.30966 @@ -110,8 +110,6 @@ var_method: jackknife covariance: default_masked: true - use_semianalytic: true - n_samples: 2000 cl: n_ell_bins: 32 diff --git a/workflow/rules/cosmo_val.smk b/workflow/rules/cosmo_val.smk index 1bff5b43..898e9ab4 100644 --- a/workflow/rules/cosmo_val.smk +++ b/workflow/rules/cosmo_val.smk @@ -7,8 +7,7 @@ # products they write under COSMO_VAL (= cosmo_val/output): # # catalogue ──→ xi (one job per grid: reporting, integration) -# ├─ reporting part ──────→ pure_eb (part, npz, figures) -# ├─ integration part ─┬──→ pure_eb +# ├─ integration part ─┬──→ pure_eb (part, npz, figures) # │ └──→ cosebis (part, npz, figures) # └─ reporting .txt ──→ 2pcf plot, ratio_xi_sys_xi # CosmoCov ξ±, integration grid ──→ pure_eb, cosebis (their covariances) @@ -80,7 +79,7 @@ def _pure_eb_stub(version): f"{version}_eb_minsep={CV['theta_min']}_maxsep={CV['theta_max']}" f"_nbins={CV['nbins']}_minsepint={eb['min_sep']}" f"_maxsepint={eb['max_sep']}_nbinsint={eb['nbins']}" - f"_npatch={CV['npatch']}_varmethod=semi-analytic" + f"_npatch={CV['npatch']}_varmethod=analytic" ) ) @@ -396,13 +395,12 @@ rule cv_plot_pseudo_cl: # --------------------------------------------------------------------------- rule cv_pure_eb: - """Pure E/B-mode decomposition for one version, from its ξ± parts. + """Pure E/B-mode decomposition for one version, from its integration-grid part. - The modes come from the two parts; the covariance is Monte Carlo from the - integration-grid covariance model, so no patched estimator run is involved. + The modes are a fixed linear operator on the part's ξ±; the covariance is + the CosmoCov ξ± covariance on the same grid pushed exactly through it. """ input: - xi_reporting=lambda w: cv_xi_sacc(w.version, "reporting"), xi_integration=lambda w: cv_xi_sacc(w.version, "integration"), cov_integration=lambda w: cv_xi_cov_integration(w.version), output: @@ -414,13 +412,10 @@ rule cv_pure_eb: min_sep=CV["theta_min"], max_sep=CV["theta_max"], nbins=CV["nbins"], - n_samples=CV.get("n_mc_samples", 1000), - cosmo_params=CV["cosmo_params"], fiducial_scale_cut=CV["fiducial_scale_cut"], - threads: 24 resources: - mem_mb=40000, - runtime=360, + mem_mb=8000, + runtime=20, script: "../scripts/cv_pure_eb.py" diff --git a/workflow/scripts/cv_pure_eb.py b/workflow/scripts/cv_pure_eb.py index 1edaea93..b22a3f0e 100644 --- a/workflow/scripts/cv_pure_eb.py +++ b/workflow/scripts/cv_pure_eb.py @@ -1,28 +1,25 @@ """Rule cv_pure_eb: pure E/B-mode decomposition for one version. -A consumer of the two ξ± parts plus one covariance file — nothing here touches -a catalogue. The modes come from the reporting and integration parts through -the pipeline kernel; the covariance is Monte Carlo through that same kernel, -drawn from the CosmoCov integration-grid ξ± covariance around a theory mean, so -it depends on the covariance model and the grids rather than on the measured -vector. A jackknife of the transformed modes would need per-patch realisations, -which are never persisted. +A consumer of the integration-grid ξ± part plus its analytic covariance — +nothing here touches a catalogue. The estimator is one fixed linear operator +on the fine ξ± (b_modes.pure_eb_operator), averaged into the reporting bins with +the part's pair counts, so its covariance is the CosmoCov ξ± covariance pushed +exactly through that operator. """ import numpy as np -from cs_util.cosmo import get_cosmo from cv_runner import _unbuffer_streams, verify_outputs from sp_validation import sacc_io from sp_validation.b_modes import ( calculate_eb_statistics, + calculate_pure_eb_correlation, + covariance_label, log_bin_edges, plot_eb_covariance_matrix, plot_integration_vs_reporting, plot_pte_2d_heatmaps, plot_pure_eb_correlations, - pure_eb_covariance_mc, - pure_eb_from_xi, save_pure_eb_results, ) from sp_validation.cosmo_val.sacc_writers import pure_eb_to_sacc @@ -32,48 +29,20 @@ version = p["version"] fiducial_scale_cut = tuple(p["fiducial_scale_cut"]) -reporting = sacc_io.load(snakemake.input["xi_reporting"]) -integration = sacc_io.load(snakemake.input["xi_integration"]) -theta, xip, xim = sacc_io.get_xi(reporting, (0, 0), grid="reporting") -theta_int, xip_int, xim_int = sacc_io.get_xi(integration, (0, 0), grid="integration") +part = sacc_io.load(snakemake.input["xi_integration"]) +theta_int, xip_int, xim_int = sacc_io.get_xi(part, (0, 0), grid="integration") +npairs_int = sacc_io.get_xi_npairs(part, (0, 0), grid="integration") left_edges, right_edges = log_bin_edges(p["min_sep"], p["max_sep"], p["nbins"]) -# The reporting grid must sit strictly inside the integration grid: a reporting -# point on the boundary has no interior support and comes back NaN. -modes = pure_eb_from_xi( - theta, xip, xim, theta_int, xip_int, xim_int, left_edges[0], right_edges[-1] +results = calculate_pure_eb_correlation( + theta_int, + xip_int, + xim_int, + npairs_int, + np.loadtxt(snakemake.input["cov_integration"]), + left_edges, + right_edges, ) - -z, nz = sacc_io.get_nz(reporting, 0) -cov, eb_samples = pure_eb_covariance_mc( - theta=theta, - left_edges=left_edges, - right_edges=right_edges, - theta_int=theta_int, - cov_int=np.loadtxt(snakemake.input["cov_integration"]), - z=z, - nz=nz, - cosmo=get_cosmo(**p["cosmo_params"]), - n_samples=p["n_samples"], -) - -variances = reporting.covariance.dense.diagonal() -results = { - "theta": theta, - "left_edges": left_edges, - "right_edges": right_edges, - "xip": xip, - "xim": xim, - "var_xip": variances[: len(theta)], - "var_xim": variances[len(theta) :], - "theta_int": theta_int, - "xip_int": xip_int, - "xim_int": xim_int, - "n_eff": p["n_samples"], - "cov": cov, - "eb_samples": eb_samples, - **modes, -} results = calculate_eb_statistics(results) plot_integration_vs_reporting( @@ -94,19 +63,22 @@ fiducial_xim_scale_cut=fiducial_scale_cut, ) plot_eb_covariance_matrix( - cov, "semi-analytic", snakemake.output["figure_covariance"], version + results["cov"], + covariance_label(results["npatch"]), + snakemake.output["figure_covariance"], + version, ) save_pure_eb_results(results, snakemake.output["npz"]) # The part inherits the ξ± part's provenance; `type` is re-stamped on save. -metadata = {k: v for k, v in reporting.metadata.items() if k != "type"} +metadata = {k: v for k, v in part.metadata.items() if k != "type"} s = pure_eb_to_sacc( - {0: (z, nz)}, + {0: sacc_io.get_nz(part, 0)}, metadata, - theta, + results["theta"], {key: results[key] for key in sacc_io.PURE_KEYS}, - covariance=cov, + covariance=results["cov"], ) sacc_io.save(s, snakemake.output["sacc"], type="data") diff --git a/workflow/scripts/cv_summarize_bmodes.py b/workflow/scripts/cv_summarize_bmodes.py index ec44142a..42a94514 100644 --- a/workflow/scripts/cv_summarize_bmodes.py +++ b/workflow/scripts/cv_summarize_bmodes.py @@ -13,7 +13,11 @@ from cv_runner import _unbuffer_streams, verify_outputs from sp_validation import sacc_io -from sp_validation.b_modes import _get_pte_from_scale_cut, log_bin_edges +from sp_validation.b_modes import ( + _get_pte_from_scale_cut, + covariance_label, + log_bin_edges, +) from sp_validation.cosmo_val.core import print_bmode_summary from sp_validation.statistics import chi2_and_pte @@ -36,7 +40,8 @@ ) except (KeyError, RuntimeError): pass - cov_methods.add(f"pure-E/B: semi-analytic ({int(pure_eb['n_eff'])} draws)") + npatch = int(pure_eb["npatch"]) if "npatch" in pure_eb else None + cov_methods.add(f"pure-E/B: {covariance_label(npatch)}") # The COSEBIs .npz is written at the fiducial cut, so its PTE is the one # this table wants. diff --git a/workflow/tests/test_dag.py b/workflow/tests/test_dag.py index 90272dca..b11acf3b 100644 --- a/workflow/tests/test_dag.py +++ b/workflow/tests/test_dag.py @@ -52,7 +52,7 @@ def test_one_integration_grid(toy): part = str(toy.cosmo_val / f"{version}_xi_{tag}.sacc") covariance = str(toy.covariances[version, "g"]) assert by_rule["cv_cosebis"] == {part, covariance}, by_rule["cv_cosebis"] - assert {part, covariance} <= by_rule["cv_pure_eb"], by_rule["cv_pure_eb"] + assert by_rule["cv_pure_eb"] == {part, covariance}, by_rule["cv_pure_eb"] @pytest.mark.parametrize("named", [True, False], ids=["named", "unnamed"]) From e2e41fecdb1ddb189fbb68afa0c7338e431ede7e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 04:51:18 +0200 Subject: [PATCH 03/10] papers/bmodes: pure E/B through the library operator, no Monte-Carlo chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pure_eb_modes.py replaces the chunk scatter/gather (precompute_pure_eb_chunk, gather_pure_eb_chunks): one call to calculate_pure_eb_correlation on the fine ξ± and its Gaussian covariance, written to {version}_pure_eb.npz. The PTE and data-vector scripts drop the MC Hartlap factor, the n(z) covariance comparison drops its MC-noise band, and the n_samples / n_chunks config keys go. Co-Authored-By: Claude Opus 5.5 --- papers/bmodes/config/config.yaml | 10 - papers/bmodes/rules/figures.smk | 66 +----- .../scripts/bb_covariance_nz_independence.py | 39 +--- .../bmodes/scripts/calculate_pure_eb_ptes.py | 21 +- .../bmodes/scripts/gather_pure_eb_chunks.py | 136 ------------ .../scripts/precompute_pure_eb_chunk.py | 209 ------------------ papers/bmodes/scripts/pure_eb_covariance.py | 3 +- papers/bmodes/scripts/pure_eb_data_vector.py | 24 +- papers/bmodes/scripts/pure_eb_modes.py | 98 ++++++++ .../scripts/pure_eb_version_comparison.py | 4 +- 10 files changed, 139 insertions(+), 471 deletions(-) delete mode 100644 papers/bmodes/scripts/gather_pure_eb_chunks.py delete mode 100644 papers/bmodes/scripts/precompute_pure_eb_chunk.py create mode 100644 papers/bmodes/scripts/pure_eb_modes.py diff --git a/papers/bmodes/config/config.yaml b/papers/bmodes/config/config.yaml index 1b31c647..1198d984 100644 --- a/papers/bmodes/config/config.yaml +++ b/papers/bmodes/config/config.yaml @@ -58,14 +58,9 @@ fiducial: var_method: jackknife -# covariance settings for semi-analytical E/B mode covariance covariance: # Use masked covariance by default when true default_masked: true - # Enable semi-analytical covariance propagation - use_semianalytic: true - # Number of Monte Carlo samples for covariance propagation (used by pure E/B mode analysis) - n_samples: 2000 # Cosmology: imported from cs_util.cosmo.PLANCK18 (astropy Planck18) # Mask Cl paths: defined in covariance.smk (MASK_CLS_FILES) @@ -135,11 +130,6 @@ plotting: fiducial_line_color: "black" fiducial_line_width: 1.0 -# Pure E/B mode analysis -pure_eb: - # Number of parallel chunks for MC covariance estimation - n_chunks: 20 - # pixel mask processing for survey geometry and CosmoCov integration pixel_mask: # Target nside values for downgrading (balance accuracy vs computational cost) diff --git a/papers/bmodes/rules/figures.smk b/papers/bmodes/rules/figures.smk index 590735e3..31b65ea0 100644 --- a/papers/bmodes/rules/figures.smk +++ b/papers/bmodes/rules/figures.smk @@ -81,14 +81,6 @@ def _reporting_cov_path(version): return covariance_path(version, gaussian="ng") -def _xi_reporting_path(version): - """Path to reporting-scale 2PCF file.""" - return ( - f"{COSMO_VAL_OUTPUT}/{version}_xi_minsep={FIDUCIAL['min_sep']}" - f"_maxsep={FIDUCIAL['max_sep']}_nbins={FIDUCIAL['nbins']}_npatch={FIDUCIAL['npatch']}.txt" - ) - - def _xi_integration_path(version): """Path to fine-binned 2PCF integration file. Unpatched: values only, no covariance.""" return ( @@ -183,50 +175,20 @@ rule cosebis_data_vector: # Pure E/B # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -# Number of parallel chunks for MC covariance estimation -N_PURE_EB_CHUNKS = config["pure_eb"]["n_chunks"] - - -rule precompute_pure_eb_chunk: - """Compute a chunk of MC samples for pure E/B covariance (scatter).""" - input: - cov_integration=lambda w: _cov_integration_path(w.version), - xi_reporting=lambda w: _xi_reporting_path(w.version), - xi_integration=lambda w: _xi_integration_path(w.version), - output: - "results/paper_plots/intermediate/chunks/{version}_pure_eb_chunk_{chunk_id}.npz", - params: - version="{version}", - chunk_id="{chunk_id}", - n_chunks=N_PURE_EB_CHUNKS, - n_samples=config["covariance"]["n_samples"], - cosmo_params=PLANCK18, - **FIDUCIAL_BINNING, - resources: - mem_mb=8000, - script: - "../scripts/precompute_pure_eb_chunk.py" - - -rule precompute_pure_eb: - """Gather MC sample chunks and compute final pure E/B covariance.""" +rule pure_eb_modes: + """Pure E/B modes and their exact covariance K C_ξ Kᵀ from the fine ξ±.""" input: - chunks=expand( - "results/paper_plots/intermediate/chunks/{{version}}_pure_eb_chunk_{chunk_id}.npz", - chunk_id=range(N_PURE_EB_CHUNKS), - ), - xi_reporting=lambda w: _xi_reporting_path(w.version), xi_integration=lambda w: _xi_integration_path(w.version), + cov_integration=lambda w: _cov_integration_path(w.version), output: - "results/paper_plots/intermediate/{version}_pure_eb_semianalytic.npz", + "results/paper_plots/intermediate/{version}_pure_eb.npz", params: - version="{version}", **FIDUCIAL_BINNING, resources: mem_mb=8000, - runtime=5, + runtime=20, script: - "../scripts/gather_pure_eb_chunks.py" + "../scripts/pure_eb_modes.py" rule pure_eb_data_vector: @@ -239,7 +201,7 @@ rule pure_eb_data_vector: """ input: # Per-version inputs: pure_eb_{version} and cov_{version} for all versions - **{f"pure_eb_{ver}": f"results/paper_plots/intermediate/{ver}_pure_eb_semianalytic.npz" + **{f"pure_eb_{ver}": f"results/paper_plots/intermediate/{ver}_pure_eb.npz" for ver in VERSIONS_ALL_FOR_PLOTS}, **{f"cov_{ver}": _reporting_cov_path(ver) for ver in VERSIONS_ALL_FOR_PLOTS}, output: @@ -259,7 +221,7 @@ rule pure_eb_version_comparison: input: # Pure E/B only for leak-corrected versions pure_eb_data=[ - f"results/paper_plots/intermediate/{ver}_pure_eb_semianalytic.npz" + f"results/paper_plots/intermediate/{ver}_pure_eb.npz" for ver in VERSIONS_LEAK_CORR ], params: @@ -282,7 +244,7 @@ rule pure_eb_covariance: - Correlation structure across 6 blocks (E+/E-/B+/B-/amb+/amb-) """ input: - pure_eb_data=f"results/paper_plots/intermediate/{FIDUCIAL_VERSION}_pure_eb_semianalytic.npz", + pure_eb_data=f"results/paper_plots/intermediate/{FIDUCIAL_VERSION}_pure_eb.npz", output: evidence=f"{TAPESTRY_DIR}/pure_eb_covariance/evidence.json", figure=f"{TAPESTRY_DIR}/pure_eb_covariance/figure.png", @@ -292,17 +254,13 @@ rule pure_eb_covariance: rule calculate_pure_eb_ptes: - """PTE matrices for pure E/B-mode scale-cut robustness. - - The PTEs are Hartlap-debiased by the MC draw count. - """ + """PTE matrices for pure E/B-mode scale-cut robustness.""" input: - pure_eb_data="results/paper_plots/intermediate/{version}_pure_eb_semianalytic.npz", + pure_eb_data="results/paper_plots/intermediate/{version}_pure_eb.npz", output: "results/paper_plots/intermediate/{version}_pure_eb_ptes.npz", params: version="{version}", - n_samples=config["covariance"]["n_samples"], resources: mem_mb=16000, runtime=30, @@ -455,7 +413,7 @@ rule bb_covariance_nz_independence: """ input: # Per-realisation MC-propagated pure E/B covariances - **{f"pure_eb_{label}": f"results/paper_plots/intermediate/{ver}_pure_eb_semianalytic.npz" + **{f"pure_eb_{label}": f"results/paper_plots/intermediate/{ver}_pure_eb.npz" for label, ver in NZ_REALISATIONS.items()}, # COSEBIS: xi integration file (shared) + per-realisation config-space covariances xi_integration=_xi_integration_path(MOCK_VERSION), diff --git a/papers/bmodes/scripts/bb_covariance_nz_independence.py b/papers/bmodes/scripts/bb_covariance_nz_independence.py index 69c0db18..7f8cae02 100644 --- a/papers/bmodes/scripts/bb_covariance_nz_independence.py +++ b/papers/bmodes/scripts/bb_covariance_nz_independence.py @@ -136,7 +136,6 @@ def make_figure( cosebis_results, reference, output_path, - n_samples=2000, ): """Four-panel figure comparing BB vs EE stability across n(z) realisations. @@ -152,40 +151,26 @@ def make_figure( color_E = "#E69F00" # orange for E color_B = "#0072B2" # blue for B - # Expected 1σ error on ratio of two MC-estimated quantities - # σ(ratio) ≈ √(2/N) for ratio ≈ 1 - ratio_err = np.sqrt(2.0 / n_samples) - - def setup_ratio_panel(ax, xlabel, title, show_mc_band=False): + def setup_ratio_panel(ax, xlabel, title): ax.axhline(1.0, color="gray", ls="-", lw=0.8, zorder=0) - if show_mc_band: - ax.axhspan( - 1 - ratio_err, - 1 + ratio_err, - color="gray", - alpha=0.25, - label=rf"$\pm\sqrt{{2/N}}$ ($N={n_samples}$)", - ) ax.set_xscale("log") ax.set_xlabel(xlabel) ax.set_title(title) - def plot_ratios(ax, x, results, b_key, e_key, b_name, e_name, shift, b_err): - """B (with optional MC error bar) and E ratios for every realisation.""" + def plot_ratios(ax, x, results, b_key, e_key, b_name, e_name, shift): + """B and E ratios for every realisation.""" for i, (label, res) in enumerate(results.items()): xi = shift(x, i) marker = MARKERS[i % len(MARKERS)] pair = f"{label}/{reference}" - ax.errorbar( + ax.plot( xi, res[b_key]["ratio"], - yerr=b_err, - fmt=marker, + marker, color=color_B, label=f"{b_name} {pair}", markersize=5, alpha=0.8, - capsize=0, ) ax.plot( xi, @@ -212,7 +197,6 @@ def lin_shift(x, i): ax, r"$\theta$ [arcmin]", rf"${name}$: covariance ratio across n(z)", - show_mc_band=True, ) plot_ratios( ax, @@ -223,7 +207,6 @@ def lin_shift(x, i): "B-mode", "E-mode", log_shift, - ratio_err, ) ax.legend(loc="upper right", fontsize=7, ncol=2) ax.set_xlim(1, 300) @@ -237,9 +220,7 @@ def lin_shift(x, i): ax.axhline(1.0, color="gray", ls="-", lw=0.8, zorder=0) ax.set_xlabel(r"Mode $n$") ax.set_title(r"COSEBIS: covariance ratio across n(z)") - plot_ratios( - ax, n_arr, cosebis_results, "B", "E", "B-mode", "E-mode", lin_shift, None - ) + plot_ratios(ax, n_arr, cosebis_results, "B", "E", "B-mode", "E-mode", lin_shift) ax.set_ylabel("Diagonal ratio") ax.legend(loc="upper right", fontsize=7, ncol=2) ax.set_ylim(0.85, 1.15) @@ -247,7 +228,7 @@ def lin_shift(x, i): # --- Panel 4: C_ell^BB vs C_ell^EE --- ax = axes[1, 1] setup_ratio_panel(ax, r"$\ell$", r"$C_\ell$: covariance ratio across n(z)") - plot_ratios(ax, ell_eff, harmonic_results, "BB", "EE", "BB", "EE", log_shift, None) + plot_ratios(ax, ell_eff, harmonic_results, "BB", "EE", "BB", "EE", log_shift) ax.set_ylabel("Diagonal ratio") ax.legend(loc="upper right", fontsize=7, ncol=2) ax.set_ylim(0.85, 1.15) @@ -330,7 +311,6 @@ def ratios_to_reference(data, modes): for res in cosebis_results.values(): res["nmodes"] = nmodes - n_samples = config["covariance"]["n_samples"] make_figure( theta, ell_eff, @@ -339,7 +319,6 @@ def ratios_to_reference(data, modes): cosebis_results, reference, figure_path, - n_samples=n_samples, ) def max_dev(results, mode): @@ -485,7 +464,7 @@ def _from_cli(argv=None): ap.add_argument( "--pure-eb-dir", required=True, - help="Dir with {version}_pure_eb_semianalytic.npz per n(z) realisation", + help="Dir with {version}_pure_eb.npz per n(z) realisation", ) ap.add_argument( "--cosmo-val-dir", @@ -517,7 +496,7 @@ def _from_cli(argv=None): realisations = fid["nz_realisations"] pure_eb_paths = { - b: os.path.join(a.pure_eb_dir, f"{ver}_pure_eb_semianalytic.npz") + b: os.path.join(a.pure_eb_dir, f"{ver}_pure_eb.npz") for b, ver in realisations.items() } harmonic_paths = { diff --git a/papers/bmodes/scripts/calculate_pure_eb_ptes.py b/papers/bmodes/scripts/calculate_pure_eb_ptes.py index feb0be78..6f8a94fc 100644 --- a/papers/bmodes/scripts/calculate_pure_eb_ptes.py +++ b/papers/bmodes/scripts/calculate_pure_eb_ptes.py @@ -1,16 +1,14 @@ """Calculate PTE matrices for pure E/B-mode scale-cut robustness. -CLI refactor of the former Snakemake ``script:`` rule. Reads the gathered -pure-E/B ``semianalytic.npz`` (data vectors + MC covariance), evaluates the +CLI refactor of the former Snakemake ``script:`` rule. Reads the pure-E/B +``_pure_eb.npz`` (data vectors + analytic covariance), evaluates the ξ_+^B / ξ_-^B / joint ξ_tot^B χ² PTE matrices over the scale-cut grid via -``sp_validation.b_modes.calculate_eb_statistics`` (Hartlap-corrected inverse -MC covariance, debiased by the draw count), and writes the PTE matrices to -``{out}/{version}_pure_eb_ptes.npz``. +``sp_validation.b_modes.calculate_eb_statistics``, and writes the PTE matrices +to ``{out}/{version}_pure_eb_ptes.npz``. python calculate_pure_eb_ptes.py \ --version SP_v1.4.6.3_leak_corr \ - --pure-eb-data <..._pure_eb_semianalytic.npz> \ - --n-samples 2000 --out + --pure-eb-data <..._pure_eb.npz> --out """ import argparse @@ -24,7 +22,6 @@ def calculate_ptes( version, pure_eb_data, - n_samples, output_dir, ): dataset = np.load(pure_eb_data) @@ -33,8 +30,8 @@ def calculate_ptes( results = { "theta": theta, - # The MC draws are the realisations behind this covariance. - "n_eff": int(n_samples), + # The covariance is analytic: no Hartlap factor. + "npatch": None, "xip_E": dataset["xip_E"], "xim_E": dataset["xim_E"], "xip_B": dataset["xip_B"], @@ -65,14 +62,12 @@ def calculate_ptes( def _from_cli(argv=None): ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("--version", required=True) - ap.add_argument("--pure-eb-data", required=True, help="Gathered semianalytic .npz") - ap.add_argument("--n-samples", type=int, default=2000) + ap.add_argument("--pure-eb-data", required=True, help="Pure E/B .npz") ap.add_argument("--out", required=True, help="Output directory (lc {output})") a = ap.parse_args(argv) calculate_ptes( version=a.version, pure_eb_data=a.pure_eb_data, - n_samples=a.n_samples, output_dir=a.out, ) diff --git a/papers/bmodes/scripts/gather_pure_eb_chunks.py b/papers/bmodes/scripts/gather_pure_eb_chunks.py deleted file mode 100644 index b6d5d702..00000000 --- a/papers/bmodes/scripts/gather_pure_eb_chunks.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Gather MC sample chunks and compute the final pure E/B covariance. - -CLI refactor of the former Snakemake ``script:`` gather rule. Reads the actual -ξ± data vectors (reporting + integration grids), computes the pure E/B/ambiguous -decomposition (Schneider 2022), stacks the per-chunk MC sample blocks, and -forms the empirical 6-block covariance. Writes the per-version -``_pure_eb_semianalytic.npz`` consumed by every downstream -pure-mode plot / PTE. - - python gather_pure_eb_chunks.py \ - --version SP_v1.4.6.3_leak_corr \ - --xi-reporting \ - --xi-integration \ - --chunks-dir \ - --min-sep 1.0 --max-sep 250.0 --nbins 20 \ - --min-sep-int 0.5 --max-sep-int 300.0 --nbins-int 1000 \ - --out -""" - -import argparse -import glob -import os - -import numpy as np - - -def _load_xi(path, nbins): - """Load ξ± from a TreeCorr text dump.""" - data = np.loadtxt(path, comments="#", max_rows=nbins) - return {"meanr": data[:, 1], "xip": data[:, 3], "xim": data[:, 4]} - - -def gather( - version, - xi_reporting, - xi_integration, - chunk_files, - min_sep, - max_sep, - nbins, - nbins_int, - output_dir, -): - from cosmo_numba.B_modes.schneider2022 import get_pure_EB_modes - - print(f"Gathering pure E/B for {version}") - - gg = _load_xi(xi_reporting, nbins) - gg_int = _load_xi(xi_integration, nbins_int) - - eb_results = get_pure_EB_modes( - theta=gg["meanr"], - xip=gg["xip"], - xim=gg["xim"], - theta_int=gg_int["meanr"], - xip_int=gg_int["xip"], - xim_int=gg_int["xim"], - tmin=min_sep, - tmax=max_sep, - ) - xip_E, xim_E, xip_B, xim_B, xip_amb, xim_amb = eb_results - - chunk_files = sorted(chunk_files) - all_samples = [] - for chunk_file in chunk_files: - data = np.load(chunk_file) - all_samples.append(data["eb_samples"]) - print(f"Loaded {len(data['eb_samples'])} samples from {chunk_file}") - - eb_samples = np.vstack(all_samples) - print(f"Total samples: {len(eb_samples)}") - - cov_pure_eb = np.cov(eb_samples.T) - - package = { - "theta": gg["meanr"], - "theta_int": gg_int["meanr"], - "xip_total": gg["xip"], - "xim_total": gg["xim"], - "xip_E": xip_E, - "xim_E": xim_E, - "xip_B": xip_B, - "xim_B": xim_B, - "xip_amb": xip_amb, - "xim_amb": xim_amb, - "cov_pure_eb": cov_pure_eb, - } - - os.makedirs(output_dir, exist_ok=True) - out_path = os.path.join(output_dir, f"{version}_pure_eb_semianalytic.npz") - np.savez(out_path, **package) - print(f"Saved to {out_path}") - return out_path - - -def _resolve_chunks(args): - if args.chunks: - files = args.chunks - else: - files = glob.glob(os.path.join(args.chunks_dir, "pure_eb_chunk_*.npz")) - if not files: - raise SystemExit(f"No chunk .npz found (chunks-dir={args.chunks_dir})") - return files - - -def _from_cli(argv=None): - ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) - ap.add_argument("--version", required=True) - ap.add_argument("--xi-reporting", required=True) - ap.add_argument("--xi-integration", required=True) - ap.add_argument("--chunks-dir", help="Directory holding pure_eb_chunk_*.npz") - ap.add_argument("--chunks", nargs="+", help="Explicit chunk .npz paths") - ap.add_argument("--min-sep", type=float, default=1.0) - ap.add_argument("--max-sep", type=float, default=250.0) - ap.add_argument("--nbins", type=int, default=20) - ap.add_argument("--min-sep-int", type=float, default=0.5) - ap.add_argument("--max-sep-int", type=float, default=300.0) - ap.add_argument("--nbins-int", type=int, default=1000) - ap.add_argument("--npatch", type=int, default=1) - ap.add_argument("--out", required=True, help="Output directory (lc {output})") - a = ap.parse_args(argv) - gather( - version=a.version, - xi_reporting=a.xi_reporting, - xi_integration=a.xi_integration, - chunk_files=_resolve_chunks(a), - min_sep=a.min_sep, - max_sep=a.max_sep, - nbins=a.nbins, - nbins_int=a.nbins_int, - output_dir=a.out, - ) - - -if __name__ == "__main__": - _from_cli() diff --git a/papers/bmodes/scripts/precompute_pure_eb_chunk.py b/papers/bmodes/scripts/precompute_pure_eb_chunk.py deleted file mode 100644 index 2db6bf92..00000000 --- a/papers/bmodes/scripts/precompute_pure_eb_chunk.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Compute one chunk of MC samples for the pure E/B covariance. - -CLI refactor of the former Snakemake ``script:`` rule. The compute is -unchanged: draw ``n_samples // n_chunks`` Gaussian realisations of ξ±(θ) from -the 1000-bin integration-grid CosmoCov covariance (deterministic seed -``42 + chunk_id``), rebin to the reporting grid, and push each draw through the -Schneider-2022 pure-mode integral transforms (``cosmo_numba``). The per-chunk -E/B/amb sample block is written to ``{out}/pure_eb_chunk_{chunk_id}.npz`` for -the gather stage. Each chunk is independent (fresh RNG per chunk_id), so the -20 chunks reproduce the paper's 2000-sample covariance bit-for-bit whether run -in parallel or looped in one process. - - python precompute_pure_eb_chunk.py \ - --chunk-id 0 --n-chunks 20 --n-samples 2000 \ - --version SP_v1.4.6.3_leak_corr \ - --cat-config /path/cosmo_val/cat_config.yaml \ - --xi-reporting \ - --xi-integration \ - --cov-integration \ - --min-sep 1.0 --max-sep 250.0 --nbins 20 \ - --min-sep-int 0.5 --max-sep-int 300.0 --nbins-int 1000 \ - --npatch 1 --out -""" - -import argparse -import os - -import numpy as np -import tqdm -from scipy import sparse - - -def _build_cosmology(cosmo_params): - """Build a CCL cosmology from a PLANCK18-style params dict.""" - import pyccl as ccl - - return ccl.Cosmology( - Omega_c=cosmo_params["Omega_m"] - cosmo_params["Omega_b"], - Omega_b=cosmo_params["Omega_b"], - h=cosmo_params["h"], - sigma8=cosmo_params["sigma_8"], - n_s=cosmo_params["n_s"], - ) - - -def _load_xi(path, min_sep, max_sep, nbins): - """Load ξ± from a TreeCorr text dump and recompute the log bin edges.""" - data = np.loadtxt(path, comments="#", max_rows=nbins) - meanr = data[:, 1] - xip = data[:, 3] - xim = data[:, 4] - bin_edges = np.logspace(np.log10(min_sep), np.log10(max_sep), nbins + 1) - return { - "meanr": meanr, - "xip": xip, - "xim": xim, - "left_edges": bin_edges[:-1], - "right_edges": bin_edges[1:], - } - - -def compute_chunk( - chunk_id, - n_chunks, - n_samples_total, - version, - cat_config, - xi_reporting, - xi_integration, - cov_integration, - min_sep, - max_sep, - nbins, - min_sep_int, - max_sep_int, - nbins_int, - output_dir, - cosmo_params=None, -): - from cosmo_numba.B_modes.schneider2022 import get_pure_EB_modes - from cs_util.cosmo import PLANCK18, get_theo_xi - - from sp_validation.cosmo_val import CosmologyValidation - - if cosmo_params is None: - cosmo_params = dict(PLANCK18) - - samples_per_chunk = n_samples_total // n_chunks - start_idx = chunk_id * samples_per_chunk - end_idx = ( - start_idx + samples_per_chunk if chunk_id < n_chunks - 1 else n_samples_total - ) - n_samples_chunk = end_idx - start_idx - print( - f"Chunk {chunk_id}/{n_chunks}: samples {start_idx}-{end_idx} " - f"({n_samples_chunk} samples)" - ) - - gg = _load_xi(xi_reporting, min_sep, max_sep, nbins) - gg_int = _load_xi(xi_integration, min_sep_int, max_sep_int, nbins_int) - - cv = CosmologyValidation( - versions=[version], - catalog_config=cat_config, - output_dir=output_dir, - ) - z, nz = cv.get_redshift(version) - z_dist = np.column_stack([z, nz]) - - cosmo_cov = _build_cosmology(cosmo_params) - - cov_int = np.loadtxt(cov_integration) - - theta_int = gg_int["meanr"] - reporting_bin_edges = np.concatenate([gg["left_edges"], [gg["right_edges"][-1]]]) - bin_indices = np.digitize(theta_int, reporting_bin_edges) - 1 - valid_mask = (bin_indices >= 0) & (bin_indices < len(gg["meanr"])) - row_indices, col_indices = bin_indices[valid_mask], np.where(valid_mask)[0] - - binning_matrix = sparse.csr_matrix( - (np.ones(len(row_indices)), (row_indices, col_indices)), - shape=(len(gg["meanr"]), nbins_int), - ) - row_sums = np.array(binning_matrix.sum(axis=1)).flatten() - binning_matrix = sparse.diags(1 / row_sums) @ binning_matrix - - # One n(z) gives one tracer pair: get_theo_xi's single (xi+, xi-) entry. - (xi_pm,) = get_theo_xi( - theta=theta_int, - z=z_dist[:, 0], - nz=z_dist[:, 1], - backend="ccl", - cosmo=cosmo_cov, - ).values() - mean_int = np.concatenate(xi_pm) - - rng = np.random.default_rng(seed=42 + chunk_id) - - samples_int = rng.multivariate_normal(mean_int, cov_int, size=n_samples_chunk) - samples_int_xip = samples_int[:, :nbins_int] - samples_int_xim = samples_int[:, nbins_int:] - samples_rep_xip = (binning_matrix @ samples_int_xip.T).T - samples_rep_xim = (binning_matrix @ samples_int_xim.T).T - - transformed_samples = [ - np.concatenate( - get_pure_EB_modes( - theta=gg["meanr"], - theta_int=gg_int["meanr"], - xip=samples_rep_xip[i], - xim=samples_rep_xim[i], - xip_int=samples_int_xip[i], - xim_int=samples_int_xim[i], - tmin=min_sep, - tmax=max_sep, - ) - ) - for i in tqdm.tqdm(range(n_samples_chunk), desc=f"Chunk {chunk_id}") - ] - - eb_samples = np.array(transformed_samples) - - os.makedirs(output_dir, exist_ok=True) - out_path = os.path.join(output_dir, f"pure_eb_chunk_{chunk_id}.npz") - np.savez(out_path, eb_samples=eb_samples, chunk_id=chunk_id) - print(f"Saved {n_samples_chunk} samples to {out_path}") - return out_path - - -def _from_cli(argv=None): - ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) - ap.add_argument("--chunk-id", type=int, required=True) - ap.add_argument("--n-chunks", type=int, default=20) - ap.add_argument("--n-samples", type=int, default=2000) - ap.add_argument("--version", required=True) - ap.add_argument("--cat-config", required=True) - ap.add_argument("--xi-reporting", required=True) - ap.add_argument("--xi-integration", required=True) - ap.add_argument("--cov-integration", required=True) - ap.add_argument("--min-sep", type=float, default=1.0) - ap.add_argument("--max-sep", type=float, default=250.0) - ap.add_argument("--nbins", type=int, default=20) - ap.add_argument("--min-sep-int", type=float, default=0.5) - ap.add_argument("--max-sep-int", type=float, default=300.0) - ap.add_argument("--nbins-int", type=int, default=1000) - ap.add_argument("--npatch", type=int, default=1) - ap.add_argument("--out", required=True, help="Output directory (lc {output})") - a = ap.parse_args(argv) - compute_chunk( - chunk_id=a.chunk_id, - n_chunks=a.n_chunks, - n_samples_total=a.n_samples, - version=a.version, - cat_config=a.cat_config, - xi_reporting=a.xi_reporting, - xi_integration=a.xi_integration, - cov_integration=a.cov_integration, - min_sep=a.min_sep, - max_sep=a.max_sep, - nbins=a.nbins, - min_sep_int=a.min_sep_int, - max_sep_int=a.max_sep_int, - nbins_int=a.nbins_int, - output_dir=a.out, - ) - - -if __name__ == "__main__": - _from_cli() diff --git a/papers/bmodes/scripts/pure_eb_covariance.py b/papers/bmodes/scripts/pure_eb_covariance.py index cb137c62..08898968 100644 --- a/papers/bmodes/scripts/pure_eb_covariance.py +++ b/papers/bmodes/scripts/pure_eb_covariance.py @@ -226,8 +226,7 @@ def _from_cli(argv=None): ap.add_argument( "--pure-eb-data", required=True, - help="Fiducial _pure_eb_semianalytic.npz " - "(provides the 6-block cov_pure_eb)", + help="Fiducial _pure_eb.npz (provides the 6-block cov_pure_eb)", ) ap.add_argument("--out", required=True, help="Output directory (lc {output})") a = ap.parse_args(argv) diff --git a/papers/bmodes/scripts/pure_eb_data_vector.py b/papers/bmodes/scripts/pure_eb_data_vector.py index d7845361..d79060cc 100644 --- a/papers/bmodes/scripts/pure_eb_data_vector.py +++ b/papers/bmodes/scripts/pure_eb_data_vector.py @@ -7,7 +7,7 @@ CLI: python pure_eb_data_vector.py \ --config config.yaml \ - --pure-eb-data _pure_eb_semianalytic.npz \ + --pure-eb-data _pure_eb.npz \ --reporting-cov /covariance_processed.txt \ --out """ @@ -34,7 +34,7 @@ def _extract_sigma(covariance, block_index, block_size): return np.sqrt(np.clip(np.diag(covariance[block_slice, block_slice]), 0, None)) -def _compute_joint_pte(xip_B, xim_B, cov_xip_B, cov_xim_B, cov_cross, n_samples=None): +def _compute_joint_pte(xip_B, xim_B, cov_xip_B, cov_xim_B, cov_cross): """Compute joint PTE for combined B-mode data vector [xip_B, xim_B].""" data_joint = np.concatenate([xip_B, xim_B]) n_xip, n_xim = len(xip_B), len(xim_B) @@ -45,12 +45,12 @@ def _compute_joint_pte(xip_B, xim_B, cov_xip_B, cov_xim_B, cov_cross, n_samples= cov_joint[:n_xip, n_xip:] = cov_cross cov_joint[n_xip:, :n_xip] = cov_cross.T - chi2, pte, dof = compute_chi2_pte(data_joint, cov_joint, n_samples=n_samples) + chi2, pte, dof = compute_chi2_pte(data_joint, cov_joint) return pte, chi2, dof def _load_pure_eb_data(pure_eb_path, cov_path): - """Load pure E/B decomposition, the 6-block MC covariance, and the + """Load pure E/B decomposition, the 6-block covariance, and the reporting-grid CosmoCov ξ± covariance used for the total-curve error bars.""" dataset = np.load(pure_eb_path) theta = dataset["theta"] @@ -241,9 +241,6 @@ def main(config, pure_eb_path, cov_path, out_dir): cov_pure_eb = data["cov_pure_eb"] xip_B, xim_B = data["xip_B"], data["xim_B"] - # Hartlap correction: MC-propagated covariance uses n_samples from config - n_samples = int(config["covariance"]["n_samples"]) - # Extract B-mode covariance blocks cov_xip_B_full = cov_pure_eb[2 * nbins : 3 * nbins, 2 * nbins : 3 * nbins] cov_xim_B_full = cov_pure_eb[3 * nbins : 4 * nbins, 3 * nbins : 4 * nbins] @@ -264,10 +261,10 @@ def main(config, pure_eb_path, cov_path, out_dir): # Compute PTEs at fiducial scale cuts chi2_xip_fid, pte_xip_fid, dof_xip_fid = compute_chi2_pte( - xip_B[mask_xip], cov_xip_B_cut, n_samples=n_samples + xip_B[mask_xip], cov_xip_B_cut ) chi2_xim_fid, pte_xim_fid, dof_xim_fid = compute_chi2_pte( - xim_B[mask_xim], cov_xim_B_cut, n_samples=n_samples + xim_B[mask_xim], cov_xim_B_cut ) pte_joint_fid, chi2_joint_fid, dof_joint_fid = _compute_joint_pte( xip_B[mask_xip], @@ -275,19 +272,17 @@ def main(config, pure_eb_path, cov_path, out_dir): cov_xip_B_cut, cov_xim_B_cut, cov_cross_cut, - n_samples=n_samples, ) # Compute PTEs at full range - _, pte_xip_full, _ = compute_chi2_pte(xip_B, cov_xip_B_full, n_samples=n_samples) - _, pte_xim_full, _ = compute_chi2_pte(xim_B, cov_xim_B_full, n_samples=n_samples) + _, pte_xip_full, _ = compute_chi2_pte(xip_B, cov_xip_B_full) + _, pte_xim_full, _ = compute_chi2_pte(xim_B, cov_xim_B_full) pte_joint_full, chi2_joint_full, dof_joint_full = _compute_joint_pte( xip_B, xim_B, cov_xip_B_full, cov_xim_B_full, cov_cross_full, - n_samples=n_samples, ) print( @@ -338,8 +333,7 @@ def _from_cli(argv=None): ap.add_argument( "--pure-eb-data", required=True, - help="Fiducial _pure_eb_semianalytic.npz " - "(decomposed ξ± + 6-block MC covariance)", + help="Fiducial _pure_eb.npz (decomposed ξ± + 6-block covariance)", ) ap.add_argument( "--reporting-cov", diff --git a/papers/bmodes/scripts/pure_eb_modes.py b/papers/bmodes/scripts/pure_eb_modes.py new file mode 100644 index 00000000..f57f7ad9 --- /dev/null +++ b/papers/bmodes/scripts/pure_eb_modes.py @@ -0,0 +1,98 @@ +"""Pure E/B modes and their exact covariance for one version. + +Reads the fine-grid ξ± (TreeCorr text dump, with its pair counts) and the +Gaussian ξ± covariance on the same grid, and applies +``sp_validation.b_modes.calculate_pure_eb_correlation``: the fixed-operator +pure-E/B estimator averaged into the reporting bins, with covariance +``K C_ξ Kᵀ``. Writes ``_pure_eb.npz``, the input of every downstream +pure-mode plot / PTE. + + python pure_eb_modes.py \ + --xi-integration \ + --cov-integration \ + --min-sep 1.0 --max-sep 250.0 --nbins 20 --nbins-int 1000 \ + --out _pure_eb.npz +""" + +import argparse +import os + +import numpy as np + +from sp_validation.b_modes import calculate_pure_eb_correlation, log_bin_edges +from sp_validation.sacc_io import PURE_KEYS + + +def _load_xi(path, nbins): + """``(meanr, xip, xim, npairs)`` from a TreeCorr text dump. + + TreeCorr's ASCII header is + r_nom meanr meanlogr xip xim xip_im xim_im sigma_xip sigma_xim weight npairs. + """ + data = np.loadtxt(path, comments="#", max_rows=nbins) + return data[:, 1], data[:, 3], data[:, 4], data[:, 10] + + +def pure_eb_modes( + xi_integration, cov_integration, min_sep, max_sep, nbins, nbins_int, out_path +): + results = calculate_pure_eb_correlation( + *_load_xi(xi_integration, nbins_int), + np.loadtxt(cov_integration), + *log_bin_edges(min_sep, max_sep, nbins), + ) + package = { + "theta": results["theta"], + "theta_int": results["theta_int"], + "xip_total": results["xip"], + "xim_total": results["xim"], + **{key: results[key] for key in PURE_KEYS}, + "cov_pure_eb": results["cov"], + } + os.makedirs(os.path.dirname(out_path) or ".", exist_ok=True) + np.savez(out_path, **package) + print(f"Saved pure E/B to {out_path}") + return out_path + + +def _from_snakemake(smk): + p = smk.params + pure_eb_modes( + smk.input["xi_integration"], + smk.input["cov_integration"], + p["min_sep"], + p["max_sep"], + p["nbins"], + p["nbins_int"], + smk.output[0], + ) + + +def _from_cli(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--xi-integration", required=True) + ap.add_argument("--cov-integration", required=True) + ap.add_argument("--min-sep", type=float, default=1.0) + ap.add_argument("--max-sep", type=float, default=250.0) + ap.add_argument("--nbins", type=int, default=20) + ap.add_argument("--nbins-int", type=int, default=1000) + ap.add_argument("--out", required=True, help="Output .npz path") + a = ap.parse_args(argv) + pure_eb_modes( + a.xi_integration, + a.cov_integration, + a.min_sep, + a.max_sep, + a.nbins, + a.nbins_int, + a.out, + ) + + +if __name__ == "__main__": + try: + snakemake # noqa: F821 — injected by Snakemake's script: directive + except NameError: + _from_cli() + else: + _from_snakemake(snakemake) # noqa: F821 diff --git a/papers/bmodes/scripts/pure_eb_version_comparison.py b/papers/bmodes/scripts/pure_eb_version_comparison.py index 3e99e68b..6f20c054 100644 --- a/papers/bmodes/scripts/pure_eb_version_comparison.py +++ b/papers/bmodes/scripts/pure_eb_version_comparison.py @@ -243,7 +243,7 @@ def _create_version_comparison_figure( def _pure_eb_npz(results_dir, ver): - return f"{results_dir}/{ver}_pure_eb_semianalytic.npz" + return f"{results_dir}/{ver}_pure_eb.npz" def main( @@ -472,7 +472,7 @@ def _from_cli(argv=None): ap.add_argument( "--results-dir", required=True, - help="Directory holding per-version _pure_eb_semianalytic.npz files", + help="Directory holding per-version _pure_eb.npz files", ) ap.add_argument("--out", required=True, help="Output directory (lc {output})") ap.add_argument( From f53d43dc75f3e364de6a7471fc89dd4ff9cddecf Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 04:52:23 +0200 Subject: [PATCH 04/10] cv_pure_eb: describe the covariance input without naming its source Co-Authored-By: Claude Opus 5.5 --- workflow/rules/cosmo_val.smk | 2 +- workflow/scripts/cv_pure_eb.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/workflow/rules/cosmo_val.smk b/workflow/rules/cosmo_val.smk index 898e9ab4..67cd8791 100644 --- a/workflow/rules/cosmo_val.smk +++ b/workflow/rules/cosmo_val.smk @@ -398,7 +398,7 @@ rule cv_pure_eb: """Pure E/B-mode decomposition for one version, from its integration-grid part. The modes are a fixed linear operator on the part's ξ±; the covariance is - the CosmoCov ξ± covariance on the same grid pushed exactly through it. + the integration-grid ξ± covariance pushed exactly through it. """ input: xi_integration=lambda w: cv_xi_sacc(w.version, "integration"), diff --git a/workflow/scripts/cv_pure_eb.py b/workflow/scripts/cv_pure_eb.py index b22a3f0e..1debd7bc 100644 --- a/workflow/scripts/cv_pure_eb.py +++ b/workflow/scripts/cv_pure_eb.py @@ -1,9 +1,9 @@ """Rule cv_pure_eb: pure E/B-mode decomposition for one version. -A consumer of the integration-grid ξ± part plus its analytic covariance — +A consumer of the integration-grid ξ± part plus a ξ± covariance on that grid — nothing here touches a catalogue. The estimator is one fixed linear operator on the fine ξ± (b_modes.pure_eb_operator), averaged into the reporting bins with -the part's pair counts, so its covariance is the CosmoCov ξ± covariance pushed +the part's pair counts, so its covariance is the supplied ξ± covariance pushed exactly through that operator. """ From e6fa8e2670657e42936fb5772c7af7c3fccbba06 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 05:00:55 +0200 Subject: [PATCH 05/10] Pure E/B: average fine bins with TreeCorr's pair weight, not npairs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TreeCorr's ξ± and meanr in a bin are averages over pairs weighted by w_i w_j, so pooling fine bins with their `weight` reproduces the reporting-bin measurement exactly when the fine edges nest the reporting edges; npairs does so only for an unweighted catalogue. The operator, calculate_pure_eb_correlation, the mixin, cv_pure_eb and pure_eb_modes.py take `weight_int`; sacc_io.get_xi_npairs becomes get_xi_weight (parts already carry the tag). A new test checks the nesting identity on a weighted catalogue with exact binning; the fixture and operator pins are regenerated. Co-Authored-By: Claude Opus 5.5 --- papers/bmodes/scripts/pure_eb_modes.py | 6 +- src/sp_validation/b_modes.py | 53 ++++--- src/sp_validation/cosmo_val/pure_eb.py | 2 +- src/sp_validation/sacc_io.py | 6 +- src/sp_validation/tests/conftest.py | 2 +- .../tests/data/pure_eb_xi_fixture.npz | Bin 20818 -> 20818 bytes src/sp_validation/tests/test_b_modes.py | 148 ++++++++++++------ src/sp_validation/tests/test_cosmo_val.py | 4 +- src/sp_validation/tests/test_sacc_io.py | 2 +- workflow/scripts/cv_pure_eb.py | 6 +- 10 files changed, 138 insertions(+), 91 deletions(-) diff --git a/papers/bmodes/scripts/pure_eb_modes.py b/papers/bmodes/scripts/pure_eb_modes.py index f57f7ad9..6218db28 100644 --- a/papers/bmodes/scripts/pure_eb_modes.py +++ b/papers/bmodes/scripts/pure_eb_modes.py @@ -1,6 +1,6 @@ """Pure E/B modes and their exact covariance for one version. -Reads the fine-grid ξ± (TreeCorr text dump, with its pair counts) and the +Reads the fine-grid ξ± (TreeCorr text dump, with its pair weights) and the Gaussian ξ± covariance on the same grid, and applies ``sp_validation.b_modes.calculate_pure_eb_correlation``: the fixed-operator pure-E/B estimator averaged into the reporting bins, with covariance @@ -24,13 +24,13 @@ def _load_xi(path, nbins): - """``(meanr, xip, xim, npairs)`` from a TreeCorr text dump. + """``(meanr, xip, xim, weight)`` from a TreeCorr text dump. TreeCorr's ASCII header is r_nom meanr meanlogr xip xim xip_im xim_im sigma_xip sigma_xim weight npairs. """ data = np.loadtxt(path, comments="#", max_rows=nbins) - return data[:, 1], data[:, 3], data[:, 4], data[:, 10] + return data[:, 1], data[:, 3], data[:, 4], data[:, 9] def pure_eb_modes( diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index 039c111d..5662ffae 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -129,23 +129,25 @@ def hartlap_factor(npatch, dof): return 1.0 if npatch is None else (npatch - dof - 2) / (npatch - 1) -def _npairs_binning_matrix(theta_int, npairs_int, left_edges, right_edges): - """Pair-count weighted average from the fine grid into the reporting bins. +def _weight_binning_matrix(theta_int, weight_int, left_edges, right_edges): + """Pair-weighted average from the fine grid into the reporting bins. Row ``i`` of the ``(n_report, n_fine)`` result weights the fine nodes whose - ``theta_int`` falls in reporting bin ``i`` by their pair counts (Asgari et - al. 2019, Appendix A) and sums to one. Nodes outside the reporting range - or with no pairs get zero weight. + ``theta_int`` falls in reporting bin ``i`` by their TreeCorr pair weight + ``Σ w_i w_j`` and sums to one. TreeCorr's ξ± and ``meanr`` are averages + over pairs under that same weight, so when the fine bin edges nest the + reporting edges a row reproduces the reporting-bin measurement. Nodes + outside the reporting range or with zero weight get none. """ theta_int = np.asarray(theta_int, dtype=float) - npairs_int = np.asarray(npairs_int, dtype=float) - if npairs_int.shape != theta_int.shape: - raise ValueError("npairs_int must have one entry per integration bin") + weight_int = np.asarray(weight_int, dtype=float) + if weight_int.shape != theta_int.shape: + raise ValueError("weight_int must have one entry per integration bin") n_report = len(left_edges) rows = np.digitize(theta_int, np.append(left_edges, right_edges[-1])) - 1 - inside = (rows >= 0) & (rows < n_report) & (npairs_int > 0) + inside = (rows >= 0) & (rows < n_report) & (weight_int > 0) binning = np.zeros((n_report, theta_int.size)) - binning[rows[inside], np.flatnonzero(inside)] = npairs_int[inside] + binning[rows[inside], np.flatnonzero(inside)] = weight_int[inside] weight = binning.sum(axis=1) if np.any(weight == 0): empty = np.flatnonzero(weight == 0).tolist() @@ -184,14 +186,14 @@ def _fixed_quadrature_operator(theta_eval, theta_int): return np.vstack([operator["matrices"][key] for key in _EB_KEYS]) -def pure_eb_operator(theta_int, npairs_int, left_edges, right_edges): +def pure_eb_operator(theta_int, weight_int, left_edges, right_edges): """The pure-E/B estimator as one matrix on the fine ξ± grid. The Schneider et al. (2022) transform is evaluated with fixed-quadrature weights at the fine-grid nodes inside the reporting range, integrating over the whole fine grid, and the six pure modes are then averaged into the - reporting bins with pair-count weights. Both steps are linear and - data-independent, so the estimator is ``K = (I_6 ⊗ P) · M`` and + reporting bins with TreeCorr pair weights. Both steps are linear and + independent of the ξ± values, so the estimator is ``K = (I_6 ⊗ P) · M`` and [xip_E; xim_E; xip_B; xim_B; xip_amb; xim_amb] = K @ [xip_int; xim_int] @@ -203,8 +205,9 @@ def pure_eb_operator(theta_int, npairs_int, left_edges, right_edges): Fine (integration) grid, ascending and log-spaced — TreeCorr ``meanr``. The transform's ``[tmin, tmax]`` is its extent, so it must reach beyond the reporting range on both sides. - npairs_int : array_like - Pair counts on the fine grid, the averaging weights. + weight_int : array_like + TreeCorr pair weight ``Σ w_i w_j`` per fine bin (``gg.weight``), the + averaging weights. left_edges, right_edges : array_like Reporting-bin edges. @@ -213,10 +216,10 @@ def pure_eb_operator(theta_int, npairs_int, left_edges, right_edges): operator : numpy.ndarray ``K``, shape ``(6 * n_report, 2 * n_fine)``. binning : numpy.ndarray - ``P``, the ``(n_report, n_fine)`` pair-count average. + ``P``, the ``(n_report, n_fine)`` pair-weighted average. """ theta_int = np.asarray(theta_int, dtype=float) - binning = _npairs_binning_matrix(theta_int, npairs_int, left_edges, right_edges) + binning = _weight_binning_matrix(theta_int, weight_int, left_edges, right_edges) nodes = np.flatnonzero(binning.any(axis=0)) transform = _fixed_quadrature_operator(theta_int[nodes], theta_int) n_nodes = nodes.size @@ -233,7 +236,7 @@ def calculate_pure_eb_correlation( theta_int, xip_int, xim_int, - npairs_int, + weight_int, cov_xi, left_edges, right_edges, @@ -250,13 +253,13 @@ def calculate_pure_eb_correlation( them (:func:`hartlap_factor`). The reporting-bin ``theta``, ``xip``/``xim`` and their variances are the - same pair-count average of the fine grid, so ``xi_± = E ± B + amb`` holds + same pair-weighted average of the fine grid, so ``xi_± = E ± B + amb`` holds bin by bin. Parameters ---------- - theta_int, xip_int, xim_int, npairs_int : array_like - Fine-grid ``meanr``, ξ±, and pair counts. + theta_int, xip_int, xim_int, weight_int : array_like + Fine-grid ``meanr``, ξ±, and TreeCorr pair weight ``Σ w_i w_j``. cov_xi : array_like ``(2 n_fine, 2 n_fine)`` covariance of ``[xip_int; xim_int]``. left_edges, right_edges : array_like @@ -271,7 +274,7 @@ def calculate_pure_eb_correlation( order), ``npatch``, the reporting grid (``theta``, ``left_edges``, ``right_edges``, ``xip``, ``xim``, ``var_xip``, ``var_xim``) and the fine-grid inputs (``theta_int``, ``xip_int``, ``xim_int``, - ``npairs_int``). + ``weight_int``). """ if npatch is not None and npatch < 2: raise ValueError(f"a jackknife covariance needs npatch > 1, not {npatch}") @@ -279,7 +282,7 @@ def calculate_pure_eb_correlation( np.asarray(a, dtype=float) for a in (theta_int, xip_int, xim_int) ) cov_xi = np.asarray(cov_xi, dtype=float) - operator, binning = pure_eb_operator(theta_int, npairs_int, left_edges, right_edges) + operator, binning = pure_eb_operator(theta_int, weight_int, left_edges, right_edges) if cov_xi.shape != (operator.shape[1],) * 2: raise ValueError( f"cov_xi has shape {cov_xi.shape}; the fine grid needs " @@ -304,7 +307,7 @@ def calculate_pure_eb_correlation( "theta_int": theta_int, "xip_int": xip_int, "xim_int": xim_int, - "npairs_int": np.asarray(npairs_int, dtype=float), + "weight_int": np.asarray(weight_int, dtype=float), "cov": operator @ cov_xi @ operator.T, "npatch": npatch, } @@ -554,7 +557,7 @@ def plot_integration_vs_reporting(results, output_path, version): ---------- results : dict Pure E/B results carrying the fine grid (``theta_int``/``xip_int``/ - ``xim_int``) and its pair-count average into the reporting bins + ``xim_int``) and its pair-weighted average into the reporting bins (``theta``/``xip``/``xim``, with the ``var_xip``/``var_xim`` the error bars use) output_path : str diff --git a/src/sp_validation/cosmo_val/pure_eb.py b/src/sp_validation/cosmo_val/pure_eb.py index 4ac05aeb..e03e1697 100644 --- a/src/sp_validation/cosmo_val/pure_eb.py +++ b/src/sp_validation/cosmo_val/pure_eb.py @@ -86,7 +86,7 @@ def calculate_pure_eb( gg_int.meanr, gg_int.xip, gg_int.xim, - gg_int.npairs, + gg_int.weight, cov_xi, left_edges, right_edges, diff --git a/src/sp_validation/sacc_io.py b/src/sp_validation/sacc_io.py index fc1f9bbb..ff11db0b 100644 --- a/src/sp_validation/sacc_io.py +++ b/src/sp_validation/sacc_io.py @@ -580,9 +580,9 @@ def get_xi(s, bins, *, grid): return _get_pm(s, XI_PLUS, XI_MINUS, _pair(bins), grid=grid) -def get_xi_npairs(s, bins, *, grid): - """Return the TreeCorr pair counts stored with :func:`add_xi`'s ξ+ points.""" - return _tag(s, XI_PLUS, _pair(bins), "npairs", grid=grid) +def get_xi_weight(s, bins, *, grid): + """Return the TreeCorr pair weights stored with :func:`add_xi`'s ξ+ points.""" + return _tag(s, XI_PLUS, _pair(bins), "weight", grid=grid) def get_pseudo_cl(s, bins): diff --git a/src/sp_validation/tests/conftest.py b/src/sp_validation/tests/conftest.py index 30f4edd9..f3d4a952 100644 --- a/src/sp_validation/tests/conftest.py +++ b/src/sp_validation/tests/conftest.py @@ -12,7 +12,7 @@ def pure_eb_xi(): """Committed fine-grid ξ± of the synthetic coherent-shear catalogue. - Exact-binning integration grid [1, 300]′ in 600 bins with its pair counts, + Exact-binning integration grid [1, 300]′ in 600 bins with its pair weights, and the edges of a [15, 70]′ reporting grid in 6 bins, keyed by ``b_modes.calculate_pure_eb_correlation``'s parameters. """ diff --git a/src/sp_validation/tests/data/pure_eb_xi_fixture.npz b/src/sp_validation/tests/data/pure_eb_xi_fixture.npz index 755bccd18d8099d916343bc6cf7152b6f2c2b19f..38ad9c2598729734dfc4754870db76e18e7f7dee 100644 GIT binary patch delta 16898 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 S26ts?XJ~Yje^3~c(@=j*7(iYC literal 20818 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 a!hg>aduuA{e{!g|{`qe0#h#u2sQ(YE3`Z3J diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index ef4a1230..a0651dfe 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -324,52 +324,52 @@ def test_calculate_eb_statistics_has_teeth(): # estimator is meant to move. _PURE_EB_PINS = { "xip_E": [ - 0.000126760298350234, - 0.00011658330412818888, - 0.00011288505914719275, - 0.0001134165787303342, - 9.413529992584958e-05, - 8.504614832216474e-05, + 0.0001267927403538086, + 0.00011661471866769028, + 0.00011285998097487562, + 0.00011342826992976761, + 9.413729942634515e-05, + 8.504628509885555e-05, ], "xim_E": [ - 7.835778225465925e-06, - -3.2941169447535557e-08, - 1.258394911549768e-06, - 5.9168056533555394e-06, - -5.7480349079948186e-06, - 5.3046595852565625e-06, + 7.84636379789262e-06, + -2.1854362709680197e-08, + 1.2476650697806643e-06, + 5.931628899598654e-06, + -5.748552740802833e-06, + 5.307121527471898e-06, ], "xip_B": [ - -5.883756312983891e-05, - -5.4151696474192125e-05, - -7.033237297030058e-05, - -6.463826834039258e-05, - -6.439549548932501e-05, - -5.781563569551224e-05, + -5.890048396074994e-05, + -5.413134881293027e-05, + -7.033477076572411e-05, + -6.464207645137761e-05, + -6.438127715272536e-05, + -5.781687325365341e-05, ], "xim_B": [ - -1.238817586945211e-05, - 1.138850799409577e-06, - 3.7912186858520153e-06, - 9.138091322053387e-06, - 5.4865624360288735e-06, - 4.457794156886124e-06, + -1.2460349217324633e-05, + 1.1641684220790763e-06, + 3.7838279437020602e-06, + 9.134846190559186e-06, + 5.499212937161553e-06, + 4.460746452672484e-06, ], "xip_amb": [ - 8.984508946714618e-05, - 8.879513749174612e-05, - 8.704203334235309e-05, - 8.410988894408256e-05, - 7.923019633199604e-05, - 7.107159007248638e-05, + 8.984494180845635e-05, + 8.879536535671083e-05, + 8.704153035693623e-05, + 8.410960918777171e-05, + 7.923091774259722e-05, + 7.107059682711236e-05, ], "xim_amb": [ - 1.5110099529260723e-06, - 9.05896609235364e-07, - 5.429748754312615e-07, - 3.250845464381953e-07, - 1.9494837233251687e-07, - 1.1676980700188147e-07, + 1.5108576414191464e-06, + 9.059561894981808e-07, + 5.429016867191619e-07, + 3.250730808381593e-07, + 1.9496276632352545e-07, + 1.1676364182463817e-07, ], } @@ -394,9 +394,9 @@ def test_pure_eb_reproduces_pins_on_committed_xi(pure_eb_xi): for key in b_modes._EB_KEYS: npt.assert_allclose(results[key], _PURE_EB_PINS[key], rtol=1e-8, err_msg=key) - # Teeth: uniform rather than pair-count weights leave the pins. + # Teeth: uniform rather than pair weights leave the pins. moved = b_modes.calculate_pure_eb_correlation( - **{**pure_eb_xi, "npairs_int": np.ones(n_fine)}, cov_xi=np.eye(2 * n_fine) + **{**pure_eb_xi, "weight_int": np.ones(n_fine)}, cov_xi=np.eye(2 * n_fine) ) assert not np.allclose(moved["xip_E"], _PURE_EB_PINS["xip_E"], rtol=1e-6, atol=0) @@ -405,7 +405,7 @@ def test_pure_eb_modes_sum_to_the_averaged_xi(pure_eb_xi): """ξ± = E ± B + amb holds in every reporting bin. It holds at each fine node by construction of the decomposition, and the - modes and the reported ξ± are the same pair-count average of those nodes. + modes and the reported ξ± are the same pair-weighted average of those nodes. """ n_fine = len(pure_eb_xi["theta_int"]) r = b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=np.eye(2 * n_fine)) @@ -421,7 +421,7 @@ def test_pure_eb_modes_sum_to_the_averaged_xi(pure_eb_xi): def test_pure_eb_covariance_is_the_operator_sandwich(pure_eb_xi): """``cov`` is K C Kᵀ for the supplied ξ± covariance, and records npatch. - The reported ξ± variances are the same pair-count average pushed through + The reported ξ± variances are the same pair-weighted average pushed through the ξ+ and ξ− blocks of C. """ n_fine = len(pure_eb_xi["theta_int"]) @@ -429,7 +429,7 @@ def test_pure_eb_covariance_is_the_operator_sandwich(pure_eb_xi): r = b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=40) K, P = b_modes.pure_eb_operator( pure_eb_xi["theta_int"], - pure_eb_xi["npairs_int"], + pure_eb_xi["weight_int"], pure_eb_xi["left_edges"], pure_eb_xi["right_edges"], ) @@ -443,30 +443,74 @@ def test_pure_eb_covariance_is_the_operator_sandwich(pure_eb_xi): b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=1) -def test_pure_eb_binning_is_a_pair_count_average(): - """Each reporting row averages its fine nodes with pair-count weights. +def test_pure_eb_binning_is_a_pair_weighted_average(): + """Each reporting row averages its fine nodes with TreeCorr pair weights. Rows sum to one; nodes outside the reporting range or without pairs carry - no weight; equal pair counts give the plain mean; an empty bin raises. + no weight; equal weights give the plain mean; an empty bin raises. """ theta = np.geomspace(1.0, 100.0, 40) - npairs = np.arange(1.0, 41.0) - npairs[20] = 0.0 + weight = np.arange(1.0, 41.0) + weight[20] = 0.0 left, right = b_modes.log_bin_edges(2.0, 50.0, 4) - P = b_modes._npairs_binning_matrix(theta, npairs, left, right) + P = b_modes._weight_binning_matrix(theta, weight, left, right) npt.assert_allclose(P.sum(axis=1), 1.0) assert np.all(P[:, (theta < 2.0) | (theta >= 50.0)] == 0) assert np.all(P[:, 20] == 0) - inside = (theta >= left[1]) & (theta < right[1]) & (npairs > 0) - npt.assert_allclose(P[1, inside], npairs[inside] / npairs[inside].sum()) + inside = (theta >= left[1]) & (theta < right[1]) & (weight > 0) + npt.assert_allclose(P[1, inside], weight[inside] / weight[inside].sum()) - flat = b_modes._npairs_binning_matrix(theta, np.ones(40), left, right) + flat = b_modes._weight_binning_matrix(theta, np.ones(40), left, right) inside = (theta >= left[0]) & (theta < right[0]) npt.assert_allclose(flat[0, inside], 1.0 / inside.sum()) with pytest.raises(ValueError, match="hold no integration-grid pairs"): - b_modes._npairs_binning_matrix(theta, np.zeros(40), left, right) + b_modes._weight_binning_matrix(theta, np.zeros(40), left, right) + + +def test_pure_eb_binning_reproduces_the_reporting_measurement(): + """P on nested fine bins gives TreeCorr's reporting-bin ξ± and meanr. + + TreeCorr's ξ± and meanr in a bin are averages over its pairs weighted by + ``w_i w_j``, so pooling fine bins whose edges nest the reporting edges with + the fine ``weight`` is the same sum. A weighted catalogue with exact + binning makes that hold to round-off; weighting by ``npairs`` instead + does not. + """ + treecorr = pytest.importorskip("treecorr") + + rng = np.random.default_rng(2024) + n_gal = 3000 + x, y = rng.uniform(0.0, 300.0, (2, n_gal)) + g1, g2 = 0.02 + 0.05 * rng.standard_normal((2, n_gal)) + cat = treecorr.Catalog(x=x, y=y, g1=g1, g2=g2, w=rng.uniform(0.2, 1.0, n_gal)) + + exact = {"bin_slop": 0, "angle_slop": 0} + reporting = treecorr.GGCorrelation(min_sep=15.0, max_sep=70.0, nbins=6, **exact) + # Eight fine bins per reporting bin, plus four on either side. + step = np.log(70.0 / 15.0) / 48 + fine = treecorr.GGCorrelation( + min_sep=15.0 * np.exp(-4 * step), + max_sep=70.0 * np.exp(4 * step), + nbins=56, + **exact, + ) + reporting.process(cat) + fine.process(cat) + + P = b_modes._weight_binning_matrix( + fine.meanr, fine.weight, reporting.left_edges, reporting.right_edges + ) + for key in ("xip", "xim", "meanr"): + npt.assert_allclose( + P @ getattr(fine, key), getattr(reporting, key), rtol=1e-10, err_msg=key + ) + + by_npairs = b_modes._weight_binning_matrix( + fine.meanr, fine.npairs, reporting.left_edges, reporting.right_edges + ) + assert not np.allclose(by_npairs @ fine.xip, reporting.xip, rtol=1e-6, atol=0) def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi): @@ -479,7 +523,7 @@ def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi) with pytest.raises(ValueError, match="under-determined"): b_modes.pure_eb_operator( theta_int, - pure_eb_xi["npairs_int"], + pure_eb_xi["weight_int"], *b_modes.log_bin_edges(theta_int[0], 70.0, 6), ) diff --git a/src/sp_validation/tests/test_cosmo_val.py b/src/sp_validation/tests/test_cosmo_val.py index d8ddbf46..8e32ddbc 100644 --- a/src/sp_validation/tests/test_cosmo_val.py +++ b/src/sp_validation/tests/test_cosmo_val.py @@ -630,7 +630,7 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) "theta_int", "xip_int", "xim_int", - "npairs_int", + "weight_int", "left_edges", "right_edges", ) @@ -642,7 +642,7 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) ) operator, _ = b_modes.pure_eb_operator( - *(measured[k] for k in ("theta_int", "npairs_int")), + *(measured[k] for k in ("theta_int", "weight_int")), measured["left_edges"], measured["right_edges"], ) diff --git a/src/sp_validation/tests/test_sacc_io.py b/src/sp_validation/tests/test_sacc_io.py index 064e8103..872a8198 100644 --- a/src/sp_validation/tests/test_sacc_io.py +++ b/src/sp_validation/tests/test_sacc_io.py @@ -100,7 +100,7 @@ def test_xi_roundtrip(tmp_path): assert np.array_equal(th, theta) assert np.array_equal(p, xip) assert np.array_equal(m, xim) - assert np.array_equal(sio.get_xi_npairs(s2, (0, 0), grid="reporting"), npairs) + assert np.array_equal(sio.get_xi_weight(s2, (0, 0), grid="reporting"), weight) # extra tags survive idx = s2.indices(sio.XI_PLUS, ("source_0", "source_0"), grid="reporting") tags = s2.data[idx[0]].tags diff --git a/workflow/scripts/cv_pure_eb.py b/workflow/scripts/cv_pure_eb.py index 1debd7bc..43b0fae9 100644 --- a/workflow/scripts/cv_pure_eb.py +++ b/workflow/scripts/cv_pure_eb.py @@ -3,7 +3,7 @@ A consumer of the integration-grid ξ± part plus a ξ± covariance on that grid — nothing here touches a catalogue. The estimator is one fixed linear operator on the fine ξ± (b_modes.pure_eb_operator), averaged into the reporting bins with -the part's pair counts, so its covariance is the supplied ξ± covariance pushed +the part's TreeCorr pair weights, so its covariance is the supplied ξ± covariance pushed exactly through that operator. """ @@ -31,14 +31,14 @@ part = sacc_io.load(snakemake.input["xi_integration"]) theta_int, xip_int, xim_int = sacc_io.get_xi(part, (0, 0), grid="integration") -npairs_int = sacc_io.get_xi_npairs(part, (0, 0), grid="integration") +weight_int = sacc_io.get_xi_weight(part, (0, 0), grid="integration") left_edges, right_edges = log_bin_edges(p["min_sep"], p["max_sep"], p["nbins"]) results = calculate_pure_eb_correlation( theta_int, xip_int, xim_int, - npairs_int, + weight_int, np.loadtxt(snakemake.input["cov_integration"]), left_edges, right_edges, From 69fb71b6b2bcafd8fa0357fb117b45fb826f65ea Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 05:14:13 +0200 Subject: [PATCH 06/10] Pure E/B: reporting bins as unions of fine bins Assigning fine bins to reporting bins by meanr sends a fine bin that straddles a reporting edge wholly to one side, so P cannot reproduce TreeCorr's pair average there. The library now takes the fine-grid edges, snaps each requested reporting edge to the nearest fine edge, pools whole fine bins, and returns the edges it used; the results' left/right edges, the pure-E/B npz and the B-mode summary carry those snapped edges. Edges that snap together or reach outside the fine grid raise. Callers pass the fine edges: the mixin from gg_int's edges, cv_pure_eb and pure_eb_modes.py from the integration grid spec. The reproduction test now covers requested edges that do not nest, measuring each snapped bin directly with exact binning. Co-Authored-By: Claude Opus 5.5 --- papers/bmodes/scripts/pure_eb_modes.py | 26 ++- src/sp_validation/b_modes.py | 112 ++++++---- src/sp_validation/cosmo_val/pure_eb.py | 14 +- src/sp_validation/tests/conftest.py | 6 +- .../tests/data/pure_eb_xi_fixture.npz | Bin 20818 -> 25592 bytes src/sp_validation/tests/test_b_modes.py | 204 ++++++++++-------- src/sp_validation/tests/test_cosmo_val.py | 20 +- workflow/rules/cosmo_val.smk | 4 +- workflow/scripts/cv_pure_eb.py | 12 +- workflow/scripts/cv_summarize_bmodes.py | 10 +- 10 files changed, 240 insertions(+), 168 deletions(-) diff --git a/papers/bmodes/scripts/pure_eb_modes.py b/papers/bmodes/scripts/pure_eb_modes.py index 6218db28..643fe12c 100644 --- a/papers/bmodes/scripts/pure_eb_modes.py +++ b/papers/bmodes/scripts/pure_eb_modes.py @@ -10,7 +10,8 @@ python pure_eb_modes.py \ --xi-integration \ --cov-integration \ - --min-sep 1.0 --max-sep 250.0 --nbins 20 --nbins-int 1000 \ + --min-sep 1.0 --max-sep 250.0 --nbins 20 \ + --min-sep-int 0.5 --max-sep-int 300.0 --nbins-int 1000 \ --out _pure_eb.npz """ @@ -19,7 +20,7 @@ import numpy as np -from sp_validation.b_modes import calculate_pure_eb_correlation, log_bin_edges +from sp_validation.b_modes import calculate_pure_eb_correlation from sp_validation.sacc_io import PURE_KEYS @@ -34,15 +35,26 @@ def _load_xi(path, nbins): def pure_eb_modes( - xi_integration, cov_integration, min_sep, max_sep, nbins, nbins_int, out_path + xi_integration, + cov_integration, + min_sep, + max_sep, + nbins, + min_sep_int, + max_sep_int, + nbins_int, + out_path, ): results = calculate_pure_eb_correlation( *_load_xi(xi_integration, nbins_int), + np.geomspace(min_sep_int, max_sep_int, nbins_int + 1), np.loadtxt(cov_integration), - *log_bin_edges(min_sep, max_sep, nbins), + np.geomspace(min_sep, max_sep, nbins + 1), ) package = { "theta": results["theta"], + "left_edges": results["left_edges"], + "right_edges": results["right_edges"], "theta_int": results["theta_int"], "xip_total": results["xip"], "xim_total": results["xim"], @@ -63,6 +75,8 @@ def _from_snakemake(smk): p["min_sep"], p["max_sep"], p["nbins"], + p["min_sep_int"], + p["max_sep_int"], p["nbins_int"], smk.output[0], ) @@ -75,6 +89,8 @@ def _from_cli(argv=None): ap.add_argument("--min-sep", type=float, default=1.0) ap.add_argument("--max-sep", type=float, default=250.0) ap.add_argument("--nbins", type=int, default=20) + ap.add_argument("--min-sep-int", type=float, default=0.5) + ap.add_argument("--max-sep-int", type=float, default=300.0) ap.add_argument("--nbins-int", type=int, default=1000) ap.add_argument("--out", required=True, help="Output .npz path") a = ap.parse_args(argv) @@ -84,6 +100,8 @@ def _from_cli(argv=None): a.min_sep, a.max_sep, a.nbins, + a.min_sep_int, + a.max_sep_int, a.nbins_int, a.out, ) diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index 5662ffae..07595216 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -129,30 +129,43 @@ def hartlap_factor(npatch, dof): return 1.0 if npatch is None else (npatch - dof - 2) / (npatch - 1) -def _weight_binning_matrix(theta_int, weight_int, left_edges, right_edges): - """Pair-weighted average from the fine grid into the reporting bins. - - Row ``i`` of the ``(n_report, n_fine)`` result weights the fine nodes whose - ``theta_int`` falls in reporting bin ``i`` by their TreeCorr pair weight - ``Σ w_i w_j`` and sums to one. TreeCorr's ξ± and ``meanr`` are averages - over pairs under that same weight, so when the fine bin edges nest the - reporting edges a row reproduces the reporting-bin measurement. Nodes - outside the reporting range or with zero weight get none. +def _reporting_binning(weight_int, edges_int, reporting_edges): + """Reporting bins as unions of fine bins, and their pair-weighted average. + + Each requested reporting edge snaps to the nearest fine edge (in log θ), so + every reporting bin is a whole number of fine bins. Row ``i`` of the + ``(n_report, n_fine)`` matrix weights the fine bins of reporting bin ``i`` + by their TreeCorr pair weight ``Σ w_i w_j`` and sums to one. TreeCorr's ξ± + and ``meanr`` are averages over pairs under that same weight, so each row + reproduces what TreeCorr would measure on the snapped bin. + + Returns ``(binning, edges)``, ``edges`` being the snapped reporting edges. """ - theta_int = np.asarray(theta_int, dtype=float) weight_int = np.asarray(weight_int, dtype=float) - if weight_int.shape != theta_int.shape: - raise ValueError("weight_int must have one entry per integration bin") - n_report = len(left_edges) - rows = np.digitize(theta_int, np.append(left_edges, right_edges[-1])) - 1 - inside = (rows >= 0) & (rows < n_report) & (weight_int > 0) - binning = np.zeros((n_report, theta_int.size)) - binning[rows[inside], np.flatnonzero(inside)] = weight_int[inside] + edges_int = np.asarray(edges_int, dtype=float) + reporting_edges = np.asarray(reporting_edges, dtype=float) + if edges_int.shape != (weight_int.size + 1,): + raise ValueError("edges_int must hold n_fine + 1 fine-grid edges") + if reporting_edges.min() < edges_int[0] or reporting_edges.max() > edges_int[-1]: + raise ValueError( + f"reporting edges [{reporting_edges.min()}, {reporting_edges.max()}] " + f"reach outside the fine grid [{edges_int[0]}, {edges_int[-1]}]" + ) + snap = np.abs(np.log(reporting_edges)[:, None] - np.log(edges_int)).argmin(axis=1) + if np.any(np.diff(snap) <= 0): + raise ValueError( + "reporting edges snap onto the same fine edge; the fine grid is too " + f"coarse for them: {reporting_edges.tolist()} -> " + f"{edges_int[snap].tolist()}" + ) + binning = np.zeros((snap.size - 1, weight_int.size)) + for i, (lo, hi) in enumerate(zip(snap[:-1], snap[1:])): + binning[i, lo:hi] = weight_int[lo:hi] weight = binning.sum(axis=1) if np.any(weight == 0): empty = np.flatnonzero(weight == 0).tolist() raise ValueError(f"reporting bins {empty} hold no integration-grid pairs") - return binning / weight[:, None] + return binning / weight[:, None], edges_int[snap] def _fixed_quadrature_operator(theta_eval, theta_int): @@ -186,14 +199,16 @@ def _fixed_quadrature_operator(theta_eval, theta_int): return np.vstack([operator["matrices"][key] for key in _EB_KEYS]) -def pure_eb_operator(theta_int, weight_int, left_edges, right_edges): +def pure_eb_operator(theta_int, weight_int, edges_int, reporting_edges): """The pure-E/B estimator as one matrix on the fine ξ± grid. The Schneider et al. (2022) transform is evaluated with fixed-quadrature weights at the fine-grid nodes inside the reporting range, integrating over the whole fine grid, and the six pure modes are then averaged into the - reporting bins with TreeCorr pair weights. Both steps are linear and - independent of the ξ± values, so the estimator is ``K = (I_6 ⊗ P) · M`` and + reporting bins with TreeCorr pair weights. The reporting edges snap to the + nearest fine edges, so each reporting bin is a union of fine bins. Both + steps are linear and independent of the ξ± values, so the estimator is + ``K = (I_6 ⊗ P) · M`` and [xip_E; xim_E; xip_B; xim_B; xip_amb; xim_amb] = K @ [xip_int; xim_int] @@ -208,8 +223,10 @@ def pure_eb_operator(theta_int, weight_int, left_edges, right_edges): weight_int : array_like TreeCorr pair weight ``Σ w_i w_j`` per fine bin (``gg.weight``), the averaging weights. - left_edges, right_edges : array_like - Reporting-bin edges. + edges_int : array_like + The ``n_fine + 1`` fine-grid bin edges. + reporting_edges : array_like + Requested reporting-bin edges, ``n_report + 1`` of them. Returns ------- @@ -217,9 +234,13 @@ def pure_eb_operator(theta_int, weight_int, left_edges, right_edges): ``K``, shape ``(6 * n_report, 2 * n_fine)``. binning : numpy.ndarray ``P``, the ``(n_report, n_fine)`` pair-weighted average. + edges : numpy.ndarray + The reporting edges actually used, each a fine-grid edge. """ theta_int = np.asarray(theta_int, dtype=float) - binning = _weight_binning_matrix(theta_int, weight_int, left_edges, right_edges) + if np.shape(weight_int) != theta_int.shape: + raise ValueError("weight_int must have one entry per integration bin") + binning, edges = _reporting_binning(weight_int, edges_int, reporting_edges) nodes = np.flatnonzero(binning.any(axis=0)) transform = _fixed_quadrature_operator(theta_int[nodes], theta_int) n_nodes = nodes.size @@ -229,7 +250,7 @@ def pure_eb_operator(theta_int, weight_int, left_edges, right_edges): for i in range(len(_EB_KEYS)) ] ) - return operator, binning + return operator, binning, edges def calculate_pure_eb_correlation( @@ -237,9 +258,9 @@ def calculate_pure_eb_correlation( xip_int, xim_int, weight_int, + edges_int, cov_xi, - left_edges, - right_edges, + reporting_edges, *, npatch=None, ): @@ -252,18 +273,22 @@ def calculate_pure_eb_correlation( travels in the results and sets the Hartlap factor of every χ² built on them (:func:`hartlap_factor`). - The reporting-bin ``theta``, ``xip``/``xim`` and their variances are the - same pair-weighted average of the fine grid, so ``xi_± = E ± B + amb`` holds - bin by bin. + The reporting bins are unions of fine bins (the requested edges snap to + the nearest fine edges), and their ``theta``, ``xip``/``xim`` and + variances are the same pair-weighted average of the fine grid, so + ``xi_± = E ± B + amb`` holds bin by bin and ``theta``/``xip``/``xim`` are + what TreeCorr would measure on the snapped bins. Parameters ---------- theta_int, xip_int, xim_int, weight_int : array_like Fine-grid ``meanr``, ξ±, and TreeCorr pair weight ``Σ w_i w_j``. + edges_int : array_like + The ``n_fine + 1`` fine-grid bin edges. cov_xi : array_like ``(2 n_fine, 2 n_fine)`` covariance of ``[xip_int; xim_int]``. - left_edges, right_edges : array_like - Reporting-bin edges. + reporting_edges : array_like + Requested reporting-bin edges, ``n_report + 1`` of them. npatch : int, optional Jackknife patch count behind ``cov_xi``; ``None`` if it is analytic. @@ -271,10 +296,10 @@ def calculate_pure_eb_correlation( ------- dict The six ``_EB_KEYS`` mode arrays, ``cov`` (in ``_EB_KEYS`` block - order), ``npatch``, the reporting grid (``theta``, ``left_edges``, - ``right_edges``, ``xip``, ``xim``, ``var_xip``, ``var_xim``) and the - fine-grid inputs (``theta_int``, ``xip_int``, ``xim_int``, - ``weight_int``). + order), ``npatch``, the reporting grid (``theta``, the snapped + ``left_edges``/``right_edges``, ``xip``, ``xim``, ``var_xip``, + ``var_xim``) and the fine-grid inputs (``theta_int``, ``xip_int``, + ``xim_int``, ``weight_int``, ``edges_int``). """ if npatch is not None and npatch < 2: raise ValueError(f"a jackknife covariance needs npatch > 1, not {npatch}") @@ -282,14 +307,16 @@ def calculate_pure_eb_correlation( np.asarray(a, dtype=float) for a in (theta_int, xip_int, xim_int) ) cov_xi = np.asarray(cov_xi, dtype=float) - operator, binning = pure_eb_operator(theta_int, weight_int, left_edges, right_edges) + operator, binning, edges = pure_eb_operator( + theta_int, weight_int, edges_int, reporting_edges + ) if cov_xi.shape != (operator.shape[1],) * 2: raise ValueError( f"cov_xi has shape {cov_xi.shape}; the fine grid needs " f"{(operator.shape[1],) * 2}" ) - n_report = len(left_edges) + n_report = len(edges) - 1 modes = operator @ np.concatenate([xip_int, xim_int]) n_fine = theta_int.size var_xip, var_xim = ( @@ -298,8 +325,8 @@ def calculate_pure_eb_correlation( ) results = { "theta": binning @ theta_int, - "left_edges": np.asarray(left_edges, dtype=float), - "right_edges": np.asarray(right_edges, dtype=float), + "left_edges": edges[:-1], + "right_edges": edges[1:], "xip": binning @ xip_int, "xim": binning @ xim_int, "var_xip": var_xip, @@ -308,6 +335,7 @@ def calculate_pure_eb_correlation( "xip_int": xip_int, "xim_int": xim_int, "weight_int": np.asarray(weight_int, dtype=float), + "edges_int": np.asarray(edges_int, dtype=float), "cov": operator @ cov_xi @ operator.T, "npatch": npatch, } @@ -1264,7 +1292,9 @@ def save_pure_eb_results(results, output_path): Output .npz file path """ # Data vectors and covariance - save_dict = {"theta": results["theta"], "cov": results["cov"]} + save_dict = { + key: results[key] for key in ("theta", "left_edges", "right_edges", "cov") + } for key in _EB_KEYS: save_dict[key] = results[key] diff --git a/src/sp_validation/cosmo_val/pure_eb.py b/src/sp_validation/cosmo_val/pure_eb.py index e03e1697..81bd22f0 100644 --- a/src/sp_validation/cosmo_val/pure_eb.py +++ b/src/sp_validation/cosmo_val/pure_eb.py @@ -11,7 +11,6 @@ calculate_eb_statistics, calculate_pure_eb_correlation, covariance_label, - log_bin_edges, plot_eb_covariance_matrix, plot_integration_vs_reporting, plot_pte_2d_heatmaps, @@ -38,8 +37,8 @@ def calculate_pure_eb( ξ± is measured on the fine integration grid only; the reporting binning (the instance's treecorr_config unless overridden) enters as - bin edges, into which :func:`~sp_validation.b_modes.pure_eb_operator` - averages the modes. + bin edges, snapped onto the fine edges, into which + :func:`~sp_validation.b_modes.pure_eb_operator` averages the modes. Parameters ---------- @@ -67,9 +66,6 @@ def calculate_pure_eb( self.print_start(f"Computing {version} pure E/B") reporting = self._binning(min_sep, max_sep, nbins) - left_edges, right_edges = log_bin_edges( - reporting["min_sep"], reporting["max_sep"], reporting["nbins"] - ) gg_int = self.calculate_2pcf( version, npatch=npatch, @@ -87,9 +83,11 @@ def calculate_pure_eb( gg_int.xip, gg_int.xim, gg_int.weight, + np.append(gg_int.left_edges, gg_int.right_edges[-1]), cov_xi, - left_edges, - right_edges, + np.geomspace( + reporting["min_sep"], reporting["max_sep"], reporting["nbins"] + 1 + ), npatch=npatch, ) diff --git a/src/sp_validation/tests/conftest.py b/src/sp_validation/tests/conftest.py index f3d4a952..004abc83 100644 --- a/src/sp_validation/tests/conftest.py +++ b/src/sp_validation/tests/conftest.py @@ -12,9 +12,9 @@ def pure_eb_xi(): """Committed fine-grid ξ± of the synthetic coherent-shear catalogue. - Exact-binning integration grid [1, 300]′ in 600 bins with its pair weights, - and the edges of a [15, 70]′ reporting grid in 6 bins, keyed by - ``b_modes.calculate_pure_eb_correlation``'s parameters. + Exact-binning integration grid [1, 300]′ in 600 bins with its pair weights + and edges, and the requested edges of a [15, 70]′ reporting grid in 6 bins, + keyed by ``b_modes.calculate_pure_eb_correlation``'s parameters. """ with np.load(PURE_EB_XI) as npz: return {k: (v.item() if v.ndim == 0 else v) for k, v in npz.items()} diff --git a/src/sp_validation/tests/data/pure_eb_xi_fixture.npz b/src/sp_validation/tests/data/pure_eb_xi_fixture.npz index 38ad9c2598729734dfc4754870db76e18e7f7dee..193715c470ddb8403e9165a333264d6604c9dfcf 100644 GIT binary patch delta 18415 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�VY4v#*e;Y z0Ic}h{AUVAsN4$oJ_U@B?X7HiE1nUE NCLS8Y#x328-gOp6L%aqVDWx)g=(iie_ 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 LbO?kLfp+vCovS*I delta 13587 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 diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index a0651dfe..64240bd8 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -324,52 +324,52 @@ def test_calculate_eb_statistics_has_teeth(): # estimator is meant to move. _PURE_EB_PINS = { "xip_E": [ - 0.0001267927403538086, - 0.00011661471866769028, - 0.00011285998097487562, - 0.00011342826992976761, - 9.413729942634515e-05, - 8.504628509885555e-05, + 0.00012679274035380905, + 0.00011661471866769055, + 0.00011285998097487589, + 0.00011342826992976775, + 9.413729942634518e-05, + 8.504628509885567e-05, ], "xim_E": [ - 7.84636379789262e-06, - -2.1854362709680197e-08, - 1.2476650697806643e-06, - 5.931628899598654e-06, - -5.748552740802833e-06, - 5.307121527471898e-06, + 7.846363797892477e-06, + -2.1854362709441122e-08, + 1.247665069781001e-06, + 5.931628899598502e-06, + -5.748552740802789e-06, + 5.307121527471727e-06, ], "xip_B": [ - -5.890048396074994e-05, - -5.413134881293027e-05, - -7.033477076572411e-05, - -6.464207645137761e-05, - -6.438127715272536e-05, - -5.781687325365341e-05, + -5.8900483960750064e-05, + -5.413134881293024e-05, + -7.033477076572403e-05, + -6.464207645137748e-05, + -6.438127715272512e-05, + -5.781687325365326e-05, ], "xim_B": [ - -1.2460349217324633e-05, - 1.1641684220790763e-06, - 3.7838279437020602e-06, - 9.134846190559186e-06, - 5.499212937161553e-06, - 4.460746452672484e-06, + -1.246034921732448e-05, + 1.164168422079476e-06, + 3.783827943702509e-06, + 9.134846190559095e-06, + 5.499212937161643e-06, + 4.4607464526723306e-06, ], "xip_amb": [ - 8.984494180845635e-05, - 8.879536535671083e-05, - 8.704153035693623e-05, - 8.410960918777171e-05, - 7.923091774259722e-05, - 7.107059682711236e-05, + 8.984494180845601e-05, + 8.879536535671052e-05, + 8.704153035693591e-05, + 8.410960918777141e-05, + 7.923091774259691e-05, + 7.107059682711207e-05, ], "xim_amb": [ - 1.5108576414191464e-06, - 9.059561894981808e-07, - 5.429016867191619e-07, - 3.250730808381593e-07, - 1.9496276632352545e-07, - 1.1676364182463817e-07, + 1.5108576414194198e-06, + 9.059561894983459e-07, + 5.429016867192604e-07, + 3.2507308083821813e-07, + 1.9496276632356086e-07, + 1.1676364182465932e-07, ], } @@ -421,74 +421,94 @@ def test_pure_eb_modes_sum_to_the_averaged_xi(pure_eb_xi): def test_pure_eb_covariance_is_the_operator_sandwich(pure_eb_xi): """``cov`` is K C Kᵀ for the supplied ξ± covariance, and records npatch. - The reported ξ± variances are the same pair-weighted average pushed through - the ξ+ and ξ− blocks of C. + The reported ξ± variances are the same pair-weighted average pushed + through the ξ+ and ξ− blocks of C, and the reported edges are the snapped + ones the operator used. """ n_fine = len(pure_eb_xi["theta_int"]) cov_xi = _spd(2 * n_fine, seed=7) r = b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=40) - K, P = b_modes.pure_eb_operator( + K, P, edges = b_modes.pure_eb_operator( pure_eb_xi["theta_int"], pure_eb_xi["weight_int"], - pure_eb_xi["left_edges"], - pure_eb_xi["right_edges"], + pure_eb_xi["edges_int"], + pure_eb_xi["reporting_edges"], ) npt.assert_allclose(r["cov"], K @ cov_xi @ K.T, rtol=1e-12) npt.assert_allclose(r["cov"], r["cov"].T, rtol=1e-12) npt.assert_allclose(r["var_xip"], np.diag(P @ cov_xi[:n_fine, :n_fine] @ P.T)) npt.assert_allclose(r["var_xim"], np.diag(P @ cov_xi[n_fine:, n_fine:] @ P.T)) + npt.assert_array_equal(r["left_edges"], edges[:-1]) + npt.assert_array_equal(r["right_edges"], edges[1:]) + assert np.all(np.isin(edges, pure_eb_xi["edges_int"])) assert r["npatch"] == 40 with pytest.raises(ValueError, match="npatch > 1"): b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=1) -def test_pure_eb_binning_is_a_pair_weighted_average(): - """Each reporting row averages its fine nodes with TreeCorr pair weights. +def test_pure_eb_reporting_bins_are_unions_of_fine_bins(): + """Requested edges snap to the nearest fine edge; rows pool whole fine bins. - Rows sum to one; nodes outside the reporting range or without pairs carry - no weight; equal weights give the plain mean; an empty bin raises. + Rows sum to one and weight their fine bins by the pair weight; a fine bin + with no weight carries none; equal weights give the plain mean. Edges that + snap together, edges outside the fine grid and empty bins raise. """ - theta = np.geomspace(1.0, 100.0, 40) + edges_int = np.geomspace(1.0, 100.0, 41) weight = np.arange(1.0, 41.0) weight[20] = 0.0 - left, right = b_modes.log_bin_edges(2.0, 50.0, 4) - P = b_modes._weight_binning_matrix(theta, weight, left, right) + requested = np.array([2.1, 6.0, 20.0, 49.0]) + P, edges = b_modes._reporting_binning(weight, edges_int, requested) + snap = [np.argmin(np.abs(np.log(edges_int / e))) for e in requested] + npt.assert_array_equal(edges, edges_int[snap]) npt.assert_allclose(P.sum(axis=1), 1.0) - assert np.all(P[:, (theta < 2.0) | (theta >= 50.0)] == 0) + for row, (lo, hi) in enumerate(zip(snap[:-1], snap[1:])): + inside = np.zeros(40, dtype=bool) + inside[lo:hi] = True + npt.assert_allclose(P[row, inside], weight[inside] / weight[inside].sum()) + assert np.all(P[row, ~inside] == 0) assert np.all(P[:, 20] == 0) - inside = (theta >= left[1]) & (theta < right[1]) & (weight > 0) - npt.assert_allclose(P[1, inside], weight[inside] / weight[inside].sum()) - flat = b_modes._weight_binning_matrix(theta, np.ones(40), left, right) - inside = (theta >= left[0]) & (theta < right[0]) - npt.assert_allclose(flat[0, inside], 1.0 / inside.sum()) + flat, _ = b_modes._reporting_binning(np.ones(40), edges_int, requested) + npt.assert_allclose(flat[0, snap[0] : snap[1]], 1.0 / (snap[1] - snap[0])) + with pytest.raises(ValueError, match="same fine edge"): + b_modes._reporting_binning(weight, edges_int, [2.0, 2.05, 20.0]) + with pytest.raises(ValueError, match="outside the fine grid"): + b_modes._reporting_binning(weight, edges_int, [2.0, 20.0, 200.0]) with pytest.raises(ValueError, match="hold no integration-grid pairs"): - b_modes._weight_binning_matrix(theta, np.zeros(40), left, right) + b_modes._reporting_binning(np.zeros(40), edges_int, requested) -def test_pure_eb_binning_reproduces_the_reporting_measurement(): - """P on nested fine bins gives TreeCorr's reporting-bin ξ± and meanr. - - TreeCorr's ξ± and meanr in a bin are averages over its pairs weighted by - ``w_i w_j``, so pooling fine bins whose edges nest the reporting edges with - the fine ``weight`` is the same sum. A weighted catalogue with exact - binning makes that hold to round-off; weighting by ``npairs`` instead - does not. - """ - treecorr = pytest.importorskip("treecorr") - +def _weighted_catalogue(treecorr): + """A weighted flat shear catalogue, separations in arcmin.""" rng = np.random.default_rng(2024) n_gal = 3000 x, y = rng.uniform(0.0, 300.0, (2, n_gal)) g1, g2 = 0.02 + 0.05 * rng.standard_normal((2, n_gal)) - cat = treecorr.Catalog(x=x, y=y, g1=g1, g2=g2, w=rng.uniform(0.2, 1.0, n_gal)) + return treecorr.Catalog(x=x, y=y, g1=g1, g2=g2, w=rng.uniform(0.2, 1.0, n_gal)) + + +@pytest.mark.parametrize( + "requested", + [np.geomspace(15.0, 70.0, 7), np.geomspace(14.0, 73.0, 7)], + ids=["nested", "snapped"], +) +def test_pure_eb_binning_reproduces_the_reporting_measurement(requested): + """P on the fine grid gives TreeCorr's ξ± and meanr on the snapped bins. + TreeCorr's ξ± and meanr in a bin are averages over its pairs weighted by + ``w_i w_j``, and every reporting bin is a union of fine bins, so pooling + them with the fine ``weight`` is the same sum. A weighted catalogue with + exact binning makes that hold to round-off, whether or not the requested + edges fall on fine edges; weighting by ``npairs`` instead does not. + """ + treecorr = pytest.importorskip("treecorr") + cat = _weighted_catalogue(treecorr) exact = {"bin_slop": 0, "angle_slop": 0} - reporting = treecorr.GGCorrelation(min_sep=15.0, max_sep=70.0, nbins=6, **exact) - # Eight fine bins per reporting bin, plus four on either side. + + # Eight fine bins per [15, 70]′ / 6 reporting bin, plus four either side. step = np.log(70.0 / 15.0) / 48 fine = treecorr.GGCorrelation( min_sep=15.0 * np.exp(-4 * step), @@ -496,21 +516,28 @@ def test_pure_eb_binning_reproduces_the_reporting_measurement(): nbins=56, **exact, ) - reporting.process(cat) fine.process(cat) - - P = b_modes._weight_binning_matrix( - fine.meanr, fine.weight, reporting.left_edges, reporting.right_edges - ) + edges_int = np.append(fine.left_edges, fine.right_edges[-1]) + + P, edges = b_modes._reporting_binning(fine.weight, edges_int, requested) + assert np.all(np.isin(edges, edges_int)) + measured = [] + for lo, hi in zip(edges[:-1], edges[1:]): + gg = treecorr.GGCorrelation(min_sep=lo, max_sep=hi, nbins=1, **exact) + gg.process(cat) + measured.append(gg) for key in ("xip", "xim", "meanr"): npt.assert_allclose( - P @ getattr(fine, key), getattr(reporting, key), rtol=1e-10, err_msg=key + P @ getattr(fine, key), + [getattr(gg, key)[0] for gg in measured], + rtol=1e-10, + err_msg=key, ) - by_npairs = b_modes._weight_binning_matrix( - fine.meanr, fine.npairs, reporting.left_edges, reporting.right_edges + by_npairs, _ = b_modes._reporting_binning(fine.npairs, edges_int, requested) + assert not np.allclose( + by_npairs @ fine.xip, [gg.xip[0] for gg in measured], rtol=1e-6, atol=0 ) - assert not np.allclose(by_npairs @ fine.xip, reporting.xip, rtol=1e-6, atol=0) def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi): @@ -519,12 +546,13 @@ def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi) The ξ− integrals over [tmin, t] have fewer than interp_order + 1 nodes for the first few fine nodes, so those operator rows are under-determined. """ - theta_int = pure_eb_xi["theta_int"] + edges_int = pure_eb_xi["edges_int"] with pytest.raises(ValueError, match="under-determined"): b_modes.pure_eb_operator( - theta_int, + pure_eb_xi["theta_int"], pure_eb_xi["weight_int"], - *b_modes.log_bin_edges(theta_int[0], 70.0, 6), + edges_int, + np.geomspace(edges_int[0], 70.0, 7), ) @@ -639,13 +667,17 @@ def test_pure_eb_npz_carries_what_the_summary_reads(tmp_path): """The .npz keys cv_summarize_bmodes reads are the ones the writer emits. The two live in different rules, so the contract between them — the PTE - matrices under ``pte_matrices_{stat}`` and the jackknife patch count under - ``npatch`` — is pinned here rather than discovered on a cluster run. + matrices under ``pte_matrices_{stat}``, the reporting edges they are + indexed on and the jackknife patch count under ``npatch`` — is pinned here + rather than discovered on a cluster run. """ results, nbins = _eb_inputs() results.update( {key: np.zeros(nbins) for key in b_modes._EB_KEYS if key not in results} ) + results["left_edges"], results["right_edges"] = b_modes.log_bin_edges( + 1.0, 100.0, nbins + ) results = b_modes.calculate_eb_statistics(results) out = tmp_path / "pure_eb_data.npz" @@ -661,8 +693,10 @@ def test_pure_eb_npz_carries_what_the_summary_reads(tmp_path): assert key in saved # The summary reads the fiducial cut out of those matrices through the same - # helper the plots use, so a valid cut must resolve to a finite PTE. - edges = b_modes.log_bin_edges(1.0, 100.0, nbins) + # helper the plots use, on the saved edges, so a valid cut must resolve to + # a finite PTE. + edges = (saved["left_edges"], saved["right_edges"]) + npt.assert_array_equal(edges[0], results["left_edges"]) pte = b_modes._get_pte_from_scale_cut( saved["pte_matrices_xip_B"], edges, (1.0, 100.0) ) diff --git a/src/sp_validation/tests/test_cosmo_val.py b/src/sp_validation/tests/test_cosmo_val.py index 8e32ddbc..008614f8 100644 --- a/src/sp_validation/tests/test_cosmo_val.py +++ b/src/sp_validation/tests/test_cosmo_val.py @@ -626,26 +626,22 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) measured = { key: results[key] - for key in ( - "theta_int", - "xip_int", - "xim_int", - "weight_int", - "left_edges", - "right_edges", - ) + for key in ("theta_int", "xip_int", "xim_int", "weight_int", "edges_int") } + measured["reporting_edges"] = np.geomspace(15.0, 70.0, nbins + 1) # Regenerate the fixture with np.savez(conftest.PURE_EB_XI, **measured). for key, value in measured.items(): np.testing.assert_allclose( value, pure_eb_xi[key], rtol=1e-10, atol=0, err_msg=key ) - operator, _ = b_modes.pure_eb_operator( - *(measured[k] for k in ("theta_int", "weight_int")), - measured["left_edges"], - measured["right_edges"], + operator, _, edges = b_modes.pure_eb_operator( + *( + measured[k] + for k in ("theta_int", "weight_int", "edges_int", "reporting_edges") + ) ) + np.testing.assert_array_equal(results["left_edges"], edges[:-1]) modes = operator @ np.concatenate([measured["xip_int"], measured["xim_int"]]) for i, key in enumerate(b_modes._EB_KEYS): vec = np.asarray(results[key]) diff --git a/workflow/rules/cosmo_val.smk b/workflow/rules/cosmo_val.smk index 67cd8791..8d9839c6 100644 --- a/workflow/rules/cosmo_val.smk +++ b/workflow/rules/cosmo_val.smk @@ -412,6 +412,7 @@ rule cv_pure_eb: min_sep=CV["theta_min"], max_sep=CV["theta_max"], nbins=CV["nbins"], + integration=XI_GRIDS["integration"], fiducial_scale_cut=CV["fiducial_scale_cut"], resources: mem_mb=8000, @@ -467,9 +468,6 @@ rule cv_summarize_bmodes: params: versions=CV_VERSIONS, fiducial_scale_cut=CV["fiducial_scale_cut"], - min_sep=CV["theta_min"], - max_sep=CV["theta_max"], - nbins=CV["nbins"], include_pseudo_cl=CV.get("include_pseudo_cl", False), resources: mem_mb=8000, diff --git a/workflow/scripts/cv_pure_eb.py b/workflow/scripts/cv_pure_eb.py index 43b0fae9..3f978d3a 100644 --- a/workflow/scripts/cv_pure_eb.py +++ b/workflow/scripts/cv_pure_eb.py @@ -3,7 +3,8 @@ A consumer of the integration-grid ξ± part plus a ξ± covariance on that grid — nothing here touches a catalogue. The estimator is one fixed linear operator on the fine ξ± (b_modes.pure_eb_operator), averaged into the reporting bins with -the part's TreeCorr pair weights, so its covariance is the supplied ξ± covariance pushed +the part's TreeCorr pair weights into reporting bins snapped onto the fine +edges, so its covariance is the supplied ξ± covariance pushed exactly through that operator. """ @@ -15,7 +16,6 @@ calculate_eb_statistics, calculate_pure_eb_correlation, covariance_label, - log_bin_edges, plot_eb_covariance_matrix, plot_integration_vs_reporting, plot_pte_2d_heatmaps, @@ -32,16 +32,18 @@ part = sacc_io.load(snakemake.input["xi_integration"]) theta_int, xip_int, xim_int = sacc_io.get_xi(part, (0, 0), grid="integration") weight_int = sacc_io.get_xi_weight(part, (0, 0), grid="integration") -left_edges, right_edges = log_bin_edges(p["min_sep"], p["max_sep"], p["nbins"]) +# A part stores bin centres; the edges come from the grid it was measured on. +grid = p["integration"] +edges_int = np.geomspace(grid["min_sep"], grid["max_sep"], grid["nbins"] + 1) results = calculate_pure_eb_correlation( theta_int, xip_int, xim_int, weight_int, + edges_int, np.loadtxt(snakemake.input["cov_integration"]), - left_edges, - right_edges, + np.geomspace(p["min_sep"], p["max_sep"], p["nbins"] + 1), ) results = calculate_eb_statistics(results) diff --git a/workflow/scripts/cv_summarize_bmodes.py b/workflow/scripts/cv_summarize_bmodes.py index 42a94514..3f9d9e82 100644 --- a/workflow/scripts/cv_summarize_bmodes.py +++ b/workflow/scripts/cv_summarize_bmodes.py @@ -13,19 +13,13 @@ from cv_runner import _unbuffer_streams, verify_outputs from sp_validation import sacc_io -from sp_validation.b_modes import ( - _get_pte_from_scale_cut, - covariance_label, - log_bin_edges, -) +from sp_validation.b_modes import _get_pte_from_scale_cut, covariance_label from sp_validation.cosmo_val.core import print_bmode_summary from sp_validation.statistics import chi2_and_pte _unbuffer_streams() p = snakemake.params fiducial_scale_cut = tuple(p["fiducial_scale_cut"]) -edges = log_bin_edges(p["min_sep"], p["max_sep"], p["nbins"]) - summary = {} cov_methods = set() @@ -33,6 +27,8 @@ row = {} pure_eb = np.load(snakemake.input["pure_eb"][i]) + # The bins the PTE matrices are indexed on: the snapped reporting edges. + edges = (pure_eb["left_edges"], pure_eb["right_edges"]) for stat in ("xip_B", "xim_B", "combined"): try: row[stat] = _get_pte_from_scale_cut( From 5df40ffc1b0d9af61a45b10e61bba3013b31fdec Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 05:16:12 +0200 Subject: [PATCH 07/10] papers/bmodes: resolve pure-E/B scale-cut windows on the saved edges The pure-E/B PTE intermediate now carries the reporting edges its matrices are indexed on, and config_space_pte_matrices resolves the fiducial windows on them rather than on a nominal geomspace grid. A PTE file that records no edges was binned on the nominal grid, which stays its window. Co-Authored-By: Claude Opus 5.5 --- .../bmodes/scripts/calculate_pure_eb_ptes.py | 3 + .../scripts/config_space_pte_matrices.py | 75 +++++++++---------- 2 files changed, 40 insertions(+), 38 deletions(-) diff --git a/papers/bmodes/scripts/calculate_pure_eb_ptes.py b/papers/bmodes/scripts/calculate_pure_eb_ptes.py index 6f8a94fc..ca453dc4 100644 --- a/papers/bmodes/scripts/calculate_pure_eb_ptes.py +++ b/papers/bmodes/scripts/calculate_pure_eb_ptes.py @@ -47,6 +47,9 @@ def calculate_ptes( pte_matrices = results["pte_matrices"] output_data = { "theta": theta, + # The bins the matrices are indexed on, for scale-cut windows. + "left_edges": dataset["left_edges"], + "right_edges": dataset["right_edges"], "pte_xip_B": pte_matrices["xip_B"], "pte_xim_B": pte_matrices["xim_B"], "pte_combined": pte_matrices["combined"], diff --git a/papers/bmodes/scripts/config_space_pte_matrices.py b/papers/bmodes/scripts/config_space_pte_matrices.py index 3bfa2811..f2c559dd 100644 --- a/papers/bmodes/scripts/config_space_pte_matrices.py +++ b/papers/bmodes/scripts/config_space_pte_matrices.py @@ -189,22 +189,25 @@ def load_pure_eb_pte_matrices(pte_files, version, override_path=None): Angular scale grid. pte_combined : ndarray or None PTE matrix for combined ξ_tot^B, or None if not available. + edges : ndarray or None + The reporting edges the matrices are indexed on, or None for a file + that does not record them. """ - if override_path is not None: - data = np.load(override_path) - pte_combined = data["pte_combined"] if "pte_combined" in data else None - return data["pte_xip_B"], data["pte_xim_B"], data["theta"], pte_combined - - for pte_file in pte_files: + if override_path is None: # Filter to this version (exact match, no substring false positives) - if not _path_matches_version(pte_file, version): - continue - - data = np.load(pte_file) - pte_combined = data["pte_combined"] if "pte_combined" in data else None - return data["pte_xip_B"], data["pte_xim_B"], data["theta"], pte_combined - - raise ValueError(f"No PTE file found for version {version}") + matching = [p for p in pte_files if _path_matches_version(p, version)] + if not matching: + raise ValueError(f"No PTE file found for version {version}") + override_path = matching[0] + + data = np.load(override_path) + pte_combined = data["pte_combined"] if "pte_combined" in data else None + edges = ( + np.append(data["left_edges"], data["right_edges"][-1]) + if "left_edges" in data + else None + ) + return data["pte_xip_B"], data["pte_xim_B"], data["theta"], pte_combined, edges def _load_version_pte_data( @@ -215,12 +218,19 @@ def _load_version_pte_data( Returns ------- dict with keys: pte_xip_B, pte_xim_B, pte_combined (or None), - pte_cosebis, pte_cosebis_20, theta_pure_eb, theta_cosebis. + pte_cosebis, pte_cosebis_20, theta_pure_eb, edges_pure_eb, + theta_cosebis. """ pure_eb_override, cosebis_override = _resolve_overrides(version, fiducial_overrides) - pte_xip_B, pte_xim_B, theta_pure_eb, pte_combined = load_pure_eb_pte_matrices( - pure_eb_pte_files, version, override_path=pure_eb_override + pte_xip_B, pte_xim_B, theta_pure_eb, pte_combined, edges_pure_eb = ( + load_pure_eb_pte_matrices( + pure_eb_pte_files, version, override_path=pure_eb_override + ) ) + if edges_pure_eb is None: + # A PTE file without saved edges was binned on the nominal grid. + fid = config["fiducial"] + edges_pure_eb = np.geomspace(fid["min_sep"], fid["max_sep"], fid["nbins"] + 1) pte_cosebis, theta_cosebis = load_cosebis_pte_matrix( cosebis_pte_files, version, @@ -242,6 +252,7 @@ def _load_version_pte_data( "pte_cosebis": pte_cosebis, "pte_cosebis_20": pte_cosebis_20, "theta_pure_eb": theta_pure_eb, + "edges_pure_eb": edges_pure_eb, "theta_cosebis": theta_cosebis, } @@ -522,13 +533,9 @@ def create_3panel_composite( cosebis_fid_start = np.argmin(np.abs(theta_cosebis[:-1] - cosebis_fid[0])) cosebis_fid_stop = np.argmin(np.abs(theta_cosebis[1:] - cosebis_fid[1])) + 1 - reporting_edges = np.geomspace( - config["fiducial"]["min_sep"], - config["fiducial"]["max_sep"], - config["fiducial"]["nbins"] + 1, - ) - xip_start, xip_stop = resolve_fiducial_bin_window(reporting_edges, *xip_fid) - xim_start, xim_stop = resolve_fiducial_bin_window(reporting_edges, *xim_fid) + edges_pure_eb = matrices["edges_pure_eb"] + xip_start, xip_stop = resolve_fiducial_bin_window(edges_pure_eb, *xip_fid) + xim_start, xim_stop = resolve_fiducial_bin_window(edges_pure_eb, *xim_fid) # Create subplot axes ax_xip = fig.add_subplot(gs[0, 0]) @@ -680,13 +687,9 @@ def create_9panel_composite( cosebis_fid_start = np.argmin(np.abs(theta_cosebis[:-1] - cosebis_fid[0])) cosebis_fid_stop = np.argmin(np.abs(theta_cosebis[1:] - cosebis_fid[1])) + 1 - reporting_edges = np.geomspace( - config["fiducial"]["min_sep"], - config["fiducial"]["max_sep"], - config["fiducial"]["nbins"] + 1, - ) - xip_start, xip_stop = resolve_fiducial_bin_window(reporting_edges, *xip_fid) - xim_start, xim_stop = resolve_fiducial_bin_window(reporting_edges, *xim_fid) + edges_pure_eb = matrices["edges_pure_eb"] + xip_start, xip_stop = resolve_fiducial_bin_window(edges_pure_eb, *xip_fid) + xim_start, xim_stop = resolve_fiducial_bin_window(edges_pure_eb, *xim_fid) # Create subplot axes for this row ax_xip = fig.add_subplot(gs[row_idx, 0]) @@ -910,16 +913,12 @@ def main( fiducial_overrides, ) theta_co = matrices["theta_cosebis"] - reporting_edges = np.geomspace( - config["fiducial"]["min_sep"], - config["fiducial"]["max_sep"], - config["fiducial"]["nbins"] + 1, - ) + edges_pure_eb = matrices["edges_pure_eb"] xip_start, xip_stop = resolve_fiducial_bin_window( - reporting_edges, *xip_fid + edges_pure_eb, *xip_fid ) xim_start, xim_stop = resolve_fiducial_bin_window( - reporting_edges, *xim_fid + edges_pure_eb, *xim_fid ) cos_start = np.argmin(np.abs(theta_co[:-1] - cosebis_fid[0])) cos_stop = np.argmin(np.abs(theta_co[1:] - cosebis_fid[1])) + 1 From 19fad0ae95072efe1a8dc45974ae6545d4307f87 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 05:32:23 +0200 Subject: [PATCH 08/10] Pin cosmo-numba a64cb2e; evaluate the pure-E/B transform at every fine node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cosmo-numba's get_pure_EB_operator (schneider2022, local_from_int=True) now builds the fixed-quadrature transform. It is evaluated at every fine node: the zero-padded ξ− window extends from the evaluation grid, so this keeps the transform a function of the fine grid alone. P then selects its rows; NaN rows at the grid edges are dropped first and only a NaN row that P uses raises. The new operator clamps a θ-dependent integration limit to the interpolator's extrapolation bound, as the reference does, where the previous pin extrapolated the stencil polynomial; the operator pins move accordingly. Co-Authored-By: Claude Opus 5.5 --- pyproject.toml | 4 +- src/sp_validation/b_modes.py | 78 ++++++++++++------------- src/sp_validation/tests/test_b_modes.py | 72 +++++++++++------------ uv.lock | 6 +- 4 files changed, 79 insertions(+), 81 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b398749c..2ffcd753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,12 +39,12 @@ dependencies = [ # CosmoStat/cs_util#76 — so this goes green once #76 merges into develop. "cs_util @ git+https://github.com/CosmoStat/cs_util.git@develop", # Fast numba B-mode kernels (Schneider et al. 2022): the fixed-quadrature - # pure-E/B operator (schneider2022_operator) and COSEBIS live here, imported + # pure-E/B operator (schneider2022) and COSEBIS live here, imported # in b_modes.py. Pinned to a commit on cailmdaley/cosmo-numba, which carries # the operator on top of aguinot/cosmo-numba main (not published on PyPI): # main's numpy-2 FFT fix via rocket-fft, and its numba/numpy/rocket-fft # requirements, which reach the resolver. - "cosmo-numba @ git+https://github.com/cailmdaley/cosmo-numba.git@d78a189d9af75a9c113fef7647102a1bad0fd452", + "cosmo-numba @ git+https://github.com/cailmdaley/cosmo-numba.git@a64cb2ed13e595a16ad056a128ef24594b811504", "emcee", # numba is the load-bearing pin of this whole environment: its numpy ceiling # (numba 0.66 -> numpy<2.5) is what keeps the resolver from drifting numpy diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index 07595216..1f1b270f 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -168,46 +168,38 @@ def _reporting_binning(weight_int, edges_int, reporting_edges): return binning / weight[:, None], edges_int[snap] -def _fixed_quadrature_operator(theta_eval, theta_int): - """Rows of the Schneider (2022) transform at ``theta_eval``, ``_EB_KEYS`` order. - - The single call into cosmo_numba. ``[tmin, tmax]`` is the extent of the - integration grid, so every evaluation node keeps interpolation support on - both sides. Returns the ``(6 * n_eval, 2 * n_fine)`` stack of the six - matrices acting on ``[xi_+; xi_-]``. +def _fixed_quadrature_operator(theta_int): + """The Schneider (2022) transform at every fine node, ``_EB_KEYS`` order. + + The single call into cosmo_numba. Evaluating at every node of the fine + grid, with ``[tmin, tmax]`` at its extent, makes the transform a function + of the fine grid alone: the zero-padded ξ− window is extended from the + evaluation grid. Returns the ``(6, n_fine, 2 * n_fine)`` stack of the six + matrices acting on ``[xi_+; xi_-]``; rows whose integration support is too + small near the grid edges are NaN. """ - from cosmo_numba.B_modes.schneider2022_operator import get_pure_EB_operator - - operator = get_pure_EB_operator( - theta_eval=theta_eval, - theta=theta_int, - tmin=theta_int[0] * (1 - 1e-9), - tmax=theta_int[-1] * (1 + 1e-9), - outputs=_EB_KEYS, - ) - # A row whose support holds fewer than interp_order + 1 nodes is NaN. - invalid = { - key: int(np.count_nonzero(~valid)) - for key, valid in operator["valid"].items() - if not valid.all() - } - if invalid: - raise ValueError( - "pure-E/B operator rows are under-determined (evaluation nodes too " - f"close to the integration-grid edge): {invalid}" + from cosmo_numba.B_modes.schneider2022 import get_pure_EB_operator + + return np.stack( + get_pure_EB_operator( + theta_int, + theta_int, + tmin=theta_int[0] * (1 - 1e-9), + tmax=theta_int[-1] * (1 + 1e-9), + local_from_int=True, ) - return np.vstack([operator["matrices"][key] for key in _EB_KEYS]) + ) def pure_eb_operator(theta_int, weight_int, edges_int, reporting_edges): """The pure-E/B estimator as one matrix on the fine ξ± grid. The Schneider et al. (2022) transform is evaluated with fixed-quadrature - weights at the fine-grid nodes inside the reporting range, integrating over - the whole fine grid, and the six pure modes are then averaged into the - reporting bins with TreeCorr pair weights. The reporting edges snap to the - nearest fine edges, so each reporting bin is a union of fine bins. Both - steps are linear and independent of the ξ± values, so the estimator is + weights at the fine-grid nodes, integrating over the whole fine grid, and + the six pure modes are then averaged into the reporting bins with TreeCorr + pair weights. The reporting edges snap to the nearest fine edges, so each + reporting bin is a union of fine bins. Both steps are linear and + independent of the ξ± values, so the estimator is ``K = (I_6 ⊗ P) · M`` and [xip_E; xim_E; xip_B; xim_B; xip_amb; xim_amb] = K @ [xip_int; xim_int] @@ -242,14 +234,20 @@ def pure_eb_operator(theta_int, weight_int, edges_int, reporting_edges): raise ValueError("weight_int must have one entry per integration bin") binning, edges = _reporting_binning(weight_int, edges_int, reporting_edges) nodes = np.flatnonzero(binning.any(axis=0)) - transform = _fixed_quadrature_operator(theta_int[nodes], theta_int) - n_nodes = nodes.size - operator = np.vstack( - [ - binning[:, nodes] @ transform[i * n_nodes : (i + 1) * n_nodes] - for i in range(len(_EB_KEYS)) - ] - ) + # Only the rows P averages enter; NaN edge rows outside them are dropped, + # since 0 · NaN would still poison the product. + transform = _fixed_quadrature_operator(theta_int)[:, nodes] + undetermined = { + key: int(np.count_nonzero(~np.isfinite(rows).all(axis=1))) + for key, rows in zip(_EB_KEYS, transform) + if not np.isfinite(rows).all() + } + if undetermined: + raise ValueError( + "pure-E/B operator rows are under-determined (reporting bins too " + f"close to the integration-grid edge): {undetermined}" + ) + operator = np.vstack([binning[:, nodes] @ rows for rows in transform]) return operator, binning, edges diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index 64240bd8..0cecf8a9 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -324,52 +324,52 @@ def test_calculate_eb_statistics_has_teeth(): # estimator is meant to move. _PURE_EB_PINS = { "xip_E": [ - 0.00012679274035380905, - 0.00011661471866769055, - 0.00011285998097487589, - 0.00011342826992976775, - 9.413729942634518e-05, - 8.504628509885567e-05, + 0.00012675909649037545, + 0.00011663318638709152, + 0.00011284314562619557, + 0.00011343413369325459, + 9.414565637924643e-05, + 8.505124884351236e-05, ], "xim_E": [ - 7.846363797892477e-06, - -2.1854362709441122e-08, - 1.247665069781001e-06, - 5.931628899598502e-06, - -5.748552740802789e-06, - 5.307121527471727e-06, + 7.846111474871327e-06, + -2.1614773716458632e-08, + 1.2479998822054037e-06, + 5.931629229668291e-06, + -5.7485620307162e-06, + 5.307223369378577e-06, ], "xip_B": [ - -5.8900483960750064e-05, - -5.413134881293024e-05, - -7.033477076572403e-05, - -6.464207645137748e-05, - -6.438127715272512e-05, - -5.781687325365326e-05, + -5.8866840097316286e-05, + -5.4149816532331074e-05, + -7.031793541704363e-05, + -6.464794021486418e-05, + -6.438963410562624e-05, + -5.782183699830983e-05, ], "xim_B": [ - -1.246034921732448e-05, - 1.164168422079476e-06, - 3.783827943702509e-06, - 9.134846190559095e-06, - 5.499212937161643e-06, - 4.4607464526723306e-06, + -1.2460601540345636e-05, + 1.1644080110724575e-06, + 3.784162756126911e-06, + 9.134846520628891e-06, + 5.499203647248232e-06, + 4.460848294579186e-06, ], "xip_amb": [ - 8.984494180845601e-05, - 8.879536535671052e-05, - 8.704153035693591e-05, - 8.410960918777141e-05, - 7.923091774259691e-05, - 7.107059682711207e-05, + 8.98449418084559e-05, + 8.87953653567104e-05, + 8.704153035693577e-05, + 8.410960918777129e-05, + 7.92309177425968e-05, + 7.107059682711196e-05, ], "xim_amb": [ - 1.5108576414194198e-06, - 9.059561894983459e-07, - 5.429016867192604e-07, - 3.2507308083821813e-07, - 1.9496276632356086e-07, - 1.1676364182465932e-07, + 1.5108576414194206e-06, + 9.059561894983458e-07, + 5.429016867192606e-07, + 3.2507308083821834e-07, + 1.9496276632356097e-07, + 1.167636418246594e-07, ], } diff --git a/uv.lock b/uv.lock index 9a027424..8ab6ed4c 100644 --- a/uv.lock +++ b/uv.lock @@ -588,8 +588,8 @@ wheels = [ [[package]] name = "cosmo-numba" -version = "0.1.dev113+gd78a189d9" -source = { git = "https://github.com/cailmdaley/cosmo-numba.git?rev=d78a189d9af75a9c113fef7647102a1bad0fd452#d78a189d9af75a9c113fef7647102a1bad0fd452" } +version = "0.1.dev108+ga64cb2ed1" +source = { git = "https://github.com/cailmdaley/cosmo-numba.git?rev=a64cb2ed13e595a16ad056a128ef24594b811504#a64cb2ed13e595a16ad056a128ef24594b811504" } dependencies = [ { name = "mpmath" }, { name = "numba" }, @@ -3821,7 +3821,7 @@ requires-dist = [ { name = "camb", specifier = ">=2.0" }, { name = "clmm" }, { name = "colorama" }, - { name = "cosmo-numba", git = "https://github.com/cailmdaley/cosmo-numba.git?rev=d78a189d9af75a9c113fef7647102a1bad0fd452" }, + { name = "cosmo-numba", git = "https://github.com/cailmdaley/cosmo-numba.git?rev=a64cb2ed13e595a16ad056a128ef24594b811504" }, { name = "cosmology", marker = "extra == 'glass'", specifier = "==2022.10.9" }, { name = "cosmosis", marker = "extra == 'workflow'", specifier = ">=3.25" }, { name = "cryptography" }, From 6fb76032b4e44197f830d55fc1b47162b186e1a1 Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 05:35:34 +0200 Subject: [PATCH 09/10] Pure E/B: resolve scale cuts by snapping to the nearest reporting edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exact containment on snapped reporting edges makes a cut placed on a nominal edge select different bins on different fine grids: [12, 83]′ gave bins 9–15 on a 0.5–300′ grid but 9–14 on cosmo_val's 0.08–300′ one. bins_from_scale_cut snaps each end of a cut to the nearest reporting edge in log θ, the rule the edges themselves were snapped by, so the same cut selects the same bins on every grid. _get_pte_from_scale_cut (hence the B-mode summaries), the pure-E/B plots and papers/bmodes config_space_pte_matrices use it; COSEBIs keep bins_from_edges. Co-Authored-By: Claude Opus 5.5 --- .../scripts/config_space_pte_matrices.py | 14 +++--- src/sp_validation/b_modes.py | 43 ++++++++++++------- src/sp_validation/tests/test_b_modes.py | 24 +++++++++++ 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/papers/bmodes/scripts/config_space_pte_matrices.py b/papers/bmodes/scripts/config_space_pte_matrices.py index f2c559dd..f4365ec1 100644 --- a/papers/bmodes/scripts/config_space_pte_matrices.py +++ b/papers/bmodes/scripts/config_space_pte_matrices.py @@ -39,15 +39,19 @@ make_pte_norm, ) +from sp_validation.b_modes import bins_from_scale_cut + plt.style.use(PAPER_MPLSTYLE) def resolve_fiducial_bin_window(edges, theta_min, theta_max): - """Return the first and last reporting bins inside a scale-cut window.""" - left, right = edges[:-1], edges[1:] - inside = (left >= theta_min * (1.0 - 1e-2)) & (right <= theta_max * (1.0 + 1e-2)) - bins = np.flatnonzero(inside) - return int(bins[0]), int(bins[-1]) + """Return the first and last reporting bins of a pure-E/B scale cut. + + The cut snaps to the nearest reporting edges, as everywhere pure-E/B PTEs + are read (``sp_validation.b_modes.bins_from_scale_cut``). + """ + start, stop = bins_from_scale_cut(edges[:-1], edges[1:], (theta_min, theta_max)) + return start, stop - 1 def _path_matches_version(path, version): diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index 1f1b270f..7297316e 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -91,6 +91,23 @@ def bins_from_edges(left_edges, right_edges, min_scale=None, max_scale=None): return start_bin, stop_bin +def bins_from_scale_cut(left_edges, right_edges, scale_cut): + """Reporting bins inside a pure-E/B scale cut, as ``(start_bin, stop_bin)``. + + Each end of ``scale_cut`` snaps to the nearest reporting edge in log θ — + the rule that snaps reporting edges onto the fine grid — so a cut placed + on a nominal edge selects the same bins whichever fine grid the edges + were snapped to. ``stop_bin`` is exclusive. + """ + edges = np.append(left_edges, right_edges[-1]) + start_bin, stop_bin = ( + int(np.abs(np.log(edges) - np.log(cut)).argmin()) for cut in scale_cut + ) + if stop_bin <= start_bin: + raise RuntimeError(f"scale cut {scale_cut} selects no bins") + return start_bin, stop_bin + + def log_bin_edges(min_sep, max_sep, nbins): """TreeCorr ``Log`` bin edges — the grid a binning defines. @@ -640,7 +657,7 @@ def plot_integration_vs_reporting(results, output_path, version): def _get_pte_from_scale_cut(pte_matrix, edges, scale_cut): """ - Extract PTE value from matrix based on scale cut range using conservative logic. + Extract PTE value from matrix at a scale cut (:func:`bins_from_scale_cut`). Parameters ---------- @@ -663,13 +680,7 @@ def _get_pte_from_scale_cut(pte_matrix, edges, scale_cut): # Return full-range PTE (first row, last column) return pte_matrix[0, nbins - 1] - min_scale, max_scale = scale_cut - - start_bin, stop_bin = bins_from_edges(left_edges, right_edges, min_scale, max_scale) - - # Ensure valid range, otherwise fallback to full range - if stop_bin <= start_bin or start_bin >= nbins or stop_bin <= 0: - raise RuntimeError("Invalid scale cut range") + start_bin, stop_bin = bins_from_scale_cut(left_edges, right_edges, scale_cut) return pte_matrix[start_bin, stop_bin - 1] @@ -704,12 +715,16 @@ def plot_pure_eb_correlations( # Calculate combined PTE using off-diagonal covariance blocks # Get scale cuts for both xi+ and xi- if fiducial_xip_scale_cut is not None: - xip_start_bin, xip_stop_bin = bins_from_edges(*edges, *fiducial_xip_scale_cut) + xip_start_bin, xip_stop_bin = bins_from_scale_cut( + *edges, fiducial_xip_scale_cut + ) else: xip_start_bin, xip_stop_bin = 0, nbins if fiducial_xim_scale_cut is not None: - xim_start_bin, xim_stop_bin = bins_from_edges(*edges, *fiducial_xim_scale_cut) + xim_start_bin, xim_stop_bin = bins_from_scale_cut( + *edges, fiducial_xim_scale_cut + ) else: xim_start_bin, xim_stop_bin = 0, nbins @@ -836,11 +851,10 @@ def plot_pure_eb_correlations( scale_cuts = [(fiducial_xip_scale_cut, 0), (fiducial_xim_scale_cut, 1)] for scale_cut, ax_idx in scale_cuts: if scale_cut is not None: - min_scale, max_scale = scale_cut xlim = original_xlims[ax_idx] - # Use conservative scale_cut_to_bins helper for consistency - start_bin, stop_bin = bins_from_edges(*edges, min_scale, max_scale) + # The same bins the PTEs use + start_bin, stop_bin = bins_from_scale_cut(*edges, scale_cut) # Show excluded regions based on bin edges used in PTE calculation # Lower exclusion: bins 0 to start_bin-1 are excluded @@ -1123,8 +1137,7 @@ def plot_pte_2d_heatmaps( fiducial_scale_cuts = [fiducial_xip_scale_cut, fiducial_xim_scale_cut] for ax_idx, fiducial_scale_cut in enumerate(fiducial_scale_cuts): if fiducial_scale_cut is not None: - min_scale, max_scale = fiducial_scale_cut - start_bin, stop_bin = bins_from_edges(*edges, min_scale, max_scale) + start_bin, stop_bin = bins_from_scale_cut(*edges, fiducial_scale_cut) if stop_bin > start_bin and start_bin < nbins and stop_bin > 0: rect_x = start_bin diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index 0cecf8a9..e13aad47 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -561,6 +561,30 @@ def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi) # --------------------------------------------------------------------------- +def test_scale_cuts_select_the_same_bins_on_every_fine_grid(): + """A cut on nominal edges picks the same bins whatever grid they snapped to. + + [1, 250]′ in 20 bins snapped onto the cosmo_val (0.08–300′) and Paper II + (0.5–300′) fine grids moves edge 9 (11.997′) to either side of 12′, so + exact containment would disagree; snapping the cut to the nearest edge + selects bins 9–15 for [12, 83]′ and all bins for [1, 250]′ on both. + """ + requested = np.geomspace(1.0, 250.0, 21) + for lo in (0.08, 0.5): + fine = np.geomspace(lo, 300.0, 1001) + _, edges = b_modes._reporting_binning(np.ones(1000), fine, requested) + left, right = edges[:-1], edges[1:] + assert b_modes.bins_from_scale_cut(left, right, (12.0, 83.0)) == (9, 16) + assert b_modes.bins_from_scale_cut(left, right, (1.0, 250.0)) == (0, 20) + pte = np.arange(400.0).reshape(20, 20) + assert ( + b_modes._get_pte_from_scale_cut(pte, (left, right), (12, 83)) == pte[9, 15] + ) + + with pytest.raises(RuntimeError, match="selects no bins"): + b_modes.bins_from_scale_cut(left, right, (12.0, 12.5)) + + def test_log_bin_edges_matches_the_grid_stub(): """Edges reconstructed from a binning are the ones TreeCorr would report. From a990effd1fcdaf6b8f8291063bbdb54b9f897b9e Mon Sep 17 00:00:00 2001 From: Cail Daley Date: Thu, 1 Oct 2026 05:46:48 +0200 Subject: [PATCH 10/10] Pure E/B: run the transform on the regular log grid (TreeCorr rnom) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cosmo-numba's interpolator places its samples on a regular grid in log θ, so handing it TreeCorr meanr put every sample at its nominal position while the evaluation limit sat at the true meanr; where a spacing exceeded the mean step the limit fell past the extrapolation bound, and the old and new cosmo-numba operators resolved that knife-edge differently. The transform now runs on the geometric centres of the log-uniform fine edges (TreeCorr rnom) and is evaluated there; meanr enters only the reported θ. The edges are checked to be log-uniform, and pure_eb_operator no longer takes theta_int. On rnom the d78a189 and a64cb2e operators agree to round-off. Co-Authored-By: Claude Opus 5.5 --- src/sp_validation/b_modes.py | 64 +++++++++-------- src/sp_validation/tests/test_b_modes.py | 86 +++++++++++++---------- src/sp_validation/tests/test_cosmo_val.py | 5 +- 3 files changed, 85 insertions(+), 70 deletions(-) diff --git a/src/sp_validation/b_modes.py b/src/sp_validation/b_modes.py index 7297316e..083dafd1 100644 --- a/src/sp_validation/b_modes.py +++ b/src/sp_validation/b_modes.py @@ -185,34 +185,45 @@ def _reporting_binning(weight_int, edges_int, reporting_edges): return binning / weight[:, None], edges_int[snap] -def _fixed_quadrature_operator(theta_int): - """The Schneider (2022) transform at every fine node, ``_EB_KEYS`` order. - - The single call into cosmo_numba. Evaluating at every node of the fine - grid, with ``[tmin, tmax]`` at its extent, makes the transform a function - of the fine grid alone: the zero-padded ξ− window is extended from the - evaluation grid. Returns the ``(6, n_fine, 2 * n_fine)`` stack of the six - matrices acting on ``[xi_+; xi_-]``; rows whose integration support is too - small near the grid edges are NaN. +def _fixed_quadrature_operator(edges_int): + """The Schneider (2022) transform on the fine grid, ``_EB_KEYS`` order. + + The single call into cosmo_numba. Its interpolator places the samples on + a regular grid in log θ, so the transform runs on the log-uniform nodes of + the fine bins — their geometric centres, TreeCorr's ``rnom`` — and is + evaluated at every node, with ``[tmin, tmax]`` at their extent. That + makes it a function of the fine grid alone (the zero-padded ξ− window + extends from the evaluation grid). Returns the ``(6, n_fine, 2 * n_fine)`` + stack of the six matrices acting on ``[xi_+; xi_-]``; rows whose + integration support is too small near the grid edges are NaN. """ from cosmo_numba.B_modes.schneider2022 import get_pure_EB_operator + log_edges = np.log(np.asarray(edges_int, dtype=float)) + step = np.diff(log_edges) + if np.ptp(step) > 1e-10 * np.mean(step): + raise ValueError( + "the pure-E/B transform needs log-uniform fine-grid edges (TreeCorr " + f"Log binning); their log spacing varies by {np.ptp(step):.3e}" + ) + nodes = np.exp(0.5 * (log_edges[:-1] + log_edges[1:])) return np.stack( get_pure_EB_operator( - theta_int, - theta_int, - tmin=theta_int[0] * (1 - 1e-9), - tmax=theta_int[-1] * (1 + 1e-9), + nodes, + nodes, + tmin=nodes[0] * (1 - 1e-9), + tmax=nodes[-1] * (1 + 1e-9), local_from_int=True, ) ) -def pure_eb_operator(theta_int, weight_int, edges_int, reporting_edges): +def pure_eb_operator(weight_int, edges_int, reporting_edges): """The pure-E/B estimator as one matrix on the fine ξ± grid. The Schneider et al. (2022) transform is evaluated with fixed-quadrature - weights at the fine-grid nodes, integrating over the whole fine grid, and + weights at the log-uniform fine-grid nodes (TreeCorr ``rnom``), + integrating over the whole fine grid, and the six pure modes are then averaged into the reporting bins with TreeCorr pair weights. The reporting edges snap to the nearest fine edges, so each reporting bin is a union of fine bins. Both steps are linear and @@ -225,15 +236,13 @@ def pure_eb_operator(theta_int, weight_int, edges_int, reporting_edges): Parameters ---------- - theta_int : array_like - Fine (integration) grid, ascending and log-spaced — TreeCorr ``meanr``. - The transform's ``[tmin, tmax]`` is its extent, so it must reach - beyond the reporting range on both sides. weight_int : array_like TreeCorr pair weight ``Σ w_i w_j`` per fine bin (``gg.weight``), the averaging weights. edges_int : array_like - The ``n_fine + 1`` fine-grid bin edges. + The ``n_fine + 1`` log-uniform fine-grid bin edges. The transform's + ``[tmin, tmax]`` is the extent of their centres, so they must reach + beyond the reporting range on both sides. reporting_edges : array_like Requested reporting-bin edges, ``n_report + 1`` of them. @@ -246,14 +255,11 @@ def pure_eb_operator(theta_int, weight_int, edges_int, reporting_edges): edges : numpy.ndarray The reporting edges actually used, each a fine-grid edge. """ - theta_int = np.asarray(theta_int, dtype=float) - if np.shape(weight_int) != theta_int.shape: - raise ValueError("weight_int must have one entry per integration bin") binning, edges = _reporting_binning(weight_int, edges_int, reporting_edges) nodes = np.flatnonzero(binning.any(axis=0)) # Only the rows P averages enter; NaN edge rows outside them are dropped, # since 0 · NaN would still poison the product. - transform = _fixed_quadrature_operator(theta_int)[:, nodes] + transform = _fixed_quadrature_operator(edges_int)[:, nodes] undetermined = { key: int(np.count_nonzero(~np.isfinite(rows).all(axis=1))) for key, rows in zip(_EB_KEYS, transform) @@ -298,8 +304,10 @@ def calculate_pure_eb_correlation( ---------- theta_int, xip_int, xim_int, weight_int : array_like Fine-grid ``meanr``, ξ±, and TreeCorr pair weight ``Σ w_i w_j``. + ``meanr`` enters only the reported ``theta``; the transform runs on + the log-uniform nodes of ``edges_int``. edges_int : array_like - The ``n_fine + 1`` fine-grid bin edges. + The ``n_fine + 1`` log-uniform fine-grid bin edges. cov_xi : array_like ``(2 n_fine, 2 n_fine)`` covariance of ``[xip_int; xim_int]``. reporting_edges : array_like @@ -322,9 +330,9 @@ def calculate_pure_eb_correlation( np.asarray(a, dtype=float) for a in (theta_int, xip_int, xim_int) ) cov_xi = np.asarray(cov_xi, dtype=float) - operator, binning, edges = pure_eb_operator( - theta_int, weight_int, edges_int, reporting_edges - ) + if np.shape(weight_int) != theta_int.shape: + raise ValueError("weight_int must have one entry per integration bin") + operator, binning, edges = pure_eb_operator(weight_int, edges_int, reporting_edges) if cov_xi.shape != (operator.shape[1],) * 2: raise ValueError( f"cov_xi has shape {cov_xi.shape}; the fine grid needs " diff --git a/src/sp_validation/tests/test_b_modes.py b/src/sp_validation/tests/test_b_modes.py index e13aad47..2ed8a341 100644 --- a/src/sp_validation/tests/test_b_modes.py +++ b/src/sp_validation/tests/test_b_modes.py @@ -324,52 +324,52 @@ def test_calculate_eb_statistics_has_teeth(): # estimator is meant to move. _PURE_EB_PINS = { "xip_E": [ - 0.00012675909649037545, - 0.00011663318638709152, - 0.00011284314562619557, - 0.00011343413369325459, - 9.414565637924643e-05, - 8.505124884351236e-05, + 0.00012671119742922553, + 0.00011647263203301798, + 0.00011277680462627065, + 0.00011334741455438306, + 9.407280835053578e-05, + 8.500501372536384e-05, ], "xim_E": [ - 7.846111474871327e-06, - -2.1614773716458632e-08, - 1.2479998822054037e-06, - 5.931629229668291e-06, - -5.7485620307162e-06, - 5.307223369378577e-06, + 7.684864690558884e-06, + -2.9296916553309855e-07, + 1.1150412226648596e-06, + 5.81495542664638e-06, + -5.8802091546090845e-06, + 5.245220040191294e-06, ], "xip_B": [ - -5.8866840097316286e-05, - -5.4149816532331074e-05, - -7.031793541704363e-05, - -6.464794021486418e-05, - -6.438963410562624e-05, - -5.782183699830983e-05, + -5.872804581564311e-05, + -5.3898600777518044e-05, + -7.01611878338831e-05, + -6.447148201049941e-05, + -6.422794082425538e-05, + -5.7688865883825626e-05, ], "xim_B": [ - -1.2460601540345636e-05, - 1.1644080110724575e-06, - 3.784162756126911e-06, - 9.134846520628891e-06, - 5.499203647248232e-06, - 4.460848294579186e-06, + -1.2625808436389906e-05, + 8.906960918149856e-07, + 3.6497760325039334e-06, + 9.0173210532702e-06, + 5.367045395488404e-06, + 4.3985417696428825e-06, ], "xip_amb": [ - 8.98449418084559e-05, - 8.87953653567104e-05, - 8.704153035693577e-05, - 8.410960918777129e-05, - 7.92309177425968e-05, - 7.107059682711196e-05, + 8.975404658793264e-05, + 8.870470395597095e-05, + 8.695112377370022e-05, + 8.401987012227805e-05, + 7.914207248993659e-05, + 7.098386083077631e-05, ], "xim_amb": [ - 1.5108576414194206e-06, - 9.059561894983458e-07, - 5.429016867192606e-07, - 3.2507308083821834e-07, - 1.9496276632356097e-07, - 1.167636418246594e-07, + 1.5068975296876175e-06, + 9.035986620575128e-07, + 5.414736226368331e-07, + 3.2422141650144327e-07, + 1.9445163845662097e-07, + 1.1646044607564388e-07, ], } @@ -429,7 +429,6 @@ def test_pure_eb_covariance_is_the_operator_sandwich(pure_eb_xi): cov_xi = _spd(2 * n_fine, seed=7) r = b_modes.calculate_pure_eb_correlation(**pure_eb_xi, cov_xi=cov_xi, npatch=40) K, P, edges = b_modes.pure_eb_operator( - pure_eb_xi["theta_int"], pure_eb_xi["weight_int"], pure_eb_xi["edges_int"], pure_eb_xi["reporting_edges"], @@ -540,6 +539,18 @@ def test_pure_eb_binning_reproduces_the_reporting_measurement(requested): ) +def test_pure_eb_transform_needs_a_log_uniform_grid(): + """The transform runs on log-uniform nodes and refuses irregular edges. + + cosmo_numba's interpolator places samples on a regular grid in log θ, so + edges that are not log-uniform would silently mis-place them. + """ + edges = np.geomspace(1.0, 300.0, 61) + edges[30] *= 1.001 + with pytest.raises(ValueError, match="log-uniform"): + b_modes._fixed_quadrature_operator(edges) + + def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi): """Reporting bins that reach the fine grid's floor raise, never return NaN. @@ -549,7 +560,6 @@ def test_pure_eb_operator_refuses_a_reporting_floor_at_the_grid_edge(pure_eb_xi) edges_int = pure_eb_xi["edges_int"] with pytest.raises(ValueError, match="under-determined"): b_modes.pure_eb_operator( - pure_eb_xi["theta_int"], pure_eb_xi["weight_int"], edges_int, np.geomspace(edges_int[0], 70.0, 7), diff --git a/src/sp_validation/tests/test_cosmo_val.py b/src/sp_validation/tests/test_cosmo_val.py index 008614f8..c08c055e 100644 --- a/src/sp_validation/tests/test_cosmo_val.py +++ b/src/sp_validation/tests/test_cosmo_val.py @@ -636,10 +636,7 @@ def test_calculate_pure_eb_runs_on_synthetic_catalog(self, tmp_path, pure_eb_xi) ) operator, _, edges = b_modes.pure_eb_operator( - *( - measured[k] - for k in ("theta_int", "weight_int", "edges_int", "reporting_edges") - ) + *(measured[k] for k in ("weight_int", "edges_int", "reporting_edges")) ) np.testing.assert_array_equal(results["left_edges"], edges[:-1]) modes = operator @ np.concatenate([measured["xip_int"], measured["xim_int"]])