perturb-interp 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- perturb/grid.py +141 -0
- perturb/kernel.py +181 -0
- perturb/ood.py +150 -0
- perturb/perturb.py +276 -0
- perturb/plotting.py +484 -0
- perturb_interp-0.1.0.dist-info/METADATA +190 -0
- perturb_interp-0.1.0.dist-info/RECORD +11 -0
- perturb_interp-0.1.0.dist-info/WHEEL +5 -0
- perturb_interp-0.1.0.dist-info/licenses/LICENSE +29 -0
- perturb_interp-0.1.0.dist-info/top_level.txt +1 -0
perturb/__init__.py
ADDED
|
@@ -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
|
+
]
|
perturb/grid.py
ADDED
|
@@ -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))
|
perturb/kernel.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Step 1: choosing the width of the Gaussian kernel.
|
|
2
|
+
|
|
3
|
+
Neighbouring input features (e.g. adjacent wavelength bins) tend to move together, and the Pearson correlation between
|
|
4
|
+
two features, taken across a set of samples drawn from the prior, falls off with their separation. Fitting a Gaussian
|
|
5
|
+
to this fall-off gives us the FWHM of the perturbation window, i.e. the scale over which neighbouring features move
|
|
6
|
+
together in real data.
|
|
7
|
+
"""
|
|
8
|
+
import numpy as np
|
|
9
|
+
from scipy.optimize import curve_fit
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def gaussian_window(x_axis, centre, fwhm):
|
|
13
|
+
"""The Gaussian window w(eps, m).
|
|
14
|
+
|
|
15
|
+
The window is defined on the feature axis itself rather than on the bin index, so unevenly spaced features are
|
|
16
|
+
handled properly.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
x_axis : array_like
|
|
21
|
+
The feature positions to evaluate the window at.
|
|
22
|
+
centre : float or array_like
|
|
23
|
+
The position of the peak, which broadcasts against x_axis.
|
|
24
|
+
fwhm : float
|
|
25
|
+
The full width at half maximum, in the units of x_axis.
|
|
26
|
+
|
|
27
|
+
Returns
|
|
28
|
+
-------
|
|
29
|
+
ndarray
|
|
30
|
+
The window, equal to 1 at the centre.
|
|
31
|
+
"""
|
|
32
|
+
offset = np.asarray(x_axis, float) - centre
|
|
33
|
+
return np.exp(-4 * np.log(2) * offset ** 2 / fwhm ** 2)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def minmax(X):
|
|
37
|
+
"""Min-max normalise each sample over its features.
|
|
38
|
+
|
|
39
|
+
Parameters
|
|
40
|
+
----------
|
|
41
|
+
X : ndarray, shape (..., M)
|
|
42
|
+
The samples, one per row.
|
|
43
|
+
|
|
44
|
+
Returns
|
|
45
|
+
-------
|
|
46
|
+
ndarray, shape (..., M)
|
|
47
|
+
Each sample rescaled to run from 0 to 1. A flat sample comes out as NaN, as it cannot be rescaled.
|
|
48
|
+
"""
|
|
49
|
+
lo = X.min(axis=-1, keepdims=True)
|
|
50
|
+
hi = X.max(axis=-1, keepdims=True)
|
|
51
|
+
return (X - lo) / (hi - lo)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def correlation_matrix(X, normalise=True):
|
|
55
|
+
"""Calculate the Pearson correlation of every pair of features across a set of samples.
|
|
56
|
+
|
|
57
|
+
Parameters
|
|
58
|
+
----------
|
|
59
|
+
X : array_like, shape (N, M)
|
|
60
|
+
N samples of M features.
|
|
61
|
+
normalise : bool, optional
|
|
62
|
+
Min-max normalise each sample first. This is needed when the overall level of a sample (e.g. the depth of
|
|
63
|
+
a spectrum) varies far more than its features do, as otherwise every pair of features would look
|
|
64
|
+
correlated. Flat samples, which cannot be normalised, are dropped.
|
|
65
|
+
|
|
66
|
+
Returns
|
|
67
|
+
-------
|
|
68
|
+
ndarray, shape (M, M)
|
|
69
|
+
The correlation matrix.
|
|
70
|
+
"""
|
|
71
|
+
X = np.asarray(X, float)
|
|
72
|
+
if normalise:
|
|
73
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
74
|
+
X = minmax(X)
|
|
75
|
+
X = X[np.all(np.isfinite(X), axis=1)]
|
|
76
|
+
return np.corrcoef(X, rowvar=False)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def correlation_vs_separation(corr, x_axis, bin_width, min_count=5):
|
|
80
|
+
"""Bin the correlation of every pair of features by their separation.
|
|
81
|
+
|
|
82
|
+
Parameters
|
|
83
|
+
----------
|
|
84
|
+
corr : array_like, shape (M, M)
|
|
85
|
+
The correlation matrix, from `correlation_matrix`.
|
|
86
|
+
x_axis : array_like, shape (M,)
|
|
87
|
+
The feature positions.
|
|
88
|
+
bin_width : float
|
|
89
|
+
The width of the separation bins, which are centred on zero.
|
|
90
|
+
min_count : int, optional
|
|
91
|
+
A bin needs more than this many pairs to be given an error bar.
|
|
92
|
+
|
|
93
|
+
Returns
|
|
94
|
+
-------
|
|
95
|
+
dict
|
|
96
|
+
Arrays over the non-empty bins, holding
|
|
97
|
+
|
|
98
|
+
- ``separation``: the bin centres
|
|
99
|
+
- ``rho``: the mean correlation
|
|
100
|
+
- ``rho_err``: the standard error of the mean, which is NaN for bins with min_count pairs or fewer
|
|
101
|
+
- ``n``: the number of pairs
|
|
102
|
+
- ``fit``: bool, the bins that can be used in the fit (those with a finite, non-zero error)
|
|
103
|
+
"""
|
|
104
|
+
x_axis = np.asarray(x_axis, float)
|
|
105
|
+
sep = (x_axis[None, :] - x_axis[:, None]).ravel()
|
|
106
|
+
rho = np.asarray(corr, float).ravel()
|
|
107
|
+
|
|
108
|
+
keep = np.isfinite(rho) # a constant feature has no correlation
|
|
109
|
+
sep = sep[keep]
|
|
110
|
+
rho = rho[keep]
|
|
111
|
+
|
|
112
|
+
n_side = np.ceil(np.abs(sep).max() / bin_width)
|
|
113
|
+
centres = np.arange(-n_side, n_side + 1) * bin_width
|
|
114
|
+
n_bins = len(centres)
|
|
115
|
+
idx = np.round(sep / bin_width).astype(int) + int(n_side)
|
|
116
|
+
idx = np.clip(idx, 0, n_bins - 1)
|
|
117
|
+
|
|
118
|
+
n = np.bincount(idx, minlength=n_bins)
|
|
119
|
+
mean = np.bincount(idx, rho, n_bins) / np.maximum(n, 1)
|
|
120
|
+
sum_sq = np.bincount(idx, (rho - mean[idx]) ** 2, n_bins)
|
|
121
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
122
|
+
# we use the standard error rather than the standard deviation, as the number of pairs falls with separation
|
|
123
|
+
std = np.sqrt(sum_sq / (n - 1))
|
|
124
|
+
err = np.where(n > min_count, std / np.sqrt(n), np.nan)
|
|
125
|
+
|
|
126
|
+
has = n > 0
|
|
127
|
+
out = dict(separation=centres[has], rho=mean[has], rho_err=err[has], n=n[has])
|
|
128
|
+
# the self-pairs (rho = 1, with no spread) fill the zero bin, but the Gaussian is 1 there for any FWHM anyway
|
|
129
|
+
out["fit"] = np.isfinite(out["rho_err"]) & (out["rho_err"] > 0)
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fit_kernel_width(X, x_axis, bin_width=None, min_count=5, normalise=True, p0=None):
|
|
134
|
+
"""Fit the FWHM of the Gaussian kernel to a set of samples drawn from the prior.
|
|
135
|
+
|
|
136
|
+
Parameters
|
|
137
|
+
----------
|
|
138
|
+
X : array_like, shape (N, M)
|
|
139
|
+
The samples from the prior.
|
|
140
|
+
x_axis : array_like, shape (M,)
|
|
141
|
+
The feature positions.
|
|
142
|
+
bin_width : float, optional
|
|
143
|
+
The width of the separation bins, which defaults to the median feature spacing.
|
|
144
|
+
min_count : int, optional
|
|
145
|
+
See `correlation_vs_separation`.
|
|
146
|
+
normalise : bool, optional
|
|
147
|
+
See `correlation_matrix`.
|
|
148
|
+
p0 : float, optional
|
|
149
|
+
A starting guess for the FWHM, which defaults to five bin widths.
|
|
150
|
+
|
|
151
|
+
Returns
|
|
152
|
+
-------
|
|
153
|
+
dict
|
|
154
|
+
The ``fwhm`` and ``fwhm_err``, the correlation matrix ``corr``, the binned curve ``binned`` (from
|
|
155
|
+
`correlation_vs_separation`), and the ``bin_width`` and ``x_axis`` used. This is what `plotting.kernel_fit`
|
|
156
|
+
and `plotting.correlation_matrix` expect.
|
|
157
|
+
"""
|
|
158
|
+
x_axis = np.asarray(x_axis, float)
|
|
159
|
+
if bin_width is None:
|
|
160
|
+
bin_width = float(np.median(np.diff(np.sort(x_axis))))
|
|
161
|
+
if p0 is None:
|
|
162
|
+
p0 = 5 * bin_width
|
|
163
|
+
|
|
164
|
+
corr = correlation_matrix(X, normalise)
|
|
165
|
+
binned = correlation_vs_separation(corr, x_axis, bin_width, min_count)
|
|
166
|
+
|
|
167
|
+
def model(separation, fwhm):
|
|
168
|
+
return gaussian_window(separation, 0, fwhm)
|
|
169
|
+
|
|
170
|
+
use = binned["fit"]
|
|
171
|
+
popt, pcov = curve_fit(model, binned["separation"][use], binned["rho"][use], p0=[p0],
|
|
172
|
+
sigma=binned["rho_err"][use], absolute_sigma=True)
|
|
173
|
+
|
|
174
|
+
return dict(
|
|
175
|
+
fwhm=abs(float(popt[0])),
|
|
176
|
+
fwhm_err=float(np.sqrt(pcov[0, 0])),
|
|
177
|
+
corr=corr,
|
|
178
|
+
binned=binned,
|
|
179
|
+
bin_width=bin_width,
|
|
180
|
+
x_axis=x_axis,
|
|
181
|
+
)
|
perturb/ood.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Step 2: how far can a sample be perturbed before it leaves the distribution? We ask the retrieval itself.
|
|
2
|
+
|
|
3
|
+
For each perturbed sample, we feed f's answer back through the forward model g (outputs -> inputs), and compare this
|
|
4
|
+
best fit with the perturbation that was applied. The fraction fitted,
|
|
5
|
+
|
|
6
|
+
F = dfit . dx / dx . dx (in units of the noise),
|
|
7
|
+
|
|
8
|
+
is the fraction of the perturbation dx that the best fit reproduces. By default the best fit is g(f(X')), but you can
|
|
9
|
+
pass in your own best-fit spectra as best_fit instead (e.g. the maximum a posteriori spectra from a sampler).
|
|
10
|
+
|
|
11
|
+
At small magnitudes, F is the retrieval's linear response to the perturbation. We count a perturbed sample as 'in
|
|
12
|
+
distribution' while F stays within tol of that value, i.e. while the retrieval is still treating the perturbation the
|
|
13
|
+
same way it treats a small one (`in_distribution_curve`). Everything here works from the PerturbResult, so f never
|
|
14
|
+
needs to be called again.
|
|
15
|
+
"""
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fraction_fitted(result, forward=None, batch_size=None, best_fit=None):
|
|
20
|
+
"""Calculate the fraction of each perturbation that the retrieval's best fit reproduces.
|
|
21
|
+
|
|
22
|
+
With dx = X' - x* and dfit = g(f(X')) - g(f(x*)), both in units of sigma, F = dfit . dx / dx . dx. F is 1 when
|
|
23
|
+
the best fit follows the perturbation exactly, and 0 when the retrieval treats it as noise.
|
|
24
|
+
|
|
25
|
+
By default the best fit is g(f(X')), the forward model at f's answer. However, if f returns a posterior mean,
|
|
26
|
+
this is not necessarily the best-fitting spectrum (the maximum a posteriori spectrum from a sampler would be, for
|
|
27
|
+
example). In that case, pass those spectra in as best_fit instead.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
result : PerturbResult
|
|
32
|
+
The perturbation run.
|
|
33
|
+
forward : callable, optional
|
|
34
|
+
The forward model g, which maps outputs (N, P) to inputs (N, M). This is needed unless best_fit is given.
|
|
35
|
+
batch_size : int, optional
|
|
36
|
+
The maximum number of rows per call to g, which also caps how many perturbed samples are held in memory at
|
|
37
|
+
once. By default everything is done in one call.
|
|
38
|
+
best_fit : array_like, shape (n_centres, K, M), optional
|
|
39
|
+
Your own best-fit spectrum for every perturbed sample, used in place of g(f(X')). The xi = 0 column must
|
|
40
|
+
hold the best fit to x* itself, which is the same for every centre, so only the first centre's is used.
|
|
41
|
+
|
|
42
|
+
Returns
|
|
43
|
+
-------
|
|
44
|
+
ndarray, shape (n_centres, K)
|
|
45
|
+
The fraction fitted, which is NaN at xi = 0.
|
|
46
|
+
|
|
47
|
+
Raises
|
|
48
|
+
------
|
|
49
|
+
ValueError
|
|
50
|
+
If neither forward nor best_fit is given, or if best_fit has the wrong shape or no xi = 0 column.
|
|
51
|
+
"""
|
|
52
|
+
n_outputs = result.response.shape[-1]
|
|
53
|
+
params = (result.baseline + result.response).reshape(-1, n_outputs)
|
|
54
|
+
if best_fit is not None:
|
|
55
|
+
best_fit = np.asarray(best_fit, float)
|
|
56
|
+
if best_fit.shape != result.response.shape[:2] + result.x.shape:
|
|
57
|
+
raise ValueError(f"best_fit must have shape (n_centres, K, M) = "
|
|
58
|
+
f"{result.response.shape[:2] + result.x.shape}, not {best_fit.shape}")
|
|
59
|
+
if not (result.xi == 0).any():
|
|
60
|
+
raise ValueError("best_fit needs xi = 0 on the grid, as that column is the best fit to x* itself")
|
|
61
|
+
fit0 = best_fit[0, result.xi == 0][0]
|
|
62
|
+
best_fit = best_fit.reshape(len(params), -1)
|
|
63
|
+
elif forward is None:
|
|
64
|
+
raise ValueError("either forward or best_fit must be given")
|
|
65
|
+
else:
|
|
66
|
+
fit0 = np.asarray(forward(result.baseline[None]))[0]
|
|
67
|
+
|
|
68
|
+
n = len(params)
|
|
69
|
+
batch = batch_size or n
|
|
70
|
+
F = np.empty(n)
|
|
71
|
+
for start in range(0, n, batch):
|
|
72
|
+
rows = np.arange(start, min(start + batch, n))
|
|
73
|
+
dx = (result.samples(rows) - result.x) / result.sigma
|
|
74
|
+
fit = best_fit[rows] if best_fit is not None else np.asarray(forward(params[rows]))
|
|
75
|
+
dfit = (fit - fit0) / result.sigma
|
|
76
|
+
with np.errstate(invalid="ignore", divide="ignore"):
|
|
77
|
+
F[rows] = np.sum(dfit * dx, axis=-1) / np.sum(dx * dx, axis=-1)
|
|
78
|
+
return F.reshape(result.response.shape[:2])
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def in_distribution_curve(result, forward=None, tol=0.1, batch_size=None, best_fit=None):
|
|
82
|
+
"""Find which of the perturbed samples are still in distribution.
|
|
83
|
+
|
|
84
|
+
A perturbed sample is in distribution if its fraction fitted is within tol of the value at the smallest non-zero
|
|
85
|
+
magnitudes (+-xi_min) for the same centre.
|
|
86
|
+
|
|
87
|
+
Parameters
|
|
88
|
+
----------
|
|
89
|
+
result : PerturbResult
|
|
90
|
+
The perturbation run.
|
|
91
|
+
forward : callable, optional
|
|
92
|
+
The forward model g (see `fraction_fitted`). This is needed unless best_fit is given.
|
|
93
|
+
tol : float, optional
|
|
94
|
+
How far the fraction fitted is allowed to drift before a sample counts as out of distribution.
|
|
95
|
+
batch_size : int, optional
|
|
96
|
+
See `fraction_fitted`.
|
|
97
|
+
best_fit : array_like, shape (n_centres, K, M), optional
|
|
98
|
+
Your own best-fit spectra, used in place of forward (see `fraction_fitted`).
|
|
99
|
+
|
|
100
|
+
Returns
|
|
101
|
+
-------
|
|
102
|
+
dict
|
|
103
|
+
- ``xi``: (K,) the magnitudes
|
|
104
|
+
- ``values``: (n_centres, K) the fraction fitted
|
|
105
|
+
- ``inside``: (n_centres, K) bool, whether each sample is in distribution
|
|
106
|
+
- ``fraction_in``: (K,) the fraction of centres inside at each magnitude
|
|
107
|
+
- ``tol``: the tolerance used
|
|
108
|
+
|
|
109
|
+
`max_magnitude` reads the limit on |xi| from this.
|
|
110
|
+
"""
|
|
111
|
+
xi = result.xi
|
|
112
|
+
values = fraction_fitted(result, forward, batch_size, best_fit)
|
|
113
|
+
|
|
114
|
+
nonzero = np.flatnonzero(xi != 0)
|
|
115
|
+
abs_xi = np.abs(xi[nonzero])
|
|
116
|
+
smallest = nonzero[abs_xi == abs_xi.min()]
|
|
117
|
+
ref = np.nanmean(values[:, smallest], axis=1, keepdims=True)
|
|
118
|
+
|
|
119
|
+
inside = (np.abs(values - ref) <= tol) | (xi == 0)
|
|
120
|
+
return dict(xi=xi, values=values, inside=inside, fraction_in=inside.mean(axis=0), tol=tol)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def max_magnitude(curve, level=0.95):
|
|
124
|
+
"""Find the largest |xi| that keeps enough of the perturbed samples in distribution.
|
|
125
|
+
|
|
126
|
+
At every magnitude up to the returned value, on both sides of zero, at least `level` of the perturbed samples
|
|
127
|
+
must be in distribution.
|
|
128
|
+
|
|
129
|
+
Parameters
|
|
130
|
+
----------
|
|
131
|
+
curve : dict
|
|
132
|
+
From `in_distribution_curve`.
|
|
133
|
+
level : float, optional
|
|
134
|
+
The fraction of samples required to be in distribution.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
float
|
|
139
|
+
The limit on |xi|, or 0 if even the smallest magnitude fails.
|
|
140
|
+
"""
|
|
141
|
+
abs_xi = np.abs(curve["xi"])
|
|
142
|
+
ok = curve["fraction_in"] >= level
|
|
143
|
+
if ok.all():
|
|
144
|
+
return float(abs_xi.max())
|
|
145
|
+
|
|
146
|
+
first_failure = abs_xi[~ok].min()
|
|
147
|
+
below = abs_xi[abs_xi < first_failure]
|
|
148
|
+
if below.size == 0:
|
|
149
|
+
return 0.0
|
|
150
|
+
return float(below.max())
|