pyfcfc wraps the two pair-counting components of the C package
FCFC (by Cheng Zhao) so that they can
be driven entirely from Python with in-memory NumPy catalogues:
| module | FCFC component | data | line of sight |
|---|---|---|---|
pyfcfc.boxes |
FCFC_2PT_BOX |
periodic-box Cartesian coordinates | box z axis |
pyfcfc.sky |
FCFC_2PT |
Cartesian comoving coordinates, or {RA, Dec, redshift} with on-the-fly cosmological conversion | pair midpoint |
Pair counts, correlation functions, multipoles and projected correlation functions are computed with k-d tree or ball tree dual-tree traversal, OpenMP parallelisation and (optionally) SIMD vectorisation, and returned as plain NumPy arrays in a Python dictionary.
The package also ships utilities (pyfcfc.utils) to accumulate
split-random measurements, integrate correlation functions, and convert
results into pycorr states, so that
pair counting can be done with (fast) FCFC while the analysis uses the
pycorr API (estimators, rebinning, covariance tools, saving...). The
optional pyfcfc.external module converts results into
lsstypes containers (Count2,
Count2Correlation, Count2CorrelationPoles, ...), giving access to
replica covariances and serialisation.
Compared to pycorr (Corrfunc engine), pyfcfc pair counts and correlation
functions agree to machine precision on identical inputs (see
examples/); the wall-clock speedup is typically 1.5–2.5x on the counting
step (and larger with SIMD enabled).
- Installation
- Quick start
- The
py_compute_cffunction - Conventions
- Working with pycorr
- Accumulating split-random measurements
- Tests
- Examples
- Gotchas and notes
- Changes with respect to the original port
- Acknowledgements
Requirements: a C compiler with OpenMP support (gcc/clang), Python ≥ 3.9,
NumPy, SciPy; Cython and NumPy at build time (handled automatically by
pip).
pip install . # from the repository root
# or, for development:
pip install -e .
# or with the Makefile:
makeA one-shot script that installs the system build dependencies, creates a virtual environment with all (optional) extras and installs pyfcfc in editable mode is provided:
bash scripts/setup_dev_env.sh [VENV_DIR]SIMD acceleration. FCFC's vectorised counting kernels are enabled with
PYFCFC_WITH_SIMD=1 pip install . --no-build-isolationThis implies -march=native: build on a machine whose instruction set
(AVX2/AVX512) is available at run time. Compilation of the SIMD kernels is
memory hungry (a couple of GB of RAM); PYFCFC_NO_LTO=1 and
PYFCFC_EXTRA_CFLAGS="-O2 -mno-avx512f" can reduce the footprint (the
latter restricts the build to AVX2). Besides the hard kernels, this also
vectorises the soft histogramming kernels (leaf-pair range checks in the
box component, libmvec exp in the windowed softmax on glibc Linux, to
which -lmvec is then linked automatically); see the soft-histogramming
section below for measured speedups and numerical guarantees.
Other build environment variables: PYFCFC_MARCH_NATIVE=1 (native code
generation without SIMD), PYFCFC_NO_LTO=1 (disable link-time
optimisation), PYFCFC_EXTRA_CFLAGS="..." (extra compiler flags).
Optional dependencies for the extras:
pycorr(+Corrfunc, its default engine) for the interoperability utilities:pip install git+https://github.com/cosmodesi/pycorr.gitandpip install git+https://github.com/cosmodesi/Corrfunc@desi;matplotlibto run the plotting examples.
The number of OpenMP threads is read from OMP_NUM_THREADS at run time.
import numpy as np
from pyfcfc.boxes import py_compute_cf
data = ... # (N_d, 3) float array, periodic-box coordinates
rand = ... # (N_r, 3) float array
w_d, w_rand = np.ones(len(data)), np.ones(len(rand))
s_edges = np.arange(0, 151, 5.)
results = py_compute_cf(
[data, rand], [w_d, w_rand],
s_edges, # separation bin edges
None, # pi bin edges (only for bin=2)
48, # number of mu bins in [0, 1) (bin=1)
label=['D', 'R'], # catalogue labels
bin=1, # binning scheme: 0 iso, 1 (s,mu), 2 (s_perp,pi)
pair=['DD', 'DR', 'RR'], # pair counts to evaluate
cf=['(DD - 2 * DR + RR) / RR'], # correlation function estimator(s)
multipole=[0, 2, 4], # Legendre multipoles to integrate
box=1000., # periodic box side(s)
)
s, xi0, xi2, xi4 = results['s'], *results['multipoles'][0]Survey-like data, converting {RA, Dec, z} internally:
from pyfcfc.sky import py_compute_cf
results = py_compute_cf(
[rdd_data, rdd_rand], [w_data, w_rand],
s_edges, None, 48,
label=['D', 'R'], bin=1, pair=['DD', 'DR', 'RR'],
cf=['(DD - 2 * DR + RR) / RR'], multipole=[0, 2, 4],
convert=True, omega_m=0.31, omega_l=0.69, eos_w=-1.0,
)Configuration files (conf=...) are still supported (catalogue/position
entries are ignored, since the data come from Python); keyword arguments
override the file, exactly as command-line options do in FCFC.
py_compute_cf(data_cats, data_wts, sedges, pedges=None, nmu=1, **kwargs)data_cats: sequence of catalogues, each convertible to an (N, 3) array. Non-contiguous arrays, lists and integer/float32 inputs are converted internally (a copy is made; your arrays are never modified). float32 is used natively only when all positions and weights are float32; otherwise everything is promoted to float64.data_wts: sequence of per-object weights, each of length N. Pass arrays of ones for unweighted catalogues (this automatically enables FCFC's faster, exact integer counting path).sedges: separation (or s_perp) bin edges; strictly increasing, non-negative, finite. For the box component,smax < box/2.pedges: pi bin edges, required forbin=2.nmu: number of mu bins in [0, 1), used forbin=1.**kwargs: FCFC configuration options (below). Python booleans are translated to'T'/'F', sequences to FCFC array literals ([0, 2, 4]), and unknown options raiseValueErrorwith suggestions instead of being silently ignored.
All options reach FCFC through a single libcfg parameter table, whether
they come from keyword arguments or from a configuration file: the
keywords are translated to command-line-style --key=value strings
(which take priority), and an optional conf= file is read afterwards
with lower priority, exactly as in the FCFC CLI. Since the catalogues
and the bin edges always come from the function arguments, the upstream
file-based inputs were removed from the table (CATALOG, POSITION,
ASCII_*, SELECTION, SEP_BIN_*, MU_BIN_NUM, PI_BIN_*), as were
the CLI-only --help/--version/--template callbacks and the
FITS/HDF5 catalogue readers (excluded from the build).
What remains of the file machinery, and stays supported:
- the
conf=overlay itself (any option in the table below); - the optional outputs
pair_output,cf_output,mp_output,wp_output(without_formatandoverwrite); when aPAIR_COUNT_FILEalready exists, FCFC reads the pair counts from it instead of recomputing them (tested: write/read round-trip); cmvdst_file(sky component): a {redshift, comoving distance} table replacing the internal distance integration;- the
verbose=Trueconfiguration summary.
| option (keyword) | boxes |
sky |
meaning |
|---|---|---|---|
label |
✔ | ✔ | single-uppercase-letter catalogue labels (defaults to A, B, C, ...) |
pair |
✔ | ✔ | pair counts to evaluate, e.g. ['DD','DR','RR'] |
bin |
✔ | ✔ | binning scheme: 0 = isotropic, 1 = (s, mu), 2 = (s_perp, pi) |
soft_hist |
✔ | ✔ | soft histogramming: spread every pair over all separation bins with softmax Gaussian-kernel weights (experimental) |
soft_sigma |
✔ | ✔ | Gaussian kernel width in the units of sedges (default: half the mean bin width); requires soft_hist |
soft_sigma_log |
✔ | ✔ | interpret soft_sigma as a dimensionless fraction f with a scale-dependent kernel width sigma(s) = f * s, in the units of sedges (for logarithmic separation bins); requires soft_hist |
soft_mu |
✔ | ✔ | soft histogramming of the mu dimension of the (s, mu) scheme: softmax Gaussian kernel over the mu bin centres (bin=1 only) |
soft_mu_sigma |
✔ | ✔ | mu kernel width in mu units (default: half the mu bin width); requires soft_mu |
soft_pi |
✔ | ✔ | soft histogramming of the pi dimension of the (s_perp, pi) scheme (bin=2), making w_p differentiable |
soft_pi_sigma |
✔ | ✔ | pi kernel width in the units of pedges (default: half the mean pi bin width); requires soft_pi |
soft_pad |
✔ | ✔ | edge padding of the soft counting range in kernel widths (default: 2; 0 disables): removes the truncated-kernel-tail bias of the outermost bins, see below |
cf |
✔ | ✔ | correlation function estimator expression(s), e.g. ['(DD - 2*DR + RR)/RR']; '@@' denotes the analytic random-random counts (box) |
multipole |
✔ | ✔ | multipole orders to integrate (requires bin=1 and cf) |
wp |
✔ | ✔ | compute the projected correlation function (requires bin=2 and cf) |
box |
✔ | periodic box side length(s) | |
convert |
✔ | convert {RA, Dec, z} to comoving Cartesian coordinates | |
omega_m, omega_l, eos_w |
✔ | fiducial cosmology for the conversion | |
cmvdst_err, cmvdst_file |
✔ | accuracy / lookup table for the distance integration | |
data_struct |
✔ | ✔ | 0 = k-d tree (default), 1 = ball tree |
weight |
✔ | ✔ | per-catalogue weight flags; set automatically ('1' when all weights are exactly 1, enabling the integer path) |
pair_output, cf_output, mp_output, wp_output |
✔ | ✔ | optional files where FCFC writes its results |
out_format, overwrite |
✔ | ✔ | output file options |
verbose |
✔ | ✔ | print FCFC's configuration summary and progress (default: False) |
Errors inside the C code (bad configuration, missing pairs, ...) raise
Python exceptions (RuntimeError) and never crash the interpreter; all
allocated memory is released on both success and failure paths.
Besides the standard ("hard") histogram pair counting of FCFC, both modules
support an optional soft histogramming method, enabled with the
soft_hist=True keyword (or SOFT_HIST = T in a configuration file):
res = py_compute_cf([data, rand], [w_d, w_r], s_edges, None, 48,
soft_hist=True, soft_sigma=2.0, # kernel width, units of sedges
label=['D', 'R'], bin=1, pair=['DD', 'DR', 'RR'], box=1000.)Instead of adding the weight of every pair to exactly one separation bin, the weight is distributed over all separation bins with the softmax of a Gaussian kernel centred on the bins,
x_i = (s - c_i)^2 / (2 sigma^2), W_i = softmax_i(-x_i), cnt_i += w_pair * W_i,
where c_i are the bin centres and sigma = soft_sigma (soft_sigma=None
/ SOFT_SIGMA = 0 selects automatically half the mean separation bin
width). Since the softmax weights sum to one, the total weight of all
counted pairs is conserved exactly (over the padded counting range when
soft_pad > 0; see below): the soft histogram is a kernel-smoothed version
of the hard one, and the two are directly comparable (soft counts converge
to the hard counts in the sigma -> 0 limit). With soft_sigma_log=True (SOFT_SIGMA_LOG = T) the width is
instead scale dependent: soft_sigma is then interpreted as a
dimensionless fraction f and the kernel width becomes sigma(s) = f * s
(in the units of sedges, evaluated at each pair's separation). This is
the natural choice for logarithmic separation bins, where a fixed sigma
would wash out the small bins while barely smoothing the large ones:
locally the bin width is ~ s · dln s, so the ratio sigma / bin width =
f / dln s is the same at every scale. The default f (soft_sigma=None)
is half the mean logarithmic bin width, i.e. sigma equals half the local
bin width everywhere. The mu and pi kernels always take fixed absolute
widths (soft_mu_sigma in mu units, soft_pi_sigma in the units of
pedges): FCFC's mu bins are uniform on [0, 1) by construction, and no
log scaling is offered for pi.
Edge leakage and the soft_pad padding. The Gaussian kernel has
infinite support, but the tree traversal only counts pairs inside the
binning range [sedges[0], sedges[-1]): the kernel tails reaching beyond
the outermost edges are truncated. For the outermost bins the estimator
then averages xi(s) over a half kernel -- the weight that leaks out of
the last bin towards larger separations (where xi is smaller for a steep
correlation function) is not compensated by weight leaking in from beyond
sedges[-1], because those pairs are never counted. The result is a
positive bias of the last separation bin (a smaller negative one at the
first, where the s^2 volume factor suppresses the missing pairs),
growing with the steepness of xi and with the kernel width: for a
xi ~ s^-1.8 power law with the default scale-free kernel the last bin
carries ~ +9% excess (relative to the kernel-smoothed value the interior
bins measure), ~ +5% for gamma = 0.8 and ~ +12% for gamma = 2.6.
soft_pad = z (the default, 2 kernel widths) removes it: the counting
range is extended by z kernel widths beyond the outermost edges,
realised with buffer bins continuing the separation grid (geometrically
for log-spaced bins, arithmetically otherwise). The softmax then
normalises over the real and buffer centres, pairs beyond the original
edges contribute their decaying kernel tails to the outermost real bins,
and the buffer-bin counts are discarded -- the returned results refer to
the original bins, with their original edges. soft_pad=0 disables the
padding and restores the unpadded estimator exactly. Details:
- the padding is quantised to whole buffer bins per side,
ceil(z * sigma_edge / spacing): with the default kernel (f = 0.5 * dln s) any0 < z <= 2gives one buffer bin per side (z_eff = 2); - measured wall-clock overhead of the default on the soft counts (pairs
scale with the shell volume,
~ exp(3 z f) - 1for log-spaced bins): +32% at 0.1 dex bins, +69% at 0.2 dex, +20% for linear bins with the default kernel, +29% for a survey-like catalogue;soft_pad=0restores the previous speed exactly; - in a periodic box the extended maximum edge is clamped below
L/2(FCFC's minimum-image limit), so setups binning close to half the box get reduced (possibly no) upper padding; - the lower edge is never padded below zero;
soft_padcannot be combined with a configuration file (conf=), whose box size and kernel options the Python wrapper cannot see;- with padding, the weight conservation and the
sigma -> 0limit hold on the extended grid (the real bins alone keep less than the total, the rest sits in the discarded buffer bins).
Properties and limitations:
- the mu (
bin=1) and pi (bin=2) dimensions remain hard-binned unlesssoft_mu=True/soft_pi=Trueare set: soft binning them makes the 2D counts smooth in both arguments, hence the multipoles xi_ell(s) and the projected correlation function w_p (linear operators on xi(s, mu) / xi(s_perp, pi)) become differentiable as well; - pair counts become fractional and are always accumulated in double
precision (
cf_setupforces the weighted counting path in soft mode); - the tree traversal, periodic wrapping and per-pair range checks replicate the scalar hard kernels exactly, so both methods operate on the same set of pairs;
- the soft kernels are OpenMP-parallel with the same thread decomposition
as the hard kernels; for linear separation bins the kernel weights are
generated with an exact multiplicative recurrence (two
exp()calls per pair) over an adaptive window, so the per-pair cost is O(min(ns, sigma / ds)) instead of O(ns); PYFCFC_WITH_SIMD=1builds accelerate the soft method: in the box component the leaf-pair loops evaluate the coordinate differences, periodic wraps and range checks of 4 (AVX2) or 8 (AVX-512) pairs at a time, with vector arithmetic that matches the scalar expressions bit-for-bit (no FMA contraction), so exactly the same pairs are binned; in both components the windowed stable softmax (non-linear bins andsoft_sigma_logkernels) evaluates its Gaussian terms with glibc's libmvec vector exponential on x86-64 Linux (scalarexpelsewhere). Measured single-thread gains of such a build (2.5 GHz Xeon, AVX-512): -20...-30% for box s / sigma(s)=f*s binning, -11% for box (s, mu), -12% for box (s_perp, pi), -8...-12% for the survey-like component; the survey-like leaf loops stay scalar on purpose (there the pairs that reach the leaves mostly pass the range checks, so a vector pre-filter does not pay off). Counts differ from non-SIMD builds only by floating-point summation order and the ulp-level accuracy of the vector exponential (~1e-15 relative). The adjoint (VJP) traversals remain scalar but share the vectorised softmax, so the backward pass still replays the forward weights exactly. Single-threaded the soft method is typically a few times slower than the hard kernels (dominated by the kernel evaluation), and it parallelises like them; MPI builds are not supported.
Validation against O(N^2) brute-force soft references, the conservation
laws and the sigma -> 0 limit are covered by tests/test_soft_hist.py
(unpadded) and tests/test_soft_pad.py (padding plans, padded counts vs
brute force on the extended grid, adjoints, clamping, validation);
examples/example_soft_hist.py plots the soft histograms against the
hard ones (counts, ratios and xi(s)).
Adjoint (reverse-mode derivatives). Because the soft histogram is a smooth function of the pair separations, both modules also expose a VJP (vector-Jacobian product) entry point,
out = soft_hist_vjp(data_cats, data_wts, sedges, pedges, nmu,
pair='DD', cotangent=g, soft_sigma=2.0,
grad_wts=False, grad_sigma=False, box=1000.)
# out['grads'] : dict label -> (N, 3) coordinate cotangents
# out['grad_wts'] : dict label -> (N,) weight cotangents (or None)
# out['grad_sigma']: kernel-width cotangent (or None)which propagates cotangents from the raw pair count bins back to the catalogue coordinates (and, optionally, weights and kernel width), i.e. exactly what a reverse-mode autodiff engine needs to backpropagate through the pair counting step. With W_i the softmax weights of a pair, cbar = sum_i W_i c_i and bin cotangents g_i,
dW_i/ds = W_i (c_i - cbar) / sigma^2,
s_bar = (w / sigma^2) * sum_i g_i W_i (c_i - cbar),
chained to the coordinates through s (or s_perp for bin=2; for the
survey-like component through the midpoint-LOS decomposition
ds_perp/dr1 = (1 - q)(d - q m)/s_perp with q = d.m/|m|^2). Discrete
choices (range checks, mu / pi bins, kernel window) are held constant, as
in any autodiff engine. The adjoint replays the forward pair selection
with its own index-preserving k-d tree (FCFC's counting trees store
permuted coordinates without an index map), is OpenMP-parallel, and is
validated against central finite differences in tests/test_soft_vjp.py
(including the weight and sigma cotangents, and the invariant that a
constant cotangent yields zero coordinate gradient). examples/example_soft_hist_jax.py
wraps the forward and the adjoint into a jax.custom_vjp primitive and
demonstrates it: gradient validation against finite differences, and a
differentiable Alcock-Paczyński-like stretch recovery with jax.grad +
Adam (figures/soft_hist_jax.png).
examples/example_soft_hist_multipoles.py registers the soft 2D counts
as a jax.custom_vjp primitive and differentiates the multipoles
xi_0, xi_2 (Landy-Szalay + FCFC's Legendre integration, in JAX),
recovering Alcock-Paczyński stretch parameters from them
(figures/soft_hist_multipoles.png).
examples/example_soft_hist_wp.py makes the projected correlation
function differentiable (soft s and pi bins) and recovers a transverse
stretch from w_p alone (figures/soft_hist_wp.png).
examples/example_soft_hist_rppi.py treats the full 2D xi(r_p, pi) map
as the differentiable object: it computes the adjoint Jacobian maps
d xi(r_p, pi)/d theta of an anisotropic stretch model with one-hot
cotangents and validates them bin-by-bin against finite differences
(figures/soft_hist_rppi.png).
examples/example_soft_hist_fit.py shows the same machinery without any
autodiff framework: a 3-parameter rigid displacement of the galaxy
catalogue (one shift per axis) is recovered by Adam on the loss
1/2 |DR(theta) - DR(theta_*)|^2, with dL/dtheta obtained by
contracting the soft_hist_vjp coordinate cotangents (gradient check vs
finite differences and the rigid-translation null direction included;
figures/soft_hist_fit.png).
JAX integration. The optional pyfcfc.jax module (requires jax)
packages the forward + adjoint into a ready-made autodiff primitive:
soft_pair_counts(positions, weights, module=..., s_edges=..., ...) is a
jax.custom_vjp function returning the raw pair counts as JAX arrays,
differentiable with respect to the catalogue positions and weights (its
forward calls py_compute_cf, its backward soft_hist_vjp). The
primitive is created once per static configuration and cached, so
repeated calls - also inside jax.jit-compiled outer functions - reuse
the same callable and hence JAX's tracing/compilation caches; the C
library runs on host arrays registered via jax.pure_callback, so the
primitive works eagerly and under jax.jit, for both the forward pass
and jax.grad (jit correctness and compilation caching are covered by
tests/test_soft_vjp_jax.py). Importing pyfcfc.jax does not enable
JAX's x64 mode: the C library accumulates in double precision internally,
but the module follows the precision of the JAX inputs, returning float32
counts and float32 gradients for float32 catalogues (float64 inputs use
the full double-precision path when x64 is enabled). The helpers
pair_normalizations, xi_landy_szalay, multipoles, projected_wp
and legpoly implement FCFC's conventions in JAX, so a whole xi /
multipole / w_p pipeline becomes differentiable with plain jax.grad.
examples/example_soft_hist_wp.py builds a differentiable w_p on
realistic logarithmic r_p bins this way (scale-free kernel
sigma(s) = f * s via soft_sigma_log, transverse stretch recovered
with Adam; figures/soft_hist_wp.png, and a kernel-width comparison in
figures/soft_hist_wp_sigmas.png).
JIT caching and recompilation. Under jax.jit, only the first
call for a given input signature (array shapes/dtypes plus the static
configuration) pays the tracing + XLA compilation cost; every subsequent
call reuses the cached executable - there is no recompilation.
examples/benchmark_jit_cache.py measures exactly this (DD counts, 20
log-spaced s bins 0.5-40 Mpc/h, scale-free kernel sigma(s) = f * s;
recorded on a 2-vCPU Linux box, jax 0.10.2, 2 OpenMP threads - absolute
times are machine-dependent, the pattern is not):
| N | jitted call | first run | all subsequent runs | speedup |
|---|---|---|---|---|
| 400 | forward | 32 ms | 0.9 ms each | ~35x |
| 400 | jax.grad |
24 ms | 1.0 ms each | ~24x |
| 4000 | forward | ~30-60 ms | ~10 ms each | ~3-6x |
Per-call series of the jitted forward at N = 400 (ms):
[32.2, 0.9, 0.9, 0.9, 0.9, 0.8, 0.9, 0.9, 0.9, 0.9] - only the first
call compiles. At N = 400 a warm jitted call is also ~17x cheaper than
an eager (non-jit) one, because jax.jit additionally amortises the
Python-side argument packing of soft_pair_counts; at N = 4000 the C
pair counting itself (~10 ms here) dominates every call. A new input
shape (e.g. a different number of points) triggers exactly one extra
compilation; repeated calls with it reuse that one too. The tests
assert both the compilation-cache sizes (where the installed jax exposes
fn._cache_size()) and a first-vs-subsequent timing check that works
on any jax version (tests/test_soft_vjp_jax.py). The module adapts
to the jax.pure_callback API across jax versions (vmap_method on
jax >= 0.4.31, vectorized on older releases); run
python examples/benchmark_jit_cache.py (optionally --json FILE) to
reproduce the numbers on your machine.
| key | shape / content |
|---|---|
labels |
list of catalogue labels |
number |
dict label → number of objects |
weighted_number |
dict label → sum of weights (equals number for unit weights) |
normalization |
dict pair → pair-count normalization (see conventions) |
pairs |
dict pair → normalized pair counts, shape (ns,), (ns, nmu) or (ns, np); plus bin edge arrays smin, smax and (mumin, mumax) or (pimin, pimax) |
s |
(ns,) centers of the separation (s_perp) bins |
cf |
(ncf, ns[, nmu or np]) estimator values, if cf was given |
multipoles |
(ncf, nl, ns) multipole moments, if multipole was given |
poles |
list of the multipole orders computed |
projected |
(ncf, ns) projected correlation function w_p, if wp=True |
The pair-count arrays are normalized counts; multiply by
normalization[pair] to recover the raw (weighted) pair counts.
Every pair listed in pair (or PAIR_COUNT in a configuration file)
is evaluated and returned in results['pairs'], whether or not a CF
estimator references it: the pair counts are the main product of the
code, and estimators/multipoles/projections are optional extras computed
from them (here, or externally with pyfcfc.utils, pycorr or lsstypes).
Passing a dummy estimator such as cf=['DD'] is therefore not needed
(for historical reasons the original port required an estimator to keep
the requested binning scheme; this was fixed in 0.2.0 and is covered by
regression tests). The only pairs not counted from scratch are those with
an existing PAIR_COUNT_FILE output, which FCFC reads instead.
- Auto pairs (e.g.
DD) are counted twice per unordered pair (ordered-pair convention), and the normalization is(Σw)² − Σw²— the same convention as pycorr'swnorm. - Cross pairs (e.g.
DR) are counted once per (i, j) combination, with normalizationΣw₁ × Σw₂. - For
bin=1andbin=2, onlymu ≥ 0(pi ≥ 0) is binned, with mu (pi) the absolute value of the cosine (parallel separation). - Bins are left-closed, right-open; pairs with exactly
mu == 1are dropped (as in FCFC compiled withoutWITH_MU_ONE). - The box component uses the periodic minimum-image metric; the sky
component defines the line of sight as the pair midpoint direction
(equivalent to pycorr's
los='midpoint').
FCFC integrates multipoles with the midpoint rule,
ξ_ℓ(s) = (2ℓ+1) Σ_j ξ(s, μ_j) P_ℓ(μ_j) Δμ. pycorr instead uses the
exact integral of the Legendre polynomial over each bin. The two agree up
to O(Δμ²): for ℓ = 0 they coincide (the difference cancels), while for
ℓ = 2, 4 the difference can reach ~10⁻² at small scales when ξ is large —
this is a scheme difference, not a counting error (the raw pair counts
of both codes agree to machine precision).
pyfcfc.utils.compute_multipoles supports both schemes:
from pyfcfc.utils import compute_multipoles
mp_mid = compute_multipoles(xi_smu, [0, 2, 4]) # FCFC's scheme
mp_exact = compute_multipoles(xi_smu, [0, 2, 4], method='exact') # pycorr's schemeso you can reproduce either code's numbers exactly from pyfcfc pair
counts (both also accept ignore_nan=True to skip empty bins, as pycorr
does).
from pyfcfc.utils import pairs_to_pycorr
from pycorr import TwoPointCorrelationFunction
state = pairs_to_pycorr(
results, # pyfcfc result dict
'landyszalay', # pycorr estimator name
dict(DD='D1D2', DR=('D1R2', 'R1D2'), RR='R1R2'), # label mapping
box_size=1000., # periodic box (optional)
)
np.save('counts.pkl.npy', state)
tpcf = TwoPointCorrelationFunction.load('counts.pkl.npy')
s, xi0 = tpcf(ell=0, return_sep=True)
rebinned = tpcf[::2, ::4] # pycorr rebinning worksNotes:
- Reversible pair counts must be mapped to both pycorr names (as for
DRabove); pyfcfc's |mu| counts are mirrored to pycorr's full [-1, 1] range (exactly for auto pairs, statistically for cross pairs). - For the
naturalestimator in a periodic box, the analytic random-random counts are generated automatically whenbox_sizeis given. - The pycorr state records
los_type='z'for box measurements and'midpoint'for sky measurements, matching FCFC's conventions. - In
rppimode, pycorr stores symmetric pi edges [-pimax, pimax]; the conversion mirrors pyfcfc's pi ≥ 0 counts accordingly.
lsstypes provides container types
for LSS measurements (replica algebra, covariances, serialisation). The
optional module pyfcfc.external converts pyfcfc results into them:
pip install git+https://github.com/adematti/lsstypes # optional extrafrom pyfcfc.external import to_lsstypes, to_lsstypes_correlation, to_lsstypes_counts
counts = to_lsstypes_counts(results) # {'DD': Count2, 'DR': ..., 'RD': ..., 'RR': ...}
corr = to_lsstypes_correlation(results, 'landyszalay') # Count2Correlation
poles = to_lsstypes(results, ells=[0, 2, 4]) # Count2CorrelationPoles
wp = to_lsstypes(results_spi, project='wp') # Count2CorrelationWpprojectaccepts 'poles' (default), 'wedges', 'wp', 'binned', or None (returns the un-projectedCount2Correlation);- multipoles use lsstypes' exact per-bin Legendre integration, i.e.
compute_multipoles(..., method='exact')on the pyfcfc side; - for the 'natural' estimator in a periodic box without RR counts, the
analytic random-random counts are synthesized (same convention as
FCFC's
@@); - catalogue labels other than D/R are handled with
pair_mapping, e.g.{'AA': 'DD', 'AB': ('DR', 'RD'), 'BB': 'RR'}; - replica tools work out of the box:
lsstypes.mean/cov/sumover converted objects, andlsstypes.write/read(.pkl,.h5,.txt). Note thatlsstypes.sumweights replicas by the DD normalization, whilepyfcfc.utils.add_pair_countsaccumulates each pair type with its own normalization (exact raw-count addition); the two coincide for unweighted, equal-size replicas.
See notebooks/04_lsstypes.ipynb for a complete walkthrough and
tests/test_lsstypes.py (11 tests, skipped when lsstypes is absent).
from pyfcfc.utils import add_pair_counts
total = None
for chunk in random_chunks:
res = py_compute_cf([data, chunk.pos], [w_data, chunk.w], ...)
total = res if total is None else add_pair_counts(total, res)add_pair_counts combines the raw pair counts exactly (it is a
normalization-weighted average of the normalized counts, with summed
normalizations), returns a new dictionary, and drops stale
estimator-dependent keys (cf, multipoles, ...), which must be
recomputed from the combined counts.
pip install pytest
python -m pytest tests -v # 61 testsThe suite covers, among other things:
- exact agreement with brute-force O(N²) counting (periodic box and survey-like data; iso/(s, mu)/(s_perp, pi); linear, logarithmic and irregular bins; unit and random weights; k-d and ball trees);
- cross-validation against pycorr/Corrfunc on identical catalogues (pair counts, natural and Landy-Szalay estimators, multipoles, w_p, isotropic mode, state conversion + save/load + rebinning);
- soft histogramming (optional method): agreement with the brute-force O(N^2) soft references (box and sky, all schemes, both trees), exact conservation of the total pair weight with respect to the hard method, and the sigma -> 0 limit;
- API robustness: input validation and conversion, error propagation, immutability of caller arrays, determinism, dtype handling;
- runtime hygiene: GIL release during counting and absence of gross memory leaks on success and failure paths.
Tests that need pycorr/Corrfunc are skipped automatically when those are not installed.
examples/README.md contains the guided tour of the examples, a
feature-coverage matrix and the benchmarking guide. In short:
| script | content | figure |
|---|---|---|
examples/example_features_tour.py |
start here: every feature in one commented script (result dict, binning schemes, weights, conf files, trees, dtypes, cross-pairs, integrations, sky, errors, verbose) | — |
examples/example_validation_bruteforce.py |
pyfcfc vs brute-force counting, all binning schemes | figures/validation_bruteforce.png |
examples/example_box_vs_pycorr.py |
periodic box: xi(s), xi_0/2/4, the 2D xi(s_perp, pi), w_p, natural & LS estimators, pyfcfc vs pycorr + timings | figures/box_vs_pycorr.png, figures/box_vs_pycorr_natural.png, figures/box_vs_pycorr_2d.png |
examples/example_survey_vs_pycorr.py |
survey-like data from {RA, Dec, z}: multipoles and w_p vs pycorr (midpoint LOS) | figures/survey_vs_pycorr.png |
examples/example_pycorr_interop.py |
split-random accumulation → pycorr state → save/load/rebin | figures/pycorr_interop.png |
examples/example_benchmark.py |
performance study vs pycorr: sizes, binning schemes, thread scaling, SIMD reporting | figures/benchmark.png, figures/benchmark_results.csv |
Run them all with make examples (or individually with
python examples/example_*.py; matplotlib and, for the pycorr ones,
pycorr + Corrfunc are required).
Interactive versions are provided as executed Jupyter notebooks in
notebooks/:
| notebook | content |
|---|---|
01_quickstart_box.ipynb |
result dictionary, xi(s), multipoles, w_p, conventions |
02_survey_and_pycorr.ipynb |
survey-like data, midpoint LOS, pycorr cross-check, state save/load/rebin |
03_benchmarking.ipynb |
how to benchmark properly (sizes, threads, SIMD) with a small in-notebook run |
04_lsstypes.ipynb |
conversion to lsstypes containers: counts, estimators, poles, replica covariance, serialisation |
Typical output of the box example (40k data / 80k randoms, 2 threads):
max |pyfcfc - pycorr|:
xi(s) : 6.5e-13
multipoles, exact per-bin Legendre integration: xi_0 1.8e-13, xi_2 9.1e-13, xi_4 1.2e-12
wp : 1.6e-11
multipole(andwp) require acfestimator: multipoles are integrated from the estimator's xi(s, mu), as in FCFC. If you only need pair counts but still want the correct binning, pass a trivial estimator such ascf=['DD'].- For
bin=1, passnmu ≥ 2; pycorr comparisons need linear mu bins.nmuis ignored (clamped to 1) forbin=0andbin=2, andpedgesis ignored unlessbin=2. - The
s-mode and multipole results are deterministic for unit weights (integer counting); with non-trivial weights, repeated runs can differ at the ~1e-15 relative level due to OpenMP reduction ordering. - Empty (s, mu) cells produce NaN in
cfwhen the random-random counts vanish there; useignore_nan=Truein the integration helpers or mask them. - Catalogue labels must be unique uppercase letters (at most 26 catalogues).
- The vendored
FCFC-main/etc/*.conffiles are the upstream CLI templates: several of their entries (CATALOG,POSITION, ...) do not apply to library mode (the data come from Python), and theirCATALOG_LABELis empty, so passlabel=(andpair=,bin=, ...) explicitly or copy the template and fill them in. Unknown entries in a configuration file are reported by libcfg and otherwise ignored.
| path | content |
|---|---|
pyfcfc/ |
the Python package: boxes and sky Cython extensions, utils, optional external |
FCFC-main/ |
vendored FCFC C sources (upstream v1.0.1 with selected v1.1.0 fixes backported and the Python-binding modifications described below), plus the new pyfcfc_helpers.{c,h} accessor layer |
tests/ |
pytest suite (brute-force and pycorr cross-validation, API robustness, lsstypes) |
examples/ |
runnable scripts + figures/ outputs (see examples/README.md) |
notebooks/ |
executed Jupyter notebooks |
scripts/ |
setup_dev_env.sh environment bootstrap |
setup.py, pyproject.toml, Makefile |
build & packaging |
Version 0.2.0 is a substantial hardening of the original wrapper. The most important fixes:
Correctness bugs
- Input arrays were mutated in place.
cf_setuprescaled the separation/pi bin edges inside the caller's NumPy buffers (the C code aliased them), silently corrupting any array reused across calls. Bins are now deep-copied on entry. - Uninitialized variable in the auto-pair normalization. The hack that
switched to pycorr's
(Σw)² − Σw²normalization read an uninitialized accumulator (wt2sum), i.e. undefined behaviour. Replaced by the clean upstream FCFC v1.1.0 implementation (aw2field computed when the trees are built). weighted_numberreported the number of objects, not the sum of weights, breaking normalizations and the pycorr state conversion for weighted catalogues.w_pused rescaled pi bins (upstream FCFC bug fixed in v1.1.0, backported):dpiis now taken from the unrescaled bins.- The multipole orders (
poles) and catalogue labels were read from freed memory (the configuration structure is destroyed before the results are extracted on the Python side). Both are deep-copied now. - Dangling pointers in the command-line argument array: the bytes
objects backing
argvwere freed when the helper function returned, before the C parser ran. They are now kept alive for the duration of the call (andargvis freed afterwards). - Memory leaks / ownership hazards: the initial
cf_init()result was leaked on every call; input catalogues leaked on configuration errors; the survey-like weight-buffer branch could leak/overwrite buffers. Ownership of the input data is now explicit and covered by tests. - Linear-bin detection compared only the first and last bin widths
with
==(and mutated the configuration structure); it is now a tolerant check over all widths, and nonlinear bins with coincidental first/last widths can no longer be misclassified. - Backported upstream v1.1.0 tree fix (
gather_nodesdropping shallow leaves), which can lose pairs in some tree shapes. - SIMD padding: with SIMD enabled, FCFC's kernels read a few elements past the end of the coordinate/weight arrays; the Python-provided buffers were unpadded (potential out-of-bounds reads). They are now padded exactly like FCFC's own input routines.
pairs_to_pycorr: therppiconversion used one-sided pi edges without the mirror+halve transformation (wrong normalization in pycorr), the isotropic mode halved counts that should not be halved,isostates crashed on 1-D bin arrays, andlos_typewas recorded asfirstpointalthough FCFC uses the midpoint convention. All fixed, and validated against native pycorr counts.
Robustness and usability
- The Cython layer no longer duplicates the C structure layouts (a
silent-corruption hazard): all access goes through an accessor layer
(
pyfcfc_helpers.c) that treatsCF/DATAas opaque. - Full input validation with informative Python exceptions: shapes, dtypes, monotonic/finite/non-negative bin edges, label consistency, unknown configuration keywords (with typo suggestions).
- Python booleans, lists, tuples and NumPy arrays are accepted as
keyword values;
weightis set automatically so that unweighted catalogues use the exact integer counting path. - The GIL is released during pair counting; the library is quiet by
default (
verbose=Truerestores FCFC's progress output). - Result arrays are plain contiguous NumPy arrays with deterministic
shapes (
cfis no longer squeezed), andpolesis returned. - Modern packaging:
pyproject.toml, nonumpy.compat/distutilsimports (works with NumPy 2 and Cython 3), no hardcoded system paths, optional SIMD/march/LTO flags, stale generated C sources removed. - Dead code from the CLI era was pruned:
read_bins(file-based bin edges), theusage/version/conf_templatecallbacks with their stale texts, the commented-outconf_printblocks, and the FITS/HDF5 catalogue readers (excluded from the build);read_ascii.cstays because it backscmvdst_file. - Pair counts read back from an existing
PAIR_COUNT_FILEnow get their catalogue indices resolved like computed ones; previously their labels were left unset (out-of-bounds label reads in the original port), so the results dictionary could not name them correctly. - The number of mu bins is now forced to 1 outside the (s, mu) scheme
(upstream enforced this in the configuration parser, which the Python
binding bypasses): passing
nmu > 1withbin=0used to overflow the analytic-RR buffer whenever an estimator with@@was requested, corrupting memory;piedges are likewise ignored unlessbin=2. - Pair counts requested via
pairare always evaluated and returned, even when no CF estimator references them (the estimator only controls the optionalcf/multipoles/projectedproducts); a defensive check now raises if FCFC ever returns an unevaluated pair. - Repository hygiene: the legacy
test/scripts and artefacts of the original port (NERSC-dependent scripts, stale pair-count/.pkl.npy/.pngoutputs,command_line_help.txt, duplicated*_original.conffiles) and thecython_debug/andmain.zipleftovers were removed; their functionality is superseded bytests/,examples/andnotebooks/.
FCFC is written by Cheng Zhao and distributed under the MIT licence
(see LICENSE.txt); this wrapper inherits it. If you use FCFC in
research, please cite the FCFC paper (Zhao et al.). pycorr is developed
by the DESI collaboration (cosmodesi/pycorr).