dstndstn / tractor

The Tractor: measuring astronomical sources via probabilistic inference
Other
86 stars 23 forks source link

Sersic fit yields significant bias in flux #108

Open hbahk opened 3 days ago

hbahk commented 3 days ago

Hello,

I'm struggling with the tractor to photometer Galsim-simulated galaxy profiles. I found that the tractor fit typically underestimates given fluxes, and the Gaussian mixture model flux for a Sersic profile is generally higher than the one generated from Galsim with the same total flux. This bias seems to depend on shear, size of the PSF, size of the source, sersic index, etc.

Below are some of results, this is the profile of the Galsim and tractor model with Gaussian PSF of sigma = 5 pix.

drawing

If this model is optimized then it gives slightly lower flux.

drawing

If I apply some shear, then the flux of the tractor mod gets boosted and the optimized flux gives underestimated value.

drawing drawing

This bias gets even worse for smaller PSF (I'm handling images of which PSF width < 1 pix)

drawing drawing

This behavior is also depends on sersic index and half light radius.

drawing

Freezing all shape parameters and thawing only brightness still show this bias..

drawing

I checked the legacy survey photometry and COSMOS2020 The Farmer catalog, but in these catalog this bias seems to be somehow corrected, or not existed at the first place.

drawing drawing

Did you recognize this flux bias? Then how did you resolve this for the legacy survey photometry? If not, did I make a mistake?

Thank you for your amazing work on the tractor by the way.

dstndstn commented 3 days ago

Hi,

If your PSF is not well sampled, then the Tractor code is definitely not guaranteed to produce correct results.

I believe that if you make your model image large enough, then the total flux in the Tractor model should equal your model flux.

hbahk commented 21 hours ago

Thank you for your comment!

Based on your comment I checked the PSF sampling, but the bias seems to persist.

For Galsim simulation, I changed the sampling of the PSF and found no significant difference. I also tried simulating in x10 oversampled image and downsampling the image, but bias was similar.

For Tractor, skimming through the source code I thought that NCircularGaussianPSF should be analytically convolved with the gaussian mixture model of Sersic profile. So I think it should not affect on the bias in my simulated sample.

Changing the window size doesn't help either.

drawing

Maybe we need more gaussian components for modeling high-indexed sersic profiles, since the summed flux in image indicates that the Tractor model lacks some flux than the Galsim model (~5%p in this image, but the truncated flux of Galsim model should exist). It is hard to understand how the shear transformation causes larger bias too. Or I just handled either Galsim or Tractor wrong...

Below is my code to simulate and model sersic profiles for reference. Thank you!

# Define the function to process each (r_e, n) combination
def _process_r_e_n(
    r_e,
    n,
    sigma=1.5,
    draw_figure=False,
    use_gaussian_psf=False,
    fix_sersic=False,
    verbose=False,
    nx=32,
    ny=32,
    e1=0.42,
    e2=0.07,
    flux=100.0,
    dlnplim=1e-3,
    fix_all_but_flux=False,
    no_opti=False,
):
    pixel_scale = 0.2
    pixnoise = 0.002
    try:
        # Galsim simulation ================================
        # Create the galaxy profile
        bdgal = galsim.Sersic(
            half_light_radius=r_e, n=n, flux=flux, flux_untruncated=False
        )
        g = np.hypot(e1, e2)
        beta = -0.5 * (np.arctan2(e2, e1) - np.pi) * galsim.radians
        bdgal = bdgal.shear(g=g, beta=beta)

        # Convolve with PSF
        if use_gaussian_psf:
            psf_sigma = sigma * pixel_scale  # in arcsec
            sigma_x10 = 10 * sigma
            xx, yy = np.meshgrid(
                np.arange(-5 * sigma_x10, 5 * sigma_x10 + 1),
                np.arange(-5 * sigma_x10, 5 * sigma_x10 + 1),
            )
            psf_image = np.exp(-0.5 * (xx**2 + yy**2) / sigma_x10**2)
            _psf = galsim.InterpolatedImage(
                galsim.Image(psf_image, scale=pixel_scale / 10.0), flux=1.0
            )
            # _psf = galsim.Gaussian(flux=1.0, sigma=psf_sigma)

        bdfinal = galsim.Convolve([bdgal, _psf])
        if verbose:
            print(
                f"total flux = {bdfinal.flux}\ngalaxy flux = {bdgal.flux}\n" + 
                f"hlr = {bdgal.original.half_light_radius}\ninput hlr = {r_e}"
            )

        # Draw the image
        seed = int((r_e * 1000) + (n * 1000)) % 2**32
        rng = galsim.BaseDeviate(seed)
        gaussian_noise = galsim.GaussianNoise(rng, sigma=pixnoise)
        # img = galsim.Image(nx, ny, scale=pixel_scale)
        img = galsim.Image(nx*10, ny*10, scale=pixel_scale / 10.0)
        bdfinal.drawImage(image=img)
        dsimg = downscale_local_mean(img.array, (10, 10)) * 100
        img = galsim.Image(dsimg, scale=pixel_scale)

        # Add noise
        newImg = img.copy()
        newImg.addNoise(gaussian_noise)

        # Tractor modeling ================================
        # Prepare for Tractor fitting
        if use_gaussian_psf:
            tractor_psf = NCircularGaussianPSF([sigma], [1.0])

        tim = Image(
            data=newImg.array,
            inverr=np.ones_like(newImg.array) / pixnoise,
            photocal=LinearPhotoCal(1.0),
            wcs=NullWCS(pixscale=pixel_scale),
            psf=tractor_psf,
        )

        fluxinit = flux if no_opti else flux * 0.5

        if use_gaussian_psf:
            galaxy_class = SersicGalaxy
        else:
            galaxy_class = SPHERExTractorSersicGalaxy
        galaxy = galaxy_class(
            PixPos(nx / 2 - 0.5, ny / 2 - 0.5),
            Flux(fluxinit),
            # EllipseE(r_e, e1, e2),
            EllipseESoft(np.log(r_e), e1, e2),
            # EllipseESoft(0.0, 0.0, 0.0),
            SersicIndex(n),
        )
        if verbose:
            print(f"initial model flux = {galaxy.getBrightness().getValue()}")

        from tractor.constrained_optimizer import ConstrainedOptimizer

        tractor = Tractor([tim], [galaxy], optimizer=ConstrainedOptimizer())
        tractor.freezeParam("images")
        if fix_sersic:
            galaxy.freezeParam("sersicindex")
        if fix_all_but_flux:
            galaxy.freezeAllBut("brightness")

        # Optimize the model
        # for _ in range(20):
        #     dlnp, X, alpha, var = tractor.optimize(
        #         shared_params=False, variance=True
        #     )
        #     if dlnp < dlnplim:
        #         if verbose:
        #             print(f"Converged for r_e={r_e}, n={n} at dlnP={dlnp}, iter={_}")
        #         break

        if not no_opti:
            tractor.optimize_loop(shared_params=False)
            var = tractor.optimize(
                shared_params=False, variance=True, just_variance=True
            )

            fluxresult = galaxy.getBrightness().getValue()
            fluxerrid = np.array(galaxy.getParamNames()) == "brightness.Flux"
            fluxerror = np.sqrt(var[fluxerrid][0])
        else:
            fluxresult = galaxy.getBrightness().getValue()
            fluxerror = np.nan

        if verbose:
            print(galaxy.getStepSizes())

        if draw_figure:
            mod = tractor.getModelImage(0)
            fig = plt.figure(figsize=(8, 3))
            ax = fig.add_subplot(131)
            im = ax.imshow(newImg.array, cmap="gray", origin="lower")
            # ax.plot(nx/2-0.5, ny/2-0.5, "r+")
            vmin, vmax = im.get_clim()
            ax.set_title("Image")
            ax.text(
                0.05,
                0.05,
                r"$F_{\rm input}$" + f" = {flux:.2f}",
                transform=ax.transAxes,
                c="w",
                ha="left",
                va="bottom",
            )
            ax = fig.add_subplot(132)
            ax.imshow(mod, cmap="gray", origin="lower")
            ax.text(
                0.05,
                0.05,
                r"$n_{\rm mod}$"
                + f"={galaxy.sersicindex.getValue():.2f}\n"
                + f"$r_e$={galaxy.shape.re/pixel_scale:.2f} pix \n"
                + r"$F_{\rm mod}$"
                + f"={fluxresult:.2f}",
                transform=ax.transAxes,
                c="w",
                ha="left",
                va="bottom",
            )
            ax.text(
                0.95,
                0.05,
                f"e1={galaxy.shape.ee1:.2f}\ne2={galaxy.shape.ee2:.2f}",
                transform=ax.transAxes,
                c="w",
                ha="right",
                va="bottom",
            )
            ax.set_title("Model")
            ax = fig.add_subplot(133)
            ax.imshow(newImg.array - mod, cmap="gray", origin="lower")
            ax.set_title("Residual")
            fig.suptitle(f"Sersic Index: {n:.1f}, $r_e$: {r_e/pixel_scale:.1f} pixels")

            rr = ((np.arange(ny) - ny // 2) / r_e * pixel_scale) ** 0.25
            gs = newImg.array[ny // 2, :]
            md = mod[ny // 2, :]
            fig = plt.figure(figsize=(5, 3))
            ax = fig.add_subplot(111)
            ax.plot(rr, gs, label="Galsim")
            ax.plot(rr, md, label="Tractor")
            ax.legend()
            ax.text(
                0.05,
                0.05,
                f"Galsim sum: {newImg.array.sum():.2f}\n"
                + f"Tractor sum: {mod.sum():.2f}\n"
                + r"$F_{\rm input}$"
                + f"={flux:.2f}\n"
                + r"$F_{\rm mod}$"
                + f"={fluxresult:.2f}\n"
                + r"$r_{e,{\rm mod}}$"
                + f"={galaxy.shape.re/pixel_scale:.2f} pix \n"
                + r"$n_{\rm mod}$"
                + f"={galaxy.sersicindex.getValue():.2f}\n"
                + f"e1={galaxy.shape.ee1:.2f}\ne2={galaxy.shape.ee2:.2f}",
                transform=ax.transAxes,
                c="k",
                ha="left",
                va="bottom",
            )
            ax.set_title(r"$\sigma_{\rm PSF}$" + f"={sigma:.1f} pix")
            ax.set_xlabel("$(r/r_e)^{1/4}$")
            ax.set_ylabel("Flux")
            # ax.set_xscale('log')
            ax.set_yscale("log")
            ax.set_xlim(0.0, 0.5)
            ax.set_ylim(1e-3, 30)

        return (galaxy.shape.re, galaxy.sersicindex.getValue(), fluxresult, fluxerror)

    except Exception as e:
        if verbose:
            print(f"Error processing r_e={r_e}, n={n}: {e}")
        # fluxresult = np.nan
        # fluxerror = np.nan
        return (np.nan, np.nan, np.nan, np.nan)

# Run single combination
_process_r_e_n(
    1,            # half light radius in pixels
    4,            # sersic index
    0.5,          # sigma of PSF in pixels
    e1=0.2,       # shear e1
    e2=0.40,      # shear e2
    flux=10000.0, # total flux
    nx=100,       # window size x
    ny=100,       # window size y
    draw_figure=True, 
    use_gaussian_psf=True,
    fix_sersic=False,
    verbose=True,
    dlnplim=1e-6,
    fix_all_but_flux=False,
    no_opti=False,
)