perturb-interp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Jools Clarke.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the names of the copyright holders nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: perturb-interp
3
+ Version: 0.1.0
4
+ Summary: Gaussian perturbation interpretability for black-box regression
5
+ License-Expression: BSD-3-Clause
6
+ Project-URL: Repository, https://github.com/Jools-Clarke/perturb
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy
11
+ Requires-Dist: scipy
12
+ Requires-Dist: matplotlib
13
+ Requires-Dist: h5py
14
+ Provides-Extra: examples
15
+ Requires-Dist: torch; extra == "examples"
16
+ Requires-Dist: jupyter; extra == "examples"
17
+ Requires-Dist: tqdm; extra == "examples"
18
+ Requires-Dist: ipywidgets; extra == "examples"
19
+ Dynamic: license-file
20
+
21
+ ![perturb](https://joolsclarke.co.uk/perturb/perturb-full-light.svg)
22
+
23
+ [Visit the Perturb webpage](https://joolsclarke.co.uk/perturb)
24
+
25
+ Perturb is an interpretability framework for understanding the predictions made by black-box regression models, such
26
+ as machine learning retrievals. For a single sample, it tells you which input features the black-box's answer depends
27
+ on, how strongly, and in which direction. This repo packages the Gaussian perturbation method of Clarke, Yip &
28
+ Nikolaou, *Gaussian perturbation interpretability for regression techniques to understand retrieval black-boxes*
29
+ (RASTI 2026), so that it can be used on its own, along with a few additions of our own: a way of choosing how far to
30
+ perturb (step 2 below), and the non-linearity and threshold crossings of each response. The paper's own code,
31
+ including the TauREx-III experiments, is in `gaussian-perturbation-interpretability`.
32
+
33
+ Perturb is completely black-box agnostic. The black-box can be any function `f` that maps inputs `(N, M)` to outputs
34
+ `(N, P)`, whether that is a neural network, a Bayesian retrieval or a wrapper around some external code.
35
+
36
+ ## What can Perturb tell me?
37
+
38
+ At its core, Perturb gives you the rate-of-change of the black-box's response to perturbing individual input
39
+ features, i.e. if I increase wavelength x, what happens to output y? This is useful for understanding which regions
40
+ of the input the black-box is sensitive to for each parameter, and so which input features are most important for
41
+ predicting which outputs.
42
+
43
+ As well as importance, the response tells you how robust the answer is to noise at an input feature level. By
44
+ reading it backwards, you can estimate a per-wavelength (or per-pixel) noise budget for an observation to achieve a
45
+ desired maximum posterior uncertainty (`examples/uncertainty_budget_demo.ipynb`). Importance can also be used to find
46
+ the parts of an observation that contribute very little, so that superfluous bins can be identified, which is useful
47
+ in both observation planning and instrument design (`examples/instrument_design_demo.ipynb`).
48
+
49
+ ## Can I use Perturb on my data?
50
+
51
+ Perturb can be used on any 1D regression problem where neighbouring inputs are correlated, not just exoplanet
52
+ spectra. The Gaussian assumption may hold better or worse depending on the nature of your problem, but you should
53
+ expect at least some added stability over naive perturbation, which varies one input feature at a time.
54
+
55
+ Perturb does need to be able to sort the input features in order along an axis (e.g. wavelength). If two bins have
56
+ very different widths but similar centres, Perturb can still be used, but it won't be able to distinguish between
57
+ these bins well, and bins with exactly the same centre can't be told apart at all.
58
+
59
+ ## About this package
60
+
61
+ I am a researcher, and not a software developer. I did not have the time to develop this library from scratch. This library was refactored
62
+ from the code used in the original implementation and documented with the help of a large language model (LLM). The LLM was used to assist
63
+ in code refactoring and documentation generation. However, the final implementation, testing, and validation were performed by myself (a
64
+ human, last time I checked) to ensure that the implimentation is faithful to the paper. If you wish to use the code in your research, please
65
+ cite the original paper.
66
+
67
+ If you find any issues or have suggestions for improvements, please feel free to open an issue or submit a pull request.
68
+
69
+ ## Install
70
+
71
+ ```
72
+ pip install perturb-interp # numpy, scipy, matplotlib, h5py; then `import perturb`
73
+ ```
74
+
75
+ The demo notebooks live in `examples/` and aren't part of the PyPI package, so clone the repo to run them:
76
+
77
+ ```
78
+ pip install -e ".[examples]" # + torch, jupyter, tqdm and ipywidgets, for the demo notebooks
79
+ ```
80
+
81
+ ## The four steps
82
+
83
+ For a single sample `x_star`, Perturb nudges a small, smooth region of the input up and down by a few times its
84
+ noise, and records how the black-box's answer moves. Doing this with the region centred on every feature in turn
85
+ gives a map of which parts of the input each output depends on, and in which direction.
86
+
87
+ 1. **Choose the kernel width** (`perturb.fit_kernel_width`). Neighbouring features in real data tend to move
88
+ together, so rather than perturbing one feature at a time, we perturb a Gaussian window of them. The width of this
89
+ window is set from samples drawn from the prior, by calculating the correlation of every pair of features against
90
+ their separation and fitting a Gaussian to it.
91
+ 2. **Choose how far to perturb** (`perturb.in_distribution_curve`, `perturb.max_magnitude`). If a sample is pushed
92
+ too far, the black-box is no longer seeing anything like the data it was built for, and its answers stop meaning
93
+ much. To check for this, we feed each answer back through the forward model and measure how much of the
94
+ perturbation the best fit reproduces. The largest magnitude at which the black-box still treats a perturbation the
95
+ same way it treats a small one sets `xi_max`.
96
+ 3. **Perturb the sample** (`perturb.Perturber`). Each centre is perturbed by every magnitude `xi` on a grid, giving
97
+ the perturbed samples `X'_m = x* + sigma_m xi w(eps, m)`. Running these through the black-box gives the response,
98
+ its gradient, and the importance (i.e. the mean of the gradient).
99
+ 4. **Plot the results** (`perturb.plotting`).
100
+
101
+ Step 2 needs the forward model `g` (outputs -> inputs), i.e. the model whose parameters `f` retrieves. Each perturbed
102
+ sample's answer is fed back through `g`, and the fraction of the perturbation that this best fit reproduces is
103
+ compared with its value at the smallest magnitudes. We count a perturbed sample as in distribution while the two agree
104
+ to within `tol`. If you don't have a forward model, you can skip step 2 and choose `xi_max` yourself, as in
105
+ `examples/no_forward_model_demo.ipynb`.
106
+
107
+ By default the best fit is `g(f(X'))`, i.e. the forward model evaluated at the black-box's answer. However, if `f`
108
+ returns a posterior mean, this isn't necessarily the best-fitting spectrum (the maximum a posteriori spectrum from a
109
+ sampler would be, for example). In that case you can pass in your own spectra with `best_fit=`, as an array
110
+ `(centres, K, M)` whose `xi = 0` column is the best fit to `x_star`, using either
111
+ `pt.fraction_fitted(result, best_fit=fits)` or `pt.in_distribution_curve(result, best_fit=fits)`.
112
+
113
+ Putting it all together looks like this:
114
+
115
+ ```python
116
+ import perturb as pt
117
+ from perturb import plotting
118
+
119
+ fit = pt.fit_kernel_width(X_prior, x_axis) # X_prior: (N, M) samples from the prior
120
+ wide = pt.Perturber(f, x_axis, sigma, fit["fwhm"])(x_star, pt.magnitude_grid(xi_max=10, n=81)) # explore
121
+ curve = pt.in_distribution_curve(wide, g, tol=0.1)
122
+ xi_max = pt.max_magnitude(curve)
123
+
124
+ result = pt.Perturber(f, x_axis, sigma, fit["fwhm"])(x_star, pt.magnitude_grid(xi_max))
125
+ result.response, result.gradient, result.importance # (centres, K, P), (centres, K-1, P), (centres, P)
126
+ result.nonlinearity, result.crossing(threshold) # (centres, P), (centres, 2, P): first xi below/above 0
127
+
128
+ plotting.kernel_fit(fit); plotting.in_distribution(curve); plotting.ood_map(curve, wide)
129
+ plotting.gradient_map(result, output=0); plotting.callouts(result, output=0); plotting.importance(result)
130
+ ```
131
+
132
+ `result.nonlinearity` measures how far each response bends away from a straight line, and `result.crossing(threshold)`
133
+ finds the first magnitude on either side of zero at which the response reaches `threshold`. `plotting.callouts` draws
134
+ both of these on top of the gradient map.
135
+
136
+ If memory is a concern, `batch_size=` on `Perturber` and `in_distribution_curve` caps the number of rows passed to `f`
137
+ or `g` in each call, and with it the number of perturbed samples held in memory at once.
138
+
139
+ At `xi = 0` every centre's perturbed sample is just `x_star` itself, so `Perturber` doesn't run `f` there again.
140
+ Instead, the response is set to 0 and every centre shares the one baseline `f(x_star)`. This saves one call per
141
+ centre, and if `f` is stochastic (e.g. a sampler), it also keeps the run-to-run noise out of the gradient next to
142
+ `xi = 0`. If you would rather run `f` at `xi = 0` for every centre anyway, pass `rerun_zero=True`.
143
+
144
+ ## Saving results, and black-boxes run elsewhere
145
+
146
+ `result.save("run.h5")` writes every array in a result to an HDF5 file (`x`, `x_axis`, `sigma`, `centres`, `xi`,
147
+ `baseline`, `response`, `gradient` and `importance`, with `fwhm` and `scale` saved as attributes). The file can be
148
+ read with plain `h5py` or any other HDF5 tool, so you don't need `perturb` installed to use it, and
149
+ `pt.PerturbResult.load("run.h5")` reads it back in.
150
+
151
+ Perturb can also be used to interpret slower, traditional retrieval codes, but expect inference to take much longer,
152
+ and to only be able to afford a lower response resolution (i.e. a coarser grid of magnitudes). If `f` is too slow to
153
+ call from Python (e.g. a retrieval running on a cluster), you can build the inputs, run them yourself, and fill in the
154
+ result directly:
155
+
156
+ ```python
157
+ xi = pt.magnitude_grid(xi_max)
158
+ X = pt.perturbation_grid(x_star, sigma, x_axis, xi, fwhm) # (M, K, M): every centre, every magnitude
159
+ nz = xi != 0 # xi = 0 is x_star itself: no need to run it
160
+ Y = run_elsewhere(X[:, nz].reshape(-1, M)).reshape(M, nz.sum(), -1) # (M, K-1, P)
161
+ y0 = run_elsewhere(x_star[None])[0] # (P,)
162
+ response = np.zeros((M, len(xi), len(y0)))
163
+ response[:, nz] = Y - y0
164
+ result = pt.PerturbResult(x=x_star, x_axis=x_axis, sigma=sigma, centres=np.arange(M), xi=xi, baseline=y0,
165
+ response=response, fwhm=fwhm)
166
+ ```
167
+
168
+ Note that `xi` must be sorted in ascending order, and so must `x_axis` if you want to use the plots.
169
+
170
+ ## Demos
171
+
172
+ `examples/fourier_demo.ipynb` (demo A) takes a CNN through every step above. The network is trained to recover the
173
+ amplitudes of a set of sine waves (of fixed frequency and phase) from a noisy signal, so we know what it *should* be
174
+ looking at, and sure enough, the importance of each amplitude comes out as its own sine wave. The network comes
175
+ pre-trained, with its weights in `examples/fourier/fourier_cnn.pt`.
176
+
177
+ `examples/no_forward_model_demo.ipynb` (demo A2) uses the same network, but assumes all you have is one sample and
178
+ `f`, so the kernel width and the largest magnitude are set by hand.
179
+
180
+ `examples/uncertainty_budget_demo.ipynb` (demo B) uses a black-box that isn't machine learning at all, a `scipy`
181
+ least-squares retrieval of overlapping Gaussian bands seen by 4 filters and 2 spectrographs, which fits one
182
+ observation at a time (`batch_size=1`). Here the perturbation is read backwards as a requirement. To constrain one
183
+ amplitude to a target error bar, `result.crossing(target)` gives the magnitude at which each region's response first
184
+ reaches the target, and from this we get the largest noise each data point can have, and the number of exposures each
185
+ instrument needs. This runs in under a minute.
186
+
187
+ `examples/instrument_design_demo.ipynb` (demo C) uses the same retrieval and instruments, but this time the importance
188
+ of each data point is used to reshape the instrument. Filters are dropped, and the ends of the spectrographs are
189
+ cropped and their interior bins merged, one point at a time, until any parameter has lost more than 50 per cent of its
190
+ precision. This takes a few minutes to run.
@@ -0,0 +1,170 @@
1
+ ![perturb](https://joolsclarke.co.uk/perturb/perturb-full-light.svg)
2
+
3
+ [Visit the Perturb webpage](https://joolsclarke.co.uk/perturb)
4
+
5
+ Perturb is an interpretability framework for understanding the predictions made by black-box regression models, such
6
+ as machine learning retrievals. For a single sample, it tells you which input features the black-box's answer depends
7
+ on, how strongly, and in which direction. This repo packages the Gaussian perturbation method of Clarke, Yip &
8
+ Nikolaou, *Gaussian perturbation interpretability for regression techniques to understand retrieval black-boxes*
9
+ (RASTI 2026), so that it can be used on its own, along with a few additions of our own: a way of choosing how far to
10
+ perturb (step 2 below), and the non-linearity and threshold crossings of each response. The paper's own code,
11
+ including the TauREx-III experiments, is in `gaussian-perturbation-interpretability`.
12
+
13
+ Perturb is completely black-box agnostic. The black-box can be any function `f` that maps inputs `(N, M)` to outputs
14
+ `(N, P)`, whether that is a neural network, a Bayesian retrieval or a wrapper around some external code.
15
+
16
+ ## What can Perturb tell me?
17
+
18
+ At its core, Perturb gives you the rate-of-change of the black-box's response to perturbing individual input
19
+ features, i.e. if I increase wavelength x, what happens to output y? This is useful for understanding which regions
20
+ of the input the black-box is sensitive to for each parameter, and so which input features are most important for
21
+ predicting which outputs.
22
+
23
+ As well as importance, the response tells you how robust the answer is to noise at an input feature level. By
24
+ reading it backwards, you can estimate a per-wavelength (or per-pixel) noise budget for an observation to achieve a
25
+ desired maximum posterior uncertainty (`examples/uncertainty_budget_demo.ipynb`). Importance can also be used to find
26
+ the parts of an observation that contribute very little, so that superfluous bins can be identified, which is useful
27
+ in both observation planning and instrument design (`examples/instrument_design_demo.ipynb`).
28
+
29
+ ## Can I use Perturb on my data?
30
+
31
+ Perturb can be used on any 1D regression problem where neighbouring inputs are correlated, not just exoplanet
32
+ spectra. The Gaussian assumption may hold better or worse depending on the nature of your problem, but you should
33
+ expect at least some added stability over naive perturbation, which varies one input feature at a time.
34
+
35
+ Perturb does need to be able to sort the input features in order along an axis (e.g. wavelength). If two bins have
36
+ very different widths but similar centres, Perturb can still be used, but it won't be able to distinguish between
37
+ these bins well, and bins with exactly the same centre can't be told apart at all.
38
+
39
+ ## About this package
40
+
41
+ I am a researcher, and not a software developer. I did not have the time to develop this library from scratch. This library was refactored
42
+ from the code used in the original implementation and documented with the help of a large language model (LLM). The LLM was used to assist
43
+ in code refactoring and documentation generation. However, the final implementation, testing, and validation were performed by myself (a
44
+ human, last time I checked) to ensure that the implimentation is faithful to the paper. If you wish to use the code in your research, please
45
+ cite the original paper.
46
+
47
+ If you find any issues or have suggestions for improvements, please feel free to open an issue or submit a pull request.
48
+
49
+ ## Install
50
+
51
+ ```
52
+ pip install perturb-interp # numpy, scipy, matplotlib, h5py; then `import perturb`
53
+ ```
54
+
55
+ The demo notebooks live in `examples/` and aren't part of the PyPI package, so clone the repo to run them:
56
+
57
+ ```
58
+ pip install -e ".[examples]" # + torch, jupyter, tqdm and ipywidgets, for the demo notebooks
59
+ ```
60
+
61
+ ## The four steps
62
+
63
+ For a single sample `x_star`, Perturb nudges a small, smooth region of the input up and down by a few times its
64
+ noise, and records how the black-box's answer moves. Doing this with the region centred on every feature in turn
65
+ gives a map of which parts of the input each output depends on, and in which direction.
66
+
67
+ 1. **Choose the kernel width** (`perturb.fit_kernel_width`). Neighbouring features in real data tend to move
68
+ together, so rather than perturbing one feature at a time, we perturb a Gaussian window of them. The width of this
69
+ window is set from samples drawn from the prior, by calculating the correlation of every pair of features against
70
+ their separation and fitting a Gaussian to it.
71
+ 2. **Choose how far to perturb** (`perturb.in_distribution_curve`, `perturb.max_magnitude`). If a sample is pushed
72
+ too far, the black-box is no longer seeing anything like the data it was built for, and its answers stop meaning
73
+ much. To check for this, we feed each answer back through the forward model and measure how much of the
74
+ perturbation the best fit reproduces. The largest magnitude at which the black-box still treats a perturbation the
75
+ same way it treats a small one sets `xi_max`.
76
+ 3. **Perturb the sample** (`perturb.Perturber`). Each centre is perturbed by every magnitude `xi` on a grid, giving
77
+ the perturbed samples `X'_m = x* + sigma_m xi w(eps, m)`. Running these through the black-box gives the response,
78
+ its gradient, and the importance (i.e. the mean of the gradient).
79
+ 4. **Plot the results** (`perturb.plotting`).
80
+
81
+ Step 2 needs the forward model `g` (outputs -> inputs), i.e. the model whose parameters `f` retrieves. Each perturbed
82
+ sample's answer is fed back through `g`, and the fraction of the perturbation that this best fit reproduces is
83
+ compared with its value at the smallest magnitudes. We count a perturbed sample as in distribution while the two agree
84
+ to within `tol`. If you don't have a forward model, you can skip step 2 and choose `xi_max` yourself, as in
85
+ `examples/no_forward_model_demo.ipynb`.
86
+
87
+ By default the best fit is `g(f(X'))`, i.e. the forward model evaluated at the black-box's answer. However, if `f`
88
+ returns a posterior mean, this isn't necessarily the best-fitting spectrum (the maximum a posteriori spectrum from a
89
+ sampler would be, for example). In that case you can pass in your own spectra with `best_fit=`, as an array
90
+ `(centres, K, M)` whose `xi = 0` column is the best fit to `x_star`, using either
91
+ `pt.fraction_fitted(result, best_fit=fits)` or `pt.in_distribution_curve(result, best_fit=fits)`.
92
+
93
+ Putting it all together looks like this:
94
+
95
+ ```python
96
+ import perturb as pt
97
+ from perturb import plotting
98
+
99
+ fit = pt.fit_kernel_width(X_prior, x_axis) # X_prior: (N, M) samples from the prior
100
+ wide = pt.Perturber(f, x_axis, sigma, fit["fwhm"])(x_star, pt.magnitude_grid(xi_max=10, n=81)) # explore
101
+ curve = pt.in_distribution_curve(wide, g, tol=0.1)
102
+ xi_max = pt.max_magnitude(curve)
103
+
104
+ result = pt.Perturber(f, x_axis, sigma, fit["fwhm"])(x_star, pt.magnitude_grid(xi_max))
105
+ result.response, result.gradient, result.importance # (centres, K, P), (centres, K-1, P), (centres, P)
106
+ result.nonlinearity, result.crossing(threshold) # (centres, P), (centres, 2, P): first xi below/above 0
107
+
108
+ plotting.kernel_fit(fit); plotting.in_distribution(curve); plotting.ood_map(curve, wide)
109
+ plotting.gradient_map(result, output=0); plotting.callouts(result, output=0); plotting.importance(result)
110
+ ```
111
+
112
+ `result.nonlinearity` measures how far each response bends away from a straight line, and `result.crossing(threshold)`
113
+ finds the first magnitude on either side of zero at which the response reaches `threshold`. `plotting.callouts` draws
114
+ both of these on top of the gradient map.
115
+
116
+ If memory is a concern, `batch_size=` on `Perturber` and `in_distribution_curve` caps the number of rows passed to `f`
117
+ or `g` in each call, and with it the number of perturbed samples held in memory at once.
118
+
119
+ At `xi = 0` every centre's perturbed sample is just `x_star` itself, so `Perturber` doesn't run `f` there again.
120
+ Instead, the response is set to 0 and every centre shares the one baseline `f(x_star)`. This saves one call per
121
+ centre, and if `f` is stochastic (e.g. a sampler), it also keeps the run-to-run noise out of the gradient next to
122
+ `xi = 0`. If you would rather run `f` at `xi = 0` for every centre anyway, pass `rerun_zero=True`.
123
+
124
+ ## Saving results, and black-boxes run elsewhere
125
+
126
+ `result.save("run.h5")` writes every array in a result to an HDF5 file (`x`, `x_axis`, `sigma`, `centres`, `xi`,
127
+ `baseline`, `response`, `gradient` and `importance`, with `fwhm` and `scale` saved as attributes). The file can be
128
+ read with plain `h5py` or any other HDF5 tool, so you don't need `perturb` installed to use it, and
129
+ `pt.PerturbResult.load("run.h5")` reads it back in.
130
+
131
+ Perturb can also be used to interpret slower, traditional retrieval codes, but expect inference to take much longer,
132
+ and to only be able to afford a lower response resolution (i.e. a coarser grid of magnitudes). If `f` is too slow to
133
+ call from Python (e.g. a retrieval running on a cluster), you can build the inputs, run them yourself, and fill in the
134
+ result directly:
135
+
136
+ ```python
137
+ xi = pt.magnitude_grid(xi_max)
138
+ X = pt.perturbation_grid(x_star, sigma, x_axis, xi, fwhm) # (M, K, M): every centre, every magnitude
139
+ nz = xi != 0 # xi = 0 is x_star itself: no need to run it
140
+ Y = run_elsewhere(X[:, nz].reshape(-1, M)).reshape(M, nz.sum(), -1) # (M, K-1, P)
141
+ y0 = run_elsewhere(x_star[None])[0] # (P,)
142
+ response = np.zeros((M, len(xi), len(y0)))
143
+ response[:, nz] = Y - y0
144
+ result = pt.PerturbResult(x=x_star, x_axis=x_axis, sigma=sigma, centres=np.arange(M), xi=xi, baseline=y0,
145
+ response=response, fwhm=fwhm)
146
+ ```
147
+
148
+ Note that `xi` must be sorted in ascending order, and so must `x_axis` if you want to use the plots.
149
+
150
+ ## Demos
151
+
152
+ `examples/fourier_demo.ipynb` (demo A) takes a CNN through every step above. The network is trained to recover the
153
+ amplitudes of a set of sine waves (of fixed frequency and phase) from a noisy signal, so we know what it *should* be
154
+ looking at, and sure enough, the importance of each amplitude comes out as its own sine wave. The network comes
155
+ pre-trained, with its weights in `examples/fourier/fourier_cnn.pt`.
156
+
157
+ `examples/no_forward_model_demo.ipynb` (demo A2) uses the same network, but assumes all you have is one sample and
158
+ `f`, so the kernel width and the largest magnitude are set by hand.
159
+
160
+ `examples/uncertainty_budget_demo.ipynb` (demo B) uses a black-box that isn't machine learning at all, a `scipy`
161
+ least-squares retrieval of overlapping Gaussian bands seen by 4 filters and 2 spectrographs, which fits one
162
+ observation at a time (`batch_size=1`). Here the perturbation is read backwards as a requirement. To constrain one
163
+ amplitude to a target error bar, `result.crossing(target)` gives the magnitude at which each region's response first
164
+ reaches the target, and from this we get the largest noise each data point can have, and the number of exposures each
165
+ instrument needs. This runs in under a minute.
166
+
167
+ `examples/instrument_design_demo.ipynb` (demo C) uses the same retrieval and instruments, but this time the importance
168
+ of each data point is used to reshape the instrument. Filters are dropped, and the ends of the spectrographs are
169
+ cropped and their interior bins merged, one point at a time, until any parameter has lost more than 50 per cent of its
170
+ precision. This takes a few minutes to run.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "perturb-interp"
7
+ version = "0.1.0"
8
+ description = "Gaussian perturbation interpretability for black-box regression"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ requires-python = ">=3.9"
12
+ dependencies = ["numpy", "scipy", "matplotlib", "h5py"]
13
+
14
+ [project.urls]
15
+ Repository = "https://github.com/Jools-Clarke/perturb"
16
+
17
+ [project.optional-dependencies]
18
+ # the example network and notebooks
19
+ examples = ["torch", "jupyter", "tqdm", "ipywidgets"]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,22 @@
1
+ """Perturb: Gaussian perturbation interpretability for any black-box function.
2
+
3
+ The package follows the four steps of the method, with one module for each (and the perturbation split over two):
4
+
5
+ kernel.py step 1, choosing the kernel width from how quickly the correlation between features falls off
6
+ ood.py step 2, choosing how far to perturb, by checking how much of each perturbation the retrieval fits
7
+ grid.py step 3, building the magnitude grid and the perturbed samples
8
+ perturb.py step 3, running the black-box over the perturbed samples (Perturber) to get the response, its
9
+ gradient and the importance
10
+ plotting.py step 4, plotting the results
11
+ """
12
+ from .grid import magnitude_grid, perturbation_grid, perturbed_samples
13
+ from .kernel import correlation_matrix, fit_kernel_width, gaussian_window
14
+ from .ood import fraction_fitted, in_distribution_curve, max_magnitude
15
+ from .perturb import PerturbResult, Perturber
16
+
17
+ __all__ = [
18
+ "magnitude_grid", "perturbation_grid", "perturbed_samples",
19
+ "correlation_matrix", "fit_kernel_width", "gaussian_window",
20
+ "fraction_fitted", "in_distribution_curve", "max_magnitude",
21
+ "PerturbResult", "Perturber",
22
+ ]
@@ -0,0 +1,141 @@
1
+ """Step 3: building the perturbation grid.
2
+
3
+ For a sample x* with noise sigma, we perturb the region around feature m by adding a Gaussian window w of FWHM eps,
4
+ scaled by each magnitude xi_k in turn (in units of sigma_m),
5
+
6
+ X'_m = 1_K x* + sigma_m xi w(eps, m).
7
+
8
+ This gives one perturbed copy of x* per magnitude. Everything here is pure numpy.
9
+ """
10
+ import numpy as np
11
+
12
+ from .kernel import gaussian_window
13
+
14
+
15
+ def magnitude_grid(xi_max=3.0, n=101):
16
+ """Build the grid of perturbation magnitudes.
17
+
18
+ Parameters
19
+ ----------
20
+ xi_max : float, optional
21
+ The largest magnitude, in units of sigma. The grid runs from -xi_max to xi_max.
22
+ n : int, optional
23
+ The number of magnitudes. An even n is bumped up by one, and anything below 3 becomes 3, so that xi = 0 is
24
+ always on the grid.
25
+
26
+ Returns
27
+ -------
28
+ ndarray, shape (K,)
29
+ The magnitudes, linearly spaced.
30
+ """
31
+ if n % 2 == 0:
32
+ n += 1
33
+ n = max(n, 3)
34
+ return np.round(np.linspace(-xi_max, xi_max, n), 10)
35
+
36
+
37
+ def perturbation_rows(x, sigma, x_axis, xi, fwhm, scale="centre", centres=None, rows=None):
38
+ """Build some or all of the rows of the flattened perturbation grid.
39
+
40
+ The full grid has shape (n_centres * K, M), where row ``j * K + k`` is centre j perturbed by magnitude xi_k.
41
+ Asking for only a subset of rows means the grid can be built a batch at a time, rather than all at once.
42
+
43
+ Parameters
44
+ ----------
45
+ x : array_like, shape (M,)
46
+ The sample being perturbed.
47
+ sigma : float or array_like, shape (M,)
48
+ The noise on each feature of x.
49
+ x_axis : array_like, shape (M,)
50
+ The position of each feature (e.g. wavelength).
51
+ xi : array_like, shape (K,)
52
+ The perturbation magnitudes, in units of sigma.
53
+ fwhm : float
54
+ The FWHM of the Gaussian window, in the units of x_axis.
55
+ scale : {"centre", "own"}, optional
56
+ How each feature in the window is scaled. "centre" moves every feature by the noise of the centre feature,
57
+ which keeps the smooth shape of the window. "own" moves each feature by its own noise instead, so that the
58
+ perturbation follows the noise from one feature to the next. This is worth using if the noise jumps sharply
59
+ between neighbouring features.
60
+ centres : array_like of int, optional
61
+ The indices of the centre features. By default every feature is used.
62
+ rows : array_like of int, optional
63
+ The flat row indices to build. By default every row is built.
64
+
65
+ Returns
66
+ -------
67
+ ndarray, shape (len(rows), M)
68
+ The perturbed samples.
69
+
70
+ Raises
71
+ ------
72
+ ValueError
73
+ If scale is not "centre" or "own".
74
+ """
75
+ if scale not in ("centre", "own"):
76
+ raise ValueError(f"scale must be 'centre' or 'own', not {scale!r}")
77
+
78
+ x = np.asarray(x, float)
79
+ x_axis = np.asarray(x_axis, float)
80
+ xi = np.atleast_1d(np.asarray(xi, float))
81
+ sigma = np.broadcast_to(np.asarray(sigma, float), x.shape)
82
+
83
+ if centres is None:
84
+ centres = np.arange(len(x))
85
+ else:
86
+ centres = np.atleast_1d(centres)
87
+
88
+ if rows is None:
89
+ rows = np.arange(len(centres) * len(xi))
90
+ else:
91
+ rows = np.asarray(rows)
92
+
93
+ # split each flat row index into its centre and its magnitude
94
+ j, k = np.divmod(rows, len(xi))
95
+ m = centres[j]
96
+
97
+ window = gaussian_window(x_axis[None, :], x_axis[m][:, None], fwhm)
98
+ if scale == "centre":
99
+ s = sigma[m][:, None]
100
+ else:
101
+ s = sigma[None, :]
102
+ return x[None, :] + xi[k][:, None] * s * window
103
+
104
+
105
+ def perturbed_samples(x, sigma, x_axis, centre_index, xi, fwhm, scale="centre"):
106
+ """Perturb x around a single centre feature m, giving X'_m.
107
+
108
+ Parameters
109
+ ----------
110
+ x, sigma, x_axis, xi, fwhm, scale
111
+ As in `perturbation_rows`.
112
+ centre_index : int
113
+ The index of the centre feature m.
114
+
115
+ Returns
116
+ -------
117
+ ndarray, shape (K, M)
118
+ One perturbed copy of x per magnitude.
119
+ """
120
+ return perturbation_rows(x, sigma, x_axis, xi, fwhm, scale, [centre_index])
121
+
122
+
123
+ def perturbation_grid(x, sigma, x_axis, xi, fwhm, scale="centre", centres=None):
124
+ """Perturb x around every centre feature.
125
+
126
+ Parameters
127
+ ----------
128
+ x, sigma, x_axis, xi, fwhm, scale, centres
129
+ As in `perturbation_rows`.
130
+
131
+ Returns
132
+ -------
133
+ ndarray, shape (n_centres, K, M)
134
+ The output of `perturbed_samples` for each centre in turn.
135
+ """
136
+ if centres is None:
137
+ n_centres = len(x)
138
+ else:
139
+ n_centres = len(np.atleast_1d(centres))
140
+ rows = perturbation_rows(x, sigma, x_axis, xi, fwhm, scale, centres)
141
+ return rows.reshape(n_centres, -1, len(x))