Skip to content

Commit cfe610b

Browse files
tmcornishdamonge
andauthored
Mode deprojection for NmtFieldCatalogClustering (#227)
* Added systematics deprojection to NmtFieldCatalogClustering * Conforming to PEP 8 * Added libgsl-dev to Ubuntu install part of workflow * Made positions_rand and weights_rand optional arguments and moved mask to higher prioirty * Mask can now entirely replace randoms (currently assumes same lmax for mask) * fixed API and added notes * deprojection test * error test * one more error test * fix doc --------- Co-authored-by: damonge <[email protected]>
1 parent d6e6fc1 commit cfe610b

File tree

3 files changed

+268
-21
lines changed

3 files changed

+268
-21
lines changed

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ jobs:
8383
- name: Install fitsio, fftw (ubuntu)
8484
if: matrix.label == 'linux-64'
8585
run: |
86-
sudo -H apt-get install libcfitsio-dev libfftw3-dev
86+
sudo -H apt-get install libcfitsio-dev libfftw3-dev libgsl-dev
8787
8888
- name: Install other dependencies (mac)
8989
if: matrix.label == 'osx-64'

pymaster/field.py

Lines changed: 190 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -977,6 +977,15 @@ class NmtFieldCatalogClustering(NmtField):
977977
of discrete sources, with a survey footprint characterised by a set of
978978
random points or through a standard mask.
979979
980+
.. note::
981+
The ordering of arguments for this class will change in the next
982+
major version of NaMaster.
983+
984+
.. note::
985+
Currently only HEALPix format is accepted for the mask and
986+
templates passed to this field, but we hope to implement CAR
987+
pixelisation in the near future.
988+
980989
Args:
981990
positions (`array`): Source positions, provided as a list or array
982991
of 2 arrays. If ``lonlat`` is True, the arrays should contain the
@@ -991,12 +1000,55 @@ class NmtFieldCatalogClustering(NmtField):
9911000
weights_rand (`array`): As ``weights`` for the random catalog.
9921001
lmax (:obj:`int`): Maximum multipole up to which the spherical
9931002
harmonics of this field will be computed.
1003+
mask (`array`): Array containing a map corresponding to the
1004+
field's mask. Should be 1-dimensional for a HEALPix map or
1005+
2-dimensional for a map with rectangular (CAR) pixelization.
1006+
If not ``None``, the random catalogue (``positions_rand`` and
1007+
``weights_rand``) will be ignored. Note that, if deprojection
1008+
templates are provided but no mask is passed, a mask will be
1009+
constructed from the random catalogue for deprojection.
9941010
lonlat (:obj:`bool`): If ``True``, longitude and latitude in degrees
9951011
are provided as input. If ``False``, colatitude and longitude in
9961012
radians are provided instead.
1013+
templates (`array`): Array containing a set of contaminant templates
1014+
for this field. This array should have shape ``[ntemp,nmap,...]``,
1015+
where ``ntemp`` is the number of templates, ``nmap`` should be 1
1016+
for spin-0 fields and 2 otherwise. The other dimensions should be
1017+
``[npix]`` for HEALPix maps or ``[ny,nx]`` for maps with
1018+
rectangular pixels. The best-fit contribution from each
1019+
contaminant is automatically removed from the maps unless
1020+
``templates=None``.
1021+
masked_on_input (:obj:`bool`): Set to ``True`` if the input templates
1022+
are already multiplied by the mask.
1023+
n_iter (:obj:`int`): Number of iterations when computing the
1024+
:math:`a_{\\ell m}` s of the input maps. See the documentation of
1025+
:meth:`~pymaster.utils.map2alm`. If ``None``, it will default to
1026+
the internal value (see documentation of
1027+
:class:`~pymaster.utils.NmtParams`), which can be accessed via
1028+
:meth:`~pymaster.utils.get_default_params`, and modified via
1029+
:meth:`~pymaster.utils.set_n_iter_default`.
1030+
n_iter_mask (:obj:`int`): Number of iterations when computing the
1031+
spherical harmonic transform of the mask. If ``None``, it will
1032+
default to the internal value (see documentation of
1033+
:class:`~pymaster.utils.NmtParams`), which can be accessed via
1034+
:meth:`~pymaster.utils.get_default_params`,
1035+
and modified via :meth:`~pymaster.utils.set_n_iter_default`.
1036+
wcs (`WCS`): A WCS object if using rectangular (CAR) pixels (see
1037+
`the astropy documentation
1038+
<http://docs.astropy.org/en/stable/wcs/index.html>`_).
1039+
tol_pinv (:obj:`float`): When computing the pseudo-inverse of the
1040+
contaminant covariance matrix. See documentation of
1041+
:meth:`~pymaster.utils.moore_penrose_pinvh`. Only relevant if
1042+
passing contaminant templates that are likely to be highly
1043+
correlated. If ``None``, it will default to the internal value,
1044+
which can be accessed via
1045+
:meth:`~pymaster.utils.get_default_params`, and modified via
1046+
:meth:`~pymaster.utils.set_tol_pinv_default`.
9971047
"""
9981048
def __init__(self, positions, weights, positions_rand, weights_rand,
999-
lmax, lonlat=False):
1049+
lmax, lonlat=False, mask=None, templates=None,
1050+
masked_on_input=False, n_iter=None, n_iter_mask=None,
1051+
wcs=None, tol_pinv=None):
10001052
# Preliminary initializations
10011053
if ut.HAVE_DUCC:
10021054
self.sht_calculator = 'ducc'
@@ -1005,10 +1057,10 @@ def __init__(self, positions, weights, positions_rand, weights_rand,
10051057

10061058
# These first attributes are compulsory for all fields
10071059
self.lite = True
1008-
self.mask = None
1060+
self.mask = mask
10091061
self.beam = np.ones(lmax+1)
1010-
self.n_iter = None
1011-
self.n_iter_mask = None
1062+
self.n_iter = n_iter
1063+
self.n_iter_mask = n_iter_mask
10121064
self.pure_e = False
10131065
self.pure_b = False
10141066
self.alm = None
@@ -1020,10 +1072,11 @@ def __init__(self, positions, weights, positions_rand, weights_rand,
10201072
self.spin = 0
10211073
self.is_catalog = True
10221074

1023-
# The remaining attributes are only required for non-lite maps
1024-
self.maps = None
1075+
# These attributes only required if templates provided for deprojection
10251076
self.temp = None
10261077
self.alm_temp = None
1078+
# The remaining attributes are only required for non-lite maps
1079+
self.maps = None
10271080
self.minfo = None
10281081
self.n_temp = 0
10291082
self.anisotropic_mask = False
@@ -1067,22 +1120,52 @@ def process_pos_w(pos, w, kind):
10671120
return pos, w
10681121

10691122
positions, weights = process_pos_w(positions, weights, "data")
1070-
positions_rand, weights_rand = process_pos_w(positions_rand,
1071-
weights_rand, "random")
1072-
1073-
# Compute alpha
1074-
self._alpha = np.sum(weights)/np.sum(weights_rand)
10751123

1076-
# Compute mask shot noise
1077-
self._Nw = self._alpha**2*np.sum(weights_rand**2.)/(4.*np.pi)
1078-
1079-
# Compute mask alms
1080-
# Sanity checks
1124+
# Define alm info for mask using same lmax as field
10811125
self.ainfo_mask = ut.NmtAlmInfo(lmax)
1082-
# Mask alms
1083-
self.alm_mask = ut._catalog2alm_ducc0(weights_rand*self._alpha,
1084-
positions_rand,
1085-
spin=0, lmax=lmax)
1126+
1127+
# Check if mask provided, use randoms if not
1128+
if mask is None:
1129+
positions_rand, weights_rand = process_pos_w(positions_rand,
1130+
weights_rand,
1131+
"random")
1132+
# Compute alpha
1133+
self._alpha = np.sum(weights)/np.sum(weights_rand)
1134+
1135+
# Compute mask shot noise
1136+
self._Nw = self._alpha**2*np.sum(weights_rand**2.)/(4.*np.pi)
1137+
1138+
# Compute mask alms
1139+
self.alm_mask = ut._catalog2alm_ducc0(weights_rand*self._alpha,
1140+
positions_rand,
1141+
spin=0, lmax=lmax)
1142+
# If mask provided, ignore/replace randoms-related quantities
1143+
else:
1144+
# Get the number of pixels in the mask and convert to NSIDE
1145+
# NOTE: assumes HealPIX format for now
1146+
npix = len(mask)
1147+
nside = hp.npix2nside(npix)
1148+
# Pixel area in steradians
1149+
Apix = 4. * np.pi / npix
1150+
1151+
# Initialisation of parameters related to the mask
1152+
if n_iter_mask is None:
1153+
n_iter_mask = ut.nmt_params.n_iter_default
1154+
# No randoms, so set alpha to 1
1155+
self._alpha = 1.
1156+
# No shot noise for map-based masks
1157+
self._Nw = 0.
1158+
1159+
# Use mask to estimate expected mean number density in each pixel
1160+
nbar = mask * np.sum(weights) / (Apix * np.sum(mask))
1161+
1162+
# Get spatial info from the mask
1163+
self.minfo = ut.NmtMapInfo(wcs, mask.shape)
1164+
self.mask = self.minfo.reform_map(mask)
1165+
# Compute mask alms
1166+
self.alm_mask = ut.map2alm(np.array([nbar]), 0,
1167+
self.minfo, self.ainfo_mask,
1168+
n_iter=n_iter_mask)
10861169

10871170
# Compute field alms
10881171
self.alm = ut._catalog2alm_ducc0(weights, positions,
@@ -1091,3 +1174,90 @@ def process_pos_w(pos, w, kind):
10911174

10921175
# Compute field shot noise
10931176
self._Nf = np.sum(weights**2)/(4*np.pi)+self._Nw
1177+
1178+
# Systematics mitigation with mode deprojection
1179+
if templates is not None:
1180+
# Initialisation of parameters related to deprojection
1181+
if n_iter is None:
1182+
n_iter = ut.nmt_params.n_iter_default
1183+
if tol_pinv is None:
1184+
tol_pinv = ut.nmt_params.tol_pinv_default
1185+
1186+
# Check format of templates
1187+
if isinstance(templates, (list, tuple, np.ndarray)):
1188+
templates = np.array(templates, dtype=np.float64)
1189+
if (len(templates[0]) != 1):
1190+
raise ValueError("Templates must have length 1 "
1191+
"along axis 1.")
1192+
else:
1193+
raise ValueError("Input templates can only be an array "
1194+
"or None")
1195+
self.n_temp = len(templates)
1196+
1197+
# Method of deprojection depends on whether mask provided
1198+
if mask is None:
1199+
# Get spatial info from first template
1200+
self.minfo = ut.NmtMapInfo(wcs, templates.shape[2:])
1201+
templates = self.minfo.reform_map(templates)
1202+
# Get the number of pixels per template and convert to NSIDE
1203+
npix = len(templates[0][0])
1204+
nside = hp.npix2nside(npix)
1205+
# Identify the pixels to which each random belongs
1206+
ipix_rand = hp.ang2pix(nside, *positions_rand)
1207+
1208+
# Apply mask to templates if not done already
1209+
if not masked_on_input:
1210+
# Generate a mask from the positions of the randoms
1211+
mask = np.bincount(ipix_rand,
1212+
minlength=npix).astype(np.float64)
1213+
# Normalise to range [0,1]
1214+
mask /= mask.max()
1215+
# Multiply the templates by the mask
1216+
templates *= mask[None, :]
1217+
1218+
# Get the template values at each random's position
1219+
temp_at_rand = templates[:, :, ipix_rand]
1220+
# Multiply by the weights assigned to each random
1221+
temp_at_rand *= weights_rand[None, None, :]
1222+
# Sum across all randoms
1223+
S_rand = temp_at_rand.sum(axis=2) * self._alpha
1224+
else:
1225+
templates = self.minfo.reform_map(templates)
1226+
# Apply mask to templates if not done already
1227+
if not masked_on_input:
1228+
templates *= mask[None, :]
1229+
S_rand = np.sum(Apix * templates * nbar[None, :], axis=2)
1230+
1231+
self.n_temp = len(templates)
1232+
1233+
# Identify the pixels to which each source belongs
1234+
ipix_data = hp.ang2pix(nside, *positions)
1235+
# Get the template values at each source's position
1236+
temp_at_data = templates[:, :, ipix_data]
1237+
# Multiply by the weights assigned to each source
1238+
temp_at_data *= weights[None, None, :]
1239+
# Sum across all sources
1240+
S_data = temp_at_data.sum(axis=2)
1241+
1242+
# Weighted difference between the two sums
1243+
dS = S_data - S_rand
1244+
1245+
# Compute alms of each template
1246+
alm_temp = np.array([ut.map2alm(t, self.spin, self.minfo,
1247+
self.ainfo, n_iter=n_iter)
1248+
for t in templates])
1249+
1250+
# Compute template normalisation matrix
1251+
M = np.array([[self.minfo.si.dot_map(t1, t2)
1252+
for t1 in templates]
1253+
for t2 in templates])
1254+
iM = ut.moore_penrose_pinvh(M, tol_pinv)
1255+
1256+
# Compute the alms of the deprojected component
1257+
alm_deproj = np.zeros(self.alm.shape, dtype=np.complex128)
1258+
for i in range(self.n_temp):
1259+
for j in range(self.n_temp):
1260+
alm_deproj += alm_temp[i] * iM[i][j] * dS[j]
1261+
1262+
# Subtract from the alms of the field
1263+
self.alm -= alm_deproj

pymaster/tests/test_field_catalog.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,15 @@ def test_field_catalog_errors():
253253
nmt.NmtFieldCatalogClustering([[0., 0.], [1., 1.]], [1., 1.],
254254
[[0., 0.], [1., 1.]], [1., 1.], 10)
255255
nmt.utils.HAVE_DUCC = True
256+
# Wrong template size ([1, npix] instead of [1, 1, npix])
257+
with pytest.raises(ValueError):
258+
nmt.NmtFieldCatalogClustering([[0., 0.], [1., 1.]], [1., 1.],
259+
[[0., 0.], [1., 1.]], [1., 1.], 10,
260+
templates=np.zeros([1, 12*64**2]))
261+
with pytest.raises(ValueError):
262+
nmt.NmtFieldCatalogClustering([[0., 0.], [1., 1.]], [1., 1.],
263+
[[0., 0.], [1., 1.]], [1., 1.], 10,
264+
templates=-5)
256265

257266

258267
def test_field_catalog_clustering_poisson():
@@ -310,3 +319,71 @@ def test_field_catalog_clustering_poisson():
310319
x = np.mean(c, axis=0)
311320
s = np.std(c, axis=0)/np.sqrt(nsims)
312321
assert np.all(np.fabs(x) < 5*s)
322+
323+
324+
def test_field_catalog_clustering_deproj():
325+
nside = 128
326+
npix = hp.nside2npix(nside)
327+
328+
# Create depth variations
329+
depth = np.ones(npix)
330+
for lat in np.linspace(-60, 60, 5):
331+
for lon in np.linspace(0, 360, 10):
332+
v = hp.ang2vec(lon, lat, lonlat=True)
333+
ip = hp.query_disc(nside, v, np.radians(5))
334+
depth[ip] = 0.7
335+
fsky = np.sum(depth)/len(depth)
336+
depth_var = depth - np.mean(depth)
337+
338+
# Create random catalog
339+
pos_ran = np.array([np.arccos(-1+2*np.random.rand(4*npix)),
340+
2*np.pi*np.random.rand(4*npix)])
341+
w_ran = np.ones(4*npix)
342+
343+
# Create dummy field for workspaces
344+
b = nmt.NmtBin.from_nside_linear(nside, 4)
345+
f = nmt.NmtFieldCatalogClustering(pos_ran, w_ran, pos_ran, w_ran,
346+
lmax=3*nside-1)
347+
w = nmt.NmtWorkspace.from_fields(f, f, b)
348+
349+
nsims = 10
350+
ncat = npix//4
351+
cls_biased = []
352+
cls_deproj_cat = []
353+
cls_deproj_msk = []
354+
for i in range(nsims):
355+
# Generate sim and get C_ell
356+
pos_dat = np.array([np.arccos(-1+2*np.random.rand(ncat)),
357+
2*np.pi*np.random.rand(ncat)])
358+
keep = np.random.rand(ncat) < depth[hp.ang2pix(nside, *pos_dat)]
359+
pos_dat = pos_dat[:, keep]
360+
w_dat = np.ones(pos_dat.shape[1])
361+
f = nmt.NmtFieldCatalogClustering(pos_dat, w_dat, pos_ran, w_ran,
362+
lmax=3*nside-1)
363+
assert np.isclose(f.alpha, fsky/16, rtol=0.1)
364+
cls_biased.append(w.decouple_cell(nmt.compute_coupled_cell(f, f)))
365+
f = nmt.NmtFieldCatalogClustering(pos_dat, w_dat, pos_ran, w_ran,
366+
lmax=3*nside-1,
367+
templates=[[depth_var]])
368+
cls_deproj_cat.append(w.decouple_cell(nmt.compute_coupled_cell(f, f)))
369+
f = nmt.NmtFieldCatalogClustering(pos_dat, w_dat, None, None,
370+
lmax=3*nside-1, mask=np.ones(npix),
371+
templates=[[depth_var]])
372+
cls_deproj_msk.append(w.decouple_cell(nmt.compute_coupled_cell(f, f)))
373+
cls_biased = np.array(cls_biased).squeeze()
374+
cls_deproj_cat = np.array(cls_deproj_cat).squeeze()
375+
cls_deproj_msk = np.array(cls_deproj_msk).squeeze()
376+
377+
def check_biased(c_ells, biased_bad=True):
378+
x = np.mean(c_ells, axis=0)
379+
s = np.std(c_ells, axis=0)/np.sqrt(nsims)
380+
if biased_bad:
381+
assert np.all(np.fabs(x) < 5*s)
382+
else:
383+
assert np.any(np.fabs(x) > 5*s)
384+
385+
# Check that the power spectra are biased without deprojection
386+
check_biased(cls_biased, biased_bad=False)
387+
# But they're unbiased otherwise (whether randoms or mask are passed)
388+
check_biased(cls_deproj_msk)
389+
check_biased(cls_deproj_msk)

0 commit comments

Comments
 (0)