Skip to content

Commit 2c5cb69

Browse files
author
James Nightingale
authored
Merge pull request #619 from PyAutoLabs/feature/sparse-noise-map-pooling
feat: opt-in quadrature pooling of unequal real/imag sigma on the sparse path
2 parents f56986a + be725f8 commit 2c5cb69

4 files changed

Lines changed: 714 additions & 18 deletions

File tree

‎autoarray/dataset/interferometer/dataset.py‎

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,7 @@ def from_stream(
339339
show_progress: bool = False,
340340
batch_size: int = 128,
341341
phase_centre: Optional[Tuple[float, float]] = None,
342+
pool_noise_map: bool = False,
342343
) -> "Interferometer":
343344
"""
344345
Build an array-free `Interferometer` by accumulating a stream of visibility chunks,
@@ -351,6 +352,19 @@ def from_stream(
351352
which `from_sparse_terms` turns into the dataset; peak memory is set by the chunk size,
352353
not the dataset size.
353354
355+
The sparse terms assume every visibility has equal real and imaginary noise sigma: the
356+
precision operator and dirty beam are built from the real-part sigma alone. By default
357+
a chunk with unequal sigmas raises. For thermal noise a difference usually comes from
358+
the noise estimator (1-2 % when estimated by differencing adjacent visibilities); pass
359+
`pool_noise_map=True` to pool each chunk's sigmas in quadrature,
360+
`sigma^2 = (sigma_real^2 + sigma_imag^2) / 2` (which preserves the total variance),
361+
or pool them yourself before handing the chunks over with
362+
`inversion_interferometer_util.noise_map_pooled_from`. Pooling is approximate when the
363+
sigmas genuinely differ (the sparse curvature then drops the `cos(a + b)` term of the
364+
unequal weights; see `sparse_terms_from_chunks`), and the pooled `noise_normalization`
365+
differs from the unpooled one, so do not compare log-evidences of a pooled and an
366+
unpooled fit.
367+
354368
Parameters
355369
----------
356370
chunks
@@ -367,11 +381,17 @@ def from_stream(
367381
`(y0, x0)` lands at the image origin; recorded as `sparse_terms.phase_centre`.
368382
`None` applies no shift (recorded as `(0.0, 0.0)`). See
369383
`sparse_terms_from_chunks`.
384+
pool_noise_map
385+
If `True`, pool every chunk's real and imaginary noise sigma in quadrature before
386+
forming the terms instead of raising on unequal sigmas, logging the median and
387+
maximum difference once (a warning when the median exceeds 25 %). See
388+
`sparse_terms_from_chunks`.
370389
371390
Raises
372391
------
373392
exc.DatasetException
374-
If any chunk has unequal real and imaginary noise sigma.
393+
If any chunk has unequal real and imaginary noise sigma and `pool_noise_map` is
394+
`False`.
375395
"""
376396
if disable_jax():
377397
use_jax = False
@@ -387,6 +407,7 @@ def from_stream(
387407
use_jax=use_jax,
388408
show_progress=show_progress,
389409
phase_centre=phase_centre,
410+
pool_noise_map=pool_noise_map,
390411
)
391412

392413
return cls.from_sparse_terms(
@@ -427,6 +448,7 @@ def apply_sparse_operator(
427448
show_progress: bool = False,
428449
show_memory: bool = False,
429450
use_jax: bool = False,
451+
pool_noise_map: bool = False,
430452
):
431453
"""
432454
Precompute the NUFFT precision operator for efficient pixelized source reconstruction.
@@ -493,6 +515,11 @@ def apply_sparse_operator(
493515
the harness pays 2.3-3.2 s of compile for a backend it asked to disable. (The
494516
same switch demotes the `"nufft"` builder to the NumPy brute force inside
495517
`nufft_precision_operator_from`.)
518+
pool_noise_map
519+
If `True`, pool the real and imaginary noise sigma of every visibility in
520+
quadrature, `sigma^2 = (sigma_real^2 + sigma_imag^2) / 2`, before building the
521+
operator, instead of raising on unequal sigmas (see "Precondition" below). The
522+
returned dataset carries the pooled `noise_map`.
496523
497524
Precondition
498525
------------
@@ -502,19 +529,39 @@ def apply_sparse_operator(
502529
`psf_precision_operator_from`, which passes `noise_map_real` to
503530
`nufft_precision_operator_from`), a reduction that is exact only under that
504531
equality. With unequal sigmas the sparse curvature matrix silently disagrees with
505-
the dense `InversionInterferometerMapping` path, so this method raises a
532+
the dense `InversionInterferometerMapping` path, so by default this method raises a
506533
`DatasetException` rather than returning a wrong operator.
507534
535+
For thermal noise the real and imaginary parts of one visibility have the same
536+
variance, so a measured difference usually comes from the noise estimator (1-2 % when
537+
the noise is estimated by differencing adjacent visibilities). `pool_noise_map=True`
538+
pools the two in quadrature (which preserves the total variance) and logs the median
539+
and maximum difference once: an info line up to a 25 % median difference, a warning
540+
above it, where the difference may be real. The equivalent by hand is building the
541+
dataset with `noise_map=inversion_interferometer_util.noise_map_pooled_from(noise_map)`.
542+
543+
Pooling is approximate when the sigmas genuinely differ: with per-visibility weights
544+
`w_r`, `w_i` the exact curvature is `sum wbar cos(a - b) + dw cos(a + b)`
545+
(`wbar = (w_r + w_i) / 2`, `dw = (w_r - w_i) / 2`) and `W~` holds only the
546+
`cos(a - b)` part, so the dense `InversionInterferometerMapping` path (no
547+
`apply_sparse_operator`) remains the exact one. The returned dataset carries the pooled
548+
`noise_map`, so the dense residual and chi-squared maps and the cached sparse
549+
`data_term` / `noise_normalization` describe the same noise model. The pooled
550+
`noise_normalization` differs from the unpooled one, so do not compare the
551+
log-evidences of a pooled and an unpooled fit of the same data.
552+
508553
Returns
509554
-------
510555
Interferometer
511556
A new `Interferometer` dataset with the precomputed `InterferometerSparseOperator` attached,
512557
enabling efficient pixelized source reconstruction via the sparse linear algebra formalism.
558+
With `pool_noise_map=True` its `noise_map` is the pooled one.
513559
514560
Raises
515561
------
516562
exc.DatasetException
517-
If any visibility has unequal real and imaginary noise sigma.
563+
If any visibility has unequal real and imaginary noise sigma and `pool_noise_map`
564+
is `False`.
518565
"""
519566

520567
# `use_jax` now only selects between the two brute forces (the `"nufft"` builder
@@ -527,6 +574,41 @@ def apply_sparse_operator(
527574
"apply_sparse_operator", "data", "noise_map", "uv_wavelengths", "transformer"
528575
)
529576

577+
if pool_noise_map:
578+
# Every term is built from the pooled dataset, and it is the pooled dataset that is
579+
# returned, so the dense residual / chi-squared maps and the cached sparse
580+
# `data_term` / `noise_normalization` describe the same noise model.
581+
noise_map = np.asarray(self.noise_map.array, dtype=np.complex128)
582+
583+
pooling_record = inversion_interferometer_util._NoiseMapPoolingRecord()
584+
pooling_record.add(noise_map)
585+
586+
dataset_pooled = Interferometer(
587+
real_space_mask=self.real_space_mask,
588+
data=self.data,
589+
noise_map=VisibilitiesNoiseMap(
590+
visibilities=inversion_interferometer_util.noise_map_pooled_from(
591+
noise_map
592+
)
593+
),
594+
uv_wavelengths=self.uv_wavelengths,
595+
transformer_class=lambda uv_wavelengths, real_space_mask: self.transformer,
596+
)
597+
598+
pooling_record.log()
599+
600+
return dataset_pooled.apply_sparse_operator(
601+
nufft_precision_operator=nufft_precision_operator,
602+
batch_size=batch_size,
603+
method=method,
604+
eps=eps,
605+
nufft_chunk_size=nufft_chunk_size,
606+
chunk_k=chunk_k,
607+
show_progress=show_progress,
608+
show_memory=show_memory,
609+
use_jax=use_jax,
610+
)
611+
530612
inversion_interferometer_util.check_noise_map_real_imag_equal(self.noise_map)
531613

532614
if nufft_precision_operator is None:
@@ -639,9 +721,15 @@ def apply_sparse_operator_from_chunks(
639721
differ per chunk: `uv_wavelengths` a real `(K, 2)` array in wavelengths, `data` and
640722
`noise_map` a `Visibilities`, a complex `(K,)` array or a real `(K, 2)` array of
641723
(real, imag) columns. Every chunk must have equal real and imaginary noise sigma
642-
(checked per chunk). The chunks must together be exactly this dataset's visibilities
643-
(in any order) for the returned dataset to be self-consistent -- this method does not
644-
check that.
724+
(checked per chunk), the assumption the sparse operator rests on (see
725+
`apply_sparse_operator`, "Precondition"). `pool_noise_map=True` is refused here: the
726+
returned dataset retains this dataset's unpooled `noise_map`, which would then describe
727+
a different noise model from the operator. Pool the dataset itself first (build it
728+
with `noise_map=inversion_interferometer_util.noise_map_pooled_from(noise_map)` and
729+
take the chunks from it), or use `Interferometer.from_stream(..., pool_noise_map=True)`
730+
(array-free, no retained noise-map). The chunks must together be exactly this
731+
dataset's visibilities (in any order) for the returned dataset to be self-consistent
732+
-- this method does not check that.
645733
646734
Memory
647735
------
@@ -681,10 +769,11 @@ def apply_sparse_operator_from_chunks(
681769
`chunk_size`, `chunk_k`, `use_jax`, `show_progress`). `phase_centre` is rejected:
682770
it would shift the operator's terms but not this dataset's retained `data`, so the
683771
two would describe different phase centres; use `Interferometer.from_stream`
684-
(array-free, no retained data) to stream with a phase-centre shift. When not given,
685-
`transformer_class`, `eps` and `chunk_size` follow this dataset's transformer (the
686-
same defaults `psf_precision_operator_from` takes), so the accumulated terms match
687-
`apply_sparse_operator()`.
772+
(array-free, no retained data) to stream with a phase-centre shift.
773+
`pool_noise_map=True` is rejected for the same reason (see "Chunk contract").
774+
When not given, `transformer_class`, `eps` and `chunk_size` follow this dataset's
775+
transformer (the same defaults `psf_precision_operator_from` takes), so the
776+
accumulated terms match `apply_sparse_operator()`.
688777
689778
Returns
690779
-------
@@ -695,8 +784,8 @@ def apply_sparse_operator_from_chunks(
695784
Raises
696785
------
697786
exc.DatasetException
698-
If any chunk has unequal real and imaginary noise sigma, or a `phase_centre` is
699-
passed.
787+
If any chunk has unequal real and imaginary noise sigma, or a `phase_centre` or
788+
`pool_noise_map=True` is passed.
700789
"""
701790
if accumulator_kwargs.get("phase_centre") is not None:
702791
raise exc.DatasetException(
@@ -708,6 +797,19 @@ def apply_sparse_operator_from_chunks(
708797
"array-free dataset with no retained visibilities."
709798
)
710799

800+
if accumulator_kwargs.get("pool_noise_map", False):
801+
raise exc.DatasetException(
802+
"Interferometer.apply_sparse_operator_from_chunks does not take "
803+
"`pool_noise_map=True`: it would build the sparse operator from the pooled "
804+
"noise-map but retain this dataset's unpooled `noise_map`, so the two would "
805+
"describe different noise models and give contradictory likelihoods. Pool the "
806+
"dataset first -- `Interferometer(..., noise_map="
807+
"inversion_interferometer_util.noise_map_pooled_from(noise_map))`, with the "
808+
"chunks taken from it -- or use `Interferometer.from_stream(..., "
809+
"pool_noise_map=True)`, which builds an array-free dataset with no retained "
810+
"noise-map."
811+
)
812+
711813
if disable_jax() and accumulator_kwargs.get("use_jax", False):
712814
accumulator_kwargs["use_jax"] = False
713815

0 commit comments

Comments
 (0)