diff --git a/README.md b/README.md index 97ade1d..764e24a 100644 --- a/README.md +++ b/README.md @@ -98,14 +98,13 @@ for the chapter 1 tutorials listed with a `markdown` link below; every other tut - [Tutorial 5: Data and Likelihoods](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_2_lens_modeling/tutorial_5_data_and_likelihoods.ipynb) — Separating datasets, models and likelihoods, including positions penalties. - [Tutorial 6: Search Chaining](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_2_lens_modeling/tutorial_6_search_chaining.ipynb) — Combining searches and introducing the SLaM pipelines. - [Tutorial 7: Summary](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_2_lens_modeling/tutorial_7_summary.ipynb) — Bringing the chapter together and continuing to pixelized sources. -- **[Chapter 3: Pixelizations](scripts/chapter_3_pixelizations/README.md)** — From MGE to pixelized sources: mappings, inversions, Bayesian regularization, borders and chained lens fits. +- **[Chapter 3: Pixelizations](scripts/chapter_3_pixelizations/README.md)** — From MGE to pixelized sources: mappings, inversions, Bayesian regularization, borders, fit problems, adaptive reconstruction and chained lens fits. - Tutorial 1 Pixelizations — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb)) - Tutorial 2 Bayesian Regularization — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb)) - Tutorial 3 Borders — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb)) - Tutorial 4 Lens Modeling — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb)) - - Tutorial 8 Adaptive Pixelization — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb)) - - Tutorial 11 Brightness Adaption — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb)) - - Tutorial 12 Adaptive Regularization — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb)) + - Tutorial 5 Fit Problems — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb)) + - Tutorial 6 Adaptive Reconstruction — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb)) - **[Chapter 4: Scaling Up Lensing](scripts/chapter_4_scaling_up_lensing/README.md)** — Extra galaxies, multi-galaxy lenses, scaling relations, group and cluster scales, and weak lensing. - Tutorial 1: Extra Galaxies — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/2026.11.1.1/notebooks/chapter_4_scaling_up_lensing/tutorial_1_extra_galaxies.ipynb)) - Tutorial 2: Multi Galaxy — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/2026.11.1.1/notebooks/chapter_4_scaling_up_lensing/tutorial_2_multi_galaxy.ipynb)) diff --git a/config/build/profile_smoke.yaml b/config/build/profile_smoke.yaml index 2bcf776..cc76aef 100644 --- a/config/build/profile_smoke.yaml +++ b/config/build/profile_smoke.yaml @@ -30,8 +30,5 @@ defaults: # `multi/start_here` and `guides/results/` are all autolens_workspace paths # copied in from that repo's profile_smoke.yaml. HowToLens contains only # chapter_*/tutorial_*.py, so none of them ever matched a file here. -# Do not copy override patterns between repos; a pattern that matches nothing -# is silently inert, not an override. The list is now empty, and `overrides: []` -# is written explicitly because env_config.py reads `.get("overrides", [])` — -# a bare `overrides:` key would load as None and raise on iteration. +# Tutorials requiring full-size datasets declare this in their __Env__ sections. overrides: [] diff --git a/llms-full.txt b/llms-full.txt index d0234f3..11a8eaa 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -44,20 +44,18 @@ AUTO-GENERATED by PyAutoHands — do not edit by hand; regenerate with generate. ## chapter_3_pixelizations -- [Tutorial 11: Brightness Adaption](scripts/chapter_3_pixelizations/tutorial_11_brightness_adaption.py): In the previous tutorial we motivated our need to adapt the pixelization to the source's morphology, such that source pixels congregates in the source's brightest regions regardless of where the source is located in the source-plane. - - Contents: Initial Setup, Adapt Image, Adaption, Hilbert, Weight Map, Wrap Up -- [Tutorial 12: Adaptive Regularization](scripts/chapter_3_pixelizations/tutorial_12_adaptive_regularization.py): Tutorial 2 introduced `Constant` regularization. Different regions of the source can favour different levels of regularization, motivating a scheme which adapts to the reconstructed source's surface brightness. - - Contents: Initial Setup, Convenience Function, Adaptive Regularization, Wrap Up -- [Tutorial 1: Pixelizations, Mappers and Inversions](scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py): (no summary in script docstring) - - Contents: Opening — Pixelized source reconstruction, A complex source, Why a simple source model falls short, A grid for the source light, Following the same image pixels into the source plane, Where a traced image pixel meets the source mesh, One source region, several image regions, Reconstructing the source, A first look at regularization, From source indexes to reconstructed brightnesses, Turning the reconstructed source back into an image, Inspecting the reconstruction, Changing the source resolution, Wrap up -- [Tutorial 2: Bayesian Regularization](scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py): (no summary in script docstring) - - Contents: Interpolation and Bayesian regularization, Interpolation and our starting reconstruction, Reducing regularization, Increasing regularization, Bayesian evidence, Exploring the regularization coefficient, Inspecting the preferred reconstruction, What is linear, and what requires a search?, Mesh resolution and regularization belong together, What we have learned, and what comes next -- [Tutorial 3: Borders](scripts/chapter_3_pixelizations/tutorial_3_borders.py): (no summary in script docstring) - - Contents: Why the extent of the source mesh matters, What we mean by a border, Starting without border relocation, Including the central image pixels, Following the central pixels into the source plane, Tracing the outer border, Switching border relocation on, What relocation changes—and what it does not, The mask still needs to cover the lensed source, Wrap up -- [Tutorial 4: Lens Modeling](scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py): (no summary in script docstring) - - Contents: Lens modelling with a pixelized source, Giving the inversion an incorrect mass model, Comparing with the correct mass model, Seeing how the mass model changes the mappings, Why evidence does not remove the search difficulty, Starting with a simpler source model, Running and inspecting the initial search, Requiring corresponding images to trace together, Replacing the analytic source with a pixelization, Running the pixelized-source search, Establishing the foreground light model, Using that result in the pixelized-source fit -- [Tutorial 8: Adaptive Pixelization](scripts/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.py): In this tutorial we will introduce a new `Pixelization` object, which uses an `Overlay` image-mesh and a `Delaunay` mesh. - - Contents: Initial Setup, Advantages and Disadvantages, Image Mesh, Regularization, Source Compactness, Wrap Up +- [Tutorial 1: Pixelizations, Mappers and Inversions](scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py): Reconstruct a complex source using pixelizations, image-to-source mappings and a linear inversion. + - Contents: Opening — Pixelized source reconstruction, A complex source, Why a simple source model falls short, A grid for the source light, Following the same image pixels into the source plane, Where a traced image pixel meets the source mesh, One source region, several image regions, Reconstructing the source, A first look at regularization, From source indexes to reconstructed brightnesses, Keeping source brightnesses non-negative, Where the image weakly constrains the source, Turning the reconstructed source back into an image, Inspecting the reconstruction, Changing the source resolution, Wrap up +- [Tutorial 2: Bayesian Regularization](scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py): Explore regularization and use Bayesian evidence to choose its strength for a source reconstruction. + - Contents: Interpolation and Bayesian regularization, Interpolation and our starting reconstruction, Reducing regularization, Increasing regularization, Bayesian evidence, Choosing the coefficient, Exploring the regularization coefficient, Inspecting the preferred reconstruction, What is linear, and what requires a search?, Mesh resolution and regularization belong together, Wrap Up +- [Tutorial 3: Borders](scripts/chapter_3_pixelizations/tutorial_3_borders.py): Explore how masks and border relocation affect the extent and resolution of a source mesh. + - Contents: Why the extent of the source mesh matters, What we mean by a border, Starting without border relocation, Including the central image pixels, Following the central pixels into the source plane, Tracing the outer border, Switching border relocation on, What relocation changes, The mask still needs to cover the lensed source, Wrap Up +- [Tutorial 4: Lens Modeling](scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py): Fit lens mass and foreground light alongside a pixelized source using search chaining and a positions likelihood penalty. + - Contents: Lens modelling with a pixelized source, Giving the inversion an incorrect mass model, Comparing with the correct mass model, Seeing how the mass model changes the mappings, Why evidence does not remove the search difficulty, Starting with a simpler source model, Running and inspecting the initial search, Requiring corresponding images to trace together, Replacing the analytic source with a pixelization, Running the pixelized-source search, Establishing the foreground light model, Using that result in the pixelized-source fit, Wrap Up +- [Tutorial 5: Fit Problems](scripts/chapter_3_pixelizations/tutorial_5_fit_problems.py): Investigate why compact sources can challenge mesh resolution and constant regularization even when the mass model is correct. + - Contents: When the best coefficient is not enough, Three progressively compact sources, Simulating the images, Reconstructing the sources, Finding the preferred regularization, Starting with the extended source, Making the source more compact, A very compact source, Why the mesh becomes a limitation, Why one regularization coefficient becomes a compromise, Why maximizing the evidence does not remove the compromise, Wrap Up +- [Tutorial 6: Adaptive Reconstruction](scripts/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.py): Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization in a chained lens-model fit. + - Contents: Adaptive source reconstruction, Returning to the compact-source problem, From an image mesh to a source mesh, Constructing the Delaunay source mesh, Interpolating within a triangle, Regularization on an irregular mesh, Building an adapt image, Placing points with a Hilbert image mesh, Controlling the brightness weighting, Keeping resolution in the faint regions, Reconstructing with the Hilbert mesh, Comparing overlay and Hilbert reconstructions, Smoothing on an irregular mesh, Reconstructing with ConstantSplit, Allowing different smoothing strengths, Controlling the transition with `signal_scale`, Exploring the adaptive coefficients, Does adaptive regularization address the fit problem?, Starting the final modelling chain, Preparing adaptation and the positions likelihood penalty, Defining the adaptive Delaunay model, Running the adaptive Delaunay fit, Inspecting the source and smoothing pattern, Wrap Up ## chapter_4_scaling_up_lensing diff --git a/notebooks/chapter_3_pixelizations/README.md b/notebooks/chapter_3_pixelizations/README.md index 4cd4c8b..08f4039 100644 --- a/notebooks/chapter_3_pixelizations/README.md +++ b/notebooks/chapter_3_pixelizations/README.md @@ -21,8 +21,5 @@ MGE remains valuable, especially for foreground lens light and initial source fi - [Borders](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb) - [Lens Modeling](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb) -The following tutorials explore adaptive meshes and regularization: - -- [Adaptive Pixelization](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb) -- [Brightness Adaption](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb) -- [Adaptive Regularization](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb) +- [Fit Problems](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb) +- [Adaptive Reconstruction](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb) diff --git a/notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb b/notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb deleted file mode 100644 index 2fc15cc..0000000 --- a/notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb +++ /dev/null @@ -1,621 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Tutorial 11: Brightness Adaption\n", - "================================\n", - "\n", - "In the previous tutorial we motivated our need to adapt the pixelization to the source's morphology, such that\n", - "source pixels congregates in the source's brightest regions regardless of where the source is located in the\n", - "source-plane.\n", - "\n", - "This poses a challenge; how do we adapt our pixelization to the reconstructed source's light, before we've actually\n", - "reconstructed the source and therefore know what to adapt it too?\n", - "\n", - "To do this, we define 'adapt_images' of the lensed source galaxy, which are model images of the source computed\n", - "using a previous lens model that has been fit to the image (e.g. in the earlier search of a pipeline). This image\n", - "tells us where in the image our source is located, thus informing us of where we need to adapt our source\n", - "pixelization!\n", - "\n", - "Tutorial 8's compactness experiment motivated adapting to the source's morphology. Here we use the standalone\n", - "`simple__no_lens_light` dataset, rather than one of those progressively compact simulated sources. We begin with a\n", - "magnification-based pixelization and use its model image as an adapt image.\n", - "\n", - "__Contents__\n", - "\n", - "- **Initial Setup:** we'll use the same strong lensing data as the previous tutorial, where.\n", - "- **Adapt Image:** We can use this fit to set up our adapt image.\n", - "- **Adaption:** Now lets take a look at brightness based adaption in action.\n", - "- **Hilbert:** So how does the `adapt_image` adapt the pixelization to the source's brightness?\n", - "- **Weight Map:** We now have a sense of how our `Hilbert` image-mesh is computed, so lets look at how we create\n", - " the.\n", - "- **Wrap Up:** Summary of the script and next steps." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Google Colab Setup__\n", - "\n", - "Set up Google Colab as in [the introductory notebook](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_1_introduction/tutorial_1_grids_and_galaxies.ipynb).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "cellView": "form", - "jupyter": { - "source_hidden": true - } - }, - "outputs": [], - "source": [ - "# @title Set up Google Colab { display-mode: \"form\" }\n", - "try:\n", - " import google.colab\n", - "except ImportError:\n", - " from autolens import setup_colab as _setup_colab\n", - "else:\n", - " import importlib\n", - " import subprocess\n", - " import sys\n", - "\n", - " subprocess.check_call(\n", - " [sys.executable, \"-m\", \"pip\", \"install\", \"autonerves\", \"--no-deps\"]\n", - " )\n", - " _setup_colab = importlib.import_module(\"autonerves.setup_colab\")\n", - "\n", - "_setup_colab.setup(\"howtolens\")" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "\n", - "import importlib.util\n", - "import sys\n", - "\n", - "if importlib.util.find_spec(\"jax\") is None:\n", - " print(\n", - " \"Skipping this tutorial: it requires the `jax` package, which is not \"\n", - " \"installed (install with `pip install autolens[optional]`).\"\n", - " )\n", - " sys.exit(0)\n", - "\n", - "from autolens import jax_wrapper # Sets JAX environment before other imports\n", - "\n", - "from autolens import setup_notebook; setup_notebook()\n", - "\n", - "from pathlib import Path\n", - "import autolens as al\n", - "import autolens.plot as aplt\n", - "import autoarray.plot as aaplt" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Initial Setup__\n", - "\n", - "we'll use the same strong lensing data as the previous tutorial, where:\n", - "\n", - " - The lens galaxy's light is omitted.\n", - " - The lens system uses an `Isothermal` galaxy mass profile and a separate `ExternalShear` field.\n", - " - The source galaxy's light is an `Sersic`." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "dataset_name = \"simple__no_lens_light\"\n", - "dataset_path = Path(\"dataset\") / \"imaging\" / dataset_name" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Dataset Auto-Simulation__\n", - "\n", - "If the dataset does not already exist on your system, it will be created by running the corresponding simulator\n", - "script. This ensures that all example scripts can be run without manually simulating data first." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "al.util.dataset.simulate_if_needed(\n", - " dataset_path, simulator=\"scripts/simulator/no_lens_light.py\"\n", - ")\n", - "\n", - "dataset = al.Imaging.from_fits(\n", - " data_path=dataset_path / \"data.fits\",\n", - " noise_map_path=dataset_path / \"noise_map.fits\",\n", - " psf_path=dataset_path / \"psf.fits\",\n", - " pixel_scales=0.1,\n", - ")\n", - "\n", - "mask = al.Mask2D.circular(\n", - " shape_native=dataset.shape_native,\n", - " pixel_scales=dataset.pixel_scales,\n", - " radius=3.0,\n", - ")\n", - "\n", - "dataset = dataset.apply_mask(mask=mask)\n", - "\n", - "dataset = dataset.apply_sparse_operator()" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Next, we're going to fit the image using the RectangularBilinearAdaptDensity magnification based grid.\n", - "\n", - "The code below does all the usual steps required to do this." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "lens_galaxy = al.Galaxy(\n", - " redshift=0.5,\n", - " mass=al.mp.Isothermal(\n", - " centre=(0.0, 0.0),\n", - " einstein_radius=1.6,\n", - " ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0),\n", - " ),\n", - ")\n", - "field = al.MassField(\n", - " redshift=0.5, shear=al.mp.ExternalShear(gamma_1=0.05, gamma_2=0.05)\n", - ")\n", - "\n", - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)),\n", - " regularization=al.reg.Constant(coefficient=3.3),\n", - ")\n", - "\n", - "source_galaxy_magnification = al.Galaxy(redshift=1.0, pixelization=pixelization)\n", - "\n", - "tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy_magnification], fields=[field])\n", - "\n", - "magnification_fit = al.FitImaging(dataset=dataset, tracer=tracer)\n", - "fit = magnification_fit" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Inspect the source reconstruction and residuals before using its model image to guide the next mesh." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "\n", - "\n", - "aplt.subplot_fit_imaging(fit=fit)\n", - "aplt.subplot_fit_imaging_of_planes(fit=fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Adapt Image__\n", - "\n", - "We can use this fit to set up our adapt image.\n", - "\n", - "The adapt image is conditional on this first fit. Check its residuals: an inaccurate starting model can guide the\n", - "next mesh to the wrong parts of the image." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "adapt_image = fit.model_data.slim" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Adaption__\n", - "\n", - "Now lets take a look at brightness based adaption in action.\n", - "\n", - "Below, we define a source galaxy using the `Hilbert` image-mesh and a `Delaunay` mesh. We discuss below how Hilbert\n", - "adapts to the source light. Unlike the density-adaptive rectangular mesh, Delaunay uses the supplied Hilbert points\n", - "as its source-mesh vertices after ray tracing." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=10.0)\n", - "\n", - "image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(\n", - " mask=dataset.mask, adapt_data=adapt_image\n", - ")\n", - "\n", - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.Delaunay(pixels=image_plane_mesh_grid.shape[0]),\n", - " regularization=al.reg.Constant(coefficient=1.0),\n", - ")\n", - "\n", - "galaxy_adapt = al.Galaxy(\n", - " redshift=1.0,\n", - " pixelization=pixelization,\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The adapt image is paired to the galaxy that it represents and that it is used to adapt the mesh too.\n", - "\n", - "This uses the `AdaptImages` object, which receives a dictionary mapping every galaxy to its adapt image." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "adapt_images = al.AdaptImages(\n", - " galaxy_image_dict={galaxy_adapt: adapt_image},\n", - " galaxy_image_plane_mesh_grid_dict={galaxy_adapt: image_plane_mesh_grid},\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We now fit using this adapt image and mesh using the normal API.\n", - "\n", - "Note however that the `FitImaging` object receives the `adapt_images` as an input and they are used when setting up\n", - "the image-mesh and mesh." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "tracer = al.Tracer(galaxies=[lens_galaxy, galaxy_adapt], fields=[field])\n", - "\n", - "brightness_fit = al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images)\n", - "fit = brightness_fit\n", - "\n", - "aplt.plot_array(\n", - " array=dataset.data, positions=image_plane_mesh_grid, title=\"Hilbert Image-Mesh Points\",\n", - ")\n", - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "aaplt.plot_mapper(\n", - " mapper=mapper, solution_vector=fit.inversion.reconstruction,\n", - " mesh_grid=mapper.source_plane_mesh_grid, title=\"Brightness-Adaptive Source Mesh\",\n", - ")\n", - "aplt.subplot_fit_imaging(fit=fit)\n", - "aplt.subplot_fit_imaging_of_planes(fit=fit)\n" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Compare the source mesh and residuals with the initial density-adaptive reconstruction. We can now compare measured\n", - "evidence values instead of carrying a number over from another simulation. This first comparison changes both mesh\n", - "family and regularization coefficient; it does not isolate brightness weighting alone." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "print(\"Evidence using magnification based pixelization:\", magnification_fit.log_evidence)\n", - "print(\"Evidence using brightness based pixelization:\", brightness_fit.log_evidence)\n", - "print(\"Difference in log evidence:\", brightness_fit.log_evidence - magnification_fit.log_evidence)\n", - "print(\"Source mesh values:\", mapper.pixels)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Brightness adaptation gives us another way to distribute source resolution. It does not guarantee an improved fit\n", - "or higher evidence for every dataset. Inspect both the calculated comparison and the residuals.\n", - "\n", - "__Hilbert__\n", - "\n", - "So how does the `adapt_image` adapt the pixelization to the source's brightness? It uses a standard algorithm for\n", - "partitioning data in statistics called a Hilbert curve:\n", - "\n", - "https://en.wikipedia.org/wiki/Hilbert_curve\n", - "\n", - "In simple terms, this algorithm works as follows:\n", - "\n", - " 1) Input an image of weight values to the Hilbert algorithm which determines the \"hilbert space filling curve\" \n", - " (e.g. this `weight_map` is determined from the adapt-image). The Hilbert space-filling curve fills more space\n", - " there\n", - " the weight values are highest, and less space where they are lowest. It therefore closely traces the brightest\n", - " regions of the image.\n", - " \n", - " 2) Probabilistically draw N $(y,x)$ points from this Hilbert curve, where the majority of points will therefore be\n", - " drawn from high weighted regions. \n", - " \n", - " 3) These N $(y,x)$ are our source-pixel centres, albeit we have drawn them in the image-plane so we first map them\n", - " to the source-plane, via the mass model, in order to set up the source pixel centres of the mesh. Because points\n", - " are\n", - " drawn from high weighted regions (e.g. the brightest image pixels in the lensed source adapt image), we will trace\n", - " more source-pixels to the brightest regions of where the source is actually reconstructed.\n", - " \n", - "__Weight Map__\n", - "\n", - "We now have a sense of how our `Hilbert` image-mesh is computed, so lets look at how we create the weighted data\n", - "the Hilbert space filling curve uses.\n", - "\n", - "This image, called the `weight_map` is generated using the `weight_floor` and `weight_power` parameters of the\n", - "`Hilbert` object. The weight map is generated following 4 steps:\n", - "\n", - " 1) Take an absolute value of all pixels in the adapt image, because negative values break the Hilbert algorithm.\n", - " \n", - " 2) Divide all values of this image by its maximum value, such that the adapt-image now only contains values\n", - " between\n", - " 0.0 and 1.0 (where the values of 1.0 are the maximum values of the adapt-image).\n", - " \n", - " 3) Raise all values to the power of `weight_power`. A power of 1.0 leaves them unchanged; a power of 0.0\n", - " makes all weights equal to 1.0.\n", - "\n", - " 4) Set any value below `weight_floor` to that floor. This is a minimum weight, not a value added to every pixel.\n", - " \n", - "The idea is that using high values of `weight_power` will make the highest weight values much higher than the\n", - "lowest values, such that the Hilbert curve will trace these values much more than the lower values. The\n", - "weight_floor gives the algorithm some balance, by introducing a floor to the weight map that prevents the lowest\n", - "values from being weighted to near zero.\n", - "\n", - "Lets look at this in action. we'll inspect 3 weight_maps, using a weight_power of 0.0, 5.0 and 10.0 and setting the\n", - "`weight_floor` to 0.0 for now." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=0.0)\n", - "\n", - "weight_map = image_mesh.weight_map_from(adapt_data=adapt_image)\n", - "weight_map = al.Array2D(values=weight_map, mask=mask)\n", - "\n", - "aplt.plot_array(array=weight_map, title=\"Hilbert Weight Power 0: Weight Map\")\n", - "\n", - "\n", - "image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=5.0)\n", - "\n", - "weight_map = image_mesh.weight_map_from(adapt_data=adapt_image)\n", - "weight_map = al.Array2D(values=weight_map, mask=mask)\n", - "\n", - "aplt.plot_array(array=weight_map, title=\"Hilbert Weight Power 5: Weight Map\")\n", - "\n", - "\n", - "image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=10.0)\n", - "\n", - "weight_map = image_mesh.weight_map_from(adapt_data=adapt_image)\n", - "weight_map = al.Array2D(values=weight_map, mask=mask)\n", - "\n", - "aplt.plot_array(array=weight_map, title=\"Hilbert Weight Power 10: Weight Map\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When we increase the weight-power the brightest regions of the adapt-image become weighted higher relative to the\n", - "fainter regions.\n", - "\n", - "This means that the Hilbert algorithm will adapt more pixels to the brightest regions of the source.\n", - "\n", - "Let us compare powers 0, 5 and 10 using the same number of Hilbert points, Delaunay mesh and regularization\n", - "coefficient. We reuse the power-10 reconstruction above. Each new power changes the mesh points, so we must\n", - "reconstruct the source again before comparing evidence." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "fits_by_power = {10.0: brightness_fit}\n", - "\n", - "for weight_power in (0.0, 5.0, 10.0):\n", - " if weight_power not in fits_by_power:\n", - " image_mesh = al.image_mesh.Hilbert(\n", - " pixels=500, weight_floor=0.0, weight_power=weight_power,\n", - " )\n", - " image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(\n", - " mask=dataset.mask, adapt_data=adapt_image,\n", - " )\n", - " source = al.Galaxy(\n", - " redshift=1.0,\n", - " pixelization=al.Pixelization(\n", - " mesh=al.mesh.Delaunay(pixels=image_plane_mesh_grid.shape[0]),\n", - " regularization=al.reg.Constant(coefficient=1.0),\n", - " ),\n", - " )\n", - " adapt_images = al.AdaptImages(\n", - " galaxy_image_dict={source: adapt_image},\n", - " galaxy_image_plane_mesh_grid_dict={source: image_plane_mesh_grid},\n", - " )\n", - " tracer = al.Tracer(galaxies=[lens_galaxy, source], fields=[field])\n", - " fits_by_power[weight_power] = al.FitImaging(\n", - " dataset=dataset, tracer=tracer, adapt_images=adapt_images,\n", - " )\n", - "\n", - " current_fit = fits_by_power[weight_power]\n", - " current_mapper = current_fit.inversion.cls_list_from(al.Mapper)[0]\n", - " aaplt.plot_mapper(\n", - " mapper=current_mapper, solution_vector=current_fit.inversion.reconstruction,\n", - " mesh_grid=current_mapper.source_plane_mesh_grid,\n", - " title=f\"Hilbert Weight Power {weight_power:g}\",\n", - " )\n", - " aplt.subplot_fit_imaging(fit=current_fit)\n", - " print(f\"Weight power {weight_power:g}: log evidence={float(current_fit.log_evidence):.3f}\")\n", - "\n", - "print(\n", - " \"Log evidence difference, power 10 minus power 0:\",\n", - " fits_by_power[10.0].log_evidence - fits_by_power[0.0].log_evidence,\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "So, what does the `weight_floor` do? Increasing the weight-power congregates pixels around the source. However,\n", - "there is a risk that by congregating too many source pixels in its brightest regions we lose resolution further\n", - "out, where the source is bright, but not its brightest!\n", - "\n", - "The `weight_floor` allows these regions to maintain a higher weighting whilst the `weight_power` increases. This\n", - "means that the mesh can retain more coverage away from the brightest regions. Check whether this improves the fit.\n", - "\n", - "Let us compare a floor of 0.5 with the power-10, floor-zero fit, keeping the other settings fixed:" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.5, weight_power=10.0)\n", - "\n", - "image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(\n", - " mask=dataset.mask, adapt_data=adapt_image\n", - ")\n", - "\n", - "\n", - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.Delaunay(pixels=image_plane_mesh_grid.shape[0]),\n", - " regularization=al.reg.Constant(coefficient=1.0),\n", - ")\n", - "\n", - "source_weight_floor = al.Galaxy(\n", - " redshift=1.0, pixelization=pixelization\n", - ")\n", - "\n", - "weight_map = image_mesh.weight_map_from(adapt_data=adapt_image)\n", - "weight_map = al.Array2D(values=weight_map, mask=mask)\n", - "\n", - "aplt.plot_array(array=weight_map, title=\"Hilbert Weight Floor 0.5: Weight Map\")\n", - "\n", - "tracer = al.Tracer(galaxies=[lens_galaxy, source_weight_floor], fields=[field])\n", - "\n", - "adapt_images = al.AdaptImages(\n", - " galaxy_image_dict={source_weight_floor: adapt_image},\n", - " galaxy_image_plane_mesh_grid_dict={source_weight_floor: image_plane_mesh_grid},\n", - ")\n", - "\n", - "fit = al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images)\n", - "\n", - "floor_fit = fit\n", - "floor_mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "aaplt.plot_mapper(\n", - " mapper=floor_mapper, solution_vector=fit.inversion.reconstruction,\n", - " mesh_grid=floor_mapper.source_plane_mesh_grid, title=\"Hilbert Weight Floor 0.5\",\n", - ")\n", - "aplt.subplot_fit_imaging(fit=fit)\n", - "print(\"Evidence with weight floor 0.0:\", fits_by_power[10.0].log_evidence)\n", - "print(\"Evidence with weight floor 0.5:\", floor_fit.log_evidence)\n", - "print(\"Difference in log evidence:\", floor_fit.log_evidence - fits_by_power[10.0].log_evidence)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Wrap Up__\n", - "\n", - "The measured evidence comparisons show how these choices affect this dataset. Revisit tutorial 2 for the\n", - "interpretation of evidence: image agreement and the source prior must be considered together.\n", - "\n", - "Brightness weighting can concentrate mesh points where source light is expected. Excessive concentration can leave\n", - "faint regions poorly resolved, and a more detailed mesh does not establish that every reconstructed feature is\n", - "real. Inspect the mesh, source and residuals together before choosing settings.\n", - "\n", - "Here we held the coefficient fixed while comparing powers and floors. A full analysis would also explore\n", - "regularization, since its preferred strength depends on mesh geometry. Fewer source values can reduce the cost of\n", - "the inversion, but neither faster fits nor higher evidence are guaranteed by brightness adaptation alone.\n", - "\n", - "Tutorial 11 done, next up, adaptive regularization!" - ] - } - ], - "metadata": { - "anaconda-cloud": {}, - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.1" - }, - "colab": { - "aiContexts": { - "97746750-6915-569b-a61d-792a6bc69437": { - "id": "97746750-6915-569b-a61d-792a6bc69437", - "name": "PyAutoLens assistant", - "context": "These instructions were prepared by the PyAutoLens assistant\n(https://github.com/PyAutoLabs/autolens_assistant). The reference material at the\nend was written for a coding agent working inside a checkout of that repository.\nHere in Google Colab there is no checkout: ignore any step about repository files,\nhooks, `scripts/` or `wiki/` paths, saving scripts, or terminal tools, and use the\nPyAutoLens knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoLens notebook (notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb in HowToLens).\nExplain what it does and help them adapt it to their own questions.\n\nCore rules:\n1. Stay with this notebook's API. Only use PyAutoLens calls that appear in the\n notebook or in the reference material below. Never invent class names, functions\n or arguments. If you are unsure whether something exists, say so and suggest\n checking with `dir()` or the PyAutoLens docs (https://pyautolens.readthedocs.io).\n2. Real data needs a check first. Before helping fit real observational data, ask the\n user to plot it and confirm (a) whether extra galaxies, foreground stars or\n artefacts need masking or noise-scaling, and (b) a mask radius that contains all\n of the lensed emission. Never leave the mask radius at its default on real data.\n Simulated data from the workspace is exempt.\n3. Never claim a fit or a cell ran, or what it returned. Only the user's own outputs\n tell you that; ask them to share what they see.\n4. When the answer depends on something you do not know (their data, their goal, what\n a cell printed), ask one focused question rather than guessing.\n5. For multi-step projects (a new lens, a sample of lenses, a paper's analysis), point\n them to the PyAutoLens assistant https://github.com/PyAutoLabs/autolens_assistant,\n which runs in Claude Code or Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: al_adaptive_pixelization\n\n---\nname: al_adaptive_pixelization\ndescription: Build a model whose source reconstruction uses an adaptive pixelisation (mesh density tracks source brightness or surface density) and adaptive regularisation (smoothing strength inferred from data, not fixed). Use when a uniform mesh wastes resolution on dim regions and under-resolves bright ones — common for complex arcs and ALMA data. More expressive than `al_build_imaging_model`'s \"pixelised source\" branch, at the cost of more knobs. Pairs with `al_inspect_source_reconstruction` (post-fit inspection). Writes a runnable Python script in scripts/. **Status: stub.**\n---\n\n# Adaptive pixelisation + adaptive regularisation\n\nA fixed-resolution pixelised source treats every region of the source\nplane the same. An adaptive pixelisation concentrates pixels where the\nflux concentrates, and adaptive regularisation lets the smoothness\nstrength vary spatially — both inferred jointly with the lens parameters.\n\nThe wins are sharper at high signal-to-noise and especially for\ninterferometer data where rectangular meshes waste capacity on empty\nsky.\n\nWorkspace path: `autolens_workspace:scripts/imaging/features/pixelization/adaptive.py`,\n`scripts/imaging/features/pixelization/delaunay.py`.\n\n## Ask\n\n- *\"Mesh type — Delaunay (default adaptive), KMeans-Voronoi, Hilbert\n (memory-efficient for groups), rectangular adaptive?\"*\n- *\"Regularisation — constant (single strength), split (different per\n region), brightness-adaptive (strength scales with local source\n flux)?\"*\n- *\"Adapt-image source — do you have a converged base fit to use as the\n adapt-image, or should we bootstrap from a parametric source fit\n first?\"* — either way the source adapt image is capped at S/N 3.0\n before use (see below); the cap applies regardless of which fit it\n came from.\n\n## Adapt image S/N cap (always)\n\nThe Hilbert image-mesh weights and the adaptive regularization pixel signals are both\nmax-normalised — `(adapt_image / max) ** power` — so the brightest peak of a raw\nsignal-to-noise adapt image sets the scale for everything else. Fainter multiply-imaged\nfeatures fall to a few percent of that weight and lose both source-pixel density and\nregularization weight, so the source adapt image is capped at S/N 3.0 wherever it is\nbuilt. The library applies no cap; it is the script's job at every site.\n\nApply it immediately after the dict is built, before `al.AdaptImages(...)` or a Hilbert\n`image_plane_mesh_grid_from(adapt_data=...)`:\n\n```python\ngalaxy_image_name_dict = al.galaxy_name_image_dict_via_result_from(result=result)\n\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsource_adapt_image[source_adapt_image > adapt_image_snr_cap] = adapt_image_snr_cap\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\nRules:\n\n- **Cap a copy, never the dict entry in place.** `.copy()` keeps the class and mask; an\n in-place cap would silently truncate the raw image every later consumer shares.\n- **Anything that needs the raw image binds it before the cap.** The adaptive\n over-sampling map (`np.where(source_image_raw > 3.0, 4, 2)`, see \"Adaptive pixelization\n over-sampling\" below) reads a\n `source_image_raw = galaxy_image_name_dict[\"('galaxies', 'source')\"]` bound above the cap\n block, because the capped image never exceeds the threshold.\n- **Every stage that builds `AdaptImages` from a source image gets the cap** — `source_lp`,\n `source_pix_*`, `light_lp` and `mass_total` alike; the three lines repeat per stage.\n- Scripts with more than one source cap every source key present\n (`\"('galaxies', 'source_0')\"`, `\"('galaxies', 'source_1')\"`, …).\n\n### Interferometer\n\nInterferometer adapt images are real-space *model* images in flux units\n(`use_model_images=True`): the interferometer dirty noise map is a dirty beam rather than a\nnoise map, so dividing by it does not give a usable S/N image. Derive the clip level from a\nbeam-smoothed S/N instead — transform the model image to a dirty image, divide by the\nhomogeneous image-plane noise, and clip the copy at the flux where that S/N crosses 3.0:\n\n```python\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsigma_pix = np.sqrt(0.5 * np.sum(np.abs(dataset.noise_map.array) ** 2))\ndirty_source = dataset.transformer.image_from(\n visibilities=dataset.transformer.visibilities_from(image=source_adapt_image)\n)\nabove_cap = source_adapt_image.array[\n dirty_source.array / sigma_pix > adapt_image_snr_cap\n]\nif above_cap.size:\n source_adapt_image[source_adapt_image > above_cap.min()] = above_cap.min()\nelse:\n print(\n \"Adapt image S/N cap not applied: no pixel reaches S/N 3.0 in the dirty image.\"\n )\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\n`sigma_pix` is the square root of *half* the summed visibility variances, because the dirty\nimage is the real part of the transform.\n\nWorked sites to copy from:\n\n- `autolens_workspace:scripts/guides/modeling/slam_start_here.py` — the canonical SLaM\n template, capped at every stage.\n- `autolens_workspace:scripts/imaging/features/pixelization/delaunay.py` — the cap feeding\n a Hilbert image mesh.\n- `autolens_workspace:scripts/interferometer/features/pixelization/slam.py` — the\n interferometer form above.\n\n## Adaptive pixelization over-sampling (from `source_pix_2`)\n\nEvery SLaM pipeline — imaging, group, multi-galaxy, multi-dataset — over-samples the\npixelization grid adaptively from `source_pix_2` onwards: sub-size 4 where the source is\ndetected, 2 elsewhere. Build the map from the *raw* (pre-cap) source entry and re-apply the\ndataset before the fit is composed; `source_pix_2` and every later stage receive that\ndataset.\n\n```python\nsignal_to_noise_threshold = 3.0\n\nsource_image_raw = al.galaxy_name_image_dict_via_result_from(result=source_pix_result_1)[\n \"('galaxies', 'source')\"\n]\n\nover_sample_size_pixelization = al.Array2D(\n values=np.where(source_image_raw > signal_to_noise_threshold, 4, 2), mask=dataset.mask\n)\n\ndataset = dataset.apply_over_sampling(\n over_sample_size_pixelization=over_sample_size_pixelization\n)\n```\n\n- **The dict entry is already a signal-to-noise map**, so it is thresholded directly.\n `al.galaxy_name_image_dict_via_result_from` with its default `use_model_images=False`\n returns `subtracted_image / noise_map` (its on-disk cache is literally\n `galaxy_images_snr.fits`); only `use_model_images=True` returns flux-unit model images.\n The function's docstring says \"model-image\" and is misleading.\n- **Never do this:** `al.util.over_sample.over_sample_size_via_adapt_from(data=,\n noise_map=...)`. That helper divides by the noise itself, so feeding it an S/N map divides\n twice (~18x inflation on HST-depth data — ~90 % of the mask at sub-size 4 instead of\n ~30 %), and its `signal_to_noise_cut` defaults to 5.0 and silently drops to `max / 2` when\n the map's maximum is below `2 * cut`. The workspace no longer calls it anywhere.\n- **`source_pix_1` is exempt** — it keeps the library default uniform sub-size (4), because\n its adapt image comes from the parametric SOURCE LP fit and is not yet reliable enough to\n steer over-sampling.\n- **Interferometer SLaM does not do this** — a pixelization fit to visibilities cannot be\n over-sampled.\n\n## Branch — Delaunay mesh + adaptive brightness regularisation\n\n> TODO: recipe. Pattern: `pix = al.Pixelization(mesh=al.mesh.Delaunay(...),\n> regularization=al.reg.AdaptiveBrightnessSplit(...))` ; wrap in a\n> `Galaxy`; analysis needs `adapt_images` set from the bootstrap fit.\n> See `PyAutoArray:autoarray/inversion/...` and\n> `PyAutoLens:autolens/imaging/...`.\n\n## Branch — bootstrap workflow\n\nTwo-stage: (1) fit with a parametric source to produce an adapt image,\n(2) refit with the adaptive pixelisation, using the parametric model\nimage as the adapt source.\n\n> TODO: recipe.\n\n## Combine\n\n- [`al_inspect_source_reconstruction`](./al_inspect_source_reconstruction.md)\n — post-fit inspection of the reconstructed source + regularisation\n state.\n- [`al_chain_searches`](./al_chain_searches.md) — the bootstrap is a\n classic two-search chain.\n- [`al_datacube_modeling`](./al_datacube_modeling.md) — datacube\n pixelisation is similar but spectrally extended.\n\n## Further reading\n\n- **Student / new to lensing** — [HowToLens: chapter_3 tutorial_8 adaptive](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb):\n pedagogical adaptive-pixelisation walkthrough.\n- **General reference** — [RTD: overview_3_features](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n pixelisation feature section.\n- **Experienced PyAutoLens user** — [workspace/lens: imaging/features/pixelization/adaptive.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/adaptive.py):\n the canonical adaptive setup.\n\nSee also [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md)\nfor the underlying inversion theory.\n\n## Skill: al_inspect_source_reconstruction\n\n---\nname: al_inspect_source_reconstruction\ndescription: Inspect a pixelised source reconstruction from a completed fit — source-plane image, mesh / regularisation state, reconstruction error, and the inversion's linear solution. Pixelised sources are common for complex arcs and for ALMA/JVLA visibilities. Use after `al_load_results` has handed you the dataset + tracer; needs to rebuild the `FitImaging` because pixelisation state isn't stored in `tracer.json`.\n---\n\n# Inspecting a pixelised source reconstruction\n\nWhen the source has been reconstructed on a pixel grid (Delaunay / Voronoi /\nrectangular mesh) rather than as a parametric Sersic, the interesting object is the\n**inversion** — the linear solution that mapped source-plane pixels to image-plane\ncounts. Pixelised reconstructions live in `FitImaging.inversion`, *not* in the\nsaved `tracer.json`. To see them you have to rebuild the fit.\n\nFor the theory — what regularisation does, what makes a pixelisation adaptive vs.\nfixed, why the inversion can demagnify pathologically without a positions penalty —\nread [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\nCanonical reference:\n`autolens_workspace:scripts/imaging/features/pixelization/plot.py`.\n\n## Ask\n\n- *\"What do you want to look at — the source image on its mesh, the regularisation\n matrix structure, the reconstruction error, the data-mapping matrix?\"*\n- *\"Was this a parametric or pixelised fit?\"* — if parametric, this skill doesn't\n apply; use [`al_plot_tracer`](./al_plot_tracer.md) for the source-plane image.\n\n## Saving plots\n\n`autolens.plot` in `2026.5.21+` is a flat module of free functions — the old\n`InversionPlotter` / `MatPlot2D` / `Output` classes are gone. Plot the\ninversion's component arrays directly via `aplt.plot_array`:\n\n```python\nfrom pathlib import Path\nimport autolens as al\nimport autolens.plot as aplt\n\nPLOT_DIR = Path(\"scripts/scratch\") / \"\" # pick a meaningful slug\nPLOT_DIR.mkdir(parents=True, exist_ok=True)\n```\n\nAfter running, the agent quotes `PLOT_DIR.resolve()` and offers\n`open ` — see `_style.md` \"Plot output and path announcement\". The\nfunction-style plot API is documented in\n[`wiki/core/api/plotting.md`](../wiki/core/api/plotting.md).\n\n> ⚠️ **Known regression (still open as of `2026.7.6`).** `Delaunay` and\n> `KNNBarycentric` crash inside `FitImaging` (`'NoneType' object has no attribute\n> 'array'`), and `ConstantSplit` is broken on `RectangularUniform`. Use\n> `al.mesh.RectangularUniform` + `al.reg.Constant` for now; tracking:\n> .\n\n## Branch — source-plane reconstruction\n\nRebuild the fit so the inversion is computed, then plot the components:\n\nThe quickest full view is the fit subplot — when the fit uses an inversion its\nfinal panel *is* the source-plane reconstruction:\n\n```python\n# `dataset` from al_prepare_imaging_data, `tracer` from al_load_results.\nfit = al.FitImaging(dataset=dataset, tracer=tracer)\n\naplt.subplot_fit_imaging(\n fit=fit, output_path=str(PLOT_DIR), output_format=\"png\",\n)\n```\n\nFor component-level access, the inversion exposes (names verified against the\n2026.7 stack):\n\n```python\ninv = fit.inversion\n\n# Image-plane image the linear solution reconstructs (Array2D — plottable):\naplt.plot_array(array=inv.mapped_reconstructed_data,\n output_path=str(PLOT_DIR),\n output_filename=\"mapped_reconstruction\",\n output_format=\"png\")\n\n# Source-plane solution: a 1D vector of per-source-pixel intensities\n# (keyed per mapper in inv.reconstruction_dict). For a RectangularUniform\n# mesh, reshape to the mesh's native 2D shape to plot it.\nreconstruction = inv.reconstruction\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nSource: `PyAutoArray:autoarray/inversion/inversion/`.\n\n## Branch — mesh + regularisation diagnostics\n\nThe previous object-oriented inversion plotter API is removed.\nThe closest current entry point is `aplt.subplot_basis_image`, which renders\nthe mapper's pixelisation overlaid on the image:\n\n```python\naplt.subplot_basis_image(\n inversion=fit.inversion,\n output_path=str(PLOT_DIR), output_format=\"png\",\n)\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nThe mesh-triangulation and regularisation-neighbour graphs no longer have\ndirect plot helpers; build them with NumPy and `aplt.plot_grid` if needed.\n\n## Branch — double source-plane lens (DSPL) / multi-plane pixelisations\n\nFor systems with two source planes (each pixelised independently), the\n`pixelization_index` argument indexes into the planes. See\n`autolens_workspace:scripts/imaging/features/advanced/double_source_plane_lens/plot.py`.\n\n## Quality checks\n\nA healthy pixelised reconstruction:\n\n- Source-plane reconstruction shows recognisable structure (clumps, arms), not white\n noise.\n- Reconstruction error / signal-to-noise is positive across the source.\n- Regularisation weights smoothly decrease toward the edges (for adaptive variants).\n- The reconstructed image-plane model matches the data with featureless residuals\n (see [`al_plot_fit_residuals`](./al_plot_fit_residuals.md)).\n\nPathological signs:\n\n- Source-plane is a single bright pixel — the inversion has demagnified. Add image\n positions and refit.\n- Reconstruction looks like the noise map — regularisation is too low. Raise the\n regularisation coefficient range.\n- Source is unphysically extended beyond the caustic — mass model is wrong; the\n positions penalty isn't holding it in.\n\nConnect to [`al_debug_fit_failure`](./al_debug_fit_failure.md) when any of these\nappear.\n\n## Combine\n\n- [`al_plot_tracer`](./al_plot_tracer.md) — overlay caustics on the source-plane\n reconstruction to confirm signal is inside the caustic.\n- [`al_chain_searches`](./al_chain_searches.md) — the canonical chain for moving from\n a parametric source fit to a pixelised one.\n- [`al_run_slam_pipeline`](./al_run_slam_pipeline.md) — automated pipeline that\n handles the parametric → pixelised transition.\n\n## Further reading\n\n- **Student / new to lensing** — [HowToLens: Reconstructing source light via\n least-squares](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_inversions.ipynb):\n the inversion at its heart — least-squares solution for source-plane pixel\n intensities. Chapter 3 as a whole covers mappers, regularization, adaptive grids.\n- **General reference** — [RTD: Features overview](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n the pixelization feature sits here alongside MGE / interferometry / shapelets —\n links into deeper docs.\n- **Experienced PyAutoLens user** — [workspace/lens: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n rectangular-mesh + constant-regularization pixelization model — production\n pattern for extended-arc sources.\n" - } - }, - "selectedAiContext": "97746750-6915-569b-a61d-792a6bc69437" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file diff --git a/notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb b/notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb deleted file mode 100644 index dc6cf1f..0000000 --- a/notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb +++ /dev/null @@ -1,475 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Tutorial 12: Adaptive Regularization\n", - "====================================\n", - "\n", - "Tutorial 2 introduced `Constant` regularization. Different regions of the source can favour different levels of\n", - "regularization, motivating a scheme which adapts to the reconstructed source's surface brightness.\n", - "\n", - "This raises the same question as before, how do we adapt our regularization scheme to the source before we've\n", - "reconstructed it? Just like in the last tutorial, we'll use a model image of a strongly lensed source from a\n", - "previous model that we've begun calling the `adapt-image`.\n", - "\n", - "__Contents__\n", - "\n", - "- **Initial Setup:** we'll use the same strong lensing data as the previous tutorial, where.\n", - "- **Convenience Function:** We are going to fit the image using a magnification based grid.\n", - "- **Adaptive Regularization:** Lets now look at adaptive regularization in action, by setting up a adapt-image and\n", - " using the.\n", - "- **Wrap Up:** Summary of the script and next steps." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Google Colab Setup__\n", - "\n", - "Set up Google Colab as in [the introductory notebook](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_1_introduction/tutorial_1_grids_and_galaxies.ipynb).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "cellView": "form", - "jupyter": { - "source_hidden": true - } - }, - "outputs": [], - "source": [ - "# @title Set up Google Colab { display-mode: \"form\" }\n", - "try:\n", - " import google.colab\n", - "except ImportError:\n", - " from autolens import setup_colab as _setup_colab\n", - "else:\n", - " import importlib\n", - " import subprocess\n", - " import sys\n", - "\n", - " subprocess.check_call(\n", - " [sys.executable, \"-m\", \"pip\", \"install\", \"autonerves\", \"--no-deps\"]\n", - " )\n", - " _setup_colab = importlib.import_module(\"autonerves.setup_colab\")\n", - "\n", - "_setup_colab.setup(\"howtolens\")" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "\n", - "from autolens import jax_wrapper # Sets JAX environment before other imports\n", - "\n", - "from autolens import setup_notebook; setup_notebook()\n", - "\n", - "from pathlib import Path\n", - "import autolens as al\n", - "import autolens.plot as aplt\n", - "import autoarray.plot as aaplt" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Initial Setup__\n", - "\n", - "Tutorial 8's compactness experiment motivates the need for resolution and smoothing checks. As in tutorial 11, this\n", - "standalone example uses `simple__no_lens_light`, rather than the progressively compact simulations, where:\n", - "\n", - " - The lens galaxy's light is omitted.\n", - " - The lens system uses an `Isothermal` galaxy mass profile and a separate `ExternalShear` field.\n", - " - The source galaxy's light is an `Sersic`." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "dataset_name = \"simple__no_lens_light\"\n", - "dataset_path = Path(\"dataset\") / \"imaging\" / dataset_name" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Dataset Auto-Simulation__\n", - "\n", - "If the dataset does not already exist on your system, it will be created by running the corresponding simulator\n", - "script. This ensures that all example scripts can be run without manually simulating data first." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "al.util.dataset.simulate_if_needed(\n", - " dataset_path, simulator=\"scripts/simulator/no_lens_light.py\"\n", - ")\n", - "\n", - "dataset = al.Imaging.from_fits(\n", - " data_path=dataset_path / \"data.fits\",\n", - " noise_map_path=dataset_path / \"noise_map.fits\",\n", - " psf_path=dataset_path / \"psf.fits\",\n", - " pixel_scales=0.1,\n", - ")\n", - "\n", - "mask = al.Mask2D.circular(\n", - " shape_native=dataset.shape_native,\n", - " pixel_scales=dataset.pixel_scales,\n", - " radius=3.0,\n", - ")\n", - "\n", - "dataset = dataset.apply_mask(mask=mask)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Convenience Function__\n", - "\n", - "We are going to fit the image using a magnification based grid.\n", - "\n", - "To perform the fits, we'll use a convenience function to fit the lens data we simulated above." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "\n", - "\n", - "def fit_via_source_galaxy_from(dataset, source_galaxy, adapt_images=None):\n", - " lens_galaxy = al.Galaxy(\n", - " redshift=0.5,\n", - " mass=al.mp.Isothermal(\n", - " centre=(0.0, 0.0),\n", - " einstein_radius=1.6,\n", - " ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0),\n", - " ),\n", - " )\n", - " field = al.MassField(\n", - " redshift=0.5, shear=al.mp.ExternalShear(gamma_1=0.05, gamma_2=0.05)\n", - " )\n", - "\n", - " tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy], fields=[field])\n", - "\n", - " return al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images)\n" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Use the magnification based source to fit this data." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)),\n", - " regularization=al.reg.Constant(coefficient=3.3),\n", - ")\n", - "\n", - "source_magnification = al.Galaxy(redshift=1.0, pixelization=pixelization)\n", - "\n", - "constant_fit = fit_via_source_galaxy_from(dataset=dataset, source_galaxy=source_magnification)\n", - "fit = constant_fit\n", - "\n", - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "\n", - "\n", - "aplt.subplot_fit_imaging(fit=fit)\n", - "aplt.plot_array(array=fit.model_data, title=\"Plane 1 Image\")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Inspect the residuals before changing the prior. We can display the actual regularization weights on the source\n", - "mesh: Constant gives every source value the input coefficient of 3.3. These are weights, rather than source\n", - "brightnesses or an image-plane reconstruction." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "constant_weights = mapper.regularization.regularization_weights_from(linear_obj=mapper)\n", - "aaplt.plot_mapper(\n", - " mapper=mapper, solution_vector=constant_weights,\n", - " mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False,\n", - " vmin=0.0, vmax=4.0, title=\"Constant Regularization Weights\",\n", - ")\n", - "print(\"Constant weight range:\", float(min(constant_weights)), float(max(constant_weights)))" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Adaptive Regularization__\n", - "\n", - "Lets now look at adaptive regularization in action, by setting up a adapt-image and using the `Adapt`\n", - "regularization scheme.\n", - "\n", - "This introduces additional parameters, that are explained below." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "adapt_image = fit.model_data.slim\n", - "\n", - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)),\n", - " regularization=al.reg.Adapt(\n", - " inner_coefficient=0.005, outer_coefficient=1.9, signal_scale=3.0\n", - " ),\n", - ")\n", - "\n", - "source_adapt_regularization = al.Galaxy(\n", - " redshift=1.0,\n", - " pixelization=pixelization,\n", - ")\n", - "\n", - "adapt_images = al.AdaptImages(\n", - " galaxy_image_dict={source_adapt_regularization: adapt_image}\n", - ")\n", - "\n", - "fit = fit_via_source_galaxy_from(\n", - " dataset=dataset,\n", - " source_galaxy=source_adapt_regularization,\n", - " adapt_images=adapt_images,\n", - ")\n", - "\n", - "adaptive_fit = fit\n", - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "adaptive_weights = mapper.regularization.regularization_weights_from(linear_obj=mapper)\n", - "aaplt.plot_mapper(\n", - " mapper=mapper, solution_vector=adaptive_weights,\n", - " mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False,\n", - " vmin=0.0, vmax=4.0, title=\"Adaptive Regularization Weights\",\n", - ")\n", - "aaplt.plot_mapper(\n", - " mapper=mapper, solution_vector=fit.inversion.reconstruction,\n", - " mesh_grid=mapper.source_plane_mesh_grid, title=\"Adaptively Regularized Source\",\n", - ")\n", - "print(\"Adaptive weight range:\", float(min(adaptive_weights)), float(max(adaptive_weights)))" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The weights now vary across the source mesh. Compare their locations with the reconstructed source, using the same\n", - "weight colour scale as the Constant example. These weights enter the regularization matrix; they are not\n", - "uncertainties on the reconstructed brightnesses.\n", - "\n", - "Let us compare the evidence measured from these two fits:" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "print(\"Evidence using constant regularization:\", constant_fit.log_evidence)\n", - "print(\"Evidence using adaptive regularization:\", adaptive_fit.log_evidence)\n", - "print(\"Difference in log evidence:\", adaptive_fit.log_evidence - constant_fit.log_evidence)\n", - "\n", - "aplt.subplot_fit_imaging(fit=fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "An evidence increase is something to measure, rather than assume. The mesh is unchanged here, so this comparison\n", - "examines the regularization choice. These hand-chosen settings are not optimized, and varying the smoothing cannot\n", - "supply resolution that the source mesh lacks. Inspect the source and residuals alongside the evidence.\n", - "\n", - "__How does adaptive regularization work?__\n", - "\n", - "For every source-pixel, we have a mapping between that pixel and a set of pixels in the adapt-image. Therefore, for\n", - "every source-pixel, if we sum the values of all adapt-image pixels that map to it we get an estimate of how much of\n", - "the lensed source's signal we expect will be reconstructed. We call this each pixel's `pixel signal`.\n", - "\n", - "If a source-pixel has a higher pixel-signal, we anticipate that it`ll reconstruct more flux and we use this\n", - "information to regularize it less. Conversely, if the pixel-signal is close to zero, the source pixel will\n", - "reconstruct near-zero flux and regularization will smooth over these pixels by using a high regularization\n", - "coefficient.\n", - "\n", - "This works as follows:\n", - "\n", - " 1) For every source pixel, compute its pixel-signal, the summed flux of all corresponding image-pixels in the \n", - " adapt-galaxy-image.\n", - " \n", - " 2) Divide every pixel-signal by the number of image-pixels that map directly to that source-pixel. In doing so,\n", - " all\n", - " pixel-signals are 'relative'. This means that source-pixels which by chance map to more image-pixels than their \n", - " neighbors will not have a higher pixel-signal, and visa versa. This ensures the specific pixelization\n", - " does not by itself determine the adaptive regularization pattern.\n", - " \n", - " 3) Divide the pixel-signals by the maximum pixel signal so that they range between 0.0 and 1.0.\n", - " \n", - " 4) Raise these values to the power of the adapt-parameter `signal_scale`. For a `signal_scale` of 0.0, all \n", - " pixels will therefore have the same final pixel-scale. As the `signal_scale` increases, a sharper transition of \n", - " pixel-signal values arises between regions with high and low pixel-signals.\n", - " \n", - " 5) Compute every source pixel's effective regularization coefficient as:\n", - " \n", - " (inner_coefficient * pixel_signals + outer_coefficient * (1.0 - pixel_signals)) ** 2.0\n", - " \n", - " This uses two regularization coefficient parameters, one which is applied to pixels with high pixel-signals and\n", - " one to\n", - " pixels with low pixel-signals. Thus, pixels in the inner regions of the source may be given a lower level of \n", - " regularization than pixels further away, as desired.\n", - "\n", - "Thus, we now adapt our regularization scheme to the source's surface brightness. Where its brighter (and therefore\n", - "has a steeper flux gradient) we apply a lower level of regularization than further out. Furthermore, in the edges\n", - "of the source-plane where no source-flux is present we will assume a high regularization coefficient that smooths\n", - "over all of the source-pixels.\n", - "\n", - "Try looking at a couple of extra solutions which use with different inner and outer regularization coefficients or\n", - "signal scales. I doubt you'll notice a lot change visually, but the log evidence certainly has a lot of room for\n", - "manuveur with different values.\n", - "\n", - "You may find solutions that raise an `InversionException`. These solutions mean that the matrix used during the\n", - "linear algebra calculation was ill-defined, and could not be inverted. These solutions are removed during lens\n", - "modeling." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)),\n", - " regularization=al.reg.Adapt(\n", - " inner_coefficient=0.001, outer_coefficient=0.2, signal_scale=2.0\n", - " ),\n", - ")\n", - "\n", - "source_adapt_regularization = al.Galaxy(\n", - " redshift=1.0,\n", - " pixelization=pixelization,\n", - ")\n", - "\n", - "adapt_images = al.AdaptImages(\n", - " galaxy_image_dict={source_adapt_regularization: adapt_image}\n", - ")\n", - "\n", - "fit = fit_via_source_galaxy_from(\n", - " dataset=dataset,\n", - " source_galaxy=source_adapt_regularization,\n", - " adapt_images=adapt_images,\n", - ")\n", - "\n", - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "\n", - "\n", - "aplt.subplot_fit_imaging(fit=fit)\n", - "\n", - "aplt.plot_array(\n", - " array=fit.inversion.mapped_reconstructed_operated_data,\n", - " title=\"Inversion Reconstruction\",\n", - ")\n", - "\n", - "alternative_weights = mapper.regularization.regularization_weights_from(linear_obj=mapper)\n", - "aaplt.plot_mapper(\n", - " mapper=mapper, solution_vector=alternative_weights,\n", - " mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False,\n", - " vmin=0.0, vmax=4.0, title=\"Alternative Adaptive Regularization Weights\",\n", - ")\n", - "print(\"Evidence using alternative adaptive regularization:\", fit.log_evidence)\n", - "print(\"Difference from constant regularization:\", fit.log_evidence - constant_fit.log_evidence)\n", - "print(\"Difference from first adaptive example:\", fit.log_evidence - adaptive_fit.log_evidence)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Wrap Up__\n", - "\n", - "Adaptive regularization lets bright and faint parts of the source receive different smoothing weights. Lower\n", - "weights can preserve sharp structure where the data support it; higher weights can correlate values in faint\n", - "regions that would otherwise fit noise. The adapt image guides that distinction and must itself be checked.\n", - "\n", - "The evidence comparisons quantify the outcomes for our chosen settings. They do not guarantee an increase for every\n", - "dataset, a unique maximum, or recovery of arbitrary source detail. Mesh resolution, the adapt image and the lens\n", - "model still limit the reconstruction. In a scientific analysis, explore the regularization parameters and inspect\n", - "the source and residuals together.\n", - "\n", - "That completes this chapter on pixelized source reconstruction. You can now investigate flexible source\n", - "reconstructions while checking the assumptions that shape them. In chapter 4, `Scaling Up Lensing`, we take these\n", - "tools to bigger systems: lenses with extra galaxies along the line of sight, multi-galaxy lenses, scaling\n", - "relations, group and cluster scale lenses and weak lensing." - ] - } - ], - "metadata": { - "anaconda-cloud": {}, - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.1" - }, - "colab": { - "aiContexts": { - "97746750-6915-569b-a61d-792a6bc69437": { - "id": "97746750-6915-569b-a61d-792a6bc69437", - "name": "PyAutoLens assistant", - "context": "These instructions were prepared by the PyAutoLens assistant\n(https://github.com/PyAutoLabs/autolens_assistant). The reference material at the\nend was written for a coding agent working inside a checkout of that repository.\nHere in Google Colab there is no checkout: ignore any step about repository files,\nhooks, `scripts/` or `wiki/` paths, saving scripts, or terminal tools, and use the\nPyAutoLens knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoLens notebook (notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb in HowToLens).\nExplain what it does and help them adapt it to their own questions.\n\nCore rules:\n1. Stay with this notebook's API. Only use PyAutoLens calls that appear in the\n notebook or in the reference material below. Never invent class names, functions\n or arguments. If you are unsure whether something exists, say so and suggest\n checking with `dir()` or the PyAutoLens docs (https://pyautolens.readthedocs.io).\n2. Real data needs a check first. Before helping fit real observational data, ask the\n user to plot it and confirm (a) whether extra galaxies, foreground stars or\n artefacts need masking or noise-scaling, and (b) a mask radius that contains all\n of the lensed emission. Never leave the mask radius at its default on real data.\n Simulated data from the workspace is exempt.\n3. Never claim a fit or a cell ran, or what it returned. Only the user's own outputs\n tell you that; ask them to share what they see.\n4. When the answer depends on something you do not know (their data, their goal, what\n a cell printed), ask one focused question rather than guessing.\n5. For multi-step projects (a new lens, a sample of lenses, a paper's analysis), point\n them to the PyAutoLens assistant https://github.com/PyAutoLabs/autolens_assistant,\n which runs in Claude Code or Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: al_adaptive_pixelization\n\n---\nname: al_adaptive_pixelization\ndescription: Build a model whose source reconstruction uses an adaptive pixelisation (mesh density tracks source brightness or surface density) and adaptive regularisation (smoothing strength inferred from data, not fixed). Use when a uniform mesh wastes resolution on dim regions and under-resolves bright ones — common for complex arcs and ALMA data. More expressive than `al_build_imaging_model`'s \"pixelised source\" branch, at the cost of more knobs. Pairs with `al_inspect_source_reconstruction` (post-fit inspection). Writes a runnable Python script in scripts/. **Status: stub.**\n---\n\n# Adaptive pixelisation + adaptive regularisation\n\nA fixed-resolution pixelised source treats every region of the source\nplane the same. An adaptive pixelisation concentrates pixels where the\nflux concentrates, and adaptive regularisation lets the smoothness\nstrength vary spatially — both inferred jointly with the lens parameters.\n\nThe wins are sharper at high signal-to-noise and especially for\ninterferometer data where rectangular meshes waste capacity on empty\nsky.\n\nWorkspace path: `autolens_workspace:scripts/imaging/features/pixelization/adaptive.py`,\n`scripts/imaging/features/pixelization/delaunay.py`.\n\n## Ask\n\n- *\"Mesh type — Delaunay (default adaptive), KMeans-Voronoi, Hilbert\n (memory-efficient for groups), rectangular adaptive?\"*\n- *\"Regularisation — constant (single strength), split (different per\n region), brightness-adaptive (strength scales with local source\n flux)?\"*\n- *\"Adapt-image source — do you have a converged base fit to use as the\n adapt-image, or should we bootstrap from a parametric source fit\n first?\"* — either way the source adapt image is capped at S/N 3.0\n before use (see below); the cap applies regardless of which fit it\n came from.\n\n## Adapt image S/N cap (always)\n\nThe Hilbert image-mesh weights and the adaptive regularization pixel signals are both\nmax-normalised — `(adapt_image / max) ** power` — so the brightest peak of a raw\nsignal-to-noise adapt image sets the scale for everything else. Fainter multiply-imaged\nfeatures fall to a few percent of that weight and lose both source-pixel density and\nregularization weight, so the source adapt image is capped at S/N 3.0 wherever it is\nbuilt. The library applies no cap; it is the script's job at every site.\n\nApply it immediately after the dict is built, before `al.AdaptImages(...)` or a Hilbert\n`image_plane_mesh_grid_from(adapt_data=...)`:\n\n```python\ngalaxy_image_name_dict = al.galaxy_name_image_dict_via_result_from(result=result)\n\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsource_adapt_image[source_adapt_image > adapt_image_snr_cap] = adapt_image_snr_cap\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\nRules:\n\n- **Cap a copy, never the dict entry in place.** `.copy()` keeps the class and mask; an\n in-place cap would silently truncate the raw image every later consumer shares.\n- **Anything that needs the raw image binds it before the cap.** The adaptive\n over-sampling map (`np.where(source_image_raw > 3.0, 4, 2)`, see \"Adaptive pixelization\n over-sampling\" below) reads a\n `source_image_raw = galaxy_image_name_dict[\"('galaxies', 'source')\"]` bound above the cap\n block, because the capped image never exceeds the threshold.\n- **Every stage that builds `AdaptImages` from a source image gets the cap** — `source_lp`,\n `source_pix_*`, `light_lp` and `mass_total` alike; the three lines repeat per stage.\n- Scripts with more than one source cap every source key present\n (`\"('galaxies', 'source_0')\"`, `\"('galaxies', 'source_1')\"`, …).\n\n### Interferometer\n\nInterferometer adapt images are real-space *model* images in flux units\n(`use_model_images=True`): the interferometer dirty noise map is a dirty beam rather than a\nnoise map, so dividing by it does not give a usable S/N image. Derive the clip level from a\nbeam-smoothed S/N instead — transform the model image to a dirty image, divide by the\nhomogeneous image-plane noise, and clip the copy at the flux where that S/N crosses 3.0:\n\n```python\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsigma_pix = np.sqrt(0.5 * np.sum(np.abs(dataset.noise_map.array) ** 2))\ndirty_source = dataset.transformer.image_from(\n visibilities=dataset.transformer.visibilities_from(image=source_adapt_image)\n)\nabove_cap = source_adapt_image.array[\n dirty_source.array / sigma_pix > adapt_image_snr_cap\n]\nif above_cap.size:\n source_adapt_image[source_adapt_image > above_cap.min()] = above_cap.min()\nelse:\n print(\n \"Adapt image S/N cap not applied: no pixel reaches S/N 3.0 in the dirty image.\"\n )\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\n`sigma_pix` is the square root of *half* the summed visibility variances, because the dirty\nimage is the real part of the transform.\n\nWorked sites to copy from:\n\n- `autolens_workspace:scripts/guides/modeling/slam_start_here.py` — the canonical SLaM\n template, capped at every stage.\n- `autolens_workspace:scripts/imaging/features/pixelization/delaunay.py` — the cap feeding\n a Hilbert image mesh.\n- `autolens_workspace:scripts/interferometer/features/pixelization/slam.py` — the\n interferometer form above.\n\n## Adaptive pixelization over-sampling (from `source_pix_2`)\n\nEvery SLaM pipeline — imaging, group, multi-galaxy, multi-dataset — over-samples the\npixelization grid adaptively from `source_pix_2` onwards: sub-size 4 where the source is\ndetected, 2 elsewhere. Build the map from the *raw* (pre-cap) source entry and re-apply the\ndataset before the fit is composed; `source_pix_2` and every later stage receive that\ndataset.\n\n```python\nsignal_to_noise_threshold = 3.0\n\nsource_image_raw = al.galaxy_name_image_dict_via_result_from(result=source_pix_result_1)[\n \"('galaxies', 'source')\"\n]\n\nover_sample_size_pixelization = al.Array2D(\n values=np.where(source_image_raw > signal_to_noise_threshold, 4, 2), mask=dataset.mask\n)\n\ndataset = dataset.apply_over_sampling(\n over_sample_size_pixelization=over_sample_size_pixelization\n)\n```\n\n- **The dict entry is already a signal-to-noise map**, so it is thresholded directly.\n `al.galaxy_name_image_dict_via_result_from` with its default `use_model_images=False`\n returns `subtracted_image / noise_map` (its on-disk cache is literally\n `galaxy_images_snr.fits`); only `use_model_images=True` returns flux-unit model images.\n The function's docstring says \"model-image\" and is misleading.\n- **Never do this:** `al.util.over_sample.over_sample_size_via_adapt_from(data=,\n noise_map=...)`. That helper divides by the noise itself, so feeding it an S/N map divides\n twice (~18x inflation on HST-depth data — ~90 % of the mask at sub-size 4 instead of\n ~30 %), and its `signal_to_noise_cut` defaults to 5.0 and silently drops to `max / 2` when\n the map's maximum is below `2 * cut`. The workspace no longer calls it anywhere.\n- **`source_pix_1` is exempt** — it keeps the library default uniform sub-size (4), because\n its adapt image comes from the parametric SOURCE LP fit and is not yet reliable enough to\n steer over-sampling.\n- **Interferometer SLaM does not do this** — a pixelization fit to visibilities cannot be\n over-sampled.\n\n## Branch — Delaunay mesh + adaptive brightness regularisation\n\n> TODO: recipe. Pattern: `pix = al.Pixelization(mesh=al.mesh.Delaunay(...),\n> regularization=al.reg.AdaptiveBrightnessSplit(...))` ; wrap in a\n> `Galaxy`; analysis needs `adapt_images` set from the bootstrap fit.\n> See `PyAutoArray:autoarray/inversion/...` and\n> `PyAutoLens:autolens/imaging/...`.\n\n## Branch — bootstrap workflow\n\nTwo-stage: (1) fit with a parametric source to produce an adapt image,\n(2) refit with the adaptive pixelisation, using the parametric model\nimage as the adapt source.\n\n> TODO: recipe.\n\n## Combine\n\n- [`al_inspect_source_reconstruction`](./al_inspect_source_reconstruction.md)\n — post-fit inspection of the reconstructed source + regularisation\n state.\n- [`al_chain_searches`](./al_chain_searches.md) — the bootstrap is a\n classic two-search chain.\n- [`al_datacube_modeling`](./al_datacube_modeling.md) — datacube\n pixelisation is similar but spectrally extended.\n\n## Further reading\n\n- **Student / new to lensing** — [HowToLens: chapter_3 tutorial_8 adaptive](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb):\n pedagogical adaptive-pixelisation walkthrough.\n- **General reference** — [RTD: overview_3_features](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n pixelisation feature section.\n- **Experienced PyAutoLens user** — [workspace/lens: imaging/features/pixelization/adaptive.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/adaptive.py):\n the canonical adaptive setup.\n\nSee also [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md)\nfor the underlying inversion theory.\n\n## Skill: al_inspect_source_reconstruction\n\n---\nname: al_inspect_source_reconstruction\ndescription: Inspect a pixelised source reconstruction from a completed fit — source-plane image, mesh / regularisation state, reconstruction error, and the inversion's linear solution. Pixelised sources are common for complex arcs and for ALMA/JVLA visibilities. Use after `al_load_results` has handed you the dataset + tracer; needs to rebuild the `FitImaging` because pixelisation state isn't stored in `tracer.json`.\n---\n\n# Inspecting a pixelised source reconstruction\n\nWhen the source has been reconstructed on a pixel grid (Delaunay / Voronoi /\nrectangular mesh) rather than as a parametric Sersic, the interesting object is the\n**inversion** — the linear solution that mapped source-plane pixels to image-plane\ncounts. Pixelised reconstructions live in `FitImaging.inversion`, *not* in the\nsaved `tracer.json`. To see them you have to rebuild the fit.\n\nFor the theory — what regularisation does, what makes a pixelisation adaptive vs.\nfixed, why the inversion can demagnify pathologically without a positions penalty —\nread [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\nCanonical reference:\n`autolens_workspace:scripts/imaging/features/pixelization/plot.py`.\n\n## Ask\n\n- *\"What do you want to look at — the source image on its mesh, the regularisation\n matrix structure, the reconstruction error, the data-mapping matrix?\"*\n- *\"Was this a parametric or pixelised fit?\"* — if parametric, this skill doesn't\n apply; use [`al_plot_tracer`](./al_plot_tracer.md) for the source-plane image.\n\n## Saving plots\n\n`autolens.plot` in `2026.5.21+` is a flat module of free functions — the old\n`InversionPlotter` / `MatPlot2D` / `Output` classes are gone. Plot the\ninversion's component arrays directly via `aplt.plot_array`:\n\n```python\nfrom pathlib import Path\nimport autolens as al\nimport autolens.plot as aplt\n\nPLOT_DIR = Path(\"scripts/scratch\") / \"\" # pick a meaningful slug\nPLOT_DIR.mkdir(parents=True, exist_ok=True)\n```\n\nAfter running, the agent quotes `PLOT_DIR.resolve()` and offers\n`open ` — see `_style.md` \"Plot output and path announcement\". The\nfunction-style plot API is documented in\n[`wiki/core/api/plotting.md`](../wiki/core/api/plotting.md).\n\n> ⚠️ **Known regression (still open as of `2026.7.6`).** `Delaunay` and\n> `KNNBarycentric` crash inside `FitImaging` (`'NoneType' object has no attribute\n> 'array'`), and `ConstantSplit` is broken on `RectangularUniform`. Use\n> `al.mesh.RectangularUniform` + `al.reg.Constant` for now; tracking:\n> .\n\n## Branch — source-plane reconstruction\n\nRebuild the fit so the inversion is computed, then plot the components:\n\nThe quickest full view is the fit subplot — when the fit uses an inversion its\nfinal panel *is* the source-plane reconstruction:\n\n```python\n# `dataset` from al_prepare_imaging_data, `tracer` from al_load_results.\nfit = al.FitImaging(dataset=dataset, tracer=tracer)\n\naplt.subplot_fit_imaging(\n fit=fit, output_path=str(PLOT_DIR), output_format=\"png\",\n)\n```\n\nFor component-level access, the inversion exposes (names verified against the\n2026.7 stack):\n\n```python\ninv = fit.inversion\n\n# Image-plane image the linear solution reconstructs (Array2D — plottable):\naplt.plot_array(array=inv.mapped_reconstructed_data,\n output_path=str(PLOT_DIR),\n output_filename=\"mapped_reconstruction\",\n output_format=\"png\")\n\n# Source-plane solution: a 1D vector of per-source-pixel intensities\n# (keyed per mapper in inv.reconstruction_dict). For a RectangularUniform\n# mesh, reshape to the mesh's native 2D shape to plot it.\nreconstruction = inv.reconstruction\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nSource: `PyAutoArray:autoarray/inversion/inversion/`.\n\n## Branch — mesh + regularisation diagnostics\n\nThe previous object-oriented inversion plotter API is removed.\nThe closest current entry point is `aplt.subplot_basis_image`, which renders\nthe mapper's pixelisation overlaid on the image:\n\n```python\naplt.subplot_basis_image(\n inversion=fit.inversion,\n output_path=str(PLOT_DIR), output_format=\"png\",\n)\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nThe mesh-triangulation and regularisation-neighbour graphs no longer have\ndirect plot helpers; build them with NumPy and `aplt.plot_grid` if needed.\n\n## Branch — double source-plane lens (DSPL) / multi-plane pixelisations\n\nFor systems with two source planes (each pixelised independently), the\n`pixelization_index` argument indexes into the planes. See\n`autolens_workspace:scripts/imaging/features/advanced/double_source_plane_lens/plot.py`.\n\n## Quality checks\n\nA healthy pixelised reconstruction:\n\n- Source-plane reconstruction shows recognisable structure (clumps, arms), not white\n noise.\n- Reconstruction error / signal-to-noise is positive across the source.\n- Regularisation weights smoothly decrease toward the edges (for adaptive variants).\n- The reconstructed image-plane model matches the data with featureless residuals\n (see [`al_plot_fit_residuals`](./al_plot_fit_residuals.md)).\n\nPathological signs:\n\n- Source-plane is a single bright pixel — the inversion has demagnified. Add image\n positions and refit.\n- Reconstruction looks like the noise map — regularisation is too low. Raise the\n regularisation coefficient range.\n- Source is unphysically extended beyond the caustic — mass model is wrong; the\n positions penalty isn't holding it in.\n\nConnect to [`al_debug_fit_failure`](./al_debug_fit_failure.md) when any of these\nappear.\n\n## Combine\n\n- [`al_plot_tracer`](./al_plot_tracer.md) — overlay caustics on the source-plane\n reconstruction to confirm signal is inside the caustic.\n- [`al_chain_searches`](./al_chain_searches.md) — the canonical chain for moving from\n a parametric source fit to a pixelised one.\n- [`al_run_slam_pipeline`](./al_run_slam_pipeline.md) — automated pipeline that\n handles the parametric → pixelised transition.\n\n## Further reading\n\n- **Student / new to lensing** — [HowToLens: Reconstructing source light via\n least-squares](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_inversions.ipynb):\n the inversion at its heart — least-squares solution for source-plane pixel\n intensities. Chapter 3 as a whole covers mappers, regularization, adaptive grids.\n- **General reference** — [RTD: Features overview](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n the pixelization feature sits here alongside MGE / interferometry / shapelets —\n links into deeper docs.\n- **Experienced PyAutoLens user** — [workspace/lens: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n rectangular-mesh + constant-regularization pixelization model — production\n pattern for extended-arc sources.\n" - } - }, - "selectedAiContext": "97746750-6915-569b-a61d-792a6bc69437" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file diff --git a/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb b/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb index 83f54b6..ace6a2a 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb @@ -7,6 +7,8 @@ "Tutorial 1: Pixelizations, Mappers and Inversions\n", "=================================================\n", "\n", + "Reconstruct a complex source using pixelizations, image-to-source mappings and a linear inversion.\n", + "\n", "__Contents__\n", "\n", "- Opening \u2014 Pixelized source reconstruction\n", @@ -19,6 +21,8 @@ "- Reconstructing the source\n", "- A first look at regularization\n", "- From source indexes to reconstructed brightnesses\n", + "- Keeping source brightnesses non-negative\n", + "- Where the image weakly constrains the source\n", "- Turning the reconstructed source back into an image\n", "- Inspecting the reconstruction\n", "- Changing the source resolution\n", @@ -242,6 +246,10 @@ "next tutorial. For now, the important distinction is that the mesh specifies where we represent the source; it does\n", "not yet tell us how bright the source is.\n", "\n", + "The image mask also affects the extent of this mesh, because it determines which image coordinates are traced.\n", + "Tutorial 3, **Borders**, compares masks and shows how distant traced coordinates can spread the mesh over regions\n", + "with little useful source information.\n", + "\n", "\n", "\n", "__Following the same image pixels into the source plane__\n", @@ -462,6 +470,10 @@ "coloured patches is not necessarily the physical image multiplicity: masking and pixel sampling can split or hide\n", "regions.\n", "\n", + "Tutorial 4, **Lens modelling**, returns to this picture in **Seeing how the mass model changes the mappings**.\n", + "There we change the Einstein radius and see why an incorrect mass model changes which image regions can share\n", + "the same reconstructed source.\n", + "\n", "\n", "\n", "__Reconstructing the source__\n", @@ -566,6 +578,59 @@ "\n", "\n", "\n", + "__Keeping source brightnesses non-negative__\n", + "\n", + "By default, the inversion uses a positive-only solver: reconstructed source brightnesses are constrained to be\n", + "non-negative, so zero is allowed. This prevents the model from explaining the image by adding negative source light.\n", + "\n", + "Positivity and regularization impose different constraints. Positivity restricts the allowed brightness values;\n", + "regularization discourages large differences between neighbours. A non-negative reconstruction can still contain\n", + "isolated bright values and fit noise, so positivity does not remove the need for regularization.\n", + "\n", + "__Where the image weakly constrains the source__\n", + "\n", + "Not every mesh point contributes equally to the image. Some receive many ray-traced image coordinates, while\n", + "others receive little support or none at all. We can inspect this using the mapping matrix we already introduced.\n", + "\n", + "The next cell sums the absolute mapping weights for each source mesh point, then prints the least-supported points\n", + "alongside their reconstructed brightnesses. It also counts points whose total weight is effectively zero; there\n", + "may be none for this particular mesh and mask." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "mapping_support = np.sum(np.abs(np.asarray(mapper.mapping_matrix)), axis=0)\n", + "unsupported = np.flatnonzero(mapping_support <= 1.0e-12)\n", + "least_supported = np.argsort(mapping_support)[:5]\n", + "\n", + "print(\"Source mesh points with effectively zero mapping weight:\", len(unsupported))\n", + "for source_index in least_supported:\n", + " print(\n", + " f\"Source mesh index {source_index}: \"\n", + " f\"total mapping weight={mapping_support[source_index]:.6g}, \"\n", + " f\"reconstructed brightness={reconstruction[source_index]:.6g}\"\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A point with no direct image contribution cannot have its brightness determined directly by the data. Its inferred\n", + "value can instead be influenced by regularization linking it to neighbouring source values, together with the\n", + "non-negativity constraint and any boundary constraints used by the inversion.\n", + "\n", + "For a weakly supported point, both the data and these constraints can matter. The total mapping weight is a\n", + "geometric diagnostic, not a brightness uncertainty: it does not account for image noise, telescope blurring or\n", + "correlations between source values. Ask how much confidence you should place in faint structure at these points.\n", + "\n", + "\n", + "\n", "__Turning the reconstructed source back into an image__\n", "\n", "The source reconstruction and the reconstructed image are different objects. The source lives on our source-plane\n", diff --git a/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb b/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb index 3e11c21..40a9f11 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb @@ -7,6 +7,8 @@ "Tutorial 2: Bayesian Regularization\n", "===================================\n", "\n", + "Explore regularization and use Bayesian evidence to choose its strength for a source reconstruction.\n", + "\n", "__Contents__\n", "\n", "- Interpolation and Bayesian regularization\n", @@ -14,11 +16,12 @@ "- Reducing regularization\n", "- Increasing regularization\n", "- Bayesian evidence\n", + "- Choosing the coefficient\n", "- Exploring the regularization coefficient\n", "- Inspecting the preferred reconstruction\n", "- What is linear, and what requires a search?\n", "- Mesh resolution and regularization belong together\n", - "- What we have learned, and what comes next\n", + "- Wrap Up\n", "\n", "__Interpolation and Bayesian regularization__\n", "\n", @@ -415,35 +418,32 @@ "favour giving the source too much freedom.\n", "\n", "**\ud83e\udd16 Ask AI:** Why might a weakly regularized reconstruction have a lower chi-squared but a less trustworthy source\n", - "than a more strongly regularized reconstruction?\n", - "\n", + "than a more strongly regularized reconstruction?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Bayesian evidence__\n", "\n", - "For inversions, we therefore need more than image agreement to choose an appropriate level of regularization.\n", + "Choosing the regularization coefficient is a Goldilocks problem. Too little regularization gives the source enough freedom to fit the image\u2019s noise. Too much regularization smooths away source structure that we need to explain the image. We want an amount of regularization that is **\u201cjust right\u201d**.\n", "\n", - "We use the **Bayesian evidence**, which accounts for both the fit to the image and the source prior. It helps us\n", - "compare reconstructions that have different amounts of freedom, rather than simply choosing whichever one follows\n", - "the observed pixel values most closely.\n", + "But how do we determine what \u201cjust right\u201d means without choosing the reconstruction that looks best by eye?\n", "\n", - "There are two useful parts to this intuition.\n", + "We use the **Bayesian evidence**, which accounts for both how accurately we reconstruct the image and the complexity of the source reconstruction.\n", "\n", - "First, the reconstruction must explain the image. If strong regularization prevents it from reproducing genuine\n", - "source structure, the resulting residuals count against that choice. Preferring a smooth source is not useful if\n", - "that source cannot explain the observed arcs.\n", + "With **too much regularization**, neighbouring source pixels are forced towards similar brightness values. This makes it difficult to reconstruct steep changes in brightness, such as a compact source\u2019s bright centre. The source is too smooth, the corresponding image fit leaves residuals, and the evidence decreases.\n", "\n", - "Second, allowing a more flexible source comes at a cost. Weak regularization permits many more patterns of\n", - "brightness across neighbouring mesh points. Some of that extra freedom may be needed to describe the galaxy, but\n", - "some may only help reproduce the particular noise fluctuations in this image.\n", + "With **too little regularization**, neighbouring source pixels have greater freedom to vary independently. The image fit improves, but the reconstruction can use this freedom to reproduce noise as additional source structure. The evidence accounts for the extra complexity, so a closer fit to the image does not necessarily give a higher evidence.\n", "\n", - "Evidence weighs the improvement in fit against the freedom allowed by the prior. An improvement must be sufficient\n", - "to justify that additional flexibility.\n", + "The **\u201cjust right\u201d coefficient** balances these effects: enough freedom to reconstruct the source structure supported by the data, with sufficient smoothing to discourage fitting noise. We identify it by finding the coefficient that gives the **highest Bayesian evidence**.\n", "\n", - "This is also why complexity is not simply the number of source mesh points. Our three examples have exactly the\n", - "same mesh. Stronger regularization ties neighbouring brightnesses together more closely, reducing their freedom to\n", - "vary independently. We can therefore change the effective flexibility of the reconstruction without changing the\n", - "number of mesh points.\n", + "Complexity here means more than the number of source pixels. Our examples use exactly the same mesh. Stronger regularization couples neighbouring brightness values more closely, reducing their freedom to vary independently without changing the number of pixels.\n", "\n", - "Let\u2019s compare the evidence values returned by `FitImaging`." + "In **PyAutoLens**, we obtain the natural logarithm of the evidence using `fit.log_evidence`. Taking the logarithm preserves the ranking, so choosing the largest log evidence is equivalent to choosing the largest evidence.\n", + "\n", + "Let\u2019s compare our three reconstructions." ] }, { @@ -460,15 +460,49 @@ " print(\n", " f\"{label}: coefficient={coefficient:g}, \"\n", " f\"log evidence={log_evidence:.3f}\"\n", - " )\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Choosing the coefficient__\n", + "\n", + "How do we interpret these numbers?\n", + "\n", + "Suppose, purely as an illustration, that coefficients of `0.01`, `1.0` and `100.0` gave log evidences of `4200`, `4250` and `4100`. We would choose `1.0` from these three values because `4250` is the largest. The same rule applies if the values are negative: `-100` is larger than `-150`.\n", + "\n", + "There is no particular log evidence value that we are trying to reach. We compare the values obtained by fitting **the same dataset**, keeping the lens model, mesh, mask and noise map fixed while changing the regularization coefficient.\n", + "\n", + "The code below selects the coefficient with the highest log evidence and prints how far each alternative lies below it." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "log_evidences = np.asarray(log_evidences)\n", "\n", "best_index = int(np.argmax(log_evidences))\n", - "best_label, best_coefficient, _ = fit_comparisons[best_index]\n", + "best_label, best_coefficient, best_fit = fit_comparisons[best_index]\n", "\n", - "print(\n", - " f\"\\nHighest evidence among these examples: \"\n", - " f\"{best_label}, coefficient={best_coefficient:g}\"\n", - ")" + "for (label, coefficient, _), log_evidence in zip(\n", + " fit_comparisons, log_evidences\n", + "):\n", + " print(\n", + " f\"{label}: coefficient={coefficient:g}, \"\n", + " f\"difference from highest log evidence=\"\n", + " f\"{log_evidence - log_evidences[best_index]:.3f}\"\n", + " )\n", + "\n", + "print(f\"\\nPreferred coefficient among these examples: {best_coefficient:g}\")\n", + "\n", + "aplt.subplot_fit_imaging(fit=best_fit)\n", + "aplt.subplot_fit_imaging_of_planes(fit=best_fit)" ], "outputs": [], "execution_count": null @@ -477,30 +511,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Compare this ranking with the source plots and the chi-squared values. The highest evidence need not belong to the\n", - "reconstruction with the lowest chi-squared, because image agreement is only part of the comparison.\n", - "\n", - "We still need to inspect the residuals ourselves. Evidence does not explicitly examine their spatial pattern or\n", - "guarantee that a preferred model is correct. Structured residuals can reveal shortcomings in the source, lens mass,\n", - "PSF or other assumptions that this three-coefficient comparison does not address.\n", - "\n", - "For now, we have compared only three choices. The starting value of `1.0` has no special status, and the best of\n", - "these three is not necessarily the best coefficient overall. Next, we will explore a broader range and see how this\n", - "choice fits into the wider modelling process.\n", + "Compare this reconstruction with the weakly and strongly regularized examples. How much of the irregular source structure has been smoothed away? Are the bright components still reconstructed accurately? What remains in the image residuals?\n", "\n", + "We have selected the best of **three coefficients**, rather than found the overall maximum. A coefficient between our trial values could give a higher log evidence. Next, we will evaluate a wider range and plot the evidence against the coefficient to locate its peak." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Exploring the regularization coefficient__\n", "\n", - "We have compared weak, moderate and strong regularization, but those three values were chosen to illustrate\n", - "different behaviours. They do not tell us where the evidence reaches its highest value.\n", + "Our three examples illustrate too little, moderate and too much regularization. However, there is nothing special about the starting coefficient of `1.0`. A value of `0.5`, `2.0`, or something else entirely might give a higher log evidence.\n", "\n", - "Let\u2019s explore the coefficient more systematically. We will keep the lens mass, mesh resolution, mask and noise map\n", - "fixed, and reconstruct the source for a sequence of coefficients.\n", + "Let\u2019s explore this systematically. We keep the lens mass model, source mesh and dataset fixed, and calculate the log evidence for a range of regularization coefficients.\n", "\n", - "Regularization coefficients can span several orders of magnitude, so we use logarithmically spaced values. This\n", - "gives us comparable coverage between `0.01` and `0.1`, between `0.1` and `1.0`, and so on.\n", + "For every coefficient, we perform a new inversion. We are not taking one reconstructed source and smoothing it afterwards: changing the coefficient changes the brightness values that the inversion solves for.\n", "\n", - "Every point in this experiment requires a new source reconstruction. We are not taking one reconstructed source and\n", - "smoothing it by different amounts afterwards. The coefficient changes the problem that the inversion solves." + "Regularization coefficients can span several orders of magnitude, so we use logarithmically spaced values. This gives us evenly spaced steps between `0.01` and `0.1`, between `0.1` and `1.0`, and so on." ] }, { @@ -512,41 +540,46 @@ "\n", "coefficient_grid = np.logspace(-2, 2, 13)\n", "\n", - "# Reuse the three reconstructions already calculated.\n", - "known_fits = {\n", + "# Reuse the three reconstructions we have already calculated.\n", + "fits_by_coefficient = {\n", " 0.01: weak_regularization_fit,\n", " 1.0: fit,\n", " 100.0: strong_regularization_fit,\n", "}\n", "\n", "evidence_grid = []\n", + "best_evidence = -np.inf\n", + "preferred_fit = None\n", "\n", "for coefficient in coefficient_grid:\n", " coefficient = float(coefficient)\n", "\n", - " if coefficient in known_fits:\n", - " current_fit = known_fits[coefficient]\n", - " else:\n", + " current_fit = fits_by_coefficient.get(coefficient)\n", + " if current_fit is None:\n", " current_fit = fit_with_coefficient(coefficient=coefficient)\n", "\n", - " evidence_grid.append(float(current_fit.log_evidence))\n", + " evidence = float(current_fit.log_evidence)\n", + " evidence_grid.append(evidence)\n", + " if evidence > best_evidence:\n", + " best_evidence = evidence\n", + " preferred_fit = current_fit\n", "\n", "evidence_grid = np.asarray(evidence_grid)\n", "\n", "if not np.all(np.isfinite(evidence_grid)):\n", - " raise ValueError(\n", - " \"Some evidence values are not finite. Inspect those fits before comparing.\"\n", - " )\n", + " raise ValueError(\"Inspect fits with non-finite evidence before comparing them.\")\n", "\n", "fig, ax = plt.subplots()\n", + "\n", "ax.semilogx(\n", " coefficient_grid,\n", " evidence_grid - np.max(evidence_grid),\n", " marker=\"o\",\n", ")\n", + "\n", "ax.set_xlabel(\"Regularization coefficient\")\n", "ax.set_ylabel(\"Log evidence relative to the highest sampled value\")\n", - "ax.set_title(\"Choosing the Strength of Regularization\")\n", + "\n", "plt.show()" ], "outputs": [], @@ -556,26 +589,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Look for the part of the curve where the evidence is highest. Moving towards weaker regularization gives the source\n", - "more freedom; moving towards stronger regularization increasingly restricts its variations.\n", + "We have subtracted the highest log evidence from every value. This places the highest point at zero and the other points below it, without changing their ranking.\n", "\n", - "We may find a clear peak, a broad region with similar evidence, or a highest value at one end of the range. These\n", - "outcomes mean different things. A boundary maximum tells us that this experiment has not bracketed the preferred\n", - "coefficient\u2014we should extend the range before claiming to have located it.\n", - "\n", - "The plotted points are also discrete samples. Even an interior maximum only identifies the best coefficient we have\n", - "tried so far.\n", + "Look for the peak of the curve. Towards small coefficients, the source has more freedom to fit noise. Towards large coefficients, smoothing increasingly prevents it from reproducing the source structure. The peak identifies our Goldilocks coefficient **among the values we have tested**.\n", "\n", + "If the highest point lies at either end of the range, we should extend the range in that direction. We have not yet located a peak between weaker and stronger regularization." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Inspecting the preferred reconstruction__\n", "\n", - "Let\u2019s inspect the reconstruction with the highest evidence in our sampled range.\n", + "Let\u2019s take the coefficient with the highest log evidence and look at its source reconstruction and image residuals.\n", "\n", - "It is tempting to treat the highest number as the end of the analysis. However, we should connect that number back\n", - "to the source and image. We want to understand what this choice preserves, what it smooths away, and what the\n", - "resulting model still fails to explain.\n", + "Before looking, think back to our earlier examples. Weak regularization allowed sharp variations between neighbouring source pixels, including in faint regions. Strong regularization suppressed these variations but could also flatten the bright source components.\n", "\n", - "Compare it with the weakly and strongly regularized examples. In particular, examine the distinct bright source\n", - "components and the faint regions between them. Then look at the image residuals where those components are lensed." + "Does the evidence-preferred reconstruction provide a better balance?" ] }, { @@ -583,31 +614,21 @@ "metadata": {}, "source": [ "\n", - "best_index = int(np.argmax(evidence_grid))\n", - "preferred_coefficient = float(coefficient_grid[best_index])\n", + "preferred_index = int(np.argmax(evidence_grid))\n", + "preferred_coefficient = float(coefficient_grid[preferred_index])\n", "\n", - "if preferred_coefficient in known_fits:\n", - " preferred_fit = known_fits[preferred_coefficient]\n", - "else:\n", - " preferred_fit = fit_with_coefficient(\n", - " coefficient=preferred_coefficient\n", - " )\n", - "\n", - "print(\"Preferred sampled coefficient:\", preferred_coefficient)\n", - "\n", - "if best_index in (0, len(coefficient_grid) - 1):\n", - " print(\n", - " \"The highest evidence lies at the boundary. \"\n", - " \"Extend the coefficient range before interpreting this as a peak.\"\n", - " )\n", + "print(f\"Preferred sampled coefficient: {preferred_coefficient:.4g}\")\n", + "print(f\"Log evidence: {evidence_grid[preferred_index]:.3f}\")\n", "\n", "preferred_mapper = preferred_fit.inversion.cls_list_from(al.Mapper)[0]\n", "\n", "aaplt.plot_mapper(\n", " mapper=preferred_mapper,\n", " solution_vector=preferred_fit.inversion.reconstruction,\n", + " vmin=0.0,\n", + " vmax=source_vmax,\n", " zoom_to_brightest=False,\n", - " title=\"Source at the Preferred Sampled Coefficient\",\n", + " title=\"Source at the Preferred Regularization Coefficient\",\n", ")\n", "\n", "aplt.subplot_fit_imaging(fit=preferred_fit)\n", @@ -620,33 +641,30 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "A preferred coefficient does not establish that every visible feature in the source is real. The reconstruction\n", - "remains conditional on the lens model, source mesh, noise map and telescope blurring.\n", + "The source plot uses the same brightness limits as our earlier comparisons. Look at the bright components and the faint regions around them, then examine the corresponding image residuals.\n", "\n", - "Nor does a good-looking source excuse coherent residuals in the image. The source and residual plots provide\n", - "complementary information: one shows what we inferred, while the other shows what that inference leaves\n", - "unexplained.\n", + "Choosing the highest evidence does not mean the residuals must now be satisfactory. We have found the preferred coefficient for **this particular mesh and regularization scheme**. If the mesh has too few pixels across a compact source component, changing the coefficient cannot supply the missing resolution.\n", "\n", - "**\ud83e\udd16 Ask AI:** If the evidence prefers a particular regularization coefficient but the residuals still follow the\n", - "shape of an arc, what modelling assumptions should I investigate next?\n", + "There is also a compromise in applying one coefficient across the whole source. The bright centre may need weaker smoothing to preserve its steep brightness gradient, while the faint outskirts benefit from stronger smoothing. A single coefficient must serve both regions.\n", "\n", + "Keep these limitations in mind. Our later **Fit Problems** tutorial will show why choosing the coefficient is only part of the solution, motivating meshes and regularization that adapt to the source." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__What is linear, and what requires a search?__\n", "\n", - "Before going further, let\u2019s separate two parts of the calculation that are easy to confuse.\n", + "Our reconstruction contains hundreds of source brightness values. Does this mean we have added hundreds of parameters to the non-linear search?\n", "\n", - "For each coefficient we supplied, the inversion solved for the source brightnesses. We did not give a non-linear\n", - "search hundreds of independent source brightness parameters and ask it to explore their possible combinations.\n", - "Instead, the source enters the predicted image linearly, allowing these brightnesses to be solved together using\n", - "linear algebra.\n", + "Fortunately, no! This is the same advantage we encountered with linear light profiles in Chapter 2. There, we solved for the intensities of Gaussian components. Here, we solve for the brightness values on the source mesh.\n", "\n", - "Regularization is included in that solve. The source is not first reconstructed without a prior and then smoothed\n", - "afterwards. The inferred brightnesses already reflect the balance between fitting the image and satisfying the\n", - "smoothness prior.\n", + "For a fixed lens model, mesh and regularization coefficient, these brightness values enter the predicted image linearly. The inversion therefore solves for them together using linear algebra, including regularization in that calculation.\n", "\n", - "Choosing the regularization coefficient is a different problem. We supplied several coefficients, performed an\n", - "inversion for each, and compared their evidence values. Our loop is a simple grid search over one quantity.\n", + "Choosing the regularization coefficient is a separate, non-linear problem. Each coefficient produces a different reconstruction and log evidence. Our loop explored this by trying a grid of coefficients and selecting the one with the highest evidence.\n", "\n", - "We can see the difference in scale directly:" + "Let\u2019s compare how many source brightness values we reconstructed with how many coefficients we tried." ] }, { @@ -658,9 +676,8 @@ " preferred_fit.inversion.reconstruction\n", ")\n", "\n", - "print(\"Number of source mesh values:\", preferred_mapper.pixels)\n", - "print(\"Number of reconstructed entries:\", source_brightnesses.size)\n", - "print(\"Number of coefficients evaluated:\", coefficient_grid.size)" + "print(\"Number of reconstructed brightness values:\", source_brightnesses.size)\n", + "print(\"Number of regularization coefficients tried:\", coefficient_grid.size)" ], "outputs": [], "execution_count": null @@ -669,38 +686,33 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The reconstruction contains an entry for each source mesh point in this single-mapper example. Some values may be\n", - "constrained to zero at the mesh boundary, and regularization couples neighbouring values, so the number of entries\n", - "is not the number of independently constrained source features.\n", - "\n", - "During lens modelling, the search also changes the mass parameters. Each proposed mass model changes the ray\n", - "tracing and therefore the mappings between source and image. The source must be reconstructed again for that\n", - "proposal.\n", + "Every coefficient in our grid required a solution for **all** those brightness values. We did not search through their possible combinations individually.\n", "\n", - "The sequence is therefore:\n", + "During lens modelling, we also vary the lens mass parameters. Changing the mass model changes where the image pixels trace in the source plane, so we must perform a new inversion for each proposed model.\n", "\n", - "1. Propose the non-linear parameters, including the lens mass and regularization coefficient.\n", - "2. Construct the corresponding mappings.\n", - "3. Solve for the source brightnesses.\n", - "4. Evaluate how well that model explains the data, accounting for the source prior.\n", + "The calculation follows this sequence:\n", "\n", - "This is what makes pixelized sources practical: the search explores the non-linear model choices, while the\n", - "inversion handles the many source brightness values.\n", + "1. The non-linear search proposes a lens mass model and regularization coefficient.\n", + "2. We ray-trace the image pixels and construct their mappings to the source mesh.\n", + "3. The inversion solves for the source brightness values.\n", + "4. We calculate the log evidence, which the search uses to assess that proposal.\n", "\n", + "This is what makes pixelized sources practical. We can reconstruct hundreds of source brightness values without asking the non-linear search to explore hundreds of additional parameters." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Mesh resolution and regularization belong together__\n", "\n", - "We have held the source mesh at `(25, 25)`. The preferred coefficient we found belongs to that choice of mesh.\n", + "We have found a preferred regularization coefficient for our current mesh. Would the same coefficient still be preferred if we changed its resolution?\n", "\n", - "Changing the mesh changes the locations and number of source brightness values, their neighbours, and how they\n", - "contribute to the image. The same numerical coefficient therefore need not give an equivalent amount of smoothing\n", - "on another mesh.\n", + "Not necessarily. A finer mesh introduces more brightness values and changes the distances between neighbouring points. It gives the source more freedom to describe small-scale structure, but also more freedom to fit noise. The regularization coefficient must be chosen alongside that resolution.\n", "\n", - "This matters when testing resolution. If we increase the number of source points but keep the coefficient fixed,\n", - "any change in the reconstruction reflects both the new resolution and its interaction with the prior. We cannot\n", - "assume that we have made a fair comparison of the best reconstructions available at each resolution.\n", + "Think of our Goldilocks analogy again: changing the mesh changes the problem for which we are finding the \u201cjust right\u201d amount of smoothing.\n", "\n", - "Let\u2019s repeat the coefficient experiment for `(15, 15)` and `(35, 35)`, alongside our existing `(25, 25)` results.\n", - "We extend our helper so the mesh shape is an explicit input." + "Let\u2019s repeat our coefficient scan with coarser and finer meshes. We first extend our helper function so that we can specify the mesh shape." ] }, { @@ -721,12 +733,26 @@ "\n", " tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy])\n", "\n", - " return al.FitImaging(dataset=dataset, tracer=tracer)\n", - "\n", + " return al.FitImaging(dataset=dataset, tracer=tracer)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We reuse the evidence values for our existing mesh and calculate two additional curves. This takes longer than the previous experiment because each additional mesh requires another complete coefficient scan." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ "\n", "evidence_by_shape = {mesh_shape: evidence_grid}\n", "\n", - "for shape in ([(6, 6), (10, 10)] if mesh_shape == (8, 8) else [(15, 15), (35, 35)]):\n", + "for shape in [(15, 15), (35, 35)]:\n", " values = []\n", "\n", " for coefficient in coefficient_grid:\n", @@ -761,8 +787,8 @@ "\n", "ax.set_xlabel(\"Regularization coefficient\")\n", "ax.set_ylabel(\"Log evidence relative to the highest sampled value\")\n", - "ax.set_title(\"Mesh Resolution and Regularization\")\n", "ax.legend()\n", + "\n", "plt.show()" ], "outputs": [], @@ -772,34 +798,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This experiment performs 26 additional reconstructions, so it takes longer than our earlier three-way comparison.\n", + "Look at **where each curve peaks** and **how high that peak is**.\n", "\n", - "Look at both the locations and heights of the curves\u2019 maxima. The preferred coefficient may shift as the mesh\n", - "changes. A finer mesh may help describe source structure, but adding points does not guarantee a higher evidence.\n", + "The position tells us which coefficient is preferred for that mesh. The height lets us compare the best sampled reconstructions across the different resolutions. We subtract the same reference value from every curve so that these differences remain visible.\n", "\n", - "All three curves use the same image pixels and noise model. We also subtract one common reference evidence,\n", - "preserving differences between the meshes. Subtracting each curve\u2019s own maximum would hide those differences.\n", - "\n", - "As before, a boundary maximum means that the coefficient range needs extending. These are controlled resolution\n", - "checks, not a demonstration that mesh dimensions should become free parameters in every lens-model search.\n", + "A finer mesh may increase the evidence by resolving source structure that the coarser mesh cannot describe. However, more source pixels do not automatically mean a better reconstruction: the additional freedom must be justified by the image data.\n", "\n", + "Before drawing a conclusion, check that each peak lies inside the coefficient range. If a curve is still rising at either end, extend its range. Once a peak is bracketed, try more closely spaced coefficients around it to refine the comparison." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Wrap Up__\n", "\n", - "We can now give the regularization input a more meaningful interpretation.\n", - "\n", - "The source mesh supplies brightness values that can vary to explain the image. Interpolation defines the brightness\n", - "between those values. Regularization places a prior on their variation, discouraging unnecessary differences\n", - "between neighbours.\n", - "\n", - "Too little regularization can allow the reconstruction to explain noise through spurious source structure. Too much\n", - "can suppress real structure and leave it visible in the image residuals. Evidence helps compare these choices by\n", - "accounting for both image agreement and the freedom allowed by the source prior.\n", + "We can now explain the regularization coefficient that we first supplied without much discussion in tutorial 1.\n", "\n", - "We also saw that the coefficient is not a universal setting. Its appropriate value depends on the dataset, lens\n", - "model, mesh and regularization scheme. A value that works for one reconstruction should not simply be carried into\n", - "another analysis without checking it.\n", + "It controls how strongly we discourage differences between neighbouring source brightness values. Too little regularization allows the reconstruction to fit noise; too much smooths away structure needed to explain the image. We find the Goldilocks balance by maximizing the Bayesian evidence.\n", "\n", - "For a final comparison, print the preferred sampled coefficient for each mesh:" + "For a final comparison, let\u2019s print the preferred sampled coefficient for each mesh." ] }, { @@ -815,11 +833,8 @@ " print(\n", " f\"Mesh {shape}: coefficient={coefficient:.4g}, \"\n", " f\"log evidence={values[index]:.3f}\"\n", - " + (\" \u2014 boundary maximum; extend the range\" if boundary else \"\")\n", - " )\n", - "\n", - "# Retain the original mesh's preferred fit for subsequent inspection.\n", - "fit = preferred_fit" + " + (\" \u2014 extend the coefficient range\" if boundary else \"\")\n", + " )" ], "outputs": [], "execution_count": null @@ -828,17 +843,20 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Throughout this tutorial, we fixed the lens mass to the simulation values. We have selected source-reconstruction\n", - "settings under that assumption; we have not inferred the mass or its uncertainty.\n", + "The preferred coefficient depends on the mesh, as well as the data and lens model. We should therefore infer it as part of an analysis, rather than assume that a value which worked for one reconstruction will work for another.\n", "\n", - "Later, these source reconstructions will sit inside a lens-model search. Before that, the next tutorial examines\n", - "another practical issue: how the source mesh covers the ray-traced image coordinates, and why a small number of\n", - "extreme coordinates can affect the resolution available to reconstruct the source.\n", + "Throughout this tutorial, we fixed the lens mass model to the values used to simulate the image. Later, we will fit the mass parameters alongside the regularization coefficient, performing an inversion for every model proposed by the non-linear search.\n", "\n", - "For readers who want the underlying equations, the workspace\u2019s [pixelization likelihood\n", - "guide](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py)\n", - "follows the mapping, blurring, regularization and evidence calculations step by step. That derivation complements\n", - "the experiments here; it is not required to continue using pixelizations." + "Before that, we need to examine another practical issue. Choosing the number of source pixels does not, by itself, determine our source resolution. We also need to consider **how much area those pixels cover**.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For the underlying equations, the workspace\u2019s [pixelization likelihood guide](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py) follows the mapping, blurring, regularization and evidence calculations step by step." ] } ], diff --git a/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb b/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb index cb0db4e..bb450b6 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb @@ -7,6 +7,8 @@ "Tutorial 3: Borders\n", "===================\n", "\n", + "Explore how masks and border relocation affect the extent and resolution of a source mesh.\n", + "\n", "__Contents__\n", "\n", "- Why the extent of the source mesh matters\n", @@ -16,25 +18,9 @@ "- Following the central pixels into the source plane\n", "- Tracing the outer border\n", "- Switching border relocation on\n", - "- What relocation changes\u2014and what it does not\n", + "- What relocation changes\n", "- The mask still needs to cover the lensed source\n", - "- Wrap up\n", - "\n", - "__Why the extent of the source mesh matters__\n", - "\n", - "In the previous tutorials, we chose how many points our rectangular source mesh contained. However, the number of\n", - "points is only part of what determines its resolution. We also need to consider how much of the source plane those\n", - "points cover.\n", - "\n", - "A `(25, 25)` mesh spread across a small source region can describe finer structure than the same mesh spread across\n", - "a much larger region. If a few ray-traced coordinates extend far beyond the source, the mesh can expand to\n", - "accommodate them, leaving fewer points where we actually need to reconstruct the galaxy.\n", - "\n", - "This tutorial examines how that happens and introduces the border relocator, which provides a practical way to\n", - "control these extreme coordinates.\n", - "\n", - "We return to a simpler simulated source so that its morphology does not distract from the geometry. The image\n", - "contains lensed source light but no foreground lens light." + "- Wrap Up" ] }, { @@ -75,6 +61,25 @@ "_setup_colab.setup(\"howtolens\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Why the extent of the source mesh matters__\n", + "\n", + "Imagine spreading a `(25, 25)` rectangular mesh across a source-plane region one arcsecond wide. Now spread the same mesh across a region ten arcseconds wide. We still have the same number of brightness values, but the spacing between them is ten times larger. Small source components that the first mesh could resolve may be poorly represented by the second.\n", + "\n", + "What determines this extent?\n", + "\n", + "The uniform rectangular mesh we are using is constructed to cover the ray-traced image coordinates. If some coordinates land far away from the source galaxy, the mesh expands to include them. Much of its resolution can then be spent covering regions where there is little or no source light.\n", + "\n", + "This can happen when image pixels close to the centre of the lens are strongly demagnified and trace far out into the source plane. Adding these central image pixels to a fit can therefore have a surprisingly large effect on the source mesh.\n", + "\n", + "In this tutorial, we will follow those pixels through the calculation and introduce **border relocation**, which prevents these distant coordinates from unnecessarily expanding the reconstruction.\n", + "\n", + "We return to a simpler simulated source, with no foreground lens light, so that we can focus on the geometry." + ] + }, { "cell_type": "code", "metadata": {}, @@ -83,8 +88,6 @@ "from autolens import jax_wrapper\n", "\n", "from pathlib import Path\n", - "import subprocess\n", - "import sys\n", "\n", "import numpy as np\n", "import autolens as al\n", @@ -94,7 +97,8 @@ "dataset_path = Path(\"dataset\") / \"imaging\" / \"simple__no_lens_light\"\n", "\n", "al.util.dataset.simulate_if_needed(\n", - " dataset_path, simulator=\"scripts/simulator/no_lens_light.py\"\n", + " dataset_path,\n", + " simulator=\"scripts/simulator/no_lens_light.py\",\n", ")\n", "\n", "dataset_unmasked = al.Imaging.from_fits(\n", @@ -105,7 +109,7 @@ ")\n", "\n", "dataset_unmasked = dataset_unmasked.apply_over_sampling(\n", - " over_sample_size_pixelization=1\n", + " over_sample_size_pixelization=1,\n", ")\n", "\n", "aplt.subplot_imaging_dataset(dataset=dataset_unmasked)" @@ -117,18 +121,22 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We will compare two masks with the same outer radius. One includes the centre of the image; the other excludes it.\n", - "This lets us see what happens when the central image pixels enter the reconstruction.\n", + "We will compare two masks with the same outer radius. One includes the central image pixels; the other removes them. This lets us isolate how including the centre changes the area covered by the source mesh.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__What we mean by a border__\n", "\n", - "The border is the outermost boundary of the unmasked image region.\n", + "Here, the **border** means the outermost boundary of the unmasked image region: the ring of image pixels just inside the mask\u2019s outer edge.\n", "\n", - "For a circular mask, this is straightforward: the border follows the outside of the circle. An annular mask has an\n", - "inner and an outer edge, but the border used here follows the outer edge. The hole in the middle does not become a\n", - "second outer border.\n", + "For a circular mask, this follows the circumference of the circle. An annular mask also has an inner edge around its central hole, but that inner edge is not the border used for relocation.\n", "\n", - "Let\u2019s plot both masks and mark their border coordinates." + "Let\u2019s construct both masks and mark their outer borders on the image." ] }, { @@ -171,20 +179,22 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The outer borders should coincide because the masks share the same outer radius. The difference is inside: the\n", - "circular mask includes image pixels near the lens centre that the annular mask removes.\n", + "Both masks have the same outer border because their outer radii are identical. Their difference is in the centre: the circular mask includes pixels that the annular mask excludes.\n", "\n", - "We will keep the masks fixed while comparing the source-plane grids. Choosing the mask and relocating a border are\n", - "different operations: the mask selects which image pixels enter the fit, while relocation changes how certain\n", - "coordinates are treated when constructing the source reconstruction.\n", + "We can ray-trace the marked border pixels just as we ray-trace any other image pixels. Their traced positions define a border in the **source plane**, which we will use to identify coordinates that land beyond it.\n", "\n", + "Keep these two operations separate. **Masking** determines which image pixels we fit. **Border relocation** changes how coordinates beyond the traced border enter the source reconstruction; it does not remove those image pixels from the fit." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Starting without border relocation__\n", "\n", - "First, let\u2019s reconstruct the source using the annular mask with border relocation explicitly switched off.\n", + "Let\u2019s first reconstruct the source using the annular mask, which excludes the central image pixels. We explicitly switch border relocation off so that we can inspect the original ray-traced coordinates.\n", "\n", - "We use the mass model that generated the simulation and a uniform rectangular source mesh. The helper below lets us\n", - "change the mask and relocation setting while keeping the lens model, mesh shape and regularization coefficient\n", - "unchanged." + "We use the same mass model that generated the simulated image: an isothermal galaxy with external shear. Keeping the mass model fixed lets us focus on how the mask affects the source reconstruction." ] }, { @@ -192,8 +202,7 @@ "metadata": {}, "source": [ "\n", - "# Match mesh size to capped smoke data; normal tutorial runs retain 25 x 25.\n", - "mesh_shape = (8, 8) if dataset_unmasked.shape_native == al.util.dataset.SMALL_DATASETS_SHAPE_NATIVE else (25, 25)\n", + "mesh_shape = (25, 25)\n", "\n", "lens_galaxy = al.Galaxy(\n", " redshift=0.5,\n", @@ -228,8 +237,22 @@ "tracer = al.Tracer(\n", " galaxies=[lens_galaxy, source_galaxy],\n", " fields=[field],\n", - ")\n", - "\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The helper below lets us change the mask and switch relocation on or off. We keep the mesh dimensions and regularization coefficient fixed throughout these comparisons. We are investigating the mesh geometry here, rather than finding the preferred coefficient for each setup." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ "\n", "def fit_with_mask_and_border(mask, use_border_relocator):\n", " dataset = dataset_unmasked.apply_mask(mask=mask)\n", @@ -270,18 +293,22 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The first plot shows where the image coordinates land after ray tracing. The second shows the source mesh and the\n", - "brightnesses inferred on it.\n", + "The first plot shows where the unmasked image pixels trace in the source plane. The second shows the mesh covering those coordinates and the source brightness reconstructed on it.\n", "\n", - "Look at the coordinate ranges as well as the source itself. These tell us how much source-plane area the mesh is\n", - "covering. We will use this reconstruction as a reference when we add the central image pixels.\n", + "Pay attention to the axis ranges and the spacing of the mesh points. These provide our reference for the next comparison.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Including the central image pixels__\n", "\n", - "Now repeat the calculation with the circular mask, still without border relocation.\n", + "Now repeat the reconstruction with the circular mask.\n", "\n", - "The outer image boundary is unchanged. We are adding the pixels within the central hole of the annulus, rather than\n", - "extending the image to larger radii." + "Its outer radius is unchanged. We are simply adding the image pixels inside the central hole of the annulus. Before looking at the result, consider what you expect: will adding pixels near the **image centre** necessarily add coordinates near the **source centre**?" ] }, { @@ -307,7 +334,24 @@ " mesh_grid=mapper_circular.source_plane_mesh_grid,\n", " zoom_to_brightest=False,\n", " title=\"Circular Mask: Source Reconstruction\",\n", - ")\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the coordinate ranges with the annular reconstruction. The added central image pixels can trace well outside the region occupied by the other source-plane coordinates. The rectangular mesh expands to cover them.\n", + "\n", + "Let\u2019s measure the extent of each mesh." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ "\n", "for label, mapper in [\n", " (\"Annular\", mapper_annular),\n", @@ -317,7 +361,7 @@ " span_y, span_x = np.ptp(coordinates, axis=0)\n", "\n", " print(\n", - " f\"{label} mesh coordinate spans: \"\n", + " f\"{label} mesh spans: \"\n", " f\"y={span_y:.3f} arcsec, x={span_x:.3f} arcsec\"\n", " )" ], @@ -328,24 +372,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Compare the source-plane extent with the annular case. The added image pixels can trace outside the compact region\n", - "occupied by the other coordinates, causing the rectangular mesh to cover a larger area.\n", - "\n", - "Both reconstructions still use the same number of mesh points. If the mesh expands, their spacing increases,\n", - "reducing the resolution available to describe the central source.\n", + "Both meshes still contain `25 \u00d7 25` points. When their extent increases, the points become more widely spaced. We therefore have fewer points across the source galaxy itself, reducing our ability to resolve its structure.\n", "\n", - "This illustrates why increasing the number of image pixels does not automatically improve a reconstruction with a\n", - "fixed mesh. We must also consider how those pixels affect the source-plane coverage.\n", + "Adding more image pixels has changed the resolution available to reconstruct the source, even though we did not change the mesh dimensions!\n", "\n", - "The two masks contain different data, so this is a geometric comparison\u2014not a direct evidence comparison between\n", - "otherwise identical fits.\n", + "The masks contain different sets of image pixels, so we should not rank these two fits by their absolute log evidences. For now, compare their geometry and source reconstructions.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Following the central pixels into the source plane__\n", "\n", - "Let\u2019s confirm where the extra source-plane coordinates come from.\n", + "Let\u2019s identify exactly where those distant source-plane coordinates came from.\n", "\n", - "We select two small groups of image pixels: one close to the lens centre and another near the lensed arc. As in\n", - "tutorial 1, we use the same index groups in both planes, so matching colours identify the same light paths." + "We select two groups of image pixels: twelve close to the lens centre and twelve near the arc. We highlight the same index groups in both planes, so that each colour follows the same pixels through the ray tracing." ] }, { @@ -373,20 +417,55 @@ "aaplt.plot_grid(\n", " grid=mapper_circular.image_plane_data_grid,\n", " indexes=index_groups,\n", - " title=\"Image Coordinates: Central Pixels and Arc Pixels\",\n", + " title=\"Image Plane: Central Pixels and Arc Pixels\",\n", ")\n", "\n", "aaplt.plot_grid(\n", " grid=mapper_circular.source_plane_data_grid,\n", " indexes=index_groups,\n", - " title=\"The Same Pixels Traced into the Source Plane\",\n", + " title=\"Source Plane: The Same Ray-Traced Pixels\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The pixels near the arc trace towards the source. The central image pixels can land much further away.\n", + "\n", + "Recall the lens equation:\n", + "\n", + "\\[\n", + "\\boldsymbol{\\beta}\n", + "=\n", + "\\boldsymbol{\\theta}\n", + "-\n", + "\\boldsymbol{\\alpha}(\\boldsymbol{\\theta}).\n", + "\\]\n", + "\n", + "A small image-plane position, \\(\\boldsymbol{\\theta}\\), does not necessarily give a small source-plane position, \\(\\boldsymbol{\\beta}\\). We must subtract the deflection at that position.\n", + "\n", + "Near the centre of our singular isothermal lens, a small image region can correspond to a much larger source-plane region. This is **demagnification**: the image occupies less area than the corresponding region of the source plane. It does not mean that the deflection angle becomes infinite.\n", + "\n", + "We can inspect a few of these coordinate pairs directly." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "source_coordinates = np.asarray(\n", + " mapper_circular.source_plane_data_grid\n", ")\n", "\n", "for index in central_indexes[:4]:\n", " print(\n", - " f\"Image index {index}: \"\n", + " f\"Image pixel {index}: \"\n", " f\"{image_coordinates[index]} -> \"\n", - " f\"{np.asarray(mapper_circular.source_plane_data_grid[index])}\"\n", + " f\"{source_coordinates[index]}\"\n", " )" ], "outputs": [], @@ -396,38 +475,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The central image coordinates need not map close to the source-plane origin. Their source positions depend on the\n", - "difference between their image positions and the lens deflections.\n", - "\n", - "Near the centre of a cuspy mass distribution, a small image region can correspond to a much larger source region.\n", - "This is demagnification: the image occupies less area than the corresponding source region. It does not require the\n", - "deflection angle itself to become arbitrarily large.\n", - "\n", - "For our reconstruction, the practical consequence is that central image pixels can map well outside the region\n", - "traced by the arcs. The mesh then spends some of its resolution covering those distant positions.\n", - "\n", - "There is another concern. Source structure reconstructed at those positions may predict other images outside our\n", - "chosen mask, where we are not comparing the model with the data. The reconstruction is then missing some of the\n", - "observational constraints that would otherwise act on that structure.\n", - "\n", - "In the next blocks, we will trace the outer border into the source plane and examine how border relocation handles\n", - "coordinates beyond it. We will compare the same circularly masked dataset with relocation switched off and on, so\n", - "that the effect of relocation is isolated.\n", + "These central pixels contain no bright source emission in our simulated image, yet their traced positions still help determine the rectangular mesh\u2019s extent. The mesh consequently spends resolution covering distant regions instead of concentrating it around the source.\n", "\n", - "**\ud83e\udd16 Ask AI:** Why can a small group of central image pixels change the resolution of a rectangular source mesh,\n", - "even when most of the observed source light lies in the arcs?\n", + "There is another potential problem: assigning source brightness at these distant positions may predict additional images outside our mask. Those predictions would not be checked against the excluded image data.\n", "\n", + "Next, we will trace the outer image border into the source plane and use it to control these distant coordinates." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Tracing the outer border__\n", "\n", - "We have seen that some central image pixels trace beyond the compact source region. To decide how to handle those\n", - "coordinates, we first need a reference boundary in the source plane.\n", + "We have seen that pixels near the image centre can trace far beyond the source galaxy. To decide which coordinates to relocate, we first need a boundary in the source plane.\n", "\n", - "We obtain this from the outer border of the image mask. These are ordinary image coordinates, so we can ray-trace\n", - "them through the same lens model as every other pixel. Their traced positions describe where the edge of our fitted\n", - "image region maps into the source plane.\n", + "We obtain this by ray-tracing the **outer border of the image mask**. These border pixels pass through the same lens equation as every other image pixel.\n", "\n", - "Let\u2019s highlight the border pixels in the image and follow the same indexes into the source plane. Border relocation\n", - "is still switched off here, so we are looking at the original ray-traced coordinates." + "Let\u2019s highlight them in the image plane, then follow the same indexes into the source plane. Relocation is still switched off, so we are looking at their original ray-traced positions." ] }, { @@ -440,13 +505,13 @@ "aaplt.plot_grid(\n", " grid=mapper_circular.image_plane_data_grid,\n", " indexes=[border_indexes],\n", - " title=\"Image-Plane Coordinates: Outer Border Highlighted\",\n", + " title=\"Image Plane: Outer Border\",\n", ")\n", "\n", "aaplt.plot_grid(\n", " grid=mapper_circular.source_plane_data_grid,\n", " indexes=[border_indexes],\n", - " title=\"Source-Plane Coordinates: Traced Outer Border Highlighted\",\n", + " title=\"Source Plane: Ray-Traced Outer Border\",\n", ")" ], "outputs": [], @@ -456,22 +521,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Compare the highlighted border with the central image pixels traced in the previous block. Some image coordinates\n", - "that were well inside the image mask can land beyond the traced outer border in the source plane.\n", + "Compare the traced border with the central pixels highlighted previously. Although those central pixels lie well inside the image mask, some land **outside the traced border** in the source plane.\n", "\n", - "This is the important geometric distinction: being inside the image-plane border does not guarantee that a\n", - "coordinate remains inside its traced counterpart.\n", + "This is possible because the lens mapping does not preserve the ordering of distances from the centre. Being inside the image border does not guarantee that a pixel traces inside its source-plane counterpart.\n", "\n", - "The border relocator uses the traced outer-border coordinates to estimate a source-plane boundary. In the current\n", - "implementation, this is an ellipse. Coordinates beyond that boundary are moved inward for the source\n", - "reconstruction, preventing them from extending its coverage unchecked.\n", + "The border relocator uses the traced border to estimate a source-plane boundary. Coordinates beyond that boundary are moved inward for the reconstruction, preventing them from forcing the mesh to extend far beyond the region we want to reconstruct.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Switching border relocation on__\n", "\n", "Let\u2019s repeat the circular-mask fit with border relocation enabled.\n", "\n", - "The data, mask, lens mass, mesh shape and regularization coefficient all remain unchanged. This time, the source\n", - "mesh is constructed using the relocated coordinates." + "We use exactly the same image pixels, lens model, mesh dimensions and regularization coefficient. The change is how the source reconstruction handles coordinates beyond the traced border." ] }, { @@ -493,7 +560,7 @@ " aaplt.plot_grid(\n", " grid=mapper.source_plane_data_grid,\n", " indexes=index_groups,\n", - " title=f\"{label}: Selected Source-Plane Coordinates\",\n", + " title=f\"{label}: Selected Source Coordinates\",\n", " )\n", "\n", " mesh_coordinates = np.asarray(mapper.source_plane_mesh_grid)\n", @@ -511,13 +578,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Follow the same coloured coordinate groups through the two plots. Coordinates outside the estimated boundary are\n", - "relocated towards it; those inside remain unchanged.\n", + "Follow the coloured groups between the plots. The distant coordinates move inward, while coordinates inside the relocation boundary retain their positions.\n", "\n", - "If relocation reduces the mesh extent, the same number of source points now covers a smaller area. This can restore\n", - "resolution in the region where the source is being reconstructed, without increasing the mesh dimensions.\n", + "When this reduces the mesh\u2019s extent, our `25 \u00d7 25` points cover a smaller area. Their spacing decreases, giving us finer resolution across the source without increasing the number of source brightness values.\n", "\n", - "We can also identify which coordinates moved and display their positions before and after relocation." + "Let\u2019s identify every coordinate that moved." ] }, { @@ -536,21 +601,22 @@ " coordinates_after - coordinates_before,\n", " axis=1,\n", ")\n", + "\n", "moved_indexes = np.flatnonzero(displacements > 1.0e-8).tolist()\n", "\n", - "print(\"Number of relocated image-pixel coordinates:\", len(moved_indexes))\n", + "print(\"Number of relocated coordinates:\", len(moved_indexes))\n", "\n", "if moved_indexes:\n", " aaplt.plot_grid(\n", " grid=mapper_circular.source_plane_data_grid,\n", " indexes=[moved_indexes],\n", - " title=\"Relocated Coordinates: Original Positions\",\n", + " title=\"Relocated Coordinates: Before\",\n", " )\n", "\n", " aaplt.plot_grid(\n", " grid=mapper_relocated.source_plane_data_grid,\n", " indexes=[moved_indexes],\n", - " title=\"Relocated Coordinates: Positions Used by the Mapper\",\n", + " title=\"Relocated Coordinates: After\",\n", " )" ], "outputs": [], @@ -560,19 +626,22 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "These are still the same image pixels with the same measured brightnesses. Relocation changes their source-plane\n", - "mapping coordinates; it does not remove them from the fit or move their positions in the observed image.\n", + "These coordinates still correspond to the same image pixels and measured brightnesses. We have changed where they map within the source reconstruction. We have not moved any observed image pixels or excluded them from the fit.\n", "\n", - "__What relocation changes\u2014and what it does not__\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__What relocation changes__\n", "\n", - "Border relocation is a numerical treatment of the source reconstruction. It does not change the lens mass profile\n", - "or the physical deflection calculation.\n", + "Does relocating these coordinates mean we have changed the lens model?\n", "\n", - "That distinction matters. If we ray-trace the image grid directly through the tracer, we recover the original\n", - "source coordinates. The mapper used by the relocated inversion instead contains the coordinates after the border\n", - "treatment.\n", + "No. The mass profile and its deflection angles are unchanged. Border relocation is applied when constructing the source reconstruction, after ray tracing.\n", "\n", - "Let\u2019s compare these explicitly." + "We can demonstrate this by ray-tracing the image coordinates directly through the tracer. This returns their original source-plane positions." ] }, { @@ -587,8 +656,8 @@ "physical_coordinates = np.asarray(physical_source_grid)\n", "\n", "print(\n", - " \"Largest difference between direct ray tracing and the \"\n", - " \"mapper without relocation:\",\n", + " \"Largest difference between direct ray tracing \"\n", + " \"and the mapper without relocation:\",\n", " np.max(\n", " np.linalg.norm(\n", " physical_coordinates - coordinates_before,\n", @@ -601,7 +670,7 @@ " index = moved_indexes[0]\n", "\n", " print(\"Image-pixel index:\", index)\n", - " print(\"Directly ray-traced position:\", physical_coordinates[index])\n", + " print(\"Ray-traced position:\", physical_coordinates[index])\n", " print(\"Position used after relocation:\", coordinates_after[index])" ], "outputs": [], @@ -611,11 +680,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We should therefore understand relocation as a practical boundary treatment, not an alternative physical lens\n", - "equation. It controls how extreme coordinates participate in a reconstruction whose image coverage is finite.\n", + "The first difference should be zero, apart from numerical precision. For a relocated pixel, the second comparison shows the distinction between its ray-traced position and the position used by the inversion.\n", + "\n", + "Relocation therefore changes the mapping used to reconstruct the source, and can change the predicted image even though the lens mass is fixed.\n", "\n", - "The next step is to inspect its effect on the inferred source and the model image. We use a common source colour\n", - "scale so that changes in brightness are not hidden by separately scaled figures." + "Let\u2019s inspect that effect. We use the same brightness scale for both source plots so that their colours remain comparable." ] }, { @@ -658,25 +727,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Look for changes in the mesh coverage, the source structure and the residuals. A more compact mesh alone does not\n", - "prove that the model is accurate, and relocation does not guarantee freedom from systematic errors.\n", + "Compare the spacing of the mesh points across the source. Does the smaller extent allow its brightness distribution to be represented more accurately? Are corresponding changes visible in the image residuals?\n", "\n", - "We have also kept the regularization coefficient fixed to isolate the change in configuration. As tutorial 2\n", - "showed, changing mesh geometry can alter how regularization acts, so this is a diagnostic comparison rather than a\n", - "complete optimization of both reconstructions.\n", + "We have kept the regularization coefficient fixed to show the effect of switching relocation on. As we learned in tutorial 2, changing the mesh geometry can also change the preferred coefficient. A full comparison would therefore repeat the regularization search for each setup.\n", "\n", + "Next, we will examine why border relocation still requires a mask that covers the lensed source." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__The mask still needs to cover the lensed source__\n", "\n", - "Border relocation does not replace choosing an appropriate image mask.\n", + "Border relocation helps us control the source mesh, but it cannot compensate for an image mask that excludes part of the lensed source.\n", "\n", - "If the mask excludes part of an arc or another image of the source, those pixels do not constrain the\n", - "reconstruction. Relocating source coordinates cannot recover information that we have excluded from the fit.\n", + "Suppose our mask cuts through an arc. The reconstruction only has to explain the pixels inside the mask; the excluded part of the arc contributes nothing to the fit. A reconstruction could therefore fit the selected pixels well while failing to explain the rest of the observed source.\n", "\n", - "The mask also determines the border from which the source-plane boundary is estimated. Changing its size therefore\n", - "changes both the image pixels being fitted and the boundary treatment.\n", + "The mask also determines which border coordinates we trace into the source plane. Changing its radius changes both the data being fitted and the boundary used for relocation.\n", "\n", - "Let\u2019s inspect several circular masks before fitting them. We plot their borders on the full image so that any\n", - "source light outside the selected region remains visible." + "Let\u2019s inspect three masks. We draw their borders on the **full image**, so that any source emission outside each mask remains visible." ] }, { @@ -698,7 +768,7 @@ " aplt.plot_array(\n", " array=dataset_unmasked.data,\n", " positions=mask.derive_grid.border,\n", - " title=f\"Full Image with Mask Border at {radius:.1f} Arcsec\",\n", + " title=f\"Full Image: Mask Radius {radius:.1f} Arcsec\",\n", " )" ], "outputs": [], @@ -708,10 +778,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Check whether each border encloses the visible lensed emission with some surrounding background. A mask that\n", - "follows only the brightest parts can exclude faint extensions that still matter to the reconstruction.\n", + "Check whether each border encloses the lensed emission, including its faint extensions, with some surrounding background. We should not choose a mask simply because it closely follows the brightest pixels.\n", "\n", - "Now compare the resulting fits with relocation enabled." + "Now reconstruct the source with each mask, keeping border relocation enabled." ] }, { @@ -735,7 +804,7 @@ " solution_vector=current_fit.inversion.reconstruction,\n", " mesh_grid=mapper.source_plane_mesh_grid,\n", " zoom_to_brightest=False,\n", - " title=f\"Source Reconstruction: Mask Radius {radius:.1f} Arcsec\",\n", + " title=f\"Source: Mask Radius {radius:.1f} Arcsec\",\n", " )\n", "\n", " aplt.subplot_fit_imaging(fit=current_fit)" @@ -747,34 +816,32 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Compare the source structure and residuals, especially where a smaller mask approaches the arcs. If the\n", - "reconstruction changes substantially as clearly relevant source light enters the mask, the smaller-mask result\n", - "deserves closer inspection.\n", + "Compare the source reconstructions and residuals. Does the source change substantially as additional lensed emission enters the fit? Does a smaller mask hide a region where the model struggles?\n", + "\n", + "These masks contain different image pixels, so their absolute log evidences should not be used to select the mask. Our aim is to include the relevant emission and understand how the reconstruction responds to the chosen boundary.\n", "\n", - "Do not rank these masks by their raw evidence values: they contain different sets of image pixels. The purpose here\n", - "is to examine coverage and stability, not to select whichever mask produces the largest number.\n", + "A larger mask can also spread the fixed number of mesh points over a larger source-plane region. If the reconstruction changes, inspect the mesh spacing as well as the newly included data.\n", "\n", - "**\ud83e\udd16 Ask AI:** Why can a reconstruction appear satisfactory inside a small mask while failing to explain other\n", - "visible parts of the same lensed source?\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Wrap Up__\n", "\n", - "__Wrap up__\n", + "The resolution of a rectangular source mesh depends on both its number of points and the area they cover.\n", "\n", - "We began with a rectangular source mesh whose resolution depended on both its number of points and the area they\n", - "covered. A small number of extreme ray-traced coordinates could expand that area and reduce the resolution\n", - "available for the source.\n", + "We saw that central image pixels can trace far from the source galaxy. Without border relocation, these coordinates can expand the mesh, leaving fewer points across the source itself.\n", "\n", - "The border relocator uses the traced outer image border to estimate a source-plane boundary, then moves coordinates\n", - "beyond that boundary inward for the reconstruction. The observed image pixels remain in the fit, and the physical\n", - "lens model is unchanged.\n", + "Border relocation uses the traced outer image border to estimate a source-plane boundary. Coordinates beyond that boundary are moved inward when constructing the source reconstruction. The observed image pixels remain in the fit, and the lens mass model is unchanged.\n", "\n", - "In routine modelling, we normally leave this treatment enabled. Knowing what it does is useful when a source mesh\n", - "extends unexpectedly far from the reconstructed galaxy, or when a reconstruction changes strongly with the mask.\n", + "We will keep border relocation enabled in the fits that follow. If a mesh extends unexpectedly far from the reconstructed source, we now know how to investigate: follow the image pixels into the source plane and inspect which coordinates determine its extent.\n", "\n", - "These issues can also occur in systems with several lens galaxies. Extreme coordinates need not originate only near\n", - "the centre of the image; they can be associated with other mass concentrations. The same diagnostic applies: follow\n", - "the image coordinates into the source plane and inspect the boundary and mesh coverage.\n", + "This can also matter for lenses containing several mass concentrations. The pixels responsible for distant source-plane coordinates need not all lie near the centre of the image.\n", "\n", - "For subsequent inspection, return to our circular mask with radius `2.5` arcsec and relocation enabled." + "Let\u2019s finish by returning to our circular mask with radius `2.5` arcsec." ] }, { @@ -793,7 +860,7 @@ " solution_vector=fit.inversion.reconstruction,\n", " mesh_grid=mapper.source_plane_mesh_grid,\n", " zoom_to_brightest=False,\n", - " title=\"Source Reconstruction with Border Relocation Enabled\",\n", + " title=\"Source Reconstruction with Border Relocation\",\n", ")" ], "outputs": [], @@ -803,12 +870,18 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We have now examined the source mesh, interpolation, regularization and border treatment while holding the lens\n", - "mass fixed.\n", + "So far, we have investigated the source mesh, interpolation, regularization and borders while holding the lens mass fixed.\n", + "\n", + "Next, we will let the mass model vary. This introduces another challenge: a flexible source reconstruction can sometimes produce a close image fit even when the lens mass model is wrong.\n", "\n", - "In the next tutorial, we let the mass model vary. We will see how a pixelized source participates in lens\n", - "modelling, why its flexibility can admit misleading solutions, and how a sensible starting model helps the search\n", - "find the relevant region of parameter space." + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**\ud83e\udd16 Ask AI:** Why can adding central image pixels reduce the useful resolution of a rectangular source mesh, and what does border relocation change?" ] } ], diff --git a/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb b/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb index 53a040b..463b9b3 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb @@ -7,6 +7,8 @@ "Tutorial 4: Lens Modeling\n", "=========================\n", "\n", + "Fit lens mass and foreground light alongside a pixelized source using search chaining and a positions likelihood penalty.\n", + "\n", "__Contents__\n", "\n", "- Lens modelling with a pixelized source\n", @@ -21,36 +23,7 @@ "- Running the pixelized-source search\n", "- Establishing the foreground light model\n", "- Using that result in the pixelized-source fit\n", - "\n", - "__Lens modelling with a pixelized source__\n", - "\n", - "So far, we have reconstructed sources while keeping the lens mass fixed. We could therefore concentrate on the\n", - "mesh, mappings and regularization without worrying about whether the lens model itself was correct.\n", - "\n", - "In a scientific analysis, the mass distribution is usually one of the things we want to measure. Changing it\n", - "changes the ray tracing, which changes how the source must be reconstructed to explain the image.\n", - "\n", - "Pixelizations are powerful here because they can describe complicated source structure without requiring us to\n", - "specify a separate analytic profile for every bright component. The non-linear search explores the lens parameters\n", - "and regularization settings, while the inversion solves for the source brightnesses.\n", - "\n", - "However, this flexibility comes with a difficulty. An incorrect mass model can sometimes be compensated for by an\n", - "unusual source reconstruction. A close fit to the image does not, by itself, establish that we have found the right\n", - "mass distribution.\n", - "\n", - "We will investigate this using the familiar simulated lens with no foreground light. Its true Einstein radius is\n", - "`1.6` arcsec, giving us a reference against which to compare deliberately incorrect models.\n", - "\n", - "The published example below makes this ambiguity visible before we explore it in code. Compare the reconstructed\n", - "source structures with their corresponding image fits: a model can describe much of the observed image while\n", - "assigning it a very different source morphology.\n", - "\n", - "![Demagnified source solutions, Figure 1 of Maresca et\n", - "al.](https://raw.githubusercontent.com/PyAutoLabs/HowToLens/main/scripts/chapter_3_pixelizations/images/maresca_fig1.png)\n", - "\n", - "Figure 1 from [Maresca et al. (2021)](https://arxiv.org/abs/2012.04665), also discussed in the [PyAutoLens\n", - "demagnified-solutions guide](https://pyautolens.readthedocs.io/en/latest/general/demagnified_solutions.html). We\n", - "reproduce this figure to illustrate the ambiguity; the experiments below use our own simulated dataset." + "- Wrap Up" ] }, { @@ -91,6 +64,31 @@ "_setup_colab.setup(\"howtolens\")" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Lens modelling with a pixelized source__\n", + "\n", + "In the previous tutorials, we supplied the mass model used to simulate the image. This let us concentrate on reconstructing the source.\n", + "\n", + "For an observed lens, we usually do not know the mass distribution. Measuring it is one of the main reasons we perform lens modelling!\n", + "\n", + "Changing the mass model changes where the image pixels trace in the source plane. For every proposed mass model, we must therefore reconstruct the source again and calculate the corresponding log evidence.\n", + "\n", + "Pixelizations are useful because they can describe complicated source structure without requiring us to choose a separate light profile for every component. However, this flexibility also creates a difficulty: **an incorrect mass model can sometimes fit the image by reconstructing an unusual source**.\n", + "\n", + "For example, instead of tracing several arcs back to a common source component, an incorrect model may reconstruct separate source features to explain them. The image fit can look convincing even though the inferred source and lens mass are wrong.\n", + "\n", + "This problem is illustrated by Figure 1 from [Maresca et al. (2021)](https://arxiv.org/abs/2012.04665).\n", + "\n", + "![Demagnified source reconstructions, Figure 1 of Maresca et al. (2021)](https://raw.githubusercontent.com/PyAutoLabs/HowToLens/main/scripts/chapter_3_pixelizations/images/maresca_fig1.png)\n", + "\n", + "Figure 1 from Maresca et al. (2021). Compare the reconstructed sources with their corresponding image fits. The experiments below use our own simulated dataset. See also the [PyAutoLens demagnified-solutions guide](https://pyautolens.readthedocs.io/en/latest/general/demagnified_solutions.html).\n", + "\n", + "We return to the familiar lens with no foreground light. Its true Einstein radius is `1.6` arcsec, giving us a reference against which to compare deliberately incorrect models." + ] + }, { "cell_type": "code", "metadata": {}, @@ -99,8 +97,6 @@ "from autolens import jax_wrapper\n", "\n", "from pathlib import Path\n", - "import subprocess\n", - "import sys\n", "\n", "import numpy as np\n", "import matplotlib.pyplot as plt\n", @@ -111,7 +107,8 @@ "dataset_path = Path(\"dataset\") / \"imaging\" / \"simple__no_lens_light\"\n", "\n", "al.util.dataset.simulate_if_needed(\n", - " dataset_path, simulator=\"scripts/simulator/no_lens_light.py\"\n", + " dataset_path,\n", + " simulator=\"scripts/simulator/no_lens_light.py\",\n", ")\n", "\n", "dataset = al.Imaging.from_fits(\n", @@ -139,19 +136,22 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We retain one sample per image pixel for the indexed mapping demonstration below. As before, the sampling accuracy\n", - "would need checking before interpreting a scientific fit.\n", + "We use one sample per image pixel to make the mapping demonstrations easy to follow. Before interpreting a scientific fit, we would check that finer sampling does not materially change the result.\n", "\n", + "First, we will deliberately give the inversion the wrong Einstein radius and inspect how it attempts to explain the image." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Giving the inversion an incorrect mass model__\n", "\n", - "Let\u2019s begin with a mass model whose Einstein radius is `0.8` arcsec\u2014half the value used in the simulation.\n", + "Let\u2019s deliberately give the inversion an Einstein radius of `0.8` arcsec\u2014half the value used to simulate the image.\n", "\n", - "Everything else remains fixed, including the mass ellipticity, external shear, mesh dimensions and regularization\n", - "coefficient. We then ask the inversion to reconstruct whatever source best satisfies this model\u2019s mappings and\n", - "smoothness prior.\n", + "We keep the mass ellipticity and external shear at their simulation values. We also fix the mesh dimensions and regularization coefficient. This lets us investigate what happens when just the Einstein radius is wrong.\n", "\n", - "This is the kind of incorrect mass model that a non-linear search could encounter while exploring a broad parameter\n", - "range." + "The helper below reconstructs the source for whichever Einstein radius we supply." ] }, { @@ -159,8 +159,8 @@ "metadata": {}, "source": [ "\n", - "# Match mesh size to capped smoke data; normal tutorial runs retain 25 x 25.\n", - "mesh_shape = (8, 8) if dataset.shape_native == al.util.dataset.SMALL_DATASETS_SHAPE_NATIVE else (25, 25)\n", + "mesh_shape = (25, 25)\n", + "\n", "\n", "def fit_at_einstein_radius(einstein_radius, coefficient=1.0):\n", " lens_galaxy = al.Galaxy(\n", @@ -215,26 +215,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Inspect both the image residuals and the reconstructed source. The experiment is designed to show how an inversion\n", - "can explain some of the observed structure even when the lens mass is substantially wrong.\n", + "Inspect the reconstructed source alongside the model image and residuals. How much of the observed image can the inversion reproduce despite using the wrong mass model?\n", "\n", - "Under insufficient deflection, parts of the observed arcs can remain separated in the source plane. Instead of\n", - "reconstructing one compact galaxy whose light produces multiple images, the inversion can assign brightness to\n", - "those separate source regions. The source may begin to resemble a less strongly lensed version of the observed\n", - "image.\n", + "With insufficient deflection, the observed arcs do not trace back towards one compact source. They remain more widely separated in the source plane. The inversion can nevertheless assign brightness to those separate regions, reconstructing a source that begins to resemble a less strongly lensed version of the image.\n", "\n", - "Such solutions are often called **demagnified solutions**. This is a different issue from the demagnified central\n", - "image coordinates discussed in the borders tutorial: here, the problem concerns an alternative mass-and-source\n", - "interpretation of the image.\n", + "This type of incorrect mass-and-source reconstruction is called a **demagnified solution**. The inversion uses its freedom to describe the source to compensate for an incorrect mass model.\n", "\n", - "Border relocation does not remove this modelling ambiguity.\n", + "This differs from the demagnified central pixels in the borders tutorial. There, we investigated individual coordinates that stretched the mesh. Here, we have an alternative interpretation of the whole lens system. Border relocation is enabled, but it does not prevent this solution.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Comparing with the correct mass model__\n", "\n", - "Now repeat the reconstruction with the Einstein radius restored to `1.6` arcsec.\n", + "Now restore the Einstein radius to `1.6` arcsec and reconstruct the source again.\n", "\n", - "The inversion still has the same mesh dimensions and regularization coefficient. Only the Einstein radius changes.\n", - "This gives us a controlled comparison of how the mass model affects the source required to explain the image." + "Only the Einstein radius changes. We use the same mesh dimensions and regularization coefficient, and display both reconstructions with a common brightness scale." ] }, { @@ -277,15 +277,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Compare the source morphology and its coordinate extent. With the correct mass model, the different images of the\n", - "source should be brought into a mutually consistent source reconstruction. With an incorrect mass model, the\n", - "inversion may need more extended or fragmented structure to account for them.\n", + "Compare the size and structure of the two sources. With the correct mass model, the multiple images can be explained by a common compact source. With the incorrect mass model, the inversion may need a more extended or fragmented brightness distribution.\n", "\n", - "This is where regularization and evidence become important. The inversion\u2019s flexibility does not mean that all\n", - "source reconstructions are equally preferred. A reconstruction requiring large variations between neighbouring\n", - "brightnesses is treated differently from one that explains the data with a smoother source.\n", + "Recall our discussion of Bayesian evidence. A close image fit is only part of the comparison: the evidence also accounts for the source reconstruction and its regularization prior.\n", "\n", - "Let\u2019s compare image agreement and evidence directly." + "Let\u2019s compare both chi-squared and log evidence." ] }, { @@ -312,22 +308,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The expected lesson is that image agreement and source plausibility must be considered together. We should inspect\n", - "the calculated evidence values rather than assume that the correct model wins by a particular amount.\n", + "Which model gives the closer image fit? Which gives the higher evidence? Connect these numbers back to the source and residual plots.\n", "\n", - "We have also held the coefficient fixed. A full model search would allow regularization to change, so these two\n", - "evaluations do not establish the final relative preference after all model parameters have been explored.\n", + "We have evaluated only two mass models at one regularization coefficient. As tutorial 2 showed, the preferred coefficient can change when the reconstruction changes. These numbers are therefore a first comparison, rather than the outcome of a full search over the mass and regularization parameters.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Seeing how the mass model changes the mappings__\n", "\n", - "The source plots show the outcome, but the mappings help explain why it happens.\n", + "The source reconstructions show the result. Let\u2019s now inspect the mappings that produce it.\n", "\n", - "Let\u2019s select two small image regions on opposite sides of the lens and follow them into the source plane under each\n", - "mass model. The image indexes stay the same: we are examining the same measured pixels through two different\n", - "ray-tracing calculations.\n", + "We select two small groups of image pixels on opposite sides of the lens. We then trace the **same pixels** through each mass model, keeping their colours consistent between plots.\n", "\n", - "To isolate the physical ray tracing, we use the tracer directly. These plots show the coordinates before border\n", - "relocation." + "Here we use the tracer directly, so the plots show the ray-traced coordinates before border relocation." ] }, { @@ -351,7 +349,7 @@ "aaplt.plot_grid(\n", " grid=image_grid,\n", " indexes=index_groups,\n", - " title=\"The Same Two Image Regions\",\n", + " title=\"Two Selected Image Regions\",\n", ")\n", "\n", "for label, current_fit in [\n", @@ -375,30 +373,36 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Look at the relative positions of the coloured groups. Changing the mass model changes whether image regions can be\n", - "explained by nearby parts of the source or require brightness in more widely separated regions.\n", + "Follow the coloured groups. The mass model determines whether these image regions trace to nearby parts of the source or require brightness in widely separated regions.\n", "\n", - "These groups were chosen to illustrate the geometry, not identified as exact images of the same source feature. We\n", - "should therefore not expect their traced coordinates to coincide perfectly, even for the correct model.\n", + "The inversion must work with whichever mappings we give it. It cannot change the mass model to bring the groups closer together; it can only change the source brightness values. Exploring different mass models is the job of the non-linear search.\n", "\n", - "For a stronger constraint, we can identify image positions that genuinely correspond to the same source feature and\n", - "require them to trace close together. We will introduce that idea when preparing the model search.\n", + "We selected these groups by their image positions, rather than identifying exact multiple images of the same source feature. Their traced coordinates therefore need not coincide perfectly, even under the correct model.\n", "\n", - "**\ud83e\udd16 Ask AI:** How can an incorrect mass model still fit an arc if the source reconstruction is allowed to place\n", - "light in several separate source regions?\n", + "Later, we will identify positions that **do** correspond to the same source feature and require them to trace close together. That gives us a way to steer the search away from demagnified solutions.\n", "\n", + "First, we will examine why calculating the evidence does not, by itself, ensure that a search finds the correct mass model." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Why evidence does not remove the search difficulty__\n", "\n", - "Even if the correct mass model has higher evidence than our deliberately incorrect example, the non-linear search\n", - "still has to find it.\n", + "Suppose the correct mass model gives a higher log evidence than our incorrect example. Does that mean a non-linear search will automatically find it?\n", + "\n", + "Unfortunately, no. The physically meaningful solution can occupy a very small region of the non-linear parameter space. The search must find a combination of mass parameters that traces the different arcs back to a common source.\n", + "\n", + "Incorrect mass models occupy a much larger volume of parameter space. With a pixelized source, many of these models can still reproduce much of the image: the inversion compensates for the incorrect deflections by placing source light in separate regions.\n", + "\n", + "This creates a huge landscape of competing local maxima. There are many ways to obtain a reasonably good image fit with an incorrect mass model and an unphysical source reconstruction, while the region containing the correct solution can be narrow and difficult to discover.\n", "\n", - "A pixelized source can provide tolerable image fits across a broad range of incorrect mass models. This can create\n", - "competing regions of parameter space that require substantial exploration. Solving the source brightnesses\n", - "efficiently does not make the remaining non-linear problem trivial or guarantee that a search finds the relevant\n", - "solution.\n", + "A search started with broad priors can therefore spend most of its effort exploring these incorrect solutions and converge on one of them without finding the physically meaningful solution\u2014even when that solution has a higher evidence. For difficult systems, this can happen repeatedly across independent searches.\n", "\n", - "Let\u2019s examine a simple slice through this problem by changing only the Einstein radius. At every value, we\n", - "reconstruct a new source and calculate its evidence." + "Efficiently solving the source brightnesses does not solve this search problem. We need to help the non-linear search reach the relevant region of mass-model parameter space before giving the source the full flexibility of a pixelization.\n", + "\n", + "Let\u2019s explore this by varying the Einstein radius while holding the other parameters fixed. At every radius, we reconstruct the source again and calculate the log evidence." ] }, { @@ -432,11 +436,10 @@ "log_evidences = np.asarray(log_evidences)\n", "\n", "if not np.all(np.isfinite(log_evidences)):\n", - " raise ValueError(\n", - " \"Inspect the non-finite evidence values before interpreting this scan.\"\n", - " )\n", + " raise ValueError(\"Inspect non-finite evidence values before interpreting the scan.\")\n", "\n", "fig, ax = plt.subplots()\n", + "\n", "ax.plot(\n", " einstein_radii,\n", " log_evidences - np.max(log_evidences),\n", @@ -448,10 +451,11 @@ " linestyle=\"--\",\n", " label=\"Simulation Einstein radius\",\n", ")\n", + "\n", "ax.set_xlabel(\"Einstein radius (arcsec)\")\n", "ax.set_ylabel(\"Log evidence relative to the highest sampled value\")\n", - "ax.set_title(\"A Fixed-Parameter Slice Through Lens-Model Space\")\n", "ax.legend()\n", + "\n", "plt.show()" ], "outputs": [], @@ -461,42 +465,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This scan shows how the evidence behaves along one restricted direction. It is not a posterior distribution or an\n", - "uncertainty measurement: the other mass parameters and regularization coefficient remain fixed, and we have sampled\n", - "only a few radii.\n", - "\n", - "In a full search, those other parameters can vary together. An incorrect Einstein radius may be partly compensated\n", - "for by changes in ellipticity, shear or source regularization, creating possibilities that this one-dimensional\n", - "experiment cannot show.\n", - "\n", - "A useful strategy is therefore **search chaining**. We begin with a simpler source model to find a plausible region\n", - "of lens parameter space, then introduce the pixelized source so that the detailed source structure can be\n", - "reconstructed.\n", - "\n", - "The initial analytic source is a starting point, not the final description of the galaxy. Its purpose is to help\n", - "establish the lensing geometry without giving the source enough freedom to reproduce almost any image arrangement.\n", - "\n", - "In the next blocks, we will turn that idea into a model-search setup and explain how corresponding image positions\n", - "can help reject misleading solutions. We will also introduce foreground lens light, which must be modelled\n", - "alongside the pixelized source.\n", + "This one-dimensional scan greatly understates the difficulty of the full search. We have fixed the mass ellipticity, shear and regularization coefficient, whereas a real search varies them together. The resulting parameter space can contain many competing maxima that are absent from this simple slice.\n", "\n", + "This is why we begin with an MGE source and use search chaining. Its restricted geometry makes it harder to compensate for an incorrect mass model by reconstructing arbitrary source structure. We first establish a plausible lensing geometry, then introduce the pixelization and additional constraints to help keep the search away from unphysical solutions." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Starting with a simpler source model__\n", "\n", - "The misleading reconstructions we have just examined arise because a pixelized source has enough freedom to\n", - "compensate for some incorrect mass models.\n", + "We will begin with the multi-Gaussian expansion (MGE) from Chapter 2.\n", "\n", - "A useful starting point is therefore a source model with less freedom. We first fit an analytic light profile,\n", - "using it to establish a plausible lensing geometry. Once that fit has identified a useful region of parameter\n", - "space, we replace the analytic source with a pixelization.\n", + "The Gaussians have different fixed widths, allowing their combined light to describe a flexible radial brightness distribution. Their intensities are solved linearly. However, they share a common centre and elliptical shape, so they cannot independently place source components wherever an incorrect mass model requires them.\n", "\n", - "This is search chaining, introduced in chapter 2. Here, the reason for chaining is particularly important: we want\n", - "the pixelized reconstruction to improve the description of the source without beginning its search among a wide\n", - "range of unrelated mass-and-source configurations.\n", + "This makes the MGE a useful starting point for establishing the lensing geometry. It may leave some complicated source structure unexplained, but we can introduce the pixelization once we have inspected the initial fit.\n", "\n", - "We use the multi-Gaussian expansion (MGE) from Chapter 2 as our baseline analytic source. Its fixed Gaussian widths\n", - "and linearly solved intensities provide radial flexibility while a common centre and elliptical shape keep the\n", - "geometry manageable. For a more complex galaxy, this first model may leave source structure unexplained. We would\n", - "inspect those residuals and the inferred geometry before using it to initialize the next search." + "Let\u2019s construct the model." ] }, { @@ -508,7 +494,6 @@ "\n", "mass = af.Model(al.mp.Isothermal)\n", "\n", - "# Keep the mass centre fixed for this introductory experiment.\n", "mass.centre = (0.0, 0.0)\n", "mass.einstein_radius = af.UniformPrior(\n", " lower_limit=0.5,\n", @@ -531,7 +516,9 @@ " al.Galaxy,\n", " redshift=1.0,\n", " bulge=al.model_util.mge_model_from(\n", - " mask_radius=2.5, total_gaussians=20, centre_prior_is_uniform=False,\n", + " mask_radius=2.5,\n", + " total_gaussians=20,\n", + " centre_prior_is_uniform=False,\n", " ),\n", ")\n", "\n", @@ -552,20 +539,24 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Unlike our earlier experiments, the Einstein radius, mass ellipticity and external shear are now free. The MGE\n", - "group\u2019s common centre and elliptical shape are also free. Its Gaussian widths are fixed, and all Gaussian\n", - "intensities are solved linearly.\n", + "Unlike the earlier experiments, the Einstein radius, mass ellipticity and external shear are now free parameters. We keep the mass centre fixed for this introductory example.\n", + "\n", + "The MGE\u2019s common centre and elliptical shape are also free. Its Gaussian widths remain fixed, and the inversion solves for the Gaussian intensities at every non-linear proposal.\n", "\n", - "Inspect `model_1.info` to see the complete set of free parameters and their priors. The known simulation values\n", - "provide a check on the result; they are not supplied as the answer to the search.\n", + "Inspect `model_1.info` to see these parameters and their priors. Although we know the simulated Einstein radius is `1.6` arcsec, we have not supplied that answer to the search: it must infer the radius from the image.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Running and inspecting the initial search__\n", "\n", - "We now perform a model fit. For each proposal, the search changes the non-linear parameters, predicts the image and\n", - "evaluates its agreement with the data.\n", + "We are now ready to run the first search.\n", "\n", - "This is different from calling `FitImaging` with specified parameter values. The search explores alternatives and\n", - "produces samples that describe the inferred parameter distribution." + "Previously, we supplied parameter values directly to `FitImaging`. Here, Nautilus proposes different combinations of mass and source parameters. For each proposal, the Gaussian intensities are solved linearly and the resulting image is compared with the data." ] }, { @@ -601,34 +592,34 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Before continuing, inspect the model image, residuals and source. The purpose of this first fit is to establish a\n", - "credible starting geometry, so a poor or clearly misleading solution should not simply be passed to the next\n", - "search.\n", + "Inspect the model image, residuals and reconstructed source before continuing.\n", "\n", - "For this simulated example, we can also compare the recovered Einstein radius with the input value of `1.6` arcsec.\n", - "In an observed system, that answer would not be available; we would rely on the fit diagnostics, image\n", - "configuration and parameter constraints.\n", + "Does the model reproduce the positions and overall shapes of the arcs? Does the source look like a compact galaxy whose multiple images explain the observed configuration? Or has the search found a misleading arrangement?\n", "\n", - "The maximum-likelihood fit is useful for visualization, but it is only one model. The result summary and samples\n", - "are needed to assess uncertainty and whether the search adequately explored the parameters.\n", + "For this simulated example, we can also compare the inferred Einstein radius with `1.6` arcsec. In an observed lens, we would not have that answer available.\n", "\n", + "The initial MGE need not reproduce every detail of a complicated source. However, a fit that misses an arc or gives a clearly incorrect lensing configuration is not a suitable starting point for the next search. Passing its parameters forward could steer the pixelized fit towards the same problem.\n", + "\n", + "Finally, remember that the plotted maximum-likelihood fit is one model. Use the result summary and parameter distributions, as in Chapter 2, to assess the constraints and whether further exploration is needed.\n", + "\n", + "Next, we will use corresponding image positions to help the pixelized-source search avoid demagnified solutions." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Requiring corresponding images to trace together__\n", "\n", - "Search chaining gives us a useful starting point, but we can also impose a simple geometric check.\n", + "In Chapter 2, Tutorial 5, we introduced positions resampling and the **positions likelihood penalty**. We now use that same approach to help the pixelized-source search avoid the unphysical solutions discussed above.\n", "\n", - "If several image positions represent the same source feature, a plausible mass model should trace them close\n", - "together in the source plane. An incorrect model that reconstructs them as unrelated pieces of source light fails\n", - "this check.\n", + "Recall the underlying idea: image positions corresponding to the same source feature should trace close together in the source plane. An incorrect mass model may leave them widely separated, allowing a pixelized reconstruction to explain them by placing unrelated source features in different locations.\n", "\n", - "The `PositionsLH` object applies a penalty when the traced positions are separated by more than a chosen threshold.\n", - "Models that bring them within the threshold receive no positions penalty.\n", + "The positions likelihood penalty discourages these solutions. When the maximum separation between the traced positions exceeds our threshold, the model receives a likelihood penalty. Models that bring the positions within the threshold receive no positions penalty.\n", "\n", - "`PositionsLH` penalizes demagnified solutions whose corresponding image positions fail to trace sufficiently close\n", - "together.\n", + "Search chaining provides informed starting priors, while this penalty helps prevent the search from wandering into the much larger regions of parameter space occupied by unphysical reconstructions.\n", "\n", - "We can identify corresponding positions manually, but our initial fit also provides a way to estimate them. The\n", - "following method uses the fitted source centre and mass model to predict its multiple-image positions and\n", - "constructs a positions constraint for the next search." + "Following Chapter 2, Tutorial 5, we use the initial fit to generate the positions and threshold for the next search. Here, those positions are predicted from the fitted MGE source centre and mass model." ] }, { @@ -663,7 +654,7 @@ "\n", "aaplt.plot_grid(\n", " grid=traced_positions,\n", - " title=\"Predicted Image Positions Traced to the Source Plane\",\n", + " title=\"Predicted Positions Traced to the Source Plane\",\n", ")" ], "outputs": [], @@ -673,27 +664,30 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "These positions are predictions from the initial model, not independent measurements. Check that they correspond\n", - "sensibly to the observed image configuration before using them.\n", + "Inspect the marked positions on the image. They come from our fitted model, so we should check that they sensibly follow the observed multiple-image configuration before using them.\n", + "\n", + "We allow some source-plane separation because the initial mass model is approximate. The next search must have room to improve it.\n", "\n", - "The threshold should be loose enough to allow the mass model to improve. We are trying to reject grossly\n", - "inconsistent mappings, not force every subsequent model to reproduce the initial solution exactly. Here, `factor`\n", - "enlarges the separation estimated from the initial result, while `minimum_threshold` prevents the returned\n", - "threshold from becoming smaller than `0.2` arcsec.\n", + "The `factor` enlarges the separation used to set the threshold from the initial result. The `minimum_threshold` prevents that threshold from becoming smaller than `0.2` arcsec. These are source-plane distances, not distances between the observed images.\n", "\n", - "This positions penalty is a modelling aid rather than a replacement for the image likelihood. It does not by itself\n", - "establish that the mass model is correct, and an inappropriate set of positions or an overly restrictive threshold\n", - "can exclude useful solutions.\n", + "The purpose is to exclude grossly inconsistent mappings while allowing useful changes to the mass model. An excessively tight threshold could instead force the second search to remain too close to an imperfect initial fit.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Replacing the analytic source with a pixelization__\n", "\n", "We are ready to construct the second model.\n", "\n", - "The lens mass and shear remain free, but their priors are initialized from the first result using the prior-passing\n", - "machinery introduced in chapter 2. The source profile is replaced by a rectangular mesh and a regularization model.\n", + "We retain the lens mass and external shear from the initial result, using the prior-passing machinery introduced in Chapter 2. We then replace the MGE source with a rectangular pixelization.\n", + "\n", + "The mass parameters remain free. We want the first fit to guide the second search towards the relevant region of parameter space, while allowing the improved source description to change the inferred mass model.\n", "\n", - "The distinction between `result_1.model` and `result_1.instance` matters here. Using the model carries free\n", - "parameters into the next search. Using the instance would instead fix those components to particular fitted values." + "This is why we use `result_1.model`, rather than `result_1.instance`. The model carries free parameters and their updated priors into the next search; the instance would fix them to particular fitted values." ] }, { @@ -702,6 +696,7 @@ "source": [ "\n", "regularization = af.Model(al.reg.Constant)\n", + "\n", "regularization.coefficient = af.LogUniformPrior(\n", " lower_limit=1.0e-3,\n", " upper_limit=1.0e3,\n", @@ -736,23 +731,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Inspect the resulting priors rather than assuming that they are suitable simply because they came from an earlier\n", - "fit. In particular, a restrictive analytic source can bias an initial mass estimate; its result should guide the\n", - "next search without preventing the mass model from changing.\n", + "The regularization coefficient is now a non-linear parameter, explored alongside the mass and shear parameters. Its logarithmic prior allows the search to explore several orders of magnitude in smoothing strength.\n", "\n", - "The mesh shape stays fixed. The regularization coefficient is now a non-linear parameter, explored alongside the\n", - "mass and shear parameters.\n", + "The mesh dimensions remain fixed. Its hundreds of source brightness values are solved by the inversion at every proposal; they do not become hundreds of additional non-linear parameters.\n", "\n", - "The hundreds of source brightnesses do not appear as hundreds of additional search parameters. For each proposal,\n", - "the inversion reconstructs those values using the proposed mass model and coefficient.\n", + "Inspect the priors printed by `model_2.info`. The initial MGE may have left source structure unexplained, potentially shifting its preferred mass model. Prior passing should guide the pixelized search without restricting it so tightly that it cannot correct that initial estimate.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Running the pixelized-source search__\n", "\n", - "The second search combines the pixelized model with the positions constraint.\n", + "We now combine the pixelized model with the **positions likelihood penalty**, following the same procedure as Chapter 2, Tutorial 5.\n", + "\n", + "Search chaining and the penalty serve complementary purposes. Prior passing guides the search towards the initial mass solution. The positions likelihood penalty discourages proposals that trace corresponding image positions too far apart.\n", "\n", - "For each proposed mass model, the analysis checks the positions condition and evaluates the source reconstruction\n", - "as appropriate. The search can adjust the lens parameters and regularization coefficient, while each inversion\n", - "solves for the source brightnesses." + "For proposals within the threshold, no positions penalty is applied, and the pixelized imaging log evidence determines their fit statistic. The inversion reconstructs the source using each proposed mass model and regularization coefficient." ] }, { @@ -792,15 +790,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Compare this result with the analytic-source fit. Inspect the source structure and image residuals, then examine\n", - "how the inferred mass parameters and their uncertainties have changed.\n", + "Compare the source reconstruction and residuals with the initial MGE fit. Then inspect the inferred mass parameters and their uncertainties.\n", "\n", - "For this simple simulated source, an MGE can already describe its smooth light distribution, so a pixelization need\n", - "not produce a dramatic improvement. Its value becomes clearer for irregular sources that cannot be described\n", - "adequately by the initial profile. This example teaches the modelling procedure without requiring that extra\n", - "complication.\n", + "Our simulated source is smooth, so the MGE may already describe it well. We should not expect a dramatic improvement simply because we introduced a pixelization. For irregular sources, its additional spatial freedom becomes more useful.\n", "\n", - "We can also check whether the displayed pixelized model satisfies the positions threshold." + "Let\u2019s also check whether the displayed model brings our selected image positions within the source-plane threshold." ] }, { @@ -813,22 +807,16 @@ ")[1]\n", "\n", "coordinates = np.asarray(final_source_positions)\n", + "\n", "separations = np.linalg.norm(\n", " coordinates[:, None, :] - coordinates[None, :, :],\n", " axis=-1,\n", ")\n", + "\n", "maximum_separation = float(np.max(separations))\n", "\n", - "print(\n", - " \"Maximum source-plane separation:\",\n", - " maximum_separation,\n", - " \"arcsec\",\n", - ")\n", - "print(\n", - " \"Positions threshold:\",\n", - " positions_likelihood.threshold,\n", - " \"arcsec\",\n", - ")\n", + "print(\"Maximum source-plane separation:\", maximum_separation, \"arcsec\")\n", + "print(\"Positions threshold:\", positions_likelihood.threshold, \"arcsec\")\n", "print(\n", " \"Within threshold:\",\n", " maximum_separation <= positions_likelihood.threshold,\n", @@ -841,47 +829,38 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Passing this check means the selected positions trace sufficiently close together under this model. It does not\n", - "replace inspecting the residuals, uncertainties or search behaviour.\n", - "\n", - "We have now performed a chained fit with a pixelized source and a free mass model. The next blocks introduce\n", - "foreground lens light, explaining how its analytic profiles are fitted alongside the source reconstruction and why\n", - "errors in that light model can affect the inferred source.\n", + "Passing this check confirms that these selected positions trace sufficiently close together. We still need to inspect the image residuals, parameter constraints and search behaviour to assess the overall fit.\n", "\n", + "We have now fitted a free mass model and a pixelized source using search chaining. Next, we will include the foreground lens galaxy\u2019s light and examine how it is fitted alongside the source reconstruction." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Establishing the foreground light model__\n", "\n", - "We can model the foreground lens galaxy\u2019s light and reconstruct a pixelized background source within the same fit.\n", - "The foreground light profile and the lensed source contribute together to the predicted image.\n", + "So far, our images have contained only the lensed source. In most observed systems, the foreground lens galaxy also contributes light, often overlapping the arcs.\n", "\n", - "However, fitting everything from the outset gives the search several problems to solve at once. It must establish\n", - "the mass distribution, separate foreground light from source light, and choose an appropriate source\n", - "reconstruction.\n", + "We can fit both contributions together. The predicted image is the sum of the foreground galaxy\u2019s light and the lensed background source, including telescope blurring.\n", "\n", - "Search chaining helps us approach these problems in stages. We begin with MGE light models for both galaxies,\n", - "following Chapter 2. This simpler source description provides a useful starting lens geometry and lets us establish\n", - "the foreground light model before introducing a pixelization.\n", + "However, this gives the search several things to determine at once: the mass distribution, the foreground light, and the background source. An error in the foreground light model can leave residuals that the pixelized source attempts to explain.\n", "\n", - "Let\u2019s perform that initial fit using a simulated dataset containing foreground lens light. This is a new dataset,\n", - "so we run a new search rather than reuse the result from our earlier no-lens-light example." + "We therefore use search chaining again. Following Chapter 2, we first fit MGEs for both the foreground galaxy and source. This establishes an initial lens-light model and lensing geometry before we introduce a pixelization.\n", + "\n", + "We use a new simulated dataset containing foreground light, so this requires a new initial search." ] }, { "cell_type": "code", "metadata": {}, "source": [ - "\n", - "from pathlib import Path\n", - "import subprocess\n", - "import sys\n", - "\n", - "import autofit as af\n", - "import autolens as al\n", - "import autolens.plot as aplt\n", "\n", "foreground_dataset_path = Path(\"dataset\") / \"imaging\" / \"lens_sersic\"\n", "\n", "al.util.dataset.simulate_if_needed(\n", - " foreground_dataset_path, simulator=\"scripts/simulator/lens_sersic.py\"\n", + " foreground_dataset_path,\n", + " simulator=\"scripts/simulator/lens_sersic.py\",\n", ")\n", "\n", "foreground_dataset = al.Imaging.from_fits(\n", @@ -896,6 +875,7 @@ " pixel_scales=foreground_dataset.pixel_scales,\n", " radius=2.5,\n", ")\n", + "\n", "foreground_dataset = foreground_dataset.apply_mask(mask=mask)\n", "\n", "over_sample_size = al.util.over_sample.over_sample_size_via_radial_bins_from(\n", @@ -904,18 +884,39 @@ " radial_list=[0.3, 0.6],\n", " centre_list=[(0.0, 0.0)],\n", ")\n", + "\n", "foreground_dataset = foreground_dataset.apply_over_sampling(\n", " over_sample_size_lp=over_sample_size,\n", " over_sample_size_pixelization=2,\n", ")\n", "\n", - "aplt.subplot_imaging_dataset(dataset=foreground_dataset)\n", + "aplt.subplot_imaging_dataset(dataset=foreground_dataset)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The foreground galaxy has a bright centre where its light changes rapidly across individual image pixels. We use finer sampling there when evaluating the light profiles, following the oversampling approach introduced earlier.\n", + "\n", + "For the foreground MGE, we use two Gaussian groups that can have different elliptical shapes. This allows more flexibility than a single group. We fix their centres and the mass centre to the simulation\u2019s origin for this introductory example." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ "\n", "lens_bulge = al.model_util.mge_model_from(\n", - " mask_radius=2.5, total_gaussians=20, gaussian_per_basis=2,\n", + " mask_radius=2.5,\n", + " total_gaussians=20,\n", + " gaussian_per_basis=2,\n", " centre_prior_is_uniform=True,\n", " sigma_min=foreground_dataset.pixel_scales[0] / 10.0,\n", ")\n", + "\n", "for gaussian in lens_bulge.profile_list:\n", " gaussian.centre = (0.0, 0.0)\n", "\n", @@ -937,7 +938,9 @@ " al.Galaxy,\n", " redshift=1.0,\n", " bulge=al.model_util.mge_model_from(\n", - " mask_radius=2.5, total_gaussians=20, centre_prior_is_uniform=False,\n", + " mask_radius=2.5,\n", + " total_gaussians=20,\n", + " centre_prior_is_uniform=False,\n", " ),\n", ")\n", "\n", @@ -952,6 +955,8 @@ " fields=field,\n", ")\n", "\n", + "print(foreground_model_1.info)\n", + "\n", "foreground_search_1 = af.Nautilus(\n", " path_prefix=Path(\"howtolens\") / \"chapter_3\" / \"tutorial_4\",\n", " name=\"foreground_search_1_analytic_source\",\n", @@ -984,31 +989,26 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "We fix the foreground light and mass centres for this introductory example, while allowing their other parameters\n", - "to vary. The foreground MGE uses two Gaussian groups, which can have different elliptical shapes; the source uses\n", - "one. Their Gaussian widths are fixed and all Gaussian intensities are solved linearly.\n", - "\n", - "Before continuing, inspect the foreground model and residuals. We want a satisfactory description of the lens light\n", - "and a plausible source geometry. For an irregular source, the analytic profile may leave structure unexplained, but\n", - "obvious foreground residuals should not simply be passed into the next stage.\n", + "The Gaussian widths are fixed, while their intensities are solved linearly. The search explores the remaining free geometry, mass and shear parameters.\n", "\n", - "This is a genuine non-linear search, so it will take longer than evaluating a single `FitImaging`.\n", + "Inspect the foreground light model, source and residuals. We want the initial model to separate the foreground galaxy from the arcs sensibly. Significant foreground residuals should be investigated before introducing a more flexible source that might absorb them.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Using that result in the pixelized-source fit__\n", "\n", - "We now replace the analytic source with a pixelization and carry the foreground model into the second search.\n", + "Now replace the analytic source with a pixelization.\n", "\n", - "The distinction between `.model` and `.instance` is useful here. We use `.instance` to hold the foreground\n", - "profile\u2019s fitted shape fixed, reducing the number of non-linear parameters the next search must explore. We use\n", - "`.model` for the mass and shear so that they remain free, with priors passed from the first result.\n", + "We hold the fitted **foreground MGE geometry** fixed using `.instance`, while keeping the mass and shear free using `.model` and its updated priors.\n", "\n", - "Because the foreground MGE uses linear Gaussian profiles, fixing its geometry does **not** fix its intensities. The\n", - "second fit solves all Gaussian intensities alongside the pixelized source brightnesses. The foreground subtraction\n", - "can therefore adjust without reopening the non-linear Gaussian geometry.\n", + "There is an important distinction here. The MGE contains **linear** Gaussian profiles, so fixing their geometry does not fix their intensities. The next fit solves those intensities together with the pixelized source brightnesses.\n", "\n", - "Search chaining also supplies the initial geometry from which we construct the positions constraint.\n", - "**`PositionsLH` penalizes demagnified solutions whose corresponding image positions fail to trace sufficiently\n", - "close together.**" + "We therefore continue to fit the foreground and background light simultaneously. We are not subtracting a fixed foreground image and treating that subtraction as exact." ] }, { @@ -1043,6 +1043,23 @@ " galaxies=af.Collection(lens=lens, source=source),\n", " fields=foreground_result_1.model.fields,\n", ")\n", + "\n", + "print(foreground_model_2.info)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As in Chapter 2, Tutorial 5, we use the initial result to construct the **positions likelihood penalty**. This helps discourage the unphysical mass-and-source solutions discussed earlier." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ "\n", "foreground_positions_likelihood = (\n", " foreground_result_1.positions_likelihood_from(\n", @@ -1054,10 +1071,23 @@ "aplt.plot_array(\n", " array=foreground_dataset.data,\n", " positions=foreground_positions_likelihood.positions,\n", - " title=\"Positions Predicted by the Initial Foreground-Light Fit\",\n", - ")\n", - "\n", - "print(foreground_model_2.info)\n", + " title=\"Positions Predicted by the Initial Fit\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Check these positions against the observed arcs before running the second search. They are predictions from the initial model, so their usefulness depends on that model having found a sensible lensing geometry." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ "\n", "foreground_analysis_2 = al.AnalysisImaging(\n", " dataset=foreground_dataset,\n", @@ -1093,22 +1123,45 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Inspect the predicted positions before running the second search, as we did earlier. They come from the initial\n", - "model and should sensibly represent the multiple-image configuration.\n", + "Compare the foreground subtraction, reconstructed source and residuals with the initial fit. The mass parameters, regularization coefficient and linearly solved foreground intensities can all respond to the new source description.\n", "\n", - "The second search now starts with an established foreground light shape and lens geometry. It can refine the mass\n", - "and regularization while reconstructing the source, rather than solving the entire problem from broad initial\n", - "priors.\n", - "\n", - "Compare the two results, paying attention to the foreground subtraction, source reconstruction and residuals. If\n", - "the fixed foreground shape leaves significant residuals, a later chained search can free its parameters again.\n", - "Fixing it is a useful intermediate step, not a requirement that the initial light model must remain unchanged\n", - "throughout the analysis.\n", + "If the fixed foreground geometry leaves significant residuals, we can free its parameters in a later chained search. Holding it fixed is a useful intermediate step, rather than a requirement for the final analysis.\n", "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ "__Wrap Up__\n", "\n", - "This completes the practical chain: fit MGE lens and source light, carry forward the foreground geometry and mass\n", - "priors, then fit a pixelized source with the positions constraint." + "We have now fitted a lens mass model alongside a pixelized source, both with and without foreground lens light.\n", + "\n", + "The main difficulty was not solving for the source brightnesses. The inversion does that efficiently for every proposed model. The difficulty was finding the physically meaningful mass model among the many incorrect models that a flexible source can partly compensate for.\n", + "\n", + "We addressed this using the tools introduced in Chapter 2:\n", + "\n", + "- **Search chaining:** begin with an MGE source to establish the lensing geometry, then introduce a pixelization.\n", + "- **Prior passing:** keep the mass parameters free while using the initial result to guide the next search.\n", + "- **The positions likelihood penalty:** discourage models that trace corresponding image positions too far apart.\n", + "\n", + "When foreground light is present, we also establish its geometry in the initial fit. Its linear intensities can then be solved alongside the pixelized source, allowing both contributions to adjust together.\n", + "\n", + "However, finding the appropriate mass model does not guarantee that our source reconstruction can describe all the observed structure.\n", + "\n", + "What happens if the source has a very compact bright centre, but our mesh places too few pixels there? What if that centre needs weak smoothing while the faint outskirts need strong smoothing?\n", + "\n", + "Even the evidence-preferred constant regularization coefficient must compromise between these demands.\n", + "\n", + "In the next tutorial, **Fit Problems**, we will deliberately make the source more compact while keeping the true mass model fixed. This will reveal limitations of the mesh and regularization themselves, and motivate the adaptive methods that follow." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**\ud83e\udd16 Ask AI:** Why can an incorrect mass model fit the arcs with a pixelized source, and how do search chaining and the positions likelihood penalty address this?" ] } ], diff --git a/notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb b/notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb new file mode 100644 index 0000000..fcaa35a --- /dev/null +++ b/notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb @@ -0,0 +1,991 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Tutorial 5: Fit Problems\n", + "========================\n", + "\n", + "Investigate why compact sources can challenge mesh resolution and constant regularization even when the mass model is correct.\n", + "\n", + "__Contents__\n", + "\n", + "- When the best coefficient is not enough\n", + "- Three progressively compact sources\n", + "- Simulating the images\n", + "- Reconstructing the sources\n", + "- Finding the preferred regularization\n", + "- Starting with the extended source\n", + "- Making the source more compact\n", + "- A very compact source\n", + "- Why the mesh becomes a limitation\n", + "- Why one regularization coefficient becomes a compromise\n", + "- Why maximizing the evidence does not remove the compromise\n", + "- Wrap Up" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Google Colab Setup__\n", + "\n", + "Set up Google Colab as in [the introductory notebook](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_1_introduction/tutorial_1_grids_and_galaxies.ipynb).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "cellView": "form", + "jupyter": { + "source_hidden": true + } + }, + "outputs": [], + "source": [ + "# @title Set up Google Colab { display-mode: \"form\" }\n", + "try:\n", + " import google.colab\n", + "except ImportError:\n", + " from autolens import setup_colab as _setup_colab\n", + "else:\n", + " import importlib\n", + " import subprocess\n", + " import sys\n", + "\n", + " subprocess.check_call(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"autonerves\", \"--no-deps\"]\n", + " )\n", + " _setup_colab = importlib.import_module(\"autonerves.setup_colab\")\n", + "\n", + "_setup_colab.setup(\"howtolens\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__When the best coefficient is not enough__\n", + "\n", + "We have learned how to reconstruct a source, choose its regularization coefficient using Bayesian evidence, and fit the lens mass model.\n", + "\n", + "Does this mean we can now reconstruct any source accurately?\n", + "\n", + "Let\u2019s investigate using three galaxies whose light becomes progressively more concentrated towards their centres. We will simulate their images using exactly the same lens mass model, then supply that correct mass model when reconstructing each source.\n", + "\n", + "This removes the search difficulty from the previous tutorial. If a reconstruction leaves significant residuals, we cannot blame the search for finding an incorrect mass model: we have already supplied the answer.\n", + "\n", + "We will also choose the regularization coefficient separately for each dataset by maximizing its log evidence. This lets us ask a more interesting question than whether an arbitrary coefficient works:\n", + "\n", + "**What happens when the preferred coefficient still does not give a satisfactory reconstruction?**\n", + "\n", + "There are two possible limitations to investigate.\n", + "\n", + "First, the mesh may place too few source pixels across a compact bright component. Changing the regularization coefficient cannot add the missing resolution.\n", + "\n", + "Second, one coefficient controls smoothing across the entire source. The bright centre may require weak smoothing to preserve its steep brightness gradient, while the faint outskirts benefit from stronger smoothing. A single coefficient must compromise between them.\n", + "\n", + "Our Goldilocks coefficient is therefore \u201cjust right\u201d **within the mesh and regularization scheme we have provided**. It cannot change those choices for us.\n", + "\n", + "We will work through:\n", + "\n", + "- Three progressively compact sources.\n", + "- Simulating their lensed images.\n", + "- Selecting a regularization coefficient for each dataset.\n", + "- Inspecting the reconstructions and residuals.\n", + "- Separating the limitations of mesh resolution and constant regularization.\n", + "- Motivating adaptation to the source\u2019s brightness distribution.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Three progressively compact sources__\n", + "\n", + "We use three S\u00e9rsic profiles with the same centre and elliptical shape.\n", + "\n", + "For the first, we choose a relatively large effective radius and a S\u00e9rsic index of `1.0`. For the next two, we decrease the effective radius and increase the S\u00e9rsic index. Their light becomes more concentrated, with a steeper brightness gradient near the centre." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "from autolens import jax_wrapper\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import autolens as al\n", + "import autolens.plot as aplt\n", + "import autoarray.plot as aaplt\n", + "\n", + "source_parameters = [\n", + " (\"Extended\", 0.5, 1.0),\n", + " (\"Compact\", 0.2, 2.5),\n", + " (\"Very compact\", 0.1, 4.0),\n", + "]\n", + "\n", + "sources = {}\n", + "\n", + "for label, effective_radius, sersic_index in source_parameters:\n", + " sources[label] = al.Galaxy(\n", + " redshift=1.0,\n", + " bulge=al.lp.Sersic(\n", + " centre=(0.0, 0.0),\n", + " ell_comps=(0.0, 0.15),\n", + " intensity=0.2,\n", + " effective_radius=effective_radius,\n", + " sersic_index=sersic_index,\n", + " ),\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The S\u00e9rsic `intensity` is the brightness at the effective radius, rather than the central brightness. Keeping it fixed while changing the effective radius and S\u00e9rsic index changes both the central brightness and total flux.\n", + "\n", + "These examples therefore represent different source morphologies and signal-to-noise distributions. They are not an experiment in which only the source size changes.\n", + "\n", + "We place all three sources behind the same lens:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "lens_galaxy = al.Galaxy(\n", + " redshift=0.5,\n", + " mass=al.mp.Isothermal(\n", + " centre=(0.0, 0.0),\n", + " ell_comps=(0.111111, 0.0),\n", + " einstein_radius=1.6,\n", + " ),\n", + ")\n", + "\n", + "true_tracers = {\n", + " label: al.Tracer(galaxies=[lens_galaxy, source])\n", + " for label, source in sources.items()\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The lens produces exactly the same deflection and magnification fields in all three cases. Changing the source brightness does not change where an image coordinate traces in the source plane.\n", + "\n", + "This distinction will become important: the lensing geometry stays the same, but the spatial resolution needed to describe the source\u2019s light changes.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Simulating the images__\n", + "\n", + "We now simulate the three lensed images using the same image grid and telescope blurring.\n", + "\n", + "To make the comparison easier to interpret, we also use the same Gaussian noise map and the same noise realization. Any change between the images then comes from the source light, rather than a different random draw of the noise.\n", + "\n", + "We first generate images without added Poisson noise, then add our common Gaussian noise explicitly." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "simulation_grid = al.Grid2D.uniform(\n", + " shape_native=(150, 150),\n", + " pixel_scales=0.05,\n", + ")\n", + "\n", + "simulation_grid = simulation_grid.apply_over_sampling(\n", + " over_sample_size=8,\n", + ")\n", + "\n", + "psf = al.Convolver.from_gaussian(\n", + " shape_native=(11, 11),\n", + " sigma=0.05,\n", + " pixel_scales=0.05,\n", + ")\n", + "\n", + "simulator = al.SimulatorImaging(\n", + " exposure_time=300.0,\n", + " psf=psf,\n", + " background_sky_level=0.1,\n", + " add_poisson_noise_to_data=False,\n", + ")\n", + "\n", + "noise_sigma = 0.05\n", + "\n", + "noise_values = np.random.default_rng(1).normal(\n", + " loc=0.0,\n", + " scale=noise_sigma,\n", + " size=simulation_grid.shape_native,\n", + ")\n", + "\n", + "common_noise_map = al.Array2D.no_mask(\n", + " values=np.full(simulation_grid.shape_native, noise_sigma),\n", + " pixel_scales=0.05,\n", + ")\n", + "\n", + "datasets = {}\n", + "\n", + "for label, tracer in true_tracers.items():\n", + " noiseless = simulator.via_tracer_from(\n", + " tracer=tracer,\n", + " grid=simulation_grid,\n", + " )\n", + "\n", + " datasets[label] = al.Imaging(\n", + " data=al.Array2D.no_mask(\n", + " values=np.asarray(noiseless.data.native) + noise_values,\n", + " pixel_scales=0.05,\n", + " ),\n", + " noise_map=common_noise_map,\n", + " psf=psf,\n", + " )\n", + "\n", + " print(label)\n", + " aplt.subplot_imaging_dataset(dataset=datasets[label])" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Look at how the lensed emission changes. Which parts become narrower or more sharply peaked as the source becomes compact? These are the features our reconstruction will need to explain after accounting for telescope blurring.\n", + "\n", + "We use the same circular mask for all three datasets. Before fitting, check its border against the full images so that we can see whether it includes the lensed emission." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "mask = al.Mask2D.circular(\n", + " shape_native=simulation_grid.shape_native,\n", + " pixel_scales=0.05,\n", + " radius=3.0,\n", + ")\n", + "\n", + "for label, dataset in datasets.items():\n", + " aplt.plot_array(\n", + " array=dataset.data,\n", + " positions=mask.derive_grid.border,\n", + " title=f\"{label}: Image and Mask Border\",\n", + " )\n", + "\n", + "datasets = {\n", + " label: dataset.apply_mask(mask=mask).apply_over_sampling(\n", + " over_sample_size_pixelization=2,\n", + " )\n", + " for label, dataset in datasets.items()\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We now have three datasets with a common lens model, image sampling, PSF, mask and noise realization.\n", + "\n", + "Next, we will reconstruct them using the same rectangular mesh and constant regularization scheme. For each dataset, we will scan the coefficient and inspect its evidence-preferred reconstruction.\n", + "\n", + "We will compare alternative reconstructions **within each dataset**. We will not rank the three different simulated galaxies against one another using their absolute evidence values." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Reconstructing the sources__\n", + "\n", + "We will reconstruct all three sources using a `25 \u00d7 25` rectangular mesh and `Constant` regularization.\n", + "\n", + "We supply the same lens galaxy used to simulate the images. Its mass parameters remain fixed, so the inversion cannot compensate for limitations of the source reconstruction by changing the lens model.\n", + "\n", + "The function below takes a dataset and regularization coefficient, reconstructs the source, and returns the fit." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "mesh_shape = (25, 25)\n", + "\n", + "\n", + "def fit_source(dataset, coefficient):\n", + " pixelization = al.Pixelization(\n", + " mesh=al.mesh.RectangularUniform(shape=mesh_shape),\n", + " regularization=al.reg.Constant(coefficient=coefficient),\n", + " )\n", + "\n", + " reconstructed_source = al.Galaxy(\n", + " redshift=1.0,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " tracer = al.Tracer(\n", + " galaxies=[lens_galaxy, reconstructed_source],\n", + " )\n", + "\n", + " return al.FitImaging(\n", + " dataset=dataset,\n", + " tracer=tracer,\n", + " settings=al.Settings(use_border_relocator=True),\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The image grid, mask and lens model are identical across our three datasets. The rectangular mesh is therefore constructed from the same mapped coordinates, regardless of how compact the source light becomes.\n", + "\n", + "This is the limitation we want to explore. The source can become smaller and more centrally concentrated, but the mesh does not respond by placing more points across its bright centre.\n", + "\n", + "For now, the only reconstruction setting we vary is the regularization coefficient.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Finding the preferred regularization__\n", + "\n", + "As in tutorial 2, we evaluate a range of coefficients and compare their log evidences. We do this separately for each dataset: a coefficient suitable for the extended source need not be suitable for either compact source.\n", + "\n", + "We first make a broad scan, then sample more closely around its highest point." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "coefficient_grid = np.logspace(-3, 3, 25)\n", + "\n", + "preferred_fits = {}\n", + "preferred_coefficients = {}\n", + "\n", + "for label, dataset in datasets.items():\n", + " # Retain scalar evidence values, not every full inversion in the scan.\n", + " trial_evidences = {}\n", + " best_fit = None\n", + " best_evidence = -np.inf\n", + "\n", + " for coefficient in coefficient_grid:\n", + " coefficient = float(coefficient)\n", + " current_fit = fit_source(dataset=dataset, coefficient=coefficient)\n", + " evidence = float(current_fit.log_evidence)\n", + " trial_evidences[coefficient] = evidence\n", + " if evidence > best_evidence:\n", + " best_fit, best_evidence = current_fit, evidence\n", + "\n", + " values = np.array([trial_evidences[float(c)] for c in coefficient_grid])\n", + "\n", + " if not np.all(np.isfinite(values)):\n", + " raise ValueError(f\"{label}: inspect non-finite evidence values.\")\n", + "\n", + " best_index = int(np.argmax(values))\n", + " if best_index in (0, len(coefficient_grid) - 1):\n", + " raise ValueError(f\"{label}: extend the coefficient range before continuing.\")\n", + "\n", + " refined_grid = np.geomspace(\n", + " coefficient_grid[best_index - 1],\n", + " coefficient_grid[best_index + 1],\n", + " 15,\n", + " )\n", + "\n", + " for coefficient in refined_grid:\n", + " coefficient = float(coefficient)\n", + " if coefficient not in trial_evidences:\n", + " current_fit = fit_source(dataset=dataset, coefficient=coefficient)\n", + " evidence = float(current_fit.log_evidence)\n", + " trial_evidences[coefficient] = evidence\n", + " if evidence > best_evidence:\n", + " best_fit, best_evidence = current_fit, evidence\n", + "\n", + " if not np.all(np.isfinite(list(trial_evidences.values()))):\n", + " raise ValueError(f\"{label}: inspect non-finite evidence values.\")\n", + "\n", + " preferred_coefficient = max(trial_evidences, key=trial_evidences.get)\n", + " preferred_coefficients[label] = preferred_coefficient\n", + " preferred_fits[label] = best_fit\n", + "\n", + " coefficients = np.array(sorted(trial_evidences))\n", + " evidences = np.array([trial_evidences[c] for c in coefficients])\n", + "\n", + " fig, ax = plt.subplots()\n", + " ax.semilogx(\n", + " coefficients,\n", + " evidences - np.max(evidences),\n", + " marker=\"o\",\n", + " )\n", + " ax.set_xlabel(\"Regularization coefficient\")\n", + " ax.set_ylabel(\"Log evidence relative to this dataset's maximum\")\n", + " ax.set_title(label)\n", + " plt.show()\n", + "\n", + " print(f\"{label}: preferred coefficient = {preferred_coefficient:.4g}\")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This performs many inversions, so it will take longer than the earlier three-coefficient comparisons.\n", + "\n", + "Inspect each curve. Have we resolved its peak sufficiently well, or would further sampling be useful? These are the highest-evidence coefficients we have evaluated, rather than mathematically exact optima.\n", + "\n", + "Each plot has its own maximum subtracted. We are examining how regularization affects each dataset, not comparing evidence values between different simulated images.\n", + "\n", + "We can now inspect the reconstructions knowing that we have selected their smoothing strengths systematically.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Starting with the extended source__\n", + "\n", + "Let\u2019s begin with the extended source.\n", + "\n", + "Its brightness varies relatively gradually across the source plane. We therefore expect the rectangular mesh to describe it more easily than the compact sources, whose central brightness changes over smaller distances.\n", + "\n", + "Inspect the reconstruction and image residuals at its preferred coefficient." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "extended_fit = preferred_fits[\"Extended\"]\n", + "extended_mapper = extended_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "print(\n", + " \"Extended-source coefficient:\",\n", + " preferred_coefficients[\"Extended\"],\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=extended_fit)\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=extended_mapper,\n", + " solution_vector=extended_fit.inversion.reconstruction,\n", + " mesh_grid=extended_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Extended Source: Preferred Reconstruction\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging_of_planes(fit=extended_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Look at how many mesh points lie across the bright source region. Are its brightness variations represented over several neighbouring points, or concentrated into only one or two?\n", + "\n", + "Then inspect the normalized residual map. A satisfactory reconstruction should leave fluctuations broadly consistent with the simulated noise, without coherent patches following the arcs.\n", + "\n", + "We can summarize the residuals numerically, too." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "normalized_residuals = np.asarray(\n", + " (datasets[\"Extended\"].data - extended_fit.model_data)\n", + " / datasets[\"Extended\"].noise_map\n", + ")\n", + "\n", + "print(\n", + " \"Mean squared normalized residual:\",\n", + " float(np.mean(normalized_residuals**2)),\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A value around one is a useful reference for noise-level residuals, but it does not tell us whether those residuals form a spatial pattern. Keep the residual map alongside this number.\n", + "\n", + "This extended-source fit provides our baseline. Next, we will make the source more compact without giving the mesh any additional resolution, and examine where the reconstruction begins to struggle." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Making the source more compact__\n", + "\n", + "Now inspect the compact source.\n", + "\n", + "Its effective radius is smaller and its central brightness gradient is steeper. However, we have given the reconstruction exactly the same mesh as before. We have changed the regularization coefficient to the value preferred by this dataset, but we have not added any source pixels." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "compact_fit = preferred_fits[\"Compact\"]\n", + "compact_mapper = compact_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "print(\n", + " \"Compact-source coefficient:\",\n", + " preferred_coefficients[\"Compact\"],\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=compact_fit)\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=compact_mapper,\n", + " solution_vector=compact_fit.inversion.reconstruction,\n", + " mesh_grid=compact_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Compact Source: Preferred Reconstruction\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging_of_planes(fit=compact_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Look closely at the bright source centre and the corresponding bright regions of the arcs. Does the reconstruction reproduce their sharp brightness peaks, or do coherent patches remain in the residual map?\n", + "\n", + "The source reconstruction may look broadly reasonable while still missing these details. A small error in the bright centre can produce significant residuals because the image measures that emission with high signal-to-noise." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "normalized_residuals = np.asarray(\n", + " (datasets[\"Compact\"].data - compact_fit.model_data)\n", + " / datasets[\"Compact\"].noise_map\n", + ")\n", + "\n", + "print(\n", + " \"Mean squared normalized residual:\",\n", + " float(np.mean(normalized_residuals**2)),\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If structured residuals remain, remember what we have already controlled: the mass model is correct, and the coefficient was selected using the evidence.\n", + "\n", + "The question is therefore no longer simply \u201cHave we chosen the wrong coefficient?\u201d We need to ask whether our mesh and regularization scheme can represent this source adequately.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__A very compact source__\n", + "\n", + "Our final source has an effective radius of just `0.1` arcsec and a S\u00e9rsic index of `4.0`. Its light rises steeply towards the centre, placing a greater demand on the reconstruction.\n", + "\n", + "Before looking at the result, consider the two difficulties this creates.\n", + "\n", + "The mesh must have sufficiently closely spaced points to represent the central brightness peak. At the same time, regularization must allow large brightness differences between those points without allowing unnecessary fluctuations across the faint outskirts.\n", + "\n", + "Let\u2019s inspect the evidence-preferred reconstruction." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "very_compact_fit = preferred_fits[\"Very compact\"]\n", + "very_compact_mapper = very_compact_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "print(\n", + " \"Very-compact-source coefficient:\",\n", + " preferred_coefficients[\"Very compact\"],\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=very_compact_fit)\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=very_compact_mapper,\n", + " solution_vector=very_compact_fit.inversion.reconstruction,\n", + " mesh_grid=very_compact_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Very Compact Source: Preferred Reconstruction\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging_of_planes(fit=very_compact_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the residuals with those of the extended and compact sources. Pay particular attention to the bright parts of the arcs: are there neighbouring positive and negative residuals suggesting that the model is spreading the light differently from the data?\n", + "\n", + "We can summarize all three fits together." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "for label, current_fit in preferred_fits.items():\n", + " dataset = datasets[label]\n", + "\n", + " normalized_residuals = np.asarray(\n", + " (dataset.data - current_fit.model_data) / dataset.noise_map\n", + " )\n", + "\n", + " print(\n", + " f\"{label}: \"\n", + " f\"coefficient={preferred_coefficients[label]:.4g}, \"\n", + " f\"mean squared normalized residual=\"\n", + " f\"{np.mean(normalized_residuals**2):.3f}\"\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Use these numbers alongside the maps. They summarize the overall residual amplitude, whereas the maps show where the fit struggles.\n", + "\n", + "These galaxies also have different brightness distributions and signal-to-noise ratios. We should not interpret the comparison as a measurement of source-size effects alone. Its purpose is to expose the practical difficulty of reconstructing increasingly concentrated light with an unchanged mesh and one smoothing coefficient.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Why the mesh becomes a limitation__\n", + "\n", + "Let\u2019s examine the first problem: where our source pixels are placed.\n", + "\n", + "The rectangular mesh covers the same source-plane region in all three fits. Its points do not move towards the bright centre when we make the source more compact. Consequently, fewer points lie across the region where most of the central brightness variation occurs.\n", + "\n", + "We can illustrate this by counting mesh points within a circular aperture whose radius equals each source\u2019s effective radius." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "for label, effective_radius, _ in source_parameters:\n", + " current_fit = preferred_fits[label]\n", + " mapper = current_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + " mesh_coordinates = np.asarray(mapper.source_plane_mesh_grid)\n", + "\n", + " radii = np.sqrt(\n", + " np.sum(mesh_coordinates**2, axis=1)\n", + " )\n", + "\n", + " points_within_radius = int(\n", + " np.count_nonzero(radii <= effective_radius)\n", + " )\n", + "\n", + " print(\n", + " f\"{label}: effective radius={effective_radius:.2f} arcsec, \"\n", + " f\"mesh points inside circular aperture={points_within_radius}\"\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is a simple geometric diagnostic. Our sources are elliptical, so the circular aperture is not their exact half-light contour. Nor does the count tell us how many brightness values are independently constrained. It shows how the mesh samples progressively smaller central regions.\n", + "\n", + "Bilinear interpolation can calculate brightness between mesh points, but it cannot introduce an independently adjustable peak between them. If a sharp source component falls within a region sampled by too few points, changing the coefficient cannot supply the missing spatial freedom.\n", + "\n", + "We could increase the resolution everywhere. However, that also adds points across faint regions where the data may provide little information. Those extra brightness values increase the computational work and may require strong regularization.\n", + "\n", + "A more efficient approach is to place additional points where the source needs them.\n", + "\n", + "Magnification offers one useful guide because it tells us where lensing provides finer source-plane resolution. However, the magnification field is identical for our three sources: it does not respond when their light becomes more concentrated. Adapting to magnification alone therefore cannot guarantee enough resolution across every bright source component.\n", + "\n", + "This motivates adapting the mesh to the **source\u2019s brightness distribution**, which we will explore in the next tutorial.\n", + "\n", + "First, there is a second limitation to understand. Even with enough mesh points, should we apply the same regularization coefficient to the bright centre and faint outskirts?" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Why one regularization coefficient becomes a compromise__\n", + "\n", + "Suppose we had enough mesh points to resolve the source\u2019s bright centre. Would that solve the problem?\n", + "\n", + "We would still need to choose how strongly to regularize those points.\n", + "\n", + "Recall that `Constant` regularization penalizes differences between neighbouring source brightness values. One coefficient controls the strength of that penalty across the entire mesh.\n", + "\n", + "However, the brightness gradients differ substantially between the centre and outskirts of our compact source.\n", + "\n", + "Near the centre, the brightness rises steeply. Neighbouring mesh points need to take quite different values to reproduce that rise. Strong regularization discourages those differences and can flatten the reconstructed peak.\n", + "\n", + "In the outskirts, the brightness changes more gradually, or there is little source emission at all. Here, stronger regularization can suppress noise-driven fluctuations without removing the structure needed to explain the image.\n", + "\n", + "Our single coefficient must serve both regions. Let\u2019s compare weaker and stronger regularization with the evidence-preferred value for the very compact source." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "preferred_coefficient = preferred_coefficients[\"Very compact\"]\n", + "\n", + "regularization_comparisons = [\n", + " (\n", + " \"Weaker Regularization\",\n", + " preferred_coefficient / 10.0,\n", + " fit_source(\n", + " dataset=datasets[\"Very compact\"],\n", + " coefficient=preferred_coefficient / 10.0,\n", + " ),\n", + " ),\n", + " (\n", + " \"Evidence-Preferred Regularization\",\n", + " preferred_coefficient,\n", + " preferred_fits[\"Very compact\"],\n", + " ),\n", + " (\n", + " \"Stronger Regularization\",\n", + " preferred_coefficient * 10.0,\n", + " fit_source(\n", + " dataset=datasets[\"Very compact\"],\n", + " coefficient=preferred_coefficient * 10.0,\n", + " ),\n", + " ),\n", + "]\n", + "\n", + "source_vmax = max(\n", + " float(np.max(np.asarray(current_fit.inversion.reconstruction)))\n", + " for _, _, current_fit in regularization_comparisons\n", + ")\n", + "\n", + "for label, coefficient, current_fit in regularization_comparisons:\n", + " mapper = current_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + " print(f\"{label}: coefficient={coefficient:.4g}\")\n", + "\n", + " aaplt.plot_mapper(\n", + " mapper=mapper,\n", + " solution_vector=current_fit.inversion.reconstruction,\n", + " mesh_grid=mapper.source_plane_mesh_grid,\n", + " vmin=0.0,\n", + " vmax=source_vmax,\n", + " zoom_to_brightest=False,\n", + " title=label,\n", + " )\n", + "\n", + " aplt.subplot_fit_imaging(fit=current_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the bright centre and faint outskirts separately.\n", + "\n", + "Does weaker regularization help reproduce the central peak? Does it also introduce more variation in the faint regions? Does stronger regularization suppress those variations at the cost of leaving residuals around the bright arcs?\n", + "\n", + "These changes need not be equally visible in every dataset. The important point is that changing one coefficient changes the smoothing preference everywhere, even though different source regions may benefit from different strengths.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Why maximizing the evidence does not remove the compromise__\n", + "\n", + "Why does the Bayesian evidence not simply select a coefficient that fixes both regions?\n", + "\n", + "Because we have only given it one coefficient to choose.\n", + "\n", + "The evidence evaluates the complete reconstruction. It balances improvements in the image fit against the source complexity, but it cannot introduce a separate smoothing strength for the centre and outskirts unless our regularization model allows that.\n", + "\n", + "Let\u2019s compare the evidence of the three reconstructions." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "comparison_evidences = np.array([\n", + " float(current_fit.log_evidence)\n", + " for _, _, current_fit in regularization_comparisons\n", + "])\n", + "\n", + "highest_evidence = float(np.max(comparison_evidences))\n", + "\n", + "for (label, coefficient, _), log_evidence in zip(\n", + " regularization_comparisons,\n", + " comparison_evidences,\n", + "):\n", + " print(\n", + " f\"{label}: coefficient={coefficient:.4g}, \"\n", + " f\"log evidence difference={log_evidence - highest_evidence:.3f}\"\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The coefficient selected by our earlier scan should give the highest evidence of these examples. If either additional trial improves it, we should revisit the scan before treating its maximum as established.\n", + "\n", + "Even at the preferred coefficient, the reconstruction may compromise: smoothing the centre more than we would like while allowing more variation in the outskirts than those regions require.\n", + "\n", + "Our Goldilocks analogy now has a limitation. We have been choosing one amount of smoothing for the whole galaxy, but what is \u201cjust right\u201d for the bright centre may not be \u201cjust right\u201d for the outskirts.\n", + "\n", + "An **adaptive regularization scheme** gives us more control. It can allow weaker smoothing in bright regions with steep gradients and stronger smoothing in faint regions.\n", + "\n", + "The evidence still decides whether that additional flexibility is useful. We compare the constant and adaptive schemes on the same dataset, allowing their parameters to vary appropriately. Adaptation is a way to improve the available reconstructions, rather than a guarantee that every fit will improve.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Wrap Up__\n", + "\n", + "We began with three sources whose light became progressively more concentrated, while keeping the lens mass model fixed to its true values.\n", + "\n", + "For each dataset, we selected the regularization coefficient using Bayesian evidence. This allowed us to investigate limitations that remain even when the mass is correct and the smoothing strength has been chosen systematically.\n", + "\n", + "We identified two distinct problems:\n", + "\n", + "- **Mesh resolution:** a fixed rectangular mesh can place too few points across a compact bright component. Changing regularization cannot add the missing resolution.\n", + "- **Constant regularization:** one smoothing coefficient must compromise between steep central brightness gradients and faint, slowly varying outskirts.\n", + "\n", + "These problems are connected, but solving one does not automatically solve the other. Adding more points around the centre will not help fully if regularization forces their brightnesses to be too similar. Weakening regularization will not resolve a feature that the mesh cannot represent.\n", + "\n", + "The next tutorial brings together the adaptive methods needed to address both.\n", + "\n", + "We will first introduce a Delaunay mesh whose source-plane points are obtained by tracing an image-plane mesh through the lens model. This lets the source sampling respond to the lensing geometry.\n", + "\n", + "We will then use a fitted brightness distribution to guide a Hilbert image mesh, concentrating sampling where the lensed source is bright. Finally, we will introduce adaptive regularization and explain how its smoothing acts across the irregular mesh.\n", + "\n", + "At each stage, we will return to the questions raised here: does the mesh resolve the source\u2019s bright structure, and does regularization preserve that structure while controlling fluctuations elsewhere?\n", + "\n", + "We will finish by using these methods in a lens-model fit, with the search-chaining procedure and positions likelihood penalty already introduced." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**\ud83e\udd16 Ask AI:** How would you distinguish insufficient source-mesh resolution from over-smoothing when compact-source residuals remain at the evidence-preferred coefficient?" + ] + } + ], + "metadata": { + "anaconda-cloud": {}, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.1" + }, + "colab": { + "aiContexts": { + "97746750-6915-569b-a61d-792a6bc69437": { + "id": "97746750-6915-569b-a61d-792a6bc69437", + "name": "PyAutoLens assistant", + "context": "These instructions were prepared by the PyAutoLens assistant\n(https://github.com/PyAutoLabs/autolens_assistant). The reference material at the\nend was written for a coding agent working inside a checkout of that repository.\nHere in Google Colab there is no checkout: ignore any step about repository files,\nhooks, `scripts/` or `wiki/` paths, saving scripts, or terminal tools, and use the\nPyAutoLens knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoLens notebook (notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb in HowToLens).\nExplain what it does and help them adapt it to their own questions.\n\nCore rules:\n1. Stay with this notebook's API. Only use PyAutoLens calls that appear in the\n notebook or in the reference material below. Never invent class names, functions\n or arguments. If you are unsure whether something exists, say so and suggest\n checking with `dir()` or the PyAutoLens docs (https://pyautolens.readthedocs.io).\n2. Real data needs a check first. Before helping fit real observational data, ask the\n user to plot it and confirm (a) whether extra galaxies, foreground stars or\n artefacts need masking or noise-scaling, and (b) a mask radius that contains all\n of the lensed emission. Never leave the mask radius at its default on real data.\n Simulated data from the workspace is exempt.\n3. Never claim a fit or a cell ran, or what it returned. Only the user's own outputs\n tell you that; ask them to share what they see.\n4. When the answer depends on something you do not know (their data, their goal, what\n a cell printed), ask one focused question rather than guessing.\n5. For multi-step projects (a new lens, a sample of lenses, a paper's analysis), point\n them to the PyAutoLens assistant https://github.com/PyAutoLabs/autolens_assistant,\n which runs in Claude Code or Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: al_adaptive_pixelization\n\n---\nname: al_adaptive_pixelization\ndescription: Build a model whose source reconstruction uses an adaptive pixelisation (mesh density tracks source brightness or surface density) and adaptive regularisation (smoothing strength inferred from data, not fixed). Use when a uniform mesh wastes resolution on dim regions and under-resolves bright ones \u2014 common for complex arcs and ALMA data. More expressive than `al_build_imaging_model`'s \"pixelised source\" branch, at the cost of more knobs. Pairs with `al_inspect_source_reconstruction` (post-fit inspection). Writes a runnable Python script in scripts/. **Status: stub.**\n---\n\n# Adaptive pixelisation + adaptive regularisation\n\nA fixed-resolution pixelised source treats every region of the source\nplane the same. An adaptive pixelisation concentrates pixels where the\nflux concentrates, and adaptive regularisation lets the smoothness\nstrength vary spatially \u2014 both inferred jointly with the lens parameters.\n\nThe wins are sharper at high signal-to-noise and especially for\ninterferometer data where rectangular meshes waste capacity on empty\nsky.\n\nWorkspace path: `autolens_workspace:scripts/imaging/features/pixelization/adaptive.py`,\n`scripts/imaging/features/pixelization/delaunay.py`.\n\n## Ask\n\n- *\"Mesh type \u2014 Delaunay (default adaptive), KMeans-Voronoi, Hilbert\n (memory-efficient for groups), rectangular adaptive?\"*\n- *\"Regularisation \u2014 constant (single strength), split (different per\n region), brightness-adaptive (strength scales with local source\n flux)?\"*\n- *\"Adapt-image source \u2014 do you have a converged base fit to use as the\n adapt-image, or should we bootstrap from a parametric source fit\n first?\"* \u2014 either way the source adapt image is capped at S/N 3.0\n before use (see below); the cap applies regardless of which fit it\n came from.\n\n## Adapt image S/N cap (always)\n\nThe Hilbert image-mesh weights and the adaptive regularization pixel signals are both\nmax-normalised \u2014 `(adapt_image / max) ** power` \u2014 so the brightest peak of a raw\nsignal-to-noise adapt image sets the scale for everything else. Fainter multiply-imaged\nfeatures fall to a few percent of that weight and lose both source-pixel density and\nregularization weight, so the source adapt image is capped at S/N 3.0 wherever it is\nbuilt. The library applies no cap; it is the script's job at every site.\n\nApply it immediately after the dict is built, before `al.AdaptImages(...)` or a Hilbert\n`image_plane_mesh_grid_from(adapt_data=...)`:\n\n```python\ngalaxy_image_name_dict = al.galaxy_name_image_dict_via_result_from(result=result)\n\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsource_adapt_image[source_adapt_image > adapt_image_snr_cap] = adapt_image_snr_cap\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\nRules:\n\n- **Cap a copy, never the dict entry in place.** `.copy()` keeps the class and mask; an\n in-place cap would silently truncate the raw image every later consumer shares.\n- **Anything that needs the raw image binds it before the cap.** The adaptive\n over-sampling map (`np.where(source_image_raw > 3.0, 4, 2)`, see \"Adaptive pixelization\n over-sampling\" below) reads a\n `source_image_raw = galaxy_image_name_dict[\"('galaxies', 'source')\"]` bound above the cap\n block, because the capped image never exceeds the threshold.\n- **Every stage that builds `AdaptImages` from a source image gets the cap** \u2014 `source_lp`,\n `source_pix_*`, `light_lp` and `mass_total` alike; the three lines repeat per stage.\n- Scripts with more than one source cap every source key present\n (`\"('galaxies', 'source_0')\"`, `\"('galaxies', 'source_1')\"`, \u2026).\n\n### Interferometer\n\nInterferometer adapt images are real-space *model* images in flux units\n(`use_model_images=True`): the interferometer dirty noise map is a dirty beam rather than a\nnoise map, so dividing by it does not give a usable S/N image. Derive the clip level from a\nbeam-smoothed S/N instead \u2014 transform the model image to a dirty image, divide by the\nhomogeneous image-plane noise, and clip the copy at the flux where that S/N crosses 3.0:\n\n```python\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsigma_pix = np.sqrt(0.5 * np.sum(np.abs(dataset.noise_map.array) ** 2))\ndirty_source = dataset.transformer.image_from(\n visibilities=dataset.transformer.visibilities_from(image=source_adapt_image)\n)\nabove_cap = source_adapt_image.array[\n dirty_source.array / sigma_pix > adapt_image_snr_cap\n]\nif above_cap.size:\n source_adapt_image[source_adapt_image > above_cap.min()] = above_cap.min()\nelse:\n print(\n \"Adapt image S/N cap not applied: no pixel reaches S/N 3.0 in the dirty image.\"\n )\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\n`sigma_pix` is the square root of *half* the summed visibility variances, because the dirty\nimage is the real part of the transform.\n\nWorked sites to copy from:\n\n- `autolens_workspace:scripts/guides/modeling/slam_start_here.py` \u2014 the canonical SLaM\n template, capped at every stage.\n- `autolens_workspace:scripts/imaging/features/pixelization/delaunay.py` \u2014 the cap feeding\n a Hilbert image mesh.\n- `autolens_workspace:scripts/interferometer/features/pixelization/slam.py` \u2014 the\n interferometer form above.\n\n## Adaptive pixelization over-sampling (from `source_pix_2`)\n\nEvery SLaM pipeline \u2014 imaging, group, multi-galaxy, multi-dataset \u2014 over-samples the\npixelization grid adaptively from `source_pix_2` onwards: sub-size 4 where the source is\ndetected, 2 elsewhere. Build the map from the *raw* (pre-cap) source entry and re-apply the\ndataset before the fit is composed; `source_pix_2` and every later stage receive that\ndataset.\n\n```python\nsignal_to_noise_threshold = 3.0\n\nsource_image_raw = al.galaxy_name_image_dict_via_result_from(result=source_pix_result_1)[\n \"('galaxies', 'source')\"\n]\n\nover_sample_size_pixelization = al.Array2D(\n values=np.where(source_image_raw > signal_to_noise_threshold, 4, 2), mask=dataset.mask\n)\n\ndataset = dataset.apply_over_sampling(\n over_sample_size_pixelization=over_sample_size_pixelization\n)\n```\n\n- **The dict entry is already a signal-to-noise map**, so it is thresholded directly.\n `al.galaxy_name_image_dict_via_result_from` with its default `use_model_images=False`\n returns `subtracted_image / noise_map` (its on-disk cache is literally\n `galaxy_images_snr.fits`); only `use_model_images=True` returns flux-unit model images.\n The function's docstring says \"model-image\" and is misleading.\n- **Never do this:** `al.util.over_sample.over_sample_size_via_adapt_from(data=,\n noise_map=...)`. That helper divides by the noise itself, so feeding it an S/N map divides\n twice (~18x inflation on HST-depth data \u2014 ~90 % of the mask at sub-size 4 instead of\n ~30 %), and its `signal_to_noise_cut` defaults to 5.0 and silently drops to `max / 2` when\n the map's maximum is below `2 * cut`. The workspace no longer calls it anywhere.\n- **`source_pix_1` is exempt** \u2014 it keeps the library default uniform sub-size (4), because\n its adapt image comes from the parametric SOURCE LP fit and is not yet reliable enough to\n steer over-sampling.\n- **Interferometer SLaM does not do this** \u2014 a pixelization fit to visibilities cannot be\n over-sampled.\n\n## Branch \u2014 Delaunay mesh + adaptive brightness regularisation\n\n> TODO: recipe. Pattern: `pix = al.Pixelization(mesh=al.mesh.Delaunay(...),\n> regularization=al.reg.AdaptiveBrightnessSplit(...))` ; wrap in a\n> `Galaxy`; analysis needs `adapt_images` set from the bootstrap fit.\n> See `PyAutoArray:autoarray/inversion/...` and\n> `PyAutoLens:autolens/imaging/...`.\n\n## Branch \u2014 bootstrap workflow\n\nTwo-stage: (1) fit with a parametric source to produce an adapt image,\n(2) refit with the adaptive pixelisation, using the parametric model\nimage as the adapt source.\n\n> TODO: recipe.\n\n## Combine\n\n- [`al_inspect_source_reconstruction`](./al_inspect_source_reconstruction.md)\n \u2014 post-fit inspection of the reconstructed source + regularisation\n state.\n- [`al_chain_searches`](./al_chain_searches.md) \u2014 the bootstrap is a\n classic two-search chain.\n- [`al_datacube_modeling`](./al_datacube_modeling.md) \u2014 datacube\n pixelisation is similar but spectrally extended.\n\n## Further reading\n\n- **Student / new to lensing** \u2014 [HowToLens: chapter_3 tutorial_8 adaptive](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb):\n pedagogical adaptive-pixelisation walkthrough.\n- **General reference** \u2014 [RTD: overview_3_features](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n pixelisation feature section.\n- **Experienced PyAutoLens user** \u2014 [workspace/lens: imaging/features/pixelization/adaptive.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/adaptive.py):\n the canonical adaptive setup.\n\nSee also [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md)\nfor the underlying inversion theory.\n\n## Skill: al_inspect_source_reconstruction\n\n---\nname: al_inspect_source_reconstruction\ndescription: Inspect a pixelised source reconstruction from a completed fit \u2014 source-plane image, mesh / regularisation state, reconstruction error, and the inversion's linear solution. Pixelised sources are common for complex arcs and for ALMA/JVLA visibilities. Use after `al_load_results` has handed you the dataset + tracer; needs to rebuild the `FitImaging` because pixelisation state isn't stored in `tracer.json`.\n---\n\n# Inspecting a pixelised source reconstruction\n\nWhen the source has been reconstructed on a pixel grid (Delaunay / Voronoi /\nrectangular mesh) rather than as a parametric Sersic, the interesting object is the\n**inversion** \u2014 the linear solution that mapped source-plane pixels to image-plane\ncounts. Pixelised reconstructions live in `FitImaging.inversion`, *not* in the\nsaved `tracer.json`. To see them you have to rebuild the fit.\n\nFor the theory \u2014 what regularisation does, what makes a pixelisation adaptive vs.\nfixed, why the inversion can demagnify pathologically without a positions penalty \u2014\nread [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\nCanonical reference:\n`autolens_workspace:scripts/imaging/features/pixelization/plot.py`.\n\n## Ask\n\n- *\"What do you want to look at \u2014 the source image on its mesh, the regularisation\n matrix structure, the reconstruction error, the data-mapping matrix?\"*\n- *\"Was this a parametric or pixelised fit?\"* \u2014 if parametric, this skill doesn't\n apply; use [`al_plot_tracer`](./al_plot_tracer.md) for the source-plane image.\n\n## Saving plots\n\n`autolens.plot` in `2026.5.21+` is a flat module of free functions \u2014 the old\n`InversionPlotter` / `MatPlot2D` / `Output` classes are gone. Plot the\ninversion's component arrays directly via `aplt.plot_array`:\n\n```python\nfrom pathlib import Path\nimport autolens as al\nimport autolens.plot as aplt\n\nPLOT_DIR = Path(\"scripts/scratch\") / \"\" # pick a meaningful slug\nPLOT_DIR.mkdir(parents=True, exist_ok=True)\n```\n\nAfter running, the agent quotes `PLOT_DIR.resolve()` and offers\n`open ` \u2014 see `_style.md` \"Plot output and path announcement\". The\nfunction-style plot API is documented in\n[`wiki/core/api/plotting.md`](../wiki/core/api/plotting.md).\n\n> \u26a0\ufe0f **Known regression (still open as of `2026.7.6`).** `Delaunay` and\n> `KNNBarycentric` crash inside `FitImaging` (`'NoneType' object has no attribute\n> 'array'`), and `ConstantSplit` is broken on `RectangularUniform`. Use\n> `al.mesh.RectangularUniform` + `al.reg.Constant` for now; tracking:\n> .\n\n## Branch \u2014 source-plane reconstruction\n\nRebuild the fit so the inversion is computed, then plot the components:\n\nThe quickest full view is the fit subplot \u2014 when the fit uses an inversion its\nfinal panel *is* the source-plane reconstruction:\n\n```python\n# `dataset` from al_prepare_imaging_data, `tracer` from al_load_results.\nfit = al.FitImaging(dataset=dataset, tracer=tracer)\n\naplt.subplot_fit_imaging(\n fit=fit, output_path=str(PLOT_DIR), output_format=\"png\",\n)\n```\n\nFor component-level access, the inversion exposes (names verified against the\n2026.7 stack):\n\n```python\ninv = fit.inversion\n\n# Image-plane image the linear solution reconstructs (Array2D \u2014 plottable):\naplt.plot_array(array=inv.mapped_reconstructed_data,\n output_path=str(PLOT_DIR),\n output_filename=\"mapped_reconstruction\",\n output_format=\"png\")\n\n# Source-plane solution: a 1D vector of per-source-pixel intensities\n# (keyed per mapper in inv.reconstruction_dict). For a RectangularUniform\n# mesh, reshape to the mesh's native 2D shape to plot it.\nreconstruction = inv.reconstruction\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nSource: `PyAutoArray:autoarray/inversion/inversion/`.\n\n## Branch \u2014 mesh + regularisation diagnostics\n\nThe previous object-oriented inversion plotter API is removed.\nThe closest current entry point is `aplt.subplot_basis_image`, which renders\nthe mapper's pixelisation overlaid on the image:\n\n```python\naplt.subplot_basis_image(\n inversion=fit.inversion,\n output_path=str(PLOT_DIR), output_format=\"png\",\n)\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nThe mesh-triangulation and regularisation-neighbour graphs no longer have\ndirect plot helpers; build them with NumPy and `aplt.plot_grid` if needed.\n\n## Branch \u2014 double source-plane lens (DSPL) / multi-plane pixelisations\n\nFor systems with two source planes (each pixelised independently), the\n`pixelization_index` argument indexes into the planes. See\n`autolens_workspace:scripts/imaging/features/advanced/double_source_plane_lens/plot.py`.\n\n## Quality checks\n\nA healthy pixelised reconstruction:\n\n- Source-plane reconstruction shows recognisable structure (clumps, arms), not white\n noise.\n- Reconstruction error / signal-to-noise is positive across the source.\n- Regularisation weights smoothly decrease toward the edges (for adaptive variants).\n- The reconstructed image-plane model matches the data with featureless residuals\n (see [`al_plot_fit_residuals`](./al_plot_fit_residuals.md)).\n\nPathological signs:\n\n- Source-plane is a single bright pixel \u2014 the inversion has demagnified. Add image\n positions and refit.\n- Reconstruction looks like the noise map \u2014 regularisation is too low. Raise the\n regularisation coefficient range.\n- Source is unphysically extended beyond the caustic \u2014 mass model is wrong; the\n positions penalty isn't holding it in.\n\nConnect to [`al_debug_fit_failure`](./al_debug_fit_failure.md) when any of these\nappear.\n\n## Combine\n\n- [`al_plot_tracer`](./al_plot_tracer.md) \u2014 overlay caustics on the source-plane\n reconstruction to confirm signal is inside the caustic.\n- [`al_chain_searches`](./al_chain_searches.md) \u2014 the canonical chain for moving from\n a parametric source fit to a pixelised one.\n- [`al_run_slam_pipeline`](./al_run_slam_pipeline.md) \u2014 automated pipeline that\n handles the parametric \u2192 pixelised transition.\n\n## Further reading\n\n- **Student / new to lensing** \u2014 [HowToLens: Reconstructing source light via\n least-squares](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_inversions.ipynb):\n the inversion at its heart \u2014 least-squares solution for source-plane pixel\n intensities. Chapter 3 as a whole covers mappers, regularization, adaptive grids.\n- **General reference** \u2014 [RTD: Features overview](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n the pixelization feature sits here alongside MGE / interferometry / shapelets \u2014\n links into deeper docs.\n- **Experienced PyAutoLens user** \u2014 [workspace/lens: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n rectangular-mesh + constant-regularization pixelization model \u2014 production\n pattern for extended-arc sources.\n" + } + }, + "selectedAiContext": "97746750-6915-569b-a61d-792a6bc69437" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb b/notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb new file mode 100644 index 0000000..81bb47c --- /dev/null +++ b/notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb @@ -0,0 +1,2069 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Tutorial 6: Adaptive Reconstruction\n", + "===================================\n", + "\n", + "Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization in a chained lens-model fit.\n", + "\n", + "__Contents__\n", + "\n", + "- Adaptive source reconstruction\n", + "- Returning to the compact-source problem\n", + "- From an image mesh to a source mesh\n", + "- Constructing the Delaunay source mesh\n", + "- Interpolating within a triangle\n", + "- Regularization on an irregular mesh\n", + "- Building an adapt image\n", + "- Placing points with a Hilbert image mesh\n", + "- Controlling the brightness weighting\n", + "- Keeping resolution in the faint regions\n", + "- Reconstructing with the Hilbert mesh\n", + "- Comparing overlay and Hilbert reconstructions\n", + "- Smoothing on an irregular mesh\n", + "- Reconstructing with ConstantSplit\n", + "- Allowing different smoothing strengths\n", + "- Controlling the transition with `signal_scale`\n", + "- Exploring the adaptive coefficients\n", + "- Does adaptive regularization address the fit problem?\n", + "- Starting the final modelling chain\n", + "- Preparing adaptation and the positions likelihood penalty\n", + "- Defining the adaptive Delaunay model\n", + "- Running the adaptive Delaunay fit\n", + "- Inspecting the source and smoothing pattern\n", + "- Wrap Up" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Google Colab Setup__\n", + "\n", + "Set up Google Colab as in [the introductory notebook](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_1_introduction/tutorial_1_grids_and_galaxies.ipynb).\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "cellView": "form", + "jupyter": { + "source_hidden": true + } + }, + "outputs": [], + "source": [ + "# @title Set up Google Colab { display-mode: \"form\" }\n", + "try:\n", + " import google.colab\n", + "except ImportError:\n", + " from autolens import setup_colab as _setup_colab\n", + "else:\n", + " import importlib\n", + " import subprocess\n", + " import sys\n", + "\n", + " subprocess.check_call(\n", + " [sys.executable, \"-m\", \"pip\", \"install\", \"autonerves\", \"--no-deps\"]\n", + " )\n", + " _setup_colab = importlib.import_module(\"autonerves.setup_colab\")\n", + "\n", + "_setup_colab.setup(\"howtolens\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Adaptive source reconstruction__\n", + "\n", + "In **Fit Problems**, we identified two limitations of our reconstruction: too few mesh points across compact source structure, and a single regularization coefficient that must compromise between the bright centre and faint outskirts.\n", + "\n", + "We will now introduce methods that address these problems.\n", + "\n", + "Our first step is to replace the rectangular source mesh with a **Delaunay mesh**. Its points need not lie in regular rows and columns, allowing us to vary their spacing across the source.\n", + "\n", + "We then need to decide where to place those points. We will do this in the image plane, using an **image mesh**, and ray-trace its coordinates to obtain the source-mesh points.\n", + "\n", + "This gives us two useful ways to allocate resolution:\n", + "\n", + "- An evenly spaced image-plane overlay produces source-plane sampling that responds to the lens magnification.\n", + "- A brightness-adaptive image mesh places more points in bright image regions, directing more resolution towards the source emission.\n", + "\n", + "Finally, we will introduce adaptive regularization, allowing the smoothing strength to vary across the reconstructed source.\n", + "\n", + "These choices work together. Placing more points across a bright component gives the reconstruction enough resolution to describe it; allowing appropriate brightness differences between those points lets it use that resolution.\n", + "\n", + "We will proceed through:\n", + "\n", + "- Constructing an image-plane overlay.\n", + "- Ray-tracing it to build a Delaunay source mesh.\n", + "- Understanding interpolation and regularization on that mesh.\n", + "- Using a fitted image to guide Hilbert brightness adaptation.\n", + "- Introducing Split and adaptive regularization.\n", + "- Comparing reconstructions on the same dataset.\n", + "- Performing a lens-model fit with an adaptive pixelization.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Returning to the compact-source problem__\n", + "\n", + "We return to the very compact source from **Fit Problems**. Keeping the same example lets us examine whether the adaptive methods address the difficulties we have just investigated.\n", + "\n", + "The setup below reproduces that dataset, including its lens model, PSF and Gaussian noise realization. This makes the tutorial runnable independently." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "from autolens import jax_wrapper\n", + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import autolens as al\n", + "import autolens.plot as aplt\n", + "import autoarray.plot as aaplt\n", + "\n", + "simulation_grid = al.Grid2D.uniform(\n", + " shape_native=(150, 150),\n", + " pixel_scales=0.05,\n", + ")\n", + "simulation_grid = simulation_grid.apply_over_sampling(\n", + " over_sample_size=8,\n", + ")\n", + "\n", + "psf = al.Convolver.from_gaussian(\n", + " shape_native=(11, 11),\n", + " sigma=0.05,\n", + " pixel_scales=0.05,\n", + ")\n", + "\n", + "lens_galaxy = al.Galaxy(\n", + " redshift=0.5,\n", + " mass=al.mp.Isothermal(\n", + " centre=(0.0, 0.0),\n", + " ell_comps=(0.111111, 0.0),\n", + " einstein_radius=1.6,\n", + " ),\n", + ")\n", + "\n", + "true_source = al.Galaxy(\n", + " redshift=1.0,\n", + " bulge=al.lp.Sersic(\n", + " centre=(0.0, 0.0),\n", + " ell_comps=(0.0, 0.15),\n", + " intensity=0.2,\n", + " effective_radius=0.1,\n", + " sersic_index=4.0,\n", + " ),\n", + ")\n", + "\n", + "true_tracer = al.Tracer(\n", + " galaxies=[lens_galaxy, true_source],\n", + ")\n", + "\n", + "simulator = al.SimulatorImaging(\n", + " exposure_time=300.0,\n", + " psf=psf,\n", + " background_sky_level=0.1,\n", + " add_poisson_noise_to_data=False,\n", + ")\n", + "\n", + "noiseless = simulator.via_tracer_from(\n", + " tracer=true_tracer,\n", + " grid=simulation_grid,\n", + ")\n", + "\n", + "noise_sigma = 0.05\n", + "noise_values = np.random.default_rng(1).normal(\n", + " loc=0.0,\n", + " scale=noise_sigma,\n", + " size=simulation_grid.shape_native,\n", + ")\n", + "\n", + "dataset = al.Imaging(\n", + " data=al.Array2D.no_mask(\n", + " values=np.asarray(noiseless.data.native) + noise_values,\n", + " pixel_scales=0.05,\n", + " ),\n", + " noise_map=al.Array2D.no_mask(\n", + " values=np.full(simulation_grid.shape_native, noise_sigma),\n", + " pixel_scales=0.05,\n", + " ),\n", + " psf=psf,\n", + ")\n", + "\n", + "mask = al.Mask2D.circular(\n", + " shape_native=simulation_grid.shape_native,\n", + " pixel_scales=0.05,\n", + " radius=3.0,\n", + ")\n", + "\n", + "dataset = dataset.apply_mask(mask=mask)\n", + "dataset = dataset.apply_over_sampling(\n", + " over_sample_size_pixelization=2,\n", + ")\n", + "\n", + "aplt.subplot_imaging_dataset(dataset=dataset)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Initially, we keep the true mass model fixed. This lets us inspect how changing the source mesh and regularization affects the reconstruction before combining them with a non-linear mass-model search.\n", + "\n", + "We know the simulated source profile, but we will not use its brightness distribution to tell the adaptive mesh where to place its points. Brightness adaptation will use a fitted model image, as it would for observed data.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__From an image mesh to a source mesh__\n", + "\n", + "Previously, we constructed a rectangular mesh directly in the source plane.\n", + "\n", + "For our Delaunay reconstruction, we instead begin by selecting coordinates in the **image plane**. These coordinates form the image mesh. Ray-tracing them through the lens model gives the points from which we construct the source mesh.\n", + "\n", + "The simplest example is an `Overlay`, which lays a coarse, evenly spaced grid over the image and retains the points inside the mask." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "image_mesh = al.image_mesh.Overlay(\n", + " shape=(25, 25),\n", + ")\n", + "\n", + "image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(\n", + " mask=dataset.mask,\n", + ")\n", + "\n", + "print(\n", + " \"Number of image-mesh points inside the mask:\",\n", + " image_plane_mesh_grid.shape[0],\n", + ")\n", + "\n", + "aplt.plot_array(\n", + " array=dataset.data,\n", + " grid=image_plane_mesh_grid,\n", + " title=\"Image-Plane Mesh: Uniform Overlay\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "These are mesh coordinates, rather than a selection of the only image pixels we will fit. All unmasked data pixels still contribute to the reconstruction.\n", + "\n", + "The overlay\u2019s shape describes the grid before masking. Its retained number of points is therefore smaller than `25 \u00d7 25` for our circular mask.\n", + "\n", + "Now trace those mesh coordinates into the source plane." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "traced_mesh_grid = true_tracer.traced_grid_2d_list_from(\n", + " grid=image_plane_mesh_grid\n", + ")[1]\n", + "\n", + "aaplt.plot_grid(\n", + " grid=traced_mesh_grid,\n", + " title=\"Image-Mesh Points Traced into the Source Plane\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The evenly spaced image-plane points are no longer evenly spaced after ray tracing.\n", + "\n", + "In highly magnified regions, a given source-plane area corresponds to a larger image-plane area. An evenly sampled image mesh therefore tends to provide more traced points per unit source-plane area there. The sampling can also be stretched differently in different directions.\n", + "\n", + "This lets the source mesh respond to the lensing geometry. However, the overlay does not inspect the image brightness: changing our source from extended to compact would leave these mesh coordinates unchanged for a fixed mask and mass model.\n", + "\n", + "That is why magnification-based sampling is useful but does not completely address the problem from the previous tutorial.\n", + "\n", + "Next, we will connect the traced points into a Delaunay triangulation and use it to reconstruct the source. Border relocation will remain enabled when constructing the fit; the plot above shows the direct ray tracing before that treatment." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Constructing the Delaunay source mesh__\n", + "\n", + "We now connect the traced mesh points into a **Delaunay triangulation**.\n", + "\n", + "Unlike a rectangular mesh, the triangles can have different sizes and orientations. Their vertices follow the distribution of the ray-traced image-mesh points, allowing finer sampling in some source regions than others.\n", + "\n", + "The source brightness values are assigned to the **vertices**, rather than one constant brightness being assigned to each triangle. We will examine how brightness is calculated between the vertices in the next block.\n", + "\n", + "The helper below constructs a Delaunay reconstruction using our image-plane overlay. It also lets us vary the regularization coefficient." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "dataset = dataset.apply_sparse_operator()\n", + "\n", + "\n", + "def fit_delaunay_overlay(coefficient):\n", + " pixelization = al.Pixelization(\n", + " mesh=al.mesh.Delaunay(\n", + " pixels=image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=al.reg.Constant(\n", + " coefficient=coefficient,\n", + " ),\n", + " )\n", + "\n", + " source_galaxy = al.Galaxy(\n", + " redshift=1.0,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " tracer = al.Tracer(\n", + " galaxies=[lens_galaxy, source_galaxy],\n", + " )\n", + "\n", + " adapt_images = al.AdaptImages(\n", + " galaxy_image_plane_mesh_grid_dict={\n", + " source_galaxy: image_plane_mesh_grid,\n", + " },\n", + " )\n", + "\n", + " return al.FitImaging(\n", + " dataset=dataset,\n", + " tracer=tracer,\n", + " adapt_images=adapt_images,\n", + " settings=al.Settings(use_border_relocator=True),\n", + " )\n", + "\n", + "\n", + "overlay_fit = fit_delaunay_overlay(coefficient=1.0)\n", + "overlay_mapper = overlay_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=overlay_mapper,\n", + " solution_vector=overlay_fit.inversion.reconstruction,\n", + " mesh_grid=overlay_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Delaunay Reconstruction from an Image-Plane Overlay\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=overlay_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "`AdaptImages` supplies the image-mesh coordinates associated with the pixelized source galaxy. Here, we are supplying an overlay; we have not yet introduced brightness adaptation.\n", + "\n", + "Inspect the mesh points around the reconstructed source. Their spacing follows the lens mapping rather than a regular source-plane lattice.\n", + "\n", + "The coefficient of `1.0` is only a starting value. We should not judge whether this mesh improves the fit until we have explored its regularization.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Interpolating within a triangle__\n", + "\n", + "How does a triangular mesh predict the brightness at a ray-traced coordinate?\n", + "\n", + "Recall our rectangular mesh: bilinear interpolation combined the brightness values at four surrounding mesh points.\n", + "\n", + "For a Delaunay mesh, we locate the triangle containing the coordinate and interpolate between its **three vertices**. Each vertex contributes according to a weight determined by the coordinate\u2019s position within the triangle.\n", + "\n", + "At a vertex, that vertex supplies the brightness. At the midpoint of an edge, the two edge vertices contribute equally. At the triangle\u2019s centroid, all three vertices contribute equally.\n", + "\n", + "The brightness therefore varies continuously across each triangle. It is not a collection of uniformly bright triangular patches.\n", + "\n", + "We can inspect the mapping weights for an image pixel, using the same approach as tutorial 1." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "image_index = int(\n", + " np.argmax(np.asarray(dataset.data.slim))\n", + ")\n", + "\n", + "mapping_weights = np.asarray(\n", + " overlay_mapper.mapping_matrix[image_index]\n", + ")\n", + "\n", + "source_indexes = np.flatnonzero(\n", + " np.abs(mapping_weights) > 1.0e-12\n", + ")\n", + "\n", + "reconstruction = np.asarray(\n", + " overlay_fit.inversion.reconstruction\n", + ")\n", + "\n", + "for source_index in source_indexes:\n", + " weight = mapping_weights[source_index]\n", + " brightness = reconstruction[source_index]\n", + "\n", + " print(\n", + " f\"Source vertex {source_index}: \"\n", + " f\"weight={weight:.5g}, \"\n", + " f\"brightness={brightness:.5g}, \"\n", + " f\"contribution={weight * brightness:.5g}\"\n", + " )\n", + "\n", + "print(\n", + " \"Predicted image-pixel brightness before PSF blurring:\",\n", + " float(np.dot(mapping_weights, reconstruction)),\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We have enabled image-pixel oversampling. Each sub-pixel sample is interpolated within its triangle, and those contributions are combined for the image pixel. Consequently, the printed row can contain contributions from more than three source vertices if its samples fall in different triangles.\n", + "\n", + "The PSF then redistributes the predicted light between image pixels, as in our rectangular reconstruction.\n", + "\n", + "Interpolation still does not determine the vertex brightnesses. Those are solved by the inversion, with regularization controlling how freely they can vary.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Regularization on an irregular mesh__\n", + "\n", + "We begin with the same `Constant` regularization scheme used earlier.\n", + "\n", + "For the rectangular mesh, an interior point had four immediate neighbours. On a Delaunay mesh, neighbours are connected by triangle edges. The number of neighbours can vary from one vertex to another; belonging to a triangular mesh does not mean every vertex has only three neighbours.\n", + "\n", + "Regularization penalizes brightness differences between connected vertices. One coefficient controls the strength of this smoothing across the mesh.\n", + "\n", + "The neighbour connections and spacings differ from the rectangular case, so we should not assume that its preferred coefficient transfers to the Delaunay mesh.\n", + "\n", + "Let\u2019s evaluate a range of coefficients." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "overlay_coefficients = np.logspace(-3, 3, 13)\n", + "overlay_evidences = []\n", + "best_evidence = -np.inf\n", + "\n", + "# Keep only the preferred full fit; other scan entries are scalar evidences.\n", + "for coefficient in overlay_coefficients:\n", + " coefficient = float(coefficient)\n", + " current_fit = fit_delaunay_overlay(coefficient=coefficient)\n", + " evidence = float(current_fit.log_evidence)\n", + " overlay_evidences.append(evidence)\n", + " if evidence > best_evidence:\n", + " best_evidence = evidence\n", + " overlay_fit = current_fit\n", + "\n", + "overlay_evidences = np.asarray(overlay_evidences)\n", + "if not np.all(np.isfinite(overlay_evidences)):\n", + " raise ValueError(\"Inspect non-finite evidence values before continuing.\")\n", + "\n", + "best_index = int(np.argmax(overlay_evidences))\n", + "if best_index in (0, len(overlay_coefficients) - 1):\n", + " raise ValueError(\"Extend the coefficient range to bracket the overlay peak.\")\n", + "\n", + "overlay_coefficient = float(overlay_coefficients[best_index])\n", + "\n", + "fig, ax = plt.subplots()\n", + "ax.semilogx(\n", + " overlay_coefficients,\n", + " overlay_evidences - np.max(overlay_evidences),\n", + " marker=\"o\",\n", + ")\n", + "ax.set_xlabel(\"Regularization coefficient\")\n", + "ax.set_ylabel(\"Log evidence relative to the highest sampled value\")\n", + "ax.set_title(\"Delaunay Overlay: Constant Regularization\")\n", + "plt.show()\n", + "\n", + "print(\"Preferred sampled coefficient:\", overlay_coefficient)\n", + "\n", + "aplt.subplot_fit_imaging(fit=overlay_fit)\n", + "aplt.subplot_fit_imaging_of_planes(fit=overlay_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "As before, inspect the peak and refine the coefficient sampling around it before making a close quantitative comparison.\n", + "\n", + "Now examine the source centre and bright arc residuals. Has the overlay placed enough vertices across the compact component? Does the preferred smoothing reproduce its steep brightness gradient?\n", + "\n", + "We have changed the mesh geometry, but two limitations remain. The overlay does not respond to the source brightness, and one coefficient still controls regularization everywhere.\n", + "\n", + "Next, we will use a fitted image to guide where the image-mesh points are placed, concentrating more sampling on the source emission." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Building an adapt image__\n", + "\n", + "Our overlay responds to the lensing geometry, but it does not distinguish bright arcs from regions containing only background noise.\n", + "\n", + "To direct more mesh points towards the source emission, we need an image describing where that emission is located. We call this the **adapt image**.\n", + "\n", + "We use the model image from our initial reconstruction. Unlike the observed image, it does not contain the individual noise fluctuations that the model leaves unexplained. It provides a fitted estimate of where the lensed source light lies." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "adapt_image = overlay_fit.model_data\n", + "\n", + "aplt.plot_array(\n", + " array=overlay_fit.model_data,\n", + " title=\"Adapt Image: Fitted Lensed Source Light\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=overlay_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the adapt image with the data and residuals. Does it locate the bright arcs correctly, even if it does not yet reproduce their sharpest features?\n", + "\n", + "The initial reconstruction need not be perfect to provide useful guidance. However, if it misses a source component entirely, the adapt image may allocate too little resolution to that component. We should therefore inspect the starting fit before using it to guide the next mesh.\n", + "\n", + "Our dataset contains no foreground lens light, so the complete model image represents the lensed source. When foreground light is present, we must use the model contribution from the **source galaxy**, rather than allowing the foreground galaxy\u2019s bright centre to determine where source-mesh points are placed.\n", + "\n", + "We will keep this adapt image fixed while comparing mesh settings. It guides the placement of points; it does not fix the brightness values reconstructed on the new mesh.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Placing points with a Hilbert image mesh__\n", + "\n", + "We now replace the evenly spaced overlay with a **Hilbert image mesh**.\n", + "\n", + "The Hilbert approach orders locations along a space-filling path through the image. Brightness-derived weights determine how points are distributed along that path, allocating more sampling to regions with larger weights.\n", + "\n", + "The path itself is not the source mesh. The resulting image-plane coordinates are ray-traced and connected into a Delaunay mesh, just as our overlay coordinates were.\n", + "\n", + "Let\u2019s construct a brightness-weighted image mesh using the same number of points as our retained overlay. This allows us to examine a change in their distribution without deliberately increasing their total number." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "hilbert_pixels = image_plane_mesh_grid.shape[0]\n", + "\n", + "hilbert_image_mesh = al.image_mesh.Hilbert(\n", + " pixels=hilbert_pixels,\n", + " weight_power=1.0,\n", + " weight_floor=0.05,\n", + ")\n", + "\n", + "hilbert_image_plane_mesh_grid = (\n", + " hilbert_image_mesh.image_plane_mesh_grid_from(\n", + " mask=dataset.mask,\n", + " adapt_data=adapt_image,\n", + " )\n", + ")\n", + "\n", + "for label, mesh_grid in [\n", + " (\"Uniform Overlay\", image_plane_mesh_grid),\n", + " (\"Brightness-Weighted Hilbert\", hilbert_image_plane_mesh_grid),\n", + "]:\n", + " print(f\"{label}: {mesh_grid.shape[0]} image-mesh points\")\n", + "\n", + " aplt.plot_array(\n", + " array=dataset.data,\n", + " grid=mesh_grid,\n", + " title=label,\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the spacing of the points across the bright arcs and the surrounding background.\n", + "\n", + "The overlay distributes its points evenly across the image mask. The Hilbert mesh uses the adapt image to concentrate more points on the emission, while the non-zero weight floor retains some sampling in faint regions.\n", + "\n", + "Now trace the Hilbert points into the source plane." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "hilbert_traced_mesh_grid = true_tracer.traced_grid_2d_list_from(\n", + " grid=hilbert_image_plane_mesh_grid\n", + ")[1]\n", + "\n", + "aaplt.plot_grid(\n", + " grid=hilbert_traced_mesh_grid,\n", + " title=\"Brightness-Weighted Points Traced into the Source Plane\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The point distribution now responds to both the image brightness and the lens mapping.\n", + "\n", + "This addresses the distinction raised in **Fit Problems**: the magnification field remains unchanged when the source becomes more compact, but the brightness distribution changes. A brightness-weighted image mesh can respond by placing more points across the compact emission.\n", + "\n", + "Next, let\u2019s examine how its weights are calculated.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Controlling the brightness weighting__\n", + "\n", + "Two parameters control how the adapt image becomes a weight map: `weight_power` and `weight_floor`.\n", + "\n", + "The calculation first takes the absolute adapt-image values and divides them by their maximum. For our positive model image, this gives normalized brightnesses between zero and one.\n", + "\n", + "It then raises those values to `weight_power` and replaces any weight below `weight_floor` with that minimum value.\n", + "\n", + "Consider three powers:\n", + "\n", + "- `0.0` gives equal weights, removing the brightness preference.\n", + "- `1.0` retains the normalized brightness weighting.\n", + "- A value greater than `1.0` increases the contrast between bright and faint regions.\n", + "\n", + "For example, a normalized brightness of `0.5` becomes `0.03125` when raised to the fifth power, whereas the brightest value remains `1.0`. Larger powers therefore concentrate the sampling more strongly on the brightest emission.\n", + "\n", + "Let\u2019s inspect the weight maps and resulting point distributions together." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "for weight_power in [0.0, 1.0, 5.0]:\n", + " trial_image_mesh = al.image_mesh.Hilbert(\n", + " pixels=hilbert_pixels,\n", + " weight_power=weight_power,\n", + " weight_floor=0.05,\n", + " )\n", + "\n", + " weights = trial_image_mesh.weight_map_from(\n", + " adapt_data=adapt_image,\n", + " )\n", + "\n", + " weight_map = al.Array2D(\n", + " values=weights,\n", + " mask=dataset.mask,\n", + " )\n", + "\n", + " trial_mesh_grid = trial_image_mesh.image_plane_mesh_grid_from(\n", + " mask=dataset.mask,\n", + " adapt_data=adapt_image,\n", + " )\n", + "\n", + " aplt.plot_array(\n", + " array=weight_map,\n", + " vmin=0.0,\n", + " vmax=1.0,\n", + " title=f\"Weight Map: Power {weight_power:g}\",\n", + " )\n", + "\n", + " aplt.plot_array(\n", + " array=dataset.data,\n", + " grid=trial_mesh_grid,\n", + " title=f\"Image-Mesh Points: Power {weight_power:g}\",\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Follow how increasing the power changes the allocation of points. Does the mesh resolve the brightest parts more densely? How much sampling remains across the fainter emission?\n", + "\n", + "A higher power is not automatically better. Concentrating too many points on the brightest peaks can leave too few to describe the rest of the source.\n", + "\n", + "The weight floor helps moderate this effect. Here, every location has a weight of at least `0.05`. This is a minimum weight, not a statement that five per cent of the mesh points must lie in the background.\n", + "\n", + "Next, we will vary that floor and reconstruct the source with the Hilbert mesh, checking the residuals and evidence rather than judging the mesh by its appearance alone." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Keeping resolution in the faint regions__\n", + "\n", + "Increasing `weight_power` concentrates points on the brightest parts of the adapt image. However, the source may also have faint extended emission that needs to be reconstructed.\n", + "\n", + "The **weight floor** prevents those regions from receiving arbitrarily small weights. After applying the power, any weight below the floor is raised to that value.\n", + "\n", + "For example, with a floor of `0.05`, weights of `0.001` and `0.02` both become `0.05`, while a weight of `0.8` remains unchanged.\n", + "\n", + "Let\u2019s compare three floors while keeping the power and number of mesh points fixed." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "for weight_floor in [0.0, 0.05, 0.5]:\n", + " trial_image_mesh = al.image_mesh.Hilbert(\n", + " pixels=hilbert_pixels,\n", + " weight_power=5.0,\n", + " weight_floor=weight_floor,\n", + " )\n", + "\n", + " weights = trial_image_mesh.weight_map_from(\n", + " adapt_data=adapt_image,\n", + " )\n", + "\n", + " weight_map = al.Array2D(\n", + " values=weights,\n", + " mask=dataset.mask,\n", + " )\n", + "\n", + " trial_mesh_grid = trial_image_mesh.image_plane_mesh_grid_from(\n", + " mask=dataset.mask,\n", + " adapt_data=adapt_image,\n", + " )\n", + "\n", + " aplt.plot_array(\n", + " array=weight_map,\n", + " vmin=0.0,\n", + " vmax=1.0,\n", + " title=f\"Weight Map: Floor {weight_floor:g}\",\n", + " )\n", + "\n", + " aplt.plot_array(\n", + " array=dataset.data,\n", + " grid=trial_mesh_grid,\n", + " title=f\"Image-Mesh Points: Floor {weight_floor:g}\",\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the points around the brightest peaks, along the fainter arcs, and in the background.\n", + "\n", + "Increasing the floor reduces the contrast between their weights. This retains more sampling away from the brightest emission, but leaves fewer points available to concentrate on its peaks.\n", + "\n", + "Because the background can cover much more area than the arcs, even a modest floor can affect the allocation substantially. We should inspect the resulting mesh rather than interpret the floor as a fixed fraction of points assigned to faint regions.\n", + "\n", + "For our first reconstruction, we return to the mesh already constructed with `weight_power=1.0` and `weight_floor=0.05`. These are starting choices to investigate, rather than settings we have established as optimal.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Reconstructing with the Hilbert mesh__\n", + "\n", + "We now use the Hilbert coordinates to build a Delaunay source mesh.\n", + "\n", + "The reconstruction proceeds as before: ray-trace the image-mesh points, construct the source triangulation, and solve for its vertex brightnesses. The difference is where we placed the image-mesh points.\n", + "\n", + "We retain `Constant` regularization for now, so that we can investigate brightness-based mesh placement before introducing adaptive smoothing." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "def fit_delaunay_hilbert(coefficient):\n", + " pixelization = al.Pixelization(\n", + " mesh=al.mesh.Delaunay(\n", + " pixels=hilbert_image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=al.reg.Constant(\n", + " coefficient=coefficient,\n", + " ),\n", + " )\n", + "\n", + " source_galaxy = al.Galaxy(\n", + " redshift=1.0,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " tracer = al.Tracer(\n", + " galaxies=[lens_galaxy, source_galaxy],\n", + " )\n", + "\n", + " adapt_images = al.AdaptImages(\n", + " galaxy_image_dict={\n", + " source_galaxy: adapt_image,\n", + " },\n", + " galaxy_image_plane_mesh_grid_dict={\n", + " source_galaxy: hilbert_image_plane_mesh_grid,\n", + " },\n", + " )\n", + "\n", + " return al.FitImaging(\n", + " dataset=dataset,\n", + " tracer=tracer,\n", + " adapt_images=adapt_images,\n", + " settings=al.Settings(use_border_relocator=True),\n", + " )\n", + "\n", + "\n", + "hilbert_fit = fit_delaunay_hilbert(\n", + " coefficient=overlay_coefficient,\n", + ")\n", + "\n", + "hilbert_mapper = hilbert_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=hilbert_mapper,\n", + " solution_vector=hilbert_fit.inversion.reconstruction,\n", + " mesh_grid=hilbert_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Hilbert Mesh: Initial Source Reconstruction\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=hilbert_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Inspect the mesh around the compact source centre. Has the brightness weighting placed more vertices across the region where its brightness changes rapidly?\n", + "\n", + "Then inspect the residuals around the bright arcs. Additional resolution gives the reconstruction more freedom to represent their structure, but regularization still determines how freely neighbouring brightnesses can differ.\n", + "\n", + "We have initially reused the overlay\u2019s preferred coefficient. As tutorial 2 showed, changing the mesh can change the preferred smoothing strength. We therefore need to explore regularization again before comparing the two reconstructions.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Comparing overlay and Hilbert reconstructions__\n", + "\n", + "Let\u2019s scan the regularization coefficient for the Hilbert mesh, using the same range as the overlay.\n", + "\n", + "The adapt image and Hilbert mesh coordinates remain fixed during this scan. Each coefficient produces a new reconstruction on that mesh." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "hilbert_evidences = []\n", + "best_evidence = -np.inf\n", + "\n", + "# Keep only the preferred full fit; other scan entries are scalar evidences.\n", + "for coefficient in overlay_coefficients:\n", + " coefficient = float(coefficient)\n", + " current_fit = fit_delaunay_hilbert(coefficient=coefficient)\n", + " evidence = float(current_fit.log_evidence)\n", + " hilbert_evidences.append(evidence)\n", + " if evidence > best_evidence:\n", + " best_evidence = evidence\n", + " hilbert_fit = current_fit\n", + "\n", + "hilbert_evidences = np.asarray(hilbert_evidences)\n", + "if not np.all(np.isfinite(hilbert_evidences)):\n", + " raise ValueError(\"Inspect non-finite evidence values before continuing.\")\n", + "\n", + "best_index = int(np.argmax(hilbert_evidences))\n", + "if best_index in (0, len(overlay_coefficients) - 1):\n", + " raise ValueError(\"Extend the coefficient range to bracket the hilbert peak.\")\n", + "\n", + "hilbert_coefficient = float(overlay_coefficients[best_index])\n", + "\n", + "reference_evidence = max(\n", + " float(np.max(overlay_evidences)),\n", + " float(np.max(hilbert_evidences)),\n", + ")\n", + "\n", + "fig, ax = plt.subplots()\n", + "\n", + "for label, values in [\n", + " (\"Overlay\", overlay_evidences),\n", + " (\"Hilbert\", hilbert_evidences),\n", + "]:\n", + " ax.semilogx(\n", + " overlay_coefficients,\n", + " values - reference_evidence,\n", + " marker=\"o\",\n", + " label=label,\n", + " )\n", + "\n", + "ax.set_xlabel(\"Regularization coefficient\")\n", + "ax.set_ylabel(\"Log evidence relative to the highest sampled value\")\n", + "ax.legend()\n", + "plt.show()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Both curves use the same reference value, preserving the difference between their peaks. Compare their preferred coefficients as well as their peak heights.\n", + "\n", + "We can now inspect each mesh at its own preferred sampled coefficient." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "for label, current_fit in [\n", + " (\"Overlay\", overlay_fit),\n", + " (\"Hilbert\", hilbert_fit),\n", + "]:\n", + " print(f\"{label}: log evidence={float(current_fit.log_evidence):.3f}\")\n", + "\n", + " aplt.subplot_fit_imaging(fit=current_fit)\n", + " aplt.subplot_fit_imaging_of_planes(fit=current_fit)\n", + "\n", + "print(\n", + " \"Hilbert minus overlay log evidence:\",\n", + " float(hilbert_fit.log_evidence - overlay_fit.log_evidence),\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A positive difference favours this Hilbert reconstruction over this overlay reconstruction under the comparison we have made. A negative difference favours the overlay. Refine the coefficient sampling around both peaks before interpreting a small difference.\n", + "\n", + "Connect the evidence comparison to the residuals. Does the brightness-adaptive mesh better reproduce the compact emission? Are the fainter regions still adequately described?\n", + "\n", + "This tests one Hilbert weighting choice, using a fixed adapt image. It does not establish that every Hilbert mesh outperforms every overlay.\n", + "\n", + "We have now addressed where the source points are placed. Next, we will address how to regularize their brightnesses on an irregular mesh, before allowing the smoothing strength to vary between bright and faint regions." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Smoothing on an irregular mesh__\n", + "\n", + "Our Delaunay mesh has vertices with different neighbour counts, edge lengths and directions.\n", + "\n", + "With neighbour-based regularization, we penalize brightness differences along those edges. Consequently, the pattern of comparisons changes with the triangulation: one vertex might have several nearby neighbours, while another has fewer neighbours at larger distances.\n", + "\n", + "**Split regularization** provides another way to measure local brightness variations.\n", + "\n", + "Around each source vertex, it defines four nearby sampling positions arranged in a cross. It interpolates the reconstructed brightness at those positions and compares them with the brightness at the central vertex. The size of the cross follows the local mesh scale.\n", + "\n", + "This gives each vertex a common pattern of local comparisons, instead of relying only on its particular set of connected neighbours.\n", + "\n", + "The additional sampling positions are not new source brightness parameters. Their brightnesses are interpolated from the existing reconstruction. We therefore change how smoothing is applied without increasing the number of brightness values solved by the inversion.\n", + "\n", + "It is also important to distinguish **Split** from **adaptive** regularization:\n", + "\n", + "- `ConstantSplit` uses the cross-based comparisons with one coefficient across the mesh.\n", + "- Adaptive Split regularization uses those comparisons while allowing the smoothing strength to vary across the source.\n", + "\n", + "We will first examine `ConstantSplit`, then introduce the brightness-dependent coefficients.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Reconstructing with ConstantSplit__\n", + "\n", + "Let\u2019s keep the Hilbert mesh and adapt image fixed and change the regularization scheme.\n", + "\n", + "We generalize our fitting helper so that it accepts a regularization object. This will let us use the same reconstruction setup for both constant and adaptive Split regularization." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "def fit_hilbert_with_regularization(regularization):\n", + " pixelization = al.Pixelization(\n", + " mesh=al.mesh.Delaunay(\n", + " pixels=hilbert_image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=regularization,\n", + " )\n", + "\n", + " source_galaxy = al.Galaxy(\n", + " redshift=1.0,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " tracer = al.Tracer(\n", + " galaxies=[lens_galaxy, source_galaxy],\n", + " )\n", + "\n", + " adapt_images = al.AdaptImages(\n", + " galaxy_image_dict={\n", + " source_galaxy: adapt_image,\n", + " },\n", + " galaxy_image_plane_mesh_grid_dict={\n", + " source_galaxy: hilbert_image_plane_mesh_grid,\n", + " },\n", + " )\n", + "\n", + " return al.FitImaging(\n", + " dataset=dataset,\n", + " tracer=tracer,\n", + " adapt_images=adapt_images,\n", + " settings=al.Settings(use_border_relocator=True),\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The coefficient preferred by neighbour-based regularization need not be preferred by Split regularization. We therefore repeat the coefficient scan." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "split_evidences = []\n", + "best_evidence = -np.inf\n", + "\n", + "# Keep only the preferred full fit; other scan entries are scalar evidences.\n", + "for coefficient in overlay_coefficients:\n", + " coefficient = float(coefficient)\n", + " current_fit = fit_hilbert_with_regularization(regularization=al.reg.ConstantSplit(coefficient=coefficient))\n", + " evidence = float(current_fit.log_evidence)\n", + " split_evidences.append(evidence)\n", + " if evidence > best_evidence:\n", + " best_evidence = evidence\n", + " split_fit = current_fit\n", + "\n", + "split_evidences = np.asarray(split_evidences)\n", + "if not np.all(np.isfinite(split_evidences)):\n", + " raise ValueError(\"Inspect non-finite evidence values before continuing.\")\n", + "\n", + "best_index = int(np.argmax(split_evidences))\n", + "if best_index in (0, len(overlay_coefficients) - 1):\n", + " raise ValueError(\"Extend the coefficient range to bracket the split peak.\")\n", + "\n", + "split_coefficient = float(overlay_coefficients[best_index])\n", + "\n", + "print(\"Preferred sampled Split coefficient:\", split_coefficient)\n", + "\n", + "aplt.subplot_fit_imaging(fit=split_fit)\n", + "aplt.subplot_fit_imaging_of_planes(fit=split_fit)\n", + "\n", + "print(\n", + " \"ConstantSplit minus Constant log evidence:\",\n", + " float(split_fit.log_evidence - hilbert_fit.log_evidence),\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the source and residuals with the previous Hilbert reconstruction. We have kept the mesh fixed, so the changes arise from the regularization scheme and its selected coefficient.\n", + "\n", + "As before, refine the coefficient sampling around the peaks before interpreting small evidence differences.\n", + "\n", + "Split changes how local brightness variations are penalized, but `ConstantSplit` still applies one coefficient everywhere. It therefore retains the centre-versus-outskirts compromise identified in **Fit Problems**.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Allowing different smoothing strengths__\n", + "\n", + "We now let the regularization strength depend on the source brightness.\n", + "\n", + "The adapt image supplies a brightness-based signal estimate for each source vertex through its mappings to the image. These signals are normalized so that the highest has a value of one.\n", + "\n", + "We use that information to transition between two coefficients:\n", + "\n", + "- `inner_coefficient` controls smoothing in high-signal regions.\n", + "- `outer_coefficient` controls smoothing in low-signal regions.\n", + "\n", + "Despite their names, these coefficients are not assigned using distance from the source centre. A bright off-centre component can receive the high-signal coefficient too.\n", + "\n", + "For our compact source, we want to explore weaker smoothing in the bright region and stronger smoothing in the faint outskirts. The `signal_scale` parameter controls how the normalized signal determines the transition between them.\n", + "\n", + "We use `AdaptSplitPower` with `power=1.0`, which keeps the coefficient convention consistent with `ConstantSplit`. Setting its inner and outer coefficients equal then recovers the constant-strength Split scheme.\n", + "\n", + "Let\u2019s first try a deliberately contrasting pair of coefficients to make the spatial variation visible." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "adaptive_regularization = al.reg.AdaptSplitPower(\n", + " inner_coefficient=split_coefficient / 10.0,\n", + " outer_coefficient=split_coefficient * 10.0,\n", + " signal_scale=1.0,\n", + " power=1.0,\n", + ")\n", + "\n", + "adaptive_fit = fit_hilbert_with_regularization(\n", + " regularization=adaptive_regularization,\n", + ")\n", + "\n", + "adaptive_mapper = adaptive_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "adaptive_weights = (\n", + " adaptive_mapper.regularization.regularization_weights_from(\n", + " linear_obj=adaptive_mapper,\n", + " )\n", + ")\n", + "\n", + "aaplt.plot_inversion_reconstruction(\n", + " mapper=adaptive_mapper,\n", + " pixel_values=adaptive_weights,\n", + " grid=adaptive_mapper.source_plane_mesh_grid,\n", + " cb_unit=\"\",\n", + " zoom_to_brightest=False,\n", + " title=\"Adaptive Split: Regularization Weights\",\n", + ")\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=adaptive_mapper,\n", + " solution_vector=adaptive_fit.inversion.reconstruction,\n", + " mesh_grid=adaptive_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Adaptive Split: Source Reconstruction\",\n", + ")\n", + "\n", + "aplt.subplot_fit_imaging(fit=adaptive_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the regularization-weight map with the reconstructed source. Smaller weights should occur where the adapt image indicates stronger source emission, allowing greater local brightness variation. Larger weights encourage more smoothing in low-signal regions.\n", + "\n", + "These weights control the regularization penalty; they are not uncertainties on the reconstructed brightnesses.\n", + "\n", + "The trial coefficients were chosen to illustrate the mechanism. We have not yet established that they improve the fit or maximize the evidence. Next, we will examine the transition controlled by `signal_scale` and explore the adaptive coefficients systematically." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Controlling the transition with `signal_scale`__\n", + "\n", + "The inner and outer coefficients set the smoothing strengths at the high- and low-signal ends of the source. How do we assign a strength to the regions between them?\n", + "\n", + "We first raise each normalized source signal to `signal_scale`. With `power=1.0`, the regularization weight is then:\n", + "\n", + "\\[\n", + "w = c_{\\mathrm{inner}}\\,s\n", + " + c_{\\mathrm{outer}}\\,(1-s),\n", + "\\]\n", + "\n", + "where \\(s\\) is the normalized signal **after** applying `signal_scale`.\n", + "\n", + "At \\(s=1\\), the weight equals the inner coefficient. At \\(s=0\\), it equals the outer coefficient. Intermediate values blend the two.\n", + "\n", + "Let\u2019s plot this relationship while keeping the coefficients fixed." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "normalized_signal = np.linspace(0.0, 1.0, 200)\n", + "\n", + "inner_coefficient = split_coefficient / 10.0\n", + "outer_coefficient = split_coefficient * 10.0\n", + "\n", + "fig, ax = plt.subplots()\n", + "\n", + "for signal_scale in [0.5, 1.0, 3.0]:\n", + " scaled_signal = normalized_signal**signal_scale\n", + "\n", + " weights = (\n", + " inner_coefficient * scaled_signal\n", + " + outer_coefficient * (1.0 - scaled_signal)\n", + " )\n", + "\n", + " ax.plot(\n", + " normalized_signal,\n", + " weights,\n", + " label=f\"signal_scale={signal_scale:g}\",\n", + " )\n", + "\n", + "ax.set_xlabel(\"Normalized source signal before scaling\")\n", + "ax.set_ylabel(\"Regularization weight\")\n", + "ax.legend()\n", + "plt.show()" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For a normalized signal between zero and one, increasing `signal_scale` reduces its scaled value. In this example, that moves its weight towards the stronger outer coefficient. Only the highest-signal regions retain weights close to the weaker inner coefficient.\n", + "\n", + "Decreasing `signal_scale` has the opposite effect: more of the source receives weights closer to the inner coefficient.\n", + "\n", + "These weights are based on the fixed adapt image and its mappings. They are not recalculated from each newly reconstructed brightness distribution during this experiment.\n", + "\n", + "Also distinguish `signal_scale` from the Hilbert mesh\u2019s `weight_power`. Both transform brightness-derived information, but they control different things: `weight_power` changes **where mesh points are placed**, whereas `signal_scale` changes **how their brightnesses are regularized**.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Exploring the adaptive coefficients__\n", + "\n", + "We now have three regularization parameters: the inner coefficient, outer coefficient and signal scale.\n", + "\n", + "Let\u2019s explore a small grid of their values, keeping the mass model, Hilbert mesh and adapt image fixed. This makes the experiment more expensive than our one-coefficient scans, so we retain only the evidence values and the best fit encountered.\n", + "\n", + "We centre the coefficient grid on the preferred `ConstantSplit` value. The grid includes equal inner and outer coefficients, allowing the adaptive scheme to recover constant-strength smoothing.\n", + "\n", + "We also allow either coefficient to be larger. Although weaker smoothing in bright regions motivates the scheme, we should inspect what the evidence actually prefers." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "adaptive_coefficient_grid = (\n", + " split_coefficient * np.logspace(-1, 1, 5)\n", + ")\n", + "signal_scales = [0.5, 1.0, 3.0]\n", + "\n", + "adaptive_trials = []\n", + "best_adaptive_fit = None\n", + "best_adaptive_parameters = None\n", + "best_adaptive_evidence = -np.inf\n", + "\n", + "for inner_coefficient in adaptive_coefficient_grid:\n", + " for outer_coefficient in adaptive_coefficient_grid:\n", + " for signal_scale in signal_scales:\n", + " regularization = al.reg.AdaptSplitPower(\n", + " inner_coefficient=float(inner_coefficient),\n", + " outer_coefficient=float(outer_coefficient),\n", + " signal_scale=signal_scale,\n", + " power=1.0,\n", + " )\n", + "\n", + " current_fit = fit_hilbert_with_regularization(\n", + " regularization=regularization,\n", + " )\n", + " log_evidence = float(current_fit.log_evidence)\n", + "\n", + " if not np.isfinite(log_evidence):\n", + " raise ValueError(\n", + " \"Inspect the non-finite adaptive evidence before continuing.\"\n", + " )\n", + "\n", + " parameters = (\n", + " float(inner_coefficient),\n", + " float(outer_coefficient),\n", + " signal_scale,\n", + " )\n", + "\n", + " adaptive_trials.append((*parameters, log_evidence))\n", + "\n", + " if log_evidence > best_adaptive_evidence:\n", + " best_adaptive_evidence = log_evidence\n", + " best_adaptive_parameters = parameters\n", + " best_adaptive_fit = current_fit\n", + "\n", + "best_inner, best_outer, best_signal_scale = best_adaptive_parameters\n", + "\n", + "print(\"Preferred sampled inner coefficient:\", best_inner)\n", + "print(\"Preferred sampled outer coefficient:\", best_outer)\n", + "print(\"Preferred sampled signal scale:\", best_signal_scale)\n", + "print(\"Highest sampled log evidence:\", best_adaptive_evidence)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This performs 75 inversions. It is an introductory exploration, rather than a precise optimization of all three parameters.\n", + "\n", + "Check whether a preferred coefficient lies at either end of its range. If so, extend that range before concluding that the optimum has been located. The three signal-scale values are also widely spaced and may require further exploration.\n", + "\n", + "If the preferred inner and outer coefficients are equal, changing `signal_scale` has no effect: the weight is constant everywhere.\n", + "\n", + "For a complete modelling analysis, these parameters can be explored by a non-linear search alongside the lens parameters. We will use that approach in the final fit.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Does adaptive regularization address the fit problem?__\n", + "\n", + "Let\u2019s inspect the highest-evidence adaptive reconstruction we have evaluated and compare it with `ConstantSplit`.\n", + "\n", + "We keep the mesh fixed between these fits. This isolates the change from one smoothing strength to brightness-dependent smoothing." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "comparison_fits = [\n", + " (\"ConstantSplit\", split_fit),\n", + " (\"Adaptive Split\", best_adaptive_fit),\n", + "]\n", + "\n", + "source_vmax = max(\n", + " float(np.max(np.asarray(current_fit.inversion.reconstruction)))\n", + " for _, current_fit in comparison_fits\n", + ")\n", + "\n", + "for label, current_fit in comparison_fits:\n", + " mapper = current_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + " aaplt.plot_mapper(\n", + " mapper=mapper,\n", + " solution_vector=current_fit.inversion.reconstruction,\n", + " mesh_grid=mapper.source_plane_mesh_grid,\n", + " vmin=0.0,\n", + " vmax=source_vmax,\n", + " zoom_to_brightest=False,\n", + " title=f\"{label}: Source Reconstruction\",\n", + " )\n", + "\n", + " aplt.subplot_fit_imaging(fit=current_fit)\n", + "\n", + "print(\n", + " \"Adaptive minus constant Split log evidence:\",\n", + " best_adaptive_evidence - float(split_fit.log_evidence),\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Return to the two regions that motivated this experiment.\n", + "\n", + "At the bright centre, does the adaptive reconstruction preserve a sharper brightness peak and reduce the corresponding arc residuals? In the outskirts, does it suppress fluctuations without removing faint emission needed to explain the image?\n", + "\n", + "Inspect the preferred regularization weights alongside those changes." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "best_adaptive_mapper = best_adaptive_fit.inversion.cls_list_from(\n", + " al.Mapper\n", + ")[0]\n", + "\n", + "best_adaptive_weights = (\n", + " best_adaptive_mapper.regularization.regularization_weights_from(\n", + " linear_obj=best_adaptive_mapper,\n", + " )\n", + ")\n", + "\n", + "aaplt.plot_inversion_reconstruction(\n", + " mapper=best_adaptive_mapper,\n", + " pixel_values=best_adaptive_weights,\n", + " grid=best_adaptive_mapper.source_plane_mesh_grid,\n", + " cb_unit=\"\",\n", + " zoom_to_brightest=False,\n", + " title=\"Preferred Adaptive Regularization Weights\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A higher log evidence shows that this adaptive setting improves the reconstruction statistic for the fixed lens model and mesh. It is not, by itself, a model-comparison evidence marginalized over the additional regularization parameters.\n", + "\n", + "If substantial residuals remain, inspect where they occur. Adaptive regularization still cannot supply resolution missing from the mesh, and both adaptive steps depend on the initial adapt image.\n", + "\n", + "We have now explored the mesh placement and smoothing separately. Next, we will bring them together in an actual lens-model search, using an initial MGE fit, prior passing and the positions likelihood penalty." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Starting the final modelling chain__\n", + "\n", + "So far, we have supplied the true lens mass model to isolate the effects of mesh placement and regularization.\n", + "\n", + "We will now fit the mass parameters as well. Following tutorial 4, we begin with an MGE source, then use its result to initialize the adaptive pixelized-source search.\n", + "\n", + "This first fit provides three things: an initial mass estimate, an image of the fitted source emission to guide adaptation, and the positions used by the positions likelihood penalty.\n", + "\n", + "We keep the mass centre fixed for this example, while allowing its Einstein radius and ellipticity to vary." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "from pathlib import Path\n", + "import autofit as af\n", + "\n", + "initial_mass = af.Model(al.mp.Isothermal)\n", + "initial_mass.centre = (0.0, 0.0)\n", + "initial_mass.einstein_radius = af.UniformPrior(\n", + " lower_limit=0.5,\n", + " upper_limit=2.5,\n", + ")\n", + "\n", + "initial_lens = af.Model(\n", + " al.Galaxy,\n", + " redshift=0.5,\n", + " mass=initial_mass,\n", + ")\n", + "\n", + "initial_source = af.Model(\n", + " al.Galaxy,\n", + " redshift=1.0,\n", + " bulge=al.model_util.mge_model_from(\n", + " mask_radius=3.0,\n", + " total_gaussians=20,\n", + " centre_prior_is_uniform=False,\n", + " sigma_min=dataset.pixel_scales[0] / 10.0,\n", + " ),\n", + ")\n", + "\n", + "initial_model = af.Collection(\n", + " galaxies=af.Collection(\n", + " lens=initial_lens,\n", + " source=initial_source,\n", + " ),\n", + ")\n", + "\n", + "print(initial_model.info)\n", + "\n", + "initial_search = af.Nautilus(\n", + " path_prefix=Path(\"howtolens\") / \"chapter_3\" / \"tutorial_6\",\n", + " name=\"search_1_mge\",\n", + " unique_tag=\"very_compact\",\n", + " n_live=100,\n", + ")\n", + "\n", + "initial_analysis = al.AnalysisImaging(dataset=dataset)\n", + "\n", + "initial_result = initial_search.fit(\n", + " model=initial_model,\n", + " analysis=initial_analysis,\n", + ")\n", + "\n", + "print(initial_result.info)\n", + "\n", + "initial_model_fit = initial_result.max_log_likelihood_fit\n", + "\n", + "aplt.subplot_fit_imaging(fit=initial_model_fit)\n", + "aplt.subplot_fit_imaging_of_planes(fit=initial_model_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The narrow Gaussian components allow the MGE to describe the compact source centre. Their intensities are still solved linearly, while the search explores the shared source geometry and lens mass parameters.\n", + "\n", + "Inspect this result before continuing. Does it explain the multiple-image configuration, and does it recover a sensible compact source? We should not use a misleading initial solution to guide the adaptive fit.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Preparing adaptation and the positions likelihood penalty__\n", + "\n", + "We now derive a new adapt image from the fitted MGE result. This replaces the image used in our fixed-mass experiments.\n", + "\n", + "For the modelling chain, we express the fitted source image in signal-to-noise units and cap it at `3.0`. This reduces the dominance of the very brightest peaks, retaining more weight for fainter detected emission.\n", + "\n", + "The cap affects only the image used to guide adaptation. It does not change the observed data, noise map or image likelihood.\n", + "\n", + "Our example contains no foreground light, so the full model image is the source contribution." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "source_model_image = np.asarray(\n", + " initial_model_fit.model_data.slim\n", + ")\n", + "\n", + "source_model_snr = (\n", + " source_model_image / np.asarray(dataset.noise_map.slim)\n", + ")\n", + "\n", + "search_adapt_image = al.Array2D(\n", + " values=np.clip(source_model_snr, a_min=0.0, a_max=3.0),\n", + " mask=dataset.mask,\n", + ")\n", + "\n", + "aplt.plot_array(\n", + " array=search_adapt_image,\n", + " title=\"Adapt Image for the Search: Capped Model Signal-to-Noise\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We construct the Hilbert image mesh from this image. Its image-plane coordinates remain fixed during the next search, while their source-plane positions change as the proposed mass model changes." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "search_image_mesh = al.image_mesh.Hilbert(\n", + " pixels=hilbert_pixels,\n", + " weight_power=1.0,\n", + " weight_floor=0.05,\n", + ")\n", + "\n", + "search_image_plane_mesh_grid = (\n", + " search_image_mesh.image_plane_mesh_grid_from(\n", + " mask=dataset.mask,\n", + " adapt_data=search_adapt_image,\n", + " )\n", + ")\n", + "\n", + "search_adapt_images = al.AdaptImages(\n", + " galaxy_name_image_dict={\n", + " \"('galaxies', 'source')\": search_adapt_image,\n", + " },\n", + " galaxy_name_image_plane_mesh_grid_dict={\n", + " \"('galaxies', 'source')\": search_image_plane_mesh_grid,\n", + " },\n", + ")\n", + "\n", + "aplt.plot_array(\n", + " array=dataset.data,\n", + " grid=search_image_plane_mesh_grid,\n", + " title=\"Hilbert Image Mesh for the Model Search\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The dictionaries identify the source by its path in the model, `galaxies.source`. This lets the analysis associate the fixed adaptation inputs with each new source-galaxy instance proposed by the search.\n", + "\n", + "Following Chapter 2, Tutorial 5, we also prepare the **positions likelihood penalty** from the initial result." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "search_positions_likelihood = initial_result.positions_likelihood_from(\n", + " factor=3.0,\n", + " minimum_threshold=0.2,\n", + ")\n", + "\n", + "aplt.plot_array(\n", + " array=dataset.data,\n", + " positions=search_positions_likelihood.positions,\n", + " title=\"Positions for the Pixelized-Source Search\",\n", + ")\n", + "\n", + "print(\n", + " \"Source-plane separation threshold:\",\n", + " search_positions_likelihood.threshold,\n", + " \"arcsec\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Inspect both the mesh placement and predicted image positions. They should follow the source emission and multiple-image configuration before we use them in the next fit.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Defining the adaptive Delaunay model__\n", + "\n", + "We now replace the MGE source with a Delaunay pixelization and adaptive Split regularization.\n", + "\n", + "The mass parameters remain free, with priors passed from the initial result. The mesh\u2019s image-plane coordinates and total number of vertices are fixed, but ray tracing changes its source-plane geometry for every proposed mass model.\n", + "\n", + "We also make the inner coefficient, outer coefficient and signal scale free parameters. The search can therefore adjust the smoothing pattern as it refines the mass model." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "search_regularization = af.Model(al.reg.AdaptSplitPower)\n", + "\n", + "search_regularization.inner_coefficient = af.LogUniformPrior(\n", + " lower_limit=1.0e-3,\n", + " upper_limit=1.0e3,\n", + ")\n", + "\n", + "search_regularization.outer_coefficient = af.LogUniformPrior(\n", + " lower_limit=1.0e-3,\n", + " upper_limit=1.0e3,\n", + ")\n", + "\n", + "search_regularization.signal_scale = af.UniformPrior(\n", + " lower_limit=0.1,\n", + " upper_limit=5.0,\n", + ")\n", + "\n", + "search_regularization.power = 1.0\n", + "\n", + "search_pixelization = af.Model(\n", + " al.Pixelization,\n", + " mesh=al.mesh.Delaunay(\n", + " pixels=search_image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=search_regularization,\n", + ")\n", + "\n", + "search_source = af.Model(\n", + " al.Galaxy,\n", + " redshift=1.0,\n", + " pixelization=search_pixelization,\n", + ")\n", + "\n", + "adaptive_model = af.Collection(\n", + " galaxies=af.Collection(\n", + " lens=initial_result.model.galaxies.lens,\n", + " source=search_source,\n", + " ),\n", + ")\n", + "\n", + "print(adaptive_model.info)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Inspect the model summary. The source brightness values are still absent from the non-linear parameter list: the inversion solves them for each proposal.\n", + "\n", + "We have not forced the inner coefficient to be smaller than the outer coefficient. That ordering motivated our experiment, but the search is allowed to determine which values the data favour.\n", + "\n", + "The `power` setting remains fixed at `1.0`; it specifies the coefficient convention rather than providing another degree of freedom in this tutorial.\n", + "\n", + "The regularization priors are starting ranges. After fitting, we will check whether the inferred parameters approach their boundaries and whether broader ranges or further exploration are needed.\n", + "\n", + "We are now ready to run the adaptive Delaunay fit and inspect the resulting mass model, source reconstruction and smoothing pattern." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Running the adaptive Delaunay fit__\n", + "\n", + "The second search combines our Delaunay model, adaptation inputs and positions likelihood penalty.\n", + "\n", + "For each proposed mass model, the image-mesh coordinates are ray-traced into the source plane. The inversion reconstructs the source using the proposed adaptive regularization parameters, and the analysis evaluates the resulting fit.\n", + "\n", + "The positions likelihood penalty discourages proposals that trace the selected corresponding image positions too far apart." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "adaptive_analysis = al.AnalysisImaging(\n", + " dataset=dataset,\n", + " adapt_images=search_adapt_images,\n", + " positions_likelihood_list=[search_positions_likelihood],\n", + " settings=al.Settings(use_border_relocator=True),\n", + ")\n", + "\n", + "adaptive_search = af.Nautilus(\n", + " path_prefix=Path(\"howtolens\") / \"chapter_3\" / \"tutorial_6\",\n", + " name=\"search_2_adaptive_delaunay\",\n", + " unique_tag=\"very_compact\",\n", + " n_live=100,\n", + ")\n", + "\n", + "adaptive_result = adaptive_search.fit(\n", + " model=adaptive_model,\n", + " analysis=adaptive_analysis,\n", + ")\n", + "\n", + "print(adaptive_result.info)\n", + "\n", + "final_fit = adaptive_result.max_log_likelihood_fit\n", + "\n", + "aplt.subplot_fit_imaging(fit=final_fit)\n", + "aplt.subplot_fit_imaging_of_planes(fit=final_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This is a full non-linear search, rather than the fixed-mass reconstructions we evaluated earlier. It must explore combinations of mass and regularization parameters, so it will take longer.\n", + "\n", + "Inspect the image residuals first. Are there coherent patches around the bright arcs? Has the reconstruction captured the compact source without introducing conspicuous fluctuations elsewhere?\n", + "\n", + "Then examine the parameter distributions. A satisfactory-looking best fit does not establish that the search has explored the relevant solutions or measured reliable uncertainties. Check for parameters approaching prior boundaries and use the search diagnostics introduced in Chapter 2.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Inspecting the source and smoothing pattern__\n", + "\n", + "Let\u2019s look more closely at where the final mesh places its vertices and how strongly those vertices are regularized." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "final_mapper = final_fit.inversion.cls_list_from(al.Mapper)[0]\n", + "\n", + "final_regularization_weights = (\n", + " final_mapper.regularization.regularization_weights_from(\n", + " linear_obj=final_mapper,\n", + " )\n", + ")\n", + "\n", + "aaplt.plot_mapper(\n", + " mapper=final_mapper,\n", + " solution_vector=final_fit.inversion.reconstruction,\n", + " mesh_grid=final_mapper.source_plane_mesh_grid,\n", + " zoom_to_brightest=False,\n", + " title=\"Final Adaptive Delaunay Source\",\n", + ")\n", + "\n", + "aaplt.plot_inversion_reconstruction(\n", + " mapper=final_mapper,\n", + " pixel_values=final_regularization_weights,\n", + " grid=final_mapper.source_plane_mesh_grid,\n", + " cb_unit=\"\",\n", + " zoom_to_brightest=False,\n", + " title=\"Final Regularization Weights\",\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Return to the two problems from **Fit Problems**.\n", + "\n", + "First, does the mesh place enough vertices across the compact source centre to describe its steep brightness gradient?\n", + "\n", + "Second, does the inferred regularization allow the centre to vary sharply while smoothing the faint regions appropriately? Inspect the fitted inner and outer coefficients rather than assuming that the search must prefer the ordering we anticipated.\n", + "\n", + "We can also check the positions likelihood penalty for the displayed model." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "final_positions = final_fit.tracer.traced_grid_2d_list_from(\n", + " grid=search_positions_likelihood.positions\n", + ")[1]\n", + "\n", + "position_coordinates = np.asarray(final_positions)\n", + "\n", + "position_separations = np.linalg.norm(\n", + " position_coordinates[:, None, :]\n", + " - position_coordinates[None, :, :],\n", + " axis=-1,\n", + ")\n", + "\n", + "maximum_separation = float(np.max(position_separations))\n", + "\n", + "print(\n", + " \"Maximum source-plane position separation:\",\n", + " maximum_separation,\n", + " \"arcsec\",\n", + ")\n", + "print(\n", + " \"Positions threshold:\",\n", + " search_positions_likelihood.threshold,\n", + " \"arcsec\",\n", + ")\n", + "print(\n", + " \"Within threshold:\",\n", + " maximum_separation <= search_positions_likelihood.threshold,\n", + ")" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For this simulation, the true Einstein radius is `1.6` arcsec. Compare it with the inferred value and uncertainty in the result summary. In observed data, that answer would not be available, making the residuals, parameter constraints and search behaviour especially important.\n", + "\n", + "Finally, compare the initial MGE fit with the adaptive pixelized fit." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "for label, current_fit in [\n", + " (\"Initial MGE\", initial_model_fit),\n", + " (\"Adaptive Delaunay\", final_fit),\n", + "]:\n", + " print(label)\n", + " aplt.subplot_fit_imaging(fit=current_fit)\n", + " aplt.subplot_fit_imaging_of_planes(fit=current_fit)" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Our simulated source is a smooth S\u00e9rsic profile, which an MGE can describe well. The adaptive pixelization need not outperform that analytic model dramatically. Here, we are demonstrating how to fit a compact source with a pixelization while allowing the lens mass to vary.\n", + "\n", + "The additional spatial freedom becomes particularly useful for irregular sources whose structure is difficult to describe using a prescribed arrangement of analytic components.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Wrap Up__\n", + "\n", + "We began this chapter by replacing an analytic source profile with brightness values on a mesh.\n", + "\n", + "That gave the source more spatial freedom, but it also introduced choices that affect the reconstruction: where to place the mesh points, how to interpolate between them, and how strongly to regularize their brightnesses.\n", + "\n", + "In **Fit Problems**, we saw why selecting a constant regularization coefficient is not always enough. A compact source may require more resolution around its bright centre and different smoothing strengths across its brightness distribution.\n", + "\n", + "This tutorial addressed those limitations in stages.\n", + "\n", + "An image-plane overlay gave us source sampling that responds to the lens magnification. A Hilbert image mesh then used a fitted brightness distribution to concentrate more sampling on the source emission. Delaunay triangulation connected the traced points and provided interpolation between their brightness values.\n", + "\n", + "Split regularization supplied local smoothing comparisons on that irregular mesh. Adaptive Split regularization allowed the strength of those comparisons to vary with the source signal.\n", + "\n", + "We then brought these methods into a lens-model search, using an initial MGE fit, prior passing and the positions likelihood penalty to help avoid unphysical mass-and-source solutions.\n", + "\n", + "Delaunay meshes with brightness adaptation are a useful choice when the source needs spatially varying resolution. Their flexibility does not remove the need to inspect the fit. An inaccurate adapt image, insufficient mesh resolution or poorly explored regularization parameters can still leave a misleading reconstruction.\n", + "\n", + "When assessing a result, connect three views:\n", + "\n", + "- **The image residuals:** what observed structure remains unexplained?\n", + "- **The source mesh and reconstruction:** where is resolution available, and what brightness structure is being inferred?\n", + "- **The mass and regularization constraints:** what parameters does the search favour, and how well has it explored them?\n", + "\n", + "MGE models remain valuable for initial fits, foreground light and sources well described by analytic components. Pixelizations complement them when we need greater freedom to reconstruct the source.\n", + "\n", + "We now have the tools to use both together\u2014and to investigate why a reconstruction succeeds or struggles, rather than relying only on how its source plot looks." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**\ud83e\udd16 Ask AI:** How do Hilbert mesh weighting and adaptive Split regularization address different limitations of a compact-source reconstruction?" + ] + } + ], + "metadata": { + "anaconda-cloud": {}, + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.1" + }, + "colab": { + "aiContexts": { + "97746750-6915-569b-a61d-792a6bc69437": { + "id": "97746750-6915-569b-a61d-792a6bc69437", + "name": "PyAutoLens assistant", + "context": "These instructions were prepared by the PyAutoLens assistant\n(https://github.com/PyAutoLabs/autolens_assistant). The reference material at the\nend was written for a coding agent working inside a checkout of that repository.\nHere in Google Colab there is no checkout: ignore any step about repository files,\nhooks, `scripts/` or `wiki/` paths, saving scripts, or terminal tools, and use the\nPyAutoLens knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoLens notebook (notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb in HowToLens).\nExplain what it does and help them adapt it to their own questions.\n\nCore rules:\n1. Stay with this notebook's API. Only use PyAutoLens calls that appear in the\n notebook or in the reference material below. Never invent class names, functions\n or arguments. If you are unsure whether something exists, say so and suggest\n checking with `dir()` or the PyAutoLens docs (https://pyautolens.readthedocs.io).\n2. Real data needs a check first. Before helping fit real observational data, ask the\n user to plot it and confirm (a) whether extra galaxies, foreground stars or\n artefacts need masking or noise-scaling, and (b) a mask radius that contains all\n of the lensed emission. Never leave the mask radius at its default on real data.\n Simulated data from the workspace is exempt.\n3. Never claim a fit or a cell ran, or what it returned. Only the user's own outputs\n tell you that; ask them to share what they see.\n4. When the answer depends on something you do not know (their data, their goal, what\n a cell printed), ask one focused question rather than guessing.\n5. For multi-step projects (a new lens, a sample of lenses, a paper's analysis), point\n them to the PyAutoLens assistant https://github.com/PyAutoLabs/autolens_assistant,\n which runs in Claude Code or Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: al_adaptive_pixelization\n\n---\nname: al_adaptive_pixelization\ndescription: Build a model whose source reconstruction uses an adaptive pixelisation (mesh density tracks source brightness or surface density) and adaptive regularisation (smoothing strength inferred from data, not fixed). Use when a uniform mesh wastes resolution on dim regions and under-resolves bright ones \u2014 common for complex arcs and ALMA data. More expressive than `al_build_imaging_model`'s \"pixelised source\" branch, at the cost of more knobs. Pairs with `al_inspect_source_reconstruction` (post-fit inspection). Writes a runnable Python script in scripts/. **Status: stub.**\n---\n\n# Adaptive pixelisation + adaptive regularisation\n\nA fixed-resolution pixelised source treats every region of the source\nplane the same. An adaptive pixelisation concentrates pixels where the\nflux concentrates, and adaptive regularisation lets the smoothness\nstrength vary spatially \u2014 both inferred jointly with the lens parameters.\n\nThe wins are sharper at high signal-to-noise and especially for\ninterferometer data where rectangular meshes waste capacity on empty\nsky.\n\nWorkspace path: `autolens_workspace:scripts/imaging/features/pixelization/adaptive.py`,\n`scripts/imaging/features/pixelization/delaunay.py`.\n\n## Ask\n\n- *\"Mesh type \u2014 Delaunay (default adaptive), KMeans-Voronoi, Hilbert\n (memory-efficient for groups), rectangular adaptive?\"*\n- *\"Regularisation \u2014 constant (single strength), split (different per\n region), brightness-adaptive (strength scales with local source\n flux)?\"*\n- *\"Adapt-image source \u2014 do you have a converged base fit to use as the\n adapt-image, or should we bootstrap from a parametric source fit\n first?\"* \u2014 either way the source adapt image is capped at S/N 3.0\n before use (see below); the cap applies regardless of which fit it\n came from.\n\n## Adapt image S/N cap (always)\n\nThe Hilbert image-mesh weights and the adaptive regularization pixel signals are both\nmax-normalised \u2014 `(adapt_image / max) ** power` \u2014 so the brightest peak of a raw\nsignal-to-noise adapt image sets the scale for everything else. Fainter multiply-imaged\nfeatures fall to a few percent of that weight and lose both source-pixel density and\nregularization weight, so the source adapt image is capped at S/N 3.0 wherever it is\nbuilt. The library applies no cap; it is the script's job at every site.\n\nApply it immediately after the dict is built, before `al.AdaptImages(...)` or a Hilbert\n`image_plane_mesh_grid_from(adapt_data=...)`:\n\n```python\ngalaxy_image_name_dict = al.galaxy_name_image_dict_via_result_from(result=result)\n\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsource_adapt_image[source_adapt_image > adapt_image_snr_cap] = adapt_image_snr_cap\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\nRules:\n\n- **Cap a copy, never the dict entry in place.** `.copy()` keeps the class and mask; an\n in-place cap would silently truncate the raw image every later consumer shares.\n- **Anything that needs the raw image binds it before the cap.** The adaptive\n over-sampling map (`np.where(source_image_raw > 3.0, 4, 2)`, see \"Adaptive pixelization\n over-sampling\" below) reads a\n `source_image_raw = galaxy_image_name_dict[\"('galaxies', 'source')\"]` bound above the cap\n block, because the capped image never exceeds the threshold.\n- **Every stage that builds `AdaptImages` from a source image gets the cap** \u2014 `source_lp`,\n `source_pix_*`, `light_lp` and `mass_total` alike; the three lines repeat per stage.\n- Scripts with more than one source cap every source key present\n (`\"('galaxies', 'source_0')\"`, `\"('galaxies', 'source_1')\"`, \u2026).\n\n### Interferometer\n\nInterferometer adapt images are real-space *model* images in flux units\n(`use_model_images=True`): the interferometer dirty noise map is a dirty beam rather than a\nnoise map, so dividing by it does not give a usable S/N image. Derive the clip level from a\nbeam-smoothed S/N instead \u2014 transform the model image to a dirty image, divide by the\nhomogeneous image-plane noise, and clip the copy at the flux where that S/N crosses 3.0:\n\n```python\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsigma_pix = np.sqrt(0.5 * np.sum(np.abs(dataset.noise_map.array) ** 2))\ndirty_source = dataset.transformer.image_from(\n visibilities=dataset.transformer.visibilities_from(image=source_adapt_image)\n)\nabove_cap = source_adapt_image.array[\n dirty_source.array / sigma_pix > adapt_image_snr_cap\n]\nif above_cap.size:\n source_adapt_image[source_adapt_image > above_cap.min()] = above_cap.min()\nelse:\n print(\n \"Adapt image S/N cap not applied: no pixel reaches S/N 3.0 in the dirty image.\"\n )\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\n`sigma_pix` is the square root of *half* the summed visibility variances, because the dirty\nimage is the real part of the transform.\n\nWorked sites to copy from:\n\n- `autolens_workspace:scripts/guides/modeling/slam_start_here.py` \u2014 the canonical SLaM\n template, capped at every stage.\n- `autolens_workspace:scripts/imaging/features/pixelization/delaunay.py` \u2014 the cap feeding\n a Hilbert image mesh.\n- `autolens_workspace:scripts/interferometer/features/pixelization/slam.py` \u2014 the\n interferometer form above.\n\n## Adaptive pixelization over-sampling (from `source_pix_2`)\n\nEvery SLaM pipeline \u2014 imaging, group, multi-galaxy, multi-dataset \u2014 over-samples the\npixelization grid adaptively from `source_pix_2` onwards: sub-size 4 where the source is\ndetected, 2 elsewhere. Build the map from the *raw* (pre-cap) source entry and re-apply the\ndataset before the fit is composed; `source_pix_2` and every later stage receive that\ndataset.\n\n```python\nsignal_to_noise_threshold = 3.0\n\nsource_image_raw = al.galaxy_name_image_dict_via_result_from(result=source_pix_result_1)[\n \"('galaxies', 'source')\"\n]\n\nover_sample_size_pixelization = al.Array2D(\n values=np.where(source_image_raw > signal_to_noise_threshold, 4, 2), mask=dataset.mask\n)\n\ndataset = dataset.apply_over_sampling(\n over_sample_size_pixelization=over_sample_size_pixelization\n)\n```\n\n- **The dict entry is already a signal-to-noise map**, so it is thresholded directly.\n `al.galaxy_name_image_dict_via_result_from` with its default `use_model_images=False`\n returns `subtracted_image / noise_map` (its on-disk cache is literally\n `galaxy_images_snr.fits`); only `use_model_images=True` returns flux-unit model images.\n The function's docstring says \"model-image\" and is misleading.\n- **Never do this:** `al.util.over_sample.over_sample_size_via_adapt_from(data=,\n noise_map=...)`. That helper divides by the noise itself, so feeding it an S/N map divides\n twice (~18x inflation on HST-depth data \u2014 ~90 % of the mask at sub-size 4 instead of\n ~30 %), and its `signal_to_noise_cut` defaults to 5.0 and silently drops to `max / 2` when\n the map's maximum is below `2 * cut`. The workspace no longer calls it anywhere.\n- **`source_pix_1` is exempt** \u2014 it keeps the library default uniform sub-size (4), because\n its adapt image comes from the parametric SOURCE LP fit and is not yet reliable enough to\n steer over-sampling.\n- **Interferometer SLaM does not do this** \u2014 a pixelization fit to visibilities cannot be\n over-sampled.\n\n## Branch \u2014 Delaunay mesh + adaptive brightness regularisation\n\n> TODO: recipe. Pattern: `pix = al.Pixelization(mesh=al.mesh.Delaunay(...),\n> regularization=al.reg.AdaptiveBrightnessSplit(...))` ; wrap in a\n> `Galaxy`; analysis needs `adapt_images` set from the bootstrap fit.\n> See `PyAutoArray:autoarray/inversion/...` and\n> `PyAutoLens:autolens/imaging/...`.\n\n## Branch \u2014 bootstrap workflow\n\nTwo-stage: (1) fit with a parametric source to produce an adapt image,\n(2) refit with the adaptive pixelisation, using the parametric model\nimage as the adapt source.\n\n> TODO: recipe.\n\n## Combine\n\n- [`al_inspect_source_reconstruction`](./al_inspect_source_reconstruction.md)\n \u2014 post-fit inspection of the reconstructed source + regularisation\n state.\n- [`al_chain_searches`](./al_chain_searches.md) \u2014 the bootstrap is a\n classic two-search chain.\n- [`al_datacube_modeling`](./al_datacube_modeling.md) \u2014 datacube\n pixelisation is similar but spectrally extended.\n\n## Further reading\n\n- **Student / new to lensing** \u2014 [HowToLens: chapter_3 tutorial_8 adaptive](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb):\n pedagogical adaptive-pixelisation walkthrough.\n- **General reference** \u2014 [RTD: overview_3_features](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n pixelisation feature section.\n- **Experienced PyAutoLens user** \u2014 [workspace/lens: imaging/features/pixelization/adaptive.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/adaptive.py):\n the canonical adaptive setup.\n\nSee also [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md)\nfor the underlying inversion theory.\n\n## Skill: al_inspect_source_reconstruction\n\n---\nname: al_inspect_source_reconstruction\ndescription: Inspect a pixelised source reconstruction from a completed fit \u2014 source-plane image, mesh / regularisation state, reconstruction error, and the inversion's linear solution. Pixelised sources are common for complex arcs and for ALMA/JVLA visibilities. Use after `al_load_results` has handed you the dataset + tracer; needs to rebuild the `FitImaging` because pixelisation state isn't stored in `tracer.json`.\n---\n\n# Inspecting a pixelised source reconstruction\n\nWhen the source has been reconstructed on a pixel grid (Delaunay / Voronoi /\nrectangular mesh) rather than as a parametric Sersic, the interesting object is the\n**inversion** \u2014 the linear solution that mapped source-plane pixels to image-plane\ncounts. Pixelised reconstructions live in `FitImaging.inversion`, *not* in the\nsaved `tracer.json`. To see them you have to rebuild the fit.\n\nFor the theory \u2014 what regularisation does, what makes a pixelisation adaptive vs.\nfixed, why the inversion can demagnify pathologically without a positions penalty \u2014\nread [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\nCanonical reference:\n`autolens_workspace:scripts/imaging/features/pixelization/plot.py`.\n\n## Ask\n\n- *\"What do you want to look at \u2014 the source image on its mesh, the regularisation\n matrix structure, the reconstruction error, the data-mapping matrix?\"*\n- *\"Was this a parametric or pixelised fit?\"* \u2014 if parametric, this skill doesn't\n apply; use [`al_plot_tracer`](./al_plot_tracer.md) for the source-plane image.\n\n## Saving plots\n\n`autolens.plot` in `2026.5.21+` is a flat module of free functions \u2014 the old\n`InversionPlotter` / `MatPlot2D` / `Output` classes are gone. Plot the\ninversion's component arrays directly via `aplt.plot_array`:\n\n```python\nfrom pathlib import Path\nimport autolens as al\nimport autolens.plot as aplt\n\nPLOT_DIR = Path(\"scripts/scratch\") / \"\" # pick a meaningful slug\nPLOT_DIR.mkdir(parents=True, exist_ok=True)\n```\n\nAfter running, the agent quotes `PLOT_DIR.resolve()` and offers\n`open ` \u2014 see `_style.md` \"Plot output and path announcement\". The\nfunction-style plot API is documented in\n[`wiki/core/api/plotting.md`](../wiki/core/api/plotting.md).\n\n> \u26a0\ufe0f **Known regression (still open as of `2026.7.6`).** `Delaunay` and\n> `KNNBarycentric` crash inside `FitImaging` (`'NoneType' object has no attribute\n> 'array'`), and `ConstantSplit` is broken on `RectangularUniform`. Use\n> `al.mesh.RectangularUniform` + `al.reg.Constant` for now; tracking:\n> .\n\n## Branch \u2014 source-plane reconstruction\n\nRebuild the fit so the inversion is computed, then plot the components:\n\nThe quickest full view is the fit subplot \u2014 when the fit uses an inversion its\nfinal panel *is* the source-plane reconstruction:\n\n```python\n# `dataset` from al_prepare_imaging_data, `tracer` from al_load_results.\nfit = al.FitImaging(dataset=dataset, tracer=tracer)\n\naplt.subplot_fit_imaging(\n fit=fit, output_path=str(PLOT_DIR), output_format=\"png\",\n)\n```\n\nFor component-level access, the inversion exposes (names verified against the\n2026.7 stack):\n\n```python\ninv = fit.inversion\n\n# Image-plane image the linear solution reconstructs (Array2D \u2014 plottable):\naplt.plot_array(array=inv.mapped_reconstructed_data,\n output_path=str(PLOT_DIR),\n output_filename=\"mapped_reconstruction\",\n output_format=\"png\")\n\n# Source-plane solution: a 1D vector of per-source-pixel intensities\n# (keyed per mapper in inv.reconstruction_dict). For a RectangularUniform\n# mesh, reshape to the mesh's native 2D shape to plot it.\nreconstruction = inv.reconstruction\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nSource: `PyAutoArray:autoarray/inversion/inversion/`.\n\n## Branch \u2014 mesh + regularisation diagnostics\n\nThe previous object-oriented inversion plotter API is removed.\nThe closest current entry point is `aplt.subplot_basis_image`, which renders\nthe mapper's pixelisation overlaid on the image:\n\n```python\naplt.subplot_basis_image(\n inversion=fit.inversion,\n output_path=str(PLOT_DIR), output_format=\"png\",\n)\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nThe mesh-triangulation and regularisation-neighbour graphs no longer have\ndirect plot helpers; build them with NumPy and `aplt.plot_grid` if needed.\n\n## Branch \u2014 double source-plane lens (DSPL) / multi-plane pixelisations\n\nFor systems with two source planes (each pixelised independently), the\n`pixelization_index` argument indexes into the planes. See\n`autolens_workspace:scripts/imaging/features/advanced/double_source_plane_lens/plot.py`.\n\n## Quality checks\n\nA healthy pixelised reconstruction:\n\n- Source-plane reconstruction shows recognisable structure (clumps, arms), not white\n noise.\n- Reconstruction error / signal-to-noise is positive across the source.\n- Regularisation weights smoothly decrease toward the edges (for adaptive variants).\n- The reconstructed image-plane model matches the data with featureless residuals\n (see [`al_plot_fit_residuals`](./al_plot_fit_residuals.md)).\n\nPathological signs:\n\n- Source-plane is a single bright pixel \u2014 the inversion has demagnified. Add image\n positions and refit.\n- Reconstruction looks like the noise map \u2014 regularisation is too low. Raise the\n regularisation coefficient range.\n- Source is unphysically extended beyond the caustic \u2014 mass model is wrong; the\n positions penalty isn't holding it in.\n\nConnect to [`al_debug_fit_failure`](./al_debug_fit_failure.md) when any of these\nappear.\n\n## Combine\n\n- [`al_plot_tracer`](./al_plot_tracer.md) \u2014 overlay caustics on the source-plane\n reconstruction to confirm signal is inside the caustic.\n- [`al_chain_searches`](./al_chain_searches.md) \u2014 the canonical chain for moving from\n a parametric source fit to a pixelised one.\n- [`al_run_slam_pipeline`](./al_run_slam_pipeline.md) \u2014 automated pipeline that\n handles the parametric \u2192 pixelised transition.\n\n## Further reading\n\n- **Student / new to lensing** \u2014 [HowToLens: Reconstructing source light via\n least-squares](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_inversions.ipynb):\n the inversion at its heart \u2014 least-squares solution for source-plane pixel\n intensities. Chapter 3 as a whole covers mappers, regularization, adaptive grids.\n- **General reference** \u2014 [RTD: Features overview](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n the pixelization feature sits here alongside MGE / interferometry / shapelets \u2014\n links into deeper docs.\n- **Experienced PyAutoLens user** \u2014 [workspace/lens: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n rectangular-mesh + constant-regularization pixelization model \u2014 production\n pattern for extended-arc sources.\n" + } + }, + "selectedAiContext": "97746750-6915-569b-a61d-792a6bc69437" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb b/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb deleted file mode 100644 index 4f03397..0000000 --- a/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb +++ /dev/null @@ -1,493 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Tutorial 8: Adaptive Pixelization\n", - "=================================\n", - "\n", - "In this tutorial we will introduce a new `Pixelization` object, which uses an `Overlay` image-mesh and a `Delaunay`\n", - "mesh.\n", - "\n", - "This pixelization does not use a rectangular mesh, but instead uses a `Delaunay` triangulation.\n", - "\n", - "So, why would we want to do that? Lets take another quick look at the rectangular grid.\n", - "\n", - "__Contents__\n", - "\n", - "- **Initial Setup:** We'll use the same strong lensing data as the previous tutorial, where.\n", - "- **Advantages and Disadvantages:** Lets think about the rectangular pixelization.\n", - "- **Image Mesh:** The Delaunay mesh is an irregular grid of pixels (or triangles) in the source-plane.\n", - "- **Regularization:** Penalize differences between neighbouring reconstructed values.\n", - "- **Source Compactness:** Reconstruct three sources with the same lens, mesh and noise.\n", - "- **Wrap Up:** Summary of the script and next steps." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Google Colab Setup__\n", - "\n", - "Set up Google Colab as in [the introductory notebook](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_1_introduction/tutorial_1_grids_and_galaxies.ipynb).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "cellView": "form", - "jupyter": { - "source_hidden": true - } - }, - "outputs": [], - "source": [ - "# @title Set up Google Colab { display-mode: \"form\" }\n", - "try:\n", - " import google.colab\n", - "except ImportError:\n", - " from autolens import setup_colab as _setup_colab\n", - "else:\n", - " import importlib\n", - " import subprocess\n", - " import sys\n", - "\n", - " subprocess.check_call(\n", - " [sys.executable, \"-m\", \"pip\", \"install\", \"autonerves\", \"--no-deps\"]\n", - " )\n", - " _setup_colab = importlib.import_module(\"autonerves.setup_colab\")\n", - "\n", - "_setup_colab.setup(\"howtolens\")" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "\n", - "import importlib.util\n", - "import sys\n", - "\n", - "if importlib.util.find_spec(\"jax\") is None:\n", - " print(\n", - " \"Skipping this tutorial: it requires the `jax` package, which is not \"\n", - " \"installed (install with `pip install autolens[optional]`).\"\n", - " )\n", - " sys.exit(0)\n", - "\n", - "from autolens import jax_wrapper # Sets JAX environment before other imports\n", - "\n", - "from autolens import setup_notebook; setup_notebook()\n", - "\n", - "from pathlib import Path\n", - "import numpy as np\n", - "import autolens as al\n", - "import autolens.plot as aplt" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Initial Setup__\n", - "\n", - "We'll use the same strong lensing data as the previous tutorial, where:\n", - "\n", - " - The lens galaxy's light is omitted.\n", - " - The lens system uses an `Isothermal` galaxy mass profile and a separate `ExternalShear` field.\n", - " - The source galaxy's light is an `Sersic`." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "dataset_name = \"simple__no_lens_light\"\n", - "dataset_path = Path(\"dataset\") / \"imaging\" / dataset_name" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Dataset Auto-Simulation__\n", - "\n", - "If the dataset does not already exist on your system, it will be created by running the corresponding simulator\n", - "script. This ensures that all example scripts can be run without manually simulating data first." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "al.util.dataset.simulate_if_needed(\n", - " dataset_path, simulator=\"scripts/simulator/no_lens_light.py\"\n", - ")\n", - "\n", - "dataset = al.Imaging.from_fits(\n", - " data_path=dataset_path / \"data.fits\",\n", - " noise_map_path=dataset_path / \"noise_map.fits\",\n", - " psf_path=dataset_path / \"psf.fits\",\n", - " pixel_scales=0.1,\n", - ")\n", - "\n", - "mask = al.Mask2D.circular(\n", - " shape_native=dataset.shape_native,\n", - " pixel_scales=dataset.pixel_scales,\n", - " radius=3.0,\n", - ")\n", - "\n", - "aplt.subplot_imaging_dataset(dataset=dataset)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The lines of code below do everything we're used to, that is, setup an image, mask it, trace it via a tracer, setup\n", - "the rectangular mapper, etc." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "lens_galaxy = al.Galaxy(\n", - " redshift=0.5,\n", - " mass=al.mp.Isothermal(\n", - " centre=(0.0, 0.0),\n", - " einstein_radius=1.6,\n", - " ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0),\n", - " ),\n", - ")\n", - "field = al.MassField(\n", - " redshift=0.5, shear=al.mp.ExternalShear(gamma_1=0.05, gamma_2=0.05)\n", - ")\n", - "\n", - "dataset = dataset.apply_mask(mask=mask)\n", - "\n", - "mesh_pixels_yx = 28\n", - "mesh_shape = (mesh_pixels_yx, mesh_pixels_yx)\n", - "\n", - "\n", - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.RectangularBilinearAdaptDensity(shape=mesh_shape),\n", - " regularization=al.reg.Constant(coefficient=0.5),\n", - ")\n", - "\n", - "source_galaxy = al.Galaxy(redshift=1.0, pixelization=pixelization)\n", - "\n", - "tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy], fields=[field])\n", - "\n", - "fit = al.FitImaging(dataset=dataset, tracer=tracer)\n", - "\n", - "aplt.subplot_fit_imaging(fit=fit)\n", - "aplt.subplot_fit_imaging_of_planes(fit=fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Advantages and Disadvantages__\n", - "\n", - "The rectangular mesh is a useful baseline, but its finite resolution can limit a reconstruction.\n", - "`RectangularBilinearAdaptDensity` adjusts rectangular sampling to the traced data density; it is not simply a\n", - "uniform source-plane grid. Inspect where its mesh points lie relative to the bright source." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "rectangular_pixels = mapper.pixels\n", - "print(f\"Rectangular source pixels: {rectangular_pixels}\")\n", - "aplt.subplot_fit_imaging_of_planes(fit=fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "A mesh point in a faint region is not necessarily wasted: those measurements can constrain the source's extent. The\n", - "evidence balances fit quality, regularization and complexity, rather than simply rewarding fewer pixels. An\n", - "image-plane overlay offers another allocation: ray-tracing its points concentrates source-plane sampling according\n", - "to lens magnification. Whether this improves a fit must be checked for the data and regularization used.\n", - "\n", - "__Image Mesh__\n", - "\n", - "The Delaunay mesh is an irregular triangulation in the source-plane. First determine a set of (y,x) source-plane\n", - "coordinates defining this grid, specifically where each triangle vertex is located.\n", - "\n", - "We do this using an `image_mesh`, which defines a method to determine a set of coordinates in the image-plane which\n", - "are ray-traced to the source-plane. These traced coordinates are the vertices of our source-pixel mesh.\n", - "\n", - "Below, we use the `Overlay` image-mesh to do this, which overlays a grid of (y,x) coordinates over the image-plane\n", - "mask and retains all (Y,x) coordinates which fall within this mask." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "dataset = dataset.apply_sparse_operator()\n", - "\n", - "image_mesh = al.image_mesh.Overlay(shape=(20, 20))\n", - "\n", - "image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(mask=dataset.mask)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "We can plot this grid over the image, to see that it is a coarse grid of (y,x) coordinates laid over the image." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "\n", - "aplt.plot_array(\n", - " array=dataset.data, grid=image_plane_mesh_grid, title=\"Image-plane mesh overlay\"\n", - ")" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Give the source galaxy a `Pixelization` containing a `Delaunay` mesh. The fit receives the image-plane mesh\n", - "coordinates separately through `AdaptImages` below, and ray-traces them to construct the source-plane mesh." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "pixelization = al.Pixelization(\n", - " mesh=al.mesh.Delaunay(\n", - " pixels=image_plane_mesh_grid.shape[0],\n", - " ),\n", - " regularization=al.reg.Constant(coefficient=1.0),\n", - ")\n", - "\n", - "source_galaxy = al.Galaxy(redshift=1.0, pixelization=pixelization)\n", - "\n", - "tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy], fields=[field])" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "By using this tracer in a fit, we see that our source-plane no longer uses rectangular pixels, but a Delaunay mesh!" - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "adapt_images = al.AdaptImages(\n", - " galaxy_image_plane_mesh_grid_dict={source_galaxy: image_plane_mesh_grid},\n", - ")\n", - "\n", - "fit = al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images)\n", - "\n", - "mapper = fit.inversion.cls_list_from(al.Mapper)[0]\n", - "\n", - "\n", - "aplt.subplot_fit_imaging(fit=fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Compare the residuals and reconstructed source with the rectangular example. The overlay retains only points inside\n", - "the mask, so its actual number of source pixels is smaller than the full 20 by 20 overlay. The two fits also use\n", - "different regularization coefficients; this is not a controlled ranking of mesh quality." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "print(f\"Delaunay source pixels: {mapper.pixels}; rectangular: {rectangular_pixels}\")\n", - "aplt.subplot_fit_imaging_of_planes(fit=fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "__Regularization__\n", - "\n", - "Constant regularization penalizes differences between neighbouring reconstructed source values. Rectangular meshes\n", - "use their grid connectivity; Delaunay meshes use the connected vertices of the triangulation. A vertex need not\n", - "have exactly three neighbours. One coefficient controls the strength of smoothing across the mesh.\n", - "\n", - "__Source Compactness__\n", - "\n", - "Magnification depends on the lens and source position, not on the brightness distribution we place there. Keep this\n", - "lens, image-plane overlay and regularization fixed while making three source profiles progressively more compact.\n", - "This restores a direct test of a limitation of magnification-based sampling.\n", - "\n", - "Use identical PSF, mask, Gaussian noise map and noise realization for each simulated image. The profiles retain the\n", - "same intensity at their effective radius, so their total fluxes and signal-to-noise ratios also differ; this is a\n", - "diagnostic experiment, not an isolated measurement of the effect of radius. The coefficient of 1.0 is an explicit\n", - "illustrative choice, not an evidence optimum for any of these datasets." - ] - }, - { - "cell_type": "code", - "metadata": {}, - "source": [ - "\n", - "simulation_grid = al.Grid2D.uniform(\n", - " shape_native=dataset.shape_native, pixel_scales=dataset.pixel_scales\n", - ")\n", - "simulation_grid = simulation_grid.apply_over_sampling(over_sample_size=8)\n", - "simulator = al.SimulatorImaging(\n", - " exposure_time=300.0,\n", - " psf=dataset.psf,\n", - " background_sky_level=0.1,\n", - " add_poisson_noise_to_data=False,\n", - ")\n", - "noise_sigma = 0.05\n", - "noise_values = np.random.default_rng(1).normal(\n", - " loc=0.0, scale=noise_sigma, size=simulation_grid.shape_native\n", - ")\n", - "common_noise_map = al.Array2D.no_mask(\n", - " values=np.full(simulation_grid.shape_native, noise_sigma),\n", - " pixel_scales=dataset.pixel_scales,\n", - ")\n", - "\n", - "compactness_fits = {}\n", - "for label, effective_radius, sersic_index in [\n", - " (\"Extended\", 0.5, 1.0),\n", - " (\"Compact\", 0.2, 2.5),\n", - " (\"Very compact\", 0.1, 4.0),\n", - "]:\n", - " true_source = al.Galaxy(\n", - " redshift=1.0,\n", - " bulge=al.lp.Sersic(\n", - " centre=(0.0, 0.0),\n", - " ell_comps=(0.0, 0.15),\n", - " intensity=0.2,\n", - " effective_radius=effective_radius,\n", - " sersic_index=sersic_index,\n", - " ),\n", - " )\n", - " true_tracer = al.Tracer(galaxies=[lens_galaxy, true_source], fields=[field])\n", - " noiseless = simulator.via_tracer_from(tracer=true_tracer, grid=simulation_grid)\n", - " simulated_dataset = al.Imaging(\n", - " data=al.Array2D.no_mask(\n", - " values=np.asarray(noiseless.data.native) + noise_values,\n", - " pixel_scales=dataset.pixel_scales,\n", - " ),\n", - " noise_map=common_noise_map,\n", - " psf=dataset.psf,\n", - " )\n", - " simulated_dataset = simulated_dataset.apply_mask(mask=mask)\n", - " simulated_dataset = simulated_dataset.apply_sparse_operator()\n", - " compactness_fit = al.FitImaging(\n", - " dataset=simulated_dataset, tracer=tracer, adapt_images=adapt_images\n", - " )\n", - " compactness_fits[label] = compactness_fit\n", - " compactness_mapper = compactness_fit.inversion.cls_list_from(al.Mapper)[0]\n", - " mesh_radii = np.sqrt(\n", - " np.sum(np.asarray(compactness_mapper.source_plane_mesh_grid) ** 2, axis=1)\n", - " )\n", - " vertices_within_radius = np.count_nonzero(mesh_radii <= effective_radius)\n", - " print(\n", - " f\"{label}: radius={effective_radius:.2f}, index={sersic_index:.1f}, \"\n", - " f\"coefficient=1.0, mesh pixels={compactness_mapper.pixels}, \"\n", - " f\"vertices within circular effective-radius aperture={vertices_within_radius}, \"\n", - " f\"chi_squared / data pixels={compactness_fit.chi_squared / simulated_dataset.data.shape[0]:.3f}, \"\n", - " f\"max |normalized residual|={np.max(np.abs(compactness_fit.normalized_residual_map)):.3f}\"\n", - " )\n", - " aplt.subplot_fit_imaging(fit=compactness_fit)\n", - " aplt.subplot_fit_imaging_of_planes(fit=compactness_fit)" - ], - "outputs": [], - "execution_count": null - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "The printed aperture counts measure available mesh vertices, not the number of independently constrained source\n", - "values. Inspect the source reconstructions alongside the residual maps: can the same mesh resolve each bright\n", - "centre without coherent residuals? Changing the coefficient may trade smoothing of the centre against noise in the\n", - "outskirts, but cannot add mesh vertices. We have not searched for an optimal coefficient, and these separate\n", - "simulated datasets must not be ranked against one another using their absolute evidences.\n", - "\n", - "__Wrap Up__\n", - "\n", - "The overlay responds to magnification rather than source morphology. The compactness experiment lets us inspect\n", - "where that distinction matters; neither fewer pixels nor a more adaptive mesh guarantees a better reconstruction.\n", - "Tutorials 11 and 12 explore adapting the mesh and regularization to a fitted brightness distribution. They test\n", - "those choices on the same data so that changes in fit quality and evidence can be assessed meaningfully.\n", - "\n", - "You can also experiment with rectangular and Delaunay meshes by changing the pixelization in a pipeline." - ] - } - ], - "metadata": { - "anaconda-cloud": {}, - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.6.1" - }, - "colab": { - "aiContexts": { - "97746750-6915-569b-a61d-792a6bc69437": { - "id": "97746750-6915-569b-a61d-792a6bc69437", - "name": "PyAutoLens assistant", - "context": "These instructions were prepared by the PyAutoLens assistant\n(https://github.com/PyAutoLabs/autolens_assistant). The reference material at the\nend was written for a coding agent working inside a checkout of that repository.\nHere in Google Colab there is no checkout: ignore any step about repository files,\nhooks, `scripts/` or `wiki/` paths, saving scripts, or terminal tools, and use the\nPyAutoLens knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoLens notebook (notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb in HowToLens).\nExplain what it does and help them adapt it to their own questions.\n\nCore rules:\n1. Stay with this notebook's API. Only use PyAutoLens calls that appear in the\n notebook or in the reference material below. Never invent class names, functions\n or arguments. If you are unsure whether something exists, say so and suggest\n checking with `dir()` or the PyAutoLens docs (https://pyautolens.readthedocs.io).\n2. Real data needs a check first. Before helping fit real observational data, ask the\n user to plot it and confirm (a) whether extra galaxies, foreground stars or\n artefacts need masking or noise-scaling, and (b) a mask radius that contains all\n of the lensed emission. Never leave the mask radius at its default on real data.\n Simulated data from the workspace is exempt.\n3. Never claim a fit or a cell ran, or what it returned. Only the user's own outputs\n tell you that; ask them to share what they see.\n4. When the answer depends on something you do not know (their data, their goal, what\n a cell printed), ask one focused question rather than guessing.\n5. For multi-step projects (a new lens, a sample of lenses, a paper's analysis), point\n them to the PyAutoLens assistant https://github.com/PyAutoLabs/autolens_assistant,\n which runs in Claude Code or Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: al_adaptive_pixelization\n\n---\nname: al_adaptive_pixelization\ndescription: Build a model whose source reconstruction uses an adaptive pixelisation (mesh density tracks source brightness or surface density) and adaptive regularisation (smoothing strength inferred from data, not fixed). Use when a uniform mesh wastes resolution on dim regions and under-resolves bright ones — common for complex arcs and ALMA data. More expressive than `al_build_imaging_model`'s \"pixelised source\" branch, at the cost of more knobs. Pairs with `al_inspect_source_reconstruction` (post-fit inspection). Writes a runnable Python script in scripts/. **Status: stub.**\n---\n\n# Adaptive pixelisation + adaptive regularisation\n\nA fixed-resolution pixelised source treats every region of the source\nplane the same. An adaptive pixelisation concentrates pixels where the\nflux concentrates, and adaptive regularisation lets the smoothness\nstrength vary spatially — both inferred jointly with the lens parameters.\n\nThe wins are sharper at high signal-to-noise and especially for\ninterferometer data where rectangular meshes waste capacity on empty\nsky.\n\nWorkspace path: `autolens_workspace:scripts/imaging/features/pixelization/adaptive.py`,\n`scripts/imaging/features/pixelization/delaunay.py`.\n\n## Ask\n\n- *\"Mesh type — Delaunay (default adaptive), KMeans-Voronoi, Hilbert\n (memory-efficient for groups), rectangular adaptive?\"*\n- *\"Regularisation — constant (single strength), split (different per\n region), brightness-adaptive (strength scales with local source\n flux)?\"*\n- *\"Adapt-image source — do you have a converged base fit to use as the\n adapt-image, or should we bootstrap from a parametric source fit\n first?\"* — either way the source adapt image is capped at S/N 3.0\n before use (see below); the cap applies regardless of which fit it\n came from.\n\n## Adapt image S/N cap (always)\n\nThe Hilbert image-mesh weights and the adaptive regularization pixel signals are both\nmax-normalised — `(adapt_image / max) ** power` — so the brightest peak of a raw\nsignal-to-noise adapt image sets the scale for everything else. Fainter multiply-imaged\nfeatures fall to a few percent of that weight and lose both source-pixel density and\nregularization weight, so the source adapt image is capped at S/N 3.0 wherever it is\nbuilt. The library applies no cap; it is the script's job at every site.\n\nApply it immediately after the dict is built, before `al.AdaptImages(...)` or a Hilbert\n`image_plane_mesh_grid_from(adapt_data=...)`:\n\n```python\ngalaxy_image_name_dict = al.galaxy_name_image_dict_via_result_from(result=result)\n\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsource_adapt_image[source_adapt_image > adapt_image_snr_cap] = adapt_image_snr_cap\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\nRules:\n\n- **Cap a copy, never the dict entry in place.** `.copy()` keeps the class and mask; an\n in-place cap would silently truncate the raw image every later consumer shares.\n- **Anything that needs the raw image binds it before the cap.** The adaptive\n over-sampling map (`np.where(source_image_raw > 3.0, 4, 2)`, see \"Adaptive pixelization\n over-sampling\" below) reads a\n `source_image_raw = galaxy_image_name_dict[\"('galaxies', 'source')\"]` bound above the cap\n block, because the capped image never exceeds the threshold.\n- **Every stage that builds `AdaptImages` from a source image gets the cap** — `source_lp`,\n `source_pix_*`, `light_lp` and `mass_total` alike; the three lines repeat per stage.\n- Scripts with more than one source cap every source key present\n (`\"('galaxies', 'source_0')\"`, `\"('galaxies', 'source_1')\"`, …).\n\n### Interferometer\n\nInterferometer adapt images are real-space *model* images in flux units\n(`use_model_images=True`): the interferometer dirty noise map is a dirty beam rather than a\nnoise map, so dividing by it does not give a usable S/N image. Derive the clip level from a\nbeam-smoothed S/N instead — transform the model image to a dirty image, divide by the\nhomogeneous image-plane noise, and clip the copy at the flux where that S/N crosses 3.0:\n\n```python\nadapt_image_snr_cap = 3.0\n\nsource_adapt_image = galaxy_image_name_dict[\"('galaxies', 'source')\"].copy()\nsigma_pix = np.sqrt(0.5 * np.sum(np.abs(dataset.noise_map.array) ** 2))\ndirty_source = dataset.transformer.image_from(\n visibilities=dataset.transformer.visibilities_from(image=source_adapt_image)\n)\nabove_cap = source_adapt_image.array[\n dirty_source.array / sigma_pix > adapt_image_snr_cap\n]\nif above_cap.size:\n source_adapt_image[source_adapt_image > above_cap.min()] = above_cap.min()\nelse:\n print(\n \"Adapt image S/N cap not applied: no pixel reaches S/N 3.0 in the dirty image.\"\n )\ngalaxy_image_name_dict[\"('galaxies', 'source')\"] = source_adapt_image\n```\n\n`sigma_pix` is the square root of *half* the summed visibility variances, because the dirty\nimage is the real part of the transform.\n\nWorked sites to copy from:\n\n- `autolens_workspace:scripts/guides/modeling/slam_start_here.py` — the canonical SLaM\n template, capped at every stage.\n- `autolens_workspace:scripts/imaging/features/pixelization/delaunay.py` — the cap feeding\n a Hilbert image mesh.\n- `autolens_workspace:scripts/interferometer/features/pixelization/slam.py` — the\n interferometer form above.\n\n## Adaptive pixelization over-sampling (from `source_pix_2`)\n\nEvery SLaM pipeline — imaging, group, multi-galaxy, multi-dataset — over-samples the\npixelization grid adaptively from `source_pix_2` onwards: sub-size 4 where the source is\ndetected, 2 elsewhere. Build the map from the *raw* (pre-cap) source entry and re-apply the\ndataset before the fit is composed; `source_pix_2` and every later stage receive that\ndataset.\n\n```python\nsignal_to_noise_threshold = 3.0\n\nsource_image_raw = al.galaxy_name_image_dict_via_result_from(result=source_pix_result_1)[\n \"('galaxies', 'source')\"\n]\n\nover_sample_size_pixelization = al.Array2D(\n values=np.where(source_image_raw > signal_to_noise_threshold, 4, 2), mask=dataset.mask\n)\n\ndataset = dataset.apply_over_sampling(\n over_sample_size_pixelization=over_sample_size_pixelization\n)\n```\n\n- **The dict entry is already a signal-to-noise map**, so it is thresholded directly.\n `al.galaxy_name_image_dict_via_result_from` with its default `use_model_images=False`\n returns `subtracted_image / noise_map` (its on-disk cache is literally\n `galaxy_images_snr.fits`); only `use_model_images=True` returns flux-unit model images.\n The function's docstring says \"model-image\" and is misleading.\n- **Never do this:** `al.util.over_sample.over_sample_size_via_adapt_from(data=,\n noise_map=...)`. That helper divides by the noise itself, so feeding it an S/N map divides\n twice (~18x inflation on HST-depth data — ~90 % of the mask at sub-size 4 instead of\n ~30 %), and its `signal_to_noise_cut` defaults to 5.0 and silently drops to `max / 2` when\n the map's maximum is below `2 * cut`. The workspace no longer calls it anywhere.\n- **`source_pix_1` is exempt** — it keeps the library default uniform sub-size (4), because\n its adapt image comes from the parametric SOURCE LP fit and is not yet reliable enough to\n steer over-sampling.\n- **Interferometer SLaM does not do this** — a pixelization fit to visibilities cannot be\n over-sampled.\n\n## Branch — Delaunay mesh + adaptive brightness regularisation\n\n> TODO: recipe. Pattern: `pix = al.Pixelization(mesh=al.mesh.Delaunay(...),\n> regularization=al.reg.AdaptiveBrightnessSplit(...))` ; wrap in a\n> `Galaxy`; analysis needs `adapt_images` set from the bootstrap fit.\n> See `PyAutoArray:autoarray/inversion/...` and\n> `PyAutoLens:autolens/imaging/...`.\n\n## Branch — bootstrap workflow\n\nTwo-stage: (1) fit with a parametric source to produce an adapt image,\n(2) refit with the adaptive pixelisation, using the parametric model\nimage as the adapt source.\n\n> TODO: recipe.\n\n## Combine\n\n- [`al_inspect_source_reconstruction`](./al_inspect_source_reconstruction.md)\n — post-fit inspection of the reconstructed source + regularisation\n state.\n- [`al_chain_searches`](./al_chain_searches.md) — the bootstrap is a\n classic two-search chain.\n- [`al_datacube_modeling`](./al_datacube_modeling.md) — datacube\n pixelisation is similar but spectrally extended.\n\n## Further reading\n\n- **Student / new to lensing** — [HowToLens: chapter_3 tutorial_8 adaptive](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb):\n pedagogical adaptive-pixelisation walkthrough.\n- **General reference** — [RTD: overview_3_features](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n pixelisation feature section.\n- **Experienced PyAutoLens user** — [workspace/lens: imaging/features/pixelization/adaptive.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/adaptive.py):\n the canonical adaptive setup.\n\nSee also [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md)\nfor the underlying inversion theory.\n\n## Skill: al_inspect_source_reconstruction\n\n---\nname: al_inspect_source_reconstruction\ndescription: Inspect a pixelised source reconstruction from a completed fit — source-plane image, mesh / regularisation state, reconstruction error, and the inversion's linear solution. Pixelised sources are common for complex arcs and for ALMA/JVLA visibilities. Use after `al_load_results` has handed you the dataset + tracer; needs to rebuild the `FitImaging` because pixelisation state isn't stored in `tracer.json`.\n---\n\n# Inspecting a pixelised source reconstruction\n\nWhen the source has been reconstructed on a pixel grid (Delaunay / Voronoi /\nrectangular mesh) rather than as a parametric Sersic, the interesting object is the\n**inversion** — the linear solution that mapped source-plane pixels to image-plane\ncounts. Pixelised reconstructions live in `FitImaging.inversion`, *not* in the\nsaved `tracer.json`. To see them you have to rebuild the fit.\n\nFor the theory — what regularisation does, what makes a pixelisation adaptive vs.\nfixed, why the inversion can demagnify pathologically without a positions penalty —\nread [`wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\nCanonical reference:\n`autolens_workspace:scripts/imaging/features/pixelization/plot.py`.\n\n## Ask\n\n- *\"What do you want to look at — the source image on its mesh, the regularisation\n matrix structure, the reconstruction error, the data-mapping matrix?\"*\n- *\"Was this a parametric or pixelised fit?\"* — if parametric, this skill doesn't\n apply; use [`al_plot_tracer`](./al_plot_tracer.md) for the source-plane image.\n\n## Saving plots\n\n`autolens.plot` in `2026.5.21+` is a flat module of free functions — the old\n`InversionPlotter` / `MatPlot2D` / `Output` classes are gone. Plot the\ninversion's component arrays directly via `aplt.plot_array`:\n\n```python\nfrom pathlib import Path\nimport autolens as al\nimport autolens.plot as aplt\n\nPLOT_DIR = Path(\"scripts/scratch\") / \"\" # pick a meaningful slug\nPLOT_DIR.mkdir(parents=True, exist_ok=True)\n```\n\nAfter running, the agent quotes `PLOT_DIR.resolve()` and offers\n`open ` — see `_style.md` \"Plot output and path announcement\". The\nfunction-style plot API is documented in\n[`wiki/core/api/plotting.md`](../wiki/core/api/plotting.md).\n\n> ⚠️ **Known regression (still open as of `2026.7.6`).** `Delaunay` and\n> `KNNBarycentric` crash inside `FitImaging` (`'NoneType' object has no attribute\n> 'array'`), and `ConstantSplit` is broken on `RectangularUniform`. Use\n> `al.mesh.RectangularUniform` + `al.reg.Constant` for now; tracking:\n> .\n\n## Branch — source-plane reconstruction\n\nRebuild the fit so the inversion is computed, then plot the components:\n\nThe quickest full view is the fit subplot — when the fit uses an inversion its\nfinal panel *is* the source-plane reconstruction:\n\n```python\n# `dataset` from al_prepare_imaging_data, `tracer` from al_load_results.\nfit = al.FitImaging(dataset=dataset, tracer=tracer)\n\naplt.subplot_fit_imaging(\n fit=fit, output_path=str(PLOT_DIR), output_format=\"png\",\n)\n```\n\nFor component-level access, the inversion exposes (names verified against the\n2026.7 stack):\n\n```python\ninv = fit.inversion\n\n# Image-plane image the linear solution reconstructs (Array2D — plottable):\naplt.plot_array(array=inv.mapped_reconstructed_data,\n output_path=str(PLOT_DIR),\n output_filename=\"mapped_reconstruction\",\n output_format=\"png\")\n\n# Source-plane solution: a 1D vector of per-source-pixel intensities\n# (keyed per mapper in inv.reconstruction_dict). For a RectangularUniform\n# mesh, reshape to the mesh's native 2D shape to plot it.\nreconstruction = inv.reconstruction\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nSource: `PyAutoArray:autoarray/inversion/inversion/`.\n\n## Branch — mesh + regularisation diagnostics\n\nThe previous object-oriented inversion plotter API is removed.\nThe closest current entry point is `aplt.subplot_basis_image`, which renders\nthe mapper's pixelisation overlaid on the image:\n\n```python\naplt.subplot_basis_image(\n inversion=fit.inversion,\n output_path=str(PLOT_DIR), output_format=\"png\",\n)\n\nprint(f\"Saved to: {PLOT_DIR.resolve()}\")\n```\n\nThe mesh-triangulation and regularisation-neighbour graphs no longer have\ndirect plot helpers; build them with NumPy and `aplt.plot_grid` if needed.\n\n## Branch — double source-plane lens (DSPL) / multi-plane pixelisations\n\nFor systems with two source planes (each pixelised independently), the\n`pixelization_index` argument indexes into the planes. See\n`autolens_workspace:scripts/imaging/features/advanced/double_source_plane_lens/plot.py`.\n\n## Quality checks\n\nA healthy pixelised reconstruction:\n\n- Source-plane reconstruction shows recognisable structure (clumps, arms), not white\n noise.\n- Reconstruction error / signal-to-noise is positive across the source.\n- Regularisation weights smoothly decrease toward the edges (for adaptive variants).\n- The reconstructed image-plane model matches the data with featureless residuals\n (see [`al_plot_fit_residuals`](./al_plot_fit_residuals.md)).\n\nPathological signs:\n\n- Source-plane is a single bright pixel — the inversion has demagnified. Add image\n positions and refit.\n- Reconstruction looks like the noise map — regularisation is too low. Raise the\n regularisation coefficient range.\n- Source is unphysically extended beyond the caustic — mass model is wrong; the\n positions penalty isn't holding it in.\n\nConnect to [`al_debug_fit_failure`](./al_debug_fit_failure.md) when any of these\nappear.\n\n## Combine\n\n- [`al_plot_tracer`](./al_plot_tracer.md) — overlay caustics on the source-plane\n reconstruction to confirm signal is inside the caustic.\n- [`al_chain_searches`](./al_chain_searches.md) — the canonical chain for moving from\n a parametric source fit to a pixelised one.\n- [`al_run_slam_pipeline`](./al_run_slam_pipeline.md) — automated pipeline that\n handles the parametric → pixelised transition.\n\n## Further reading\n\n- **Student / new to lensing** — [HowToLens: Reconstructing source light via\n least-squares](https://github.com/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_inversions.ipynb):\n the inversion at its heart — least-squares solution for source-plane pixel\n intensities. Chapter 3 as a whole covers mappers, regularization, adaptive grids.\n- **General reference** — [RTD: Features overview](https://pyautolens.readthedocs.io/en/latest/overview/overview_3_features.html):\n the pixelization feature sits here alongside MGE / interferometry / shapelets —\n links into deeper docs.\n- **Experienced PyAutoLens user** — [workspace/lens: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n rectangular-mesh + constant-regularization pixelization model — production\n pattern for extended-arc sources.\n" - } - }, - "selectedAiContext": "97746750-6915-569b-a61d-792a6bc69437" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} \ No newline at end of file diff --git a/scripts/chapter_3_pixelizations/README.md b/scripts/chapter_3_pixelizations/README.md index 4cd4c8b..08f4039 100644 --- a/scripts/chapter_3_pixelizations/README.md +++ b/scripts/chapter_3_pixelizations/README.md @@ -21,8 +21,5 @@ MGE remains valuable, especially for foreground lens light and initial source fi - [Borders](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb) - [Lens Modeling](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb) -The following tutorials explore adaptive meshes and regularization: - -- [Adaptive Pixelization](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb) -- [Brightness Adaption](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb) -- [Adaptive Regularization](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb) +- [Fit Problems](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb) +- [Adaptive Reconstruction](https://colab.research.google.com/github/PyAutoLabs/HowToLens/blob/main/notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb) diff --git a/scripts/chapter_3_pixelizations/tutorial_11_brightness_adaption.py b/scripts/chapter_3_pixelizations/tutorial_11_brightness_adaption.py deleted file mode 100644 index 4a7b34a..0000000 --- a/scripts/chapter_3_pixelizations/tutorial_11_brightness_adaption.py +++ /dev/null @@ -1,402 +0,0 @@ -""" -Tutorial 11: Brightness Adaption -================================ - -In the previous tutorial we motivated our need to adapt the pixelization to the source's morphology, such that -source pixels congregates in the source's brightest regions regardless of where the source is located in the -source-plane. - -This poses a challenge; how do we adapt our pixelization to the reconstructed source's light, before we've actually -reconstructed the source and therefore know what to adapt it too? - -To do this, we define 'adapt_images' of the lensed source galaxy, which are model images of the source computed -using a previous lens model that has been fit to the image (e.g. in the earlier search of a pipeline). This image -tells us where in the image our source is located, thus informing us of where we need to adapt our source -pixelization! - -Tutorial 8's compactness experiment motivated adapting to the source's morphology. Here we use the standalone -`simple__no_lens_light` dataset, rather than one of those progressively compact simulated sources. We begin with a -magnification-based pixelization and use its model image as an adapt image. - -__Contents__ - -- **Initial Setup:** we'll use the same strong lensing data as the previous tutorial, where. -- **Adapt Image:** We can use this fit to set up our adapt image. -- **Adaption:** Now lets take a look at brightness based adaption in action. -- **Hilbert:** So how does the `adapt_image` adapt the pixelization to the source's brightness? -- **Weight Map:** We now have a sense of how our `Hilbert` image-mesh is computed, so lets look at how we create - the. -- **Wrap Up:** Summary of the script and next steps. - -""" - -import importlib.util -import sys - -if importlib.util.find_spec("jax") is None: - print( - "Skipping this tutorial: it requires the `jax` package, which is not " - "installed (install with `pip install autolens[optional]`)." - ) - sys.exit(0) - -from autolens import jax_wrapper # Sets JAX environment before other imports - -# from autolens import setup_notebook; setup_notebook() - -from pathlib import Path -import autolens as al -import autolens.plot as aplt -import autoarray.plot as aaplt - -""" -__Initial Setup__ - -we'll use the same strong lensing data as the previous tutorial, where: - - - The lens galaxy's light is omitted. - - The lens system uses an `Isothermal` galaxy mass profile and a separate `ExternalShear` field. - - The source galaxy's light is an `Sersic`. -""" -dataset_name = "simple__no_lens_light" -dataset_path = Path("dataset") / "imaging" / dataset_name - -""" -__Dataset Auto-Simulation__ - -If the dataset does not already exist on your system, it will be created by running the corresponding simulator -script. This ensures that all example scripts can be run without manually simulating data first. -""" -al.util.dataset.simulate_if_needed( - dataset_path, simulator="scripts/simulator/no_lens_light.py" -) - -dataset = al.Imaging.from_fits( - data_path=dataset_path / "data.fits", - noise_map_path=dataset_path / "noise_map.fits", - psf_path=dataset_path / "psf.fits", - pixel_scales=0.1, -) - -mask = al.Mask2D.circular( - shape_native=dataset.shape_native, - pixel_scales=dataset.pixel_scales, - radius=3.0, -) - -dataset = dataset.apply_mask(mask=mask) - -dataset = dataset.apply_sparse_operator() - -""" -Next, we're going to fit the image using the RectangularBilinearAdaptDensity magnification based grid. - -The code below does all the usual steps required to do this. -""" -lens_galaxy = al.Galaxy( - redshift=0.5, - mass=al.mp.Isothermal( - centre=(0.0, 0.0), - einstein_radius=1.6, - ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0), - ), -) -field = al.MassField( - redshift=0.5, shear=al.mp.ExternalShear(gamma_1=0.05, gamma_2=0.05) -) - -pixelization = al.Pixelization( - mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)), - regularization=al.reg.Constant(coefficient=3.3), -) - -source_galaxy_magnification = al.Galaxy(redshift=1.0, pixelization=pixelization) - -tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy_magnification], fields=[field]) - -magnification_fit = al.FitImaging(dataset=dataset, tracer=tracer) -fit = magnification_fit - -""" -Inspect the source reconstruction and residuals before using its model image to guide the next mesh. -""" -mapper = fit.inversion.cls_list_from(al.Mapper)[0] - - -aplt.subplot_fit_imaging(fit=fit) -aplt.subplot_fit_imaging_of_planes(fit=fit) - -""" -__Adapt Image__ - -We can use this fit to set up our adapt image. - -The adapt image is conditional on this first fit. Check its residuals: an inaccurate starting model can guide the -next mesh to the wrong parts of the image. -""" -adapt_image = fit.model_data.slim - -""" -__Adaption__ - -Now lets take a look at brightness based adaption in action. - -Below, we define a source galaxy using the `Hilbert` image-mesh and a `Delaunay` mesh. We discuss below how Hilbert -adapts to the source light. Unlike the density-adaptive rectangular mesh, Delaunay uses the supplied Hilbert points -as its source-mesh vertices after ray tracing. -""" -image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=10.0) - -image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from( - mask=dataset.mask, adapt_data=adapt_image -) - -pixelization = al.Pixelization( - mesh=al.mesh.Delaunay(pixels=image_plane_mesh_grid.shape[0]), - regularization=al.reg.Constant(coefficient=1.0), -) - -galaxy_adapt = al.Galaxy( - redshift=1.0, - pixelization=pixelization, -) - -""" -The adapt image is paired to the galaxy that it represents and that it is used to adapt the mesh too. - -This uses the `AdaptImages` object, which receives a dictionary mapping every galaxy to its adapt image. -""" -adapt_images = al.AdaptImages( - galaxy_image_dict={galaxy_adapt: adapt_image}, - galaxy_image_plane_mesh_grid_dict={galaxy_adapt: image_plane_mesh_grid}, -) - -""" -We now fit using this adapt image and mesh using the normal API. - -Note however that the `FitImaging` object receives the `adapt_images` as an input and they are used when setting up -the image-mesh and mesh. -""" -tracer = al.Tracer(galaxies=[lens_galaxy, galaxy_adapt], fields=[field]) - -brightness_fit = al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images) -fit = brightness_fit - -aplt.plot_array( - array=dataset.data, positions=image_plane_mesh_grid, title="Hilbert Image-Mesh Points", -) -mapper = fit.inversion.cls_list_from(al.Mapper)[0] -aaplt.plot_mapper( - mapper=mapper, solution_vector=fit.inversion.reconstruction, - mesh_grid=mapper.source_plane_mesh_grid, title="Brightness-Adaptive Source Mesh", -) -aplt.subplot_fit_imaging(fit=fit) -aplt.subplot_fit_imaging_of_planes(fit=fit) - - -""" -Compare the source mesh and residuals with the initial density-adaptive reconstruction. We can now compare measured -evidence values instead of carrying a number over from another simulation. This first comparison changes both mesh -family and regularization coefficient; it does not isolate brightness weighting alone. -""" -print("Evidence using magnification based pixelization:", magnification_fit.log_evidence) -print("Evidence using brightness based pixelization:", brightness_fit.log_evidence) -print("Difference in log evidence:", brightness_fit.log_evidence - magnification_fit.log_evidence) -print("Source mesh values:", mapper.pixels) - -""" -Brightness adaptation gives us another way to distribute source resolution. It does not guarantee an improved fit -or higher evidence for every dataset. Inspect both the calculated comparison and the residuals. - -__Hilbert__ - -So how does the `adapt_image` adapt the pixelization to the source's brightness? It uses a standard algorithm for -partitioning data in statistics called a Hilbert curve: - -https://en.wikipedia.org/wiki/Hilbert_curve - -In simple terms, this algorithm works as follows: - - 1) Input an image of weight values to the Hilbert algorithm which determines the "hilbert space filling curve" - (e.g. this `weight_map` is determined from the adapt-image). The Hilbert space-filling curve fills more space - there - the weight values are highest, and less space where they are lowest. It therefore closely traces the brightest - regions of the image. - - 2) Probabilistically draw N $(y,x)$ points from this Hilbert curve, where the majority of points will therefore be - drawn from high weighted regions. - - 3) These N $(y,x)$ are our source-pixel centres, albeit we have drawn them in the image-plane so we first map them - to the source-plane, via the mass model, in order to set up the source pixel centres of the mesh. Because points - are - drawn from high weighted regions (e.g. the brightest image pixels in the lensed source adapt image), we will trace - more source-pixels to the brightest regions of where the source is actually reconstructed. - -__Weight Map__ - -We now have a sense of how our `Hilbert` image-mesh is computed, so lets look at how we create the weighted data -the Hilbert space filling curve uses. - -This image, called the `weight_map` is generated using the `weight_floor` and `weight_power` parameters of the -`Hilbert` object. The weight map is generated following 4 steps: - - 1) Take an absolute value of all pixels in the adapt image, because negative values break the Hilbert algorithm. - - 2) Divide all values of this image by its maximum value, such that the adapt-image now only contains values - between - 0.0 and 1.0 (where the values of 1.0 are the maximum values of the adapt-image). - - 3) Raise all values to the power of `weight_power`. A power of 1.0 leaves them unchanged; a power of 0.0 - makes all weights equal to 1.0. - - 4) Set any value below `weight_floor` to that floor. This is a minimum weight, not a value added to every pixel. - -The idea is that using high values of `weight_power` will make the highest weight values much higher than the -lowest values, such that the Hilbert curve will trace these values much more than the lower values. The -weight_floor gives the algorithm some balance, by introducing a floor to the weight map that prevents the lowest -values from being weighted to near zero. - -Lets look at this in action. we'll inspect 3 weight_maps, using a weight_power of 0.0, 5.0 and 10.0 and setting the -`weight_floor` to 0.0 for now. -""" -image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=0.0) - -weight_map = image_mesh.weight_map_from(adapt_data=adapt_image) -weight_map = al.Array2D(values=weight_map, mask=mask) - -aplt.plot_array(array=weight_map, title="Hilbert Weight Power 0: Weight Map") - - -image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=5.0) - -weight_map = image_mesh.weight_map_from(adapt_data=adapt_image) -weight_map = al.Array2D(values=weight_map, mask=mask) - -aplt.plot_array(array=weight_map, title="Hilbert Weight Power 5: Weight Map") - - -image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.0, weight_power=10.0) - -weight_map = image_mesh.weight_map_from(adapt_data=adapt_image) -weight_map = al.Array2D(values=weight_map, mask=mask) - -aplt.plot_array(array=weight_map, title="Hilbert Weight Power 10: Weight Map") - -""" -When we increase the weight-power the brightest regions of the adapt-image become weighted higher relative to the -fainter regions. - -This means that the Hilbert algorithm will adapt more pixels to the brightest regions of the source. - -Let us compare powers 0, 5 and 10 using the same number of Hilbert points, Delaunay mesh and regularization -coefficient. We reuse the power-10 reconstruction above. Each new power changes the mesh points, so we must -reconstruct the source again before comparing evidence. -""" -fits_by_power = {10.0: brightness_fit} - -for weight_power in (0.0, 5.0, 10.0): - if weight_power not in fits_by_power: - image_mesh = al.image_mesh.Hilbert( - pixels=500, weight_floor=0.0, weight_power=weight_power, - ) - image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from( - mask=dataset.mask, adapt_data=adapt_image, - ) - source = al.Galaxy( - redshift=1.0, - pixelization=al.Pixelization( - mesh=al.mesh.Delaunay(pixels=image_plane_mesh_grid.shape[0]), - regularization=al.reg.Constant(coefficient=1.0), - ), - ) - adapt_images = al.AdaptImages( - galaxy_image_dict={source: adapt_image}, - galaxy_image_plane_mesh_grid_dict={source: image_plane_mesh_grid}, - ) - tracer = al.Tracer(galaxies=[lens_galaxy, source], fields=[field]) - fits_by_power[weight_power] = al.FitImaging( - dataset=dataset, tracer=tracer, adapt_images=adapt_images, - ) - - current_fit = fits_by_power[weight_power] - current_mapper = current_fit.inversion.cls_list_from(al.Mapper)[0] - aaplt.plot_mapper( - mapper=current_mapper, solution_vector=current_fit.inversion.reconstruction, - mesh_grid=current_mapper.source_plane_mesh_grid, - title=f"Hilbert Weight Power {weight_power:g}", - ) - aplt.subplot_fit_imaging(fit=current_fit) - print(f"Weight power {weight_power:g}: log evidence={float(current_fit.log_evidence):.3f}") - -print( - "Log evidence difference, power 10 minus power 0:", - fits_by_power[10.0].log_evidence - fits_by_power[0.0].log_evidence, -) - -""" -So, what does the `weight_floor` do? Increasing the weight-power congregates pixels around the source. However, -there is a risk that by congregating too many source pixels in its brightest regions we lose resolution further -out, where the source is bright, but not its brightest! - -The `weight_floor` allows these regions to maintain a higher weighting whilst the `weight_power` increases. This -means that the mesh can retain more coverage away from the brightest regions. Check whether this improves the fit. - -Let us compare a floor of 0.5 with the power-10, floor-zero fit, keeping the other settings fixed: -""" -image_mesh = al.image_mesh.Hilbert(pixels=500, weight_floor=0.5, weight_power=10.0) - -image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from( - mask=dataset.mask, adapt_data=adapt_image -) - - -pixelization = al.Pixelization( - mesh=al.mesh.Delaunay(pixels=image_plane_mesh_grid.shape[0]), - regularization=al.reg.Constant(coefficient=1.0), -) - -source_weight_floor = al.Galaxy( - redshift=1.0, pixelization=pixelization -) - -weight_map = image_mesh.weight_map_from(adapt_data=adapt_image) -weight_map = al.Array2D(values=weight_map, mask=mask) - -aplt.plot_array(array=weight_map, title="Hilbert Weight Floor 0.5: Weight Map") - -tracer = al.Tracer(galaxies=[lens_galaxy, source_weight_floor], fields=[field]) - -adapt_images = al.AdaptImages( - galaxy_image_dict={source_weight_floor: adapt_image}, - galaxy_image_plane_mesh_grid_dict={source_weight_floor: image_plane_mesh_grid}, -) - -fit = al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images) - -floor_fit = fit -floor_mapper = fit.inversion.cls_list_from(al.Mapper)[0] -aaplt.plot_mapper( - mapper=floor_mapper, solution_vector=fit.inversion.reconstruction, - mesh_grid=floor_mapper.source_plane_mesh_grid, title="Hilbert Weight Floor 0.5", -) -aplt.subplot_fit_imaging(fit=fit) -print("Evidence with weight floor 0.0:", fits_by_power[10.0].log_evidence) -print("Evidence with weight floor 0.5:", floor_fit.log_evidence) -print("Difference in log evidence:", floor_fit.log_evidence - fits_by_power[10.0].log_evidence) - -""" -__Wrap Up__ - -The measured evidence comparisons show how these choices affect this dataset. Revisit tutorial 2 for the -interpretation of evidence: image agreement and the source prior must be considered together. - -Brightness weighting can concentrate mesh points where source light is expected. Excessive concentration can leave -faint regions poorly resolved, and a more detailed mesh does not establish that every reconstructed feature is -real. Inspect the mesh, source and residuals together before choosing settings. - -Here we held the coefficient fixed while comparing powers and floors. A full analysis would also explore -regularization, since its preferred strength depends on mesh geometry. Fewer source values can reduce the cost of -the inversion, but neither faster fits nor higher evidence are guaranteed by brightness adaptation alone. - -Tutorial 11 done, next up, adaptive regularization! -""" diff --git a/scripts/chapter_3_pixelizations/tutorial_12_adaptive_regularization.py b/scripts/chapter_3_pixelizations/tutorial_12_adaptive_regularization.py deleted file mode 100644 index c275a4f..0000000 --- a/scripts/chapter_3_pixelizations/tutorial_12_adaptive_regularization.py +++ /dev/null @@ -1,300 +0,0 @@ -""" -Tutorial 12: Adaptive Regularization -==================================== - -Tutorial 2 introduced `Constant` regularization. Different regions of the source can favour different levels of -regularization, motivating a scheme which adapts to the reconstructed source's surface brightness. - -This raises the same question as before, how do we adapt our regularization scheme to the source before we've -reconstructed it? Just like in the last tutorial, we'll use a model image of a strongly lensed source from a -previous model that we've begun calling the `adapt-image`. - -__Contents__ - -- **Initial Setup:** we'll use the same strong lensing data as the previous tutorial, where. -- **Convenience Function:** We are going to fit the image using a magnification based grid. -- **Adaptive Regularization:** Lets now look at adaptive regularization in action, by setting up a adapt-image and - using the. -- **Wrap Up:** Summary of the script and next steps. - -""" - -from autolens import jax_wrapper # Sets JAX environment before other imports - -# from autolens import setup_notebook; setup_notebook() - -from pathlib import Path -import autolens as al -import autolens.plot as aplt -import autoarray.plot as aaplt - -""" -__Initial Setup__ - -Tutorial 8's compactness experiment motivates the need for resolution and smoothing checks. As in tutorial 11, this -standalone example uses `simple__no_lens_light`, rather than the progressively compact simulations, where: - - - The lens galaxy's light is omitted. - - The lens system uses an `Isothermal` galaxy mass profile and a separate `ExternalShear` field. - - The source galaxy's light is an `Sersic`. -""" -dataset_name = "simple__no_lens_light" -dataset_path = Path("dataset") / "imaging" / dataset_name - -""" -__Dataset Auto-Simulation__ - -If the dataset does not already exist on your system, it will be created by running the corresponding simulator -script. This ensures that all example scripts can be run without manually simulating data first. -""" -al.util.dataset.simulate_if_needed( - dataset_path, simulator="scripts/simulator/no_lens_light.py" -) - -dataset = al.Imaging.from_fits( - data_path=dataset_path / "data.fits", - noise_map_path=dataset_path / "noise_map.fits", - psf_path=dataset_path / "psf.fits", - pixel_scales=0.1, -) - -mask = al.Mask2D.circular( - shape_native=dataset.shape_native, - pixel_scales=dataset.pixel_scales, - radius=3.0, -) - -dataset = dataset.apply_mask(mask=mask) - -""" -__Convenience Function__ - -We are going to fit the image using a magnification based grid. - -To perform the fits, we'll use a convenience function to fit the lens data we simulated above. -""" - - -def fit_via_source_galaxy_from(dataset, source_galaxy, adapt_images=None): - lens_galaxy = al.Galaxy( - redshift=0.5, - mass=al.mp.Isothermal( - centre=(0.0, 0.0), - einstein_radius=1.6, - ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0), - ), - ) - field = al.MassField( - redshift=0.5, shear=al.mp.ExternalShear(gamma_1=0.05, gamma_2=0.05) - ) - - tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy], fields=[field]) - - return al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images) - - -""" -Use the magnification based source to fit this data. -""" -pixelization = al.Pixelization( - mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)), - regularization=al.reg.Constant(coefficient=3.3), -) - -source_magnification = al.Galaxy(redshift=1.0, pixelization=pixelization) - -constant_fit = fit_via_source_galaxy_from(dataset=dataset, source_galaxy=source_magnification) -fit = constant_fit - -mapper = fit.inversion.cls_list_from(al.Mapper)[0] - - -aplt.subplot_fit_imaging(fit=fit) -aplt.plot_array(array=fit.model_data, title="Plane 1 Image") - -""" -Inspect the residuals before changing the prior. We can display the actual regularization weights on the source -mesh: Constant gives every source value the input coefficient of 3.3. These are weights, rather than source -brightnesses or an image-plane reconstruction. -""" -constant_weights = mapper.regularization.regularization_weights_from(linear_obj=mapper) -aaplt.plot_mapper( - mapper=mapper, solution_vector=constant_weights, - mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False, - vmin=0.0, vmax=4.0, title="Constant Regularization Weights", -) -print("Constant weight range:", float(min(constant_weights)), float(max(constant_weights))) - -""" -__Adaptive Regularization__ - -Lets now look at adaptive regularization in action, by setting up a adapt-image and using the `Adapt` -regularization scheme. - -This introduces additional parameters, that are explained below. -""" -adapt_image = fit.model_data.slim - -pixelization = al.Pixelization( - mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)), - regularization=al.reg.Adapt( - inner_coefficient=0.005, outer_coefficient=1.9, signal_scale=3.0 - ), -) - -source_adapt_regularization = al.Galaxy( - redshift=1.0, - pixelization=pixelization, -) - -adapt_images = al.AdaptImages( - galaxy_image_dict={source_adapt_regularization: adapt_image} -) - -fit = fit_via_source_galaxy_from( - dataset=dataset, - source_galaxy=source_adapt_regularization, - adapt_images=adapt_images, -) - -adaptive_fit = fit -mapper = fit.inversion.cls_list_from(al.Mapper)[0] -adaptive_weights = mapper.regularization.regularization_weights_from(linear_obj=mapper) -aaplt.plot_mapper( - mapper=mapper, solution_vector=adaptive_weights, - mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False, - vmin=0.0, vmax=4.0, title="Adaptive Regularization Weights", -) -aaplt.plot_mapper( - mapper=mapper, solution_vector=fit.inversion.reconstruction, - mesh_grid=mapper.source_plane_mesh_grid, title="Adaptively Regularized Source", -) -print("Adaptive weight range:", float(min(adaptive_weights)), float(max(adaptive_weights))) - -""" -The weights now vary across the source mesh. Compare their locations with the reconstructed source, using the same -weight colour scale as the Constant example. These weights enter the regularization matrix; they are not -uncertainties on the reconstructed brightnesses. - -Let us compare the evidence measured from these two fits: -""" -print("Evidence using constant regularization:", constant_fit.log_evidence) -print("Evidence using adaptive regularization:", adaptive_fit.log_evidence) -print("Difference in log evidence:", adaptive_fit.log_evidence - constant_fit.log_evidence) - -aplt.subplot_fit_imaging(fit=fit) - -""" -An evidence increase is something to measure, rather than assume. The mesh is unchanged here, so this comparison -examines the regularization choice. These hand-chosen settings are not optimized, and varying the smoothing cannot -supply resolution that the source mesh lacks. Inspect the source and residuals alongside the evidence. - -__How does adaptive regularization work?__ - -For every source-pixel, we have a mapping between that pixel and a set of pixels in the adapt-image. Therefore, for -every source-pixel, if we sum the values of all adapt-image pixels that map to it we get an estimate of how much of -the lensed source's signal we expect will be reconstructed. We call this each pixel's `pixel signal`. - -If a source-pixel has a higher pixel-signal, we anticipate that it`ll reconstruct more flux and we use this -information to regularize it less. Conversely, if the pixel-signal is close to zero, the source pixel will -reconstruct near-zero flux and regularization will smooth over these pixels by using a high regularization -coefficient. - -This works as follows: - - 1) For every source pixel, compute its pixel-signal, the summed flux of all corresponding image-pixels in the - adapt-galaxy-image. - - 2) Divide every pixel-signal by the number of image-pixels that map directly to that source-pixel. In doing so, - all - pixel-signals are 'relative'. This means that source-pixels which by chance map to more image-pixels than their - neighbors will not have a higher pixel-signal, and visa versa. This ensures the specific pixelization - does not by itself determine the adaptive regularization pattern. - - 3) Divide the pixel-signals by the maximum pixel signal so that they range between 0.0 and 1.0. - - 4) Raise these values to the power of the adapt-parameter `signal_scale`. For a `signal_scale` of 0.0, all - pixels will therefore have the same final pixel-scale. As the `signal_scale` increases, a sharper transition of - pixel-signal values arises between regions with high and low pixel-signals. - - 5) Compute every source pixel's effective regularization coefficient as: - - (inner_coefficient * pixel_signals + outer_coefficient * (1.0 - pixel_signals)) ** 2.0 - - This uses two regularization coefficient parameters, one which is applied to pixels with high pixel-signals and - one to - pixels with low pixel-signals. Thus, pixels in the inner regions of the source may be given a lower level of - regularization than pixels further away, as desired. - -Thus, we now adapt our regularization scheme to the source's surface brightness. Where its brighter (and therefore -has a steeper flux gradient) we apply a lower level of regularization than further out. Furthermore, in the edges -of the source-plane where no source-flux is present we will assume a high regularization coefficient that smooths -over all of the source-pixels. - -Try looking at a couple of extra solutions which use with different inner and outer regularization coefficients or -signal scales. I doubt you'll notice a lot change visually, but the log evidence certainly has a lot of room for -manuveur with different values. - -You may find solutions that raise an `InversionException`. These solutions mean that the matrix used during the -linear algebra calculation was ill-defined, and could not be inverted. These solutions are removed during lens -modeling. -""" -pixelization = al.Pixelization( - mesh=al.mesh.RectangularBilinearAdaptDensity(shape=(24, 24)), - regularization=al.reg.Adapt( - inner_coefficient=0.001, outer_coefficient=0.2, signal_scale=2.0 - ), -) - -source_adapt_regularization = al.Galaxy( - redshift=1.0, - pixelization=pixelization, -) - -adapt_images = al.AdaptImages( - galaxy_image_dict={source_adapt_regularization: adapt_image} -) - -fit = fit_via_source_galaxy_from( - dataset=dataset, - source_galaxy=source_adapt_regularization, - adapt_images=adapt_images, -) - -mapper = fit.inversion.cls_list_from(al.Mapper)[0] - - -aplt.subplot_fit_imaging(fit=fit) - -aplt.plot_array( - array=fit.inversion.mapped_reconstructed_operated_data, - title="Inversion Reconstruction", -) - -alternative_weights = mapper.regularization.regularization_weights_from(linear_obj=mapper) -aaplt.plot_mapper( - mapper=mapper, solution_vector=alternative_weights, - mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False, - vmin=0.0, vmax=4.0, title="Alternative Adaptive Regularization Weights", -) -print("Evidence using alternative adaptive regularization:", fit.log_evidence) -print("Difference from constant regularization:", fit.log_evidence - constant_fit.log_evidence) -print("Difference from first adaptive example:", fit.log_evidence - adaptive_fit.log_evidence) - -""" -__Wrap Up__ - -Adaptive regularization lets bright and faint parts of the source receive different smoothing weights. Lower -weights can preserve sharp structure where the data support it; higher weights can correlate values in faint -regions that would otherwise fit noise. The adapt image guides that distinction and must itself be checked. - -The evidence comparisons quantify the outcomes for our chosen settings. They do not guarantee an increase for every -dataset, a unique maximum, or recovery of arbitrary source detail. Mesh resolution, the adapt image and the lens -model still limit the reconstruction. In a scientific analysis, explore the regularization parameters and inspect -the source and residuals together. - -That completes this chapter on pixelized source reconstruction. You can now investigate flexible source -reconstructions while checking the assumptions that shape them. In chapter 4, `Scaling Up Lensing`, we take these -tools to bigger systems: lenses with extra galaxies along the line of sight, multi-galaxy lenses, scaling -relations, group and cluster scale lenses and weak lensing. -""" diff --git a/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py b/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py index 5601aad..08a3390 100644 --- a/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py +++ b/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py @@ -2,6 +2,8 @@ Tutorial 1: Pixelizations, Mappers and Inversions ================================================= +Reconstruct a complex source using pixelizations, image-to-source mappings and a linear inversion. + __Contents__ - Opening — Pixelized source reconstruction @@ -14,6 +16,8 @@ - Reconstructing the source - A first look at regularization - From source indexes to reconstructed brightnesses +- Keeping source brightnesses non-negative +- Where the image weakly constrains the source - Turning the reconstructed source back into an image - Inspecting the reconstruction - Changing the source resolution @@ -177,6 +181,10 @@ next tutorial. For now, the important distinction is that the mesh specifies where we represent the source; it does not yet tell us how bright the source is. +The image mask also affects the extent of this mesh, because it determines which image coordinates are traced. +Tutorial 3, **Borders**, compares masks and shows how distant traced coordinates can spread the mesh over regions +with little useful source information. + __Following the same image pixels into the source plane__ @@ -364,6 +372,10 @@ coloured patches is not necessarily the physical image multiplicity: masking and pixel sampling can split or hide regions. +Tutorial 4, **Lens modelling**, returns to this picture in **Seeing how the mass model changes the mappings**. +There we change the Einstein radius and see why an incorrect mass model changes which image regions can share +the same reconstructed source. + __Reconstructing the source__ @@ -446,6 +458,48 @@ +__Keeping source brightnesses non-negative__ + +By default, the inversion uses a positive-only solver: reconstructed source brightnesses are constrained to be +non-negative, so zero is allowed. This prevents the model from explaining the image by adding negative source light. + +Positivity and regularization impose different constraints. Positivity restricts the allowed brightness values; +regularization discourages large differences between neighbours. A non-negative reconstruction can still contain +isolated bright values and fit noise, so positivity does not remove the need for regularization. + +__Where the image weakly constrains the source__ + +Not every mesh point contributes equally to the image. Some receive many ray-traced image coordinates, while +others receive little support or none at all. We can inspect this using the mapping matrix we already introduced. + +The next cell sums the absolute mapping weights for each source mesh point, then prints the least-supported points +alongside their reconstructed brightnesses. It also counts points whose total weight is effectively zero; there +may be none for this particular mesh and mask. +""" + +mapping_support = np.sum(np.abs(np.asarray(mapper.mapping_matrix)), axis=0) +unsupported = np.flatnonzero(mapping_support <= 1.0e-12) +least_supported = np.argsort(mapping_support)[:5] + +print("Source mesh points with effectively zero mapping weight:", len(unsupported)) +for source_index in least_supported: + print( + f"Source mesh index {source_index}: " + f"total mapping weight={mapping_support[source_index]:.6g}, " + f"reconstructed brightness={reconstruction[source_index]:.6g}" + ) + +""" +A point with no direct image contribution cannot have its brightness determined directly by the data. Its inferred +value can instead be influenced by regularization linking it to neighbouring source values, together with the +non-negativity constraint and any boundary constraints used by the inversion. + +For a weakly supported point, both the data and these constraints can matter. The total mapping weight is a +geometric diagnostic, not a brightness uncertainty: it does not account for image noise, telescope blurring or +correlations between source values. Ask how much confidence you should place in faint structure at these points. + + + __Turning the reconstructed source back into an image__ The source reconstruction and the reconstructed image are different objects. The source lives on our source-plane diff --git a/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py b/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py index 11c5a71..8f634ad 100644 --- a/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py +++ b/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py @@ -2,6 +2,8 @@ Tutorial 2: Bayesian Regularization =================================== +Explore regularization and use Bayesian evidence to choose its strength for a source reconstruction. + __Contents__ - Interpolation and Bayesian regularization @@ -9,11 +11,12 @@ - Reducing regularization - Increasing regularization - Bayesian evidence +- Choosing the coefficient - Exploring the regularization coefficient - Inspecting the preferred reconstruction - What is linear, and what requires a search? - Mesh resolution and regularization belong together -- What we have learned, and what comes next +- Wrap Up __Interpolation and Bayesian regularization__ @@ -296,34 +299,28 @@ def fit_with_coefficient(coefficient): **🤖 Ask AI:** Why might a weakly regularized reconstruction have a lower chi-squared but a less trustworthy source than a more strongly regularized reconstruction? +""" +""" __Bayesian evidence__ -For inversions, we therefore need more than image agreement to choose an appropriate level of regularization. +Choosing the regularization coefficient is a Goldilocks problem. Too little regularization gives the source enough freedom to fit the image’s noise. Too much regularization smooths away source structure that we need to explain the image. We want an amount of regularization that is **“just right”**. + +But how do we determine what “just right” means without choosing the reconstruction that looks best by eye? -We use the **Bayesian evidence**, which accounts for both the fit to the image and the source prior. It helps us -compare reconstructions that have different amounts of freedom, rather than simply choosing whichever one follows -the observed pixel values most closely. +We use the **Bayesian evidence**, which accounts for both how accurately we reconstruct the image and the complexity of the source reconstruction. -There are two useful parts to this intuition. +With **too much regularization**, neighbouring source pixels are forced towards similar brightness values. This makes it difficult to reconstruct steep changes in brightness, such as a compact source’s bright centre. The source is too smooth, the corresponding image fit leaves residuals, and the evidence decreases. -First, the reconstruction must explain the image. If strong regularization prevents it from reproducing genuine -source structure, the resulting residuals count against that choice. Preferring a smooth source is not useful if -that source cannot explain the observed arcs. +With **too little regularization**, neighbouring source pixels have greater freedom to vary independently. The image fit improves, but the reconstruction can use this freedom to reproduce noise as additional source structure. The evidence accounts for the extra complexity, so a closer fit to the image does not necessarily give a higher evidence. -Second, allowing a more flexible source comes at a cost. Weak regularization permits many more patterns of -brightness across neighbouring mesh points. Some of that extra freedom may be needed to describe the galaxy, but -some may only help reproduce the particular noise fluctuations in this image. +The **“just right” coefficient** balances these effects: enough freedom to reconstruct the source structure supported by the data, with sufficient smoothing to discourage fitting noise. We identify it by finding the coefficient that gives the **highest Bayesian evidence**. -Evidence weighs the improvement in fit against the freedom allowed by the prior. An improvement must be sufficient -to justify that additional flexibility. +Complexity here means more than the number of source pixels. Our examples use exactly the same mesh. Stronger regularization couples neighbouring brightness values more closely, reducing their freedom to vary independently without changing the number of pixels. -This is also why complexity is not simply the number of source mesh points. Our three examples have exactly the -same mesh. Stronger regularization ties neighbouring brightnesses together more closely, reducing their freedom to -vary independently. We can therefore change the effective flexibility of the reconstruction without changing the -number of mesh points. +In **PyAutoLens**, we obtain the natural logarithm of the evidence using `fit.log_evidence`. Taking the logarithm preserves the ranking, so choosing the largest log evidence is equivalent to choosing the largest evidence. -Let’s compare the evidence values returned by `FitImaging`. +Let’s compare our three reconstructions. """ log_evidences = [] @@ -337,206 +334,195 @@ def fit_with_coefficient(coefficient): f"log evidence={log_evidence:.3f}" ) -best_index = int(np.argmax(log_evidences)) -best_label, best_coefficient, _ = fit_comparisons[best_index] +""" +__Choosing the coefficient__ -print( - f"\nHighest evidence among these examples: " - f"{best_label}, coefficient={best_coefficient:g}" -) +How do we interpret these numbers? + +Suppose, purely as an illustration, that coefficients of `0.01`, `1.0` and `100.0` gave log evidences of `4200`, `4250` and `4100`. We would choose `1.0` from these three values because `4250` is the largest. The same rule applies if the values are negative: `-100` is larger than `-150`. +There is no particular log evidence value that we are trying to reach. We compare the values obtained by fitting **the same dataset**, keeping the lens model, mesh, mask and noise map fixed while changing the regularization coefficient. + +The code below selects the coefficient with the highest log evidence and prints how far each alternative lies below it. """ -Compare this ranking with the source plots and the chi-squared values. The highest evidence need not belong to the -reconstruction with the lowest chi-squared, because image agreement is only part of the comparison. -We still need to inspect the residuals ourselves. Evidence does not explicitly examine their spatial pattern or -guarantee that a preferred model is correct. Structured residuals can reveal shortcomings in the source, lens mass, -PSF or other assumptions that this three-coefficient comparison does not address. +log_evidences = np.asarray(log_evidences) -For now, we have compared only three choices. The starting value of `1.0` has no special status, and the best of -these three is not necessarily the best coefficient overall. Next, we will explore a broader range and see how this -choice fits into the wider modelling process. +best_index = int(np.argmax(log_evidences)) +best_label, best_coefficient, best_fit = fit_comparisons[best_index] +for (label, coefficient, _), log_evidence in zip( + fit_comparisons, log_evidences +): + print( + f"{label}: coefficient={coefficient:g}, " + f"difference from highest log evidence=" + f"{log_evidence - log_evidences[best_index]:.3f}" + ) + +print(f"\nPreferred coefficient among these examples: {best_coefficient:g}") + +aplt.subplot_fit_imaging(fit=best_fit) +aplt.subplot_fit_imaging_of_planes(fit=best_fit) + +""" +Compare this reconstruction with the weakly and strongly regularized examples. How much of the irregular source structure has been smoothed away? Are the bright components still reconstructed accurately? What remains in the image residuals? + +We have selected the best of **three coefficients**, rather than found the overall maximum. A coefficient between our trial values could give a higher log evidence. Next, we will evaluate a wider range and plot the evidence against the coefficient to locate its peak. +""" + +""" __Exploring the regularization coefficient__ -We have compared weak, moderate and strong regularization, but those three values were chosen to illustrate -different behaviours. They do not tell us where the evidence reaches its highest value. +Our three examples illustrate too little, moderate and too much regularization. However, there is nothing special about the starting coefficient of `1.0`. A value of `0.5`, `2.0`, or something else entirely might give a higher log evidence. -Let’s explore the coefficient more systematically. We will keep the lens mass, mesh resolution, mask and noise map -fixed, and reconstruct the source for a sequence of coefficients. +Let’s explore this systematically. We keep the lens mass model, source mesh and dataset fixed, and calculate the log evidence for a range of regularization coefficients. -Regularization coefficients can span several orders of magnitude, so we use logarithmically spaced values. This -gives us comparable coverage between `0.01` and `0.1`, between `0.1` and `1.0`, and so on. +For every coefficient, we perform a new inversion. We are not taking one reconstructed source and smoothing it afterwards: changing the coefficient changes the brightness values that the inversion solves for. -Every point in this experiment requires a new source reconstruction. We are not taking one reconstructed source and -smoothing it by different amounts afterwards. The coefficient changes the problem that the inversion solves. +Regularization coefficients can span several orders of magnitude, so we use logarithmically spaced values. This gives us evenly spaced steps between `0.01` and `0.1`, between `0.1` and `1.0`, and so on. """ import matplotlib.pyplot as plt coefficient_grid = np.logspace(-2, 2, 13) -# Reuse the three reconstructions already calculated. -known_fits = { +# Reuse the three reconstructions we have already calculated. +fits_by_coefficient = { 0.01: weak_regularization_fit, 1.0: fit, 100.0: strong_regularization_fit, } evidence_grid = [] +best_evidence = -np.inf +preferred_fit = None for coefficient in coefficient_grid: coefficient = float(coefficient) - if coefficient in known_fits: - current_fit = known_fits[coefficient] - else: + current_fit = fits_by_coefficient.get(coefficient) + if current_fit is None: current_fit = fit_with_coefficient(coefficient=coefficient) - evidence_grid.append(float(current_fit.log_evidence)) + evidence = float(current_fit.log_evidence) + evidence_grid.append(evidence) + if evidence > best_evidence: + best_evidence = evidence + preferred_fit = current_fit evidence_grid = np.asarray(evidence_grid) if not np.all(np.isfinite(evidence_grid)): - raise ValueError( - "Some evidence values are not finite. Inspect those fits before comparing." - ) + raise ValueError("Inspect fits with non-finite evidence before comparing them.") fig, ax = plt.subplots() + ax.semilogx( coefficient_grid, evidence_grid - np.max(evidence_grid), marker="o", ) + ax.set_xlabel("Regularization coefficient") ax.set_ylabel("Log evidence relative to the highest sampled value") -ax.set_title("Choosing the Strength of Regularization") + plt.show() """ -Look for the part of the curve where the evidence is highest. Moving towards weaker regularization gives the source -more freedom; moving towards stronger regularization increasingly restricts its variations. +We have subtracted the highest log evidence from every value. This places the highest point at zero and the other points below it, without changing their ranking. -We may find a clear peak, a broad region with similar evidence, or a highest value at one end of the range. These -outcomes mean different things. A boundary maximum tells us that this experiment has not bracketed the preferred -coefficient—we should extend the range before claiming to have located it. +Look for the peak of the curve. Towards small coefficients, the source has more freedom to fit noise. Towards large coefficients, smoothing increasingly prevents it from reproducing the source structure. The peak identifies our Goldilocks coefficient **among the values we have tested**. -The plotted points are also discrete samples. Even an interior maximum only identifies the best coefficient we have -tried so far. +If the highest point lies at either end of the range, we should extend the range in that direction. We have not yet located a peak between weaker and stronger regularization. +""" +""" __Inspecting the preferred reconstruction__ -Let’s inspect the reconstruction with the highest evidence in our sampled range. +Let’s take the coefficient with the highest log evidence and look at its source reconstruction and image residuals. -It is tempting to treat the highest number as the end of the analysis. However, we should connect that number back -to the source and image. We want to understand what this choice preserves, what it smooths away, and what the -resulting model still fails to explain. +Before looking, think back to our earlier examples. Weak regularization allowed sharp variations between neighbouring source pixels, including in faint regions. Strong regularization suppressed these variations but could also flatten the bright source components. -Compare it with the weakly and strongly regularized examples. In particular, examine the distinct bright source -components and the faint regions between them. Then look at the image residuals where those components are lensed. +Does the evidence-preferred reconstruction provide a better balance? """ -best_index = int(np.argmax(evidence_grid)) -preferred_coefficient = float(coefficient_grid[best_index]) - -if preferred_coefficient in known_fits: - preferred_fit = known_fits[preferred_coefficient] -else: - preferred_fit = fit_with_coefficient( - coefficient=preferred_coefficient - ) +preferred_index = int(np.argmax(evidence_grid)) +preferred_coefficient = float(coefficient_grid[preferred_index]) -print("Preferred sampled coefficient:", preferred_coefficient) - -if best_index in (0, len(coefficient_grid) - 1): - print( - "The highest evidence lies at the boundary. " - "Extend the coefficient range before interpreting this as a peak." - ) +print(f"Preferred sampled coefficient: {preferred_coefficient:.4g}") +print(f"Log evidence: {evidence_grid[preferred_index]:.3f}") preferred_mapper = preferred_fit.inversion.cls_list_from(al.Mapper)[0] aaplt.plot_mapper( mapper=preferred_mapper, solution_vector=preferred_fit.inversion.reconstruction, + vmin=0.0, + vmax=source_vmax, zoom_to_brightest=False, - title="Source at the Preferred Sampled Coefficient", + title="Source at the Preferred Regularization Coefficient", ) aplt.subplot_fit_imaging(fit=preferred_fit) aplt.subplot_fit_imaging_of_planes(fit=preferred_fit) """ -A preferred coefficient does not establish that every visible feature in the source is real. The reconstruction -remains conditional on the lens model, source mesh, noise map and telescope blurring. +The source plot uses the same brightness limits as our earlier comparisons. Look at the bright components and the faint regions around them, then examine the corresponding image residuals. + +Choosing the highest evidence does not mean the residuals must now be satisfactory. We have found the preferred coefficient for **this particular mesh and regularization scheme**. If the mesh has too few pixels across a compact source component, changing the coefficient cannot supply the missing resolution. -Nor does a good-looking source excuse coherent residuals in the image. The source and residual plots provide -complementary information: one shows what we inferred, while the other shows what that inference leaves -unexplained. +There is also a compromise in applying one coefficient across the whole source. The bright centre may need weaker smoothing to preserve its steep brightness gradient, while the faint outskirts benefit from stronger smoothing. A single coefficient must serve both regions. -**🤖 Ask AI:** If the evidence prefers a particular regularization coefficient but the residuals still follow the -shape of an arc, what modelling assumptions should I investigate next? +Keep these limitations in mind. Our later **Fit Problems** tutorial will show why choosing the coefficient is only part of the solution, motivating meshes and regularization that adapt to the source. +""" +""" __What is linear, and what requires a search?__ -Before going further, let’s separate two parts of the calculation that are easy to confuse. +Our reconstruction contains hundreds of source brightness values. Does this mean we have added hundreds of parameters to the non-linear search? -For each coefficient we supplied, the inversion solved for the source brightnesses. We did not give a non-linear -search hundreds of independent source brightness parameters and ask it to explore their possible combinations. -Instead, the source enters the predicted image linearly, allowing these brightnesses to be solved together using -linear algebra. +Fortunately, no! This is the same advantage we encountered with linear light profiles in Chapter 2. There, we solved for the intensities of Gaussian components. Here, we solve for the brightness values on the source mesh. -Regularization is included in that solve. The source is not first reconstructed without a prior and then smoothed -afterwards. The inferred brightnesses already reflect the balance between fitting the image and satisfying the -smoothness prior. +For a fixed lens model, mesh and regularization coefficient, these brightness values enter the predicted image linearly. The inversion therefore solves for them together using linear algebra, including regularization in that calculation. -Choosing the regularization coefficient is a different problem. We supplied several coefficients, performed an -inversion for each, and compared their evidence values. Our loop is a simple grid search over one quantity. +Choosing the regularization coefficient is a separate, non-linear problem. Each coefficient produces a different reconstruction and log evidence. Our loop explored this by trying a grid of coefficients and selecting the one with the highest evidence. -We can see the difference in scale directly: +Let’s compare how many source brightness values we reconstructed with how many coefficients we tried. """ source_brightnesses = np.asarray( preferred_fit.inversion.reconstruction ) -print("Number of source mesh values:", preferred_mapper.pixels) -print("Number of reconstructed entries:", source_brightnesses.size) -print("Number of coefficients evaluated:", coefficient_grid.size) +print("Number of reconstructed brightness values:", source_brightnesses.size) +print("Number of regularization coefficients tried:", coefficient_grid.size) """ -The reconstruction contains an entry for each source mesh point in this single-mapper example. Some values may be -constrained to zero at the mesh boundary, and regularization couples neighbouring values, so the number of entries -is not the number of independently constrained source features. +Every coefficient in our grid required a solution for **all** those brightness values. We did not search through their possible combinations individually. -During lens modelling, the search also changes the mass parameters. Each proposed mass model changes the ray -tracing and therefore the mappings between source and image. The source must be reconstructed again for that -proposal. +During lens modelling, we also vary the lens mass parameters. Changing the mass model changes where the image pixels trace in the source plane, so we must perform a new inversion for each proposed model. -The sequence is therefore: +The calculation follows this sequence: -1. Propose the non-linear parameters, including the lens mass and regularization coefficient. -2. Construct the corresponding mappings. -3. Solve for the source brightnesses. -4. Evaluate how well that model explains the data, accounting for the source prior. +1. The non-linear search proposes a lens mass model and regularization coefficient. +2. We ray-trace the image pixels and construct their mappings to the source mesh. +3. The inversion solves for the source brightness values. +4. We calculate the log evidence, which the search uses to assess that proposal. -This is what makes pixelized sources practical: the search explores the non-linear model choices, while the -inversion handles the many source brightness values. +This is what makes pixelized sources practical. We can reconstruct hundreds of source brightness values without asking the non-linear search to explore hundreds of additional parameters. +""" +""" __Mesh resolution and regularization belong together__ -We have held the source mesh at `(25, 25)`. The preferred coefficient we found belongs to that choice of mesh. +We have found a preferred regularization coefficient for our current mesh. Would the same coefficient still be preferred if we changed its resolution? -Changing the mesh changes the locations and number of source brightness values, their neighbours, and how they -contribute to the image. The same numerical coefficient therefore need not give an equivalent amount of smoothing -on another mesh. +Not necessarily. A finer mesh introduces more brightness values and changes the distances between neighbouring points. It gives the source more freedom to describe small-scale structure, but also more freedom to fit noise. The regularization coefficient must be chosen alongside that resolution. -This matters when testing resolution. If we increase the number of source points but keep the coefficient fixed, -any change in the reconstruction reflects both the new resolution and its interaction with the prior. We cannot -assume that we have made a fair comparison of the best reconstructions available at each resolution. +Think of our Goldilocks analogy again: changing the mesh changes the problem for which we are finding the “just right” amount of smoothing. -Let’s repeat the coefficient experiment for `(15, 15)` and `(35, 35)`, alongside our existing `(25, 25)` results. -We extend our helper so the mesh shape is an explicit input. +Let’s repeat our coefficient scan with coarser and finer meshes. We first extend our helper function so that we can specify the mesh shape. """ def fit_with_coefficient(coefficient, mesh_shape=mesh_shape): @@ -554,10 +540,13 @@ def fit_with_coefficient(coefficient, mesh_shape=mesh_shape): return al.FitImaging(dataset=dataset, tracer=tracer) +""" +We reuse the evidence values for our existing mesh and calculate two additional curves. This takes longer than the previous experiment because each additional mesh requires another complete coefficient scan. +""" evidence_by_shape = {mesh_shape: evidence_grid} -for shape in ([(6, 6), (10, 10)] if mesh_shape == (8, 8) else [(15, 15), (35, 35)]): +for shape in [(15, 15), (35, 35)]: values = [] for coefficient in coefficient_grid: @@ -592,39 +581,28 @@ def fit_with_coefficient(coefficient, mesh_shape=mesh_shape): ax.set_xlabel("Regularization coefficient") ax.set_ylabel("Log evidence relative to the highest sampled value") -ax.set_title("Mesh Resolution and Regularization") ax.legend() + plt.show() """ -This experiment performs 26 additional reconstructions, so it takes longer than our earlier three-way comparison. +Look at **where each curve peaks** and **how high that peak is**. -Look at both the locations and heights of the curves’ maxima. The preferred coefficient may shift as the mesh -changes. A finer mesh may help describe source structure, but adding points does not guarantee a higher evidence. +The position tells us which coefficient is preferred for that mesh. The height lets us compare the best sampled reconstructions across the different resolutions. We subtract the same reference value from every curve so that these differences remain visible. -All three curves use the same image pixels and noise model. We also subtract one common reference evidence, -preserving differences between the meshes. Subtracting each curve’s own maximum would hide those differences. +A finer mesh may increase the evidence by resolving source structure that the coarser mesh cannot describe. However, more source pixels do not automatically mean a better reconstruction: the additional freedom must be justified by the image data. -As before, a boundary maximum means that the coefficient range needs extending. These are controlled resolution -checks, not a demonstration that mesh dimensions should become free parameters in every lens-model search. +Before drawing a conclusion, check that each peak lies inside the coefficient range. If a curve is still rising at either end, extend its range. Once a peak is bracketed, try more closely spaced coefficients around it to refine the comparison. +""" +""" __Wrap Up__ -We can now give the regularization input a more meaningful interpretation. - -The source mesh supplies brightness values that can vary to explain the image. Interpolation defines the brightness -between those values. Regularization places a prior on their variation, discouraging unnecessary differences -between neighbours. +We can now explain the regularization coefficient that we first supplied without much discussion in tutorial 1. -Too little regularization can allow the reconstruction to explain noise through spurious source structure. Too much -can suppress real structure and leave it visible in the image residuals. Evidence helps compare these choices by -accounting for both image agreement and the freedom allowed by the source prior. +It controls how strongly we discourage differences between neighbouring source brightness values. Too little regularization allows the reconstruction to fit noise; too much smooths away structure needed to explain the image. We find the Goldilocks balance by maximizing the Bayesian evidence. -We also saw that the coefficient is not a universal setting. Its appropriate value depends on the dataset, lens -model, mesh and regularization scheme. A value that works for one reconstruction should not simply be carried into -another analysis without checking it. - -For a final comparison, print the preferred sampled coefficient for each mesh: +For a final comparison, let’s print the preferred sampled coefficient for each mesh. """ for shape, values in sorted(evidence_by_shape.items()): @@ -635,22 +613,19 @@ def fit_with_coefficient(coefficient, mesh_shape=mesh_shape): print( f"Mesh {shape}: coefficient={coefficient:.4g}, " f"log evidence={values[index]:.3f}" - + (" — boundary maximum; extend the range" if boundary else "") + + (" — extend the coefficient range" if boundary else "") ) -# Retain the original mesh's preferred fit for subsequent inspection. -fit = preferred_fit - """ -Throughout this tutorial, we fixed the lens mass to the simulation values. We have selected source-reconstruction -settings under that assumption; we have not inferred the mass or its uncertainty. +The preferred coefficient depends on the mesh, as well as the data and lens model. We should therefore infer it as part of an analysis, rather than assume that a value which worked for one reconstruction will work for another. + +Throughout this tutorial, we fixed the lens mass model to the values used to simulate the image. Later, we will fit the mass parameters alongside the regularization coefficient, performing an inversion for every model proposed by the non-linear search. -Later, these source reconstructions will sit inside a lens-model search. Before that, the next tutorial examines -another practical issue: how the source mesh covers the ray-traced image coordinates, and why a small number of -extreme coordinates can affect the resolution available to reconstruct the source. +Before that, we need to examine another practical issue. Choosing the number of source pixels does not, by itself, determine our source resolution. We also need to consider **how much area those pixels cover**. -For readers who want the underlying equations, the workspace’s [pixelization likelihood -guide](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py) -follows the mapping, blurring, regularization and evidence calculations step by step. That derivation complements -the experiments here; it is not required to continue using pixelizations. +--- +""" + +""" +For the underlying equations, the workspace’s [pixelization likelihood guide](https://github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py) follows the mapping, blurring, regularization and evidence calculations step by step. """ diff --git a/scripts/chapter_3_pixelizations/tutorial_3_borders.py b/scripts/chapter_3_pixelizations/tutorial_3_borders.py index 2c00760..e5ba4c6 100644 --- a/scripts/chapter_3_pixelizations/tutorial_3_borders.py +++ b/scripts/chapter_3_pixelizations/tutorial_3_borders.py @@ -2,6 +2,8 @@ Tutorial 3: Borders =================== +Explore how masks and border relocation affect the extent and resolution of a source mesh. + __Contents__ - Why the extent of the source mesh matters @@ -11,32 +13,30 @@ - Following the central pixels into the source plane - Tracing the outer border - Switching border relocation on -- What relocation changes—and what it does not +- What relocation changes - The mask still needs to cover the lensed source -- Wrap up +- Wrap Up +""" +""" __Why the extent of the source mesh matters__ -In the previous tutorials, we chose how many points our rectangular source mesh contained. However, the number of -points is only part of what determines its resolution. We also need to consider how much of the source plane those -points cover. +Imagine spreading a `(25, 25)` rectangular mesh across a source-plane region one arcsecond wide. Now spread the same mesh across a region ten arcseconds wide. We still have the same number of brightness values, but the spacing between them is ten times larger. Small source components that the first mesh could resolve may be poorly represented by the second. + +What determines this extent? -A `(25, 25)` mesh spread across a small source region can describe finer structure than the same mesh spread across -a much larger region. If a few ray-traced coordinates extend far beyond the source, the mesh can expand to -accommodate them, leaving fewer points where we actually need to reconstruct the galaxy. +The uniform rectangular mesh we are using is constructed to cover the ray-traced image coordinates. If some coordinates land far away from the source galaxy, the mesh expands to include them. Much of its resolution can then be spent covering regions where there is little or no source light. -This tutorial examines how that happens and introduces the border relocator, which provides a practical way to -control these extreme coordinates. +This can happen when image pixels close to the centre of the lens are strongly demagnified and trace far out into the source plane. Adding these central image pixels to a fit can therefore have a surprisingly large effect on the source mesh. -We return to a simpler simulated source so that its morphology does not distract from the geometry. The image -contains lensed source light but no foreground lens light. +In this tutorial, we will follow those pixels through the calculation and introduce **border relocation**, which prevents these distant coordinates from unnecessarily expanding the reconstruction. + +We return to a simpler simulated source, with no foreground lens light, so that we can focus on the geometry. """ from autolens import jax_wrapper from pathlib import Path -import subprocess -import sys import numpy as np import autolens as al @@ -46,7 +46,8 @@ dataset_path = Path("dataset") / "imaging" / "simple__no_lens_light" al.util.dataset.simulate_if_needed( - dataset_path, simulator="scripts/simulator/no_lens_light.py" + dataset_path, + simulator="scripts/simulator/no_lens_light.py", ) dataset_unmasked = al.Imaging.from_fits( @@ -57,24 +58,25 @@ ) dataset_unmasked = dataset_unmasked.apply_over_sampling( - over_sample_size_pixelization=1 + over_sample_size_pixelization=1, ) aplt.subplot_imaging_dataset(dataset=dataset_unmasked) """ -We will compare two masks with the same outer radius. One includes the centre of the image; the other excludes it. -This lets us see what happens when the central image pixels enter the reconstruction. +We will compare two masks with the same outer radius. One includes the central image pixels; the other removes them. This lets us isolate how including the centre changes the area covered by the source mesh. + +--- +""" +""" __What we mean by a border__ -The border is the outermost boundary of the unmasked image region. +Here, the **border** means the outermost boundary of the unmasked image region: the ring of image pixels just inside the mask’s outer edge. -For a circular mask, this is straightforward: the border follows the outside of the circle. An annular mask has an -inner and an outer edge, but the border used here follows the outer edge. The hole in the middle does not become a -second outer border. +For a circular mask, this follows the circumference of the circle. An annular mask also has an inner edge around its central hole, but that inner edge is not the border used for relocation. -Let’s plot both masks and mark their border coordinates. +Let’s construct both masks and mark their outer borders on the image. """ mask_circular = al.Mask2D.circular( @@ -106,24 +108,22 @@ ) """ -The outer borders should coincide because the masks share the same outer radius. The difference is inside: the -circular mask includes image pixels near the lens centre that the annular mask removes. +Both masks have the same outer border because their outer radii are identical. Their difference is in the centre: the circular mask includes pixels that the annular mask excludes. + +We can ray-trace the marked border pixels just as we ray-trace any other image pixels. Their traced positions define a border in the **source plane**, which we will use to identify coordinates that land beyond it. -We will keep the masks fixed while comparing the source-plane grids. Choosing the mask and relocating a border are -different operations: the mask selects which image pixels enter the fit, while relocation changes how certain -coordinates are treated when constructing the source reconstruction. +Keep these two operations separate. **Masking** determines which image pixels we fit. **Border relocation** changes how coordinates beyond the traced border enter the source reconstruction; it does not remove those image pixels from the fit. +""" +""" __Starting without border relocation__ -First, let’s reconstruct the source using the annular mask with border relocation explicitly switched off. +Let’s first reconstruct the source using the annular mask, which excludes the central image pixels. We explicitly switch border relocation off so that we can inspect the original ray-traced coordinates. -We use the mass model that generated the simulation and a uniform rectangular source mesh. The helper below lets us -change the mask and relocation setting while keeping the lens model, mesh shape and regularization coefficient -unchanged. +We use the same mass model that generated the simulated image: an isothermal galaxy with external shear. Keeping the mass model fixed lets us focus on how the mask affects the source reconstruction. """ -# Match mesh size to capped smoke data; normal tutorial runs retain 25 x 25. -mesh_shape = (8, 8) if dataset_unmasked.shape_native == al.util.dataset.SMALL_DATASETS_SHAPE_NATIVE else (25, 25) +mesh_shape = (25, 25) lens_galaxy = al.Galaxy( redshift=0.5, @@ -160,6 +160,9 @@ fields=[field], ) +""" +The helper below lets us change the mask and switch relocation on or off. We keep the mesh dimensions and regularization coefficient fixed throughout these comparisons. We are investigating the mesh geometry here, rather than finding the preferred coefficient for each setup. +""" def fit_with_mask_and_border(mask, use_border_relocator): dataset = dataset_unmasked.apply_mask(mask=mask) @@ -194,18 +197,19 @@ def fit_with_mask_and_border(mask, use_border_relocator): ) """ -The first plot shows where the image coordinates land after ray tracing. The second shows the source mesh and the -brightnesses inferred on it. +The first plot shows where the unmasked image pixels trace in the source plane. The second shows the mesh covering those coordinates and the source brightness reconstructed on it. + +Pay attention to the axis ranges and the spacing of the mesh points. These provide our reference for the next comparison. -Look at the coordinate ranges as well as the source itself. These tell us how much source-plane area the mesh is -covering. We will use this reconstruction as a reference when we add the central image pixels. +--- +""" +""" __Including the central image pixels__ -Now repeat the calculation with the circular mask, still without border relocation. +Now repeat the reconstruction with the circular mask. -The outer image boundary is unchanged. We are adding the pixels within the central hole of the annulus, rather than -extending the image to larger radii. +Its outer radius is unchanged. We are simply adding the image pixels inside the central hole of the annulus. Before looking at the result, consider what you expect: will adding pixels near the **image centre** necessarily add coordinates near the **source centre**? """ fit_circular = fit_with_mask_and_border( @@ -228,6 +232,12 @@ def fit_with_mask_and_border(mask, use_border_relocator): title="Circular Mask: Source Reconstruction", ) +""" +Compare the coordinate ranges with the annular reconstruction. The added central image pixels can trace well outside the region occupied by the other source-plane coordinates. The rectangular mesh expands to cover them. + +Let’s measure the extent of each mesh. +""" + for label, mapper in [ ("Annular", mapper_annular), ("Circular", mapper_circular), @@ -236,29 +246,26 @@ def fit_with_mask_and_border(mask, use_border_relocator): span_y, span_x = np.ptp(coordinates, axis=0) print( - f"{label} mesh coordinate spans: " + f"{label} mesh spans: " f"y={span_y:.3f} arcsec, x={span_x:.3f} arcsec" ) """ -Compare the source-plane extent with the annular case. The added image pixels can trace outside the compact region -occupied by the other coordinates, causing the rectangular mesh to cover a larger area. +Both meshes still contain `25 × 25` points. When their extent increases, the points become more widely spaced. We therefore have fewer points across the source galaxy itself, reducing our ability to resolve its structure. -Both reconstructions still use the same number of mesh points. If the mesh expands, their spacing increases, -reducing the resolution available to describe the central source. +Adding more image pixels has changed the resolution available to reconstruct the source, even though we did not change the mesh dimensions! -This illustrates why increasing the number of image pixels does not automatically improve a reconstruction with a -fixed mesh. We must also consider how those pixels affect the source-plane coverage. +The masks contain different sets of image pixels, so we should not rank these two fits by their absolute log evidences. For now, compare their geometry and source reconstructions. -The two masks contain different data, so this is a geometric comparison—not a direct evidence comparison between -otherwise identical fits. +--- +""" +""" __Following the central pixels into the source plane__ -Let’s confirm where the extra source-plane coordinates come from. +Let’s identify exactly where those distant source-plane coordinates came from. -We select two small groups of image pixels: one close to the lens centre and another near the lensed arc. As in -tutorial 1, we use the same index groups in both planes, so matching colours identify the same light paths. +We select two groups of image pixels: twelve close to the lens centre and twelve near the arc. We highlight the same index groups in both planes, so that each colour follows the same pixels through the ray tracing. """ image_coordinates = np.asarray( @@ -281,55 +288,62 @@ def fit_with_mask_and_border(mask, use_border_relocator): aaplt.plot_grid( grid=mapper_circular.image_plane_data_grid, indexes=index_groups, - title="Image Coordinates: Central Pixels and Arc Pixels", + title="Image Plane: Central Pixels and Arc Pixels", ) aaplt.plot_grid( grid=mapper_circular.source_plane_data_grid, indexes=index_groups, - title="The Same Pixels Traced into the Source Plane", + title="Source Plane: The Same Ray-Traced Pixels", +) + +r""" +The pixels near the arc trace towards the source. The central image pixels can land much further away. + +Recall the lens equation: + +\[ +\boldsymbol{\beta} += +\boldsymbol{\theta} +- +\boldsymbol{\alpha}(\boldsymbol{\theta}). +\] + +A small image-plane position, \(\boldsymbol{\theta}\), does not necessarily give a small source-plane position, \(\boldsymbol{\beta}\). We must subtract the deflection at that position. + +Near the centre of our singular isothermal lens, a small image region can correspond to a much larger source-plane region. This is **demagnification**: the image occupies less area than the corresponding region of the source plane. It does not mean that the deflection angle becomes infinite. + +We can inspect a few of these coordinate pairs directly. +""" + +source_coordinates = np.asarray( + mapper_circular.source_plane_data_grid ) for index in central_indexes[:4]: print( - f"Image index {index}: " + f"Image pixel {index}: " f"{image_coordinates[index]} -> " - f"{np.asarray(mapper_circular.source_plane_data_grid[index])}" + f"{source_coordinates[index]}" ) """ -The central image coordinates need not map close to the source-plane origin. Their source positions depend on the -difference between their image positions and the lens deflections. - -Near the centre of a cuspy mass distribution, a small image region can correspond to a much larger source region. -This is demagnification: the image occupies less area than the corresponding source region. It does not require the -deflection angle itself to become arbitrarily large. - -For our reconstruction, the practical consequence is that central image pixels can map well outside the region -traced by the arcs. The mesh then spends some of its resolution covering those distant positions. - -There is another concern. Source structure reconstructed at those positions may predict other images outside our -chosen mask, where we are not comparing the model with the data. The reconstruction is then missing some of the -observational constraints that would otherwise act on that structure. +These central pixels contain no bright source emission in our simulated image, yet their traced positions still help determine the rectangular mesh’s extent. The mesh consequently spends resolution covering distant regions instead of concentrating it around the source. -In the next blocks, we will trace the outer border into the source plane and examine how border relocation handles -coordinates beyond it. We will compare the same circularly masked dataset with relocation switched off and on, so -that the effect of relocation is isolated. +There is another potential problem: assigning source brightness at these distant positions may predict additional images outside our mask. Those predictions would not be checked against the excluded image data. -**🤖 Ask AI:** Why can a small group of central image pixels change the resolution of a rectangular source mesh, -even when most of the observed source light lies in the arcs? +Next, we will trace the outer image border into the source plane and use it to control these distant coordinates. +""" +""" __Tracing the outer border__ -We have seen that some central image pixels trace beyond the compact source region. To decide how to handle those -coordinates, we first need a reference boundary in the source plane. +We have seen that pixels near the image centre can trace far beyond the source galaxy. To decide which coordinates to relocate, we first need a boundary in the source plane. -We obtain this from the outer border of the image mask. These are ordinary image coordinates, so we can ray-trace -them through the same lens model as every other pixel. Their traced positions describe where the edge of our fitted -image region maps into the source plane. +We obtain this by ray-tracing the **outer border of the image mask**. These border pixels pass through the same lens equation as every other image pixel. -Let’s highlight the border pixels in the image and follow the same indexes into the source plane. Border relocation -is still switched off here, so we are looking at the original ray-traced coordinates. +Let’s highlight them in the image plane, then follow the same indexes into the source plane. Relocation is still switched off, so we are looking at their original ray-traced positions. """ border_indexes = mask_circular.derive_indexes.border_slim.tolist() @@ -337,32 +351,31 @@ def fit_with_mask_and_border(mask, use_border_relocator): aaplt.plot_grid( grid=mapper_circular.image_plane_data_grid, indexes=[border_indexes], - title="Image-Plane Coordinates: Outer Border Highlighted", + title="Image Plane: Outer Border", ) aaplt.plot_grid( grid=mapper_circular.source_plane_data_grid, indexes=[border_indexes], - title="Source-Plane Coordinates: Traced Outer Border Highlighted", + title="Source Plane: Ray-Traced Outer Border", ) """ -Compare the highlighted border with the central image pixels traced in the previous block. Some image coordinates -that were well inside the image mask can land beyond the traced outer border in the source plane. +Compare the traced border with the central pixels highlighted previously. Although those central pixels lie well inside the image mask, some land **outside the traced border** in the source plane. -This is the important geometric distinction: being inside the image-plane border does not guarantee that a -coordinate remains inside its traced counterpart. +This is possible because the lens mapping does not preserve the ordering of distances from the centre. Being inside the image border does not guarantee that a pixel traces inside its source-plane counterpart. -The border relocator uses the traced outer-border coordinates to estimate a source-plane boundary. In the current -implementation, this is an ellipse. Coordinates beyond that boundary are moved inward for the source -reconstruction, preventing them from extending its coverage unchecked. +The border relocator uses the traced border to estimate a source-plane boundary. Coordinates beyond that boundary are moved inward for the reconstruction, preventing them from forcing the mesh to extend far beyond the region we want to reconstruct. + +--- +""" +""" __Switching border relocation on__ Let’s repeat the circular-mask fit with border relocation enabled. -The data, mask, lens mass, mesh shape and regularization coefficient all remain unchanged. This time, the source -mesh is constructed using the relocated coordinates. +We use exactly the same image pixels, lens model, mesh dimensions and regularization coefficient. The change is how the source reconstruction handles coordinates beyond the traced border. """ fit_relocated = fit_with_mask_and_border( @@ -379,7 +392,7 @@ def fit_with_mask_and_border(mask, use_border_relocator): aaplt.plot_grid( grid=mapper.source_plane_data_grid, indexes=index_groups, - title=f"{label}: Selected Source-Plane Coordinates", + title=f"{label}: Selected Source Coordinates", ) mesh_coordinates = np.asarray(mapper.source_plane_mesh_grid) @@ -391,13 +404,11 @@ def fit_with_mask_and_border(mask, use_border_relocator): ) """ -Follow the same coloured coordinate groups through the two plots. Coordinates outside the estimated boundary are -relocated towards it; those inside remain unchanged. +Follow the coloured groups between the plots. The distant coordinates move inward, while coordinates inside the relocation boundary retain their positions. -If relocation reduces the mesh extent, the same number of source points now covers a smaller area. This can restore -resolution in the region where the source is being reconstructed, without increasing the mesh dimensions. +When this reduces the mesh’s extent, our `25 × 25` points cover a smaller area. Their spacing decreases, giving us finer resolution across the source without increasing the number of source brightness values. -We can also identify which coordinates moved and display their positions before and after relocation. +Let’s identify every coordinate that moved. """ coordinates_before = np.asarray( @@ -411,37 +422,38 @@ def fit_with_mask_and_border(mask, use_border_relocator): coordinates_after - coordinates_before, axis=1, ) + moved_indexes = np.flatnonzero(displacements > 1.0e-8).tolist() -print("Number of relocated image-pixel coordinates:", len(moved_indexes)) +print("Number of relocated coordinates:", len(moved_indexes)) if moved_indexes: aaplt.plot_grid( grid=mapper_circular.source_plane_data_grid, indexes=[moved_indexes], - title="Relocated Coordinates: Original Positions", + title="Relocated Coordinates: Before", ) aaplt.plot_grid( grid=mapper_relocated.source_plane_data_grid, indexes=[moved_indexes], - title="Relocated Coordinates: Positions Used by the Mapper", + title="Relocated Coordinates: After", ) """ -These are still the same image pixels with the same measured brightnesses. Relocation changes their source-plane -mapping coordinates; it does not remove them from the fit or move their positions in the observed image. +These coordinates still correspond to the same image pixels and measured brightnesses. We have changed where they map within the source reconstruction. We have not moved any observed image pixels or excluded them from the fit. + +--- +""" -__What relocation changes—and what it does not__ +""" +__What relocation changes__ -Border relocation is a numerical treatment of the source reconstruction. It does not change the lens mass profile -or the physical deflection calculation. +Does relocating these coordinates mean we have changed the lens model? -That distinction matters. If we ray-trace the image grid directly through the tracer, we recover the original -source coordinates. The mapper used by the relocated inversion instead contains the coordinates after the border -treatment. +No. The mass profile and its deflection angles are unchanged. Border relocation is applied when constructing the source reconstruction, after ray tracing. -Let’s compare these explicitly. +We can demonstrate this by ray-tracing the image coordinates directly through the tracer. This returns their original source-plane positions. """ physical_source_grid = tracer.traced_grid_2d_list_from( @@ -451,8 +463,8 @@ def fit_with_mask_and_border(mask, use_border_relocator): physical_coordinates = np.asarray(physical_source_grid) print( - "Largest difference between direct ray tracing and the " - "mapper without relocation:", + "Largest difference between direct ray tracing " + "and the mapper without relocation:", np.max( np.linalg.norm( physical_coordinates - coordinates_before, @@ -465,15 +477,15 @@ def fit_with_mask_and_border(mask, use_border_relocator): index = moved_indexes[0] print("Image-pixel index:", index) - print("Directly ray-traced position:", physical_coordinates[index]) + print("Ray-traced position:", physical_coordinates[index]) print("Position used after relocation:", coordinates_after[index]) """ -We should therefore understand relocation as a practical boundary treatment, not an alternative physical lens -equation. It controls how extreme coordinates participate in a reconstruction whose image coverage is finite. +The first difference should be zero, apart from numerical precision. For a relocated pixel, the second comparison shows the distinction between its ray-traced position and the position used by the inversion. -The next step is to inspect its effect on the inferred source and the model image. We use a common source colour -scale so that changes in brightness are not hidden by separately scaled figures. +Relocation therefore changes the mapping used to reconstruct the source, and can change the predicted image even though the lens mass is fixed. + +Let’s inspect that effect. We use the same brightness scale for both source plots so that their colours remain comparable. """ source_vmax = max( @@ -505,25 +517,23 @@ def fit_with_mask_and_border(mask, use_border_relocator): ) """ -Look for changes in the mesh coverage, the source structure and the residuals. A more compact mesh alone does not -prove that the model is accurate, and relocation does not guarantee freedom from systematic errors. +Compare the spacing of the mesh points across the source. Does the smaller extent allow its brightness distribution to be represented more accurately? Are corresponding changes visible in the image residuals? -We have also kept the regularization coefficient fixed to isolate the change in configuration. As tutorial 2 -showed, changing mesh geometry can alter how regularization acts, so this is a diagnostic comparison rather than a -complete optimization of both reconstructions. +We have kept the regularization coefficient fixed to show the effect of switching relocation on. As we learned in tutorial 2, changing the mesh geometry can also change the preferred coefficient. A full comparison would therefore repeat the regularization search for each setup. +Next, we will examine why border relocation still requires a mask that covers the lensed source. +""" + +""" __The mask still needs to cover the lensed source__ -Border relocation does not replace choosing an appropriate image mask. +Border relocation helps us control the source mesh, but it cannot compensate for an image mask that excludes part of the lensed source. -If the mask excludes part of an arc or another image of the source, those pixels do not constrain the -reconstruction. Relocating source coordinates cannot recover information that we have excluded from the fit. +Suppose our mask cuts through an arc. The reconstruction only has to explain the pixels inside the mask; the excluded part of the arc contributes nothing to the fit. A reconstruction could therefore fit the selected pixels well while failing to explain the rest of the observed source. -The mask also determines the border from which the source-plane boundary is estimated. Changing its size therefore -changes both the image pixels being fitted and the boundary treatment. +The mask also determines which border coordinates we trace into the source plane. Changing its radius changes both the data being fitted and the boundary used for relocation. -Let’s inspect several circular masks before fitting them. We plot their borders on the full image so that any -source light outside the selected region remains visible. +Let’s inspect three masks. We draw their borders on the **full image**, so that any source emission outside each mask remains visible. """ mask_radii = [2.0, 2.5, 3.0] @@ -540,14 +550,13 @@ def fit_with_mask_and_border(mask, use_border_relocator): aplt.plot_array( array=dataset_unmasked.data, positions=mask.derive_grid.border, - title=f"Full Image with Mask Border at {radius:.1f} Arcsec", + title=f"Full Image: Mask Radius {radius:.1f} Arcsec", ) """ -Check whether each border encloses the visible lensed emission with some surrounding background. A mask that -follows only the brightest parts can exclude faint extensions that still matter to the reconstruction. +Check whether each border encloses the lensed emission, including its faint extensions, with some surrounding background. We should not choose a mask simply because it closely follows the brightest pixels. -Now compare the resulting fits with relocation enabled. +Now reconstruct the source with each mask, keeping border relocation enabled. """ fits_by_radius = {} @@ -566,40 +575,35 @@ def fit_with_mask_and_border(mask, use_border_relocator): solution_vector=current_fit.inversion.reconstruction, mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False, - title=f"Source Reconstruction: Mask Radius {radius:.1f} Arcsec", + title=f"Source: Mask Radius {radius:.1f} Arcsec", ) aplt.subplot_fit_imaging(fit=current_fit) """ -Compare the source structure and residuals, especially where a smaller mask approaches the arcs. If the -reconstruction changes substantially as clearly relevant source light enters the mask, the smaller-mask result -deserves closer inspection. +Compare the source reconstructions and residuals. Does the source change substantially as additional lensed emission enters the fit? Does a smaller mask hide a region where the model struggles? + +These masks contain different image pixels, so their absolute log evidences should not be used to select the mask. Our aim is to include the relevant emission and understand how the reconstruction responds to the chosen boundary. -Do not rank these masks by their raw evidence values: they contain different sets of image pixels. The purpose here -is to examine coverage and stability, not to select whichever mask produces the largest number. +A larger mask can also spread the fixed number of mesh points over a larger source-plane region. If the reconstruction changes, inspect the mesh spacing as well as the newly included data. -**🤖 Ask AI:** Why can a reconstruction appear satisfactory inside a small mask while failing to explain other -visible parts of the same lensed source? +--- +""" + +""" +__Wrap Up__ -__Wrap up__ +The resolution of a rectangular source mesh depends on both its number of points and the area they cover. -We began with a rectangular source mesh whose resolution depended on both its number of points and the area they -covered. A small number of extreme ray-traced coordinates could expand that area and reduce the resolution -available for the source. +We saw that central image pixels can trace far from the source galaxy. Without border relocation, these coordinates can expand the mesh, leaving fewer points across the source itself. -The border relocator uses the traced outer image border to estimate a source-plane boundary, then moves coordinates -beyond that boundary inward for the reconstruction. The observed image pixels remain in the fit, and the physical -lens model is unchanged. +Border relocation uses the traced outer image border to estimate a source-plane boundary. Coordinates beyond that boundary are moved inward when constructing the source reconstruction. The observed image pixels remain in the fit, and the lens mass model is unchanged. -In routine modelling, we normally leave this treatment enabled. Knowing what it does is useful when a source mesh -extends unexpectedly far from the reconstructed galaxy, or when a reconstruction changes strongly with the mask. +We will keep border relocation enabled in the fits that follow. If a mesh extends unexpectedly far from the reconstructed source, we now know how to investigate: follow the image pixels into the source plane and inspect which coordinates determine its extent. -These issues can also occur in systems with several lens galaxies. Extreme coordinates need not originate only near -the centre of the image; they can be associated with other mass concentrations. The same diagnostic applies: follow -the image coordinates into the source plane and inspect the boundary and mesh coverage. +This can also matter for lenses containing several mass concentrations. The pixels responsible for distant source-plane coordinates need not all lie near the centre of the image. -For subsequent inspection, return to our circular mask with radius `2.5` arcsec and relocation enabled. +Let’s finish by returning to our circular mask with radius `2.5` arcsec. """ dataset = dataset_circular @@ -613,14 +617,17 @@ def fit_with_mask_and_border(mask, use_border_relocator): solution_vector=fit.inversion.reconstruction, mesh_grid=mapper.source_plane_mesh_grid, zoom_to_brightest=False, - title="Source Reconstruction with Border Relocation Enabled", + title="Source Reconstruction with Border Relocation", ) """ -We have now examined the source mesh, interpolation, regularization and border treatment while holding the lens -mass fixed. +So far, we have investigated the source mesh, interpolation, regularization and borders while holding the lens mass fixed. + +Next, we will let the mass model vary. This introduces another challenge: a flexible source reconstruction can sometimes produce a close image fit even when the lens mass model is wrong. -In the next tutorial, we let the mass model vary. We will see how a pixelized source participates in lens -modelling, why its flexibility can admit misleading solutions, and how a sensible starting model helps the search -find the relevant region of parameter space. +--- +""" + +""" +**🤖 Ask AI:** Why can adding central image pixels reduce the useful resolution of a rectangular source mesh, and what does border relocation change? """ diff --git a/scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py b/scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py index acb83db..e3ab906 100644 --- a/scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py +++ b/scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py @@ -2,6 +2,8 @@ Tutorial 4: Lens Modeling ========================= +Fit lens mass and foreground light alongside a pixelized source using search chaining and a positions likelihood penalty. + __Contents__ - Lens modelling with a pixelized source @@ -16,43 +18,34 @@ - Running the pixelized-source search - Establishing the foreground light model - Using that result in the pixelized-source fit +- Wrap Up +""" +""" __Lens modelling with a pixelized source__ -So far, we have reconstructed sources while keeping the lens mass fixed. We could therefore concentrate on the -mesh, mappings and regularization without worrying about whether the lens model itself was correct. +In the previous tutorials, we supplied the mass model used to simulate the image. This let us concentrate on reconstructing the source. + +For an observed lens, we usually do not know the mass distribution. Measuring it is one of the main reasons we perform lens modelling! -In a scientific analysis, the mass distribution is usually one of the things we want to measure. Changing it -changes the ray tracing, which changes how the source must be reconstructed to explain the image. +Changing the mass model changes where the image pixels trace in the source plane. For every proposed mass model, we must therefore reconstruct the source again and calculate the corresponding log evidence. -Pixelizations are powerful here because they can describe complicated source structure without requiring us to -specify a separate analytic profile for every bright component. The non-linear search explores the lens parameters -and regularization settings, while the inversion solves for the source brightnesses. +Pixelizations are useful because they can describe complicated source structure without requiring us to choose a separate light profile for every component. However, this flexibility also creates a difficulty: **an incorrect mass model can sometimes fit the image by reconstructing an unusual source**. -However, this flexibility comes with a difficulty. An incorrect mass model can sometimes be compensated for by an -unusual source reconstruction. A close fit to the image does not, by itself, establish that we have found the right -mass distribution. +For example, instead of tracing several arcs back to a common source component, an incorrect model may reconstruct separate source features to explain them. The image fit can look convincing even though the inferred source and lens mass are wrong. -We will investigate this using the familiar simulated lens with no foreground light. Its true Einstein radius is -`1.6` arcsec, giving us a reference against which to compare deliberately incorrect models. +This problem is illustrated by Figure 1 from [Maresca et al. (2021)](https://arxiv.org/abs/2012.04665). -The published example below makes this ambiguity visible before we explore it in code. Compare the reconstructed -source structures with their corresponding image fits: a model can describe much of the observed image while -assigning it a very different source morphology. +![Demagnified source reconstructions, Figure 1 of Maresca et al. (2021)](https://raw.githubusercontent.com/PyAutoLabs/HowToLens/main/scripts/chapter_3_pixelizations/images/maresca_fig1.png) -![Demagnified source solutions, Figure 1 of Maresca et -al.](https://raw.githubusercontent.com/PyAutoLabs/HowToLens/main/scripts/chapter_3_pixelizations/images/maresca_fig1.png) +Figure 1 from Maresca et al. (2021). Compare the reconstructed sources with their corresponding image fits. The experiments below use our own simulated dataset. See also the [PyAutoLens demagnified-solutions guide](https://pyautolens.readthedocs.io/en/latest/general/demagnified_solutions.html). -Figure 1 from [Maresca et al. (2021)](https://arxiv.org/abs/2012.04665), also discussed in the [PyAutoLens -demagnified-solutions guide](https://pyautolens.readthedocs.io/en/latest/general/demagnified_solutions.html). We -reproduce this figure to illustrate the ambiguity; the experiments below use our own simulated dataset. +We return to the familiar lens with no foreground light. Its true Einstein radius is `1.6` arcsec, giving us a reference against which to compare deliberately incorrect models. """ from autolens import jax_wrapper from pathlib import Path -import subprocess -import sys import numpy as np import matplotlib.pyplot as plt @@ -63,7 +56,8 @@ dataset_path = Path("dataset") / "imaging" / "simple__no_lens_light" al.util.dataset.simulate_if_needed( - dataset_path, simulator="scripts/simulator/no_lens_light.py" + dataset_path, + simulator="scripts/simulator/no_lens_light.py", ) dataset = al.Imaging.from_fits( @@ -85,23 +79,23 @@ aplt.subplot_imaging_dataset(dataset=dataset) """ -We retain one sample per image pixel for the indexed mapping demonstration below. As before, the sampling accuracy -would need checking before interpreting a scientific fit. +We use one sample per image pixel to make the mapping demonstrations easy to follow. Before interpreting a scientific fit, we would check that finer sampling does not materially change the result. +First, we will deliberately give the inversion the wrong Einstein radius and inspect how it attempts to explain the image. +""" + +""" __Giving the inversion an incorrect mass model__ -Let’s begin with a mass model whose Einstein radius is `0.8` arcsec—half the value used in the simulation. +Let’s deliberately give the inversion an Einstein radius of `0.8` arcsec—half the value used to simulate the image. -Everything else remains fixed, including the mass ellipticity, external shear, mesh dimensions and regularization -coefficient. We then ask the inversion to reconstruct whatever source best satisfies this model’s mappings and -smoothness prior. +We keep the mass ellipticity and external shear at their simulation values. We also fix the mesh dimensions and regularization coefficient. This lets us investigate what happens when just the Einstein radius is wrong. -This is the kind of incorrect mass model that a non-linear search could encounter while exploring a broad parameter -range. +The helper below reconstructs the source for whichever Einstein radius we supply. """ -# Match mesh size to capped smoke data; normal tutorial runs retain 25 x 25. -mesh_shape = (8, 8) if dataset.shape_native == al.util.dataset.SMALL_DATASETS_SHAPE_NATIVE else (25, 25) +mesh_shape = (25, 25) + def fit_at_einstein_radius(einstein_radius, coefficient=1.0): lens_galaxy = al.Galaxy( @@ -150,26 +144,23 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aplt.subplot_fit_imaging_of_planes(fit=incorrect_fit) """ -Inspect both the image residuals and the reconstructed source. The experiment is designed to show how an inversion -can explain some of the observed structure even when the lens mass is substantially wrong. +Inspect the reconstructed source alongside the model image and residuals. How much of the observed image can the inversion reproduce despite using the wrong mass model? + +With insufficient deflection, the observed arcs do not trace back towards one compact source. They remain more widely separated in the source plane. The inversion can nevertheless assign brightness to those separate regions, reconstructing a source that begins to resemble a less strongly lensed version of the image. -Under insufficient deflection, parts of the observed arcs can remain separated in the source plane. Instead of -reconstructing one compact galaxy whose light produces multiple images, the inversion can assign brightness to -those separate source regions. The source may begin to resemble a less strongly lensed version of the observed -image. +This type of incorrect mass-and-source reconstruction is called a **demagnified solution**. The inversion uses its freedom to describe the source to compensate for an incorrect mass model. -Such solutions are often called **demagnified solutions**. This is a different issue from the demagnified central -image coordinates discussed in the borders tutorial: here, the problem concerns an alternative mass-and-source -interpretation of the image. +This differs from the demagnified central pixels in the borders tutorial. There, we investigated individual coordinates that stretched the mesh. Here, we have an alternative interpretation of the whole lens system. Border relocation is enabled, but it does not prevent this solution. -Border relocation does not remove this modelling ambiguity. +--- +""" +""" __Comparing with the correct mass model__ -Now repeat the reconstruction with the Einstein radius restored to `1.6` arcsec. +Now restore the Einstein radius to `1.6` arcsec and reconstruct the source again. -The inversion still has the same mesh dimensions and regularization coefficient. Only the Einstein radius changes. -This gives us a controlled comparison of how the mass model affects the source required to explain the image. +Only the Einstein radius changes. We use the same mesh dimensions and regularization coefficient, and display both reconstructions with a common brightness scale. """ correct_fit = fit_at_einstein_radius(einstein_radius=1.6) @@ -201,15 +192,11 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): ) """ -Compare the source morphology and its coordinate extent. With the correct mass model, the different images of the -source should be brought into a mutually consistent source reconstruction. With an incorrect mass model, the -inversion may need more extended or fragmented structure to account for them. +Compare the size and structure of the two sources. With the correct mass model, the multiple images can be explained by a common compact source. With the incorrect mass model, the inversion may need a more extended or fragmented brightness distribution. -This is where regularization and evidence become important. The inversion’s flexibility does not mean that all -source reconstructions are equally preferred. A reconstruction requiring large variations between neighbouring -brightnesses is treated differently from one that explains the data with a smoother source. +Recall our discussion of Bayesian evidence. A close image fit is only part of the comparison: the evidence also accounts for the source reconstruction and its regularization prior. -Let’s compare image agreement and evidence directly. +Let’s compare both chi-squared and log evidence. """ for label, current_fit in [ @@ -225,22 +212,21 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): print(" Log evidence:", float(current_fit.log_evidence)) """ -The expected lesson is that image agreement and source plausibility must be considered together. We should inspect -the calculated evidence values rather than assume that the correct model wins by a particular amount. +Which model gives the closer image fit? Which gives the higher evidence? Connect these numbers back to the source and residual plots. + +We have evaluated only two mass models at one regularization coefficient. As tutorial 2 showed, the preferred coefficient can change when the reconstruction changes. These numbers are therefore a first comparison, rather than the outcome of a full search over the mass and regularization parameters. -We have also held the coefficient fixed. A full model search would allow regularization to change, so these two -evaluations do not establish the final relative preference after all model parameters have been explored. +--- +""" +""" __Seeing how the mass model changes the mappings__ -The source plots show the outcome, but the mappings help explain why it happens. +The source reconstructions show the result. Let’s now inspect the mappings that produce it. -Let’s select two small image regions on opposite sides of the lens and follow them into the source plane under each -mass model. The image indexes stay the same: we are examining the same measured pixels through two different -ray-tracing calculations. +We select two small groups of image pixels on opposite sides of the lens. We then trace the **same pixels** through each mass model, keeping their colours consistent between plots. -To isolate the physical ray tracing, we use the tracer directly. These plots show the coordinates before border -relocation. +Here we use the tracer directly, so the plots show the ray-traced coordinates before border relocation. """ image_grid = dataset.grids.pixelization @@ -259,7 +245,7 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aaplt.plot_grid( grid=image_grid, indexes=index_groups, - title="The Same Two Image Regions", + title="Two Selected Image Regions", ) for label, current_fit in [ @@ -277,30 +263,33 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): ) """ -Look at the relative positions of the coloured groups. Changing the mass model changes whether image regions can be -explained by nearby parts of the source or require brightness in more widely separated regions. +Follow the coloured groups. The mass model determines whether these image regions trace to nearby parts of the source or require brightness in widely separated regions. + +The inversion must work with whichever mappings we give it. It cannot change the mass model to bring the groups closer together; it can only change the source brightness values. Exploring different mass models is the job of the non-linear search. -These groups were chosen to illustrate the geometry, not identified as exact images of the same source feature. We -should therefore not expect their traced coordinates to coincide perfectly, even for the correct model. +We selected these groups by their image positions, rather than identifying exact multiple images of the same source feature. Their traced coordinates therefore need not coincide perfectly, even under the correct model. -For a stronger constraint, we can identify image positions that genuinely correspond to the same source feature and -require them to trace close together. We will introduce that idea when preparing the model search. +Later, we will identify positions that **do** correspond to the same source feature and require them to trace close together. That gives us a way to steer the search away from demagnified solutions. -**🤖 Ask AI:** How can an incorrect mass model still fit an arc if the source reconstruction is allowed to place -light in several separate source regions? +First, we will examine why calculating the evidence does not, by itself, ensure that a search finds the correct mass model. +""" +""" __Why evidence does not remove the search difficulty__ -Even if the correct mass model has higher evidence than our deliberately incorrect example, the non-linear search -still has to find it. +Suppose the correct mass model gives a higher log evidence than our incorrect example. Does that mean a non-linear search will automatically find it? + +Unfortunately, no. The physically meaningful solution can occupy a very small region of the non-linear parameter space. The search must find a combination of mass parameters that traces the different arcs back to a common source. + +Incorrect mass models occupy a much larger volume of parameter space. With a pixelized source, many of these models can still reproduce much of the image: the inversion compensates for the incorrect deflections by placing source light in separate regions. -A pixelized source can provide tolerable image fits across a broad range of incorrect mass models. This can create -competing regions of parameter space that require substantial exploration. Solving the source brightnesses -efficiently does not make the remaining non-linear problem trivial or guarantee that a search finds the relevant -solution. +This creates a huge landscape of competing local maxima. There are many ways to obtain a reasonably good image fit with an incorrect mass model and an unphysical source reconstruction, while the region containing the correct solution can be narrow and difficult to discover. -Let’s examine a simple slice through this problem by changing only the Einstein radius. At every value, we -reconstruct a new source and calculate its evidence. +A search started with broad priors can therefore spend most of its effort exploring these incorrect solutions and converge on one of them without finding the physically meaningful solution—even when that solution has a higher evidence. For difficult systems, this can happen repeatedly across independent searches. + +Efficiently solving the source brightnesses does not solve this search problem. We need to help the non-linear search reach the relevant region of mass-model parameter space before giving the source the full flexibility of a pixelization. + +Let’s explore this by varying the Einstein radius while holding the other parameters fixed. At every radius, we reconstruct the source again and calculate the log evidence. """ einstein_radii = np.array( @@ -329,11 +318,10 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): log_evidences = np.asarray(log_evidences) if not np.all(np.isfinite(log_evidences)): - raise ValueError( - "Inspect the non-finite evidence values before interpreting this scan." - ) + raise ValueError("Inspect non-finite evidence values before interpreting the scan.") fig, ax = plt.subplots() + ax.plot( einstein_radii, log_evidences - np.max(log_evidences), @@ -345,56 +333,35 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): linestyle="--", label="Simulation Einstein radius", ) + ax.set_xlabel("Einstein radius (arcsec)") ax.set_ylabel("Log evidence relative to the highest sampled value") -ax.set_title("A Fixed-Parameter Slice Through Lens-Model Space") ax.legend() + plt.show() """ -This scan shows how the evidence behaves along one restricted direction. It is not a posterior distribution or an -uncertainty measurement: the other mass parameters and regularization coefficient remain fixed, and we have sampled -only a few radii. +This one-dimensional scan greatly understates the difficulty of the full search. We have fixed the mass ellipticity, shear and regularization coefficient, whereas a real search varies them together. The resulting parameter space can contain many competing maxima that are absent from this simple slice. -In a full search, those other parameters can vary together. An incorrect Einstein radius may be partly compensated -for by changes in ellipticity, shear or source regularization, creating possibilities that this one-dimensional -experiment cannot show. - -A useful strategy is therefore **search chaining**. We begin with a simpler source model to find a plausible region -of lens parameter space, then introduce the pixelized source so that the detailed source structure can be -reconstructed. - -The initial analytic source is a starting point, not the final description of the galaxy. Its purpose is to help -establish the lensing geometry without giving the source enough freedom to reproduce almost any image arrangement. - -In the next blocks, we will turn that idea into a model-search setup and explain how corresponding image positions -can help reject misleading solutions. We will also introduce foreground lens light, which must be modelled -alongside the pixelized source. +This is why we begin with an MGE source and use search chaining. Its restricted geometry makes it harder to compensate for an incorrect mass model by reconstructing arbitrary source structure. We first establish a plausible lensing geometry, then introduce the pixelization and additional constraints to help keep the search away from unphysical solutions. +""" +""" __Starting with a simpler source model__ -The misleading reconstructions we have just examined arise because a pixelized source has enough freedom to -compensate for some incorrect mass models. +We will begin with the multi-Gaussian expansion (MGE) from Chapter 2. -A useful starting point is therefore a source model with less freedom. We first fit an analytic light profile, -using it to establish a plausible lensing geometry. Once that fit has identified a useful region of parameter -space, we replace the analytic source with a pixelization. +The Gaussians have different fixed widths, allowing their combined light to describe a flexible radial brightness distribution. Their intensities are solved linearly. However, they share a common centre and elliptical shape, so they cannot independently place source components wherever an incorrect mass model requires them. -This is search chaining, introduced in chapter 2. Here, the reason for chaining is particularly important: we want -the pixelized reconstruction to improve the description of the source without beginning its search among a wide -range of unrelated mass-and-source configurations. +This makes the MGE a useful starting point for establishing the lensing geometry. It may leave some complicated source structure unexplained, but we can introduce the pixelization once we have inspected the initial fit. -We use the multi-Gaussian expansion (MGE) from Chapter 2 as our baseline analytic source. Its fixed Gaussian widths -and linearly solved intensities provide radial flexibility while a common centre and elliptical shape keep the -geometry manageable. For a more complex galaxy, this first model may leave source structure unexplained. We would -inspect those residuals and the inferred geometry before using it to initialize the next search. +Let’s construct the model. """ import autofit as af mass = af.Model(al.mp.Isothermal) -# Keep the mass centre fixed for this introductory experiment. mass.centre = (0.0, 0.0) mass.einstein_radius = af.UniformPrior( lower_limit=0.5, @@ -417,7 +384,9 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): al.Galaxy, redshift=1.0, bulge=al.model_util.mge_model_from( - mask_radius=2.5, total_gaussians=20, centre_prior_is_uniform=False, + mask_radius=2.5, + total_gaussians=20, + centre_prior_is_uniform=False, ), ) @@ -432,20 +401,21 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): print(model_1.info) """ -Unlike our earlier experiments, the Einstein radius, mass ellipticity and external shear are now free. The MGE -group’s common centre and elliptical shape are also free. Its Gaussian widths are fixed, and all Gaussian -intensities are solved linearly. +Unlike the earlier experiments, the Einstein radius, mass ellipticity and external shear are now free parameters. We keep the mass centre fixed for this introductory example. + +The MGE’s common centre and elliptical shape are also free. Its Gaussian widths remain fixed, and the inversion solves for the Gaussian intensities at every non-linear proposal. -Inspect `model_1.info` to see the complete set of free parameters and their priors. The known simulation values -provide a check on the result; they are not supplied as the answer to the search. +Inspect `model_1.info` to see these parameters and their priors. Although we know the simulated Einstein radius is `1.6` arcsec, we have not supplied that answer to the search: it must infer the radius from the image. +--- +""" + +""" __Running and inspecting the initial search__ -We now perform a model fit. For each proposal, the search changes the non-linear parameters, predicts the image and -evaluates its agreement with the data. +We are now ready to run the first search. -This is different from calling `FitImaging` with specified parameter values. The search explores alternatives and -produces samples that describe the inferred parameter distribution. +Previously, we supplied parameter values directly to `FitImaging`. Here, Nautilus proposes different combinations of mass and source parameters. For each proposal, the Gaussian intensities are solved linearly and the resulting image is compared with the data. """ search_1 = af.Nautilus( @@ -470,34 +440,31 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aplt.subplot_fit_imaging_of_planes(fit=initial_fit) """ -Before continuing, inspect the model image, residuals and source. The purpose of this first fit is to establish a -credible starting geometry, so a poor or clearly misleading solution should not simply be passed to the next -search. +Inspect the model image, residuals and reconstructed source before continuing. + +Does the model reproduce the positions and overall shapes of the arcs? Does the source look like a compact galaxy whose multiple images explain the observed configuration? Or has the search found a misleading arrangement? + +For this simulated example, we can also compare the inferred Einstein radius with `1.6` arcsec. In an observed lens, we would not have that answer available. + +The initial MGE need not reproduce every detail of a complicated source. However, a fit that misses an arc or gives a clearly incorrect lensing configuration is not a suitable starting point for the next search. Passing its parameters forward could steer the pixelized fit towards the same problem. -For this simulated example, we can also compare the recovered Einstein radius with the input value of `1.6` arcsec. -In an observed system, that answer would not be available; we would rely on the fit diagnostics, image -configuration and parameter constraints. +Finally, remember that the plotted maximum-likelihood fit is one model. Use the result summary and parameter distributions, as in Chapter 2, to assess the constraints and whether further exploration is needed. -The maximum-likelihood fit is useful for visualization, but it is only one model. The result summary and samples -are needed to assess uncertainty and whether the search adequately explored the parameters. +Next, we will use corresponding image positions to help the pixelized-source search avoid demagnified solutions. +""" +""" __Requiring corresponding images to trace together__ -Search chaining gives us a useful starting point, but we can also impose a simple geometric check. +In Chapter 2, Tutorial 5, we introduced positions resampling and the **positions likelihood penalty**. We now use that same approach to help the pixelized-source search avoid the unphysical solutions discussed above. -If several image positions represent the same source feature, a plausible mass model should trace them close -together in the source plane. An incorrect model that reconstructs them as unrelated pieces of source light fails -this check. +Recall the underlying idea: image positions corresponding to the same source feature should trace close together in the source plane. An incorrect mass model may leave them widely separated, allowing a pixelized reconstruction to explain them by placing unrelated source features in different locations. -The `PositionsLH` object applies a penalty when the traced positions are separated by more than a chosen threshold. -Models that bring them within the threshold receive no positions penalty. +The positions likelihood penalty discourages these solutions. When the maximum separation between the traced positions exceeds our threshold, the model receives a likelihood penalty. Models that bring the positions within the threshold receive no positions penalty. -`PositionsLH` penalizes demagnified solutions whose corresponding image positions fail to trace sufficiently close -together. +Search chaining provides informed starting priors, while this penalty helps prevent the search from wandering into the much larger regions of parameter space occupied by unphysical reconstructions. -We can identify corresponding positions manually, but our initial fit also provides a way to estimate them. The -following method uses the fitted source centre and mass model to predict its multiple-image positions and -constructs a positions constraint for the next search. +Following Chapter 2, Tutorial 5, we use the initial fit to generate the positions and threshold for the next search. Here, those positions are predicted from the fitted MGE source centre and mass model. """ positions_likelihood = result_1.positions_likelihood_from( @@ -527,34 +494,35 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aaplt.plot_grid( grid=traced_positions, - title="Predicted Image Positions Traced to the Source Plane", + title="Predicted Positions Traced to the Source Plane", ) """ -These positions are predictions from the initial model, not independent measurements. Check that they correspond -sensibly to the observed image configuration before using them. +Inspect the marked positions on the image. They come from our fitted model, so we should check that they sensibly follow the observed multiple-image configuration before using them. + +We allow some source-plane separation because the initial mass model is approximate. The next search must have room to improve it. -The threshold should be loose enough to allow the mass model to improve. We are trying to reject grossly -inconsistent mappings, not force every subsequent model to reproduce the initial solution exactly. Here, `factor` -enlarges the separation estimated from the initial result, while `minimum_threshold` prevents the returned -threshold from becoming smaller than `0.2` arcsec. +The `factor` enlarges the separation used to set the threshold from the initial result. The `minimum_threshold` prevents that threshold from becoming smaller than `0.2` arcsec. These are source-plane distances, not distances between the observed images. -This positions penalty is a modelling aid rather than a replacement for the image likelihood. It does not by itself -establish that the mass model is correct, and an inappropriate set of positions or an overly restrictive threshold -can exclude useful solutions. +The purpose is to exclude grossly inconsistent mappings while allowing useful changes to the mass model. An excessively tight threshold could instead force the second search to remain too close to an imperfect initial fit. +--- +""" + +""" __Replacing the analytic source with a pixelization__ We are ready to construct the second model. -The lens mass and shear remain free, but their priors are initialized from the first result using the prior-passing -machinery introduced in chapter 2. The source profile is replaced by a rectangular mesh and a regularization model. +We retain the lens mass and external shear from the initial result, using the prior-passing machinery introduced in Chapter 2. We then replace the MGE source with a rectangular pixelization. -The distinction between `result_1.model` and `result_1.instance` matters here. Using the model carries free -parameters into the next search. Using the instance would instead fix those components to particular fitted values. +The mass parameters remain free. We want the first fit to guide the second search towards the relevant region of parameter space, while allowing the improved source description to change the inferred mass model. + +This is why we use `result_1.model`, rather than `result_1.instance`. The model carries free parameters and their updated priors into the next search; the instance would fix them to particular fitted values. """ regularization = af.Model(al.reg.Constant) + regularization.coefficient = af.LogUniformPrior( lower_limit=1.0e-3, upper_limit=1.0e3, @@ -583,23 +551,23 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): print(model_2.info) """ -Inspect the resulting priors rather than assuming that they are suitable simply because they came from an earlier -fit. In particular, a restrictive analytic source can bias an initial mass estimate; its result should guide the -next search without preventing the mass model from changing. +The regularization coefficient is now a non-linear parameter, explored alongside the mass and shear parameters. Its logarithmic prior allows the search to explore several orders of magnitude in smoothing strength. + +The mesh dimensions remain fixed. Its hundreds of source brightness values are solved by the inversion at every proposal; they do not become hundreds of additional non-linear parameters. -The mesh shape stays fixed. The regularization coefficient is now a non-linear parameter, explored alongside the -mass and shear parameters. +Inspect the priors printed by `model_2.info`. The initial MGE may have left source structure unexplained, potentially shifting its preferred mass model. Prior passing should guide the pixelized search without restricting it so tightly that it cannot correct that initial estimate. -The hundreds of source brightnesses do not appear as hundreds of additional search parameters. For each proposal, -the inversion reconstructs those values using the proposed mass model and coefficient. +--- +""" +""" __Running the pixelized-source search__ -The second search combines the pixelized model with the positions constraint. +We now combine the pixelized model with the **positions likelihood penalty**, following the same procedure as Chapter 2, Tutorial 5. + +Search chaining and the penalty serve complementary purposes. Prior passing guides the search towards the initial mass solution. The positions likelihood penalty discourages proposals that trace corresponding image positions too far apart. -For each proposed mass model, the analysis checks the positions condition and evaluates the source reconstruction -as appropriate. The search can adjust the lens parameters and regularization coefficient, while each inversion -solves for the source brightnesses. +For proposals within the threshold, no positions penalty is applied, and the pixelized imaging log evidence determines their fit statistic. The inversion reconstructs the source using each proposed mass model and regularization coefficient. """ analysis_2 = al.AnalysisImaging( @@ -628,15 +596,11 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aplt.subplot_fit_imaging_of_planes(fit=pixelized_fit) """ -Compare this result with the analytic-source fit. Inspect the source structure and image residuals, then examine -how the inferred mass parameters and their uncertainties have changed. +Compare the source reconstruction and residuals with the initial MGE fit. Then inspect the inferred mass parameters and their uncertainties. -For this simple simulated source, an MGE can already describe its smooth light distribution, so a pixelization need -not produce a dramatic improvement. Its value becomes clearer for irregular sources that cannot be described -adequately by the initial profile. This example teaches the modelling procedure without requiring that extra -complication. +Our simulated source is smooth, so the MGE may already describe it well. We should not expect a dramatic improvement simply because we introduced a pixelization. For irregular sources, its additional spatial freedom becomes more useful. -We can also check whether the displayed pixelized model satisfies the positions threshold. +Let’s also check whether the displayed model brings our selected image positions within the source-plane threshold. """ final_source_positions = pixelized_fit.tracer.traced_grid_2d_list_from( @@ -644,64 +608,46 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): )[1] coordinates = np.asarray(final_source_positions) + separations = np.linalg.norm( coordinates[:, None, :] - coordinates[None, :, :], axis=-1, ) + maximum_separation = float(np.max(separations)) -print( - "Maximum source-plane separation:", - maximum_separation, - "arcsec", -) -print( - "Positions threshold:", - positions_likelihood.threshold, - "arcsec", -) +print("Maximum source-plane separation:", maximum_separation, "arcsec") +print("Positions threshold:", positions_likelihood.threshold, "arcsec") print( "Within threshold:", maximum_separation <= positions_likelihood.threshold, ) """ -Passing this check means the selected positions trace sufficiently close together under this model. It does not -replace inspecting the residuals, uncertainties or search behaviour. +Passing this check confirms that these selected positions trace sufficiently close together. We still need to inspect the image residuals, parameter constraints and search behaviour to assess the overall fit. -We have now performed a chained fit with a pixelized source and a free mass model. The next blocks introduce -foreground lens light, explaining how its analytic profiles are fitted alongside the source reconstruction and why -errors in that light model can affect the inferred source. +We have now fitted a free mass model and a pixelized source using search chaining. Next, we will include the foreground lens galaxy’s light and examine how it is fitted alongside the source reconstruction. +""" +""" __Establishing the foreground light model__ -We can model the foreground lens galaxy’s light and reconstruct a pixelized background source within the same fit. -The foreground light profile and the lensed source contribute together to the predicted image. +So far, our images have contained only the lensed source. In most observed systems, the foreground lens galaxy also contributes light, often overlapping the arcs. -However, fitting everything from the outset gives the search several problems to solve at once. It must establish -the mass distribution, separate foreground light from source light, and choose an appropriate source -reconstruction. +We can fit both contributions together. The predicted image is the sum of the foreground galaxy’s light and the lensed background source, including telescope blurring. -Search chaining helps us approach these problems in stages. We begin with MGE light models for both galaxies, -following Chapter 2. This simpler source description provides a useful starting lens geometry and lets us establish -the foreground light model before introducing a pixelization. +However, this gives the search several things to determine at once: the mass distribution, the foreground light, and the background source. An error in the foreground light model can leave residuals that the pixelized source attempts to explain. -Let’s perform that initial fit using a simulated dataset containing foreground lens light. This is a new dataset, -so we run a new search rather than reuse the result from our earlier no-lens-light example. -""" +We therefore use search chaining again. Following Chapter 2, we first fit MGEs for both the foreground galaxy and source. This establishes an initial lens-light model and lensing geometry before we introduce a pixelization. -from pathlib import Path -import subprocess -import sys - -import autofit as af -import autolens as al -import autolens.plot as aplt +We use a new simulated dataset containing foreground light, so this requires a new initial search. +""" foreground_dataset_path = Path("dataset") / "imaging" / "lens_sersic" al.util.dataset.simulate_if_needed( - foreground_dataset_path, simulator="scripts/simulator/lens_sersic.py" + foreground_dataset_path, + simulator="scripts/simulator/lens_sersic.py", ) foreground_dataset = al.Imaging.from_fits( @@ -716,6 +662,7 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): pixel_scales=foreground_dataset.pixel_scales, radius=2.5, ) + foreground_dataset = foreground_dataset.apply_mask(mask=mask) over_sample_size = al.util.over_sample.over_sample_size_via_radial_bins_from( @@ -724,6 +671,7 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): radial_list=[0.3, 0.6], centre_list=[(0.0, 0.0)], ) + foreground_dataset = foreground_dataset.apply_over_sampling( over_sample_size_lp=over_sample_size, over_sample_size_pixelization=2, @@ -731,11 +679,20 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aplt.subplot_imaging_dataset(dataset=foreground_dataset) +""" +The foreground galaxy has a bright centre where its light changes rapidly across individual image pixels. We use finer sampling there when evaluating the light profiles, following the oversampling approach introduced earlier. + +For the foreground MGE, we use two Gaussian groups that can have different elliptical shapes. This allows more flexibility than a single group. We fix their centres and the mass centre to the simulation’s origin for this introductory example. +""" + lens_bulge = al.model_util.mge_model_from( - mask_radius=2.5, total_gaussians=20, gaussian_per_basis=2, + mask_radius=2.5, + total_gaussians=20, + gaussian_per_basis=2, centre_prior_is_uniform=True, sigma_min=foreground_dataset.pixel_scales[0] / 10.0, ) + for gaussian in lens_bulge.profile_list: gaussian.centre = (0.0, 0.0) @@ -757,7 +714,9 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): al.Galaxy, redshift=1.0, bulge=al.model_util.mge_model_from( - mask_radius=2.5, total_gaussians=20, centre_prior_is_uniform=False, + mask_radius=2.5, + total_gaussians=20, + centre_prior_is_uniform=False, ), ) @@ -772,6 +731,8 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): fields=field, ) +print(foreground_model_1.info) + foreground_search_1 = af.Nautilus( path_prefix=Path("howtolens") / "chapter_3" / "tutorial_4", name="foreground_search_1_analytic_source", @@ -798,31 +759,23 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): ) """ -We fix the foreground light and mass centres for this introductory example, while allowing their other parameters -to vary. The foreground MGE uses two Gaussian groups, which can have different elliptical shapes; the source uses -one. Their Gaussian widths are fixed and all Gaussian intensities are solved linearly. +The Gaussian widths are fixed, while their intensities are solved linearly. The search explores the remaining free geometry, mass and shear parameters. -Before continuing, inspect the foreground model and residuals. We want a satisfactory description of the lens light -and a plausible source geometry. For an irregular source, the analytic profile may leave structure unexplained, but -obvious foreground residuals should not simply be passed into the next stage. +Inspect the foreground light model, source and residuals. We want the initial model to separate the foreground galaxy from the arcs sensibly. Significant foreground residuals should be investigated before introducing a more flexible source that might absorb them. -This is a genuine non-linear search, so it will take longer than evaluating a single `FitImaging`. +--- +""" +""" __Using that result in the pixelized-source fit__ -We now replace the analytic source with a pixelization and carry the foreground model into the second search. +Now replace the analytic source with a pixelization. -The distinction between `.model` and `.instance` is useful here. We use `.instance` to hold the foreground -profile’s fitted shape fixed, reducing the number of non-linear parameters the next search must explore. We use -`.model` for the mass and shear so that they remain free, with priors passed from the first result. +We hold the fitted **foreground MGE geometry** fixed using `.instance`, while keeping the mass and shear free using `.model` and its updated priors. -Because the foreground MGE uses linear Gaussian profiles, fixing its geometry does **not** fix its intensities. The -second fit solves all Gaussian intensities alongside the pixelized source brightnesses. The foreground subtraction -can therefore adjust without reopening the non-linear Gaussian geometry. +There is an important distinction here. The MGE contains **linear** Gaussian profiles, so fixing their geometry does not fix their intensities. The next fit solves those intensities together with the pixelized source brightnesses. -Search chaining also supplies the initial geometry from which we construct the positions constraint. -**`PositionsLH` penalizes demagnified solutions whose corresponding image positions fail to trace sufficiently -close together.** +We therefore continue to fit the foreground and background light simultaneously. We are not subtracting a fixed foreground image and treating that subtraction as exact. """ lens = af.Model( @@ -853,6 +806,12 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): fields=foreground_result_1.model.fields, ) +print(foreground_model_2.info) + +""" +As in Chapter 2, Tutorial 5, we use the initial result to construct the **positions likelihood penalty**. This helps discourage the unphysical mass-and-source solutions discussed earlier. +""" + foreground_positions_likelihood = ( foreground_result_1.positions_likelihood_from( factor=3.0, @@ -863,10 +822,12 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): aplt.plot_array( array=foreground_dataset.data, positions=foreground_positions_likelihood.positions, - title="Positions Predicted by the Initial Foreground-Light Fit", + title="Positions Predicted by the Initial Fit", ) -print(foreground_model_2.info) +""" +Check these positions against the observed arcs before running the second search. They are predictions from the initial model, so their usefulness depends on that model having found a sensible lensing geometry. +""" foreground_analysis_2 = al.AnalysisImaging( dataset=foreground_dataset, @@ -896,20 +857,37 @@ def fit_at_einstein_radius(einstein_radius, coefficient=1.0): ) """ -Inspect the predicted positions before running the second search, as we did earlier. They come from the initial -model and should sensibly represent the multiple-image configuration. +Compare the foreground subtraction, reconstructed source and residuals with the initial fit. The mass parameters, regularization coefficient and linearly solved foreground intensities can all respond to the new source description. -The second search now starts with an established foreground light shape and lens geometry. It can refine the mass -and regularization while reconstructing the source, rather than solving the entire problem from broad initial -priors. +If the fixed foreground geometry leaves significant residuals, we can free its parameters in a later chained search. Holding it fixed is a useful intermediate step, rather than a requirement for the final analysis. -Compare the two results, paying attention to the foreground subtraction, source reconstruction and residuals. If -the fixed foreground shape leaves significant residuals, a later chained search can free its parameters again. -Fixing it is a useful intermediate step, not a requirement that the initial light model must remain unchanged -throughout the analysis. +--- +""" +""" __Wrap Up__ -This completes the practical chain: fit MGE lens and source light, carry forward the foreground geometry and mass -priors, then fit a pixelized source with the positions constraint. +We have now fitted a lens mass model alongside a pixelized source, both with and without foreground lens light. + +The main difficulty was not solving for the source brightnesses. The inversion does that efficiently for every proposed model. The difficulty was finding the physically meaningful mass model among the many incorrect models that a flexible source can partly compensate for. + +We addressed this using the tools introduced in Chapter 2: + +- **Search chaining:** begin with an MGE source to establish the lensing geometry, then introduce a pixelization. +- **Prior passing:** keep the mass parameters free while using the initial result to guide the next search. +- **The positions likelihood penalty:** discourage models that trace corresponding image positions too far apart. + +When foreground light is present, we also establish its geometry in the initial fit. Its linear intensities can then be solved alongside the pixelized source, allowing both contributions to adjust together. + +However, finding the appropriate mass model does not guarantee that our source reconstruction can describe all the observed structure. + +What happens if the source has a very compact bright centre, but our mesh places too few pixels there? What if that centre needs weak smoothing while the faint outskirts need strong smoothing? + +Even the evidence-preferred constant regularization coefficient must compromise between these demands. + +In the next tutorial, **Fit Problems**, we will deliberately make the source more compact while keeping the true mass model fixed. This will reveal limitations of the mesh and regularization themselves, and motivate the adaptive methods that follow. +""" + +""" +**🤖 Ask AI:** Why can an incorrect mass model fit the arcs with a pixelized source, and how do search chaining and the positions likelihood penalty address this? """ diff --git a/scripts/chapter_3_pixelizations/tutorial_5_fit_problems.py b/scripts/chapter_3_pixelizations/tutorial_5_fit_problems.py new file mode 100644 index 0000000..9ff36e7 --- /dev/null +++ b/scripts/chapter_3_pixelizations/tutorial_5_fit_problems.py @@ -0,0 +1,716 @@ +""" +Tutorial 5: Fit Problems +======================== + +Investigate why compact sources can challenge mesh resolution and constant regularization even when the mass model is correct. + +__Contents__ + +- When the best coefficient is not enough +- Three progressively compact sources +- Simulating the images +- Reconstructing the sources +- Finding the preferred regularization +- Starting with the extended source +- Making the source more compact +- A very compact source +- Why the mesh becomes a limitation +- Why one regularization coefficient becomes a compromise +- Why maximizing the evidence does not remove the compromise +- Wrap Up +""" + +""" +__When the best coefficient is not enough__ + +We have learned how to reconstruct a source, choose its regularization coefficient using Bayesian evidence, and fit the lens mass model. + +Does this mean we can now reconstruct any source accurately? + +Let’s investigate using three galaxies whose light becomes progressively more concentrated towards their centres. We will simulate their images using exactly the same lens mass model, then supply that correct mass model when reconstructing each source. + +This removes the search difficulty from the previous tutorial. If a reconstruction leaves significant residuals, we cannot blame the search for finding an incorrect mass model: we have already supplied the answer. + +We will also choose the regularization coefficient separately for each dataset by maximizing its log evidence. This lets us ask a more interesting question than whether an arbitrary coefficient works: + +**What happens when the preferred coefficient still does not give a satisfactory reconstruction?** + +There are two possible limitations to investigate. + +First, the mesh may place too few source pixels across a compact bright component. Changing the regularization coefficient cannot add the missing resolution. + +Second, one coefficient controls smoothing across the entire source. The bright centre may require weak smoothing to preserve its steep brightness gradient, while the faint outskirts benefit from stronger smoothing. A single coefficient must compromise between them. + +Our Goldilocks coefficient is therefore “just right” **within the mesh and regularization scheme we have provided**. It cannot change those choices for us. + +We will work through: + +- Three progressively compact sources. +- Simulating their lensed images. +- Selecting a regularization coefficient for each dataset. +- Inspecting the reconstructions and residuals. +- Separating the limitations of mesh resolution and constant regularization. +- Motivating adaptation to the source’s brightness distribution. + +--- +""" + +""" +__Three progressively compact sources__ + +We use three Sérsic profiles with the same centre and elliptical shape. + +For the first, we choose a relatively large effective radius and a Sérsic index of `1.0`. For the next two, we decrease the effective radius and increase the Sérsic index. Their light becomes more concentrated, with a steeper brightness gradient near the centre. +""" + +from autolens import jax_wrapper + +import numpy as np +import matplotlib.pyplot as plt +import autolens as al +import autolens.plot as aplt +import autoarray.plot as aaplt + +source_parameters = [ + ("Extended", 0.5, 1.0), + ("Compact", 0.2, 2.5), + ("Very compact", 0.1, 4.0), +] + +sources = {} + +for label, effective_radius, sersic_index in source_parameters: + sources[label] = al.Galaxy( + redshift=1.0, + bulge=al.lp.Sersic( + centre=(0.0, 0.0), + ell_comps=(0.0, 0.15), + intensity=0.2, + effective_radius=effective_radius, + sersic_index=sersic_index, + ), + ) + +""" +The Sérsic `intensity` is the brightness at the effective radius, rather than the central brightness. Keeping it fixed while changing the effective radius and Sérsic index changes both the central brightness and total flux. + +These examples therefore represent different source morphologies and signal-to-noise distributions. They are not an experiment in which only the source size changes. + +We place all three sources behind the same lens: +""" + +lens_galaxy = al.Galaxy( + redshift=0.5, + mass=al.mp.Isothermal( + centre=(0.0, 0.0), + ell_comps=(0.111111, 0.0), + einstein_radius=1.6, + ), +) + +true_tracers = { + label: al.Tracer(galaxies=[lens_galaxy, source]) + for label, source in sources.items() +} + +""" +The lens produces exactly the same deflection and magnification fields in all three cases. Changing the source brightness does not change where an image coordinate traces in the source plane. + +This distinction will become important: the lensing geometry stays the same, but the spatial resolution needed to describe the source’s light changes. + +--- +""" + +""" +__Simulating the images__ + +We now simulate the three lensed images using the same image grid and telescope blurring. + +To make the comparison easier to interpret, we also use the same Gaussian noise map and the same noise realization. Any change between the images then comes from the source light, rather than a different random draw of the noise. + +We first generate images without added Poisson noise, then add our common Gaussian noise explicitly. +""" + +simulation_grid = al.Grid2D.uniform( + shape_native=(150, 150), + pixel_scales=0.05, +) + +simulation_grid = simulation_grid.apply_over_sampling( + over_sample_size=8, +) + +psf = al.Convolver.from_gaussian( + shape_native=(11, 11), + sigma=0.05, + pixel_scales=0.05, +) + +simulator = al.SimulatorImaging( + exposure_time=300.0, + psf=psf, + background_sky_level=0.1, + add_poisson_noise_to_data=False, +) + +noise_sigma = 0.05 + +noise_values = np.random.default_rng(1).normal( + loc=0.0, + scale=noise_sigma, + size=simulation_grid.shape_native, +) + +common_noise_map = al.Array2D.no_mask( + values=np.full(simulation_grid.shape_native, noise_sigma), + pixel_scales=0.05, +) + +datasets = {} + +for label, tracer in true_tracers.items(): + noiseless = simulator.via_tracer_from( + tracer=tracer, + grid=simulation_grid, + ) + + datasets[label] = al.Imaging( + data=al.Array2D.no_mask( + values=np.asarray(noiseless.data.native) + noise_values, + pixel_scales=0.05, + ), + noise_map=common_noise_map, + psf=psf, + ) + + print(label) + aplt.subplot_imaging_dataset(dataset=datasets[label]) + +""" +Look at how the lensed emission changes. Which parts become narrower or more sharply peaked as the source becomes compact? These are the features our reconstruction will need to explain after accounting for telescope blurring. + +We use the same circular mask for all three datasets. Before fitting, check its border against the full images so that we can see whether it includes the lensed emission. +""" + +mask = al.Mask2D.circular( + shape_native=simulation_grid.shape_native, + pixel_scales=0.05, + radius=3.0, +) + +for label, dataset in datasets.items(): + aplt.plot_array( + array=dataset.data, + positions=mask.derive_grid.border, + title=f"{label}: Image and Mask Border", + ) + +datasets = { + label: dataset.apply_mask(mask=mask).apply_over_sampling( + over_sample_size_pixelization=2, + ) + for label, dataset in datasets.items() +} + +""" +We now have three datasets with a common lens model, image sampling, PSF, mask and noise realization. + +Next, we will reconstruct them using the same rectangular mesh and constant regularization scheme. For each dataset, we will scan the coefficient and inspect its evidence-preferred reconstruction. + +We will compare alternative reconstructions **within each dataset**. We will not rank the three different simulated galaxies against one another using their absolute evidence values. +""" + +""" +__Reconstructing the sources__ + +We will reconstruct all three sources using a `25 × 25` rectangular mesh and `Constant` regularization. + +We supply the same lens galaxy used to simulate the images. Its mass parameters remain fixed, so the inversion cannot compensate for limitations of the source reconstruction by changing the lens model. + +The function below takes a dataset and regularization coefficient, reconstructs the source, and returns the fit. +""" + +mesh_shape = (25, 25) + + +def fit_source(dataset, coefficient): + pixelization = al.Pixelization( + mesh=al.mesh.RectangularUniform(shape=mesh_shape), + regularization=al.reg.Constant(coefficient=coefficient), + ) + + reconstructed_source = al.Galaxy( + redshift=1.0, + pixelization=pixelization, + ) + + tracer = al.Tracer( + galaxies=[lens_galaxy, reconstructed_source], + ) + + return al.FitImaging( + dataset=dataset, + tracer=tracer, + settings=al.Settings(use_border_relocator=True), + ) + +""" +The image grid, mask and lens model are identical across our three datasets. The rectangular mesh is therefore constructed from the same mapped coordinates, regardless of how compact the source light becomes. + +This is the limitation we want to explore. The source can become smaller and more centrally concentrated, but the mesh does not respond by placing more points across its bright centre. + +For now, the only reconstruction setting we vary is the regularization coefficient. + +--- +""" + +""" +__Finding the preferred regularization__ + +As in tutorial 2, we evaluate a range of coefficients and compare their log evidences. We do this separately for each dataset: a coefficient suitable for the extended source need not be suitable for either compact source. + +We first make a broad scan, then sample more closely around its highest point. +""" + +coefficient_grid = np.logspace(-3, 3, 25) + +preferred_fits = {} +preferred_coefficients = {} + +for label, dataset in datasets.items(): + # Retain scalar evidence values, not every full inversion in the scan. + trial_evidences = {} + best_fit = None + best_evidence = -np.inf + + for coefficient in coefficient_grid: + coefficient = float(coefficient) + current_fit = fit_source(dataset=dataset, coefficient=coefficient) + evidence = float(current_fit.log_evidence) + trial_evidences[coefficient] = evidence + if evidence > best_evidence: + best_fit, best_evidence = current_fit, evidence + + values = np.array([trial_evidences[float(c)] for c in coefficient_grid]) + + if not np.all(np.isfinite(values)): + raise ValueError(f"{label}: inspect non-finite evidence values.") + + best_index = int(np.argmax(values)) + if best_index in (0, len(coefficient_grid) - 1): + raise ValueError(f"{label}: extend the coefficient range before continuing.") + + refined_grid = np.geomspace( + coefficient_grid[best_index - 1], + coefficient_grid[best_index + 1], + 15, + ) + + for coefficient in refined_grid: + coefficient = float(coefficient) + if coefficient not in trial_evidences: + current_fit = fit_source(dataset=dataset, coefficient=coefficient) + evidence = float(current_fit.log_evidence) + trial_evidences[coefficient] = evidence + if evidence > best_evidence: + best_fit, best_evidence = current_fit, evidence + + if not np.all(np.isfinite(list(trial_evidences.values()))): + raise ValueError(f"{label}: inspect non-finite evidence values.") + + preferred_coefficient = max(trial_evidences, key=trial_evidences.get) + preferred_coefficients[label] = preferred_coefficient + preferred_fits[label] = best_fit + + coefficients = np.array(sorted(trial_evidences)) + evidences = np.array([trial_evidences[c] for c in coefficients]) + + fig, ax = plt.subplots() + ax.semilogx( + coefficients, + evidences - np.max(evidences), + marker="o", + ) + ax.set_xlabel("Regularization coefficient") + ax.set_ylabel("Log evidence relative to this dataset's maximum") + ax.set_title(label) + plt.show() + + print(f"{label}: preferred coefficient = {preferred_coefficient:.4g}") + +""" +This performs many inversions, so it will take longer than the earlier three-coefficient comparisons. + +Inspect each curve. Have we resolved its peak sufficiently well, or would further sampling be useful? These are the highest-evidence coefficients we have evaluated, rather than mathematically exact optima. + +Each plot has its own maximum subtracted. We are examining how regularization affects each dataset, not comparing evidence values between different simulated images. + +We can now inspect the reconstructions knowing that we have selected their smoothing strengths systematically. + +--- +""" + +""" +__Starting with the extended source__ + +Let’s begin with the extended source. + +Its brightness varies relatively gradually across the source plane. We therefore expect the rectangular mesh to describe it more easily than the compact sources, whose central brightness changes over smaller distances. + +Inspect the reconstruction and image residuals at its preferred coefficient. +""" + +extended_fit = preferred_fits["Extended"] +extended_mapper = extended_fit.inversion.cls_list_from(al.Mapper)[0] + +print( + "Extended-source coefficient:", + preferred_coefficients["Extended"], +) + +aplt.subplot_fit_imaging(fit=extended_fit) + +aaplt.plot_mapper( + mapper=extended_mapper, + solution_vector=extended_fit.inversion.reconstruction, + mesh_grid=extended_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Extended Source: Preferred Reconstruction", +) + +aplt.subplot_fit_imaging_of_planes(fit=extended_fit) + +""" +Look at how many mesh points lie across the bright source region. Are its brightness variations represented over several neighbouring points, or concentrated into only one or two? + +Then inspect the normalized residual map. A satisfactory reconstruction should leave fluctuations broadly consistent with the simulated noise, without coherent patches following the arcs. + +We can summarize the residuals numerically, too. +""" + +normalized_residuals = np.asarray( + (datasets["Extended"].data - extended_fit.model_data) + / datasets["Extended"].noise_map +) + +print( + "Mean squared normalized residual:", + float(np.mean(normalized_residuals**2)), +) + +""" +A value around one is a useful reference for noise-level residuals, but it does not tell us whether those residuals form a spatial pattern. Keep the residual map alongside this number. + +This extended-source fit provides our baseline. Next, we will make the source more compact without giving the mesh any additional resolution, and examine where the reconstruction begins to struggle. +""" + +""" +__Making the source more compact__ + +Now inspect the compact source. + +Its effective radius is smaller and its central brightness gradient is steeper. However, we have given the reconstruction exactly the same mesh as before. We have changed the regularization coefficient to the value preferred by this dataset, but we have not added any source pixels. +""" + +compact_fit = preferred_fits["Compact"] +compact_mapper = compact_fit.inversion.cls_list_from(al.Mapper)[0] + +print( + "Compact-source coefficient:", + preferred_coefficients["Compact"], +) + +aplt.subplot_fit_imaging(fit=compact_fit) + +aaplt.plot_mapper( + mapper=compact_mapper, + solution_vector=compact_fit.inversion.reconstruction, + mesh_grid=compact_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Compact Source: Preferred Reconstruction", +) + +aplt.subplot_fit_imaging_of_planes(fit=compact_fit) + +""" +Look closely at the bright source centre and the corresponding bright regions of the arcs. Does the reconstruction reproduce their sharp brightness peaks, or do coherent patches remain in the residual map? + +The source reconstruction may look broadly reasonable while still missing these details. A small error in the bright centre can produce significant residuals because the image measures that emission with high signal-to-noise. +""" + +normalized_residuals = np.asarray( + (datasets["Compact"].data - compact_fit.model_data) + / datasets["Compact"].noise_map +) + +print( + "Mean squared normalized residual:", + float(np.mean(normalized_residuals**2)), +) + +""" +If structured residuals remain, remember what we have already controlled: the mass model is correct, and the coefficient was selected using the evidence. + +The question is therefore no longer simply “Have we chosen the wrong coefficient?” We need to ask whether our mesh and regularization scheme can represent this source adequately. + +--- +""" + +""" +__A very compact source__ + +Our final source has an effective radius of just `0.1` arcsec and a Sérsic index of `4.0`. Its light rises steeply towards the centre, placing a greater demand on the reconstruction. + +Before looking at the result, consider the two difficulties this creates. + +The mesh must have sufficiently closely spaced points to represent the central brightness peak. At the same time, regularization must allow large brightness differences between those points without allowing unnecessary fluctuations across the faint outskirts. + +Let’s inspect the evidence-preferred reconstruction. +""" + +very_compact_fit = preferred_fits["Very compact"] +very_compact_mapper = very_compact_fit.inversion.cls_list_from(al.Mapper)[0] + +print( + "Very-compact-source coefficient:", + preferred_coefficients["Very compact"], +) + +aplt.subplot_fit_imaging(fit=very_compact_fit) + +aaplt.plot_mapper( + mapper=very_compact_mapper, + solution_vector=very_compact_fit.inversion.reconstruction, + mesh_grid=very_compact_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Very Compact Source: Preferred Reconstruction", +) + +aplt.subplot_fit_imaging_of_planes(fit=very_compact_fit) + +""" +Compare the residuals with those of the extended and compact sources. Pay particular attention to the bright parts of the arcs: are there neighbouring positive and negative residuals suggesting that the model is spreading the light differently from the data? + +We can summarize all three fits together. +""" + +for label, current_fit in preferred_fits.items(): + dataset = datasets[label] + + normalized_residuals = np.asarray( + (dataset.data - current_fit.model_data) / dataset.noise_map + ) + + print( + f"{label}: " + f"coefficient={preferred_coefficients[label]:.4g}, " + f"mean squared normalized residual=" + f"{np.mean(normalized_residuals**2):.3f}" + ) + +""" +Use these numbers alongside the maps. They summarize the overall residual amplitude, whereas the maps show where the fit struggles. + +These galaxies also have different brightness distributions and signal-to-noise ratios. We should not interpret the comparison as a measurement of source-size effects alone. Its purpose is to expose the practical difficulty of reconstructing increasingly concentrated light with an unchanged mesh and one smoothing coefficient. + +--- +""" + +""" +__Why the mesh becomes a limitation__ + +Let’s examine the first problem: where our source pixels are placed. + +The rectangular mesh covers the same source-plane region in all three fits. Its points do not move towards the bright centre when we make the source more compact. Consequently, fewer points lie across the region where most of the central brightness variation occurs. + +We can illustrate this by counting mesh points within a circular aperture whose radius equals each source’s effective radius. +""" + +for label, effective_radius, _ in source_parameters: + current_fit = preferred_fits[label] + mapper = current_fit.inversion.cls_list_from(al.Mapper)[0] + + mesh_coordinates = np.asarray(mapper.source_plane_mesh_grid) + + radii = np.sqrt( + np.sum(mesh_coordinates**2, axis=1) + ) + + points_within_radius = int( + np.count_nonzero(radii <= effective_radius) + ) + + print( + f"{label}: effective radius={effective_radius:.2f} arcsec, " + f"mesh points inside circular aperture={points_within_radius}" + ) + +""" +This is a simple geometric diagnostic. Our sources are elliptical, so the circular aperture is not their exact half-light contour. Nor does the count tell us how many brightness values are independently constrained. It shows how the mesh samples progressively smaller central regions. + +Bilinear interpolation can calculate brightness between mesh points, but it cannot introduce an independently adjustable peak between them. If a sharp source component falls within a region sampled by too few points, changing the coefficient cannot supply the missing spatial freedom. + +We could increase the resolution everywhere. However, that also adds points across faint regions where the data may provide little information. Those extra brightness values increase the computational work and may require strong regularization. + +A more efficient approach is to place additional points where the source needs them. + +Magnification offers one useful guide because it tells us where lensing provides finer source-plane resolution. However, the magnification field is identical for our three sources: it does not respond when their light becomes more concentrated. Adapting to magnification alone therefore cannot guarantee enough resolution across every bright source component. + +This motivates adapting the mesh to the **source’s brightness distribution**, which we will explore in the next tutorial. + +First, there is a second limitation to understand. Even with enough mesh points, should we apply the same regularization coefficient to the bright centre and faint outskirts? +""" + +""" +__Why one regularization coefficient becomes a compromise__ + +Suppose we had enough mesh points to resolve the source’s bright centre. Would that solve the problem? + +We would still need to choose how strongly to regularize those points. + +Recall that `Constant` regularization penalizes differences between neighbouring source brightness values. One coefficient controls the strength of that penalty across the entire mesh. + +However, the brightness gradients differ substantially between the centre and outskirts of our compact source. + +Near the centre, the brightness rises steeply. Neighbouring mesh points need to take quite different values to reproduce that rise. Strong regularization discourages those differences and can flatten the reconstructed peak. + +In the outskirts, the brightness changes more gradually, or there is little source emission at all. Here, stronger regularization can suppress noise-driven fluctuations without removing the structure needed to explain the image. + +Our single coefficient must serve both regions. Let’s compare weaker and stronger regularization with the evidence-preferred value for the very compact source. +""" + +preferred_coefficient = preferred_coefficients["Very compact"] + +regularization_comparisons = [ + ( + "Weaker Regularization", + preferred_coefficient / 10.0, + fit_source( + dataset=datasets["Very compact"], + coefficient=preferred_coefficient / 10.0, + ), + ), + ( + "Evidence-Preferred Regularization", + preferred_coefficient, + preferred_fits["Very compact"], + ), + ( + "Stronger Regularization", + preferred_coefficient * 10.0, + fit_source( + dataset=datasets["Very compact"], + coefficient=preferred_coefficient * 10.0, + ), + ), +] + +source_vmax = max( + float(np.max(np.asarray(current_fit.inversion.reconstruction))) + for _, _, current_fit in regularization_comparisons +) + +for label, coefficient, current_fit in regularization_comparisons: + mapper = current_fit.inversion.cls_list_from(al.Mapper)[0] + + print(f"{label}: coefficient={coefficient:.4g}") + + aaplt.plot_mapper( + mapper=mapper, + solution_vector=current_fit.inversion.reconstruction, + mesh_grid=mapper.source_plane_mesh_grid, + vmin=0.0, + vmax=source_vmax, + zoom_to_brightest=False, + title=label, + ) + + aplt.subplot_fit_imaging(fit=current_fit) + +""" +Compare the bright centre and faint outskirts separately. + +Does weaker regularization help reproduce the central peak? Does it also introduce more variation in the faint regions? Does stronger regularization suppress those variations at the cost of leaving residuals around the bright arcs? + +These changes need not be equally visible in every dataset. The important point is that changing one coefficient changes the smoothing preference everywhere, even though different source regions may benefit from different strengths. + +--- +""" + +""" +__Why maximizing the evidence does not remove the compromise__ + +Why does the Bayesian evidence not simply select a coefficient that fixes both regions? + +Because we have only given it one coefficient to choose. + +The evidence evaluates the complete reconstruction. It balances improvements in the image fit against the source complexity, but it cannot introduce a separate smoothing strength for the centre and outskirts unless our regularization model allows that. + +Let’s compare the evidence of the three reconstructions. +""" + +comparison_evidences = np.array([ + float(current_fit.log_evidence) + for _, _, current_fit in regularization_comparisons +]) + +highest_evidence = float(np.max(comparison_evidences)) + +for (label, coefficient, _), log_evidence in zip( + regularization_comparisons, + comparison_evidences, +): + print( + f"{label}: coefficient={coefficient:.4g}, " + f"log evidence difference={log_evidence - highest_evidence:.3f}" + ) + +""" +The coefficient selected by our earlier scan should give the highest evidence of these examples. If either additional trial improves it, we should revisit the scan before treating its maximum as established. + +Even at the preferred coefficient, the reconstruction may compromise: smoothing the centre more than we would like while allowing more variation in the outskirts than those regions require. + +Our Goldilocks analogy now has a limitation. We have been choosing one amount of smoothing for the whole galaxy, but what is “just right” for the bright centre may not be “just right” for the outskirts. + +An **adaptive regularization scheme** gives us more control. It can allow weaker smoothing in bright regions with steep gradients and stronger smoothing in faint regions. + +The evidence still decides whether that additional flexibility is useful. We compare the constant and adaptive schemes on the same dataset, allowing their parameters to vary appropriately. Adaptation is a way to improve the available reconstructions, rather than a guarantee that every fit will improve. + +--- +""" + +""" +__Wrap Up__ + +We began with three sources whose light became progressively more concentrated, while keeping the lens mass model fixed to its true values. + +For each dataset, we selected the regularization coefficient using Bayesian evidence. This allowed us to investigate limitations that remain even when the mass is correct and the smoothing strength has been chosen systematically. + +We identified two distinct problems: + +- **Mesh resolution:** a fixed rectangular mesh can place too few points across a compact bright component. Changing regularization cannot add the missing resolution. +- **Constant regularization:** one smoothing coefficient must compromise between steep central brightness gradients and faint, slowly varying outskirts. + +These problems are connected, but solving one does not automatically solve the other. Adding more points around the centre will not help fully if regularization forces their brightnesses to be too similar. Weakening regularization will not resolve a feature that the mesh cannot represent. + +The next tutorial brings together the adaptive methods needed to address both. + +We will first introduce a Delaunay mesh whose source-plane points are obtained by tracing an image-plane mesh through the lens model. This lets the source sampling respond to the lensing geometry. + +We will then use a fitted brightness distribution to guide a Hilbert image mesh, concentrating sampling where the lensed source is bright. Finally, we will introduce adaptive regularization and explain how its smoothing acts across the irregular mesh. + +At each stage, we will return to the questions raised here: does the mesh resolve the source’s bright structure, and does regularization preserve that structure while controlling fluctuations elsewhere? + +We will finish by using these methods in a lens-model fit, with the search-chaining procedure and positions likelihood penalty already introduced. +""" + +""" +**🤖 Ask AI:** How would you distinguish insufficient source-mesh resolution from over-smoothing when compact-source residuals remain at the evidence-preferred coefficient? + +__Env__ + +Keep full-size datasets during automated runs so compact sources remain resolved +and the evidence scans test the same examples as the lesson. + +ENV: full_datasets +""" diff --git a/scripts/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.py b/scripts/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.py new file mode 100644 index 0000000..d9f7645 --- /dev/null +++ b/scripts/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.py @@ -0,0 +1,1593 @@ +""" +Tutorial 6: Adaptive Reconstruction +=================================== + +Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization in a chained lens-model fit. + +__Contents__ + +- Adaptive source reconstruction +- Returning to the compact-source problem +- From an image mesh to a source mesh +- Constructing the Delaunay source mesh +- Interpolating within a triangle +- Regularization on an irregular mesh +- Building an adapt image +- Placing points with a Hilbert image mesh +- Controlling the brightness weighting +- Keeping resolution in the faint regions +- Reconstructing with the Hilbert mesh +- Comparing overlay and Hilbert reconstructions +- Smoothing on an irregular mesh +- Reconstructing with ConstantSplit +- Allowing different smoothing strengths +- Controlling the transition with `signal_scale` +- Exploring the adaptive coefficients +- Does adaptive regularization address the fit problem? +- Starting the final modelling chain +- Preparing adaptation and the positions likelihood penalty +- Defining the adaptive Delaunay model +- Running the adaptive Delaunay fit +- Inspecting the source and smoothing pattern +- Wrap Up +""" + +""" +__Adaptive source reconstruction__ + +In **Fit Problems**, we identified two limitations of our reconstruction: too few mesh points across compact source structure, and a single regularization coefficient that must compromise between the bright centre and faint outskirts. + +We will now introduce methods that address these problems. + +Our first step is to replace the rectangular source mesh with a **Delaunay mesh**. Its points need not lie in regular rows and columns, allowing us to vary their spacing across the source. + +We then need to decide where to place those points. We will do this in the image plane, using an **image mesh**, and ray-trace its coordinates to obtain the source-mesh points. + +This gives us two useful ways to allocate resolution: + +- An evenly spaced image-plane overlay produces source-plane sampling that responds to the lens magnification. +- A brightness-adaptive image mesh places more points in bright image regions, directing more resolution towards the source emission. + +Finally, we will introduce adaptive regularization, allowing the smoothing strength to vary across the reconstructed source. + +These choices work together. Placing more points across a bright component gives the reconstruction enough resolution to describe it; allowing appropriate brightness differences between those points lets it use that resolution. + +We will proceed through: + +- Constructing an image-plane overlay. +- Ray-tracing it to build a Delaunay source mesh. +- Understanding interpolation and regularization on that mesh. +- Using a fitted image to guide Hilbert brightness adaptation. +- Introducing Split and adaptive regularization. +- Comparing reconstructions on the same dataset. +- Performing a lens-model fit with an adaptive pixelization. + +--- +""" + +""" +__Returning to the compact-source problem__ + +We return to the very compact source from **Fit Problems**. Keeping the same example lets us examine whether the adaptive methods address the difficulties we have just investigated. + +The setup below reproduces that dataset, including its lens model, PSF and Gaussian noise realization. This makes the tutorial runnable independently. +""" + +from autolens import jax_wrapper + +import numpy as np +import matplotlib.pyplot as plt +import autolens as al +import autolens.plot as aplt +import autoarray.plot as aaplt + +simulation_grid = al.Grid2D.uniform( + shape_native=(150, 150), + pixel_scales=0.05, +) +simulation_grid = simulation_grid.apply_over_sampling( + over_sample_size=8, +) + +psf = al.Convolver.from_gaussian( + shape_native=(11, 11), + sigma=0.05, + pixel_scales=0.05, +) + +lens_galaxy = al.Galaxy( + redshift=0.5, + mass=al.mp.Isothermal( + centre=(0.0, 0.0), + ell_comps=(0.111111, 0.0), + einstein_radius=1.6, + ), +) + +true_source = al.Galaxy( + redshift=1.0, + bulge=al.lp.Sersic( + centre=(0.0, 0.0), + ell_comps=(0.0, 0.15), + intensity=0.2, + effective_radius=0.1, + sersic_index=4.0, + ), +) + +true_tracer = al.Tracer( + galaxies=[lens_galaxy, true_source], +) + +simulator = al.SimulatorImaging( + exposure_time=300.0, + psf=psf, + background_sky_level=0.1, + add_poisson_noise_to_data=False, +) + +noiseless = simulator.via_tracer_from( + tracer=true_tracer, + grid=simulation_grid, +) + +noise_sigma = 0.05 +noise_values = np.random.default_rng(1).normal( + loc=0.0, + scale=noise_sigma, + size=simulation_grid.shape_native, +) + +dataset = al.Imaging( + data=al.Array2D.no_mask( + values=np.asarray(noiseless.data.native) + noise_values, + pixel_scales=0.05, + ), + noise_map=al.Array2D.no_mask( + values=np.full(simulation_grid.shape_native, noise_sigma), + pixel_scales=0.05, + ), + psf=psf, +) + +mask = al.Mask2D.circular( + shape_native=simulation_grid.shape_native, + pixel_scales=0.05, + radius=3.0, +) + +dataset = dataset.apply_mask(mask=mask) +dataset = dataset.apply_over_sampling( + over_sample_size_pixelization=2, +) + +aplt.subplot_imaging_dataset(dataset=dataset) + +""" +Initially, we keep the true mass model fixed. This lets us inspect how changing the source mesh and regularization affects the reconstruction before combining them with a non-linear mass-model search. + +We know the simulated source profile, but we will not use its brightness distribution to tell the adaptive mesh where to place its points. Brightness adaptation will use a fitted model image, as it would for observed data. + +--- +""" + +""" +__From an image mesh to a source mesh__ + +Previously, we constructed a rectangular mesh directly in the source plane. + +For our Delaunay reconstruction, we instead begin by selecting coordinates in the **image plane**. These coordinates form the image mesh. Ray-tracing them through the lens model gives the points from which we construct the source mesh. + +The simplest example is an `Overlay`, which lays a coarse, evenly spaced grid over the image and retains the points inside the mask. +""" + +image_mesh = al.image_mesh.Overlay( + shape=(25, 25), +) + +image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from( + mask=dataset.mask, +) + +print( + "Number of image-mesh points inside the mask:", + image_plane_mesh_grid.shape[0], +) + +aplt.plot_array( + array=dataset.data, + grid=image_plane_mesh_grid, + title="Image-Plane Mesh: Uniform Overlay", +) + +""" +These are mesh coordinates, rather than a selection of the only image pixels we will fit. All unmasked data pixels still contribute to the reconstruction. + +The overlay’s shape describes the grid before masking. Its retained number of points is therefore smaller than `25 × 25` for our circular mask. + +Now trace those mesh coordinates into the source plane. +""" + +traced_mesh_grid = true_tracer.traced_grid_2d_list_from( + grid=image_plane_mesh_grid +)[1] + +aaplt.plot_grid( + grid=traced_mesh_grid, + title="Image-Mesh Points Traced into the Source Plane", +) + +""" +The evenly spaced image-plane points are no longer evenly spaced after ray tracing. + +In highly magnified regions, a given source-plane area corresponds to a larger image-plane area. An evenly sampled image mesh therefore tends to provide more traced points per unit source-plane area there. The sampling can also be stretched differently in different directions. + +This lets the source mesh respond to the lensing geometry. However, the overlay does not inspect the image brightness: changing our source from extended to compact would leave these mesh coordinates unchanged for a fixed mask and mass model. + +That is why magnification-based sampling is useful but does not completely address the problem from the previous tutorial. + +Next, we will connect the traced points into a Delaunay triangulation and use it to reconstruct the source. Border relocation will remain enabled when constructing the fit; the plot above shows the direct ray tracing before that treatment. +""" + +""" +__Constructing the Delaunay source mesh__ + +We now connect the traced mesh points into a **Delaunay triangulation**. + +Unlike a rectangular mesh, the triangles can have different sizes and orientations. Their vertices follow the distribution of the ray-traced image-mesh points, allowing finer sampling in some source regions than others. + +The source brightness values are assigned to the **vertices**, rather than one constant brightness being assigned to each triangle. We will examine how brightness is calculated between the vertices in the next block. + +The helper below constructs a Delaunay reconstruction using our image-plane overlay. It also lets us vary the regularization coefficient. +""" + +dataset = dataset.apply_sparse_operator() + + +def fit_delaunay_overlay(coefficient): + pixelization = al.Pixelization( + mesh=al.mesh.Delaunay( + pixels=image_plane_mesh_grid.shape[0], + ), + regularization=al.reg.Constant( + coefficient=coefficient, + ), + ) + + source_galaxy = al.Galaxy( + redshift=1.0, + pixelization=pixelization, + ) + + tracer = al.Tracer( + galaxies=[lens_galaxy, source_galaxy], + ) + + adapt_images = al.AdaptImages( + galaxy_image_plane_mesh_grid_dict={ + source_galaxy: image_plane_mesh_grid, + }, + ) + + return al.FitImaging( + dataset=dataset, + tracer=tracer, + adapt_images=adapt_images, + settings=al.Settings(use_border_relocator=True), + ) + + +overlay_fit = fit_delaunay_overlay(coefficient=1.0) +overlay_mapper = overlay_fit.inversion.cls_list_from(al.Mapper)[0] + +aaplt.plot_mapper( + mapper=overlay_mapper, + solution_vector=overlay_fit.inversion.reconstruction, + mesh_grid=overlay_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Delaunay Reconstruction from an Image-Plane Overlay", +) + +aplt.subplot_fit_imaging(fit=overlay_fit) + +""" +`AdaptImages` supplies the image-mesh coordinates associated with the pixelized source galaxy. Here, we are supplying an overlay; we have not yet introduced brightness adaptation. + +Inspect the mesh points around the reconstructed source. Their spacing follows the lens mapping rather than a regular source-plane lattice. + +The coefficient of `1.0` is only a starting value. We should not judge whether this mesh improves the fit until we have explored its regularization. + +--- +""" + +""" +__Interpolating within a triangle__ + +How does a triangular mesh predict the brightness at a ray-traced coordinate? + +Recall our rectangular mesh: bilinear interpolation combined the brightness values at four surrounding mesh points. + +For a Delaunay mesh, we locate the triangle containing the coordinate and interpolate between its **three vertices**. Each vertex contributes according to a weight determined by the coordinate’s position within the triangle. + +At a vertex, that vertex supplies the brightness. At the midpoint of an edge, the two edge vertices contribute equally. At the triangle’s centroid, all three vertices contribute equally. + +The brightness therefore varies continuously across each triangle. It is not a collection of uniformly bright triangular patches. + +We can inspect the mapping weights for an image pixel, using the same approach as tutorial 1. +""" + +image_index = int( + np.argmax(np.asarray(dataset.data.slim)) +) + +mapping_weights = np.asarray( + overlay_mapper.mapping_matrix[image_index] +) + +source_indexes = np.flatnonzero( + np.abs(mapping_weights) > 1.0e-12 +) + +reconstruction = np.asarray( + overlay_fit.inversion.reconstruction +) + +for source_index in source_indexes: + weight = mapping_weights[source_index] + brightness = reconstruction[source_index] + + print( + f"Source vertex {source_index}: " + f"weight={weight:.5g}, " + f"brightness={brightness:.5g}, " + f"contribution={weight * brightness:.5g}" + ) + +print( + "Predicted image-pixel brightness before PSF blurring:", + float(np.dot(mapping_weights, reconstruction)), +) + +""" +We have enabled image-pixel oversampling. Each sub-pixel sample is interpolated within its triangle, and those contributions are combined for the image pixel. Consequently, the printed row can contain contributions from more than three source vertices if its samples fall in different triangles. + +The PSF then redistributes the predicted light between image pixels, as in our rectangular reconstruction. + +Interpolation still does not determine the vertex brightnesses. Those are solved by the inversion, with regularization controlling how freely they can vary. + +--- +""" + +""" +__Regularization on an irregular mesh__ + +We begin with the same `Constant` regularization scheme used earlier. + +For the rectangular mesh, an interior point had four immediate neighbours. On a Delaunay mesh, neighbours are connected by triangle edges. The number of neighbours can vary from one vertex to another; belonging to a triangular mesh does not mean every vertex has only three neighbours. + +Regularization penalizes brightness differences between connected vertices. One coefficient controls the strength of this smoothing across the mesh. + +The neighbour connections and spacings differ from the rectangular case, so we should not assume that its preferred coefficient transfers to the Delaunay mesh. + +Let’s evaluate a range of coefficients. +""" + +overlay_coefficients = np.logspace(-3, 3, 13) +overlay_evidences = [] +best_evidence = -np.inf + +# Keep only the preferred full fit; other scan entries are scalar evidences. +for coefficient in overlay_coefficients: + coefficient = float(coefficient) + current_fit = fit_delaunay_overlay(coefficient=coefficient) + evidence = float(current_fit.log_evidence) + overlay_evidences.append(evidence) + if evidence > best_evidence: + best_evidence = evidence + overlay_fit = current_fit + +overlay_evidences = np.asarray(overlay_evidences) +if not np.all(np.isfinite(overlay_evidences)): + raise ValueError("Inspect non-finite evidence values before continuing.") + +best_index = int(np.argmax(overlay_evidences)) +if best_index in (0, len(overlay_coefficients) - 1): + raise ValueError("Extend the coefficient range to bracket the overlay peak.") + +overlay_coefficient = float(overlay_coefficients[best_index]) + +fig, ax = plt.subplots() +ax.semilogx( + overlay_coefficients, + overlay_evidences - np.max(overlay_evidences), + marker="o", +) +ax.set_xlabel("Regularization coefficient") +ax.set_ylabel("Log evidence relative to the highest sampled value") +ax.set_title("Delaunay Overlay: Constant Regularization") +plt.show() + +print("Preferred sampled coefficient:", overlay_coefficient) + +aplt.subplot_fit_imaging(fit=overlay_fit) +aplt.subplot_fit_imaging_of_planes(fit=overlay_fit) + +""" +As before, inspect the peak and refine the coefficient sampling around it before making a close quantitative comparison. + +Now examine the source centre and bright arc residuals. Has the overlay placed enough vertices across the compact component? Does the preferred smoothing reproduce its steep brightness gradient? + +We have changed the mesh geometry, but two limitations remain. The overlay does not respond to the source brightness, and one coefficient still controls regularization everywhere. + +Next, we will use a fitted image to guide where the image-mesh points are placed, concentrating more sampling on the source emission. +""" + +""" +__Building an adapt image__ + +Our overlay responds to the lensing geometry, but it does not distinguish bright arcs from regions containing only background noise. + +To direct more mesh points towards the source emission, we need an image describing where that emission is located. We call this the **adapt image**. + +We use the model image from our initial reconstruction. Unlike the observed image, it does not contain the individual noise fluctuations that the model leaves unexplained. It provides a fitted estimate of where the lensed source light lies. +""" + +adapt_image = overlay_fit.model_data + +aplt.plot_array( + array=overlay_fit.model_data, + title="Adapt Image: Fitted Lensed Source Light", +) + +aplt.subplot_fit_imaging(fit=overlay_fit) + +""" +Compare the adapt image with the data and residuals. Does it locate the bright arcs correctly, even if it does not yet reproduce their sharpest features? + +The initial reconstruction need not be perfect to provide useful guidance. However, if it misses a source component entirely, the adapt image may allocate too little resolution to that component. We should therefore inspect the starting fit before using it to guide the next mesh. + +Our dataset contains no foreground lens light, so the complete model image represents the lensed source. When foreground light is present, we must use the model contribution from the **source galaxy**, rather than allowing the foreground galaxy’s bright centre to determine where source-mesh points are placed. + +We will keep this adapt image fixed while comparing mesh settings. It guides the placement of points; it does not fix the brightness values reconstructed on the new mesh. + +--- +""" + +""" +__Placing points with a Hilbert image mesh__ + +We now replace the evenly spaced overlay with a **Hilbert image mesh**. + +The Hilbert approach orders locations along a space-filling path through the image. Brightness-derived weights determine how points are distributed along that path, allocating more sampling to regions with larger weights. + +The path itself is not the source mesh. The resulting image-plane coordinates are ray-traced and connected into a Delaunay mesh, just as our overlay coordinates were. + +Let’s construct a brightness-weighted image mesh using the same number of points as our retained overlay. This allows us to examine a change in their distribution without deliberately increasing their total number. +""" + +hilbert_pixels = image_plane_mesh_grid.shape[0] + +hilbert_image_mesh = al.image_mesh.Hilbert( + pixels=hilbert_pixels, + weight_power=1.0, + weight_floor=0.05, +) + +hilbert_image_plane_mesh_grid = ( + hilbert_image_mesh.image_plane_mesh_grid_from( + mask=dataset.mask, + adapt_data=adapt_image, + ) +) + +for label, mesh_grid in [ + ("Uniform Overlay", image_plane_mesh_grid), + ("Brightness-Weighted Hilbert", hilbert_image_plane_mesh_grid), +]: + print(f"{label}: {mesh_grid.shape[0]} image-mesh points") + + aplt.plot_array( + array=dataset.data, + grid=mesh_grid, + title=label, + ) + +""" +Compare the spacing of the points across the bright arcs and the surrounding background. + +The overlay distributes its points evenly across the image mask. The Hilbert mesh uses the adapt image to concentrate more points on the emission, while the non-zero weight floor retains some sampling in faint regions. + +Now trace the Hilbert points into the source plane. +""" + +hilbert_traced_mesh_grid = true_tracer.traced_grid_2d_list_from( + grid=hilbert_image_plane_mesh_grid +)[1] + +aaplt.plot_grid( + grid=hilbert_traced_mesh_grid, + title="Brightness-Weighted Points Traced into the Source Plane", +) + +""" +The point distribution now responds to both the image brightness and the lens mapping. + +This addresses the distinction raised in **Fit Problems**: the magnification field remains unchanged when the source becomes more compact, but the brightness distribution changes. A brightness-weighted image mesh can respond by placing more points across the compact emission. + +Next, let’s examine how its weights are calculated. + +--- +""" + +""" +__Controlling the brightness weighting__ + +Two parameters control how the adapt image becomes a weight map: `weight_power` and `weight_floor`. + +The calculation first takes the absolute adapt-image values and divides them by their maximum. For our positive model image, this gives normalized brightnesses between zero and one. + +It then raises those values to `weight_power` and replaces any weight below `weight_floor` with that minimum value. + +Consider three powers: + +- `0.0` gives equal weights, removing the brightness preference. +- `1.0` retains the normalized brightness weighting. +- A value greater than `1.0` increases the contrast between bright and faint regions. + +For example, a normalized brightness of `0.5` becomes `0.03125` when raised to the fifth power, whereas the brightest value remains `1.0`. Larger powers therefore concentrate the sampling more strongly on the brightest emission. + +Let’s inspect the weight maps and resulting point distributions together. +""" + +for weight_power in [0.0, 1.0, 5.0]: + trial_image_mesh = al.image_mesh.Hilbert( + pixels=hilbert_pixels, + weight_power=weight_power, + weight_floor=0.05, + ) + + weights = trial_image_mesh.weight_map_from( + adapt_data=adapt_image, + ) + + weight_map = al.Array2D( + values=weights, + mask=dataset.mask, + ) + + trial_mesh_grid = trial_image_mesh.image_plane_mesh_grid_from( + mask=dataset.mask, + adapt_data=adapt_image, + ) + + aplt.plot_array( + array=weight_map, + vmin=0.0, + vmax=1.0, + title=f"Weight Map: Power {weight_power:g}", + ) + + aplt.plot_array( + array=dataset.data, + grid=trial_mesh_grid, + title=f"Image-Mesh Points: Power {weight_power:g}", + ) + +""" +Follow how increasing the power changes the allocation of points. Does the mesh resolve the brightest parts more densely? How much sampling remains across the fainter emission? + +A higher power is not automatically better. Concentrating too many points on the brightest peaks can leave too few to describe the rest of the source. + +The weight floor helps moderate this effect. Here, every location has a weight of at least `0.05`. This is a minimum weight, not a statement that five per cent of the mesh points must lie in the background. + +Next, we will vary that floor and reconstruct the source with the Hilbert mesh, checking the residuals and evidence rather than judging the mesh by its appearance alone. +""" + +""" +__Keeping resolution in the faint regions__ + +Increasing `weight_power` concentrates points on the brightest parts of the adapt image. However, the source may also have faint extended emission that needs to be reconstructed. + +The **weight floor** prevents those regions from receiving arbitrarily small weights. After applying the power, any weight below the floor is raised to that value. + +For example, with a floor of `0.05`, weights of `0.001` and `0.02` both become `0.05`, while a weight of `0.8` remains unchanged. + +Let’s compare three floors while keeping the power and number of mesh points fixed. +""" + +for weight_floor in [0.0, 0.05, 0.5]: + trial_image_mesh = al.image_mesh.Hilbert( + pixels=hilbert_pixels, + weight_power=5.0, + weight_floor=weight_floor, + ) + + weights = trial_image_mesh.weight_map_from( + adapt_data=adapt_image, + ) + + weight_map = al.Array2D( + values=weights, + mask=dataset.mask, + ) + + trial_mesh_grid = trial_image_mesh.image_plane_mesh_grid_from( + mask=dataset.mask, + adapt_data=adapt_image, + ) + + aplt.plot_array( + array=weight_map, + vmin=0.0, + vmax=1.0, + title=f"Weight Map: Floor {weight_floor:g}", + ) + + aplt.plot_array( + array=dataset.data, + grid=trial_mesh_grid, + title=f"Image-Mesh Points: Floor {weight_floor:g}", + ) + +""" +Compare the points around the brightest peaks, along the fainter arcs, and in the background. + +Increasing the floor reduces the contrast between their weights. This retains more sampling away from the brightest emission, but leaves fewer points available to concentrate on its peaks. + +Because the background can cover much more area than the arcs, even a modest floor can affect the allocation substantially. We should inspect the resulting mesh rather than interpret the floor as a fixed fraction of points assigned to faint regions. + +For our first reconstruction, we return to the mesh already constructed with `weight_power=1.0` and `weight_floor=0.05`. These are starting choices to investigate, rather than settings we have established as optimal. + +--- +""" + +""" +__Reconstructing with the Hilbert mesh__ + +We now use the Hilbert coordinates to build a Delaunay source mesh. + +The reconstruction proceeds as before: ray-trace the image-mesh points, construct the source triangulation, and solve for its vertex brightnesses. The difference is where we placed the image-mesh points. + +We retain `Constant` regularization for now, so that we can investigate brightness-based mesh placement before introducing adaptive smoothing. +""" + +def fit_delaunay_hilbert(coefficient): + pixelization = al.Pixelization( + mesh=al.mesh.Delaunay( + pixels=hilbert_image_plane_mesh_grid.shape[0], + ), + regularization=al.reg.Constant( + coefficient=coefficient, + ), + ) + + source_galaxy = al.Galaxy( + redshift=1.0, + pixelization=pixelization, + ) + + tracer = al.Tracer( + galaxies=[lens_galaxy, source_galaxy], + ) + + adapt_images = al.AdaptImages( + galaxy_image_dict={ + source_galaxy: adapt_image, + }, + galaxy_image_plane_mesh_grid_dict={ + source_galaxy: hilbert_image_plane_mesh_grid, + }, + ) + + return al.FitImaging( + dataset=dataset, + tracer=tracer, + adapt_images=adapt_images, + settings=al.Settings(use_border_relocator=True), + ) + + +hilbert_fit = fit_delaunay_hilbert( + coefficient=overlay_coefficient, +) + +hilbert_mapper = hilbert_fit.inversion.cls_list_from(al.Mapper)[0] + +aaplt.plot_mapper( + mapper=hilbert_mapper, + solution_vector=hilbert_fit.inversion.reconstruction, + mesh_grid=hilbert_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Hilbert Mesh: Initial Source Reconstruction", +) + +aplt.subplot_fit_imaging(fit=hilbert_fit) + +""" +Inspect the mesh around the compact source centre. Has the brightness weighting placed more vertices across the region where its brightness changes rapidly? + +Then inspect the residuals around the bright arcs. Additional resolution gives the reconstruction more freedom to represent their structure, but regularization still determines how freely neighbouring brightnesses can differ. + +We have initially reused the overlay’s preferred coefficient. As tutorial 2 showed, changing the mesh can change the preferred smoothing strength. We therefore need to explore regularization again before comparing the two reconstructions. + +--- +""" + +""" +__Comparing overlay and Hilbert reconstructions__ + +Let’s scan the regularization coefficient for the Hilbert mesh, using the same range as the overlay. + +The adapt image and Hilbert mesh coordinates remain fixed during this scan. Each coefficient produces a new reconstruction on that mesh. +""" + +hilbert_evidences = [] +best_evidence = -np.inf + +# Keep only the preferred full fit; other scan entries are scalar evidences. +for coefficient in overlay_coefficients: + coefficient = float(coefficient) + current_fit = fit_delaunay_hilbert(coefficient=coefficient) + evidence = float(current_fit.log_evidence) + hilbert_evidences.append(evidence) + if evidence > best_evidence: + best_evidence = evidence + hilbert_fit = current_fit + +hilbert_evidences = np.asarray(hilbert_evidences) +if not np.all(np.isfinite(hilbert_evidences)): + raise ValueError("Inspect non-finite evidence values before continuing.") + +best_index = int(np.argmax(hilbert_evidences)) +if best_index in (0, len(overlay_coefficients) - 1): + raise ValueError("Extend the coefficient range to bracket the hilbert peak.") + +hilbert_coefficient = float(overlay_coefficients[best_index]) + +reference_evidence = max( + float(np.max(overlay_evidences)), + float(np.max(hilbert_evidences)), +) + +fig, ax = plt.subplots() + +for label, values in [ + ("Overlay", overlay_evidences), + ("Hilbert", hilbert_evidences), +]: + ax.semilogx( + overlay_coefficients, + values - reference_evidence, + marker="o", + label=label, + ) + +ax.set_xlabel("Regularization coefficient") +ax.set_ylabel("Log evidence relative to the highest sampled value") +ax.legend() +plt.show() + +""" +Both curves use the same reference value, preserving the difference between their peaks. Compare their preferred coefficients as well as their peak heights. + +We can now inspect each mesh at its own preferred sampled coefficient. +""" + +for label, current_fit in [ + ("Overlay", overlay_fit), + ("Hilbert", hilbert_fit), +]: + print(f"{label}: log evidence={float(current_fit.log_evidence):.3f}") + + aplt.subplot_fit_imaging(fit=current_fit) + aplt.subplot_fit_imaging_of_planes(fit=current_fit) + +print( + "Hilbert minus overlay log evidence:", + float(hilbert_fit.log_evidence - overlay_fit.log_evidence), +) + +""" +A positive difference favours this Hilbert reconstruction over this overlay reconstruction under the comparison we have made. A negative difference favours the overlay. Refine the coefficient sampling around both peaks before interpreting a small difference. + +Connect the evidence comparison to the residuals. Does the brightness-adaptive mesh better reproduce the compact emission? Are the fainter regions still adequately described? + +This tests one Hilbert weighting choice, using a fixed adapt image. It does not establish that every Hilbert mesh outperforms every overlay. + +We have now addressed where the source points are placed. Next, we will address how to regularize their brightnesses on an irregular mesh, before allowing the smoothing strength to vary between bright and faint regions. +""" + +""" +__Smoothing on an irregular mesh__ + +Our Delaunay mesh has vertices with different neighbour counts, edge lengths and directions. + +With neighbour-based regularization, we penalize brightness differences along those edges. Consequently, the pattern of comparisons changes with the triangulation: one vertex might have several nearby neighbours, while another has fewer neighbours at larger distances. + +**Split regularization** provides another way to measure local brightness variations. + +Around each source vertex, it defines four nearby sampling positions arranged in a cross. It interpolates the reconstructed brightness at those positions and compares them with the brightness at the central vertex. The size of the cross follows the local mesh scale. + +This gives each vertex a common pattern of local comparisons, instead of relying only on its particular set of connected neighbours. + +The additional sampling positions are not new source brightness parameters. Their brightnesses are interpolated from the existing reconstruction. We therefore change how smoothing is applied without increasing the number of brightness values solved by the inversion. + +It is also important to distinguish **Split** from **adaptive** regularization: + +- `ConstantSplit` uses the cross-based comparisons with one coefficient across the mesh. +- Adaptive Split regularization uses those comparisons while allowing the smoothing strength to vary across the source. + +We will first examine `ConstantSplit`, then introduce the brightness-dependent coefficients. + +--- +""" + +""" +__Reconstructing with ConstantSplit__ + +Let’s keep the Hilbert mesh and adapt image fixed and change the regularization scheme. + +We generalize our fitting helper so that it accepts a regularization object. This will let us use the same reconstruction setup for both constant and adaptive Split regularization. +""" + +def fit_hilbert_with_regularization(regularization): + pixelization = al.Pixelization( + mesh=al.mesh.Delaunay( + pixels=hilbert_image_plane_mesh_grid.shape[0], + ), + regularization=regularization, + ) + + source_galaxy = al.Galaxy( + redshift=1.0, + pixelization=pixelization, + ) + + tracer = al.Tracer( + galaxies=[lens_galaxy, source_galaxy], + ) + + adapt_images = al.AdaptImages( + galaxy_image_dict={ + source_galaxy: adapt_image, + }, + galaxy_image_plane_mesh_grid_dict={ + source_galaxy: hilbert_image_plane_mesh_grid, + }, + ) + + return al.FitImaging( + dataset=dataset, + tracer=tracer, + adapt_images=adapt_images, + settings=al.Settings(use_border_relocator=True), + ) + +""" +The coefficient preferred by neighbour-based regularization need not be preferred by Split regularization. We therefore repeat the coefficient scan. +""" + +split_evidences = [] +best_evidence = -np.inf + +# Keep only the preferred full fit; other scan entries are scalar evidences. +for coefficient in overlay_coefficients: + coefficient = float(coefficient) + current_fit = fit_hilbert_with_regularization(regularization=al.reg.ConstantSplit(coefficient=coefficient)) + evidence = float(current_fit.log_evidence) + split_evidences.append(evidence) + if evidence > best_evidence: + best_evidence = evidence + split_fit = current_fit + +split_evidences = np.asarray(split_evidences) +if not np.all(np.isfinite(split_evidences)): + raise ValueError("Inspect non-finite evidence values before continuing.") + +best_index = int(np.argmax(split_evidences)) +if best_index in (0, len(overlay_coefficients) - 1): + raise ValueError("Extend the coefficient range to bracket the split peak.") + +split_coefficient = float(overlay_coefficients[best_index]) + +print("Preferred sampled Split coefficient:", split_coefficient) + +aplt.subplot_fit_imaging(fit=split_fit) +aplt.subplot_fit_imaging_of_planes(fit=split_fit) + +print( + "ConstantSplit minus Constant log evidence:", + float(split_fit.log_evidence - hilbert_fit.log_evidence), +) + +""" +Compare the source and residuals with the previous Hilbert reconstruction. We have kept the mesh fixed, so the changes arise from the regularization scheme and its selected coefficient. + +As before, refine the coefficient sampling around the peaks before interpreting small evidence differences. + +Split changes how local brightness variations are penalized, but `ConstantSplit` still applies one coefficient everywhere. It therefore retains the centre-versus-outskirts compromise identified in **Fit Problems**. + +--- +""" + +""" +__Allowing different smoothing strengths__ + +We now let the regularization strength depend on the source brightness. + +The adapt image supplies a brightness-based signal estimate for each source vertex through its mappings to the image. These signals are normalized so that the highest has a value of one. + +We use that information to transition between two coefficients: + +- `inner_coefficient` controls smoothing in high-signal regions. +- `outer_coefficient` controls smoothing in low-signal regions. + +Despite their names, these coefficients are not assigned using distance from the source centre. A bright off-centre component can receive the high-signal coefficient too. + +For our compact source, we want to explore weaker smoothing in the bright region and stronger smoothing in the faint outskirts. The `signal_scale` parameter controls how the normalized signal determines the transition between them. + +We use `AdaptSplitPower` with `power=1.0`, which keeps the coefficient convention consistent with `ConstantSplit`. Setting its inner and outer coefficients equal then recovers the constant-strength Split scheme. + +Let’s first try a deliberately contrasting pair of coefficients to make the spatial variation visible. +""" + +adaptive_regularization = al.reg.AdaptSplitPower( + inner_coefficient=split_coefficient / 10.0, + outer_coefficient=split_coefficient * 10.0, + signal_scale=1.0, + power=1.0, +) + +adaptive_fit = fit_hilbert_with_regularization( + regularization=adaptive_regularization, +) + +adaptive_mapper = adaptive_fit.inversion.cls_list_from(al.Mapper)[0] + +adaptive_weights = ( + adaptive_mapper.regularization.regularization_weights_from( + linear_obj=adaptive_mapper, + ) +) + +aaplt.plot_inversion_reconstruction( + mapper=adaptive_mapper, + pixel_values=adaptive_weights, + grid=adaptive_mapper.source_plane_mesh_grid, + cb_unit="", + zoom_to_brightest=False, + title="Adaptive Split: Regularization Weights", +) + +aaplt.plot_mapper( + mapper=adaptive_mapper, + solution_vector=adaptive_fit.inversion.reconstruction, + mesh_grid=adaptive_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Adaptive Split: Source Reconstruction", +) + +aplt.subplot_fit_imaging(fit=adaptive_fit) + +""" +Compare the regularization-weight map with the reconstructed source. Smaller weights should occur where the adapt image indicates stronger source emission, allowing greater local brightness variation. Larger weights encourage more smoothing in low-signal regions. + +These weights control the regularization penalty; they are not uncertainties on the reconstructed brightnesses. + +The trial coefficients were chosen to illustrate the mechanism. We have not yet established that they improve the fit or maximize the evidence. Next, we will examine the transition controlled by `signal_scale` and explore the adaptive coefficients systematically. +""" + +r""" +__Controlling the transition with `signal_scale`__ + +The inner and outer coefficients set the smoothing strengths at the high- and low-signal ends of the source. How do we assign a strength to the regions between them? + +We first raise each normalized source signal to `signal_scale`. With `power=1.0`, the regularization weight is then: + +\[ +w = c_{\mathrm{inner}}\,s + + c_{\mathrm{outer}}\,(1-s), +\] + +where \(s\) is the normalized signal **after** applying `signal_scale`. + +At \(s=1\), the weight equals the inner coefficient. At \(s=0\), it equals the outer coefficient. Intermediate values blend the two. + +Let’s plot this relationship while keeping the coefficients fixed. +""" + +normalized_signal = np.linspace(0.0, 1.0, 200) + +inner_coefficient = split_coefficient / 10.0 +outer_coefficient = split_coefficient * 10.0 + +fig, ax = plt.subplots() + +for signal_scale in [0.5, 1.0, 3.0]: + scaled_signal = normalized_signal**signal_scale + + weights = ( + inner_coefficient * scaled_signal + + outer_coefficient * (1.0 - scaled_signal) + ) + + ax.plot( + normalized_signal, + weights, + label=f"signal_scale={signal_scale:g}", + ) + +ax.set_xlabel("Normalized source signal before scaling") +ax.set_ylabel("Regularization weight") +ax.legend() +plt.show() + +""" +For a normalized signal between zero and one, increasing `signal_scale` reduces its scaled value. In this example, that moves its weight towards the stronger outer coefficient. Only the highest-signal regions retain weights close to the weaker inner coefficient. + +Decreasing `signal_scale` has the opposite effect: more of the source receives weights closer to the inner coefficient. + +These weights are based on the fixed adapt image and its mappings. They are not recalculated from each newly reconstructed brightness distribution during this experiment. + +Also distinguish `signal_scale` from the Hilbert mesh’s `weight_power`. Both transform brightness-derived information, but they control different things: `weight_power` changes **where mesh points are placed**, whereas `signal_scale` changes **how their brightnesses are regularized**. + +--- +""" + +""" +__Exploring the adaptive coefficients__ + +We now have three regularization parameters: the inner coefficient, outer coefficient and signal scale. + +Let’s explore a small grid of their values, keeping the mass model, Hilbert mesh and adapt image fixed. This makes the experiment more expensive than our one-coefficient scans, so we retain only the evidence values and the best fit encountered. + +We centre the coefficient grid on the preferred `ConstantSplit` value. The grid includes equal inner and outer coefficients, allowing the adaptive scheme to recover constant-strength smoothing. + +We also allow either coefficient to be larger. Although weaker smoothing in bright regions motivates the scheme, we should inspect what the evidence actually prefers. +""" + +adaptive_coefficient_grid = ( + split_coefficient * np.logspace(-1, 1, 5) +) +signal_scales = [0.5, 1.0, 3.0] + +adaptive_trials = [] +best_adaptive_fit = None +best_adaptive_parameters = None +best_adaptive_evidence = -np.inf + +for inner_coefficient in adaptive_coefficient_grid: + for outer_coefficient in adaptive_coefficient_grid: + for signal_scale in signal_scales: + regularization = al.reg.AdaptSplitPower( + inner_coefficient=float(inner_coefficient), + outer_coefficient=float(outer_coefficient), + signal_scale=signal_scale, + power=1.0, + ) + + current_fit = fit_hilbert_with_regularization( + regularization=regularization, + ) + log_evidence = float(current_fit.log_evidence) + + if not np.isfinite(log_evidence): + raise ValueError( + "Inspect the non-finite adaptive evidence before continuing." + ) + + parameters = ( + float(inner_coefficient), + float(outer_coefficient), + signal_scale, + ) + + adaptive_trials.append((*parameters, log_evidence)) + + if log_evidence > best_adaptive_evidence: + best_adaptive_evidence = log_evidence + best_adaptive_parameters = parameters + best_adaptive_fit = current_fit + +best_inner, best_outer, best_signal_scale = best_adaptive_parameters + +print("Preferred sampled inner coefficient:", best_inner) +print("Preferred sampled outer coefficient:", best_outer) +print("Preferred sampled signal scale:", best_signal_scale) +print("Highest sampled log evidence:", best_adaptive_evidence) + +""" +This performs 75 inversions. It is an introductory exploration, rather than a precise optimization of all three parameters. + +Check whether a preferred coefficient lies at either end of its range. If so, extend that range before concluding that the optimum has been located. The three signal-scale values are also widely spaced and may require further exploration. + +If the preferred inner and outer coefficients are equal, changing `signal_scale` has no effect: the weight is constant everywhere. + +For a complete modelling analysis, these parameters can be explored by a non-linear search alongside the lens parameters. We will use that approach in the final fit. + +--- +""" + +""" +__Does adaptive regularization address the fit problem?__ + +Let’s inspect the highest-evidence adaptive reconstruction we have evaluated and compare it with `ConstantSplit`. + +We keep the mesh fixed between these fits. This isolates the change from one smoothing strength to brightness-dependent smoothing. +""" + +comparison_fits = [ + ("ConstantSplit", split_fit), + ("Adaptive Split", best_adaptive_fit), +] + +source_vmax = max( + float(np.max(np.asarray(current_fit.inversion.reconstruction))) + for _, current_fit in comparison_fits +) + +for label, current_fit in comparison_fits: + mapper = current_fit.inversion.cls_list_from(al.Mapper)[0] + + aaplt.plot_mapper( + mapper=mapper, + solution_vector=current_fit.inversion.reconstruction, + mesh_grid=mapper.source_plane_mesh_grid, + vmin=0.0, + vmax=source_vmax, + zoom_to_brightest=False, + title=f"{label}: Source Reconstruction", + ) + + aplt.subplot_fit_imaging(fit=current_fit) + +print( + "Adaptive minus constant Split log evidence:", + best_adaptive_evidence - float(split_fit.log_evidence), +) + +""" +Return to the two regions that motivated this experiment. + +At the bright centre, does the adaptive reconstruction preserve a sharper brightness peak and reduce the corresponding arc residuals? In the outskirts, does it suppress fluctuations without removing faint emission needed to explain the image? + +Inspect the preferred regularization weights alongside those changes. +""" + +best_adaptive_mapper = best_adaptive_fit.inversion.cls_list_from( + al.Mapper +)[0] + +best_adaptive_weights = ( + best_adaptive_mapper.regularization.regularization_weights_from( + linear_obj=best_adaptive_mapper, + ) +) + +aaplt.plot_inversion_reconstruction( + mapper=best_adaptive_mapper, + pixel_values=best_adaptive_weights, + grid=best_adaptive_mapper.source_plane_mesh_grid, + cb_unit="", + zoom_to_brightest=False, + title="Preferred Adaptive Regularization Weights", +) + +""" +A higher log evidence shows that this adaptive setting improves the reconstruction statistic for the fixed lens model and mesh. It is not, by itself, a model-comparison evidence marginalized over the additional regularization parameters. + +If substantial residuals remain, inspect where they occur. Adaptive regularization still cannot supply resolution missing from the mesh, and both adaptive steps depend on the initial adapt image. + +We have now explored the mesh placement and smoothing separately. Next, we will bring them together in an actual lens-model search, using an initial MGE fit, prior passing and the positions likelihood penalty. +""" + +""" +__Starting the final modelling chain__ + +So far, we have supplied the true lens mass model to isolate the effects of mesh placement and regularization. + +We will now fit the mass parameters as well. Following tutorial 4, we begin with an MGE source, then use its result to initialize the adaptive pixelized-source search. + +This first fit provides three things: an initial mass estimate, an image of the fitted source emission to guide adaptation, and the positions used by the positions likelihood penalty. + +We keep the mass centre fixed for this example, while allowing its Einstein radius and ellipticity to vary. +""" + +from pathlib import Path +import autofit as af + +initial_mass = af.Model(al.mp.Isothermal) +initial_mass.centre = (0.0, 0.0) +initial_mass.einstein_radius = af.UniformPrior( + lower_limit=0.5, + upper_limit=2.5, +) + +initial_lens = af.Model( + al.Galaxy, + redshift=0.5, + mass=initial_mass, +) + +initial_source = af.Model( + al.Galaxy, + redshift=1.0, + bulge=al.model_util.mge_model_from( + mask_radius=3.0, + total_gaussians=20, + centre_prior_is_uniform=False, + sigma_min=dataset.pixel_scales[0] / 10.0, + ), +) + +initial_model = af.Collection( + galaxies=af.Collection( + lens=initial_lens, + source=initial_source, + ), +) + +print(initial_model.info) + +initial_search = af.Nautilus( + path_prefix=Path("howtolens") / "chapter_3" / "tutorial_6", + name="search_1_mge", + unique_tag="very_compact", + n_live=100, +) + +initial_analysis = al.AnalysisImaging(dataset=dataset) + +initial_result = initial_search.fit( + model=initial_model, + analysis=initial_analysis, +) + +print(initial_result.info) + +initial_model_fit = initial_result.max_log_likelihood_fit + +aplt.subplot_fit_imaging(fit=initial_model_fit) +aplt.subplot_fit_imaging_of_planes(fit=initial_model_fit) + +""" +The narrow Gaussian components allow the MGE to describe the compact source centre. Their intensities are still solved linearly, while the search explores the shared source geometry and lens mass parameters. + +Inspect this result before continuing. Does it explain the multiple-image configuration, and does it recover a sensible compact source? We should not use a misleading initial solution to guide the adaptive fit. + +--- +""" + +""" +__Preparing adaptation and the positions likelihood penalty__ + +We now derive a new adapt image from the fitted MGE result. This replaces the image used in our fixed-mass experiments. + +For the modelling chain, we express the fitted source image in signal-to-noise units and cap it at `3.0`. This reduces the dominance of the very brightest peaks, retaining more weight for fainter detected emission. + +The cap affects only the image used to guide adaptation. It does not change the observed data, noise map or image likelihood. + +Our example contains no foreground light, so the full model image is the source contribution. +""" + +source_model_image = np.asarray( + initial_model_fit.model_data.slim +) + +source_model_snr = ( + source_model_image / np.asarray(dataset.noise_map.slim) +) + +search_adapt_image = al.Array2D( + values=np.clip(source_model_snr, a_min=0.0, a_max=3.0), + mask=dataset.mask, +) + +aplt.plot_array( + array=search_adapt_image, + title="Adapt Image for the Search: Capped Model Signal-to-Noise", +) + +""" +We construct the Hilbert image mesh from this image. Its image-plane coordinates remain fixed during the next search, while their source-plane positions change as the proposed mass model changes. +""" + +search_image_mesh = al.image_mesh.Hilbert( + pixels=hilbert_pixels, + weight_power=1.0, + weight_floor=0.05, +) + +search_image_plane_mesh_grid = ( + search_image_mesh.image_plane_mesh_grid_from( + mask=dataset.mask, + adapt_data=search_adapt_image, + ) +) + +search_adapt_images = al.AdaptImages( + galaxy_name_image_dict={ + "('galaxies', 'source')": search_adapt_image, + }, + galaxy_name_image_plane_mesh_grid_dict={ + "('galaxies', 'source')": search_image_plane_mesh_grid, + }, +) + +aplt.plot_array( + array=dataset.data, + grid=search_image_plane_mesh_grid, + title="Hilbert Image Mesh for the Model Search", +) + +""" +The dictionaries identify the source by its path in the model, `galaxies.source`. This lets the analysis associate the fixed adaptation inputs with each new source-galaxy instance proposed by the search. + +Following Chapter 2, Tutorial 5, we also prepare the **positions likelihood penalty** from the initial result. +""" + +search_positions_likelihood = initial_result.positions_likelihood_from( + factor=3.0, + minimum_threshold=0.2, +) + +aplt.plot_array( + array=dataset.data, + positions=search_positions_likelihood.positions, + title="Positions for the Pixelized-Source Search", +) + +print( + "Source-plane separation threshold:", + search_positions_likelihood.threshold, + "arcsec", +) + +""" +Inspect both the mesh placement and predicted image positions. They should follow the source emission and multiple-image configuration before we use them in the next fit. + +--- +""" + +""" +__Defining the adaptive Delaunay model__ + +We now replace the MGE source with a Delaunay pixelization and adaptive Split regularization. + +The mass parameters remain free, with priors passed from the initial result. The mesh’s image-plane coordinates and total number of vertices are fixed, but ray tracing changes its source-plane geometry for every proposed mass model. + +We also make the inner coefficient, outer coefficient and signal scale free parameters. The search can therefore adjust the smoothing pattern as it refines the mass model. +""" + +search_regularization = af.Model(al.reg.AdaptSplitPower) + +search_regularization.inner_coefficient = af.LogUniformPrior( + lower_limit=1.0e-3, + upper_limit=1.0e3, +) + +search_regularization.outer_coefficient = af.LogUniformPrior( + lower_limit=1.0e-3, + upper_limit=1.0e3, +) + +search_regularization.signal_scale = af.UniformPrior( + lower_limit=0.1, + upper_limit=5.0, +) + +search_regularization.power = 1.0 + +search_pixelization = af.Model( + al.Pixelization, + mesh=al.mesh.Delaunay( + pixels=search_image_plane_mesh_grid.shape[0], + ), + regularization=search_regularization, +) + +search_source = af.Model( + al.Galaxy, + redshift=1.0, + pixelization=search_pixelization, +) + +adaptive_model = af.Collection( + galaxies=af.Collection( + lens=initial_result.model.galaxies.lens, + source=search_source, + ), +) + +print(adaptive_model.info) + +""" +Inspect the model summary. The source brightness values are still absent from the non-linear parameter list: the inversion solves them for each proposal. + +We have not forced the inner coefficient to be smaller than the outer coefficient. That ordering motivated our experiment, but the search is allowed to determine which values the data favour. + +The `power` setting remains fixed at `1.0`; it specifies the coefficient convention rather than providing another degree of freedom in this tutorial. + +The regularization priors are starting ranges. After fitting, we will check whether the inferred parameters approach their boundaries and whether broader ranges or further exploration are needed. + +We are now ready to run the adaptive Delaunay fit and inspect the resulting mass model, source reconstruction and smoothing pattern. +""" + +""" +__Running the adaptive Delaunay fit__ + +The second search combines our Delaunay model, adaptation inputs and positions likelihood penalty. + +For each proposed mass model, the image-mesh coordinates are ray-traced into the source plane. The inversion reconstructs the source using the proposed adaptive regularization parameters, and the analysis evaluates the resulting fit. + +The positions likelihood penalty discourages proposals that trace the selected corresponding image positions too far apart. +""" + +adaptive_analysis = al.AnalysisImaging( + dataset=dataset, + adapt_images=search_adapt_images, + positions_likelihood_list=[search_positions_likelihood], + settings=al.Settings(use_border_relocator=True), +) + +adaptive_search = af.Nautilus( + path_prefix=Path("howtolens") / "chapter_3" / "tutorial_6", + name="search_2_adaptive_delaunay", + unique_tag="very_compact", + n_live=100, +) + +adaptive_result = adaptive_search.fit( + model=adaptive_model, + analysis=adaptive_analysis, +) + +print(adaptive_result.info) + +final_fit = adaptive_result.max_log_likelihood_fit + +aplt.subplot_fit_imaging(fit=final_fit) +aplt.subplot_fit_imaging_of_planes(fit=final_fit) + +""" +This is a full non-linear search, rather than the fixed-mass reconstructions we evaluated earlier. It must explore combinations of mass and regularization parameters, so it will take longer. + +Inspect the image residuals first. Are there coherent patches around the bright arcs? Has the reconstruction captured the compact source without introducing conspicuous fluctuations elsewhere? + +Then examine the parameter distributions. A satisfactory-looking best fit does not establish that the search has explored the relevant solutions or measured reliable uncertainties. Check for parameters approaching prior boundaries and use the search diagnostics introduced in Chapter 2. + +--- +""" + +""" +__Inspecting the source and smoothing pattern__ + +Let’s look more closely at where the final mesh places its vertices and how strongly those vertices are regularized. +""" + +final_mapper = final_fit.inversion.cls_list_from(al.Mapper)[0] + +final_regularization_weights = ( + final_mapper.regularization.regularization_weights_from( + linear_obj=final_mapper, + ) +) + +aaplt.plot_mapper( + mapper=final_mapper, + solution_vector=final_fit.inversion.reconstruction, + mesh_grid=final_mapper.source_plane_mesh_grid, + zoom_to_brightest=False, + title="Final Adaptive Delaunay Source", +) + +aaplt.plot_inversion_reconstruction( + mapper=final_mapper, + pixel_values=final_regularization_weights, + grid=final_mapper.source_plane_mesh_grid, + cb_unit="", + zoom_to_brightest=False, + title="Final Regularization Weights", +) + +""" +Return to the two problems from **Fit Problems**. + +First, does the mesh place enough vertices across the compact source centre to describe its steep brightness gradient? + +Second, does the inferred regularization allow the centre to vary sharply while smoothing the faint regions appropriately? Inspect the fitted inner and outer coefficients rather than assuming that the search must prefer the ordering we anticipated. + +We can also check the positions likelihood penalty for the displayed model. +""" + +final_positions = final_fit.tracer.traced_grid_2d_list_from( + grid=search_positions_likelihood.positions +)[1] + +position_coordinates = np.asarray(final_positions) + +position_separations = np.linalg.norm( + position_coordinates[:, None, :] + - position_coordinates[None, :, :], + axis=-1, +) + +maximum_separation = float(np.max(position_separations)) + +print( + "Maximum source-plane position separation:", + maximum_separation, + "arcsec", +) +print( + "Positions threshold:", + search_positions_likelihood.threshold, + "arcsec", +) +print( + "Within threshold:", + maximum_separation <= search_positions_likelihood.threshold, +) + +""" +For this simulation, the true Einstein radius is `1.6` arcsec. Compare it with the inferred value and uncertainty in the result summary. In observed data, that answer would not be available, making the residuals, parameter constraints and search behaviour especially important. + +Finally, compare the initial MGE fit with the adaptive pixelized fit. +""" + +for label, current_fit in [ + ("Initial MGE", initial_model_fit), + ("Adaptive Delaunay", final_fit), +]: + print(label) + aplt.subplot_fit_imaging(fit=current_fit) + aplt.subplot_fit_imaging_of_planes(fit=current_fit) + +""" +Our simulated source is a smooth Sérsic profile, which an MGE can describe well. The adaptive pixelization need not outperform that analytic model dramatically. Here, we are demonstrating how to fit a compact source with a pixelization while allowing the lens mass to vary. + +The additional spatial freedom becomes particularly useful for irregular sources whose structure is difficult to describe using a prescribed arrangement of analytic components. + +--- +""" + +""" +__Wrap Up__ + +We began this chapter by replacing an analytic source profile with brightness values on a mesh. + +That gave the source more spatial freedom, but it also introduced choices that affect the reconstruction: where to place the mesh points, how to interpolate between them, and how strongly to regularize their brightnesses. + +In **Fit Problems**, we saw why selecting a constant regularization coefficient is not always enough. A compact source may require more resolution around its bright centre and different smoothing strengths across its brightness distribution. + +This tutorial addressed those limitations in stages. + +An image-plane overlay gave us source sampling that responds to the lens magnification. A Hilbert image mesh then used a fitted brightness distribution to concentrate more sampling on the source emission. Delaunay triangulation connected the traced points and provided interpolation between their brightness values. + +Split regularization supplied local smoothing comparisons on that irregular mesh. Adaptive Split regularization allowed the strength of those comparisons to vary with the source signal. + +We then brought these methods into a lens-model search, using an initial MGE fit, prior passing and the positions likelihood penalty to help avoid unphysical mass-and-source solutions. + +Delaunay meshes with brightness adaptation are a useful choice when the source needs spatially varying resolution. Their flexibility does not remove the need to inspect the fit. An inaccurate adapt image, insufficient mesh resolution or poorly explored regularization parameters can still leave a misleading reconstruction. + +When assessing a result, connect three views: + +- **The image residuals:** what observed structure remains unexplained? +- **The source mesh and reconstruction:** where is resolution available, and what brightness structure is being inferred? +- **The mass and regularization constraints:** what parameters does the search favour, and how well has it explored them? + +MGE models remain valuable for initial fits, foreground light and sources well described by analytic components. Pixelizations complement them when we need greater freedom to reconstruct the source. + +We now have the tools to use both together—and to investigate why a reconstruction succeeds or struggles, rather than relying only on how its source plot looks. +""" + +""" +**🤖 Ask AI:** How do Hilbert mesh weighting and adaptive Split regularization address different limitations of a compact-source reconstruction? + +__Env__ + +Keep full-size datasets during automated runs so compact sources remain resolved +and the evidence scans test the same examples as the lesson. + +ENV: full_datasets +""" diff --git a/scripts/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.py b/scripts/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.py deleted file mode 100644 index 1197ce4..0000000 --- a/scripts/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.py +++ /dev/null @@ -1,295 +0,0 @@ -""" -Tutorial 8: Adaptive Pixelization -================================= - -In this tutorial we will introduce a new `Pixelization` object, which uses an `Overlay` image-mesh and a `Delaunay` -mesh. - -This pixelization does not use a rectangular mesh, but instead uses a `Delaunay` triangulation. - -So, why would we want to do that? Lets take another quick look at the rectangular grid. - -__Contents__ - -- **Initial Setup:** We'll use the same strong lensing data as the previous tutorial, where. -- **Advantages and Disadvantages:** Lets think about the rectangular pixelization. -- **Image Mesh:** The Delaunay mesh is an irregular grid of pixels (or triangles) in the source-plane. -- **Regularization:** Penalize differences between neighbouring reconstructed values. -- **Source Compactness:** Reconstruct three sources with the same lens, mesh and noise. -- **Wrap Up:** Summary of the script and next steps. - -""" - -import importlib.util -import sys - -if importlib.util.find_spec("jax") is None: - print( - "Skipping this tutorial: it requires the `jax` package, which is not " - "installed (install with `pip install autolens[optional]`)." - ) - sys.exit(0) - -from autolens import jax_wrapper # Sets JAX environment before other imports - -# from autolens import setup_notebook; setup_notebook() - -from pathlib import Path -import numpy as np -import autolens as al -import autolens.plot as aplt - -""" -__Initial Setup__ - -We'll use the same strong lensing data as the previous tutorial, where: - - - The lens galaxy's light is omitted. - - The lens system uses an `Isothermal` galaxy mass profile and a separate `ExternalShear` field. - - The source galaxy's light is an `Sersic`. -""" -dataset_name = "simple__no_lens_light" -dataset_path = Path("dataset") / "imaging" / dataset_name - -""" -__Dataset Auto-Simulation__ - -If the dataset does not already exist on your system, it will be created by running the corresponding simulator -script. This ensures that all example scripts can be run without manually simulating data first. -""" -al.util.dataset.simulate_if_needed( - dataset_path, simulator="scripts/simulator/no_lens_light.py" -) - -dataset = al.Imaging.from_fits( - data_path=dataset_path / "data.fits", - noise_map_path=dataset_path / "noise_map.fits", - psf_path=dataset_path / "psf.fits", - pixel_scales=0.1, -) - -mask = al.Mask2D.circular( - shape_native=dataset.shape_native, - pixel_scales=dataset.pixel_scales, - radius=3.0, -) - -aplt.subplot_imaging_dataset(dataset=dataset) - -""" -The lines of code below do everything we're used to, that is, setup an image, mask it, trace it via a tracer, setup -the rectangular mapper, etc. -""" -lens_galaxy = al.Galaxy( - redshift=0.5, - mass=al.mp.Isothermal( - centre=(0.0, 0.0), - einstein_radius=1.6, - ell_comps=al.convert.ell_comps_from(axis_ratio=0.9, angle=45.0), - ), -) -field = al.MassField( - redshift=0.5, shear=al.mp.ExternalShear(gamma_1=0.05, gamma_2=0.05) -) - -dataset = dataset.apply_mask(mask=mask) - -mesh_pixels_yx = 28 -mesh_shape = (mesh_pixels_yx, mesh_pixels_yx) - - -pixelization = al.Pixelization( - mesh=al.mesh.RectangularBilinearAdaptDensity(shape=mesh_shape), - regularization=al.reg.Constant(coefficient=0.5), -) - -source_galaxy = al.Galaxy(redshift=1.0, pixelization=pixelization) - -tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy], fields=[field]) - -fit = al.FitImaging(dataset=dataset, tracer=tracer) - -aplt.subplot_fit_imaging(fit=fit) -aplt.subplot_fit_imaging_of_planes(fit=fit) - -""" -__Advantages and Disadvantages__ - -The rectangular mesh is a useful baseline, but its finite resolution can limit a reconstruction. -`RectangularBilinearAdaptDensity` adjusts rectangular sampling to the traced data density; it is not simply a -uniform source-plane grid. Inspect where its mesh points lie relative to the bright source. -""" -mapper = fit.inversion.cls_list_from(al.Mapper)[0] -rectangular_pixels = mapper.pixels -print(f"Rectangular source pixels: {rectangular_pixels}") -aplt.subplot_fit_imaging_of_planes(fit=fit) - -""" -A mesh point in a faint region is not necessarily wasted: those measurements can constrain the source's extent. The -evidence balances fit quality, regularization and complexity, rather than simply rewarding fewer pixels. An -image-plane overlay offers another allocation: ray-tracing its points concentrates source-plane sampling according -to lens magnification. Whether this improves a fit must be checked for the data and regularization used. - -__Image Mesh__ - -The Delaunay mesh is an irregular triangulation in the source-plane. First determine a set of (y,x) source-plane -coordinates defining this grid, specifically where each triangle vertex is located. - -We do this using an `image_mesh`, which defines a method to determine a set of coordinates in the image-plane which -are ray-traced to the source-plane. These traced coordinates are the vertices of our source-pixel mesh. - -Below, we use the `Overlay` image-mesh to do this, which overlays a grid of (y,x) coordinates over the image-plane -mask and retains all (Y,x) coordinates which fall within this mask. -""" -dataset = dataset.apply_sparse_operator() - -image_mesh = al.image_mesh.Overlay(shape=(20, 20)) - -image_plane_mesh_grid = image_mesh.image_plane_mesh_grid_from(mask=dataset.mask) - -""" -We can plot this grid over the image, to see that it is a coarse grid of (y,x) coordinates laid over the image. -""" - -aplt.plot_array( - array=dataset.data, grid=image_plane_mesh_grid, title="Image-plane mesh overlay" -) - -""" -Give the source galaxy a `Pixelization` containing a `Delaunay` mesh. The fit receives the image-plane mesh -coordinates separately through `AdaptImages` below, and ray-traces them to construct the source-plane mesh. -""" -pixelization = al.Pixelization( - mesh=al.mesh.Delaunay( - pixels=image_plane_mesh_grid.shape[0], - ), - regularization=al.reg.Constant(coefficient=1.0), -) - -source_galaxy = al.Galaxy(redshift=1.0, pixelization=pixelization) - -tracer = al.Tracer(galaxies=[lens_galaxy, source_galaxy], fields=[field]) - -""" -By using this tracer in a fit, we see that our source-plane no longer uses rectangular pixels, but a Delaunay mesh! -""" -adapt_images = al.AdaptImages( - galaxy_image_plane_mesh_grid_dict={source_galaxy: image_plane_mesh_grid}, -) - -fit = al.FitImaging(dataset=dataset, tracer=tracer, adapt_images=adapt_images) - -mapper = fit.inversion.cls_list_from(al.Mapper)[0] - - -aplt.subplot_fit_imaging(fit=fit) - -""" -Compare the residuals and reconstructed source with the rectangular example. The overlay retains only points inside -the mask, so its actual number of source pixels is smaller than the full 20 by 20 overlay. The two fits also use -different regularization coefficients; this is not a controlled ranking of mesh quality. -""" -print(f"Delaunay source pixels: {mapper.pixels}; rectangular: {rectangular_pixels}") -aplt.subplot_fit_imaging_of_planes(fit=fit) - -""" -__Regularization__ - -Constant regularization penalizes differences between neighbouring reconstructed source values. Rectangular meshes -use their grid connectivity; Delaunay meshes use the connected vertices of the triangulation. A vertex need not -have exactly three neighbours. One coefficient controls the strength of smoothing across the mesh. - -__Source Compactness__ - -Magnification depends on the lens and source position, not on the brightness distribution we place there. Keep this -lens, image-plane overlay and regularization fixed while making three source profiles progressively more compact. -This restores a direct test of a limitation of magnification-based sampling. - -Use identical PSF, mask, Gaussian noise map and noise realization for each simulated image. The profiles retain the -same intensity at their effective radius, so their total fluxes and signal-to-noise ratios also differ; this is a -diagnostic experiment, not an isolated measurement of the effect of radius. The coefficient of 1.0 is an explicit -illustrative choice, not an evidence optimum for any of these datasets. -""" - -simulation_grid = al.Grid2D.uniform( - shape_native=dataset.shape_native, pixel_scales=dataset.pixel_scales -) -simulation_grid = simulation_grid.apply_over_sampling(over_sample_size=8) -simulator = al.SimulatorImaging( - exposure_time=300.0, - psf=dataset.psf, - background_sky_level=0.1, - add_poisson_noise_to_data=False, -) -noise_sigma = 0.05 -noise_values = np.random.default_rng(1).normal( - loc=0.0, scale=noise_sigma, size=simulation_grid.shape_native -) -common_noise_map = al.Array2D.no_mask( - values=np.full(simulation_grid.shape_native, noise_sigma), - pixel_scales=dataset.pixel_scales, -) - -compactness_fits = {} -for label, effective_radius, sersic_index in [ - ("Extended", 0.5, 1.0), - ("Compact", 0.2, 2.5), - ("Very compact", 0.1, 4.0), -]: - true_source = al.Galaxy( - redshift=1.0, - bulge=al.lp.Sersic( - centre=(0.0, 0.0), - ell_comps=(0.0, 0.15), - intensity=0.2, - effective_radius=effective_radius, - sersic_index=sersic_index, - ), - ) - true_tracer = al.Tracer(galaxies=[lens_galaxy, true_source], fields=[field]) - noiseless = simulator.via_tracer_from(tracer=true_tracer, grid=simulation_grid) - simulated_dataset = al.Imaging( - data=al.Array2D.no_mask( - values=np.asarray(noiseless.data.native) + noise_values, - pixel_scales=dataset.pixel_scales, - ), - noise_map=common_noise_map, - psf=dataset.psf, - ) - simulated_dataset = simulated_dataset.apply_mask(mask=mask) - simulated_dataset = simulated_dataset.apply_sparse_operator() - compactness_fit = al.FitImaging( - dataset=simulated_dataset, tracer=tracer, adapt_images=adapt_images - ) - compactness_fits[label] = compactness_fit - compactness_mapper = compactness_fit.inversion.cls_list_from(al.Mapper)[0] - mesh_radii = np.sqrt( - np.sum(np.asarray(compactness_mapper.source_plane_mesh_grid) ** 2, axis=1) - ) - vertices_within_radius = np.count_nonzero(mesh_radii <= effective_radius) - print( - f"{label}: radius={effective_radius:.2f}, index={sersic_index:.1f}, " - f"coefficient=1.0, mesh pixels={compactness_mapper.pixels}, " - f"vertices within circular effective-radius aperture={vertices_within_radius}, " - f"chi_squared / data pixels={compactness_fit.chi_squared / simulated_dataset.data.shape[0]:.3f}, " - f"max |normalized residual|={np.max(np.abs(compactness_fit.normalized_residual_map)):.3f}" - ) - aplt.subplot_fit_imaging(fit=compactness_fit) - aplt.subplot_fit_imaging_of_planes(fit=compactness_fit) - -""" -The printed aperture counts measure available mesh vertices, not the number of independently constrained source -values. Inspect the source reconstructions alongside the residual maps: can the same mesh resolve each bright -centre without coherent residuals? Changing the coefficient may trade smoothing of the centre against noise in the -outskirts, but cannot add mesh vertices. We have not searched for an optimal coefficient, and these separate -simulated datasets must not be ranked against one another using their absolute evidences. - -__Wrap Up__ - -The overlay responds to magnification rather than source morphology. The compactness experiment lets us inspect -where that distinction matters; neither fewer pixels nor a more adaptive mesh guarantees a better reconstruction. -Tutorials 11 and 12 explore adapting the mesh and regularization to a fitted brightness distribution. They test -those choices on the same data so that changes in fit quality and evidence can be assessed meaningfully. - -You can also experiment with rectangular and Delaunay meshes by changing the pixelization in a pipeline. -""" diff --git a/workspace_index.json b/workspace_index.json index 42bbd16..4cf6800 100644 --- a/workspace_index.json +++ b/workspace_index.json @@ -374,34 +374,6 @@ "summary": "(no summary in script docstring)", "title": "Tutorial 7: Summary" }, - { - "contents": [ - "Initial Setup", - "Adapt Image", - "Adaption", - "Hilbert", - "Weight Map", - "Wrap Up" - ], - "cross_refs": [], - "notebook": "notebooks/chapter_3_pixelizations/tutorial_11_brightness_adaption.ipynb", - "path": "scripts/chapter_3_pixelizations/tutorial_11_brightness_adaption.py", - "summary": "In the previous tutorial we motivated our need to adapt the pixelization to the source's morphology, such that source pixels congregates in the source's brightest regions regardless of where the source is located in the source-plane.", - "title": "Tutorial 11: Brightness Adaption" - }, - { - "contents": [ - "Initial Setup", - "Convenience Function", - "Adaptive Regularization", - "Wrap Up" - ], - "cross_refs": [], - "notebook": "notebooks/chapter_3_pixelizations/tutorial_12_adaptive_regularization.ipynb", - "path": "scripts/chapter_3_pixelizations/tutorial_12_adaptive_regularization.py", - "summary": "Tutorial 2 introduced `Constant` regularization. Different regions of the source can favour different levels of regularization, motivating a scheme which adapts to the reconstructed source's surface brightness.", - "title": "Tutorial 12: Adaptive Regularization" - }, { "contents": [ "Opening \u2014 Pixelized source reconstruction", @@ -414,6 +386,8 @@ "Reconstructing the source", "A first look at regularization", "From source indexes to reconstructed brightnesses", + "Keeping source brightnesses non-negative", + "Where the image weakly constrains the source", "Turning the reconstructed source back into an image", "Inspecting the reconstruction", "Changing the source resolution", @@ -422,7 +396,7 @@ "cross_refs": [], "notebook": "notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb", "path": "scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py", - "summary": "(no summary in script docstring)", + "summary": "Reconstruct a complex source using pixelizations, image-to-source mappings and a linear inversion.", "title": "Tutorial 1: Pixelizations, Mappers and Inversions" }, { @@ -432,18 +406,19 @@ "Reducing regularization", "Increasing regularization", "Bayesian evidence", + "Choosing the coefficient", "Exploring the regularization coefficient", "Inspecting the preferred reconstruction", "What is linear, and what requires a search?", "Mesh resolution and regularization belong together", - "What we have learned, and what comes next" + "Wrap Up" ], "cross_refs": [ "//github.com/PyAutoLabs/autolens_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py" ], "notebook": "notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb", "path": "scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py", - "summary": "(no summary in script docstring)", + "summary": "Explore regularization and use Bayesian evidence to choose its strength for a source reconstruction.", "title": "Tutorial 2: Bayesian Regularization" }, { @@ -455,14 +430,14 @@ "Following the central pixels into the source plane", "Tracing the outer border", "Switching border relocation on", - "What relocation changes\u2014and what it does not", + "What relocation changes", "The mask still needs to cover the lensed source", - "Wrap up" + "Wrap Up" ], "cross_refs": [], "notebook": "notebooks/chapter_3_pixelizations/tutorial_3_borders.ipynb", "path": "scripts/chapter_3_pixelizations/tutorial_3_borders.py", - "summary": "(no summary in script docstring)", + "summary": "Explore how masks and border relocation affect the extent and resolution of a source mesh.", "title": "Tutorial 3: Borders" }, { @@ -478,28 +453,68 @@ "Replacing the analytic source with a pixelization", "Running the pixelized-source search", "Establishing the foreground light model", - "Using that result in the pixelized-source fit" + "Using that result in the pixelized-source fit", + "Wrap Up" ], "cross_refs": [], "notebook": "notebooks/chapter_3_pixelizations/tutorial_4_lens_modeling.ipynb", "path": "scripts/chapter_3_pixelizations/tutorial_4_lens_modeling.py", - "summary": "(no summary in script docstring)", + "summary": "Fit lens mass and foreground light alongside a pixelized source using search chaining and a positions likelihood penalty.", "title": "Tutorial 4: Lens Modeling" }, { "contents": [ - "Initial Setup", - "Advantages and Disadvantages", - "Image Mesh", - "Regularization", - "Source Compactness", + "When the best coefficient is not enough", + "Three progressively compact sources", + "Simulating the images", + "Reconstructing the sources", + "Finding the preferred regularization", + "Starting with the extended source", + "Making the source more compact", + "A very compact source", + "Why the mesh becomes a limitation", + "Why one regularization coefficient becomes a compromise", + "Why maximizing the evidence does not remove the compromise", + "Wrap Up" + ], + "cross_refs": [], + "notebook": "notebooks/chapter_3_pixelizations/tutorial_5_fit_problems.ipynb", + "path": "scripts/chapter_3_pixelizations/tutorial_5_fit_problems.py", + "summary": "Investigate why compact sources can challenge mesh resolution and constant regularization even when the mass model is correct.", + "title": "Tutorial 5: Fit Problems" + }, + { + "contents": [ + "Adaptive source reconstruction", + "Returning to the compact-source problem", + "From an image mesh to a source mesh", + "Constructing the Delaunay source mesh", + "Interpolating within a triangle", + "Regularization on an irregular mesh", + "Building an adapt image", + "Placing points with a Hilbert image mesh", + "Controlling the brightness weighting", + "Keeping resolution in the faint regions", + "Reconstructing with the Hilbert mesh", + "Comparing overlay and Hilbert reconstructions", + "Smoothing on an irregular mesh", + "Reconstructing with ConstantSplit", + "Allowing different smoothing strengths", + "Controlling the transition with `signal_scale`", + "Exploring the adaptive coefficients", + "Does adaptive regularization address the fit problem?", + "Starting the final modelling chain", + "Preparing adaptation and the positions likelihood penalty", + "Defining the adaptive Delaunay model", + "Running the adaptive Delaunay fit", + "Inspecting the source and smoothing pattern", "Wrap Up" ], "cross_refs": [], - "notebook": "notebooks/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.ipynb", - "path": "scripts/chapter_3_pixelizations/tutorial_8_adaptive_pixelization.py", - "summary": "In this tutorial we will introduce a new `Pixelization` object, which uses an `Overlay` image-mesh and a `Delaunay` mesh.", - "title": "Tutorial 8: Adaptive Pixelization" + "notebook": "notebooks/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.ipynb", + "path": "scripts/chapter_3_pixelizations/tutorial_6_adaptive_reconstruction.py", + "summary": "Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization in a chained lens-model fit.", + "title": "Tutorial 6: Adaptive Reconstruction" }, { "contents": [