Skip to content

HEALPix kernel: interface parity with the planar kernel, angular power spectrum, masked data and synthesis - #53

Open
jmdelouis wants to merge 21 commits into
masterfrom
HealpixUpdate
Open

jmdelouis wants to merge 21 commits into
masterfrom
HealpixUpdate

Conversation

@jmdelouis

Copy link
Copy Markdown

What this does

Brings STL_Healpix_Kernel_Torch up to the same interface as
STL_2D_Kernel_Torch, so that the data-type independent machinery
(ST_Operator, ST_Statistics, Synthesis) runs unchanged on the sphere.

It also removes the foscat dependency: SphericalStencil imported it at module
load, which made import STL_main.STL_Healpix_Kernel_Torch fail outright on any
environment without that package. The spherical geometry now comes from
healpix-analyse.

Why the HEALPix kernel had diverged

Not in the spherical convolution, but in the division of responsibilities: the
HEALPix version kept the statistics (mean, cov) on the data class, while
the planar kernel had moved them onto the wavelet operator — the only object
that knows the mask, the convolution history and the borders.

Changes

Data-type independence

  • Base_DataClass: NDIM_PIX (2 planar, 1 HEALPix) and an overridable
    _infer_N0; new_like() to build a companion field on the same geometry;
    apply_bandlimit() as a hook for the band-limiting of an initial random field.
    N0 is no longer overwritten when it is passed explicitly.
  • ST_Operator.apply: six slicings assumed two pixel axes
    (data_l1m[j3][:, ch, :, :, :]). The trailing : were redundant; dropping
    them makes the code work for any number of pixel axes, with no change in 2D.
  • torch_backend.maskmean: accepts any set of trailing dimensions, not only
    (-2, -1).
  • ST_Statistics: carries data_example (an empty copy of the input) and
    pix_shape, which is not N0 for every data type.

HEALPix kernel

  • Derives from Base_DataClass, with pbc / dg / N0=(nside,) /
    conv_history / cell_ids / nest; gains divide, get_ST_op, get_CS_op.
  • Statistics moved onto the operator: mean, square_mean, cov,
    standardize, unstandardize, _compute_and_store_cross_cov, j_to_dg,
    mask_full_res, and a downsample with the planar signature.
  • Convolution via healpix_analyse.convol.HealPixConv: the complex kernel is
    carried as two output channels, the L orientations are the L gauges, so one
    call returns the complex answer. Anti-aliased decimation via
    healpix_analyse.down.HealPixDown, one level at a time.
  • Operators are cached per (resolution, grid, gauge count); a full sky in
    canonical order takes the package's fast path.

Angular power spectrum (CS_operator_Healpix_Torch)

  • Same public contract as the planar operator (n_bins, bin_centers,
    apply(...) -> [Nb, Nc, Nc, n_bins], plot_cross_spectrum), but the estimator
    is C_ell, built on healpix_analyse.healpix_sht.HEALPixSHT.
  • Full sky: map2alm once per channel, then every requested pair — Nc
    transforms instead of Nc². A reference route through anafast pair by pair
    is kept behind cross_spectrum_method="anafast".
  • Partial sky: the map is zero-padded outside cell_ids, band-filtered with
    alm2map, and the cross-covariance is taken over the observed pixels,
    C_b = 4π ⟨f_b·g⟩_obs / Σ_l (2l+1) W_b(l).
  • Binning: log-spaced multipole bands weighted by (2l+1) times a log-Gaussian
    window — the spherical transposition of _build_log_gaussian_bin_masks.

Masked data

  • The per-(layer, resolution) invalid masks and the reweighting maps are
    precomputed once, the spherical counterpart of
    _build_reweighting_maps_and_scattering_layer_masks. The mask is eroded by a
    kernel of ones over the L gauges, i.e. the exact union of the supports used;
    HealPixDown applied to the mask gives the local invalid fraction f, and a
    coarse pixel is either declared invalid past
    downsample_nan_weight_threshold or rescaled by 1/(1-f).
  • The mask is inferred from the NaNs of the input when none is given;
    mask_full_res=False opts out.
  • Bug fix in cov: for S4 both operands sit at the same depth but on
    different scales (|I*ψ_j1|*ψ_j3 vs |I*ψ_j2|*ψ_j3), so neither mask contains
    the other and the union is required. Only the first was used.

Synthesis

  • Shape logic driven by NDIM_PIX; companion fields built from a prototype
    through new_like rather than from the class alone; the Nyquist prefilter
    becomes apply_bandlimit on the data class (Nyquist disc in the plane,
    spherical harmonic round trip on the sphere). ScatteringMatchModel accepts
    either a class (legacy) or a prototype, so the planar path is unchanged.

SphericalStencil

  • import foscat.scat_cov removed. Down / Up reimplemented in healpy +
    torch: Down reproduces hp.ud_grade exactly, Up reproduces
    hp.get_interp_val to 3e-16, both differentiable and complex-capable. Also
    fixes self.cell_idsself.cell_ids_default, which made the
    cell_ids=None branch of both raise AttributeError.
  • The scat_op constructor argument

@jmdelouis

Copy link
Copy Markdown
Author

Remove python 3.9 ? very old version.
Here is some more information about the modifications:
SphericalStencil

  • import foscat.scat_cov removed. Down / Up reimplemented in healpy +
    torch: Down reproduces hp.ud_grade exactly, Up reproduces
    hp.get_interp_val to 3e-16, both differentiable and complex-capable. Also
    fixes self.cell_idsself.cell_ids_default, which made the
    cell_ids=None branch of both raise AttributeError.
  • The scat_op constructor argument is kept for compatibility but is now unused.

Packagingpyproject.toml declared no dependencies at all. Adds
numpy, torch, scipy, matplotlib, healpy, healpix-analyse, and
requires-python = ">=3.10".

Validation

Two notebooks under tests/certification_notebooks/Healpix_Kernel_Torch/, both
in English, both executed end to end with no failing cell:

  • CN_Healpix_Kernel_Torch_Interface.ipynb (57 cells) — interface parity member
    by member, plus a programmatic comparison against the planar API that reports
    no required member missing.
  • Certification_Notebook_Healpix_kernel.ipynb (24 cells) — brought back to the
    current API (it used use_NaN=True and an optimize_scattering_LBFGS that
    does not exist) and kept as the usage notebook: wavelets, holes, normalisation,
    synthesis.

Numbers worth quoting:

check result
internal C_ell vs HEALPixSHT.anafast (auto-spectrum) 7e-16
"alm" route vs "anafast" route, after binning 2e-16
band-filtered route vs harmonic route on a full sky 4e-16
vs healpy.anafast, ell < lmax/2 0.12 %
predicted masks vs where the NaNs actually land (layers 1 and 2) exact
constant field through the masked decimation 1.000000 (0.79 without reweighting)
S2 bias, 17.7 % band cut, finest scale 0.8 %
synthesis: power spectrum / S1–S4 recovered 0.4 % / 1.3 %

The planar kernel is checked for non-regression throughout, including its masked
path and its FFT power spectrum.

Behaviour changes reviewers should know

  • ST_Operator no longer builds a cross-spectrum operator when
    compute_PS=False; apply(compute_PS=True) then raises explicitly.
  • The angular power spectrum refuses NaN input rather than returning something
    meaningless — restrict the map through cell_ids, or use compute_PS=False.
  • ST_Statistics.__init__ gained two optional trailing arguments.
  • Base_DataClass.N0 keeps an explicitly passed value instead of re-deriving it
    from the array shape. No call site in the repository relied on the old
    behaviour.

Known limitations

  • Partial-sky spectra are pseudo-C_ell: the mask-induced mode coupling is not
    deconvolved (no MASTER matrix), exactly as the planar operator does not
    deconvolve its crop window. Amplitude is recovered to ~1 % on half a sky.
  • The kernel's radial profile and angle convention are not yet aligned with the
    planar ones, so coefficients are not directly comparable between data types.
  • Faint diamond patterns are visible on synthesised maps at the boundaries of the
    twelve HEALPix faces — a signature of the gauge-based convolution, worth
    watching at higher resolution.

Follow-up, not addressed here

ST_Operator.apply reshapes data.array in place to (Nb, Nc, Npix), so an
object passed to it comes back modified. Nothing breaks today, but it is
surprising and worth fixing separately.

###########################################################################
def __init__(self, array, nside=None, cell_ids=None, nest=True):
@classmethod
def _infer_N0(cls, array):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_infer_N0 n'est pas appliquée dans ce fichier. L'inférence de N0 est faite par _infer_N0 du fichier base_dataclass. Cependant, celle-ci renvoie le nombre de pixels (Npix,) au lieu de déterminer plutôt le paramètre nside (dans le cas idéal full-sky).

Comment thread STL_main/SphericalStencil.py Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Signe « − » sur le troisième terme dans vec_np[:, 2] = np.sqrt(1.0 - vec_np[:, 0] ** 2 + vec_np[:, 1] ** 2).

Comment thread STL_main/SphericalStencil.py Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Import interne dans une méthode à éviter : torch et numpy sont déjà importés au niveau du module. Remarque identique pour les méthodes _is_varlength_batch (l. 1352), Convol_torch (l. 951) et make_matrix (l. 1144).

Comment thread STL_main/SphericalStencil.py Outdated

@dtibi69 dtibi69 Sep 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Si, pour une jauge $g$ et un couple $(k, p)$, aucun des voisins n'est présent, cette condition if ne change-t-elle rien aux poids ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oui, tu as raison. Dans cette ancienne implémentation, si aucun des quatre voisins n’était présent, present[:, col] était entièrement faux. Le traitement de zero_cols faisait alors :

w[0, zero_cols[0]] = present[0, zero_cols[0]].to(w.dtype)

Comme present[0, col] valait également False, cette affectation remettait simplement un poids nul. La somme de la colonne restait donc nulle. Le clamp_min(1e-12) empêchait une division par zéro, mais le résultat de l’interpolation restait une contribution entièrement nulle. Ce n’était donc pas un véritable fallback.
Le code tentait auparavant de remplacer une colonne vide par la colonne correspondant au centre du stencil. Cela traitait le cas courant où le centre possédait au moins un voisin présent dans le domaine local, mais pas le cas où cette colonne centrale était elle-même vide.

Cette logique n’est désormais plus utilisée : l’implémentation locale de SphericalStencil a été supprimée et les convolutions ainsi que la gestion de leur support sont déléguées à healpix-analyse. Il n’y a donc plus, dans STL, cette normalisation locale des poids ni ce cas silencieux où une colonne vide produit une sortie nulle.

Comment thread STL_main/SphericalStencil.py Outdated
src = ref_cols[empty_cols]

# copie idx/w de la colonne 'centre'
# copy idx/w from the 'centre' column

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Prévoir un plan de secours si le centre du stencil local n'a lui aussi aucun voisin présent ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oui, un second niveau de fallback aurait été nécessaire dans cette implémentation.
La copie de la colonne centrale supposait implicitement que le centre du stencil avait au moins un voisin présent dans ids_sorted. Si cette hypothèse était fausse, la colonne copiée restait vide et la normalisation suivante produisait uniquement des poids nuls. Le calcul restait numériquement fini grâce au clamp_min, mais le résultat n’avait pas de signification correcte : il revenait à remplacer silencieusement la contribution par zéro.
Si nous avions conservé cette implémentation, le comportement robuste aurait été :

  1. tenter d’utiliser les voisins du point demandé ;
  2. en leur absence, tenter le centre du stencil ;
  3. si le pixel cible appartient au domaine local, imposer explicitement une interpolation identité sur ce pixel avec un poids égal à un ;
  4. si même le pixel cible n’appartient pas au domaine, signaler explicitement que la sortie n’est pas définie, plutôt que de produire silencieusement zéro.
    Toutefois, ce code de stencil local faisait double emploi avec healpix-analyse. En supprimant SphericalStencil de STL et utilisant directement les opérateurs de healpix-analyse, qui portent maintenant la responsabilité de la géométrie, de l’interpolation et du support partiel. Ce cas limite n’a donc plus à être corrigé dans STL.

Keep only the frequencies inside the Nyquist radius of the pixel grid.
"""
from STL_main.Synthesis import apply_nyquist_filter

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mettre cet import en haut du fichier

data.array = torch.abs(data.array)
npix_full = 12 * self.N0[0] ** 2
if array.shape[-1] != npix_full:
return array

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Mettre un petit warning pour prévenir, lors de la synthèse, que le filtrage bandlimit n'a pas pu être effectué à cause du masque.

Comment thread pyproject.toml
[project]
name = "STL"
dynamic = ["version"]
requires-python = ">=3.10"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

À l’origine de l’échec du job test(3.9), faut-il retirer ce test ou abaisser l’exigence de version de Python du projet ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Il faut retirer Python 3.9 de la matrice de CI plutôt que d’abaisser l’exigence de version du projet.
STL déclare maintenant :
requires-python = ">=3.10"
et healpix-analyse, qui est devenu une dépendance directe de STL pour le noyau HEALPix, déclare également requires-python = ">=3.10". Le job Python 3.9 échoue donc avant même de tester réellement le code : pip refuse à juste titre d’installer un projet dont la version minimale annoncée est Python 3.10.
Conserver le job 3.9 tout en déclarant Python 3.10 comme version minimale est contradictoire. Abaisser seulement la contrainte de STL ne suffirait pas non plus, puisque la dépendance healpix-analyse resterait incompatible avec Python 3.9. Rétablir une prise en charge réelle de Python 3.9 demanderait d’abaisser la contrainte dans les deux projets, puis de vérifier toutes les dépendances et l’ensemble des tests sous cette version. Cela dépasse le périmètre de cette PR.
La correction cohérente consiste donc à supprimer 3.9 de .github/workflows/ci.yaml et à conserver les jobs à partir de Python 3.10. La version minimale déclarée par le paquet et les versions testées par la CI seront ainsi alignées.
Le 3.9 était lié a de vieux driver GPU NVIDIA, je pense que c'est fini.

Comment thread pyproject.toml
# Reference is the head of the sibling checkout, installed in editable mode:
# pip install -e ../healpix-analyse
"healpix-analyse",
]

@dtibi69 dtibi69 Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Redondance des dépendances avec requirements.txt : tout regrouper dans pyproject.toml.

Comment thread pyproject.toml
"healpix-resample",
"xarray",
"zarr",
]

@dtibi69 dtibi69 Sep 16, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ajout des dépendances optionnelles provenant de requirements.txt pour les tests et la documentation :

[project.optional-dependencies]
dev = [
    "pytest",
    "pre-commit",
]
docs = [
    "sphinx",
    "sphinx-rtd-theme",
    "sphinx-autodoc-typehints",
]

import torch.nn.functional as F
from healpix_analyse.convol import HealPixConv
from healpix_analyse.down import HealPixDown
from healpix_analyse.healpix_sht import HEALPixSHT

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pourquoi laisser SphericalStencil dans le dossier source si on utilise healpix-analyse ? Et healpix-analyse est-elle basée sur la classe SphericalStencil pour ses convolutions, downgrading, etc. ?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Tu as raison : une fois healpix-analyse utilisé comme source de la géométrie sphérique, il n’y avait plus de raison de conserver une seconde implémentation de SphericalStencil dans STL.
SphericalStencil correspondait à une ancienne implémentation interne qui gérait notamment la recherche des voisins, les poids d’interpolation, les domaines partiels et les opérations de montée ou de descente en résolution. La conserver en parallèle de healpix-analyse créait deux implémentations de la même logique, avec le risque qu’elles divergent ou que des corrections soient appliquées à l’une mais pas à l’autre. Les deux questions précédentes sur les colonnes sans voisin illustrent précisément ce problème.
Le fichier STL_main/SphericalStencil.py a donc été supprimé. Le noyau HEALPix de STL importe maintenant directement :
from healpix_analyse.convol import HealPixConv
from healpix_analyse.down import HealPixDown
from healpix_analyse.healpix_sht import HEALPixSHT
healpix-analyse n’est pas basé sur la classe SphericalStencil de STL. C’est une implémentation indépendante :

  • HealPixConv construit et applique sa propre géométrie de convolution, ses poids d’interpolation et ses jauges ;
  • HealPixDown effectue la réduction de résolution avec son propre opérateur ;
  • HEALPixSHT fournit les transformations harmoniques utilisées pour le filtrage en bande et les spectres angulaires ;
  • les domaines locaux sont décrits directement par cell_ids.
    STL conserve uniquement la couche d’adaptation nécessaire au calcul des statistiques de scattering : création et mise en cache des opérateurs, préparation des kernels complexes, suivi de conv_history, propagation des masques et intégration avec ST_Operator.
    La dépendance est donc dans un seul sens : STL utilise les opérateurs publics de healpix-analyse; healpix-analyse ne dépend pas de SphericalStencil ni de STL. Cela évite la duplication et permet de centraliser les futures corrections géométriques dans healpix-analyse.

raise Exception(
f"Data should be a STL_Healpix_Kernel_Torch instance, got {type(data)}"
)
if self.DT != data.DT:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Rendre DT attribut de classe de WaveletOperatorHealpixKernel_Torch (faire de même pour les autres opérateurs wavelet). Cela évitera d’instancier des opérateurs wavelet avec des DT divergents de ceux des classes data correspondantes, qui sont déjà des attributs de classe.

class WaveletOperatorHealpixKernel_Torch:
    DT: ClassVar[str] = "HealpixKernel_Torch"

Le second if devient alors redondant.


###########################################################################
def get_L(self):
return self.L

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Méthode non utilisée

def _smooth_with_nan(self, data, inplace: bool = True):
"""
NaN-aware smoothing: the map and the validity mask are both smoothed,
and the result is normalized by the sum of the valid weights.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Indiquer en docstring :

"""The last dimension of `data.array` corresponds to `K`, the total number of pixels in the full-sky grid at this resolution. Pixels with no data are represented by `NaN` values."""

car, en général, la dernière dimension d’une instance data est de taille N_pix, avec N_pix <= K en présence d’un masque.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Attention, STL Healpix peut fonctionner avec une liste de cell_ids healpix pour travailler en local. Il ne faut pas casser cette propriété.

f"Npix pixels), got {tuple(m.shape)}."
)

invalid = m.to(torch.bool) if m.dtype == torch.bool else (m != 0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

invalid = m if m.dtype == torch.bool else (m != 0)

)

self.bin_windows = windows # [n_bins, lmax+1]
self.bin_weights = windows * (2.0 * ell + 1.0)[None, :]

@dtibi69 dtibi69 Sep 18, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Permet d'obtenir un estimateur de moyenne pondérée $\widehat{C}_b$ plus stable vis-à-vis de la variance d'échantillonnage. Cela ne brise pas l'interprétation physique en déformant la fenêtre vers les grands $\ell$ ? Sinon, je vais également intégrer cette correction aux opérateurs de spectre de puissance 2D Kernel et Torch.

"""
Build the [n_bins, lmax+1] window matrix and its (2l+1) weighting.
"""
l_min = max(1.0, float(2**self.Jmin))

@dtibi69 dtibi69 Sep 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pourrait être intéressant de discuter des bornes du spectre de puissance et de voir s’il serait pertinent de les faire dépendre de l’échelle $J$ de l'opérateur wavelet, afin que la plage de multipôles corresponde aux modes effectivement sondés par les scatterings ?

# the monopole carries no information and l < l_min is excluded
windows = torch.where(
(ell >= l_min)[None, :], windows, torch.zeros_like(windows)
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pourrait on obtenir un gain temps/mémoire intéressant en ne calculant pas les modes dont le multipôle est inférieur à $\ell_{\min}$, plutôt que de les mettre à zéro a posteriori.

x_flat = x.reshape(B, K)
full_sky = bool(data.pbc)
if use_band_maps is None:
use_band_maps = not full_sky

@dtibi69 dtibi69 Sep 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Avertir de la présence d'artefacts si on a une carte partiel et que l'on sélectionne tout de même l'option use_band_maps=False

if not use_band_maps and not full_sky:
    warnings.warn(
        "`use_band_maps=False` with partial-sky data: "
        "missing pixels are zero-filled before computing the cross spectrum "
        "using `anafast` or `alm`, which may introduce artifacts."
    )

data.nside = data.N0[0] // (2**dg_out)
###########################################################################
def _apply_anafast(self, maps_full, pairs):
"""Full-sky estimator going through HEALPixSHT.anafast pair by pair."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ne risque-t-on pas de recalculer plusieurs fois les coefficients $a_{\ell m}$ d'un même canal $i$ lors de l'estimation de $C_\ell$ pour les paires $(i,i+1)$ et $(i,i+2)$, à moins qu'ils ne soient mis en cache en interne par HEALPixSHT.anafast ?

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants