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