Skip to content

Commit da0f6dc

Browse files
authored
Merge pull request #500 from PyAutoLabs/feature/interferometer-sparse-func-list
Sparse interferometer inversion: support linear func lists and multiple mappers
2 parents 86e2944 + ffafa86 commit da0f6dc

4 files changed

Lines changed: 988 additions & 30 deletions

File tree

autoarray/inversion/inversion/interferometer/inversion_interferometer_util.py

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,3 +790,251 @@ def body(block_i, C):
790790
C_pad = lax.fori_loop(0, n_blocks, body, C0)
791791
C = C_pad[:, :S]
792792
return 0.5 * (C + C.T)
793+
794+
def curvature_matrix_off_diag_from(
795+
self, rows0, cols0, vals0, rows1, cols1, vals1, *, S0: int, S1: int
796+
):
797+
"""
798+
Compute the off-diagonal (mapper-mapper) curvature block F01 = A0ᵀ W~ A1.
799+
800+
This method mirrors `ImagingSparseOperator.curvature_matrix_off_diag_from` and is the
801+
structural counterpart for the interferometer W~ operator. The difference between the two
802+
is the operator itself: for imaging W = Hᵀ N⁻¹ H is a PSF correlation, whereas here
803+
W~ = Re(Fᴴ W F) is the (translationally invariant) real-space operator of the non-uniform
804+
Fourier transform `F`, applied via `apply_operator` on the *unmasked-extent* rectangular
805+
grid (M = y_shape * x_shape).
806+
807+
Given two sparse mapping operators:
808+
809+
- A0 : (M × S0)
810+
- A1 : (M × S1)
811+
812+
this method computes F01 = A0ᵀ W~ A1 in column blocks of width `batch_size`:
813+
814+
1) Assemble Fbatch = A1[:, start:start+B] on the rectangular grid via scatter-add.
815+
2) Apply W~ to the block via FFT: Gbatch = W~(Fbatch).
816+
3) Project back with A0ᵀ via segment_sum over `cols0`.
817+
818+
Parameters
819+
----------
820+
rows0, cols0, vals0
821+
COO triplets for A0, where `rows0` are extent-grid (flat) indices in [0, M).
822+
rows1, cols1, vals1
823+
COO triplets for A1, where `rows1` are extent-grid (flat) indices in [0, M).
824+
S0
825+
Number of source pixels / parameters for mapper 0.
826+
S1
827+
Number of source pixels / parameters for mapper 1.
828+
829+
Returns
830+
-------
831+
ndarray
832+
Off-diagonal curvature block of shape (S0, S1).
833+
834+
Notes
835+
-----
836+
- The result is *not* symmetrized here because it is not square in general. The symmetric
837+
counterpart is F10 = F01ᵀ, because A0 and A1 share the same W~.
838+
- Padding to `S1_pad = ceil(S1/B)*B` ensures `dynamic_update_slice` is always legal.
839+
"""
840+
import jax.numpy as jnp
841+
from jax import lax
842+
from jax.ops import segment_sum
843+
844+
rows0 = jnp.asarray(rows0, dtype=jnp.int32)
845+
cols0 = jnp.asarray(cols0, dtype=jnp.int32)
846+
vals0 = jnp.asarray(vals0, dtype=jnp.float64)
847+
848+
rows1 = jnp.asarray(rows1, dtype=jnp.int32)
849+
cols1 = jnp.asarray(cols1, dtype=jnp.int32)
850+
vals1 = jnp.asarray(vals1, dtype=jnp.float64)
851+
852+
M = self.M
853+
B = self.batch_size
854+
855+
n_blocks = (S1 + B - 1) // B
856+
S1_pad = n_blocks * B
857+
858+
F01_0 = jnp.zeros((S0, S1_pad), dtype=jnp.float64)
859+
860+
def body(block_i, F01):
861+
start = block_i * B
862+
863+
in_block = (cols1 >= start) & (cols1 < (start + B))
864+
bc = jnp.where(in_block, cols1 - start, 0).astype(jnp.int32)
865+
v = jnp.where(in_block, vals1, 0.0)
866+
867+
F = jnp.zeros((M, B), dtype=jnp.float64)
868+
F = F.at[rows1, bc].add(v)
869+
870+
G = self.apply_operator(F) # (M, B)
871+
872+
contrib = vals0[:, None] * G[rows0, :]
873+
block = segment_sum(contrib, cols0, num_segments=S0)
874+
875+
width = jnp.minimum(B, jnp.maximum(0, S1 - start))
876+
block = block * (self.col_offsets < width)[None, :]
877+
878+
return lax.dynamic_update_slice(F01, block, (0, start))
879+
880+
F01_pad = lax.fori_loop(0, n_blocks, body, F01_0)
881+
return F01_pad[:, :S1]
882+
883+
def operated_matrix_slim_from(self, matrix_slim, extent_index_for_masked_pixel):
884+
"""
885+
Apply the interferometer W~ operator to columns defined on the *slim masked* grid.
886+
887+
The input columns are scattered from the slim masked grid onto the unmasked-extent
888+
rectangular grid (on which W~ is defined), operated on with `apply_operator`, and gathered
889+
back onto the slim masked grid.
890+
891+
Parameters
892+
----------
893+
matrix_slim
894+
Array of shape (M_pix, n_cols) on the slim masked grid (e.g. the real-space
895+
`mapping_matrix` of an `AbstractLinearObjFuncList`).
896+
extent_index_for_masked_pixel
897+
Array of shape (M_pix,) mapping slim masked pixel indices to extent-grid flat indices.
898+
899+
Returns
900+
-------
901+
ndarray
902+
Array of shape (M_pix, n_cols) equal to W~ applied to each column.
903+
"""
904+
import jax.numpy as jnp
905+
906+
matrix_slim = jnp.asarray(matrix_slim, dtype=jnp.float64)
907+
extent_index_for_masked_pixel = jnp.asarray(
908+
extent_index_for_masked_pixel, dtype=jnp.int32
909+
)
910+
911+
grid_flat = jnp.zeros((self.M, matrix_slim.shape[1]), dtype=jnp.float64)
912+
grid_flat = grid_flat.at[extent_index_for_masked_pixel, :].set(matrix_slim)
913+
914+
return self.apply_operator(grid_flat)[extent_index_for_masked_pixel, :]
915+
916+
def curvature_matrix_off_diag_func_list_from(
917+
self,
918+
curvature_weights, # (M_pix, n_funcs)
919+
extent_index_for_masked_pixel, # (M_pix,) slim -> extent(flat)
920+
rows,
921+
cols,
922+
vals, # triplets where rows are EXTENT indices
923+
*,
924+
S: int,
925+
):
926+
"""
927+
Compute the mapper–linear-function off-diagonal block Aᵀ W~ B.
928+
929+
This is the interferometer counterpart of
930+
`ImagingSparseOperator.curvature_matrix_off_diag_func_list_from`, but with one important
931+
difference in what `curvature_weights` must contain.
932+
933+
For imaging the operator is split as W = Hᵀ N⁻¹ H, so the imaging method is passed
934+
`curvature_weights = (H B) / noise²` (the forward blur and the inverse variance are folded
935+
into the input) and only applies Hᵀ internally.
936+
937+
For an interferometer the whole operator W~ = Re(Fᴴ W F) is applied by `apply_operator`,
938+
with the inverse-variance weighting *already inside* W~. Therefore `curvature_weights` is
939+
the plain real-space `mapping_matrix` of the linear function list on the slim masked grid,
940+
with **no** noise weighting and **no** forward operator applied.
941+
942+
The returned matrix is:
943+
944+
off_diag = Aᵀ W~ B
945+
946+
which has shape (S, n_funcs).
947+
948+
Parameters
949+
----------
950+
curvature_weights
951+
Array of shape (M_pix, n_funcs) on the *slim masked* grid: the un-operated,
952+
un-weighted real-space mapping matrix of the linear function list.
953+
extent_index_for_masked_pixel
954+
Array of shape (M_pix,) mapping slim masked pixel indices to extent-grid flat indices.
955+
Used to scatter values onto the rectangular grid W~ is defined on.
956+
rows, cols, vals
957+
COO triplets for the mapper A, where:
958+
- `rows` are extent-grid indices (flat), shape (nnz,)
959+
- `cols` are source pixel indices, shape (nnz,)
960+
- `vals` are mapping weights, shape (nnz,)
961+
S
962+
Number of source pixels / parameters in the mapper.
963+
964+
Returns
965+
-------
966+
ndarray
967+
Off-diagonal block of shape (S, n_funcs).
968+
969+
Notes
970+
-----
971+
- No `batch_size` sweep is required because the operator is applied to `n_funcs` columns
972+
(typically a handful) rather than to all S source pixels.
973+
"""
974+
import jax.numpy as jnp
975+
from jax.ops import segment_sum
976+
977+
curvature_weights = jnp.asarray(curvature_weights, dtype=jnp.float64)
978+
extent_index_for_masked_pixel = jnp.asarray(
979+
extent_index_for_masked_pixel, dtype=jnp.int32
980+
)
981+
982+
rows = jnp.asarray(rows, dtype=jnp.int32)
983+
cols = jnp.asarray(cols, dtype=jnp.int32)
984+
vals = jnp.asarray(vals, dtype=jnp.float64)
985+
986+
n_funcs = curvature_weights.shape[1]
987+
988+
# 1) scatter slim -> extent(flat)
989+
grid_flat = jnp.zeros((self.M, n_funcs), dtype=jnp.float64)
990+
grid_flat = grid_flat.at[extent_index_for_masked_pixel, :].set(
991+
curvature_weights
992+
)
993+
994+
# 2) apply W~ on the extent grid
995+
operated = self.apply_operator(grid_flat) # (M, n_funcs)
996+
997+
# 3) gather at the mapper's rows (extent coords) and accumulate to source pixels
998+
contrib = vals[:, None] * operated[rows, :]
999+
return segment_sum(contrib, cols, num_segments=S) # (S, n_funcs)
1000+
1001+
def curvature_matrix_func_list_from(
1002+
self,
1003+
curvature_weights_0, # (M_pix, n_funcs_0)
1004+
curvature_weights_1, # (M_pix, n_funcs_1)
1005+
extent_index_for_masked_pixel, # (M_pix,) slim -> extent(flat)
1006+
):
1007+
"""
1008+
Compute a linear-function–linear-function curvature block B0ᵀ W~ B1.
1009+
1010+
The imaging sparse inversion forms this block as a plain dot product of noise-weighted,
1011+
PSF-convolved mapping matrices, because for imaging those matrices are already in the
1012+
data frame. For an interferometer the equivalent dense construction would require the
1013+
(expensive) visibility-space transformed mapping matrix, which the sparse formalism exists
1014+
to avoid. Because W~ = Re(Fᴴ W F) is exact and translationally invariant on the extent
1015+
grid, the block is instead formed directly through the same operator used by every other
1016+
block, which is both cheaper and keeps every block of `F` self-consistent.
1017+
1018+
Parameters
1019+
----------
1020+
curvature_weights_0, curvature_weights_1
1021+
The un-operated, un-weighted real-space `mapping_matrix` of each linear function list,
1022+
on the slim masked grid, of shape (M_pix, n_funcs).
1023+
extent_index_for_masked_pixel
1024+
Array of shape (M_pix,) mapping slim masked pixel indices to extent-grid flat indices.
1025+
1026+
Returns
1027+
-------
1028+
ndarray
1029+
Curvature block of shape (n_funcs_0, n_funcs_1).
1030+
"""
1031+
import jax.numpy as jnp
1032+
1033+
curvature_weights_0 = jnp.asarray(curvature_weights_0, dtype=jnp.float64)
1034+
1035+
operated = self.operated_matrix_slim_from(
1036+
matrix_slim=curvature_weights_1,
1037+
extent_index_for_masked_pixel=extent_index_for_masked_pixel,
1038+
)
1039+
1040+
return curvature_weights_0.T @ operated

0 commit comments

Comments
 (0)