From 5d98c61491cd7f03486731a855089e1b11d87d06 Mon Sep 17 00:00:00 2001 From: Jammy2211 Date: Wed, 4 Nov 2026 13:26:46 +0000 Subject: [PATCH] Expand galaxy pixelization lessons with fit problems and adaptive reconstruction --- README.md | 6 +- llms-full.txt | 14 +- llms.txt | 7 +- notebooks/chapter_3_pixelizations/README.md | 13 +- .../tutorial_1_pixelizations.ipynb | 66 +- .../tutorial_2_bayesian_regularization.ipynb | 68 +- .../tutorial_3_fit_problems.ipynb | 968 +++++++++++ .../tutorial_4_adaptive_reconstruction.ipynb | 1495 +++++++++++++++++ ...l_fit.ipynb => tutorial_5_model_fit.ipynb} | 18 +- scripts/chapter_3_pixelizations/README.md | 13 +- .../tutorial_1_pixelizations.py | 55 +- .../tutorial_2_bayesian_regularization.py | 68 +- .../tutorial_3_fit_problems.py | 697 ++++++++ .../tutorial_4_adaptive_reconstruction.py | 1153 +++++++++++++ ...3_model_fit.py => tutorial_5_model_fit.py} | 17 +- workspace_index.json | 89 +- 16 files changed, 4624 insertions(+), 123 deletions(-) create mode 100644 notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb create mode 100644 notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb rename notebooks/chapter_3_pixelizations/{tutorial_3_model_fit.ipynb => tutorial_5_model_fit.ipynb} (98%) create mode 100644 scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py create mode 100644 scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py rename scripts/chapter_3_pixelizations/{tutorial_3_model_fit.py => tutorial_5_model_fit.py} (93%) diff --git a/README.md b/README.md index 490ee89..b21dbbf 100644 --- a/README.md +++ b/README.md @@ -95,10 +95,12 @@ for the chapter 1 tutorials listed with a `markdown` link below; every other tut - Tutorial 5: Data and Likelihoods — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_2_modeling/tutorial_5_data_and_likelihoods.ipynb)) - Tutorial 6: Search Chaining — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_2_modeling/tutorial_6_search_chaining.ipynb)) - Tutorial 7: Summary — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_2_modeling/tutorial_7_summary.ipynb)) -- **[Chapter 3: Pixelizations](scripts/chapter_3_pixelizations/README.md)** — Indexed mappings, inversions, regularization and MGE search chaining. +- **[Chapter 3: Pixelizations](scripts/chapter_3_pixelizations/README.md)** — Mappings, Bayesian regularization, fit problems, adaptive reconstruction and MGE search chaining. - Tutorial 1: Pixelizations — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb)) - Tutorial 2: Bayesian Regularization — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb)) - - Tutorial 3: Model Fit — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb)) + - Tutorial 3: Fit Problems — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb)) + - Tutorial 4: Adaptive Reconstruction — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb)) + - Tutorial 5: Model Fit — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb)) - **[Chapter 4: Scaling Up Galaxies](scripts/chapter_4_scaling_up_galaxies/README.md)** — Extra galaxies, blended multi-galaxy systems, and cluster fields. - Tutorial 1: Extra Galaxies — ([Colab](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/2026.11.1.1/notebooks/chapter_4_scaling_up_galaxies/tutorial_1_extra_galaxies.ipynb)) diff --git a/llms-full.txt b/llms-full.txt index 31d3cd0..8b6b69f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -41,11 +41,15 @@ AUTO-GENERATED by PyAutoHands — do not edit by hand; regenerate with generate. ## chapter_3_pixelizations - [Tutorial 1: Pixelizations, Mappers and Inversions](scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py): Look at a resolved galaxy image. Its broad light distribution may follow a smooth bulge and disc, while smaller regions contain bright star-forming knots, off-centre clumps or irregular extensions. A model can capture the overall galaxy yet leave these features clearly visible in the residual image. - - Contents: The image and its coordinates, A brightness mesh, Following the mappings, An inversion, A model image, Using FitImaging -- [Tutorial 2: Bayesian Regularization](scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py): (no summary in script docstring) - - Contents: Interpolation and our starting reconstruction., Reducing and increasing regularization., Bayesian evidence and a coefficient experiment., Inspecting the preferred reconstruction., Linear brightnesses and non-linear model choices., Comparing mesh resolutions. -- [Tutorial 3: Model Fit](scripts/chapter_3_pixelizations/tutorial_3_model_fit.py): A pixelization can describe a galaxy without assigning an analytic centre and shape to every visible feature. However, analytic profiles remain useful when we want to describe its smooth components. In this tutorial we combine an MGE with a pixelization and use search chaining to put the smooth light model in place first. - - Contents: Dataset, Search 1, Search 2, Search 3, Interpretation + - Contents: The image and its coordinates, A brightness mesh, Following the mappings, An inversion, Keeping brightnesses non-negative, Where the image weakly constrains the mesh, A model image, Using FitImaging, Wrap Up +- [Tutorial 2: Bayesian Regularization](scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py): Choose the regularization strength by comparing log evidences, then examine how mesh resolution changes that choice. + - 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, Wrap Up +- [Tutorial 3: Fit Problems](scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py): Investigate why compact galaxies can challenge mesh resolution and constant regularization even with a known PSF. + - Contents: When the best coefficient is not enough, Three progressively compact galaxies, Simulating the images, Reconstructing the galaxies, Finding the preferred regularization, Starting with the extended galaxy, Making the galaxy more compact, A very compact galaxy, 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 4: Adaptive Reconstruction](scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py): Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization for directly observed galaxies. + - Contents: Adaptive galaxy reconstruction, Returning to the compact-galaxy problem, From image coordinates to a reconstruction mesh, Constructing the Delaunay galaxy 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?, Wrap Up +- [Tutorial 5: Model Fit](scripts/chapter_3_pixelizations/tutorial_5_model_fit.py): Tutorials 3 and 4 explored mesh resolution and adaptive smoothing using a galaxy reconstructed entirely on a mesh. We now return to the complementary question of how to combine a smooth analytic component with a pixelization. The rectangular mesh here keeps that decomposition experiment simple; inspect its residuals and revisit the adaptive choices from Tutorial 4 when the pixelized component needs more spatially varying resolution. + - Contents: Search 1: Fit the MGE, Search 2: Add the pixelization, Search 3: Refine both components, Wrap Up ## chapter_4_scaling_up_galaxies diff --git a/llms.txt b/llms.txt index f7f9ef5..c434517 100644 --- a/llms.txt +++ b/llms.txt @@ -26,7 +26,7 @@ real analysis?" below). - **Chapter 1 — Introduction** → [scripts/chapter_1_introduction/](scripts/chapter_1_introduction/): Galaxies from first principles: grids & galaxies, light profiles, simulating data, and fitting data. The conceptual foundation. - **Chapter 2 — Modeling** → [scripts/chapter_2_modeling/](scripts/chapter_2_modeling/): Fitting a galaxy model to data with a non-linear search — practicalities, difficult fits, linear profiles, data and likelihoods, and component-based search chaining. -- **Chapter 3 — Pixelizations** → [scripts/chapter_3_pixelizations/](scripts/chapter_3_pixelizations/): Reconstructing galaxy structure on a mesh — indexed mappings, inversions, Bayesian regularization and combined MGE/pixelization search chaining. +- **Chapter 3 — Pixelizations** → [scripts/chapter_3_pixelizations/](scripts/chapter_3_pixelizations/): Reconstructing galaxy structure on a mesh — indexed mappings, inversions, Bayesian regularization, fit problems, adaptive reconstruction and combined MGE/pixelization search chaining. - **Chapter 4 — Scaling Up Galaxies** → [scripts/chapter_4_scaling_up_galaxies/](scripts/chapter_4_scaling_up_galaxies/): Beyond a single galaxy — extra galaxies, blended multi-galaxy systems, and cluster fields. ## I want to understand… @@ -38,7 +38,10 @@ real analysis?" below). - **My fit found a wrong (local-maxima) solution — what do I do?** → [scripts/chapter_2_modeling/tutorial_3_why_modeling_is_hard.py](scripts/chapter_2_modeling/tutorial_3_why_modeling_is_hard.py): Prior tuning, reducing complexity, and looking harder. - **What are linear light profiles / MGE?** → [scripts/chapter_2_modeling/tutorial_4_linear_profiles.py](scripts/chapter_2_modeling/tutorial_4_linear_profiles.py): Solving intensities linearly; the Multi-Gaussian Expansion basis. - **How do I model an image with two galaxies?** → [scripts/chapter_4_scaling_up_galaxies/tutorial_2_multi_galaxy.py](scripts/chapter_4_scaling_up_galaxies/tutorial_2_multi_galaxy.py): Joint fitting of blended galaxies and their degeneracies. -- **How do I reconstruct irregular structure on a pixel grid?** → [scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py](scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py) through [tutorial_3_model_fit.py](scripts/chapter_3_pixelizations/tutorial_3_model_fit.py). +- **How do I reconstruct irregular structure on a pixel grid?** → [scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py](scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py) through [tutorial_5_model_fit.py](scripts/chapter_3_pixelizations/tutorial_5_model_fit.py). + +- **Why does a regularized reconstruction still leave residuals?** → [tutorial_3_fit_problems.py](scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py): Mesh resolution, concentrated light and the limits of smoothing. +- **How can the mesh and smoothing follow galaxy brightness?** → [tutorial_4_adaptive_reconstruction.py](scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py): Hilbert sampling and adaptive regularization, compared using log evidence. ## Ready for real analysis? diff --git a/notebooks/chapter_3_pixelizations/README.md b/notebooks/chapter_3_pixelizations/README.md index d45eaf7..61e9e06 100644 --- a/notebooks/chapter_3_pixelizations/README.md +++ b/notebooks/chapter_3_pixelizations/README.md @@ -16,10 +16,13 @@ The reconstruction still depends on modelling choices, including the mesh, its r MGE and other analytic profiles remain useful for describing and measuring smooth galaxy components. Pixelizations complement them when more complicated structure needs to be represented, including in models that combine analytic components with a pixelized reconstruction. -# Files - -- [Tutorial 1: Pixelizations](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb) — Meshes, indexed mappings, inversions and telescope blurring. +The chapter moves from mappings and evidence-based regularization to the practical limits of a fixed mesh. We then +explore brightness-guided mesh placement and adaptive smoothing, before fitting an MGE and a pixelization together. -- [Tutorial 2: Bayesian Regularization](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb) — Interpolation, smoothness priors, evidence and resolution experiments. +# Files -- [Tutorial 3: Model Fit](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb) — An MGE fit followed by a combined MGE and pixelized model. +- [Tutorial 1: Pixelizations](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb) — Mappings, inversions, positivity and telescope blurring. +- [Tutorial 2: Bayesian Regularization](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb) — The Goldilocks balance, log-evidence selection and mesh resolution. +- [Tutorial 3: Fit Problems](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb) — Compact galaxies expose mesh-resolution and constant-smoothing limitations. +- [Tutorial 4: Adaptive Reconstruction](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb) — Delaunay/Hilbert mesh placement and adaptive Split regularization. +- [Tutorial 5: Model Fit](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb) — MGE fitting, adding a pixelization and refining the combined model. diff --git a/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb b/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb index f5fbfbd..4a278ab 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb @@ -53,17 +53,21 @@ "\n", "__Contents__\n", "\n", - "- **The image and its coordinates:** Load and mask the data.\n", - "- **A brightness mesh:** Distinguish image indexes from mesh indexes.\n", - "- **Following the mappings:** Highlight which image pixels receive each mesh contribution.\n", - "- **An inversion:** Infer brightnesses, accounting for noise and telescope blurring.\n", - "- **A model image:** Inspect the reconstruction and residuals separately.\n", - "- **Using FitImaging:** Put these ingredients into a galaxy model.\n", + "- The image and its coordinates\n", + "- A brightness mesh\n", + "- Following the mappings\n", + "- An inversion\n", + "- Keeping brightnesses non-negative\n", + "- Where the image weakly constrains the mesh\n", + "- A model image\n", + "- Using FitImaging\n", + "- Wrap Up\n", "\n", "__The image and its coordinates__\n", "\n", "The simulator supplies a S\u00e9rsic bulge and exponential disc. The circular mask selects the image pixels used in the\n", - "fit. We use one sample per image pixel for the indexed illustrations; accurate scientific modelling should also\n", + "fit. It also affects the mesh extent: including distant blank sky spreads a fixed number of mesh points over a\n", + "larger area. Inspect the mask against the galaxy before deciding whether more mesh points are needed. We use one sample per image pixel for the indexed illustrations; accurate scientific modelling should also\n", "check image integration. Each unmasked pixel has a position and a flattened index. These indexes describe the data,\n", "not the separate mesh we are about to construct." ] @@ -296,6 +300,54 @@ "cell_type": "markdown", "metadata": {}, "source": [ + "__Keeping brightnesses non-negative__\n", + "\n", + "By default, the inversion constrains reconstructed brightnesses to be non-negative; zero is allowed. This prevents\n", + "it from explaining the observed image by adding negative galaxy light.\n", + "\n", + "Positivity and regularization do different jobs. Positivity restricts the allowed brightnesses, whereas\n", + "regularization discourages large differences between neighbours. Non-negative values can still fit noise, so the\n", + "positivity constraint does not remove the need for smoothing.\n", + "\n", + "__Where the image weakly constrains the mesh__\n", + "\n", + "Some mesh points contribute to many image pixels, while others have little direct support. We can sum the absolute\n", + "mapping weights for each mesh point and compare that support with its reconstructed brightness.\n", + "\n", + "The next cell counts effectively unmapped points and prints the five least-supported values. There may be no\n", + "exactly unmapped points in this example; the diagnostic does not assume otherwise." + ] + }, + { + "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", + "print(\"Mesh points with effectively zero mapping weight:\", len(unsupported))\n", + "for mesh_index in least_supported:\n", + " print(\n", + " f\"Mesh index {mesh_index}: total mapping weight={mapping_support[mesh_index]:.6g}, \"\n", + " f\"reconstructed brightness={reconstruction[mesh_index]:.6g}\"\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A point with no direct mapping cannot have its brightness determined directly by the image. Neighbouring values,\n", + "regularization, positivity and any boundary constraints can instead influence it. For weakly supported points,\n", + "both the data and those constraints matter.\n", + "\n", + "This weight sum is a geometric diagnostic, not a brightness uncertainty. It does not include image noise, PSF\n", + "blurring or correlations between inferred values. In particular, a finely sampled reconstruction does not give\n", + "independent measurements of features smaller than the telescope resolution.\n", + "\n", "__A model image__\n", "\n", "The brightness reconstruction lives on the mesh. Mapping it onto the image grid and convolving with the PSF\n", diff --git a/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb b/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb index f84b01b..7613cc5 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb @@ -7,14 +7,20 @@ "Tutorial 2: Bayesian Regularization\n", "==================================\n", "\n", + "Choose the regularization strength by comparing log evidences, then examine how mesh resolution changes that choice.\n", + "\n", "__Contents__\n", "\n", - "- Interpolation and our starting reconstruction.\n", - "- Reducing and increasing regularization.\n", - "- Bayesian evidence and a coefficient experiment.\n", - "- Inspecting the preferred reconstruction.\n", - "- Linear brightnesses and non-linear model choices.\n", - "- Comparing mesh resolutions.\n", + "- Interpolation and Bayesian regularization\n", + "- Interpolation and our starting reconstruction\n", + "- Reducing regularization\n", + "- Increasing regularization\n", + "- Bayesian evidence\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", + "- Wrap Up\n", "\n", "__Interpolation and Bayesian regularization__\n", "\n", @@ -388,31 +394,28 @@ "\n", "__Bayesian evidence__\n", "\n", - "For inversions, we therefore need more than image agreement to choose an appropriate level of regularization.\n", - "\n", - "We use the **Bayesian evidence**, which accounts for both the fit to the image and the galaxy 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", - "\n", - "There are two useful parts to this intuition.\n", + "Our regularization coefficient is a Goldilocks choice. Too little smoothing lets the reconstruction follow noise;\n", + "too much flattens brightness variations needed to explain the galaxy. We want the amount that is \u201cjust right\u201d for\n", + "the image, mesh and prior we have supplied.\n", "\n", - "First, the reconstruction must explain the image. If strong regularization prevents it from reproducing genuine\n", - "galaxy structure, the resulting residuals count against that choice. Preferring a smooth galaxy is not useful if\n", - "that galaxy cannot explain the observed galaxy.\n", + "We choose it by maximizing the **Bayesian evidence**. For a specified mesh and regularization coefficient, the\n", + "evidence accounts for the image fit and the range of brightness patterns allowed by the reconstruction prior.\n", + "The inversion solves for the mesh brightnesses; the evidence calculation also includes the normalization and\n", + "complexity terms associated with that reconstruction. It is not simply chi-squared with a smoothing penalty added.\n", "\n", - "Second, allowing a more flexible galaxy 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", + "Weak regularization permits a wider range of brightness patterns. A better image fit must justify that extra\n", + "freedom. Strong regularization restricts those patterns, but loses evidence if it prevents the reconstruction from\n", + "explaining the galaxy. The evidence weighs these effects together.\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", + "Complexity is therefore not just a count of mesh points. Our three fits have the same number of brightness values,\n", + "but regularization changes how independently they can vary.\n", "\n", - "This is also why complexity is not simply the number of mesh points. Our three examples have exactly the same mesh.\n", - "Stronger regularization ties neighbouring brightnesses together more closely, reducing their freedom to vary\n", - "independently. We can therefore change the effective flexibility of the reconstruction without changing the number\n", - "of mesh points.\n", + "In **PyAutoGalaxy**, `fit.log_evidence` returns the natural logarithm of the evidence. Taking the logarithm preserves\n", + "the ranking: **the largest log evidence selects the preferred coefficient among those evaluated**. Values may be\n", + "positive or negative; we compare their differences, not their distance from zero.\n", "\n", - "Let\u2019s compare the evidence values returned by `FitImaging`." + "Let\u2019s calculate those values for the weak, starting and strong coefficients. We keep the same data, noise, mask and\n", + "mesh so that we can interpret this as a comparison of regularization choices." ] }, { @@ -429,6 +432,8 @@ "\n", "best_index = int(np.argmax(log_evidences))\n", "best_label, best_coefficient, _ = fit_comparisons[best_index]\n", + "for (label, coefficient, _), log_evidence in zip(fit_comparisons, log_evidences):\n", + " print(f\"{label}: difference from highest log evidence={log_evidence - max(log_evidences):.3f}\")\n", "\n", "print(\n", " f\"\\nHighest evidence among these examples: \"\n", @@ -556,6 +561,7 @@ " preferred_fit = fit_with_coefficient(coefficient=preferred_coefficient)\n", "\n", "print(\"Preferred sampled coefficient:\", preferred_coefficient)\n", + "print(\"Highest sampled log evidence:\", float(evidence_grid[best_index]))\n", "\n", "if best_index in (0, len(coefficient_grid) - 1):\n", " print(\n", @@ -581,8 +587,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "A preferred coefficient does not establish that every visible feature in the galaxy is real. The reconstruction\n", - "remains conditional on the galaxy mesh, noise map and telescope blurring.\n", + "Choosing the highest log evidence selects a smoothing strength for this model. It does not identify whether a\n", + "particular knot or faint extension is reliable. Examine the corresponding residuals, and check whether that\n", + "structure persists when reasonable mesh, noise and PSF assumptions are varied.\n", "\n", "Nor does a good-looking galaxy excuse coherent residuals in the image. The galaxy and residual plots provide\n", "complementary information: one shows what we inferred, while the other shows what that inference leaves\n", @@ -784,8 +791,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Throughout this tutorial, we evaluated reconstructions of a specified galaxy dataset. In the next tutorial, we use\n", - "a non-linear search and combine an MGE with a pixelization through search chaining.\n", + "Throughout this tutorial, we evaluated reconstructions of a specified galaxy dataset. In the next tutorial, we reconstruct\n", + "three increasingly compact galaxies to see why the preferred coefficient can still leave a poor fit. We then\n", + "introduce adaptive meshes and smoothing, before returning to the combined MGE\u2013pixelization search in Tutorial 5.\n", "\n", "For readers who want the underlying equations, the workspace's [pixelization likelihood\n", "guide](https://github.com/PyAutoLabs/autogalaxy_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py)\n", diff --git a/notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb b/notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb new file mode 100644 index 0000000..4e92217 --- /dev/null +++ b/notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb @@ -0,0 +1,968 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Tutorial 3: Fit Problems\n", + "========================\n", + "\n", + "Investigate why compact galaxies can challenge mesh resolution and constant regularization even with a known PSF.\n", + "\n", + "__Contents__\n", + "\n", + "- When the best coefficient is not enough\n", + "- Three progressively compact galaxies\n", + "- Simulating the images\n", + "- Reconstructing the galaxies\n", + "- Finding the preferred regularization\n", + "- Starting with the extended galaxy\n", + "- Making the galaxy more compact\n", + "- A very compact galaxy\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/HowToGalaxy/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 autogalaxy 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(\"howtogalaxy\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__When the best coefficient is not enough__\n", + "\n", + "We have learned how to reconstruct a galaxy and choose its regularization coefficient using Bayesian evidence.\n", + "\n", + "Does this mean we can now reconstruct any galaxy accurately?\n", + "\n", + "Let\u2019s investigate using three galaxies whose light becomes progressively more concentrated towards their centres. We will simulate their images with the same image sampling, PSF and noise, then use that known PSF in each reconstruction.\n", + "\n", + "We do not search for an analytic profile geometry in this experiment. If coherent residuals remain, we can examine the mesh and regularization directly, without confusing them with an inaccurate PSF or a poorly explored profile fit.\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 galaxy 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 galaxy. 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 galaxies.\n", + "- Simulating their galaxy 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 galaxy\u2019s brightness distribution.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Three progressively compact galaxies__\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", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import autogalaxy as ag\n", + "import autogalaxy.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] = ag.Galaxy(\n", + " redshift=0.5,\n", + " bulge=ag.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 galaxy morphologies and signal-to-noise distributions. They are not an experiment in which only the galaxy size changes.\n", + "\n", + "We put each galaxy in a separate `Galaxies` collection for the simulator:" + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "true_galaxies = {\n", + " label: ag.Galaxies(galaxies=[source]) for label, source in sources.items()\n", + "}" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "There is no lensing transformation. All three images use the same sky coordinates, while the spatial resolution needed to describe their light changes.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Simulating the images__\n", + "\n", + "We now simulate the three galaxy 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 galaxy 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 = ag.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 = ag.Convolver.from_gaussian(\n", + " shape_native=(11, 11),\n", + " sigma=0.05,\n", + " pixel_scales=0.05,\n", + ")\n", + "\n", + "simulator = ag.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 = ag.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, galaxies in true_galaxies.items():\n", + " noiseless = simulator.via_galaxies_from(\n", + " galaxies=galaxies,\n", + " grid=simulation_grid,\n", + " )\n", + "\n", + " datasets[label] = ag.Imaging(\n", + " data=ag.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 galaxy emission changes. Which parts become narrower or more sharply peaked as the galaxy 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 galaxy emission." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "mask = ag.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 common 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 galaxies__\n", + "\n", + "We will reconstruct all three galaxies using a `25 \u00d7 25` rectangular mesh and `Constant` regularization.\n", + "\n", + "We supply the same PSF and noise model used in the simulations. The mesh brightnesses are the only description of the galaxy light in these fits.\n", + "\n", + "The function below takes a dataset and regularization coefficient, reconstructs the galaxy, and returns the fit." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "mesh_shape = (25, 25)\n", + "\n", + "\n", + "def fit_source(dataset, coefficient):\n", + " pixelization = ag.Pixelization(\n", + " mesh=ag.mesh.RectangularUniform(shape=mesh_shape),\n", + " regularization=ag.reg.Constant(coefficient=coefficient),\n", + " )\n", + "\n", + " reconstructed_source = ag.Galaxy(\n", + " redshift=0.5,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " galaxies = ag.Galaxies(galaxies=[reconstructed_source])\n", + "\n", + " return ag.FitImaging(\n", + " dataset=dataset,\n", + " galaxies=galaxies,\n", + " )" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The image grid and mask are identical across our three datasets. The rectangular mesh is therefore constructed from the same mapped coordinates, regardless of how compact the galaxy light becomes.\n", + "\n", + "This is the limitation we want to explore. The galaxy 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 galaxy need not be suitable for either compact galaxy.\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 galaxy__\n", + "\n", + "Let\u2019s begin with the extended galaxy.\n", + "\n", + "Its brightness varies relatively gradually across the sky. We therefore expect the rectangular mesh to describe it more easily than the compact galaxies, 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(ag.Mapper)[0]\n", + "\n", + "print(\n", + " \"Extended-galaxy 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 Galaxy: Preferred Reconstruction\",\n", + ")\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Look at how many mesh points lie across the bright galaxy 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 galaxy light.\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-galaxy fit provides our baseline. Next, we will make the galaxy more compact without giving the mesh any additional resolution, and examine where the reconstruction begins to struggle." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Making the galaxy more compact__\n", + "\n", + "Now inspect the compact galaxy.\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 galaxy pixels." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "compact_fit = preferred_fits[\"Compact\"]\n", + "compact_mapper = compact_fit.inversion.cls_list_from(ag.Mapper)[0]\n", + "\n", + "print(\n", + " \"Compact-galaxy 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 Galaxy: Preferred Reconstruction\",\n", + ")\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Look closely at the bright galaxy centre and the corresponding bright regions of the galaxy light. Does the reconstruction reproduce their sharp brightness peaks, or do coherent patches remain in the residual map?\n", + "\n", + "The galaxy 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 image sampling and PSF are fixed, 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 galaxy adequately.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__A very compact galaxy__\n", + "\n", + "Our final galaxy 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(ag.Mapper)[0]\n", + "\n", + "print(\n", + " \"Very-compact-galaxy 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 Galaxy: Preferred Reconstruction\",\n", + ")\n" + ], + "outputs": [], + "execution_count": null + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Compare the residuals with those of the extended and compact galaxies. Pay particular attention to the bright parts of the galaxy light: 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 galaxy-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 galaxy pixels are placed.\n", + "\n", + "The rectangular mesh covers the same region on the sky in all three fits. Its points do not move towards the bright centre when we make the galaxy 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 galaxy\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(ag.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 galaxies 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 galaxy 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 galaxy needs them.\n", + "\n", + "For a directly observed galaxy, finer mesh sampling does not create information absent from the PSF-blurred image. We want enough freedom to describe resolved structure, not to interpret every sub-PSF variation as an independent measurement.\n", + "\n", + "This motivates adapting the mesh to the **galaxy\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 galaxy\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 galaxy 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 galaxy.\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 galaxy 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 galaxy." + ] + }, + { + "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(ag.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 galaxy regions?\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 galaxy 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 galaxy 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 galaxies whose light became progressively more concentrated, while keeping the image sampling, PSF and noise realization fixed.\n", + "\n", + "For each dataset, we selected the regularization coefficient using Bayesian evidence. This allowed us to investigate limitations that remain even when the PSF is known 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 points can have different spacings across the observed galaxy.\n", + "\n", + "We will then use a fitted brightness distribution to guide a Hilbert image mesh, concentrating sampling where the galaxy 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 galaxy\u2019s bright structure, and does regularization preserve that structure while controlling fluctuations elsewhere?\n", + "\n", + "Tutorial 5 then returns to search chaining and combines an MGE with a pixelization. That alternative can let analytic profiles describe the smooth central light while a mesh supplies additional spatial freedom." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**\ud83e\udd16 Ask AI:** How would you distinguish insufficient galaxy-mesh resolution from over-smoothing when compact-galaxy 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": { + "24c2f562-da24-50bb-8eb9-5a887505fe54": { + "id": "24c2f562-da24-50bb-8eb9-5a887505fe54", + "name": "PyAutoGalaxy assistant", + "context": "These instructions were prepared by the PyAutoGalaxy assistant\n(https://github.com/PyAutoLabs/autogalaxy_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\nPyAutoGalaxy knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoGalaxy notebook (notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb in HowToGalaxy).\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 PyAutoGalaxy 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 PyAutoGalaxy docs (https://pyautogalaxy.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) extra galaxies, foreground stars or artefacts that\n need masking or noise-scaling, and (b) a mask extent that captures the galaxy's\n emission out to where it meets the sky. A mask that truncates the outer isophotes\n biases the effective radius and Sersic index, so never leave the mask radius at its\n default on real data. 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 galaxy, a sample of galaxies, a paper's analysis),\n point them to the PyAutoGalaxy assistant\n https://github.com/PyAutoLabs/autogalaxy_assistant, which runs in Claude Code or\n Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: ag_pixelization\n\n---\nname: ag_pixelization\ndescription: Reconstruct a galaxy's clumpy or irregular light directly on a regularized pixel mesh, using `ag.Pixelization` alongside a parametric `ag.lp_linear` bulge \u2014 the hybrid model that gives a smooth interpretable component plus a flexible flux map for everything the profile cannot explain. Covers when a pixelization is warranted and when it is overkill, choosing an `ag.mesh` and an `ag.reg` regularization scheme and what each costs in free parameters, why `mesh_shape` must be fixed before the fit, `over_sample_size_pixelization` rather than `over_sample_size_lp`, noise scaling instead of hard masking for contaminants, the positive-only solver, reading the reconstruction and its Bayesian-evidence terms out of the `Inversion`, exporting the reconstruction to CSV for collaborators, and the GPU/VRAM versus CPU run-time trade. Use when a Sersic, MGE or shapelet basis leaves structured residuals in discrete off-centre lumps. Not for morphology a basis can still fit (`ag_basis_profiles`), not for non-parametric isophote measurement (`ag_ellipse_fitting`), and not for the basics of model composition (`ag_build_imaging_model`).\n---\n\n# Reconstructing a galaxy on a pixel mesh\n\nEvery model in this workspace so far has been a *function*: you asserted a functional form and\nthe fit found its parameters. A pixelization drops the functional form. It lays a mesh of pixels\nover the galaxy, gives each pixel its own free flux value, and solves all of them at once by\nlinear inversion \u2014 thousands of amplitudes, none of them in the non-linear search's parameter\nspace. What stops that from simply fitting the noise is **regularization**: a prior that\npenalises solutions where neighbouring mesh pixels differ sharply, so the reconstruction is as\nsmooth as the data will allow and no smoother.\n\nThe science case is specific, and it is worth being honest about how specific. Galaxies with\nspiral arms, asymmetric star-forming clumps, tidal features or low-surface-brightness\nsubstructure have light that no smooth profile \u2014 and often no basis expansion either \u2014\nreproduces. The canonical use is therefore **hybrid**: a linear `Sersic` for the smooth central\nbulge, and a pixelization for the irregular remainder. That split is the point. It gives you a\nlow-dimensional, physically interpretable description of the bulge *and* a flux map of exactly\nwhat the bulge could not explain, which is the quantity you want if the science question is \"how\nmuch of this galaxy's light is in its irregular components?\"\n\nThis is a **direct** reconstruction. There is no second plane, no de-projection, no inversion of\na geometric mapping \u2014 the mesh sits on the image plane, over the galaxy, at the same coordinates\nthe data occupies. If you have met pixelized reconstruction before in a different context, that\nextra step is the one that is absent here, and its absence makes everything simpler: what the\nmesh reconstructs is the galaxy's own surface brightness, and its coordinates are sky\ncoordinates in arcseconds.\n\nStatistically, the fit now has two nested layers. The non-linear search explores a handful of\nparameters \u2014 the bulge's shape, the regularization strength \u2014 and for each proposal an inversion\nsolves the mesh amplitudes exactly and returns a **Bayesian evidence** rather than a plain\nlikelihood. That evidence includes terms that penalise an unnecessarily complex reconstruction,\nwhich is what lets a model with 400 free flux values not simply win by over-fitting. The theory\nis [`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md);\nchapter 3 of HowToGalaxy derives it from scratch.\n\nThe canonical scripts are\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py` (via a search),\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` (one direct fit, with the\ninversion internals walked through) and\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py` (export).\n\n## Before you reach for one \u2014 the honest gate\n\nA pixelization is the most powerful and the most expensive model here, with failure modes the\nparametric models do not have. Three questions decide whether you need it:\n\n- *\"What do the residuals actually look like?\"* Structured residuals that **follow the\n isophotes** \u2014 a twist, a four-lobed pattern, an ellipticity that changes with radius \u2014 are a\n *shape* problem, and an MGE fixes them for four free parameters\n ([`ag_basis_profiles`](./ag_basis_profiles.md)). Residuals in **discrete off-centre lumps** are\n a substructure problem, and that is what a pixelization is for. Look before you choose.\n- *\"Is the structure resolved?\"* At low resolution the irregular structure is not in the data,\n and a pixelization will reconstruct noise while telling you it fitted well. The workspace is\n blunt about this: for low-resolution data pixelizations are unnecessary.\n- *\"Do you need global quantities or the light distribution?\"* If you want a total flux, a size,\n an axis ratio, a Sersic index \u2014 a parametric profile, an MGE or shapelets are enough and far\n cheaper. If you want the morphology itself, particularly for faint or low-surface-brightness\n features, nothing else comes close.\n\nIf the answer to the first is \"lumps\" and to the second \"yes\", continue. Otherwise you will\nspend hours to learn less.\n\n## Ask\n\n- *\"What is the smooth component?\"* Almost always a linear `Sersic`, sometimes a Sersic plus an\n Exponential disk. It matters that this exists: without it the pixelization has to reconstruct\n the bright central bulge too, which wastes mesh resolution on light a six-parameter profile\n describes perfectly and makes the regularization choice much harder.\n- *\"How many mesh pixels can you afford?\"* This is a hardware question as much as a science one\n (see the run-time branch). 20 \u00d7 20 is the workspace's default for a GPU run; 28 \u00d7 28 to 30 \u00d7 30\n for a better-resolved reconstruction; higher only with a large-VRAM GPU or a many-core CPU.\n- *\"Are there neighbours inside the mask?\"* For a pixelization the answer is *never* to mask\n their pixels out \u2014 that punches holes in the mesh and produces discontinuity systematics.\n Scale their noise instead (branch below).\n- *\"GPU or CPU?\"* Genuinely depends on your pixel scale, and the answer is counter-intuitive.\n Ask before assuming.\n\n## Branch \u2014 the hybrid model\n\nThe canonical composition. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py`.\n\n```python\n\"\"\"\nGalaxy Structure: Bulge Plus Pixelized Reconstruction\n=====================================================\n\nFit a galaxy whose light has two very different characters: a smooth central bulge, described by\na linear Sersic profile in six parameters, and asymmetric clumpy star formation, reconstructed\non a regularized rectangular mesh. The Sersic captures the smooth component interpretably; the\npixelization absorbs everything it cannot explain, and the reconstructed flux map is the\nmeasurement of the galaxy's irregular light.\n\n__Contents__\n\n- **Imports:** Import the required libraries.\n- **Dataset:** Load the imaging, mask it, and apply pixelization over-sampling.\n- **Mesh Shape:** Fix the mesh dimensions before the model is composed.\n- **Model:** Compose the linear Sersic bulge plus the pixelization.\n- **Check:** Confirm the parameter count, evaluate one likelihood, and estimate VRAM.\n\"\"\"\n\n\"\"\"\n__Imports__\n\"\"\"\nfrom pathlib import Path\n\nimport autofit as af\nimport autogalaxy as ag\nimport autogalaxy.plot as aplt\n\nDATASET_PATH = Path(\"dataset\") / \"imaging\" / \"my_galaxy\"\nPIXEL_SCALES = 0.1\nMASK_RADIUS = 2.0\n\n\"\"\"\n__Dataset__\n\nThe mask matters more than usual here. It defines the region the mesh is laid over, so a mask\nthat reaches well beyond the galaxy spends reconstruction pixels on empty sky, and one that\ntruncates the galaxy leaves its outer light unreconstructed\n(`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nThe over-sampling argument changes too. A pixelization evaluates light on its *own* grid with\nits own over-sampling scheme, so the size is passed as `over_sample_size_pixelization`, not the\n`over_sample_size_lp` used for light profiles. Passing the wrong one silently leaves the\npixelization grid at its default.\n\"\"\"\ndataset = ag.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=PIXEL_SCALES,\n)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=dataset.pixel_scales,\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\nover_sample_size = ag.util.over_sample.over_sample_size_via_radial_bins_from(\n grid=dataset.grid,\n sub_size_list=[8, 4, 2],\n radial_list=[0.3, 0.6],\n centre_list=[(0.0, 0.0)],\n)\ndataset = dataset.apply_over_sampling(over_sample_size_pixelization=over_sample_size)\n\n\"\"\"\n__Mesh Shape__\n\nThe mesh dimensions are **fixed before the fit and cannot be a free parameter**. This is not a\nstylistic restriction: JAX compiles the likelihood against statically shaped arrays, and the\nmesh shape sets those shapes. A rectangular mesh uses the same number of pixels in y and x.\n\"\"\"\nmesh_pixels_yx = 20\nmesh_shape = (mesh_pixels_yx, mesh_pixels_yx)\n\n\"\"\"\n__Model__\n\n`ag.Pixelization` pairs a `mesh` \u2014 where the reconstruction pixels sit \u2014 with a `regularization`\nscheme \u2014 the prior that keeps the solution smooth. It is attached to the galaxy as a component\nalongside the light profiles, and the analysis knows to route it through the inversion\n(`PyAutoGalaxy:autogalaxy/galaxy/galaxy.py`).\n\nThe mesh itself contributes **zero** free parameters; the reconstruction's 400 flux values are\nsolved, not sampled. `ag.reg.GaussianKernel` contributes two (`coefficient`, `scale`). The bulge\nis `ag.lp_linear.Sersic` rather than `ag.lp.Sersic` deliberately: its `intensity` is then solved\nby the *same* inversion that solves the mesh amplitudes, which removes the degeneracy between\n\"bright bulge, faint reconstruction\" and \"faint bulge, bright reconstruction\" that a sampled\nintensity would create.\n\"\"\"\npixelization = af.Model(\n ag.Pixelization,\n mesh=ag.mesh.RectangularBilinearAdaptDensity(shape=mesh_shape),\n regularization=ag.reg.GaussianKernel,\n)\n\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n pixelization=pixelization,\n)\n\nmodel = af.Collection(galaxies=af.Collection(galaxy=galaxy))\n\n\"\"\"\n__Check__\n\nThree cheap checks, each catching a different class of mistake: the parameter count catches a\nmodel error, one likelihood evaluation catches an incompatibility between model, dataset and\nanalysis, and `print_vram_use` catches the resource error that otherwise appears as an\nout-of-memory failure mid-compile. Pixelizations use far more VRAM than profile-only models \u2014\naround 0.05 GB per batched likelihood for 400 reconstruction pixels \u2014 which is why the\nworkspace uses a lower `n_batch` for these fits than for any other.\n\"\"\"\nprint(model.info)\nprint(f\"Total free parameters = {model.total_free_parameters}\")\n\nanalysis = ag.AnalysisImaging(dataset=dataset, use_jax=True)\n\nlog_likelihood = analysis.log_likelihood_function(\n instance=model.instance_from_prior_medians()\n)\nprint(f\"log evidence at prior medians: {float(log_likelihood):.2f}\")\n```\n\n**N = 8**: the Sersic's centre (2), `ell_comps` (2), `effective_radius`, `sersic_index`, plus the\nregularization's `coefficient` and `scale`. Eight parameters, with 400 reconstruction pixels\nsolved. That is dramatically more parsimonious than stacking parametric profiles until every\nclump is described \u2014 a 20-plus-parameter approach that would still fail on genuinely irregular\nsubstructure.\n\nExtending the smooth side is exactly as you would expect \u2014 add components as further attributes\non the galaxy, keeping them linear so their intensities join the same inversion:\n\n```python\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n disk=ag.lp_linear.Exponential,\n pixelization=pixelization,\n)\n```\n\n## Branch \u2014 choosing a mesh and a regularization scheme\n\nThese are the two decisions that make or break a reconstruction, and the parameter cost is\nalmost entirely in the regularization.\n\n**Meshes** (`ag.mesh`, from `PyAutoArray:autoarray/inversion/mesh/`). All contribute zero free\nparameters, all take `shape=(y, x)`:\n\n| Mesh | Where the pixels go | Use when |\n|---|---|---|\n| `RectangularUniform` | a uniform grid over the masked region | you want the simplest, most predictable behaviour |\n| `RectangularBilinearAdaptDensity` | a rectangular grid whose spacing follows the density of image pixels mapping to it, via a rank CDF | **the default** \u2014 concentrates resolution where the data is, and the fast CPU path |\n| `RectangularBilinearAdaptImage` | adapts to the image's own brightness distribution | the irregular light is strongly concentrated |\n| `RectangularRTUAdaptDensity` / `RectangularRTUAdaptImage` | the same two adaptations through a kernel CDF | GPU, JAX gradient samplers and the interferometer sparse path \u2014 the only rectangular adaptive meshes with usable gradients |\n\n`Delaunay`, `KNearestNeighbor` and `KNNBarycentric` also exist for irregular tessellations. Start\nwith `RectangularBilinearAdaptDensity`; it is what every workspace example uses, and the\nrectangular meshes are what the fixed-`mesh_shape` JAX path is built around. (The unqualified\n`RectangularAdaptDensity` / `RectangularAdaptImage` names were split into the `Bilinear` and\n`RTU` families in PyAutoArray 2026.8.23.1 and no longer resolve.)\n\n**Regularization** (`ag.reg`, from `PyAutoArray:autoarray/inversion/regularization/`) is where\nthe free parameters are, and where the physics of \"how smooth should this be?\" lives:\n\n| Scheme | Free params | What it assumes |\n|---|---|---|\n| `ag.reg.Constant` | 1 (`coefficient`) | one smoothing strength everywhere \u2014 the simplest, and the right first try |\n| `ag.reg.GaussianKernel` | 2 (`coefficient`, `scale`) | smoothing with a Gaussian correlation length you also fit |\n| `ag.reg.MaternKernel` | 3 (`coefficient`, `scale`, `nu`) | as above, with the roughness `nu` free too |\n\nVerified counts, not guesses. `Constant` penalises the difference between neighbouring pixels\ndirectly; the kernel schemes impose a correlation *length*, which is physically closer to what a\ngalaxy's light does and generally reconstructs faint extended structure better at the cost of\none or two parameters. `ag.reg.Adapt` and its variants scale the smoothing with the\nreconstruction's own brightness \u2014 more flexible again, and requiring `adapt_images` on the\nanalysis, which is a step beyond this skill; ask if you want it.\n\nA practical route: fit once with `ag.reg.Constant`, look at the reconstruction. Over-smoothed\n(the clumps blurred into one blob) or over-fitted (isolated single-pixel spikes) both show up\nimmediately, and if `Constant` cannot get both the bright clumps and the faint envelope right at\none strength, that is the signal to move to `GaussianKernel`. Full discussion in\n[`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\n### The positive-only solver\n\nEvery pixelized reconstruction uses a **positive-only** linear solver: no mesh pixel may\nreconstruct negative flux. This is not a numerical nicety. A signed solver lets the\nreconstruction manufacture negative pixels that cancel positive ones to over-fit the data, and\nthat failure mode is a well-known systematic in the literature \u2014 many published methods allow\nit. Enforcing non-negativity efficiently needs bespoke linear algebra, which is why this is\nworth stating rather than assuming. You do not need to switch it on; it is the behaviour you get.\n(The one place in this workspace where you *must* disable it is a shapelet basis, which needs\nsigned amplitudes by construction \u2014 see [`ag_basis_profiles`](./ag_basis_profiles.md).)\n\n## Branch \u2014 contaminating neighbours: scale, never mask\n\nThe general advice for a neighbour inside the mask is to mask its pixels out. **For a\npixelization that advice is wrong.** Removing image pixels entirely removes their mapping into\nthe mesh, punching a hole in the reconstruction and producing discontinuities that show up as\nunexplained systematics.\n\nInstead, keep the pixels in the fit and make them contribute nothing: scale their data to zero\nand their noise to an enormous value, so the likelihood is indifferent to them while the mesh\nstays continuous. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py`.\n\n```python\n\"\"\"\n__Noise Scaling__\n\n`mask_extra_galaxies.fits` marks the contaminated pixels. It is loaded with `invert=True`\nbecause in this file `True` means \"scale this pixel\", the opposite of a modelling mask's\nconvention \u2014 getting this backwards scales the galaxy instead of the neighbour, so check the\nsubplot afterwards. `apply_noise_scaling` then zeroes those data values and inflates their\nnoise, driving their signal-to-noise to effectively zero without removing them from the mesh's\nmapping (`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nOrder matters: scale first, then apply the modelling mask.\n\"\"\"\nmask_extra_galaxies = ag.Mask2D.from_fits(\n file_path=DATASET_PATH / \"mask_extra_galaxies.fits\",\n pixel_scales=PIXEL_SCALES,\n invert=True,\n)\n\ndataset = dataset.apply_noise_scaling(mask=mask_extra_galaxies)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=PIXEL_SCALES,\n centre=(0.0, 0.0),\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\naplt.subplot_imaging_dataset(\n dataset=dataset,\n output_path=\"scripts/scratch/my_galaxy/\",\n output_filename=\"noise_scaled\",\n output_format=\"png\",\n)\n```\n\nThe scaled pixels go to a noise value of order 1e8 \u2014 visibly so on the signal-to-noise panel of\nthe subplot, which is how you confirm you scaled the right region. Drawing that mask\ninteractively is `autogalaxy_workspace:scripts/imaging/data_preparation/gui/mask_extra_galaxies.py`;\nthe modelling alternative, and when to prefer it, is\n[`ag_light_model_extras`](./ag_light_model_extras.md) and\n[`../wiki/core/concepts/extra_galaxies_and_noise_scaling.md`](../wiki/core/concepts/extra_galaxies_and_noise_scaling.md).\n\n## Branch \u2014 reading the reconstruction out\n\nThe reconstruction is the science product, and it lives on the `Inversion`. You can get there\nfrom a completed fit or from a single direct `ag.FitImaging` \u2014 the latter is the fast way to\nlearn the objects, and is what\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` does.\n\n```python\n\"\"\"\n__Reconstruction__\n\n`inversion.linear_obj_list` holds the linear objects the fit solved. For a hybrid model that is\none `LightProfileLinearObjFuncList` per linear light profile plus one `Mapper` for the\npixelization; with a non-linear `ag.lp.Sersic` bulge the `Mapper` is the only entry. The\n`Mapper` is the object that maps image pixels onto mesh pixels, and it is mesh-specific\n(`PyAutoArray:autoarray/inversion/mappers/`).\n\nThe reconstruction is a 1D array of one flux per mesh pixel, and the (y, x) arcsecond\ncoordinates of those pixels are `mapper.mesh_geometry.mesh_grid`. Pair them by index \u2014 they are\nthe same length and the same order \u2014 and you have the flux map.\n\"\"\"\ninversion = fit.inversion\n\nmapper = inversion.linear_obj_list[0]\n\nreconstruction = inversion.reconstruction\nmesh_grid = mapper.mesh_geometry.mesh_grid\n\nprint(f\"{len(reconstruction)} mesh pixels reconstructed\")\nprint(f\"coordinates shape: {mesh_grid.shape}\")\n\n\"\"\"\nThe reconstruction's own uncertainties, and its projection back onto the image, are also on the\ninversion. `mapped_reconstructed_operated_data` contains **only** the pixelized component \u2014 any\nparametric light profile in the model is excluded \u2014 which is exactly the quantity you want when\nasking how much light is in the irregular component.\n\"\"\"\nprint(inversion.reconstruction_noise_map)\nprint(inversion.mapped_reconstructed_operated_data.native.shape)\n```\n\nA caution on the `Mapper`'s grid attributes: `mapper.image_plane_mesh_grid` is `None` for a\nrectangular mesh, so do not reach for it. `mapper.mesh_geometry.mesh_grid` is the mesh pixel\ncentres (verified on the released stack), and `mapper.image_plane_data_grid` is the centre of\nevery masked *image* pixel. Some of the `Mapper`'s attribute names carry a plane-based prefix\ninherited from the shared inversion library that also serves a multi-plane use case; in\nPyAutoGalaxy there is only the one plane, so `mesh_geometry.mesh_grid` is both the clearer name\nand the one to use.\n\n### The evidence terms\n\nThe inversion's figure of merit is a Bayesian evidence, and its components are individually\nreadable \u2014 which is how you diagnose whether regularization is doing too much or too little:\n\n```python\nprint(f\"regularization term = {inversion.regularization_term}\")\nprint(f\"log det regularization = {inversion.log_det_regularization_matrix_term}\")\nprint(f\"log det curvature + reg = {inversion.log_det_curvature_reg_matrix_term}\")\n```\n\nThe `regularization_term` measures how non-smooth the solution is; the two log-determinant\nterms are the complexity penalty that stops a 400-pixel model winning by over-fitting. The\nmatrices themselves (`curvature_matrix`, `regularization_matrix`, `curvature_reg_matrix`) are\nthere too. The derivation is in HowToGalaxy chapter 3 and the papers it cites\n(arXiv:1708.07377, arXiv:astro-ph/0601493).\n\n### Diagnostic plots\n\n`aplt.subplot_fit_imaging` includes the reconstruction on the mesh alongside the usual data /\nmodel / residual panels, and is the first thing to look at. For the inversion's own internals\nthere are dedicated functions, which live in the shared inversion library rather than on `aplt`\nand so are imported explicitly \u2014 from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/plot.py`:\n\n```python\nfrom autoarray.inversion.plot.inversion_plots import subplot_of_mapper\nfrom autoarray.inversion.plot.mapper_plots import plot_mapper, subplot_image_and_mapper\n\nPLOT_DIR = \"scripts/scratch/my_galaxy/pixelization/\"\n\naplt.subplot_fit_imaging(fit=fit, output_path=PLOT_DIR, output_format=\"png\")\n\nsubplot_of_mapper(\n inversion=inversion, mapper_index=0, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nsubplot_image_and_mapper(\n mapper=mapper, image=dataset.data, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nprint(f\"Saved to: {Path(PLOT_DIR).resolve()}\")\n```\n\n`subplot_of_mapper` is the comprehensive diagnostic \u2014 reconstructed image, reconstruction, its\nnoise map and the regularization weights in one figure \u2014 and `mapper_index` selects which linear\nobject when there are several. `subplot_image_and_mapper` shows the data beside the mesh, which\nis how you check the mesh covers the structure you care about. Note that none of these three are\nexposed on the `aplt` module \u2014 `dir(aplt)` does not list them, so the explicit imports above are\nthe only route and writing them as `aplt.`-prefixed calls raises `AttributeError`. Quote the\nabsolute path back to the user and offer to open it.\n\n## Branch \u2014 exporting the reconstruction for downstream science\n\nA reconstruction that only exists inside a PyAutoGalaxy object is hard to share and hard to\nanalyse with other tools. Write it out as coordinates plus values and it becomes ordinary\ntabular data. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py`.\n\n```python\n\"\"\"\n__Export__\n\nFour columns \u2014 y, x, reconstruction, noise \u2014 is everything needed to rebuild, plot or analyse\nthe galaxy's irregular light without PyAutoGalaxy installed. A completed model-fit also writes\nthis file into its own `image/` folder automatically; glob for `*reconstruction_0.csv` there\nrather than hard-coding the stem, which carries a prefix from the shared inversion library.\n\"\"\"\nimport csv\n\nCSV_PATH = Path(\"scripts/scratch/my_galaxy/reconstruction.csv\")\nCSV_PATH.parent.mkdir(parents=True, exist_ok=True)\n\nwith CSV_PATH.open(\"w\", newline=\"\") as f:\n writer = csv.writer(f)\n writer.writerow([\"y\", \"x\", \"reconstruction\", \"noise_map\"])\n for (y, x), value, sigma in zip(\n mesh_grid, inversion.reconstruction, inversion.reconstruction_noise_map\n ):\n writer.writerow([y, x, value, sigma])\n\nprint(f\"Reconstruction written to: {CSV_PATH.resolve()}\")\n```\n\nReloading needs nothing but the standard library, and from there the mesh is ordinary scattered\ndata. Two things the workspace does with it: a Delaunay triangulation via `scipy.spatial` for\nplotting on the mesh's own geometry, and interpolation onto a regular grid via\n`scipy.interpolate.griddata` for any tool that wants an image:\n\n```python\nimport numpy as np\nfrom scipy.interpolate import griddata\n\nwith CSV_PATH.open() as f:\n reader = csv.reader(f)\n header = next(reader) # ['y', 'x', 'reconstruction', 'noise_map']\n loaded = {key: [] for key in header}\n for row in reader:\n for key, value in zip(header, row):\n loaded[key].append(float(value))\n\npoints = np.stack((np.array(loaded[\"x\"]), np.array(loaded[\"y\"])), axis=-1)\n\ninterpolation_grid = ag.Grid2D.from_extent(\n extent=(-1.0, 1.0, -1.0, 1.0), shape_native=(201, 201)\n)\n\ninterpolated = griddata(\n points=points, values=np.array(loaded[\"reconstruction\"]), xi=interpolation_grid\n)\n```\n\nPick the `extent` to cover the reconstruction without over-resolving it \u2014 the mesh has a few\nhundred pixels, so interpolating to 201 \u00d7 201 is presentation, not information.\n\n## Branch \u2014 run time, VRAM, and the GPU-versus-CPU choice\n\nThis is the branch that decides whether your fit takes twenty minutes or three hours, and the\nanswer is not \"use the GPU\".\n\nPixelized inversions do linear algebra on very large, very **sparse** matrices. JAX has no sparse\nsupport and must work densely, which scales badly; the CPU implementation exploits the sparsity\nfully via `numba`. So the trade runs against pixel scale:\n\n- **Low resolution (`pixel_scales > 0.05`, e.g. Euclid)** \u2014 fewer sparse operations, modest VRAM.\n **GPU with JAX is usually fastest.**\n- **High resolution (`pixel_scales <= 0.03`, e.g. HST or JWST)** \u2014 the linear algebra is\n sparsity-dominated. **CPU with `numba` and many cores can beat a powerful GPU.**\n\nVRAM is the hard constraint on the GPU side. JAX must hold the whole batched likelihood in GPU\nmemory, and a pixelization needs far more than a profile model: around 0.05 GB per batched\nlikelihood at 400 reconstruction pixels, and more than 1 GB \u2014 occasionally more than 10 GB \u2014 at\nhigh resolution with a fine mesh. Two consequences: keep `n_batch` low (the workspace uses 20 for\npixelizations against 50 elsewhere), and run\n\n```python\nanalysis.print_vram_use(model=model, batch_size=search.batch_size)\n```\n\nbefore committing. It takes 20\u201330 seconds and it is the difference between knowing your limit\nand discovering it as an out-of-memory error mid-compile. If VRAM is exceeded the run time does\nnot degrade gracefully \u2014 it goes from under ten minutes to three hours or more.\n\nRough scaling with resolution, from the workspace's own measurements at a 20 \u00d7 20 mesh with VRAM\nunder control: ~10 minutes at 0.1\"/pixel, ~30 minutes at 0.05\", ~1 hour at 0.03\". Benchmark both\npaths for your own data and hardware rather than trusting either default; the search-side\nsettings are [`ag_configure_search`](./ag_configure_search.md) and\n[`../wiki/core/api/searches.md`](../wiki/core/api/searches.md).\n\n## Combine \u2014 where this hands off\n\n- **Try a basis first** \u2192 [`ag_basis_profiles`](./ag_basis_profiles.md). An MGE is four\n parameters and minutes, not hours, and it genuinely resolves the shape-mismatch case a\n pixelization is often reached for by mistake.\n- **Pick and configure the search** \u2192 [`ag_configure_search`](./ag_configure_search.md), with\n `n_batch` lowered for VRAM.\n- **Run the fit** \u2192 [`ag_run_search`](./ag_run_search.md); `image/fit.png` in the output folder\n refreshes on the fly and shows the reconstruction as it converges, which is worth watching for\n a fit this long.\n- **Plot and inspect** \u2192 [`ag_plot_fit`](./ag_plot_fit.md), plus the `subplot_of_mapper`\n diagnostics above.\n- **Load a completed fit programmatically** \u2192 [`ag_load_results`](./ag_load_results.md).\n- **Over-smoothed, over-fitted or unphysical reconstruction** \u2192\n [`ag_debug_fit_failure`](./ag_debug_fit_failure.md). The pixelization-specific causes are a\n regularization scheme too rigid for the structure, a mask that punched holes via hard masking\n where noise scaling was needed, over-sampling passed as `over_sample_size_lp` instead of\n `over_sample_size_pixelization`, and VRAM exhaustion masquerading as a hung fit.\n- **Contaminating neighbours modelled rather than scaled** \u2192\n [`ag_light_model_extras`](./ag_light_model_extras.md).\n- **Make the fit robust and fast by starting simple** \u2192 the search-chaining skill\n (`ag_chain_searches`). This is the biggest available win for a pixelization: fit a parametric\n model first, pass its bulge as priors, and let the expensive model start from a sensible place.\n The workspace recommends it explicitly.\n- **A non-parametric shape measurement instead of a reconstruction** \u2192\n [`ag_ellipse_fitting`](./ag_ellipse_fitting.md).\n\nOffer (default-yes) a dated `wiki/project/YYYY-MM-DD-.md` entry recording the mesh and its\nshape, the regularization scheme and why, the mask radius, the hardware path, and what the\nreconstruction showed \u2014 with a pixelization the choices *are* the result, and none of them are\nrecoverable from the output folder alone.\n\n## Further reading\n\n- **Student / new to galaxy morphology** \u2014 [HowToGalaxy: Bayesian regularization](https://github.com/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_bayesian_regularization.ipynb):\n why a model with hundreds of free flux values does not simply over-fit, derived from scratch;\n `tutorial_6_model_fit` then runs the full fit.\n- **General reference** \u2014 [RTD: Pixelization API](https://pyautogalaxy.readthedocs.io/en/latest/api/pixelization.html):\n every mesh, regularization scheme and the objects that combine them.\n- **Experienced PyAutoGalaxy user** \u2014 [workspace: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autogalaxy_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n the hybrid fit end to end, with the run-time, VRAM and GPU-versus-CPU discussion in full.\n\n## Agent procedural checklist\n\n1. Look at the residual map of a parametric or basis fit first. Shape-following residuals mean\n `ag_basis_profiles`; discrete off-centre lumps mean a pixelization. Do not skip this.\n2. Confirm the structure is resolved \u2014 at coarse pixel scales a pixelization reconstructs noise.\n3. Always pair the pixelization with a linear light profile for the smooth component; never let\n the mesh reconstruct the bulge.\n4. Fix `mesh_shape` before composing the model; it cannot be a free parameter.\n5. Pass over-sampling as `over_sample_size_pixelization`, not `over_sample_size_lp`.\n6. Start with `RectangularBilinearAdaptDensity` + `ag.reg.Constant`; move to `GaussianKernel` only when\n one smoothing strength demonstrably cannot serve both the bright and faint structure.\n7. Scale contaminant noise (`apply_noise_scaling`, `invert=True`) \u2014 never hard-mask pixels inside\n a pixelization fit \u2014 and check the signal-to-noise panel afterwards.\n8. `print(model.info)`, evaluate one likelihood, then `print_vram_use` before any GPU run; keep\n `n_batch` low.\n9. Read the reconstruction via `inversion.reconstruction` +\n `mapper.mesh_geometry.mesh_grid`; export to CSV for anything downstream.\n10. Save the script to `scripts/`, quote every plot's absolute path and offer to open it, then\n offer the `wiki/project/` entry recording mesh, regularization and hardware choices.\n" + } + }, + "selectedAiContext": "24c2f562-da24-50bb-8eb9-5a887505fe54" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb b/notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb new file mode 100644 index 0000000..efc0aba --- /dev/null +++ b/notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb @@ -0,0 +1,1495 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Tutorial 4: Adaptive Reconstruction\n", + "===================================\n", + "\n", + "Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization for directly observed galaxies.\n", + "\n", + "__Contents__\n", + "\n", + "- Adaptive galaxy reconstruction\n", + "- Returning to the compact-galaxy problem\n", + "- From image coordinates to a reconstruction mesh\n", + "- Constructing the Delaunay galaxy 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", + "- 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/HowToGalaxy/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 autogalaxy 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(\"howtogalaxy\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Adaptive galaxy reconstruction__\n", + "\n", + "In **Fit Problems**, we identified two limitations of our reconstruction: too few mesh points across compact galaxy 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 galaxy mesh with a **Delaunay mesh**. Its points need not lie in regular rows and columns, allowing us to vary their spacing across the galaxy.\n", + "\n", + "We then need to decide where to place those points. We will select coordinates over the observed image, using an **image mesh**. Those same sky coordinates become the reconstruction vertices: there is no ray tracing.\n", + "\n", + "This gives us two useful ways to allocate resolution:\n", + "\n", + "- An evenly spaced overlay provides a simple starting mesh, independent of the galaxy brightness.\n", + "- A brightness-adaptive image mesh places more points in bright image regions, directing more resolution towards the galaxy emission.\n", + "\n", + "Finally, we will introduce adaptive regularization, allowing the smoothing strength to vary across the reconstructed galaxy.\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", + "- Connecting its sky coordinates to build a Delaunay 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", + "- Preparing for the combined analytic and pixelized model in Tutorial 5.\n", + "\n", + "---" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Returning to the compact-galaxy problem__\n", + "\n", + "We return to the very compact galaxy 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 PSF and Gaussian noise realization. This makes the tutorial runnable independently." + ] + }, + { + "cell_type": "code", + "metadata": {}, + "source": [ + "\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import autogalaxy as ag\n", + "import autogalaxy.plot as aplt\n", + "import autoarray.plot as aaplt\n", + "\n", + "simulation_grid = ag.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 = ag.Convolver.from_gaussian(\n", + " shape_native=(11, 11),\n", + " sigma=0.05,\n", + " pixel_scales=0.05,\n", + ")\n", + "\n", + "true_source = ag.Galaxy(\n", + " redshift=0.5,\n", + " bulge=ag.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_galaxies = ag.Galaxies(galaxies=[true_source])\n", + "\n", + "simulator = ag.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_galaxies_from(\n", + " galaxies=true_galaxies,\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 = ag.Imaging(\n", + " data=ag.Array2D.no_mask(\n", + " values=np.asarray(noiseless.data.native) + noise_values,\n", + " pixel_scales=0.05,\n", + " ),\n", + " noise_map=ag.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 = ag.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": [ + "We keep the image sampling and PSF fixed. This lets us inspect mesh placement and regularization separately from any choice of accompanying analytic profiles.\n", + "\n", + "We know the simulated galaxy 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 image coordinates to a reconstruction mesh__\n", + "\n", + "Previously, we constructed a rectangular mesh over the galaxy image.\n", + "\n", + "For our Delaunay reconstruction, we instead begin by selecting coordinates in the **image plane**. These coordinates form the image mesh. These are also the coordinates at which we infer the galaxy brightness. The shared mapper API calls them `source_plane_mesh_grid`, but here they refer to the same sky as the image.\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 = ag.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", + "We will connect these coordinates directly into a triangulation." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The overlay still does not inspect brightness. Changing an extended galaxy into a compact one leaves these coordinates unchanged for a fixed mask. Switching triangulation alone therefore does not allocate extra resolution to the bright centre. We will establish a baseline reconstruction before introducing brightness-based placement." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Constructing the Delaunay galaxy mesh__\n", + "\n", + "We now connect the selected mesh points into a **Delaunay triangulation**.\n", + "\n", + "Unlike a rectangular mesh, the triangles can have different sizes and orientations. Their vertices follow the selected image-mesh coordinates, allowing finer sampling in some galaxy regions than others.\n", + "\n", + "The galaxy 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 = ag.Pixelization(\n", + " mesh=ag.mesh.Delaunay(\n", + " pixels=image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=ag.reg.Constant(\n", + " coefficient=coefficient,\n", + " ),\n", + " )\n", + "\n", + " source_galaxy = ag.Galaxy(\n", + " redshift=0.5,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " galaxies = ag.Galaxies(galaxies=[source_galaxy])\n", + "\n", + " adapt_images = ag.AdaptImages(\n", + " galaxy_image_plane_mesh_grid_dict={\n", + " source_galaxy: image_plane_mesh_grid,\n", + " },\n", + " )\n", + "\n", + " return ag.FitImaging(\n", + " dataset=dataset,\n", + " galaxies=galaxies,\n", + " adapt_images=adapt_images,\n", + " )\n", + "\n", + "\n", + "overlay_fit = fit_delaunay_overlay(coefficient=1.0)\n", + "overlay_mapper = overlay_fit.inversion.cls_list_from(ag.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 galaxy. Here, we are supplying an overlay; we have not yet introduced brightness adaptation.\n", + "\n", + "Inspect the mesh points around the reconstructed galaxy. For this overlay, their spacing is initially uniform; the Hilbert example below will change that distribution.\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 an image 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\"Galaxy 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 galaxy 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" + ], + "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 galaxy centre and bright image 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 galaxy 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 galaxy emission." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Building an adapt image__\n", + "\n", + "Our overlay samples the image mask uniformly. It does not distinguish bright galaxy emission from background noise.\n", + "\n", + "To direct more mesh points towards the galaxy 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 galaxy 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 Galaxy 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 galaxy regions 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 galaxy 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", + "This example reconstructs one galaxy entirely on a mesh. In a combined MGE\u2013pixelization model, decide which light the pixelization should describe before choosing its adapt image. The total model can be dominated by a smooth bulge; it need not be the best guide for irregular outer components. Inspect neighbours and residuals rather than treating a noisy residual map as known positive galaxy light.\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 galaxy mesh. The resulting coordinates are connected directly 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 = ag.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 galaxy regions 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", + "These selected sky coordinates will be the reconstruction vertices." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The point distribution now responds to the fitted image brightness. As the galaxy becomes more compact, a brightness-weighted mesh can place more points across that compact emission. Its effective resolution remains limited by the image sampling, PSF and signal-to-noise; more vertices alone do not recover arbitrary small-scale structure.\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 = ag.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 = ag.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 galaxy.\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 galaxy 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 galaxy 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 = ag.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 = ag.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 galaxy emission, 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 galaxy light, 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 galaxy mesh.\n", + "\n", + "The reconstruction proceeds as before: construct the triangulation at the selected sky coordinates, 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 = ag.Pixelization(\n", + " mesh=ag.mesh.Delaunay(\n", + " pixels=hilbert_image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=ag.reg.Constant(\n", + " coefficient=coefficient,\n", + " ),\n", + " )\n", + "\n", + " source_galaxy = ag.Galaxy(\n", + " redshift=0.5,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " galaxies = ag.Galaxies(galaxies=[source_galaxy])\n", + "\n", + " adapt_images = ag.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 ag.FitImaging(\n", + " dataset=dataset,\n", + " galaxies=galaxies,\n", + " adapt_images=adapt_images,\n", + " )\n", + "\n", + "\n", + "hilbert_fit = fit_delaunay_hilbert(\n", + " coefficient=overlay_coefficient,\n", + ")\n", + "\n", + "hilbert_mapper = hilbert_fit.inversion.cls_list_from(ag.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 Galaxy 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 galaxy 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 galaxy regions. 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", + "\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 galaxy 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 galaxy 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 galaxy 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 galaxy.\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 = ag.Pixelization(\n", + " mesh=ag.mesh.Delaunay(\n", + " pixels=hilbert_image_plane_mesh_grid.shape[0],\n", + " ),\n", + " regularization=regularization,\n", + " )\n", + "\n", + " source_galaxy = ag.Galaxy(\n", + " redshift=0.5,\n", + " pixelization=pixelization,\n", + " )\n", + "\n", + " galaxies = ag.Galaxies(galaxies=[source_galaxy])\n", + "\n", + " adapt_images = ag.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 ag.FitImaging(\n", + " dataset=dataset,\n", + " galaxies=galaxies,\n", + " adapt_images=adapt_images,\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=ag.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", + "\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 galaxy 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 galaxy brightness.\n", + "\n", + "The adapt image supplies a brightness-based signal estimate for each galaxy 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 galaxy centre. A bright off-centre component can receive the high-signal coefficient too.\n", + "\n", + "For our compact galaxy, 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 = ag.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(ag.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: Galaxy 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 galaxy. Smaller weights should occur where the adapt image indicates stronger galaxy 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 galaxy. How do we assign a strength to the regions between them?\n", + "\n", + "We first raise each normalized galaxy 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 galaxy 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 galaxy 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 PSF, 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 = ag.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 analysis, a non-linear search can explore these regularization parameters. Tutorial 5 demonstrates search chaining for a combined MGE\u2013pixelization model, using constant regularization to isolate the component-decomposition question.\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(ag.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}: Galaxy 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 image 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", + " ag.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 PSF 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, Tutorial 5 explores a complementary strategy: fitting smooth light with an MGE and additional light with a pixelization. This can reduce the burden on the mesh, but makes the interpretation of individual components depend on how their shared light is divided." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "__Wrap Up__\n", + "\n", + "We have separated two choices that a single regularization coefficient cannot make: where the mesh has resolution,\n", + "and how strongly its brightnesses are smoothed. The Hilbert mesh uses a fitted image to guide placement, while\n", + "adaptive Split regularization changes the local smoothing strengths on that mesh.\n", + "\n", + "Judge both changes using the residuals and evidence on the same dataset. The adapt image, mesh size, PSF and noise\n", + "remain assumptions to inspect. A higher sampled log evidence does not identify the reliability of an individual knot or show\n", + "that the adaptive parameters have been fully explored.\n", + "\n", + "Our S\u00e9rsic example is deliberately difficult for a coarse mesh, but an analytic profile can describe it directly.\n", + "The lesson is not to replace every analytic model with a pixelization. In the final tutorial we fit an MGE and a\n", + "pixelization together, examining both the quality of the total image fit and the ambiguity of the decomposition.\n", + "\n", + "**\ud83e\udd16 Ask AI:** How do brightness-weighted mesh placement and adaptive regularization address different limitations,\n", + "and why can neither recover information erased by telescope blurring?" + ] + } + ], + "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": { + "24c2f562-da24-50bb-8eb9-5a887505fe54": { + "id": "24c2f562-da24-50bb-8eb9-5a887505fe54", + "name": "PyAutoGalaxy assistant", + "context": "These instructions were prepared by the PyAutoGalaxy assistant\n(https://github.com/PyAutoLabs/autogalaxy_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\nPyAutoGalaxy knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoGalaxy notebook (notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb in HowToGalaxy).\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 PyAutoGalaxy 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 PyAutoGalaxy docs (https://pyautogalaxy.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) extra galaxies, foreground stars or artefacts that\n need masking or noise-scaling, and (b) a mask extent that captures the galaxy's\n emission out to where it meets the sky. A mask that truncates the outer isophotes\n biases the effective radius and Sersic index, so never leave the mask radius at its\n default on real data. 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 galaxy, a sample of galaxies, a paper's analysis),\n point them to the PyAutoGalaxy assistant\n https://github.com/PyAutoLabs/autogalaxy_assistant, which runs in Claude Code or\n Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: ag_pixelization\n\n---\nname: ag_pixelization\ndescription: Reconstruct a galaxy's clumpy or irregular light directly on a regularized pixel mesh, using `ag.Pixelization` alongside a parametric `ag.lp_linear` bulge \u2014 the hybrid model that gives a smooth interpretable component plus a flexible flux map for everything the profile cannot explain. Covers when a pixelization is warranted and when it is overkill, choosing an `ag.mesh` and an `ag.reg` regularization scheme and what each costs in free parameters, why `mesh_shape` must be fixed before the fit, `over_sample_size_pixelization` rather than `over_sample_size_lp`, noise scaling instead of hard masking for contaminants, the positive-only solver, reading the reconstruction and its Bayesian-evidence terms out of the `Inversion`, exporting the reconstruction to CSV for collaborators, and the GPU/VRAM versus CPU run-time trade. Use when a Sersic, MGE or shapelet basis leaves structured residuals in discrete off-centre lumps. Not for morphology a basis can still fit (`ag_basis_profiles`), not for non-parametric isophote measurement (`ag_ellipse_fitting`), and not for the basics of model composition (`ag_build_imaging_model`).\n---\n\n# Reconstructing a galaxy on a pixel mesh\n\nEvery model in this workspace so far has been a *function*: you asserted a functional form and\nthe fit found its parameters. A pixelization drops the functional form. It lays a mesh of pixels\nover the galaxy, gives each pixel its own free flux value, and solves all of them at once by\nlinear inversion \u2014 thousands of amplitudes, none of them in the non-linear search's parameter\nspace. What stops that from simply fitting the noise is **regularization**: a prior that\npenalises solutions where neighbouring mesh pixels differ sharply, so the reconstruction is as\nsmooth as the data will allow and no smoother.\n\nThe science case is specific, and it is worth being honest about how specific. Galaxies with\nspiral arms, asymmetric star-forming clumps, tidal features or low-surface-brightness\nsubstructure have light that no smooth profile \u2014 and often no basis expansion either \u2014\nreproduces. The canonical use is therefore **hybrid**: a linear `Sersic` for the smooth central\nbulge, and a pixelization for the irregular remainder. That split is the point. It gives you a\nlow-dimensional, physically interpretable description of the bulge *and* a flux map of exactly\nwhat the bulge could not explain, which is the quantity you want if the science question is \"how\nmuch of this galaxy's light is in its irregular components?\"\n\nThis is a **direct** reconstruction. There is no second plane, no de-projection, no inversion of\na geometric mapping \u2014 the mesh sits on the image plane, over the galaxy, at the same coordinates\nthe data occupies. If you have met pixelized reconstruction before in a different context, that\nextra step is the one that is absent here, and its absence makes everything simpler: what the\nmesh reconstructs is the galaxy's own surface brightness, and its coordinates are sky\ncoordinates in arcseconds.\n\nStatistically, the fit now has two nested layers. The non-linear search explores a handful of\nparameters \u2014 the bulge's shape, the regularization strength \u2014 and for each proposal an inversion\nsolves the mesh amplitudes exactly and returns a **Bayesian evidence** rather than a plain\nlikelihood. That evidence includes terms that penalise an unnecessarily complex reconstruction,\nwhich is what lets a model with 400 free flux values not simply win by over-fitting. The theory\nis [`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md);\nchapter 3 of HowToGalaxy derives it from scratch.\n\nThe canonical scripts are\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py` (via a search),\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` (one direct fit, with the\ninversion internals walked through) and\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py` (export).\n\n## Before you reach for one \u2014 the honest gate\n\nA pixelization is the most powerful and the most expensive model here, with failure modes the\nparametric models do not have. Three questions decide whether you need it:\n\n- *\"What do the residuals actually look like?\"* Structured residuals that **follow the\n isophotes** \u2014 a twist, a four-lobed pattern, an ellipticity that changes with radius \u2014 are a\n *shape* problem, and an MGE fixes them for four free parameters\n ([`ag_basis_profiles`](./ag_basis_profiles.md)). Residuals in **discrete off-centre lumps** are\n a substructure problem, and that is what a pixelization is for. Look before you choose.\n- *\"Is the structure resolved?\"* At low resolution the irregular structure is not in the data,\n and a pixelization will reconstruct noise while telling you it fitted well. The workspace is\n blunt about this: for low-resolution data pixelizations are unnecessary.\n- *\"Do you need global quantities or the light distribution?\"* If you want a total flux, a size,\n an axis ratio, a Sersic index \u2014 a parametric profile, an MGE or shapelets are enough and far\n cheaper. If you want the morphology itself, particularly for faint or low-surface-brightness\n features, nothing else comes close.\n\nIf the answer to the first is \"lumps\" and to the second \"yes\", continue. Otherwise you will\nspend hours to learn less.\n\n## Ask\n\n- *\"What is the smooth component?\"* Almost always a linear `Sersic`, sometimes a Sersic plus an\n Exponential disk. It matters that this exists: without it the pixelization has to reconstruct\n the bright central bulge too, which wastes mesh resolution on light a six-parameter profile\n describes perfectly and makes the regularization choice much harder.\n- *\"How many mesh pixels can you afford?\"* This is a hardware question as much as a science one\n (see the run-time branch). 20 \u00d7 20 is the workspace's default for a GPU run; 28 \u00d7 28 to 30 \u00d7 30\n for a better-resolved reconstruction; higher only with a large-VRAM GPU or a many-core CPU.\n- *\"Are there neighbours inside the mask?\"* For a pixelization the answer is *never* to mask\n their pixels out \u2014 that punches holes in the mesh and produces discontinuity systematics.\n Scale their noise instead (branch below).\n- *\"GPU or CPU?\"* Genuinely depends on your pixel scale, and the answer is counter-intuitive.\n Ask before assuming.\n\n## Branch \u2014 the hybrid model\n\nThe canonical composition. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py`.\n\n```python\n\"\"\"\nGalaxy Structure: Bulge Plus Pixelized Reconstruction\n=====================================================\n\nFit a galaxy whose light has two very different characters: a smooth central bulge, described by\na linear Sersic profile in six parameters, and asymmetric clumpy star formation, reconstructed\non a regularized rectangular mesh. The Sersic captures the smooth component interpretably; the\npixelization absorbs everything it cannot explain, and the reconstructed flux map is the\nmeasurement of the galaxy's irregular light.\n\n__Contents__\n\n- **Imports:** Import the required libraries.\n- **Dataset:** Load the imaging, mask it, and apply pixelization over-sampling.\n- **Mesh Shape:** Fix the mesh dimensions before the model is composed.\n- **Model:** Compose the linear Sersic bulge plus the pixelization.\n- **Check:** Confirm the parameter count, evaluate one likelihood, and estimate VRAM.\n\"\"\"\n\n\"\"\"\n__Imports__\n\"\"\"\nfrom pathlib import Path\n\nimport autofit as af\nimport autogalaxy as ag\nimport autogalaxy.plot as aplt\n\nDATASET_PATH = Path(\"dataset\") / \"imaging\" / \"my_galaxy\"\nPIXEL_SCALES = 0.1\nMASK_RADIUS = 2.0\n\n\"\"\"\n__Dataset__\n\nThe mask matters more than usual here. It defines the region the mesh is laid over, so a mask\nthat reaches well beyond the galaxy spends reconstruction pixels on empty sky, and one that\ntruncates the galaxy leaves its outer light unreconstructed\n(`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nThe over-sampling argument changes too. A pixelization evaluates light on its *own* grid with\nits own over-sampling scheme, so the size is passed as `over_sample_size_pixelization`, not the\n`over_sample_size_lp` used for light profiles. Passing the wrong one silently leaves the\npixelization grid at its default.\n\"\"\"\ndataset = ag.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=PIXEL_SCALES,\n)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=dataset.pixel_scales,\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\nover_sample_size = ag.util.over_sample.over_sample_size_via_radial_bins_from(\n grid=dataset.grid,\n sub_size_list=[8, 4, 2],\n radial_list=[0.3, 0.6],\n centre_list=[(0.0, 0.0)],\n)\ndataset = dataset.apply_over_sampling(over_sample_size_pixelization=over_sample_size)\n\n\"\"\"\n__Mesh Shape__\n\nThe mesh dimensions are **fixed before the fit and cannot be a free parameter**. This is not a\nstylistic restriction: JAX compiles the likelihood against statically shaped arrays, and the\nmesh shape sets those shapes. A rectangular mesh uses the same number of pixels in y and x.\n\"\"\"\nmesh_pixels_yx = 20\nmesh_shape = (mesh_pixels_yx, mesh_pixels_yx)\n\n\"\"\"\n__Model__\n\n`ag.Pixelization` pairs a `mesh` \u2014 where the reconstruction pixels sit \u2014 with a `regularization`\nscheme \u2014 the prior that keeps the solution smooth. It is attached to the galaxy as a component\nalongside the light profiles, and the analysis knows to route it through the inversion\n(`PyAutoGalaxy:autogalaxy/galaxy/galaxy.py`).\n\nThe mesh itself contributes **zero** free parameters; the reconstruction's 400 flux values are\nsolved, not sampled. `ag.reg.GaussianKernel` contributes two (`coefficient`, `scale`). The bulge\nis `ag.lp_linear.Sersic` rather than `ag.lp.Sersic` deliberately: its `intensity` is then solved\nby the *same* inversion that solves the mesh amplitudes, which removes the degeneracy between\n\"bright bulge, faint reconstruction\" and \"faint bulge, bright reconstruction\" that a sampled\nintensity would create.\n\"\"\"\npixelization = af.Model(\n ag.Pixelization,\n mesh=ag.mesh.RectangularBilinearAdaptDensity(shape=mesh_shape),\n regularization=ag.reg.GaussianKernel,\n)\n\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n pixelization=pixelization,\n)\n\nmodel = af.Collection(galaxies=af.Collection(galaxy=galaxy))\n\n\"\"\"\n__Check__\n\nThree cheap checks, each catching a different class of mistake: the parameter count catches a\nmodel error, one likelihood evaluation catches an incompatibility between model, dataset and\nanalysis, and `print_vram_use` catches the resource error that otherwise appears as an\nout-of-memory failure mid-compile. Pixelizations use far more VRAM than profile-only models \u2014\naround 0.05 GB per batched likelihood for 400 reconstruction pixels \u2014 which is why the\nworkspace uses a lower `n_batch` for these fits than for any other.\n\"\"\"\nprint(model.info)\nprint(f\"Total free parameters = {model.total_free_parameters}\")\n\nanalysis = ag.AnalysisImaging(dataset=dataset, use_jax=True)\n\nlog_likelihood = analysis.log_likelihood_function(\n instance=model.instance_from_prior_medians()\n)\nprint(f\"log evidence at prior medians: {float(log_likelihood):.2f}\")\n```\n\n**N = 8**: the Sersic's centre (2), `ell_comps` (2), `effective_radius`, `sersic_index`, plus the\nregularization's `coefficient` and `scale`. Eight parameters, with 400 reconstruction pixels\nsolved. That is dramatically more parsimonious than stacking parametric profiles until every\nclump is described \u2014 a 20-plus-parameter approach that would still fail on genuinely irregular\nsubstructure.\n\nExtending the smooth side is exactly as you would expect \u2014 add components as further attributes\non the galaxy, keeping them linear so their intensities join the same inversion:\n\n```python\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n disk=ag.lp_linear.Exponential,\n pixelization=pixelization,\n)\n```\n\n## Branch \u2014 choosing a mesh and a regularization scheme\n\nThese are the two decisions that make or break a reconstruction, and the parameter cost is\nalmost entirely in the regularization.\n\n**Meshes** (`ag.mesh`, from `PyAutoArray:autoarray/inversion/mesh/`). All contribute zero free\nparameters, all take `shape=(y, x)`:\n\n| Mesh | Where the pixels go | Use when |\n|---|---|---|\n| `RectangularUniform` | a uniform grid over the masked region | you want the simplest, most predictable behaviour |\n| `RectangularBilinearAdaptDensity` | a rectangular grid whose spacing follows the density of image pixels mapping to it, via a rank CDF | **the default** \u2014 concentrates resolution where the data is, and the fast CPU path |\n| `RectangularBilinearAdaptImage` | adapts to the image's own brightness distribution | the irregular light is strongly concentrated |\n| `RectangularRTUAdaptDensity` / `RectangularRTUAdaptImage` | the same two adaptations through a kernel CDF | GPU, JAX gradient samplers and the interferometer sparse path \u2014 the only rectangular adaptive meshes with usable gradients |\n\n`Delaunay`, `KNearestNeighbor` and `KNNBarycentric` also exist for irregular tessellations. Start\nwith `RectangularBilinearAdaptDensity`; it is what every workspace example uses, and the\nrectangular meshes are what the fixed-`mesh_shape` JAX path is built around. (The unqualified\n`RectangularAdaptDensity` / `RectangularAdaptImage` names were split into the `Bilinear` and\n`RTU` families in PyAutoArray 2026.8.23.1 and no longer resolve.)\n\n**Regularization** (`ag.reg`, from `PyAutoArray:autoarray/inversion/regularization/`) is where\nthe free parameters are, and where the physics of \"how smooth should this be?\" lives:\n\n| Scheme | Free params | What it assumes |\n|---|---|---|\n| `ag.reg.Constant` | 1 (`coefficient`) | one smoothing strength everywhere \u2014 the simplest, and the right first try |\n| `ag.reg.GaussianKernel` | 2 (`coefficient`, `scale`) | smoothing with a Gaussian correlation length you also fit |\n| `ag.reg.MaternKernel` | 3 (`coefficient`, `scale`, `nu`) | as above, with the roughness `nu` free too |\n\nVerified counts, not guesses. `Constant` penalises the difference between neighbouring pixels\ndirectly; the kernel schemes impose a correlation *length*, which is physically closer to what a\ngalaxy's light does and generally reconstructs faint extended structure better at the cost of\none or two parameters. `ag.reg.Adapt` and its variants scale the smoothing with the\nreconstruction's own brightness \u2014 more flexible again, and requiring `adapt_images` on the\nanalysis, which is a step beyond this skill; ask if you want it.\n\nA practical route: fit once with `ag.reg.Constant`, look at the reconstruction. Over-smoothed\n(the clumps blurred into one blob) or over-fitted (isolated single-pixel spikes) both show up\nimmediately, and if `Constant` cannot get both the bright clumps and the faint envelope right at\none strength, that is the signal to move to `GaussianKernel`. Full discussion in\n[`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\n### The positive-only solver\n\nEvery pixelized reconstruction uses a **positive-only** linear solver: no mesh pixel may\nreconstruct negative flux. This is not a numerical nicety. A signed solver lets the\nreconstruction manufacture negative pixels that cancel positive ones to over-fit the data, and\nthat failure mode is a well-known systematic in the literature \u2014 many published methods allow\nit. Enforcing non-negativity efficiently needs bespoke linear algebra, which is why this is\nworth stating rather than assuming. You do not need to switch it on; it is the behaviour you get.\n(The one place in this workspace where you *must* disable it is a shapelet basis, which needs\nsigned amplitudes by construction \u2014 see [`ag_basis_profiles`](./ag_basis_profiles.md).)\n\n## Branch \u2014 contaminating neighbours: scale, never mask\n\nThe general advice for a neighbour inside the mask is to mask its pixels out. **For a\npixelization that advice is wrong.** Removing image pixels entirely removes their mapping into\nthe mesh, punching a hole in the reconstruction and producing discontinuities that show up as\nunexplained systematics.\n\nInstead, keep the pixels in the fit and make them contribute nothing: scale their data to zero\nand their noise to an enormous value, so the likelihood is indifferent to them while the mesh\nstays continuous. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py`.\n\n```python\n\"\"\"\n__Noise Scaling__\n\n`mask_extra_galaxies.fits` marks the contaminated pixels. It is loaded with `invert=True`\nbecause in this file `True` means \"scale this pixel\", the opposite of a modelling mask's\nconvention \u2014 getting this backwards scales the galaxy instead of the neighbour, so check the\nsubplot afterwards. `apply_noise_scaling` then zeroes those data values and inflates their\nnoise, driving their signal-to-noise to effectively zero without removing them from the mesh's\nmapping (`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nOrder matters: scale first, then apply the modelling mask.\n\"\"\"\nmask_extra_galaxies = ag.Mask2D.from_fits(\n file_path=DATASET_PATH / \"mask_extra_galaxies.fits\",\n pixel_scales=PIXEL_SCALES,\n invert=True,\n)\n\ndataset = dataset.apply_noise_scaling(mask=mask_extra_galaxies)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=PIXEL_SCALES,\n centre=(0.0, 0.0),\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\naplt.subplot_imaging_dataset(\n dataset=dataset,\n output_path=\"scripts/scratch/my_galaxy/\",\n output_filename=\"noise_scaled\",\n output_format=\"png\",\n)\n```\n\nThe scaled pixels go to a noise value of order 1e8 \u2014 visibly so on the signal-to-noise panel of\nthe subplot, which is how you confirm you scaled the right region. Drawing that mask\ninteractively is `autogalaxy_workspace:scripts/imaging/data_preparation/gui/mask_extra_galaxies.py`;\nthe modelling alternative, and when to prefer it, is\n[`ag_light_model_extras`](./ag_light_model_extras.md) and\n[`../wiki/core/concepts/extra_galaxies_and_noise_scaling.md`](../wiki/core/concepts/extra_galaxies_and_noise_scaling.md).\n\n## Branch \u2014 reading the reconstruction out\n\nThe reconstruction is the science product, and it lives on the `Inversion`. You can get there\nfrom a completed fit or from a single direct `ag.FitImaging` \u2014 the latter is the fast way to\nlearn the objects, and is what\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` does.\n\n```python\n\"\"\"\n__Reconstruction__\n\n`inversion.linear_obj_list` holds the linear objects the fit solved. For a hybrid model that is\none `LightProfileLinearObjFuncList` per linear light profile plus one `Mapper` for the\npixelization; with a non-linear `ag.lp.Sersic` bulge the `Mapper` is the only entry. The\n`Mapper` is the object that maps image pixels onto mesh pixels, and it is mesh-specific\n(`PyAutoArray:autoarray/inversion/mappers/`).\n\nThe reconstruction is a 1D array of one flux per mesh pixel, and the (y, x) arcsecond\ncoordinates of those pixels are `mapper.mesh_geometry.mesh_grid`. Pair them by index \u2014 they are\nthe same length and the same order \u2014 and you have the flux map.\n\"\"\"\ninversion = fit.inversion\n\nmapper = inversion.linear_obj_list[0]\n\nreconstruction = inversion.reconstruction\nmesh_grid = mapper.mesh_geometry.mesh_grid\n\nprint(f\"{len(reconstruction)} mesh pixels reconstructed\")\nprint(f\"coordinates shape: {mesh_grid.shape}\")\n\n\"\"\"\nThe reconstruction's own uncertainties, and its projection back onto the image, are also on the\ninversion. `mapped_reconstructed_operated_data` contains **only** the pixelized component \u2014 any\nparametric light profile in the model is excluded \u2014 which is exactly the quantity you want when\nasking how much light is in the irregular component.\n\"\"\"\nprint(inversion.reconstruction_noise_map)\nprint(inversion.mapped_reconstructed_operated_data.native.shape)\n```\n\nA caution on the `Mapper`'s grid attributes: `mapper.image_plane_mesh_grid` is `None` for a\nrectangular mesh, so do not reach for it. `mapper.mesh_geometry.mesh_grid` is the mesh pixel\ncentres (verified on the released stack), and `mapper.image_plane_data_grid` is the centre of\nevery masked *image* pixel. Some of the `Mapper`'s attribute names carry a plane-based prefix\ninherited from the shared inversion library that also serves a multi-plane use case; in\nPyAutoGalaxy there is only the one plane, so `mesh_geometry.mesh_grid` is both the clearer name\nand the one to use.\n\n### The evidence terms\n\nThe inversion's figure of merit is a Bayesian evidence, and its components are individually\nreadable \u2014 which is how you diagnose whether regularization is doing too much or too little:\n\n```python\nprint(f\"regularization term = {inversion.regularization_term}\")\nprint(f\"log det regularization = {inversion.log_det_regularization_matrix_term}\")\nprint(f\"log det curvature + reg = {inversion.log_det_curvature_reg_matrix_term}\")\n```\n\nThe `regularization_term` measures how non-smooth the solution is; the two log-determinant\nterms are the complexity penalty that stops a 400-pixel model winning by over-fitting. The\nmatrices themselves (`curvature_matrix`, `regularization_matrix`, `curvature_reg_matrix`) are\nthere too. The derivation is in HowToGalaxy chapter 3 and the papers it cites\n(arXiv:1708.07377, arXiv:astro-ph/0601493).\n\n### Diagnostic plots\n\n`aplt.subplot_fit_imaging` includes the reconstruction on the mesh alongside the usual data /\nmodel / residual panels, and is the first thing to look at. For the inversion's own internals\nthere are dedicated functions, which live in the shared inversion library rather than on `aplt`\nand so are imported explicitly \u2014 from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/plot.py`:\n\n```python\nfrom autoarray.inversion.plot.inversion_plots import subplot_of_mapper\nfrom autoarray.inversion.plot.mapper_plots import plot_mapper, subplot_image_and_mapper\n\nPLOT_DIR = \"scripts/scratch/my_galaxy/pixelization/\"\n\naplt.subplot_fit_imaging(fit=fit, output_path=PLOT_DIR, output_format=\"png\")\n\nsubplot_of_mapper(\n inversion=inversion, mapper_index=0, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nsubplot_image_and_mapper(\n mapper=mapper, image=dataset.data, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nprint(f\"Saved to: {Path(PLOT_DIR).resolve()}\")\n```\n\n`subplot_of_mapper` is the comprehensive diagnostic \u2014 reconstructed image, reconstruction, its\nnoise map and the regularization weights in one figure \u2014 and `mapper_index` selects which linear\nobject when there are several. `subplot_image_and_mapper` shows the data beside the mesh, which\nis how you check the mesh covers the structure you care about. Note that none of these three are\nexposed on the `aplt` module \u2014 `dir(aplt)` does not list them, so the explicit imports above are\nthe only route and writing them as `aplt.`-prefixed calls raises `AttributeError`. Quote the\nabsolute path back to the user and offer to open it.\n\n## Branch \u2014 exporting the reconstruction for downstream science\n\nA reconstruction that only exists inside a PyAutoGalaxy object is hard to share and hard to\nanalyse with other tools. Write it out as coordinates plus values and it becomes ordinary\ntabular data. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py`.\n\n```python\n\"\"\"\n__Export__\n\nFour columns \u2014 y, x, reconstruction, noise \u2014 is everything needed to rebuild, plot or analyse\nthe galaxy's irregular light without PyAutoGalaxy installed. A completed model-fit also writes\nthis file into its own `image/` folder automatically; glob for `*reconstruction_0.csv` there\nrather than hard-coding the stem, which carries a prefix from the shared inversion library.\n\"\"\"\nimport csv\n\nCSV_PATH = Path(\"scripts/scratch/my_galaxy/reconstruction.csv\")\nCSV_PATH.parent.mkdir(parents=True, exist_ok=True)\n\nwith CSV_PATH.open(\"w\", newline=\"\") as f:\n writer = csv.writer(f)\n writer.writerow([\"y\", \"x\", \"reconstruction\", \"noise_map\"])\n for (y, x), value, sigma in zip(\n mesh_grid, inversion.reconstruction, inversion.reconstruction_noise_map\n ):\n writer.writerow([y, x, value, sigma])\n\nprint(f\"Reconstruction written to: {CSV_PATH.resolve()}\")\n```\n\nReloading needs nothing but the standard library, and from there the mesh is ordinary scattered\ndata. Two things the workspace does with it: a Delaunay triangulation via `scipy.spatial` for\nplotting on the mesh's own geometry, and interpolation onto a regular grid via\n`scipy.interpolate.griddata` for any tool that wants an image:\n\n```python\nimport numpy as np\nfrom scipy.interpolate import griddata\n\nwith CSV_PATH.open() as f:\n reader = csv.reader(f)\n header = next(reader) # ['y', 'x', 'reconstruction', 'noise_map']\n loaded = {key: [] for key in header}\n for row in reader:\n for key, value in zip(header, row):\n loaded[key].append(float(value))\n\npoints = np.stack((np.array(loaded[\"x\"]), np.array(loaded[\"y\"])), axis=-1)\n\ninterpolation_grid = ag.Grid2D.from_extent(\n extent=(-1.0, 1.0, -1.0, 1.0), shape_native=(201, 201)\n)\n\ninterpolated = griddata(\n points=points, values=np.array(loaded[\"reconstruction\"]), xi=interpolation_grid\n)\n```\n\nPick the `extent` to cover the reconstruction without over-resolving it \u2014 the mesh has a few\nhundred pixels, so interpolating to 201 \u00d7 201 is presentation, not information.\n\n## Branch \u2014 run time, VRAM, and the GPU-versus-CPU choice\n\nThis is the branch that decides whether your fit takes twenty minutes or three hours, and the\nanswer is not \"use the GPU\".\n\nPixelized inversions do linear algebra on very large, very **sparse** matrices. JAX has no sparse\nsupport and must work densely, which scales badly; the CPU implementation exploits the sparsity\nfully via `numba`. So the trade runs against pixel scale:\n\n- **Low resolution (`pixel_scales > 0.05`, e.g. Euclid)** \u2014 fewer sparse operations, modest VRAM.\n **GPU with JAX is usually fastest.**\n- **High resolution (`pixel_scales <= 0.03`, e.g. HST or JWST)** \u2014 the linear algebra is\n sparsity-dominated. **CPU with `numba` and many cores can beat a powerful GPU.**\n\nVRAM is the hard constraint on the GPU side. JAX must hold the whole batched likelihood in GPU\nmemory, and a pixelization needs far more than a profile model: around 0.05 GB per batched\nlikelihood at 400 reconstruction pixels, and more than 1 GB \u2014 occasionally more than 10 GB \u2014 at\nhigh resolution with a fine mesh. Two consequences: keep `n_batch` low (the workspace uses 20 for\npixelizations against 50 elsewhere), and run\n\n```python\nanalysis.print_vram_use(model=model, batch_size=search.batch_size)\n```\n\nbefore committing. It takes 20\u201330 seconds and it is the difference between knowing your limit\nand discovering it as an out-of-memory error mid-compile. If VRAM is exceeded the run time does\nnot degrade gracefully \u2014 it goes from under ten minutes to three hours or more.\n\nRough scaling with resolution, from the workspace's own measurements at a 20 \u00d7 20 mesh with VRAM\nunder control: ~10 minutes at 0.1\"/pixel, ~30 minutes at 0.05\", ~1 hour at 0.03\". Benchmark both\npaths for your own data and hardware rather than trusting either default; the search-side\nsettings are [`ag_configure_search`](./ag_configure_search.md) and\n[`../wiki/core/api/searches.md`](../wiki/core/api/searches.md).\n\n## Combine \u2014 where this hands off\n\n- **Try a basis first** \u2192 [`ag_basis_profiles`](./ag_basis_profiles.md). An MGE is four\n parameters and minutes, not hours, and it genuinely resolves the shape-mismatch case a\n pixelization is often reached for by mistake.\n- **Pick and configure the search** \u2192 [`ag_configure_search`](./ag_configure_search.md), with\n `n_batch` lowered for VRAM.\n- **Run the fit** \u2192 [`ag_run_search`](./ag_run_search.md); `image/fit.png` in the output folder\n refreshes on the fly and shows the reconstruction as it converges, which is worth watching for\n a fit this long.\n- **Plot and inspect** \u2192 [`ag_plot_fit`](./ag_plot_fit.md), plus the `subplot_of_mapper`\n diagnostics above.\n- **Load a completed fit programmatically** \u2192 [`ag_load_results`](./ag_load_results.md).\n- **Over-smoothed, over-fitted or unphysical reconstruction** \u2192\n [`ag_debug_fit_failure`](./ag_debug_fit_failure.md). The pixelization-specific causes are a\n regularization scheme too rigid for the structure, a mask that punched holes via hard masking\n where noise scaling was needed, over-sampling passed as `over_sample_size_lp` instead of\n `over_sample_size_pixelization`, and VRAM exhaustion masquerading as a hung fit.\n- **Contaminating neighbours modelled rather than scaled** \u2192\n [`ag_light_model_extras`](./ag_light_model_extras.md).\n- **Make the fit robust and fast by starting simple** \u2192 the search-chaining skill\n (`ag_chain_searches`). This is the biggest available win for a pixelization: fit a parametric\n model first, pass its bulge as priors, and let the expensive model start from a sensible place.\n The workspace recommends it explicitly.\n- **A non-parametric shape measurement instead of a reconstruction** \u2192\n [`ag_ellipse_fitting`](./ag_ellipse_fitting.md).\n\nOffer (default-yes) a dated `wiki/project/YYYY-MM-DD-.md` entry recording the mesh and its\nshape, the regularization scheme and why, the mask radius, the hardware path, and what the\nreconstruction showed \u2014 with a pixelization the choices *are* the result, and none of them are\nrecoverable from the output folder alone.\n\n## Further reading\n\n- **Student / new to galaxy morphology** \u2014 [HowToGalaxy: Bayesian regularization](https://github.com/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_bayesian_regularization.ipynb):\n why a model with hundreds of free flux values does not simply over-fit, derived from scratch;\n `tutorial_6_model_fit` then runs the full fit.\n- **General reference** \u2014 [RTD: Pixelization API](https://pyautogalaxy.readthedocs.io/en/latest/api/pixelization.html):\n every mesh, regularization scheme and the objects that combine them.\n- **Experienced PyAutoGalaxy user** \u2014 [workspace: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autogalaxy_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n the hybrid fit end to end, with the run-time, VRAM and GPU-versus-CPU discussion in full.\n\n## Agent procedural checklist\n\n1. Look at the residual map of a parametric or basis fit first. Shape-following residuals mean\n `ag_basis_profiles`; discrete off-centre lumps mean a pixelization. Do not skip this.\n2. Confirm the structure is resolved \u2014 at coarse pixel scales a pixelization reconstructs noise.\n3. Always pair the pixelization with a linear light profile for the smooth component; never let\n the mesh reconstruct the bulge.\n4. Fix `mesh_shape` before composing the model; it cannot be a free parameter.\n5. Pass over-sampling as `over_sample_size_pixelization`, not `over_sample_size_lp`.\n6. Start with `RectangularBilinearAdaptDensity` + `ag.reg.Constant`; move to `GaussianKernel` only when\n one smoothing strength demonstrably cannot serve both the bright and faint structure.\n7. Scale contaminant noise (`apply_noise_scaling`, `invert=True`) \u2014 never hard-mask pixels inside\n a pixelization fit \u2014 and check the signal-to-noise panel afterwards.\n8. `print(model.info)`, evaluate one likelihood, then `print_vram_use` before any GPU run; keep\n `n_batch` low.\n9. Read the reconstruction via `inversion.reconstruction` +\n `mapper.mesh_geometry.mesh_grid`; export to CSV for anything downstream.\n10. Save the script to `scripts/`, quote every plot's absolute path and offer to open it, then\n offer the `wiki/project/` entry recording mesh, regularization and hardware choices.\n" + } + }, + "selectedAiContext": "24c2f562-da24-50bb-8eb9-5a887505fe54" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} \ No newline at end of file diff --git a/notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb b/notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb similarity index 98% rename from notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb rename to notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb index 9936eb8..dba5fa1 100644 --- a/notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb +++ b/notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb @@ -4,9 +4,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Tutorial 3: Model Fit\n", + "Tutorial 5: Model Fit\n", "=====================\n", "\n", + "Tutorials 3 and 4 explored mesh resolution and adaptive smoothing using a galaxy reconstructed entirely on a mesh.\n", + "We now return to the complementary question of how to combine a smooth analytic component with a pixelization.\n", + "The rectangular mesh here keeps that decomposition experiment simple; inspect its residuals and revisit the\n", + "adaptive choices from Tutorial 4 when the pixelized component needs more spatially varying resolution.\n", + "\n", "A pixelization can describe a galaxy without assigning an analytic centre and shape to every visible feature.\n", "However, analytic profiles remain useful when we want to describe its smooth components. In this tutorial we\n", "combine an MGE with a pixelization and use search chaining to put the smooth light model in place first.\n", @@ -22,11 +27,10 @@ "\n", "__Contents__\n", "\n", - "- **Dataset:** Load, mask and oversample the image.\n", - "- **Search 1:** Fit the smooth galaxy light with an MGE.\n", - "- **Search 2:** Add a regularized pixelization using the fitted MGE geometry.\n", - "- **Search 3:** Refine the MGE geometry together with the pixelization.\n", - "- **Interpretation:** Inspect both the residuals and the component decomposition." + "- Search 1: Fit the MGE\n", + "- Search 2: Add the pixelization\n", + "- Search 3: Refine both components\n", + "- Wrap Up" ] }, { @@ -324,7 +328,7 @@ "24c2f562-da24-50bb-8eb9-5a887505fe54": { "id": "24c2f562-da24-50bb-8eb9-5a887505fe54", "name": "PyAutoGalaxy assistant", - "context": "These instructions were prepared by the PyAutoGalaxy assistant\n(https://github.com/PyAutoLabs/autogalaxy_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\nPyAutoGalaxy knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoGalaxy notebook (notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb in HowToGalaxy).\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 PyAutoGalaxy 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 PyAutoGalaxy docs (https://pyautogalaxy.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) extra galaxies, foreground stars or artefacts that\n need masking or noise-scaling, and (b) a mask extent that captures the galaxy's\n emission out to where it meets the sky. A mask that truncates the outer isophotes\n biases the effective radius and Sersic index, so never leave the mask radius at its\n default on real data. 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 galaxy, a sample of galaxies, a paper's analysis),\n point them to the PyAutoGalaxy assistant\n https://github.com/PyAutoLabs/autogalaxy_assistant, which runs in Claude Code or\n Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: ag_pixelization\n\n---\nname: ag_pixelization\ndescription: Reconstruct a galaxy's clumpy or irregular light directly on a regularized pixel mesh, using `ag.Pixelization` alongside a parametric `ag.lp_linear` bulge \u2014 the hybrid model that gives a smooth interpretable component plus a flexible flux map for everything the profile cannot explain. Covers when a pixelization is warranted and when it is overkill, choosing an `ag.mesh` and an `ag.reg` regularization scheme and what each costs in free parameters, why `mesh_shape` must be fixed before the fit, `over_sample_size_pixelization` rather than `over_sample_size_lp`, noise scaling instead of hard masking for contaminants, the positive-only solver, reading the reconstruction and its Bayesian-evidence terms out of the `Inversion`, exporting the reconstruction to CSV for collaborators, and the GPU/VRAM versus CPU run-time trade. Use when a Sersic, MGE or shapelet basis leaves structured residuals in discrete off-centre lumps. Not for morphology a basis can still fit (`ag_basis_profiles`), not for non-parametric isophote measurement (`ag_ellipse_fitting`), and not for the basics of model composition (`ag_build_imaging_model`).\n---\n\n# Reconstructing a galaxy on a pixel mesh\n\nEvery model in this workspace so far has been a *function*: you asserted a functional form and\nthe fit found its parameters. A pixelization drops the functional form. It lays a mesh of pixels\nover the galaxy, gives each pixel its own free flux value, and solves all of them at once by\nlinear inversion \u2014 thousands of amplitudes, none of them in the non-linear search's parameter\nspace. What stops that from simply fitting the noise is **regularization**: a prior that\npenalises solutions where neighbouring mesh pixels differ sharply, so the reconstruction is as\nsmooth as the data will allow and no smoother.\n\nThe science case is specific, and it is worth being honest about how specific. Galaxies with\nspiral arms, asymmetric star-forming clumps, tidal features or low-surface-brightness\nsubstructure have light that no smooth profile \u2014 and often no basis expansion either \u2014\nreproduces. The canonical use is therefore **hybrid**: a linear `Sersic` for the smooth central\nbulge, and a pixelization for the irregular remainder. That split is the point. It gives you a\nlow-dimensional, physically interpretable description of the bulge *and* a flux map of exactly\nwhat the bulge could not explain, which is the quantity you want if the science question is \"how\nmuch of this galaxy's light is in its irregular components?\"\n\nThis is a **direct** reconstruction. There is no second plane, no de-projection, no inversion of\na geometric mapping \u2014 the mesh sits on the image plane, over the galaxy, at the same coordinates\nthe data occupies. If you have met pixelized reconstruction before in a different context, that\nextra step is the one that is absent here, and its absence makes everything simpler: what the\nmesh reconstructs is the galaxy's own surface brightness, and its coordinates are sky\ncoordinates in arcseconds.\n\nStatistically, the fit now has two nested layers. The non-linear search explores a handful of\nparameters \u2014 the bulge's shape, the regularization strength \u2014 and for each proposal an inversion\nsolves the mesh amplitudes exactly and returns a **Bayesian evidence** rather than a plain\nlikelihood. That evidence includes terms that penalise an unnecessarily complex reconstruction,\nwhich is what lets a model with 400 free flux values not simply win by over-fitting. The theory\nis [`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md);\nchapter 3 of HowToGalaxy derives it from scratch.\n\nThe canonical scripts are\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py` (via a search),\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` (one direct fit, with the\ninversion internals walked through) and\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py` (export).\n\n## Before you reach for one \u2014 the honest gate\n\nA pixelization is the most powerful and the most expensive model here, with failure modes the\nparametric models do not have. Three questions decide whether you need it:\n\n- *\"What do the residuals actually look like?\"* Structured residuals that **follow the\n isophotes** \u2014 a twist, a four-lobed pattern, an ellipticity that changes with radius \u2014 are a\n *shape* problem, and an MGE fixes them for four free parameters\n ([`ag_basis_profiles`](./ag_basis_profiles.md)). Residuals in **discrete off-centre lumps** are\n a substructure problem, and that is what a pixelization is for. Look before you choose.\n- *\"Is the structure resolved?\"* At low resolution the irregular structure is not in the data,\n and a pixelization will reconstruct noise while telling you it fitted well. The workspace is\n blunt about this: for low-resolution data pixelizations are unnecessary.\n- *\"Do you need global quantities or the light distribution?\"* If you want a total flux, a size,\n an axis ratio, a Sersic index \u2014 a parametric profile, an MGE or shapelets are enough and far\n cheaper. If you want the morphology itself, particularly for faint or low-surface-brightness\n features, nothing else comes close.\n\nIf the answer to the first is \"lumps\" and to the second \"yes\", continue. Otherwise you will\nspend hours to learn less.\n\n## Ask\n\n- *\"What is the smooth component?\"* Almost always a linear `Sersic`, sometimes a Sersic plus an\n Exponential disk. It matters that this exists: without it the pixelization has to reconstruct\n the bright central bulge too, which wastes mesh resolution on light a six-parameter profile\n describes perfectly and makes the regularization choice much harder.\n- *\"How many mesh pixels can you afford?\"* This is a hardware question as much as a science one\n (see the run-time branch). 20 \u00d7 20 is the workspace's default for a GPU run; 28 \u00d7 28 to 30 \u00d7 30\n for a better-resolved reconstruction; higher only with a large-VRAM GPU or a many-core CPU.\n- *\"Are there neighbours inside the mask?\"* For a pixelization the answer is *never* to mask\n their pixels out \u2014 that punches holes in the mesh and produces discontinuity systematics.\n Scale their noise instead (branch below).\n- *\"GPU or CPU?\"* Genuinely depends on your pixel scale, and the answer is counter-intuitive.\n Ask before assuming.\n\n## Branch \u2014 the hybrid model\n\nThe canonical composition. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py`.\n\n```python\n\"\"\"\nGalaxy Structure: Bulge Plus Pixelized Reconstruction\n=====================================================\n\nFit a galaxy whose light has two very different characters: a smooth central bulge, described by\na linear Sersic profile in six parameters, and asymmetric clumpy star formation, reconstructed\non a regularized rectangular mesh. The Sersic captures the smooth component interpretably; the\npixelization absorbs everything it cannot explain, and the reconstructed flux map is the\nmeasurement of the galaxy's irregular light.\n\n__Contents__\n\n- **Imports:** Import the required libraries.\n- **Dataset:** Load the imaging, mask it, and apply pixelization over-sampling.\n- **Mesh Shape:** Fix the mesh dimensions before the model is composed.\n- **Model:** Compose the linear Sersic bulge plus the pixelization.\n- **Check:** Confirm the parameter count, evaluate one likelihood, and estimate VRAM.\n\"\"\"\n\n\"\"\"\n__Imports__\n\"\"\"\nfrom pathlib import Path\n\nimport autofit as af\nimport autogalaxy as ag\nimport autogalaxy.plot as aplt\n\nDATASET_PATH = Path(\"dataset\") / \"imaging\" / \"my_galaxy\"\nPIXEL_SCALES = 0.1\nMASK_RADIUS = 2.0\n\n\"\"\"\n__Dataset__\n\nThe mask matters more than usual here. It defines the region the mesh is laid over, so a mask\nthat reaches well beyond the galaxy spends reconstruction pixels on empty sky, and one that\ntruncates the galaxy leaves its outer light unreconstructed\n(`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nThe over-sampling argument changes too. A pixelization evaluates light on its *own* grid with\nits own over-sampling scheme, so the size is passed as `over_sample_size_pixelization`, not the\n`over_sample_size_lp` used for light profiles. Passing the wrong one silently leaves the\npixelization grid at its default.\n\"\"\"\ndataset = ag.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=PIXEL_SCALES,\n)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=dataset.pixel_scales,\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\nover_sample_size = ag.util.over_sample.over_sample_size_via_radial_bins_from(\n grid=dataset.grid,\n sub_size_list=[8, 4, 2],\n radial_list=[0.3, 0.6],\n centre_list=[(0.0, 0.0)],\n)\ndataset = dataset.apply_over_sampling(over_sample_size_pixelization=over_sample_size)\n\n\"\"\"\n__Mesh Shape__\n\nThe mesh dimensions are **fixed before the fit and cannot be a free parameter**. This is not a\nstylistic restriction: JAX compiles the likelihood against statically shaped arrays, and the\nmesh shape sets those shapes. A rectangular mesh uses the same number of pixels in y and x.\n\"\"\"\nmesh_pixels_yx = 20\nmesh_shape = (mesh_pixels_yx, mesh_pixels_yx)\n\n\"\"\"\n__Model__\n\n`ag.Pixelization` pairs a `mesh` \u2014 where the reconstruction pixels sit \u2014 with a `regularization`\nscheme \u2014 the prior that keeps the solution smooth. It is attached to the galaxy as a component\nalongside the light profiles, and the analysis knows to route it through the inversion\n(`PyAutoGalaxy:autogalaxy/galaxy/galaxy.py`).\n\nThe mesh itself contributes **zero** free parameters; the reconstruction's 400 flux values are\nsolved, not sampled. `ag.reg.GaussianKernel` contributes two (`coefficient`, `scale`). The bulge\nis `ag.lp_linear.Sersic` rather than `ag.lp.Sersic` deliberately: its `intensity` is then solved\nby the *same* inversion that solves the mesh amplitudes, which removes the degeneracy between\n\"bright bulge, faint reconstruction\" and \"faint bulge, bright reconstruction\" that a sampled\nintensity would create.\n\"\"\"\npixelization = af.Model(\n ag.Pixelization,\n mesh=ag.mesh.RectangularBilinearAdaptDensity(shape=mesh_shape),\n regularization=ag.reg.GaussianKernel,\n)\n\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n pixelization=pixelization,\n)\n\nmodel = af.Collection(galaxies=af.Collection(galaxy=galaxy))\n\n\"\"\"\n__Check__\n\nThree cheap checks, each catching a different class of mistake: the parameter count catches a\nmodel error, one likelihood evaluation catches an incompatibility between model, dataset and\nanalysis, and `print_vram_use` catches the resource error that otherwise appears as an\nout-of-memory failure mid-compile. Pixelizations use far more VRAM than profile-only models \u2014\naround 0.05 GB per batched likelihood for 400 reconstruction pixels \u2014 which is why the\nworkspace uses a lower `n_batch` for these fits than for any other.\n\"\"\"\nprint(model.info)\nprint(f\"Total free parameters = {model.total_free_parameters}\")\n\nanalysis = ag.AnalysisImaging(dataset=dataset, use_jax=True)\n\nlog_likelihood = analysis.log_likelihood_function(\n instance=model.instance_from_prior_medians()\n)\nprint(f\"log evidence at prior medians: {float(log_likelihood):.2f}\")\n```\n\n**N = 8**: the Sersic's centre (2), `ell_comps` (2), `effective_radius`, `sersic_index`, plus the\nregularization's `coefficient` and `scale`. Eight parameters, with 400 reconstruction pixels\nsolved. That is dramatically more parsimonious than stacking parametric profiles until every\nclump is described \u2014 a 20-plus-parameter approach that would still fail on genuinely irregular\nsubstructure.\n\nExtending the smooth side is exactly as you would expect \u2014 add components as further attributes\non the galaxy, keeping them linear so their intensities join the same inversion:\n\n```python\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n disk=ag.lp_linear.Exponential,\n pixelization=pixelization,\n)\n```\n\n## Branch \u2014 choosing a mesh and a regularization scheme\n\nThese are the two decisions that make or break a reconstruction, and the parameter cost is\nalmost entirely in the regularization.\n\n**Meshes** (`ag.mesh`, from `PyAutoArray:autoarray/inversion/mesh/`). All contribute zero free\nparameters, all take `shape=(y, x)`:\n\n| Mesh | Where the pixels go | Use when |\n|---|---|---|\n| `RectangularUniform` | a uniform grid over the masked region | you want the simplest, most predictable behaviour |\n| `RectangularBilinearAdaptDensity` | a rectangular grid whose spacing follows the density of image pixels mapping to it, via a rank CDF | **the default** \u2014 concentrates resolution where the data is, and the fast CPU path |\n| `RectangularBilinearAdaptImage` | adapts to the image's own brightness distribution | the irregular light is strongly concentrated |\n| `RectangularRTUAdaptDensity` / `RectangularRTUAdaptImage` | the same two adaptations through a kernel CDF | GPU, JAX gradient samplers and the interferometer sparse path \u2014 the only rectangular adaptive meshes with usable gradients |\n\n`Delaunay`, `KNearestNeighbor` and `KNNBarycentric` also exist for irregular tessellations. Start\nwith `RectangularBilinearAdaptDensity`; it is what every workspace example uses, and the\nrectangular meshes are what the fixed-`mesh_shape` JAX path is built around. (The unqualified\n`RectangularAdaptDensity` / `RectangularAdaptImage` names were split into the `Bilinear` and\n`RTU` families in PyAutoArray 2026.8.23.1 and no longer resolve.)\n\n**Regularization** (`ag.reg`, from `PyAutoArray:autoarray/inversion/regularization/`) is where\nthe free parameters are, and where the physics of \"how smooth should this be?\" lives:\n\n| Scheme | Free params | What it assumes |\n|---|---|---|\n| `ag.reg.Constant` | 1 (`coefficient`) | one smoothing strength everywhere \u2014 the simplest, and the right first try |\n| `ag.reg.GaussianKernel` | 2 (`coefficient`, `scale`) | smoothing with a Gaussian correlation length you also fit |\n| `ag.reg.MaternKernel` | 3 (`coefficient`, `scale`, `nu`) | as above, with the roughness `nu` free too |\n\nVerified counts, not guesses. `Constant` penalises the difference between neighbouring pixels\ndirectly; the kernel schemes impose a correlation *length*, which is physically closer to what a\ngalaxy's light does and generally reconstructs faint extended structure better at the cost of\none or two parameters. `ag.reg.Adapt` and its variants scale the smoothing with the\nreconstruction's own brightness \u2014 more flexible again, and requiring `adapt_images` on the\nanalysis, which is a step beyond this skill; ask if you want it.\n\nA practical route: fit once with `ag.reg.Constant`, look at the reconstruction. Over-smoothed\n(the clumps blurred into one blob) or over-fitted (isolated single-pixel spikes) both show up\nimmediately, and if `Constant` cannot get both the bright clumps and the faint envelope right at\none strength, that is the signal to move to `GaussianKernel`. Full discussion in\n[`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\n### The positive-only solver\n\nEvery pixelized reconstruction uses a **positive-only** linear solver: no mesh pixel may\nreconstruct negative flux. This is not a numerical nicety. A signed solver lets the\nreconstruction manufacture negative pixels that cancel positive ones to over-fit the data, and\nthat failure mode is a well-known systematic in the literature \u2014 many published methods allow\nit. Enforcing non-negativity efficiently needs bespoke linear algebra, which is why this is\nworth stating rather than assuming. You do not need to switch it on; it is the behaviour you get.\n(The one place in this workspace where you *must* disable it is a shapelet basis, which needs\nsigned amplitudes by construction \u2014 see [`ag_basis_profiles`](./ag_basis_profiles.md).)\n\n## Branch \u2014 contaminating neighbours: scale, never mask\n\nThe general advice for a neighbour inside the mask is to mask its pixels out. **For a\npixelization that advice is wrong.** Removing image pixels entirely removes their mapping into\nthe mesh, punching a hole in the reconstruction and producing discontinuities that show up as\nunexplained systematics.\n\nInstead, keep the pixels in the fit and make them contribute nothing: scale their data to zero\nand their noise to an enormous value, so the likelihood is indifferent to them while the mesh\nstays continuous. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py`.\n\n```python\n\"\"\"\n__Noise Scaling__\n\n`mask_extra_galaxies.fits` marks the contaminated pixels. It is loaded with `invert=True`\nbecause in this file `True` means \"scale this pixel\", the opposite of a modelling mask's\nconvention \u2014 getting this backwards scales the galaxy instead of the neighbour, so check the\nsubplot afterwards. `apply_noise_scaling` then zeroes those data values and inflates their\nnoise, driving their signal-to-noise to effectively zero without removing them from the mesh's\nmapping (`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nOrder matters: scale first, then apply the modelling mask.\n\"\"\"\nmask_extra_galaxies = ag.Mask2D.from_fits(\n file_path=DATASET_PATH / \"mask_extra_galaxies.fits\",\n pixel_scales=PIXEL_SCALES,\n invert=True,\n)\n\ndataset = dataset.apply_noise_scaling(mask=mask_extra_galaxies)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=PIXEL_SCALES,\n centre=(0.0, 0.0),\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\naplt.subplot_imaging_dataset(\n dataset=dataset,\n output_path=\"scripts/scratch/my_galaxy/\",\n output_filename=\"noise_scaled\",\n output_format=\"png\",\n)\n```\n\nThe scaled pixels go to a noise value of order 1e8 \u2014 visibly so on the signal-to-noise panel of\nthe subplot, which is how you confirm you scaled the right region. Drawing that mask\ninteractively is `autogalaxy_workspace:scripts/imaging/data_preparation/gui/mask_extra_galaxies.py`;\nthe modelling alternative, and when to prefer it, is\n[`ag_light_model_extras`](./ag_light_model_extras.md) and\n[`../wiki/core/concepts/extra_galaxies_and_noise_scaling.md`](../wiki/core/concepts/extra_galaxies_and_noise_scaling.md).\n\n## Branch \u2014 reading the reconstruction out\n\nThe reconstruction is the science product, and it lives on the `Inversion`. You can get there\nfrom a completed fit or from a single direct `ag.FitImaging` \u2014 the latter is the fast way to\nlearn the objects, and is what\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` does.\n\n```python\n\"\"\"\n__Reconstruction__\n\n`inversion.linear_obj_list` holds the linear objects the fit solved. For a hybrid model that is\none `LightProfileLinearObjFuncList` per linear light profile plus one `Mapper` for the\npixelization; with a non-linear `ag.lp.Sersic` bulge the `Mapper` is the only entry. The\n`Mapper` is the object that maps image pixels onto mesh pixels, and it is mesh-specific\n(`PyAutoArray:autoarray/inversion/mappers/`).\n\nThe reconstruction is a 1D array of one flux per mesh pixel, and the (y, x) arcsecond\ncoordinates of those pixels are `mapper.mesh_geometry.mesh_grid`. Pair them by index \u2014 they are\nthe same length and the same order \u2014 and you have the flux map.\n\"\"\"\ninversion = fit.inversion\n\nmapper = inversion.linear_obj_list[0]\n\nreconstruction = inversion.reconstruction\nmesh_grid = mapper.mesh_geometry.mesh_grid\n\nprint(f\"{len(reconstruction)} mesh pixels reconstructed\")\nprint(f\"coordinates shape: {mesh_grid.shape}\")\n\n\"\"\"\nThe reconstruction's own uncertainties, and its projection back onto the image, are also on the\ninversion. `mapped_reconstructed_operated_data` contains **only** the pixelized component \u2014 any\nparametric light profile in the model is excluded \u2014 which is exactly the quantity you want when\nasking how much light is in the irregular component.\n\"\"\"\nprint(inversion.reconstruction_noise_map)\nprint(inversion.mapped_reconstructed_operated_data.native.shape)\n```\n\nA caution on the `Mapper`'s grid attributes: `mapper.image_plane_mesh_grid` is `None` for a\nrectangular mesh, so do not reach for it. `mapper.mesh_geometry.mesh_grid` is the mesh pixel\ncentres (verified on the released stack), and `mapper.image_plane_data_grid` is the centre of\nevery masked *image* pixel. Some of the `Mapper`'s attribute names carry a plane-based prefix\ninherited from the shared inversion library that also serves a multi-plane use case; in\nPyAutoGalaxy there is only the one plane, so `mesh_geometry.mesh_grid` is both the clearer name\nand the one to use.\n\n### The evidence terms\n\nThe inversion's figure of merit is a Bayesian evidence, and its components are individually\nreadable \u2014 which is how you diagnose whether regularization is doing too much or too little:\n\n```python\nprint(f\"regularization term = {inversion.regularization_term}\")\nprint(f\"log det regularization = {inversion.log_det_regularization_matrix_term}\")\nprint(f\"log det curvature + reg = {inversion.log_det_curvature_reg_matrix_term}\")\n```\n\nThe `regularization_term` measures how non-smooth the solution is; the two log-determinant\nterms are the complexity penalty that stops a 400-pixel model winning by over-fitting. The\nmatrices themselves (`curvature_matrix`, `regularization_matrix`, `curvature_reg_matrix`) are\nthere too. The derivation is in HowToGalaxy chapter 3 and the papers it cites\n(arXiv:1708.07377, arXiv:astro-ph/0601493).\n\n### Diagnostic plots\n\n`aplt.subplot_fit_imaging` includes the reconstruction on the mesh alongside the usual data /\nmodel / residual panels, and is the first thing to look at. For the inversion's own internals\nthere are dedicated functions, which live in the shared inversion library rather than on `aplt`\nand so are imported explicitly \u2014 from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/plot.py`:\n\n```python\nfrom autoarray.inversion.plot.inversion_plots import subplot_of_mapper\nfrom autoarray.inversion.plot.mapper_plots import plot_mapper, subplot_image_and_mapper\n\nPLOT_DIR = \"scripts/scratch/my_galaxy/pixelization/\"\n\naplt.subplot_fit_imaging(fit=fit, output_path=PLOT_DIR, output_format=\"png\")\n\nsubplot_of_mapper(\n inversion=inversion, mapper_index=0, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nsubplot_image_and_mapper(\n mapper=mapper, image=dataset.data, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nprint(f\"Saved to: {Path(PLOT_DIR).resolve()}\")\n```\n\n`subplot_of_mapper` is the comprehensive diagnostic \u2014 reconstructed image, reconstruction, its\nnoise map and the regularization weights in one figure \u2014 and `mapper_index` selects which linear\nobject when there are several. `subplot_image_and_mapper` shows the data beside the mesh, which\nis how you check the mesh covers the structure you care about. Note that none of these three are\nexposed on the `aplt` module \u2014 `dir(aplt)` does not list them, so the explicit imports above are\nthe only route and writing them as `aplt.`-prefixed calls raises `AttributeError`. Quote the\nabsolute path back to the user and offer to open it.\n\n## Branch \u2014 exporting the reconstruction for downstream science\n\nA reconstruction that only exists inside a PyAutoGalaxy object is hard to share and hard to\nanalyse with other tools. Write it out as coordinates plus values and it becomes ordinary\ntabular data. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py`.\n\n```python\n\"\"\"\n__Export__\n\nFour columns \u2014 y, x, reconstruction, noise \u2014 is everything needed to rebuild, plot or analyse\nthe galaxy's irregular light without PyAutoGalaxy installed. A completed model-fit also writes\nthis file into its own `image/` folder automatically; glob for `*reconstruction_0.csv` there\nrather than hard-coding the stem, which carries a prefix from the shared inversion library.\n\"\"\"\nimport csv\n\nCSV_PATH = Path(\"scripts/scratch/my_galaxy/reconstruction.csv\")\nCSV_PATH.parent.mkdir(parents=True, exist_ok=True)\n\nwith CSV_PATH.open(\"w\", newline=\"\") as f:\n writer = csv.writer(f)\n writer.writerow([\"y\", \"x\", \"reconstruction\", \"noise_map\"])\n for (y, x), value, sigma in zip(\n mesh_grid, inversion.reconstruction, inversion.reconstruction_noise_map\n ):\n writer.writerow([y, x, value, sigma])\n\nprint(f\"Reconstruction written to: {CSV_PATH.resolve()}\")\n```\n\nReloading needs nothing but the standard library, and from there the mesh is ordinary scattered\ndata. Two things the workspace does with it: a Delaunay triangulation via `scipy.spatial` for\nplotting on the mesh's own geometry, and interpolation onto a regular grid via\n`scipy.interpolate.griddata` for any tool that wants an image:\n\n```python\nimport numpy as np\nfrom scipy.interpolate import griddata\n\nwith CSV_PATH.open() as f:\n reader = csv.reader(f)\n header = next(reader) # ['y', 'x', 'reconstruction', 'noise_map']\n loaded = {key: [] for key in header}\n for row in reader:\n for key, value in zip(header, row):\n loaded[key].append(float(value))\n\npoints = np.stack((np.array(loaded[\"x\"]), np.array(loaded[\"y\"])), axis=-1)\n\ninterpolation_grid = ag.Grid2D.from_extent(\n extent=(-1.0, 1.0, -1.0, 1.0), shape_native=(201, 201)\n)\n\ninterpolated = griddata(\n points=points, values=np.array(loaded[\"reconstruction\"]), xi=interpolation_grid\n)\n```\n\nPick the `extent` to cover the reconstruction without over-resolving it \u2014 the mesh has a few\nhundred pixels, so interpolating to 201 \u00d7 201 is presentation, not information.\n\n## Branch \u2014 run time, VRAM, and the GPU-versus-CPU choice\n\nThis is the branch that decides whether your fit takes twenty minutes or three hours, and the\nanswer is not \"use the GPU\".\n\nPixelized inversions do linear algebra on very large, very **sparse** matrices. JAX has no sparse\nsupport and must work densely, which scales badly; the CPU implementation exploits the sparsity\nfully via `numba`. So the trade runs against pixel scale:\n\n- **Low resolution (`pixel_scales > 0.05`, e.g. Euclid)** \u2014 fewer sparse operations, modest VRAM.\n **GPU with JAX is usually fastest.**\n- **High resolution (`pixel_scales <= 0.03`, e.g. HST or JWST)** \u2014 the linear algebra is\n sparsity-dominated. **CPU with `numba` and many cores can beat a powerful GPU.**\n\nVRAM is the hard constraint on the GPU side. JAX must hold the whole batched likelihood in GPU\nmemory, and a pixelization needs far more than a profile model: around 0.05 GB per batched\nlikelihood at 400 reconstruction pixels, and more than 1 GB \u2014 occasionally more than 10 GB \u2014 at\nhigh resolution with a fine mesh. Two consequences: keep `n_batch` low (the workspace uses 20 for\npixelizations against 50 elsewhere), and run\n\n```python\nanalysis.print_vram_use(model=model, batch_size=search.batch_size)\n```\n\nbefore committing. It takes 20\u201330 seconds and it is the difference between knowing your limit\nand discovering it as an out-of-memory error mid-compile. If VRAM is exceeded the run time does\nnot degrade gracefully \u2014 it goes from under ten minutes to three hours or more.\n\nRough scaling with resolution, from the workspace's own measurements at a 20 \u00d7 20 mesh with VRAM\nunder control: ~10 minutes at 0.1\"/pixel, ~30 minutes at 0.05\", ~1 hour at 0.03\". Benchmark both\npaths for your own data and hardware rather than trusting either default; the search-side\nsettings are [`ag_configure_search`](./ag_configure_search.md) and\n[`../wiki/core/api/searches.md`](../wiki/core/api/searches.md).\n\n## Combine \u2014 where this hands off\n\n- **Try a basis first** \u2192 [`ag_basis_profiles`](./ag_basis_profiles.md). An MGE is four\n parameters and minutes, not hours, and it genuinely resolves the shape-mismatch case a\n pixelization is often reached for by mistake.\n- **Pick and configure the search** \u2192 [`ag_configure_search`](./ag_configure_search.md), with\n `n_batch` lowered for VRAM.\n- **Run the fit** \u2192 [`ag_run_search`](./ag_run_search.md); `image/fit.png` in the output folder\n refreshes on the fly and shows the reconstruction as it converges, which is worth watching for\n a fit this long.\n- **Plot and inspect** \u2192 [`ag_plot_fit`](./ag_plot_fit.md), plus the `subplot_of_mapper`\n diagnostics above.\n- **Load a completed fit programmatically** \u2192 [`ag_load_results`](./ag_load_results.md).\n- **Over-smoothed, over-fitted or unphysical reconstruction** \u2192\n [`ag_debug_fit_failure`](./ag_debug_fit_failure.md). The pixelization-specific causes are a\n regularization scheme too rigid for the structure, a mask that punched holes via hard masking\n where noise scaling was needed, over-sampling passed as `over_sample_size_lp` instead of\n `over_sample_size_pixelization`, and VRAM exhaustion masquerading as a hung fit.\n- **Contaminating neighbours modelled rather than scaled** \u2192\n [`ag_light_model_extras`](./ag_light_model_extras.md).\n- **Make the fit robust and fast by starting simple** \u2192 the search-chaining skill\n (`ag_chain_searches`). This is the biggest available win for a pixelization: fit a parametric\n model first, pass its bulge as priors, and let the expensive model start from a sensible place.\n The workspace recommends it explicitly.\n- **A non-parametric shape measurement instead of a reconstruction** \u2192\n [`ag_ellipse_fitting`](./ag_ellipse_fitting.md).\n\nOffer (default-yes) a dated `wiki/project/YYYY-MM-DD-.md` entry recording the mesh and its\nshape, the regularization scheme and why, the mask radius, the hardware path, and what the\nreconstruction showed \u2014 with a pixelization the choices *are* the result, and none of them are\nrecoverable from the output folder alone.\n\n## Further reading\n\n- **Student / new to galaxy morphology** \u2014 [HowToGalaxy: Bayesian regularization](https://github.com/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_bayesian_regularization.ipynb):\n why a model with hundreds of free flux values does not simply over-fit, derived from scratch;\n `tutorial_6_model_fit` then runs the full fit.\n- **General reference** \u2014 [RTD: Pixelization API](https://pyautogalaxy.readthedocs.io/en/latest/api/pixelization.html):\n every mesh, regularization scheme and the objects that combine them.\n- **Experienced PyAutoGalaxy user** \u2014 [workspace: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autogalaxy_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n the hybrid fit end to end, with the run-time, VRAM and GPU-versus-CPU discussion in full.\n\n## Agent procedural checklist\n\n1. Look at the residual map of a parametric or basis fit first. Shape-following residuals mean\n `ag_basis_profiles`; discrete off-centre lumps mean a pixelization. Do not skip this.\n2. Confirm the structure is resolved \u2014 at coarse pixel scales a pixelization reconstructs noise.\n3. Always pair the pixelization with a linear light profile for the smooth component; never let\n the mesh reconstruct the bulge.\n4. Fix `mesh_shape` before composing the model; it cannot be a free parameter.\n5. Pass over-sampling as `over_sample_size_pixelization`, not `over_sample_size_lp`.\n6. Start with `RectangularBilinearAdaptDensity` + `ag.reg.Constant`; move to `GaussianKernel` only when\n one smoothing strength demonstrably cannot serve both the bright and faint structure.\n7. Scale contaminant noise (`apply_noise_scaling`, `invert=True`) \u2014 never hard-mask pixels inside\n a pixelization fit \u2014 and check the signal-to-noise panel afterwards.\n8. `print(model.info)`, evaluate one likelihood, then `print_vram_use` before any GPU run; keep\n `n_batch` low.\n9. Read the reconstruction via `inversion.reconstruction` +\n `mapper.mesh_geometry.mesh_grid`; export to CSV for anything downstream.\n10. Save the script to `scripts/`, quote every plot's absolute path and offer to open it, then\n offer the `wiki/project/` entry recording mesh, regularization and hardware choices.\n" + "context": "These instructions were prepared by the PyAutoGalaxy assistant\n(https://github.com/PyAutoLabs/autogalaxy_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\nPyAutoGalaxy knowledge in that material to help with this notebook.\n\nYou are helping someone work through a PyAutoGalaxy notebook (notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb in HowToGalaxy).\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 PyAutoGalaxy 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 PyAutoGalaxy docs (https://pyautogalaxy.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) extra galaxies, foreground stars or artefacts that\n need masking or noise-scaling, and (b) a mask extent that captures the galaxy's\n emission out to where it meets the sky. A mask that truncates the outer isophotes\n biases the effective radius and Sersic index, so never leave the mask radius at its\n default on real data. 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 galaxy, a sample of galaxies, a paper's analysis),\n point them to the PyAutoGalaxy assistant\n https://github.com/PyAutoLabs/autogalaxy_assistant, which runs in Claude Code or\n Codex with its full skills and safety checks.\n\n# Reference material: assistant skills\n\n## Skill: ag_pixelization\n\n---\nname: ag_pixelization\ndescription: Reconstruct a galaxy's clumpy or irregular light directly on a regularized pixel mesh, using `ag.Pixelization` alongside a parametric `ag.lp_linear` bulge \u2014 the hybrid model that gives a smooth interpretable component plus a flexible flux map for everything the profile cannot explain. Covers when a pixelization is warranted and when it is overkill, choosing an `ag.mesh` and an `ag.reg` regularization scheme and what each costs in free parameters, why `mesh_shape` must be fixed before the fit, `over_sample_size_pixelization` rather than `over_sample_size_lp`, noise scaling instead of hard masking for contaminants, the positive-only solver, reading the reconstruction and its Bayesian-evidence terms out of the `Inversion`, exporting the reconstruction to CSV for collaborators, and the GPU/VRAM versus CPU run-time trade. Use when a Sersic, MGE or shapelet basis leaves structured residuals in discrete off-centre lumps. Not for morphology a basis can still fit (`ag_basis_profiles`), not for non-parametric isophote measurement (`ag_ellipse_fitting`), and not for the basics of model composition (`ag_build_imaging_model`).\n---\n\n# Reconstructing a galaxy on a pixel mesh\n\nEvery model in this workspace so far has been a *function*: you asserted a functional form and\nthe fit found its parameters. A pixelization drops the functional form. It lays a mesh of pixels\nover the galaxy, gives each pixel its own free flux value, and solves all of them at once by\nlinear inversion \u2014 thousands of amplitudes, none of them in the non-linear search's parameter\nspace. What stops that from simply fitting the noise is **regularization**: a prior that\npenalises solutions where neighbouring mesh pixels differ sharply, so the reconstruction is as\nsmooth as the data will allow and no smoother.\n\nThe science case is specific, and it is worth being honest about how specific. Galaxies with\nspiral arms, asymmetric star-forming clumps, tidal features or low-surface-brightness\nsubstructure have light that no smooth profile \u2014 and often no basis expansion either \u2014\nreproduces. The canonical use is therefore **hybrid**: a linear `Sersic` for the smooth central\nbulge, and a pixelization for the irregular remainder. That split is the point. It gives you a\nlow-dimensional, physically interpretable description of the bulge *and* a flux map of exactly\nwhat the bulge could not explain, which is the quantity you want if the science question is \"how\nmuch of this galaxy's light is in its irregular components?\"\n\nThis is a **direct** reconstruction. There is no second plane, no de-projection, no inversion of\na geometric mapping \u2014 the mesh sits on the image plane, over the galaxy, at the same coordinates\nthe data occupies. If you have met pixelized reconstruction before in a different context, that\nextra step is the one that is absent here, and its absence makes everything simpler: what the\nmesh reconstructs is the galaxy's own surface brightness, and its coordinates are sky\ncoordinates in arcseconds.\n\nStatistically, the fit now has two nested layers. The non-linear search explores a handful of\nparameters \u2014 the bulge's shape, the regularization strength \u2014 and for each proposal an inversion\nsolves the mesh amplitudes exactly and returns a **Bayesian evidence** rather than a plain\nlikelihood. That evidence includes terms that penalise an unnecessarily complex reconstruction,\nwhich is what lets a model with 400 free flux values not simply win by over-fitting. The theory\nis [`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md);\nchapter 3 of HowToGalaxy derives it from scratch.\n\nThe canonical scripts are\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py` (via a search),\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` (one direct fit, with the\ninversion internals walked through) and\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py` (export).\n\n## Before you reach for one \u2014 the honest gate\n\nA pixelization is the most powerful and the most expensive model here, with failure modes the\nparametric models do not have. Three questions decide whether you need it:\n\n- *\"What do the residuals actually look like?\"* Structured residuals that **follow the\n isophotes** \u2014 a twist, a four-lobed pattern, an ellipticity that changes with radius \u2014 are a\n *shape* problem, and an MGE fixes them for four free parameters\n ([`ag_basis_profiles`](./ag_basis_profiles.md)). Residuals in **discrete off-centre lumps** are\n a substructure problem, and that is what a pixelization is for. Look before you choose.\n- *\"Is the structure resolved?\"* At low resolution the irregular structure is not in the data,\n and a pixelization will reconstruct noise while telling you it fitted well. The workspace is\n blunt about this: for low-resolution data pixelizations are unnecessary.\n- *\"Do you need global quantities or the light distribution?\"* If you want a total flux, a size,\n an axis ratio, a Sersic index \u2014 a parametric profile, an MGE or shapelets are enough and far\n cheaper. If you want the morphology itself, particularly for faint or low-surface-brightness\n features, nothing else comes close.\n\nIf the answer to the first is \"lumps\" and to the second \"yes\", continue. Otherwise you will\nspend hours to learn less.\n\n## Ask\n\n- *\"What is the smooth component?\"* Almost always a linear `Sersic`, sometimes a Sersic plus an\n Exponential disk. It matters that this exists: without it the pixelization has to reconstruct\n the bright central bulge too, which wastes mesh resolution on light a six-parameter profile\n describes perfectly and makes the regularization choice much harder.\n- *\"How many mesh pixels can you afford?\"* This is a hardware question as much as a science one\n (see the run-time branch). 20 \u00d7 20 is the workspace's default for a GPU run; 28 \u00d7 28 to 30 \u00d7 30\n for a better-resolved reconstruction; higher only with a large-VRAM GPU or a many-core CPU.\n- *\"Are there neighbours inside the mask?\"* For a pixelization the answer is *never* to mask\n their pixels out \u2014 that punches holes in the mesh and produces discontinuity systematics.\n Scale their noise instead (branch below).\n- *\"GPU or CPU?\"* Genuinely depends on your pixel scale, and the answer is counter-intuitive.\n Ask before assuming.\n\n## Branch \u2014 the hybrid model\n\nThe canonical composition. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/modeling.py`.\n\n```python\n\"\"\"\nGalaxy Structure: Bulge Plus Pixelized Reconstruction\n=====================================================\n\nFit a galaxy whose light has two very different characters: a smooth central bulge, described by\na linear Sersic profile in six parameters, and asymmetric clumpy star formation, reconstructed\non a regularized rectangular mesh. The Sersic captures the smooth component interpretably; the\npixelization absorbs everything it cannot explain, and the reconstructed flux map is the\nmeasurement of the galaxy's irregular light.\n\n__Contents__\n\n- **Imports:** Import the required libraries.\n- **Dataset:** Load the imaging, mask it, and apply pixelization over-sampling.\n- **Mesh Shape:** Fix the mesh dimensions before the model is composed.\n- **Model:** Compose the linear Sersic bulge plus the pixelization.\n- **Check:** Confirm the parameter count, evaluate one likelihood, and estimate VRAM.\n\"\"\"\n\n\"\"\"\n__Imports__\n\"\"\"\nfrom pathlib import Path\n\nimport autofit as af\nimport autogalaxy as ag\nimport autogalaxy.plot as aplt\n\nDATASET_PATH = Path(\"dataset\") / \"imaging\" / \"my_galaxy\"\nPIXEL_SCALES = 0.1\nMASK_RADIUS = 2.0\n\n\"\"\"\n__Dataset__\n\nThe mask matters more than usual here. It defines the region the mesh is laid over, so a mask\nthat reaches well beyond the galaxy spends reconstruction pixels on empty sky, and one that\ntruncates the galaxy leaves its outer light unreconstructed\n(`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nThe over-sampling argument changes too. A pixelization evaluates light on its *own* grid with\nits own over-sampling scheme, so the size is passed as `over_sample_size_pixelization`, not the\n`over_sample_size_lp` used for light profiles. Passing the wrong one silently leaves the\npixelization grid at its default.\n\"\"\"\ndataset = ag.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=PIXEL_SCALES,\n)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=dataset.pixel_scales,\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\nover_sample_size = ag.util.over_sample.over_sample_size_via_radial_bins_from(\n grid=dataset.grid,\n sub_size_list=[8, 4, 2],\n radial_list=[0.3, 0.6],\n centre_list=[(0.0, 0.0)],\n)\ndataset = dataset.apply_over_sampling(over_sample_size_pixelization=over_sample_size)\n\n\"\"\"\n__Mesh Shape__\n\nThe mesh dimensions are **fixed before the fit and cannot be a free parameter**. This is not a\nstylistic restriction: JAX compiles the likelihood against statically shaped arrays, and the\nmesh shape sets those shapes. A rectangular mesh uses the same number of pixels in y and x.\n\"\"\"\nmesh_pixels_yx = 20\nmesh_shape = (mesh_pixels_yx, mesh_pixels_yx)\n\n\"\"\"\n__Model__\n\n`ag.Pixelization` pairs a `mesh` \u2014 where the reconstruction pixels sit \u2014 with a `regularization`\nscheme \u2014 the prior that keeps the solution smooth. It is attached to the galaxy as a component\nalongside the light profiles, and the analysis knows to route it through the inversion\n(`PyAutoGalaxy:autogalaxy/galaxy/galaxy.py`).\n\nThe mesh itself contributes **zero** free parameters; the reconstruction's 400 flux values are\nsolved, not sampled. `ag.reg.GaussianKernel` contributes two (`coefficient`, `scale`). The bulge\nis `ag.lp_linear.Sersic` rather than `ag.lp.Sersic` deliberately: its `intensity` is then solved\nby the *same* inversion that solves the mesh amplitudes, which removes the degeneracy between\n\"bright bulge, faint reconstruction\" and \"faint bulge, bright reconstruction\" that a sampled\nintensity would create.\n\"\"\"\npixelization = af.Model(\n ag.Pixelization,\n mesh=ag.mesh.RectangularBilinearAdaptDensity(shape=mesh_shape),\n regularization=ag.reg.GaussianKernel,\n)\n\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n pixelization=pixelization,\n)\n\nmodel = af.Collection(galaxies=af.Collection(galaxy=galaxy))\n\n\"\"\"\n__Check__\n\nThree cheap checks, each catching a different class of mistake: the parameter count catches a\nmodel error, one likelihood evaluation catches an incompatibility between model, dataset and\nanalysis, and `print_vram_use` catches the resource error that otherwise appears as an\nout-of-memory failure mid-compile. Pixelizations use far more VRAM than profile-only models \u2014\naround 0.05 GB per batched likelihood for 400 reconstruction pixels \u2014 which is why the\nworkspace uses a lower `n_batch` for these fits than for any other.\n\"\"\"\nprint(model.info)\nprint(f\"Total free parameters = {model.total_free_parameters}\")\n\nanalysis = ag.AnalysisImaging(dataset=dataset, use_jax=True)\n\nlog_likelihood = analysis.log_likelihood_function(\n instance=model.instance_from_prior_medians()\n)\nprint(f\"log evidence at prior medians: {float(log_likelihood):.2f}\")\n```\n\n**N = 8**: the Sersic's centre (2), `ell_comps` (2), `effective_radius`, `sersic_index`, plus the\nregularization's `coefficient` and `scale`. Eight parameters, with 400 reconstruction pixels\nsolved. That is dramatically more parsimonious than stacking parametric profiles until every\nclump is described \u2014 a 20-plus-parameter approach that would still fail on genuinely irregular\nsubstructure.\n\nExtending the smooth side is exactly as you would expect \u2014 add components as further attributes\non the galaxy, keeping them linear so their intensities join the same inversion:\n\n```python\ngalaxy = af.Model(\n ag.Galaxy,\n redshift=0.5,\n bulge=ag.lp_linear.Sersic,\n disk=ag.lp_linear.Exponential,\n pixelization=pixelization,\n)\n```\n\n## Branch \u2014 choosing a mesh and a regularization scheme\n\nThese are the two decisions that make or break a reconstruction, and the parameter cost is\nalmost entirely in the regularization.\n\n**Meshes** (`ag.mesh`, from `PyAutoArray:autoarray/inversion/mesh/`). All contribute zero free\nparameters, all take `shape=(y, x)`:\n\n| Mesh | Where the pixels go | Use when |\n|---|---|---|\n| `RectangularUniform` | a uniform grid over the masked region | you want the simplest, most predictable behaviour |\n| `RectangularBilinearAdaptDensity` | a rectangular grid whose spacing follows the density of image pixels mapping to it, via a rank CDF | **the default** \u2014 concentrates resolution where the data is, and the fast CPU path |\n| `RectangularBilinearAdaptImage` | adapts to the image's own brightness distribution | the irregular light is strongly concentrated |\n| `RectangularRTUAdaptDensity` / `RectangularRTUAdaptImage` | the same two adaptations through a kernel CDF | GPU, JAX gradient samplers and the interferometer sparse path \u2014 the only rectangular adaptive meshes with usable gradients |\n\n`Delaunay`, `KNearestNeighbor` and `KNNBarycentric` also exist for irregular tessellations. Start\nwith `RectangularBilinearAdaptDensity`; it is what every workspace example uses, and the\nrectangular meshes are what the fixed-`mesh_shape` JAX path is built around. (The unqualified\n`RectangularAdaptDensity` / `RectangularAdaptImage` names were split into the `Bilinear` and\n`RTU` families in PyAutoArray 2026.8.23.1 and no longer resolve.)\n\n**Regularization** (`ag.reg`, from `PyAutoArray:autoarray/inversion/regularization/`) is where\nthe free parameters are, and where the physics of \"how smooth should this be?\" lives:\n\n| Scheme | Free params | What it assumes |\n|---|---|---|\n| `ag.reg.Constant` | 1 (`coefficient`) | one smoothing strength everywhere \u2014 the simplest, and the right first try |\n| `ag.reg.GaussianKernel` | 2 (`coefficient`, `scale`) | smoothing with a Gaussian correlation length you also fit |\n| `ag.reg.MaternKernel` | 3 (`coefficient`, `scale`, `nu`) | as above, with the roughness `nu` free too |\n\nVerified counts, not guesses. `Constant` penalises the difference between neighbouring pixels\ndirectly; the kernel schemes impose a correlation *length*, which is physically closer to what a\ngalaxy's light does and generally reconstructs faint extended structure better at the cost of\none or two parameters. `ag.reg.Adapt` and its variants scale the smoothing with the\nreconstruction's own brightness \u2014 more flexible again, and requiring `adapt_images` on the\nanalysis, which is a step beyond this skill; ask if you want it.\n\nA practical route: fit once with `ag.reg.Constant`, look at the reconstruction. Over-smoothed\n(the clumps blurred into one blob) or over-fitted (isolated single-pixel spikes) both show up\nimmediately, and if `Constant` cannot get both the bright clumps and the faint envelope right at\none strength, that is the signal to move to `GaussianKernel`. Full discussion in\n[`../wiki/core/concepts/inversions_and_pixelizations.md`](../wiki/core/concepts/inversions_and_pixelizations.md).\n\n### The positive-only solver\n\nEvery pixelized reconstruction uses a **positive-only** linear solver: no mesh pixel may\nreconstruct negative flux. This is not a numerical nicety. A signed solver lets the\nreconstruction manufacture negative pixels that cancel positive ones to over-fit the data, and\nthat failure mode is a well-known systematic in the literature \u2014 many published methods allow\nit. Enforcing non-negativity efficiently needs bespoke linear algebra, which is why this is\nworth stating rather than assuming. You do not need to switch it on; it is the behaviour you get.\n(The one place in this workspace where you *must* disable it is a shapelet basis, which needs\nsigned amplitudes by construction \u2014 see [`ag_basis_profiles`](./ag_basis_profiles.md).)\n\n## Branch \u2014 contaminating neighbours: scale, never mask\n\nThe general advice for a neighbour inside the mask is to mask its pixels out. **For a\npixelization that advice is wrong.** Removing image pixels entirely removes their mapping into\nthe mesh, punching a hole in the reconstruction and producing discontinuities that show up as\nunexplained systematics.\n\nInstead, keep the pixels in the fit and make them contribute nothing: scale their data to zero\nand their noise to an enormous value, so the likelihood is indifferent to them while the mesh\nstays continuous. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py`.\n\n```python\n\"\"\"\n__Noise Scaling__\n\n`mask_extra_galaxies.fits` marks the contaminated pixels. It is loaded with `invert=True`\nbecause in this file `True` means \"scale this pixel\", the opposite of a modelling mask's\nconvention \u2014 getting this backwards scales the galaxy instead of the neighbour, so check the\nsubplot afterwards. `apply_noise_scaling` then zeroes those data values and inflates their\nnoise, driving their signal-to-noise to effectively zero without removing them from the mesh's\nmapping (`PyAutoArray:autoarray/dataset/imaging/dataset.py`).\n\nOrder matters: scale first, then apply the modelling mask.\n\"\"\"\nmask_extra_galaxies = ag.Mask2D.from_fits(\n file_path=DATASET_PATH / \"mask_extra_galaxies.fits\",\n pixel_scales=PIXEL_SCALES,\n invert=True,\n)\n\ndataset = dataset.apply_noise_scaling(mask=mask_extra_galaxies)\n\nmask = ag.Mask2D.circular(\n shape_native=dataset.shape_native,\n pixel_scales=PIXEL_SCALES,\n centre=(0.0, 0.0),\n radius=MASK_RADIUS,\n)\ndataset = dataset.apply_mask(mask=mask)\n\naplt.subplot_imaging_dataset(\n dataset=dataset,\n output_path=\"scripts/scratch/my_galaxy/\",\n output_filename=\"noise_scaled\",\n output_format=\"png\",\n)\n```\n\nThe scaled pixels go to a noise value of order 1e8 \u2014 visibly so on the signal-to-noise panel of\nthe subplot, which is how you confirm you scaled the right region. Drawing that mask\ninteractively is `autogalaxy_workspace:scripts/imaging/data_preparation/gui/mask_extra_galaxies.py`;\nthe modelling alternative, and when to prefer it, is\n[`ag_light_model_extras`](./ag_light_model_extras.md) and\n[`../wiki/core/concepts/extra_galaxies_and_noise_scaling.md`](../wiki/core/concepts/extra_galaxies_and_noise_scaling.md).\n\n## Branch \u2014 reading the reconstruction out\n\nThe reconstruction is the science product, and it lives on the `Inversion`. You can get there\nfrom a completed fit or from a single direct `ag.FitImaging` \u2014 the latter is the fast way to\nlearn the objects, and is what\n`autogalaxy_workspace:scripts/imaging/features/pixelization/fit.py` does.\n\n```python\n\"\"\"\n__Reconstruction__\n\n`inversion.linear_obj_list` holds the linear objects the fit solved. For a hybrid model that is\none `LightProfileLinearObjFuncList` per linear light profile plus one `Mapper` for the\npixelization; with a non-linear `ag.lp.Sersic` bulge the `Mapper` is the only entry. The\n`Mapper` is the object that maps image pixels onto mesh pixels, and it is mesh-specific\n(`PyAutoArray:autoarray/inversion/mappers/`).\n\nThe reconstruction is a 1D array of one flux per mesh pixel, and the (y, x) arcsecond\ncoordinates of those pixels are `mapper.mesh_geometry.mesh_grid`. Pair them by index \u2014 they are\nthe same length and the same order \u2014 and you have the flux map.\n\"\"\"\ninversion = fit.inversion\n\nmapper = inversion.linear_obj_list[0]\n\nreconstruction = inversion.reconstruction\nmesh_grid = mapper.mesh_geometry.mesh_grid\n\nprint(f\"{len(reconstruction)} mesh pixels reconstructed\")\nprint(f\"coordinates shape: {mesh_grid.shape}\")\n\n\"\"\"\nThe reconstruction's own uncertainties, and its projection back onto the image, are also on the\ninversion. `mapped_reconstructed_operated_data` contains **only** the pixelized component \u2014 any\nparametric light profile in the model is excluded \u2014 which is exactly the quantity you want when\nasking how much light is in the irregular component.\n\"\"\"\nprint(inversion.reconstruction_noise_map)\nprint(inversion.mapped_reconstructed_operated_data.native.shape)\n```\n\nA caution on the `Mapper`'s grid attributes: `mapper.image_plane_mesh_grid` is `None` for a\nrectangular mesh, so do not reach for it. `mapper.mesh_geometry.mesh_grid` is the mesh pixel\ncentres (verified on the released stack), and `mapper.image_plane_data_grid` is the centre of\nevery masked *image* pixel. Some of the `Mapper`'s attribute names carry a plane-based prefix\ninherited from the shared inversion library that also serves a multi-plane use case; in\nPyAutoGalaxy there is only the one plane, so `mesh_geometry.mesh_grid` is both the clearer name\nand the one to use.\n\n### The evidence terms\n\nThe inversion's figure of merit is a Bayesian evidence, and its components are individually\nreadable \u2014 which is how you diagnose whether regularization is doing too much or too little:\n\n```python\nprint(f\"regularization term = {inversion.regularization_term}\")\nprint(f\"log det regularization = {inversion.log_det_regularization_matrix_term}\")\nprint(f\"log det curvature + reg = {inversion.log_det_curvature_reg_matrix_term}\")\n```\n\nThe `regularization_term` measures how non-smooth the solution is; the two log-determinant\nterms are the complexity penalty that stops a 400-pixel model winning by over-fitting. The\nmatrices themselves (`curvature_matrix`, `regularization_matrix`, `curvature_reg_matrix`) are\nthere too. The derivation is in HowToGalaxy chapter 3 and the papers it cites\n(arXiv:1708.07377, arXiv:astro-ph/0601493).\n\n### Diagnostic plots\n\n`aplt.subplot_fit_imaging` includes the reconstruction on the mesh alongside the usual data /\nmodel / residual panels, and is the first thing to look at. For the inversion's own internals\nthere are dedicated functions, which live in the shared inversion library rather than on `aplt`\nand so are imported explicitly \u2014 from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/plot.py`:\n\n```python\nfrom autoarray.inversion.plot.inversion_plots import subplot_of_mapper\nfrom autoarray.inversion.plot.mapper_plots import plot_mapper, subplot_image_and_mapper\n\nPLOT_DIR = \"scripts/scratch/my_galaxy/pixelization/\"\n\naplt.subplot_fit_imaging(fit=fit, output_path=PLOT_DIR, output_format=\"png\")\n\nsubplot_of_mapper(\n inversion=inversion, mapper_index=0, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nsubplot_image_and_mapper(\n mapper=mapper, image=dataset.data, output_path=PLOT_DIR, output_format=\"png\"\n)\n\nprint(f\"Saved to: {Path(PLOT_DIR).resolve()}\")\n```\n\n`subplot_of_mapper` is the comprehensive diagnostic \u2014 reconstructed image, reconstruction, its\nnoise map and the regularization weights in one figure \u2014 and `mapper_index` selects which linear\nobject when there are several. `subplot_image_and_mapper` shows the data beside the mesh, which\nis how you check the mesh covers the structure you care about. Note that none of these three are\nexposed on the `aplt` module \u2014 `dir(aplt)` does not list them, so the explicit imports above are\nthe only route and writing them as `aplt.`-prefixed calls raises `AttributeError`. Quote the\nabsolute path back to the user and offer to open it.\n\n## Branch \u2014 exporting the reconstruction for downstream science\n\nA reconstruction that only exists inside a PyAutoGalaxy object is hard to share and hard to\nanalyse with other tools. Write it out as coordinates plus values and it becomes ordinary\ntabular data. Adapted from\n`autogalaxy_workspace:scripts/imaging/features/pixelization/galaxy_reconstruction.py`.\n\n```python\n\"\"\"\n__Export__\n\nFour columns \u2014 y, x, reconstruction, noise \u2014 is everything needed to rebuild, plot or analyse\nthe galaxy's irregular light without PyAutoGalaxy installed. A completed model-fit also writes\nthis file into its own `image/` folder automatically; glob for `*reconstruction_0.csv` there\nrather than hard-coding the stem, which carries a prefix from the shared inversion library.\n\"\"\"\nimport csv\n\nCSV_PATH = Path(\"scripts/scratch/my_galaxy/reconstruction.csv\")\nCSV_PATH.parent.mkdir(parents=True, exist_ok=True)\n\nwith CSV_PATH.open(\"w\", newline=\"\") as f:\n writer = csv.writer(f)\n writer.writerow([\"y\", \"x\", \"reconstruction\", \"noise_map\"])\n for (y, x), value, sigma in zip(\n mesh_grid, inversion.reconstruction, inversion.reconstruction_noise_map\n ):\n writer.writerow([y, x, value, sigma])\n\nprint(f\"Reconstruction written to: {CSV_PATH.resolve()}\")\n```\n\nReloading needs nothing but the standard library, and from there the mesh is ordinary scattered\ndata. Two things the workspace does with it: a Delaunay triangulation via `scipy.spatial` for\nplotting on the mesh's own geometry, and interpolation onto a regular grid via\n`scipy.interpolate.griddata` for any tool that wants an image:\n\n```python\nimport numpy as np\nfrom scipy.interpolate import griddata\n\nwith CSV_PATH.open() as f:\n reader = csv.reader(f)\n header = next(reader) # ['y', 'x', 'reconstruction', 'noise_map']\n loaded = {key: [] for key in header}\n for row in reader:\n for key, value in zip(header, row):\n loaded[key].append(float(value))\n\npoints = np.stack((np.array(loaded[\"x\"]), np.array(loaded[\"y\"])), axis=-1)\n\ninterpolation_grid = ag.Grid2D.from_extent(\n extent=(-1.0, 1.0, -1.0, 1.0), shape_native=(201, 201)\n)\n\ninterpolated = griddata(\n points=points, values=np.array(loaded[\"reconstruction\"]), xi=interpolation_grid\n)\n```\n\nPick the `extent` to cover the reconstruction without over-resolving it \u2014 the mesh has a few\nhundred pixels, so interpolating to 201 \u00d7 201 is presentation, not information.\n\n## Branch \u2014 run time, VRAM, and the GPU-versus-CPU choice\n\nThis is the branch that decides whether your fit takes twenty minutes or three hours, and the\nanswer is not \"use the GPU\".\n\nPixelized inversions do linear algebra on very large, very **sparse** matrices. JAX has no sparse\nsupport and must work densely, which scales badly; the CPU implementation exploits the sparsity\nfully via `numba`. So the trade runs against pixel scale:\n\n- **Low resolution (`pixel_scales > 0.05`, e.g. Euclid)** \u2014 fewer sparse operations, modest VRAM.\n **GPU with JAX is usually fastest.**\n- **High resolution (`pixel_scales <= 0.03`, e.g. HST or JWST)** \u2014 the linear algebra is\n sparsity-dominated. **CPU with `numba` and many cores can beat a powerful GPU.**\n\nVRAM is the hard constraint on the GPU side. JAX must hold the whole batched likelihood in GPU\nmemory, and a pixelization needs far more than a profile model: around 0.05 GB per batched\nlikelihood at 400 reconstruction pixels, and more than 1 GB \u2014 occasionally more than 10 GB \u2014 at\nhigh resolution with a fine mesh. Two consequences: keep `n_batch` low (the workspace uses 20 for\npixelizations against 50 elsewhere), and run\n\n```python\nanalysis.print_vram_use(model=model, batch_size=search.batch_size)\n```\n\nbefore committing. It takes 20\u201330 seconds and it is the difference between knowing your limit\nand discovering it as an out-of-memory error mid-compile. If VRAM is exceeded the run time does\nnot degrade gracefully \u2014 it goes from under ten minutes to three hours or more.\n\nRough scaling with resolution, from the workspace's own measurements at a 20 \u00d7 20 mesh with VRAM\nunder control: ~10 minutes at 0.1\"/pixel, ~30 minutes at 0.05\", ~1 hour at 0.03\". Benchmark both\npaths for your own data and hardware rather than trusting either default; the search-side\nsettings are [`ag_configure_search`](./ag_configure_search.md) and\n[`../wiki/core/api/searches.md`](../wiki/core/api/searches.md).\n\n## Combine \u2014 where this hands off\n\n- **Try a basis first** \u2192 [`ag_basis_profiles`](./ag_basis_profiles.md). An MGE is four\n parameters and minutes, not hours, and it genuinely resolves the shape-mismatch case a\n pixelization is often reached for by mistake.\n- **Pick and configure the search** \u2192 [`ag_configure_search`](./ag_configure_search.md), with\n `n_batch` lowered for VRAM.\n- **Run the fit** \u2192 [`ag_run_search`](./ag_run_search.md); `image/fit.png` in the output folder\n refreshes on the fly and shows the reconstruction as it converges, which is worth watching for\n a fit this long.\n- **Plot and inspect** \u2192 [`ag_plot_fit`](./ag_plot_fit.md), plus the `subplot_of_mapper`\n diagnostics above.\n- **Load a completed fit programmatically** \u2192 [`ag_load_results`](./ag_load_results.md).\n- **Over-smoothed, over-fitted or unphysical reconstruction** \u2192\n [`ag_debug_fit_failure`](./ag_debug_fit_failure.md). The pixelization-specific causes are a\n regularization scheme too rigid for the structure, a mask that punched holes via hard masking\n where noise scaling was needed, over-sampling passed as `over_sample_size_lp` instead of\n `over_sample_size_pixelization`, and VRAM exhaustion masquerading as a hung fit.\n- **Contaminating neighbours modelled rather than scaled** \u2192\n [`ag_light_model_extras`](./ag_light_model_extras.md).\n- **Make the fit robust and fast by starting simple** \u2192 the search-chaining skill\n (`ag_chain_searches`). This is the biggest available win for a pixelization: fit a parametric\n model first, pass its bulge as priors, and let the expensive model start from a sensible place.\n The workspace recommends it explicitly.\n- **A non-parametric shape measurement instead of a reconstruction** \u2192\n [`ag_ellipse_fitting`](./ag_ellipse_fitting.md).\n\nOffer (default-yes) a dated `wiki/project/YYYY-MM-DD-.md` entry recording the mesh and its\nshape, the regularization scheme and why, the mask radius, the hardware path, and what the\nreconstruction showed \u2014 with a pixelization the choices *are* the result, and none of them are\nrecoverable from the output folder alone.\n\n## Further reading\n\n- **Student / new to galaxy morphology** \u2014 [HowToGalaxy: Bayesian regularization](https://github.com/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_bayesian_regularization.ipynb):\n why a model with hundreds of free flux values does not simply over-fit, derived from scratch;\n `tutorial_6_model_fit` then runs the full fit.\n- **General reference** \u2014 [RTD: Pixelization API](https://pyautogalaxy.readthedocs.io/en/latest/api/pixelization.html):\n every mesh, regularization scheme and the objects that combine them.\n- **Experienced PyAutoGalaxy user** \u2014 [workspace: imaging/features/pixelization/modeling.py](https://github.com/PyAutoLabs/autogalaxy_workspace/blob/main/scripts/imaging/features/pixelization/modeling.py):\n the hybrid fit end to end, with the run-time, VRAM and GPU-versus-CPU discussion in full.\n\n## Agent procedural checklist\n\n1. Look at the residual map of a parametric or basis fit first. Shape-following residuals mean\n `ag_basis_profiles`; discrete off-centre lumps mean a pixelization. Do not skip this.\n2. Confirm the structure is resolved \u2014 at coarse pixel scales a pixelization reconstructs noise.\n3. Always pair the pixelization with a linear light profile for the smooth component; never let\n the mesh reconstruct the bulge.\n4. Fix `mesh_shape` before composing the model; it cannot be a free parameter.\n5. Pass over-sampling as `over_sample_size_pixelization`, not `over_sample_size_lp`.\n6. Start with `RectangularBilinearAdaptDensity` + `ag.reg.Constant`; move to `GaussianKernel` only when\n one smoothing strength demonstrably cannot serve both the bright and faint structure.\n7. Scale contaminant noise (`apply_noise_scaling`, `invert=True`) \u2014 never hard-mask pixels inside\n a pixelization fit \u2014 and check the signal-to-noise panel afterwards.\n8. `print(model.info)`, evaluate one likelihood, then `print_vram_use` before any GPU run; keep\n `n_batch` low.\n9. Read the reconstruction via `inversion.reconstruction` +\n `mapper.mesh_geometry.mesh_grid`; export to CSV for anything downstream.\n10. Save the script to `scripts/`, quote every plot's absolute path and offer to open it, then\n offer the `wiki/project/` entry recording mesh, regularization and hardware choices.\n" } }, "selectedAiContext": "24c2f562-da24-50bb-8eb9-5a887505fe54" diff --git a/scripts/chapter_3_pixelizations/README.md b/scripts/chapter_3_pixelizations/README.md index d45eaf7..61e9e06 100644 --- a/scripts/chapter_3_pixelizations/README.md +++ b/scripts/chapter_3_pixelizations/README.md @@ -16,10 +16,13 @@ The reconstruction still depends on modelling choices, including the mesh, its r MGE and other analytic profiles remain useful for describing and measuring smooth galaxy components. Pixelizations complement them when more complicated structure needs to be represented, including in models that combine analytic components with a pixelized reconstruction. -# Files - -- [Tutorial 1: Pixelizations](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb) — Meshes, indexed mappings, inversions and telescope blurring. +The chapter moves from mappings and evidence-based regularization to the practical limits of a fixed mesh. We then +explore brightness-guided mesh placement and adaptive smoothing, before fitting an MGE and a pixelization together. -- [Tutorial 2: Bayesian Regularization](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb) — Interpolation, smoothness priors, evidence and resolution experiments. +# Files -- [Tutorial 3: Model Fit](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb) — An MGE fit followed by a combined MGE and pixelized model. +- [Tutorial 1: Pixelizations](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb) — Mappings, inversions, positivity and telescope blurring. +- [Tutorial 2: Bayesian Regularization](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_2_bayesian_regularization.ipynb) — The Goldilocks balance, log-evidence selection and mesh resolution. +- [Tutorial 3: Fit Problems](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_3_fit_problems.ipynb) — Compact galaxies expose mesh-resolution and constant-smoothing limitations. +- [Tutorial 4: Adaptive Reconstruction](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb) — Delaunay/Hilbert mesh placement and adaptive Split regularization. +- [Tutorial 5: Model Fit](https://colab.research.google.com/github/PyAutoLabs/HowToGalaxy/blob/main/notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb) — MGE fitting, adding a pixelization and refining the combined model. diff --git a/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py b/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py index d21999b..a5d9bb0 100644 --- a/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py +++ b/scripts/chapter_3_pixelizations/tutorial_1_pixelizations.py @@ -48,17 +48,21 @@ __Contents__ -- **The image and its coordinates:** Load and mask the data. -- **A brightness mesh:** Distinguish image indexes from mesh indexes. -- **Following the mappings:** Highlight which image pixels receive each mesh contribution. -- **An inversion:** Infer brightnesses, accounting for noise and telescope blurring. -- **A model image:** Inspect the reconstruction and residuals separately. -- **Using FitImaging:** Put these ingredients into a galaxy model. +- The image and its coordinates +- A brightness mesh +- Following the mappings +- An inversion +- Keeping brightnesses non-negative +- Where the image weakly constrains the mesh +- A model image +- Using FitImaging +- Wrap Up __The image and its coordinates__ The simulator supplies a Sérsic bulge and exponential disc. The circular mask selects the image pixels used in the -fit. We use one sample per image pixel for the indexed illustrations; accurate scientific modelling should also +fit. It also affects the mesh extent: including distant blank sky spreads a fixed number of mesh points over a +larger area. Inspect the mask against the galaxy before deciding whether more mesh points are needed. We use one sample per image pixel for the indexed illustrations; accurate scientific modelling should also check image integration. Each unmasked pixel has a position and a flattened index. These indexes describe the data, not the separate mesh we are about to construct. """ @@ -209,6 +213,43 @@ ) """ +__Keeping brightnesses non-negative__ + +By default, the inversion constrains reconstructed brightnesses to be non-negative; zero is allowed. This prevents +it from explaining the observed image by adding negative galaxy light. + +Positivity and regularization do different jobs. Positivity restricts the allowed brightnesses, whereas +regularization discourages large differences between neighbours. Non-negative values can still fit noise, so the +positivity constraint does not remove the need for smoothing. + +__Where the image weakly constrains the mesh__ + +Some mesh points contribute to many image pixels, while others have little direct support. We can sum the absolute +mapping weights for each mesh point and compare that support with its reconstructed brightness. + +The next cell counts effectively unmapped points and prints the five least-supported values. There may be no +exactly unmapped points in this example; the diagnostic does not assume otherwise. +""" + +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("Mesh points with effectively zero mapping weight:", len(unsupported)) +for mesh_index in least_supported: + print( + f"Mesh index {mesh_index}: total mapping weight={mapping_support[mesh_index]:.6g}, " + f"reconstructed brightness={reconstruction[mesh_index]:.6g}" + ) + +""" +A point with no direct mapping cannot have its brightness determined directly by the image. Neighbouring values, +regularization, positivity and any boundary constraints can instead influence it. For weakly supported points, +both the data and those constraints matter. + +This weight sum is a geometric diagnostic, not a brightness uncertainty. It does not include image noise, PSF +blurring or correlations between inferred values. In particular, a finely sampled reconstruction does not give +independent measurements of features smaller than the telescope resolution. + __A model image__ The brightness reconstruction lives on the mesh. Mapping it onto the image grid and convolving with the PSF diff --git a/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py b/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py index 0c76a8d..8610605 100644 --- a/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py +++ b/scripts/chapter_3_pixelizations/tutorial_2_bayesian_regularization.py @@ -2,14 +2,20 @@ Tutorial 2: Bayesian Regularization ================================== +Choose the regularization strength by comparing log evidences, then examine how mesh resolution changes that choice. + __Contents__ -- Interpolation and our starting reconstruction. -- Reducing and increasing regularization. -- Bayesian evidence and a coefficient experiment. -- Inspecting the preferred reconstruction. -- Linear brightnesses and non-linear model choices. -- Comparing mesh resolutions. +- 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 +- Wrap Up __Interpolation and Bayesian regularization__ @@ -268,31 +274,28 @@ def fit_with_coefficient(coefficient): __Bayesian evidence__ -For inversions, we therefore need more than image agreement to choose an appropriate level of regularization. - -We use the **Bayesian evidence**, which accounts for both the fit to the image and the galaxy 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. - -There are two useful parts to this intuition. +Our regularization coefficient is a Goldilocks choice. Too little smoothing lets the reconstruction follow noise; +too much flattens brightness variations needed to explain the galaxy. We want the amount that is “just right” for +the image, mesh and prior we have supplied. -First, the reconstruction must explain the image. If strong regularization prevents it from reproducing genuine -galaxy structure, the resulting residuals count against that choice. Preferring a smooth galaxy is not useful if -that galaxy cannot explain the observed galaxy. +We choose it by maximizing the **Bayesian evidence**. For a specified mesh and regularization coefficient, the +evidence accounts for the image fit and the range of brightness patterns allowed by the reconstruction prior. +The inversion solves for the mesh brightnesses; the evidence calculation also includes the normalization and +complexity terms associated with that reconstruction. It is not simply chi-squared with a smoothing penalty added. -Second, allowing a more flexible galaxy 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. +Weak regularization permits a wider range of brightness patterns. A better image fit must justify that extra +freedom. Strong regularization restricts those patterns, but loses evidence if it prevents the reconstruction from +explaining the galaxy. The evidence weighs these effects together. -Evidence weighs the improvement in fit against the freedom allowed by the prior. An improvement must be sufficient -to justify that additional flexibility. +Complexity is therefore not just a count of mesh points. Our three fits have the same number of brightness values, +but regularization changes how independently they can vary. -This is also why complexity is not simply the number of 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 **PyAutoGalaxy**, `fit.log_evidence` returns the natural logarithm of the evidence. Taking the logarithm preserves +the ranking: **the largest log evidence selects the preferred coefficient among those evaluated**. Values may be +positive or negative; we compare their differences, not their distance from zero. -Let’s compare the evidence values returned by `FitImaging`. +Let’s calculate those values for the weak, starting and strong coefficients. We keep the same data, noise, mask and +mesh so that we can interpret this as a comparison of regularization choices. """ log_evidences = [] @@ -304,6 +307,8 @@ def fit_with_coefficient(coefficient): best_index = int(np.argmax(log_evidences)) best_label, best_coefficient, _ = fit_comparisons[best_index] +for (label, coefficient, _), log_evidence in zip(fit_comparisons, log_evidences): + print(f"{label}: difference from highest log evidence={log_evidence - max(log_evidences):.3f}") print( f"\nHighest evidence among these examples: " @@ -409,6 +414,7 @@ def fit_with_coefficient(coefficient): preferred_fit = fit_with_coefficient(coefficient=preferred_coefficient) print("Preferred sampled coefficient:", preferred_coefficient) +print("Highest sampled log evidence:", float(evidence_grid[best_index])) if best_index in (0, len(coefficient_grid) - 1): print( @@ -428,8 +434,9 @@ def fit_with_coefficient(coefficient): aplt.subplot_fit_imaging(fit=preferred_fit) """ -A preferred coefficient does not establish that every visible feature in the galaxy is real. The reconstruction -remains conditional on the galaxy mesh, noise map and telescope blurring. +Choosing the highest log evidence selects a smoothing strength for this model. It does not identify whether a +particular knot or faint extension is reliable. Examine the corresponding residuals, and check whether that +structure persists when reasonable mesh, noise and PSF assumptions are varied. Nor does a good-looking galaxy excuse coherent residuals in the image. The galaxy and residual plots provide complementary information: one shows what we inferred, while the other shows what that inference leaves @@ -598,8 +605,9 @@ def fit_with_coefficient(coefficient, mesh_shape=mesh_shape): fit = preferred_fit """ -Throughout this tutorial, we evaluated reconstructions of a specified galaxy dataset. In the next tutorial, we use -a non-linear search and combine an MGE with a pixelization through search chaining. +Throughout this tutorial, we evaluated reconstructions of a specified galaxy dataset. In the next tutorial, we reconstruct +three increasingly compact galaxies to see why the preferred coefficient can still leave a poor fit. We then +introduce adaptive meshes and smoothing, before returning to the combined MGE–pixelization search in Tutorial 5. For readers who want the underlying equations, the workspace's [pixelization likelihood guide](https://github.com/PyAutoLabs/autogalaxy_workspace/blob/main/scripts/imaging/features/pixelization/likelihood_function.py) diff --git a/scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py b/scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py new file mode 100644 index 0000000..a90054e --- /dev/null +++ b/scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py @@ -0,0 +1,697 @@ +r""" +Tutorial 3: Fit Problems +======================== + +Investigate why compact galaxies can challenge mesh resolution and constant regularization even with a known PSF. + +__Contents__ + +- When the best coefficient is not enough +- Three progressively compact galaxies +- Simulating the images +- Reconstructing the galaxies +- Finding the preferred regularization +- Starting with the extended galaxy +- Making the galaxy more compact +- A very compact galaxy +- Why the mesh becomes a limitation +- Why one regularization coefficient becomes a compromise +- Why maximizing the evidence does not remove the compromise +- Wrap Up + +""" + +r""" +__When the best coefficient is not enough__ + +We have learned how to reconstruct a galaxy and choose its regularization coefficient using Bayesian evidence. + +Does this mean we can now reconstruct any galaxy accurately? + +Let’s investigate using three galaxies whose light becomes progressively more concentrated towards their centres. We will simulate their images with the same image sampling, PSF and noise, then use that known PSF in each reconstruction. + +We do not search for an analytic profile geometry in this experiment. If coherent residuals remain, we can examine the mesh and regularization directly, without confusing them with an inaccurate PSF or a poorly explored profile fit. + +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 galaxy pixels across a compact bright component. Changing the regularization coefficient cannot add the missing resolution. + +Second, one coefficient controls smoothing across the entire galaxy. 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 galaxies. +- Simulating their galaxy 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 galaxy’s brightness distribution. + +--- +""" + +r""" +__Three progressively compact galaxies__ + +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. +""" + +import numpy as np +import matplotlib.pyplot as plt +import autogalaxy as ag +import autogalaxy.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] = ag.Galaxy( + redshift=0.5, + bulge=ag.lp.Sersic( + centre=(0.0, 0.0), + ell_comps=(0.0, 0.15), + intensity=0.2, + effective_radius=effective_radius, + sersic_index=sersic_index, + ), + ) + +r""" +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 galaxy morphologies and signal-to-noise distributions. They are not an experiment in which only the galaxy size changes. + +We put each galaxy in a separate `Galaxies` collection for the simulator: +""" + +true_galaxies = { + label: ag.Galaxies(galaxies=[source]) for label, source in sources.items() +} + +r""" +There is no lensing transformation. All three images use the same sky coordinates, while the spatial resolution needed to describe their light changes. + +--- +""" + +r""" +__Simulating the images__ + +We now simulate the three galaxy 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 galaxy 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 = ag.Grid2D.uniform( + shape_native=(150, 150), + pixel_scales=0.05, +) + +simulation_grid = simulation_grid.apply_over_sampling( + over_sample_size=8, +) + +psf = ag.Convolver.from_gaussian( + shape_native=(11, 11), + sigma=0.05, + pixel_scales=0.05, +) + +simulator = ag.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 = ag.Array2D.no_mask( + values=np.full(simulation_grid.shape_native, noise_sigma), + pixel_scales=0.05, +) + +datasets = {} + +for label, galaxies in true_galaxies.items(): + noiseless = simulator.via_galaxies_from( + galaxies=galaxies, + grid=simulation_grid, + ) + + datasets[label] = ag.Imaging( + data=ag.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]) + +r""" +Look at how the galaxy emission changes. Which parts become narrower or more sharply peaked as the galaxy 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 galaxy emission. +""" + +mask = ag.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() +} + +r""" +We now have three datasets with common 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. +""" + +r""" +__Reconstructing the galaxies__ + +We will reconstruct all three galaxies using a `25 × 25` rectangular mesh and `Constant` regularization. + +We supply the same PSF and noise model used in the simulations. The mesh brightnesses are the only description of the galaxy light in these fits. + +The function below takes a dataset and regularization coefficient, reconstructs the galaxy, and returns the fit. +""" + +mesh_shape = (25, 25) + + +def fit_source(dataset, coefficient): + pixelization = ag.Pixelization( + mesh=ag.mesh.RectangularUniform(shape=mesh_shape), + regularization=ag.reg.Constant(coefficient=coefficient), + ) + + reconstructed_source = ag.Galaxy( + redshift=0.5, + pixelization=pixelization, + ) + + galaxies = ag.Galaxies(galaxies=[reconstructed_source]) + + return ag.FitImaging( + dataset=dataset, + galaxies=galaxies, + ) + +r""" +The image grid and mask are identical across our three datasets. The rectangular mesh is therefore constructed from the same mapped coordinates, regardless of how compact the galaxy light becomes. + +This is the limitation we want to explore. The galaxy 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. + +--- +""" + +r""" +__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 galaxy need not be suitable for either compact galaxy. + +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}") + +r""" +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. + +--- +""" + +r""" +__Starting with the extended galaxy__ + +Let’s begin with the extended galaxy. + +Its brightness varies relatively gradually across the sky. We therefore expect the rectangular mesh to describe it more easily than the compact galaxies, 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(ag.Mapper)[0] + +print( + "Extended-galaxy 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 Galaxy: Preferred Reconstruction", +) + + +r""" +Look at how many mesh points lie across the bright galaxy 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 galaxy light. + +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)), +) + +r""" +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-galaxy fit provides our baseline. Next, we will make the galaxy more compact without giving the mesh any additional resolution, and examine where the reconstruction begins to struggle. +""" + +r""" +__Making the galaxy more compact__ + +Now inspect the compact galaxy. + +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 galaxy pixels. +""" + +compact_fit = preferred_fits["Compact"] +compact_mapper = compact_fit.inversion.cls_list_from(ag.Mapper)[0] + +print( + "Compact-galaxy 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 Galaxy: Preferred Reconstruction", +) + + +r""" +Look closely at the bright galaxy centre and the corresponding bright regions of the galaxy light. Does the reconstruction reproduce their sharp brightness peaks, or do coherent patches remain in the residual map? + +The galaxy 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)), +) + +r""" +If structured residuals remain, remember what we have already controlled: the image sampling and PSF are fixed, 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 galaxy adequately. + +--- +""" + +r""" +__A very compact galaxy__ + +Our final galaxy 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(ag.Mapper)[0] + +print( + "Very-compact-galaxy 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 Galaxy: Preferred Reconstruction", +) + + +r""" +Compare the residuals with those of the extended and compact galaxies. Pay particular attention to the bright parts of the galaxy light: 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}" + ) + +r""" +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 galaxy-size effects alone. Its purpose is to expose the practical difficulty of reconstructing increasingly concentrated light with an unchanged mesh and one smoothing coefficient. + +--- +""" + +r""" +__Why the mesh becomes a limitation__ + +Let’s examine the first problem: where our galaxy pixels are placed. + +The rectangular mesh covers the same region on the sky in all three fits. Its points do not move towards the bright centre when we make the galaxy 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 galaxy’s effective radius. +""" + +for label, effective_radius, _ in source_parameters: + current_fit = preferred_fits[label] + mapper = current_fit.inversion.cls_list_from(ag.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}" + ) + +r""" +This is a simple geometric diagnostic. Our galaxies 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 galaxy 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 galaxy needs them. + +For a directly observed galaxy, finer mesh sampling does not create information absent from the PSF-blurred image. We want enough freedom to describe resolved structure, not to interpret every sub-PSF variation as an independent measurement. + +This motivates adapting the mesh to the **galaxy’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? +""" + +r""" +__Why one regularization coefficient becomes a compromise__ + +Suppose we had enough mesh points to resolve the galaxy’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 galaxy 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 galaxy. + +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 galaxy 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 galaxy. +""" + +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(ag.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) + +r""" +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 galaxy regions? + +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 galaxy regions may benefit from different strengths. + +--- +""" + +r""" +__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 galaxy 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}" + ) + +r""" +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. + +--- +""" + +r""" +__Wrap Up__ + +We began with three galaxies whose light became progressively more concentrated, while keeping the image sampling, PSF and noise realization fixed. + +For each dataset, we selected the regularization coefficient using Bayesian evidence. This allowed us to investigate limitations that remain even when the PSF is known 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 points can have different spacings across the observed galaxy. + +We will then use a fitted brightness distribution to guide a Hilbert image mesh, concentrating sampling where the galaxy 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 galaxy’s bright structure, and does regularization preserve that structure while controlling fluctuations elsewhere? + +Tutorial 5 then returns to search chaining and combines an MGE with a pixelization. That alternative can let analytic profiles describe the smooth central light while a mesh supplies additional spatial freedom. +""" + +r""" +**🤖 Ask AI:** How would you distinguish insufficient galaxy-mesh resolution from over-smoothing when compact-galaxy residuals remain at the evidence-preferred coefficient? + +__Env__ + +Keep full-size datasets during automated runs so compact galaxies remain resolved +and the evidence scans test the same examples as the lesson. + +ENV: full_datasets +""" diff --git a/scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py b/scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py new file mode 100644 index 0000000..b8f066d --- /dev/null +++ b/scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py @@ -0,0 +1,1153 @@ +r""" +Tutorial 4: Adaptive Reconstruction +=================================== + +Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization for directly observed galaxies. + +__Contents__ + +- Adaptive galaxy reconstruction +- Returning to the compact-galaxy problem +- From image coordinates to a reconstruction mesh +- Constructing the Delaunay galaxy 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? +- Wrap Up + +""" + +r""" +__Adaptive galaxy reconstruction__ + +In **Fit Problems**, we identified two limitations of our reconstruction: too few mesh points across compact galaxy 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 galaxy mesh with a **Delaunay mesh**. Its points need not lie in regular rows and columns, allowing us to vary their spacing across the galaxy. + +We then need to decide where to place those points. We will select coordinates over the observed image, using an **image mesh**. Those same sky coordinates become the reconstruction vertices: there is no ray tracing. + +This gives us two useful ways to allocate resolution: + +- An evenly spaced overlay provides a simple starting mesh, independent of the galaxy brightness. +- A brightness-adaptive image mesh places more points in bright image regions, directing more resolution towards the galaxy emission. + +Finally, we will introduce adaptive regularization, allowing the smoothing strength to vary across the reconstructed galaxy. + +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. +- Connecting its sky coordinates to build a Delaunay 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. +- Preparing for the combined analytic and pixelized model in Tutorial 5. + +--- +""" + +r""" +__Returning to the compact-galaxy problem__ + +We return to the very compact galaxy 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 PSF and Gaussian noise realization. This makes the tutorial runnable independently. +""" + +import numpy as np +import matplotlib.pyplot as plt +import autogalaxy as ag +import autogalaxy.plot as aplt +import autoarray.plot as aaplt + +simulation_grid = ag.Grid2D.uniform( + shape_native=(150, 150), + pixel_scales=0.05, +) +simulation_grid = simulation_grid.apply_over_sampling( + over_sample_size=8, +) + +psf = ag.Convolver.from_gaussian( + shape_native=(11, 11), + sigma=0.05, + pixel_scales=0.05, +) + +true_source = ag.Galaxy( + redshift=0.5, + bulge=ag.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_galaxies = ag.Galaxies(galaxies=[true_source]) + +simulator = ag.SimulatorImaging( + exposure_time=300.0, + psf=psf, + background_sky_level=0.1, + add_poisson_noise_to_data=False, +) + +noiseless = simulator.via_galaxies_from( + galaxies=true_galaxies, + 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 = ag.Imaging( + data=ag.Array2D.no_mask( + values=np.asarray(noiseless.data.native) + noise_values, + pixel_scales=0.05, + ), + noise_map=ag.Array2D.no_mask( + values=np.full(simulation_grid.shape_native, noise_sigma), + pixel_scales=0.05, + ), + psf=psf, +) + +mask = ag.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) + +r""" +We keep the image sampling and PSF fixed. This lets us inspect mesh placement and regularization separately from any choice of accompanying analytic profiles. + +We know the simulated galaxy 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. + +--- +""" + +r""" +__From image coordinates to a reconstruction mesh__ + +Previously, we constructed a rectangular mesh over the galaxy image. + +For our Delaunay reconstruction, we instead begin by selecting coordinates in the **image plane**. These coordinates form the image mesh. These are also the coordinates at which we infer the galaxy brightness. The shared mapper API calls them `source_plane_mesh_grid`, but here they refer to the same sky as the image. + +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 = ag.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", +) + +r""" +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. + +We will connect these coordinates directly into a triangulation. +""" + +r""" +The overlay still does not inspect brightness. Changing an extended galaxy into a compact one leaves these coordinates unchanged for a fixed mask. Switching triangulation alone therefore does not allocate extra resolution to the bright centre. We will establish a baseline reconstruction before introducing brightness-based placement. +""" + +r""" +__Constructing the Delaunay galaxy mesh__ + +We now connect the selected mesh points into a **Delaunay triangulation**. + +Unlike a rectangular mesh, the triangles can have different sizes and orientations. Their vertices follow the selected image-mesh coordinates, allowing finer sampling in some galaxy regions than others. + +The galaxy 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 = ag.Pixelization( + mesh=ag.mesh.Delaunay( + pixels=image_plane_mesh_grid.shape[0], + ), + regularization=ag.reg.Constant( + coefficient=coefficient, + ), + ) + + source_galaxy = ag.Galaxy( + redshift=0.5, + pixelization=pixelization, + ) + + galaxies = ag.Galaxies(galaxies=[source_galaxy]) + + adapt_images = ag.AdaptImages( + galaxy_image_plane_mesh_grid_dict={ + source_galaxy: image_plane_mesh_grid, + }, + ) + + return ag.FitImaging( + dataset=dataset, + galaxies=galaxies, + adapt_images=adapt_images, + ) + + +overlay_fit = fit_delaunay_overlay(coefficient=1.0) +overlay_mapper = overlay_fit.inversion.cls_list_from(ag.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) + +r""" +`AdaptImages` supplies the image-mesh coordinates associated with the pixelized galaxy. Here, we are supplying an overlay; we have not yet introduced brightness adaptation. + +Inspect the mesh points around the reconstructed galaxy. For this overlay, their spacing is initially uniform; the Hilbert example below will change that distribution. + +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. + +--- +""" + +r""" +__Interpolating within a triangle__ + +How does a triangular mesh predict the brightness at an image 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"Galaxy 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)), +) + +r""" +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 galaxy 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. + +--- +""" + +r""" +__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) + + +r""" +As before, inspect the peak and refine the coefficient sampling around it before making a close quantitative comparison. + +Now examine the galaxy centre and bright image 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 galaxy 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 galaxy emission. +""" + +r""" +__Building an adapt image__ + +Our overlay samples the image mask uniformly. It does not distinguish bright galaxy emission from background noise. + +To direct more mesh points towards the galaxy 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 galaxy light lies. +""" + +adapt_image = overlay_fit.model_data + +aplt.plot_array( + array=overlay_fit.model_data, + title="Adapt Image: Fitted Galaxy Light", +) + +aplt.subplot_fit_imaging(fit=overlay_fit) + +r""" +Compare the adapt image with the data and residuals. Does it locate the bright galaxy regions 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 galaxy 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. + +This example reconstructs one galaxy entirely on a mesh. In a combined MGE–pixelization model, decide which light the pixelization should describe before choosing its adapt image. The total model can be dominated by a smooth bulge; it need not be the best guide for irregular outer components. Inspect neighbours and residuals rather than treating a noisy residual map as known positive galaxy light. + +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. + +--- +""" + +r""" +__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 galaxy mesh. The resulting coordinates are connected directly 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 = ag.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, + ) + +r""" +Compare the spacing of the points across the bright galaxy regions 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. + +These selected sky coordinates will be the reconstruction vertices. +""" + +r""" +The point distribution now responds to the fitted image brightness. As the galaxy becomes more compact, a brightness-weighted mesh can place more points across that compact emission. Its effective resolution remains limited by the image sampling, PSF and signal-to-noise; more vertices alone do not recover arbitrary small-scale structure. + +Next, let’s examine how its weights are calculated. + +--- +""" + +r""" +__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 = ag.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 = ag.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}", + ) + +r""" +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 galaxy. + +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 galaxy with the Hilbert mesh, checking the residuals and evidence rather than judging the mesh by its appearance alone. +""" + +r""" +__Keeping resolution in the faint regions__ + +Increasing `weight_power` concentrates points on the brightest parts of the adapt image. However, the galaxy 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 = ag.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 = ag.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}", + ) + +r""" +Compare the points around the brightest peaks, along the fainter galaxy emission, 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 galaxy light, 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. + +--- +""" + +r""" +__Reconstructing with the Hilbert mesh__ + +We now use the Hilbert coordinates to build a Delaunay galaxy mesh. + +The reconstruction proceeds as before: construct the triangulation at the selected sky coordinates, 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 = ag.Pixelization( + mesh=ag.mesh.Delaunay( + pixels=hilbert_image_plane_mesh_grid.shape[0], + ), + regularization=ag.reg.Constant( + coefficient=coefficient, + ), + ) + + source_galaxy = ag.Galaxy( + redshift=0.5, + pixelization=pixelization, + ) + + galaxies = ag.Galaxies(galaxies=[source_galaxy]) + + adapt_images = ag.AdaptImages( + galaxy_image_dict={ + source_galaxy: adapt_image, + }, + galaxy_image_plane_mesh_grid_dict={ + source_galaxy: hilbert_image_plane_mesh_grid, + }, + ) + + return ag.FitImaging( + dataset=dataset, + galaxies=galaxies, + adapt_images=adapt_images, + ) + + +hilbert_fit = fit_delaunay_hilbert( + coefficient=overlay_coefficient, +) + +hilbert_mapper = hilbert_fit.inversion.cls_list_from(ag.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 Galaxy Reconstruction", +) + +aplt.subplot_fit_imaging(fit=hilbert_fit) + +r""" +Inspect the mesh around the compact galaxy centre. Has the brightness weighting placed more vertices across the region where its brightness changes rapidly? + +Then inspect the residuals around the bright galaxy regions. 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. + +--- +""" + +r""" +__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() + +r""" +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) + + +print( + "Hilbert minus overlay log evidence:", + float(hilbert_fit.log_evidence - overlay_fit.log_evidence), +) + +r""" +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 galaxy 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. +""" + +r""" +__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 galaxy 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 galaxy 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 galaxy. + +We will first examine `ConstantSplit`, then introduce the brightness-dependent coefficients. + +--- +""" + +r""" +__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 = ag.Pixelization( + mesh=ag.mesh.Delaunay( + pixels=hilbert_image_plane_mesh_grid.shape[0], + ), + regularization=regularization, + ) + + source_galaxy = ag.Galaxy( + redshift=0.5, + pixelization=pixelization, + ) + + galaxies = ag.Galaxies(galaxies=[source_galaxy]) + + adapt_images = ag.AdaptImages( + galaxy_image_dict={ + source_galaxy: adapt_image, + }, + galaxy_image_plane_mesh_grid_dict={ + source_galaxy: hilbert_image_plane_mesh_grid, + }, + ) + + return ag.FitImaging( + dataset=dataset, + galaxies=galaxies, + adapt_images=adapt_images, + ) + +r""" +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=ag.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) + + +print( + "ConstantSplit minus Constant log evidence:", + float(split_fit.log_evidence - hilbert_fit.log_evidence), +) + +r""" +Compare the galaxy 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**. + +--- +""" + +r""" +__Allowing different smoothing strengths__ + +We now let the regularization strength depend on the galaxy brightness. + +The adapt image supplies a brightness-based signal estimate for each galaxy 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 galaxy centre. A bright off-centre component can receive the high-signal coefficient too. + +For our compact galaxy, 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 = ag.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(ag.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: Galaxy Reconstruction", +) + +aplt.subplot_fit_imaging(fit=adaptive_fit) + +r""" +Compare the regularization-weight map with the reconstructed galaxy. Smaller weights should occur where the adapt image indicates stronger galaxy 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 galaxy. How do we assign a strength to the regions between them? + +We first raise each normalized galaxy 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 galaxy signal before scaling") +ax.set_ylabel("Regularization weight") +ax.legend() +plt.show() + +r""" +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 galaxy 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**. + +--- +""" + +r""" +__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 PSF, 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 = ag.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) + +r""" +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 analysis, a non-linear search can explore these regularization parameters. Tutorial 5 demonstrates search chaining for a combined MGE–pixelization model, using constant regularization to isolate the component-decomposition question. + +--- +""" + +r""" +__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(ag.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}: Galaxy Reconstruction", + ) + + aplt.subplot_fit_imaging(fit=current_fit) + +print( + "Adaptive minus constant Split log evidence:", + best_adaptive_evidence - float(split_fit.log_evidence), +) + +r""" +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 image 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( + ag.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", +) + +r""" +A higher log evidence shows that this adaptive setting improves the reconstruction statistic for the fixed PSF 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, Tutorial 5 explores a complementary strategy: fitting smooth light with an MGE and additional light with a pixelization. This can reduce the burden on the mesh, but makes the interpretation of individual components depend on how their shared light is divided. +""" + +r""" +__Wrap Up__ + +We have separated two choices that a single regularization coefficient cannot make: where the mesh has resolution, +and how strongly its brightnesses are smoothed. The Hilbert mesh uses a fitted image to guide placement, while +adaptive Split regularization changes the local smoothing strengths on that mesh. + +Judge both changes using the residuals and evidence on the same dataset. The adapt image, mesh size, PSF and noise +remain assumptions to inspect. A higher sampled log evidence does not identify the reliability of an individual knot or show +that the adaptive parameters have been fully explored. + +Our Sérsic example is deliberately difficult for a coarse mesh, but an analytic profile can describe it directly. +The lesson is not to replace every analytic model with a pixelization. In the final tutorial we fit an MGE and a +pixelization together, examining both the quality of the total image fit and the ambiguity of the decomposition. + +**🤖 Ask AI:** How do brightness-weighted mesh placement and adaptive regularization address different limitations, +and why can neither recover information erased by telescope blurring? + +__Env__ + +Keep full-size datasets so the compact-galaxy experiment retains its resolved structure and evidence peaks. + +ENV: full_datasets +""" diff --git a/scripts/chapter_3_pixelizations/tutorial_3_model_fit.py b/scripts/chapter_3_pixelizations/tutorial_5_model_fit.py similarity index 93% rename from scripts/chapter_3_pixelizations/tutorial_3_model_fit.py rename to scripts/chapter_3_pixelizations/tutorial_5_model_fit.py index 17aa28a..2ff708d 100644 --- a/scripts/chapter_3_pixelizations/tutorial_3_model_fit.py +++ b/scripts/chapter_3_pixelizations/tutorial_5_model_fit.py @@ -1,7 +1,12 @@ """ -Tutorial 3: Model Fit +Tutorial 5: Model Fit ===================== +Tutorials 3 and 4 explored mesh resolution and adaptive smoothing using a galaxy reconstructed entirely on a mesh. +We now return to the complementary question of how to combine a smooth analytic component with a pixelization. +The rectangular mesh here keeps that decomposition experiment simple; inspect its residuals and revisit the +adaptive choices from Tutorial 4 when the pixelized component needs more spatially varying resolution. + A pixelization can describe a galaxy without assigning an analytic centre and shape to every visible feature. However, analytic profiles remain useful when we want to describe its smooth components. In this tutorial we combine an MGE with a pixelization and use search chaining to put the smooth light model in place first. @@ -17,11 +22,11 @@ __Contents__ -- **Dataset:** Load, mask and oversample the image. -- **Search 1:** Fit the smooth galaxy light with an MGE. -- **Search 2:** Add a regularized pixelization using the fitted MGE geometry. -- **Search 3:** Refine the MGE geometry together with the pixelization. -- **Interpretation:** Inspect both the residuals and the component decomposition. +- Search 1: Fit the MGE +- Search 2: Add the pixelization +- Search 3: Refine both components +- Wrap Up + """ from pathlib import Path diff --git a/workspace_index.json b/workspace_index.json index 7ae27d7..6bfd455 100644 --- a/workspace_index.json +++ b/workspace_index.json @@ -250,8 +250,11 @@ "A brightness mesh", "Following the mappings", "An inversion", + "Keeping brightnesses non-negative", + "Where the image weakly constrains the mesh", "A model image", - "Using FitImaging" + "Using FitImaging", + "Wrap Up" ], "cross_refs": [], "notebook": "notebooks/chapter_3_pixelizations/tutorial_1_pixelizations.ipynb", @@ -261,34 +264,86 @@ }, { "contents": [ - "Interpolation and our starting reconstruction.", - "Reducing and increasing regularization.", - "Bayesian evidence and a coefficient experiment.", - "Inspecting the preferred reconstruction.", - "Linear brightnesses and non-linear model choices.", - "Comparing mesh resolutions." + "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", + "Wrap Up" ], "cross_refs": [ "//github.com/PyAutoLabs/autogalaxy_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": "Choose the regularization strength by comparing log evidences, then examine how mesh resolution changes that choice.", "title": "Tutorial 2: Bayesian Regularization" }, { "contents": [ - "Dataset", - "Search 1", - "Search 2", - "Search 3", - "Interpretation" + "When the best coefficient is not enough", + "Three progressively compact galaxies", + "Simulating the images", + "Reconstructing the galaxies", + "Finding the preferred regularization", + "Starting with the extended galaxy", + "Making the galaxy more compact", + "A very compact galaxy", + "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_3_fit_problems.ipynb", + "path": "scripts/chapter_3_pixelizations/tutorial_3_fit_problems.py", + "summary": "Investigate why compact galaxies can challenge mesh resolution and constant regularization even with a known PSF.", + "title": "Tutorial 3: Fit Problems" + }, + { + "contents": [ + "Adaptive galaxy reconstruction", + "Returning to the compact-galaxy problem", + "From image coordinates to a reconstruction mesh", + "Constructing the Delaunay galaxy 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?", + "Wrap Up" + ], + "cross_refs": [], + "notebook": "notebooks/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.ipynb", + "path": "scripts/chapter_3_pixelizations/tutorial_4_adaptive_reconstruction.py", + "summary": "Combine Delaunay meshes, Hilbert brightness adaptation and adaptive Split regularization for directly observed galaxies.", + "title": "Tutorial 4: Adaptive Reconstruction" + }, + { + "contents": [ + "Search 1: Fit the MGE", + "Search 2: Add the pixelization", + "Search 3: Refine both components", + "Wrap Up" ], "cross_refs": [], - "notebook": "notebooks/chapter_3_pixelizations/tutorial_3_model_fit.ipynb", - "path": "scripts/chapter_3_pixelizations/tutorial_3_model_fit.py", - "summary": "A pixelization can describe a galaxy without assigning an analytic centre and shape to every visible feature. However, analytic profiles remain useful when we want to describe its smooth components. In this tutorial we combine an MGE with a pixelization and use search chaining to put the smooth light model in place first.", - "title": "Tutorial 3: Model Fit" + "notebook": "notebooks/chapter_3_pixelizations/tutorial_5_model_fit.ipynb", + "path": "scripts/chapter_3_pixelizations/tutorial_5_model_fit.py", + "summary": "Tutorials 3 and 4 explored mesh resolution and adaptive smoothing using a galaxy reconstructed entirely on a mesh. We now return to the complementary question of how to combine a smooth analytic component with a pixelization. The rectangular mesh here keeps that decomposition experiment simple; inspect its residuals and revisit the adaptive choices from Tutorial 4 when the pixelized component needs more spatially varying resolution.", + "title": "Tutorial 5: Model Fit" }, { "contents": [