Skip to content

Unify hankel transforms#40

Open
bclevine wants to merge 13 commits into
mainfrom
unify_hankel
Open

Unify hankel transforms#40
bclevine wants to merge 13 commits into
mainfrom
unify_hankel

Conversation

@bclevine

Copy link
Copy Markdown
Collaborator

Description

This PR addresses #33, combining the Hankel functionality into a single public module. Changes include the following:

  1. Created a new HankelTransformBase parent class for all the Hankel transform methods.
  2. Moved the matrix method currently implemented in the covariance module to HankelTransformMatrixZeros, and the FFTLog method currently implemented in the utils module to HankelTransformFFTLog.
  3. Added a new matrix Hankel method, HankelTransformMatrixDirect, which in some (but not all!) instances can be an improvement over HankelTransformMatrixZeros.
  4. Added a new public-facing HankelTransform class which uses one of the three aforementioned methods as a backend and provides consistent, public access to the major Hankel transform operations.
  5. Added associated tests and benchmarks.

@bclevine bclevine added enhancement New feature or request modelling labels Jun 12, 2026

@nikosarcevic nikosarcevic left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

first of all great work, ben!

i am goign to split this in several chunks as i am afrid ill loose my comments

order: Bessel order to use.

Returns:
Radial grid and projected radial statistic.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add Raises section after Returns.

from dsf.utils.validators import validate_positive_scalar


class HankelTransformMatrixDirect(HankelTransformMatrixZeros):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we need to call it MatrixZeros? WDYT? Perhpas just calling it HankelTransformMatrix woudl be ok?

k_max: Maximum wavenumber to cover.
n_r: Number of radial grid points to use for the Hankel transform.
n_k: Number of wavenumber grid points to use for Hankel transform.
orders: Bessel orders to precompute.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sohuld this arg be order instead of orders?

This module provides the ``HankelTransform`` class, which converts
Fourier-space spectra into projected radial-space quantities. It is useful for
computing projected correlation functions, covariance matrices, and higher-order
This module provides the ``HankelTransformMatrixZeros`` class, which converts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if we change to HankelTransformMatrix, do not forget to change it here

)
return self._project_spectra_to_radial([c_ell_eval], order)

# def spherical_correlation(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if obsolete now, delete everything. if we ever need it, we can go back in git history.

dln_ell = float(np.log(ell_arr[1] / ell_arr[0]))
offset = fhtoffset(dln=dln_ell, mu=order) if use_offset else 0.0

transformed_power = ifht(c_ell_arr * ell_arr, dln=dln_ell, mu=order, offset=offset)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrap it if you can multiline

secondlycan we add or point to an analytic/numerical regression test for this normalization? The formula is easy to get wrong by a factor of ell, theta, or 2*pi, so it wold be good to compare against direct quadrature for a smooth test spectrum

Comment on lines +202 to +204
"""Convert power spectrum to 3D correlation function using FFTLog:

:math:`\\xi(r) = \\int \\frac{k^2 dk}{2\\pi^2} P(k) j_\\mu(kr)`.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snice only order=0 is implemented below, can we make this docstring slightly more specific?

the formula currently writes j_mu, but for spherical bessel transforms the order is usually written as j_l, with the fftlog cylindrical bessel order being mu = l + 1/2.

Returns:
Radial grid and 3D correlation function.
"""
if order != 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this check happen before the docstring claims a general j_mu spherical transform?

or perhpas even simpler--- can the docstring say this helper currently implements only the l=0 spherical correlation?

Comment on lines +224 to +226
r = 1.0 / k_arr[::-1]
dln_k = float(np.log(k_arr[1] / k_arr[0]))
offset = fhtoffset(dln=dln_k, mu=0.5) if use_offset else 0.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same offset-grid question as above: if use_offset=True, should the returned r grid include the ftlog offset factor rather than always using 1.0 / k_arr[::-1]?

Comment on lines +228 to +236
transformed_power = ifht(
k_arr**1.5 * pk_arr,
dln=dln_k,
mu=0.5,
offset=offset,
)

prefactor = 1.0 / (2.0 * np.pi * r) ** 1.5
xi = np.asarray(prefactor * transformed_power, dtype=float)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we maybe add a direct-quadrature regression test for the spherical normalization too?
mapping from the spherical bessel transform to scipy's cylindrical fftlog convention is subtle, so a test would give us confidence in the k_arr**1.5 input factor and (2*pi*r)**(-1.5) prefactor

@@ -0,0 +1,148 @@
"""Hankel transforms for projected radial statistics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we have "projected radial statistics", which is fine, but maybe worth saying this is the direct-matrix implementation specifically.

so perhaps we can say

"""Direct matrix Hankel transforms for projected radial statistics."""

@@ -0,0 +1,148 @@
"""Hankel transforms for projected radial statistics.

This module provides the ``HankelTransformMatrix`` class, which converts

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring says this module provides HankelTransformMatrix, but the class below is HankelTransformMatrixDirect. Could we update the class name in the docstring?

Comment on lines +5 to +7
Hankel operator grids along a predefined radial grid. It is useful for computing
projected correlation functions, covariance matrices, and higher-order
radial tensors that appear in weak-lensing and Delta Sigma calculations.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we clarify the covnention here: this direct matrix backend appears to compute projected transforms of the form int k dk / (2 pi) P(k) J_n(k r).

since this is now a standalone Hankel module, it would help to state the normalization and reciprocal units explicitly

Comment on lines +9 to +10
The class owns the public validation layer and delegates low-level radial and
Bessel operations to ``hankel_utils``.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this file does not directly delegate bessel operations to hankel_utils; it builds the bessel matrix directly with scipy.special.jv. can you udpate this sentence or just remove it?

import numpy as np
from scipy.special import jv

from dsf.hankel.hankel_transform_matrix_zeros import HankelTransformMatrixZeros

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this class inherits from HankelTransformMatrixZeros, which is useful for sharing the public matrix API, but it is a little surprising because the numerical grids are not zero-crossing grids.

can we add a short comment/docstring note that this subclass intentionally reuses the matirx interface/binning helpers from the zeros backend while overriding the grid construction?

order: Bessel order used by the projected statistic.
"""

k = np.geomspace(self.k_min, self.k_max, self.n_k)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

np.gradien(np.log(k)) gives endpoint weights that are not exactly trapezoidal integration weights

is this intentional? for direct quadrature, should these weights instead use explicit trapezoidal/log-space integration weights to better control endpoint behavior?

dlnk = np.gradient(np.log(k))
weight = k**2 * dlnk / (2.0 * np.pi)

r = np.geomspace(self.r_min, self.r_max, self.n_r)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can u confirm that all allowed order values are non-negative? jv can evaluate more general orders, but the physical bessel orders used here should probably be validated epxlicitly

Comment on lines +126 to +130
"""
self._check_order(order)

product = np.ones_like(self.k[order])
for spectrum in spectra:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we validate that each spectrum has the same shape as self.k[order] before multiplying?

this may already happen upstream, but checking here would give a clearer error if a backend method passes a weird array

so smth like

for spectrum in spectra:
    if spectrum.shape != self.k[order].shape:
        raise ValueError(
            "Each spectrum must be evaluated on the internal k grid."
        )
    product *= spectrum

Comment on lines +134 to +137
j = self.j[order]
j_t = self.j_t[order]

ndim = len(spectra)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the one- and two-spectrum branches look consistent with the projected convention: j @ (weight * product) for a radial vector and (j * weight * product) @ j_t for a radial covariance matrix

would be great to have a direct-quadrature regression test covering both of these normalizations

transformed = j @ (weight * product)
elif ndim == 2:
transformed = (j * weight * product) @ j_t
elif ndim == 3:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so hmm i think this 3-spectrum branch is not computing a third-order radial tensor correctly

shape-wise, j @ (weight * product) gives a 1D radial vector, and then multiplying by j_t @ j_t only works accidentally when n_r == n_k; it does not produce an (n_r, n_r, n_r) tensor

If the intended statistic is

T(r1, r2, r3) = int k dk / (2 pi) product(k) J(r1 k) J(r2 k) J(r3 k), 

then this should probably be an einsum

transformed = np.einsum(
    "ak,bk,ck,k->abc",
    j,
    j,
    j,
    weight * product,
)

can u fix this and add a test with n_r != n_k so the shape bugzzzz cannot hide?

Fourier-space spectra into projected radial-space quantities using precomputed
Hankel operator grids along the Bessel zeros. It is useful for computing
projected correlation functions, covariance matrices, and higher-order
radial tensors that appear in weak-lensing and Delta Sigma calculations.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny terminology nit: elsewhere we usually write DeltaSigma I think. Could we make this consistent with the rest of DSF docs?

radial tensors that appear in weak-lensing and Delta Sigma calculations.

The class owns the public validation layer and delegates low-level radial and
Bessel operations to ``hankel_utils``.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this says low-level radial and bessel operations are delegated to hankel_utils, but this class directly builds the bessel matrix with scipy.special.jv.

so maybe rephrase to say that shared helper operations, such as bessel zeros, binning, and covariance utilities, are delegated to hankel_utils.

Comment on lines 48 to 51
r_min: Minimum radial scale to cover.
r_max: Maximum radial scale to cover.
k_min: Minimum wavenumber to cover.
k_max: Maximum wavenumber to cover.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can u state the reciprocal unit reqs here? r_min/r_max and k_min/k_max need to use reciprocal units so that k * r is dimensionless. this is mui importante in DSF because user-facing radial quantities may be Mpc/h while some internal calculations may use Mpc.

Comment on lines 85 to 90
self.n_zeros = int(n_zeros)
self.n_zeros_step = int(n_zeros_step)
self.prune_r = prune_r
self.prune_log_space = bool(prune_log_space)
self.verbose = bool(verbose)
self.max_iterations = int(max_iterations)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid silently truncating integer-like inputs? int(n_zeros), int(n_zeros_step), and int(max_iterations) will silently turn e.g. 1000.9 into 1000.

it would be safer to validate that these are integer-like before casting

Comment on lines 121 to 126
if self.n_zeros <= 0:
raise ValueError("n_zeros must be positive.")
if self.n_zeros_step <= 0:
raise ValueError("n_zeros_step must be positive.")
if self.max_iterations <= 0:
raise ValueError("max_iterations must be positive.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we vaildate that n_zeros, n_zeros_step, and max_iterations are at least 1 as int-like values before casting?

right now non-integer floats can be silently truncated in __init__.

Comment on lines 394 to 396
product = np.ones_like(self.k[order])
for spectrum in spectra:
product *= spectrum

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we validate each spectrum shape before multiplying? _evaluate_spectrum() should already enforce this, but this private helper accepts list[FloatArray] directly, so a local check would give a clearer error if it is reused internally

so smth like

for spectrum in spectra:
    if spectrum.shape != self.k[order].shape:
        raise ValueError(
            "Each spectrum must match the internal k-grid shape."
        )
    product *= spectrum

Comment on lines 398 to 417
weighted = product / self.j_next_at_zeros[order] ** 2
j_matrix = self.j[order]
norm = self.normalization[order]
ndim = len(spectra)

if ndim == 1:
transformed = np.dot(j_matrix, weighted) * norm
elif ndim == 2:
transformed = np.dot(j_matrix, (j_matrix * weighted).T) * norm
elif ndim == 3:
transformed = (
np.einsum(
"az,bz,cz,z->abc",
j_matrix,
j_matrix,
j_matrix,
weighted,
)
* norm
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

projection code is nicely centralized :)

can we document the output shapes here? For one, two, and three spectra I think these are (n_r,), (n_r, n_r), and (n_r, n_r, n_r).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the 3-spectr branch looks much better here than in the direct backend because it uses an explicit einsum and should produce an (n_r, n_r, n_r) tensor

can we add a test that confirms the shape and symmetry under swapping the three input spectra?

Comment on lines 500 to 511
k_pk: ArrayLike | None = None,
pk1: SpectrumInput | None = None,
pk2: SpectrumInput | None = None,
order: float | int = 0,
taper: bool = False,
taper_kwargs: dict | None = None,
**kwargs,
) -> tuple[FloatArray, FloatArray]:
"""Compute a projected covariance matrix from two spectra.

Args:
k_pk: Wavenumber grid for tabulated spectra.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clarify whether k_pk is actually a 3D wavenumber grid here or an angular multipole grid?
In projected_correlation() the analogous input is ell, but covariance uses k_pk

If both are using the same projected hankel convention, consistent naming would reduce confusion

Comment on lines 618 to 620
expected_shape = tuple([r_arr.size] * matrix_arr.ndim)
if matrix_arr.shape != expected_shape:
raise ValueError(f"matrix shape must be {expected_shape}. Got {matrix_arr.shape}.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff

raise ValueError(
    f"matrix shape must be {expected_shape}. Got {matrix_arr.shape}."
)

Comment on lines 624 to 662
def correlation_matrix(self, covariance: FloatArray) -> FloatArray:
"""Return the correlation matrix associated with a covariance matrix.

Args:
covariance: Covariance matrix.

Returns:
Dimensionless correlation matrix.
"""
covariance_arr = np.asarray(covariance, dtype=float)

if covariance_arr.ndim != 2:
raise ValueError("covariance must be two-dimensional.")
if covariance_arr.shape[0] != covariance_arr.shape[1]:
raise ValueError("covariance must be square.")
if np.any(~np.isfinite(covariance_arr)):
raise ValueError("covariance must contain only finite values.")

return compute_correlation_matrix(covariance_arr)

def diagonal_error(self, covariance: FloatArray) -> FloatArray:
"""Return one-sigma errors from a covariance matrix.

Args:
covariance: Covariance matrix.

Returns:
Square root of the covariance diagonal.
"""
covariance_arr = np.asarray(covariance, dtype=float)

if covariance_arr.ndim != 2:
raise ValueError("covariance must be two-dimensional.")
if covariance_arr.shape[0] != covariance_arr.shape[1]:
raise ValueError("covariance must be square.")
if np.any(~np.isfinite(covariance_arr)):
raise ValueError("covariance must contain only finite values.")

return compute_diagonal_error(covariance_arr)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these covariance utility wrappers are clean

can we add or confirm tests for zero or negative diagonal entries? correlation_matrix and diagonal_error should fail clearly if the covariance diagonal is not physically valid, unless hankel_utils already handles that

@@ -1 +1 @@
"""Numerical scripts for projected radial statistics.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"scripts" sounds like executable scripts, but this is a utility module. use "Numerical utilities for projected radial statistics" instead?

Comment on lines 3 to 7
This module provides small utilities used when converting Fourier-space
power spectra into radial-space projected statistics. The scripts cover
Bessel-function roots, smooth spectrum tapering, radial bin centers,
radial integration weights, covariance-to-correlation conversion, and
bin-averaging of radial matrices or tensors.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mention that these helpers are intentionally low-level and assume validated inputs? That is stated below, but I would avoid calling them "scripts" here too. Maybe "utilities" throughout.

Comment on lines 32 to 35
def _is_non_negative_integer(value: float | int) -> bool:
"""Return whether a value is a valid integer Bessel order."""
value_float = float(value)
return value_float >= 0.0 and value_float.is_integer()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this helper reject non-finite values explicitly? float(np.nan) works and then returns false, but for a validation-style helper it may be clearer to say finite non-negative integer orders only

Comment on lines 48 to 55
if n_zeros <= 0:
raise ValueError("n_zeros must be positive.")

if _is_non_negative_integer(order):
return np.asarray(jn_zeros(int(order), n_zeros), dtype=float)

order_float = float(order)
roots = np.empty(n_zeros, dtype=float)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we validate that order is finite and non-negative before computing zeros? the int path handles negative integers by falling through, but then non-integer negative orders can reach the root bracketing logic. Since the backends only allow non-negative Bessel orders, it would be safer for this utility to enforce the same contract

smth like

order_float = float(order)
if not np.isfinite(order_float):
    raise ValueError("order must be finite.")
if order_float < 0:
    raise ValueError("order must be non-negative.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then use oder_float below!!

Comment on lines 48 to 55
if n_zeros <= 0:
raise ValueError("n_zeros must be positive.")

if _is_non_negative_integer(order):
return np.asarray(jn_zeros(int(order), n_zeros), dtype=float)

order_float = float(order)
roots = np.empty(n_zeros, dtype=float)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

validate that n_zeros is integer-like. The type hint says int, but a float can still be passed at runtime and np.empty(n_zeros, ...) will fail with a less clear error.

Comment on lines 212 to 217
bin_grids = np.meshgrid(*([bin_index] * ndim), indexing="ij")
valid_grids = np.meshgrid(*([valid] * ndim), indexing="ij")
valid_matrix = np.logical_and.reduce(valid_grids)

output_indices = tuple(grid[valid_matrix] for grid in bin_grids)
np.add.at(binned_sum, output_indices, weighted_matrix[valid_matrix])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for high-rank tensors, this builds full meshgrid arrays with shape (n_r,) * ndim.
That is fine for 2D/3D with moderate grids, but it can get very memory-heavy

we can document that this helper is intended for small binned tensors, or use an einsum/loop approach if large tensors are expected?

Comment on lines 219 to 223
norm = _outer_product(bin_weight_sums, ndim)
binned = np.zeros_like(binned_sum)

nonzero = norm != 0.0
binned[nonzero] = binned_sum[nonzero] / norm[nonzero]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same empty-bin concern: leaving binned values at zero when norm == 0 can look like a real measurement. I would prefer raising if any requested bin has zero radial weight

Comment on lines +228 to +235
def apply_taper_spectrum(
k: FloatArray,
pk: FloatArray,
large_k_lower: float = 10.0,
large_k_upper: float = 100.0,
low_k_lower: float = 0.0,
low_k_upper: float = 1.0e-5,
) -> FloatArray:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we validate the taper bounds? we need large_k_upper > large_k_lower, low_k_upper > low_k_lower, and probably low_k_upper <= large_k_lower. otherwise this can divide by zero or create overlapping low/high tapers

Comment on lines +254 to +261
high = k > large_k_lower
pk_out[high] *= np.cos(
(k[high] - large_k_lower) / (large_k_upper - large_k_lower) * np.pi / 2.0
)
pk_out[k > large_k_upper] = 0.0

low = k < low_k_upper
pk_out[low] *= np.cos((k[low] - low_k_upper) / (low_k_upper - low_k_lower) * np.pi / 2.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the taper formula itself looks good, but can we add tests checking continuity at low_k_upper, large_k_lower, and zeroing beyond low_k_lower/large_k_upper?

pk_out = np.copy(pk)

high = k > large_k_lower
pk_out[high] *= np.cos(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff

phase = (
    (k[high] - large_k_lower)
    / (large_k_upper - large_k_lower)
    * np.pi
    / 2.0
)
pk_out[high] *= np.cos(phase)

check all lines L261, low k taper etc

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request modelling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants