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.
- perturb_interp-0.1.0/LICENSE +29 -0
- perturb_interp-0.1.0/PKG-INFO +190 -0
- perturb_interp-0.1.0/README.md +170 -0
- perturb_interp-0.1.0/pyproject.toml +22 -0
- perturb_interp-0.1.0/setup.cfg +4 -0
- perturb_interp-0.1.0/src/perturb/__init__.py +22 -0
- perturb_interp-0.1.0/src/perturb/grid.py +141 -0
- perturb_interp-0.1.0/src/perturb/kernel.py +181 -0
- perturb_interp-0.1.0/src/perturb/ood.py +150 -0
- perturb_interp-0.1.0/src/perturb/perturb.py +276 -0
- perturb_interp-0.1.0/src/perturb/plotting.py +484 -0
- perturb_interp-0.1.0/src/perturb_interp.egg-info/PKG-INFO +190 -0
- perturb_interp-0.1.0/src/perturb_interp.egg-info/SOURCES.txt +15 -0
- perturb_interp-0.1.0/src/perturb_interp.egg-info/dependency_links.txt +1 -0
- perturb_interp-0.1.0/src/perturb_interp.egg-info/requires.txt +10 -0
- perturb_interp-0.1.0/src/perturb_interp.egg-info/top_level.txt +1 -0
- perturb_interp-0.1.0/tests/test_perturb.py +247 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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,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))
|