pelmesha 0.0.1.dev1__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.
pelmesha/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ pelmesha
3
+
4
+ pelmesha is a Python library for loading, preprocessing, processing, storage and extracting peaklists of Mass Spectrometry Imaging (MSI) data.
5
+ """
6
+
7
+ __author__ = 'Andrey Kuzin'
8
+ __credits__ = 'Moscow Institue of Physics and Technology'
pelmesha/align.py ADDED
@@ -0,0 +1,412 @@
1
+ """Main alignment class"""
2
+ import logging
3
+ import time
4
+ import typing as ty
5
+ import warnings
6
+
7
+ import numpy as np
8
+
9
+ from .utilities import check_xy, convert_peak_values_to_index, generate_function, shift, time_loop
10
+
11
+ METHODS = ["pchip", "zero", "slinear", "quadratic", "cubic", "linear"]
12
+ LOGGER = logging.getLogger(__name__)
13
+
14
+
15
+ class Aligner:
16
+ """Main alignment class"""
17
+
18
+ _method, _gaussian_ratio, _gaussian_resolution, _gaussian_width, _n_iterations = None, None, None, None, None
19
+ _corr_sig_l, _corr_sig_x, _corr_sig_y, _reduce_range_factor, _scale_range = None, None, None, None, None
20
+ _search_space, _computed = None, False
21
+
22
+ def __init__(
23
+ self,
24
+ x: np.ndarray,
25
+ array: ty.Optional[np.ndarray],
26
+ peaks: ty.Iterable[float],
27
+ method: str = "quadratic",
28
+ width: float = 0.1,
29
+ ratio: float = 2.5,
30
+ resolution: int = 100,
31
+ iterations: int = 3,
32
+ grid_steps: int = 20,
33
+ shift_range: ty.Optional[ty.Tuple[int, int]] = None,
34
+ weights: ty.Optional[ty.List[float]] = None,
35
+ return_shifts: bool = False,
36
+ align_by_index: bool = False,
37
+ only_shift: bool = True,
38
+ ):
39
+ """Signal calibration and alignment by reference peaks
40
+
41
+ A simplified version of the MSALIGN function found in MATLAB (see references for link)
42
+
43
+ This version of the msalign function accepts most of the parameters that MATLAB's function accepts with the
44
+ following exceptions: GroupValue, ShowPlotValue. A number of other parameters is allowed, although they have
45
+ been renamed to comply with PEP8 conventions. The Python version is 8-60 times slower than the MATLAB
46
+ implementation, which is mostly caused by a really slow instantiation of the
47
+ `scipy.interpolate.PchipInterpolator` interpolator. In order to speed things up, I've also included several
48
+ other interpolation methods which are significantly faster and give similar results.
49
+
50
+ References
51
+ ----------
52
+ Monchamp, P., Andrade-Cetto, L., Zhang, J.Y., and Henson, R. (2007) Signal Processing Methods for Mass
53
+ Spectrometry. In Systems Bioinformatics: An Engineering Case-Based Approach, G. Alterovitz and M.F. Ramoni, eds.
54
+ Artech House Publishers).
55
+ MSALIGN: https://nl.mathworks.com/help/bioinfo/ref/msalign.html
56
+
57
+ Parameters
58
+ ----------
59
+ x : np.ndarray
60
+ 1D array of separation units (N). The number of elements of xvals must equal the number of elements of
61
+ zvals.shape[1]
62
+ array : np.ndarray
63
+ 2D array of intensities that must have common separation units (M x N) where M is the number of vectors
64
+ and N is number of points in the vector
65
+ peaks : list
66
+ list of reference peaks that must be found in the xvals vector
67
+ method : str
68
+ interpolation method. Default: 'cubic'. MATLAB version uses 'pchip' which is significantly slower in Python
69
+ weights: list (optional)
70
+ list of weights associated with the list of peaks. Must be the same length as list of peaks
71
+ width : float (optional)
72
+ width of the gaussian peak in separation units. Default: 10
73
+ ratio : float (optional)
74
+ scaling value that determines the size of the window around every alignment peak. The synthetic signal is
75
+ compared to the input signal within these regions. Default: 2.5
76
+ resolution : int (optional)
77
+ Default: 100
78
+ iterations : int (optional)
79
+ number of iterations. Increasing this value will (slightly) slow down the function but will improve
80
+ performance. Default: 5
81
+ grid_steps : int (optional)
82
+ number of steps to be used in the grid search. Default: 20
83
+ shift_range : list / numpy array (optional)
84
+ maximum allowed shifts. Default: [-100, 100]
85
+ only_shift : bool
86
+ determines if signal should be shifted (True) or rescaled (False). Default: True
87
+ return_shifts : bool
88
+ decide whether shift parameter `shift_opt` should also be returned. Default: False
89
+ align_by_index : bool
90
+ decide whether alignment should be done based on index rather than `xvals` array. Default: False
91
+ """
92
+ self.x = np.asarray(x)
93
+ if array.ndim==1:
94
+ self.array = [array]
95
+ self.n_signals=1
96
+ else:
97
+ if array is not None:
98
+ self.array = check_xy(self.x, np.asarray(array))
99
+ else:
100
+ self.array = np.empty((0, len(self.x)))
101
+
102
+ self.n_signals = self.array.shape[0]
103
+ self.array_aligned = np.zeros_like(self.array)
104
+ self.peaks = list(peaks)
105
+
106
+ # set attributes
107
+ self.n_peaks = len(self.peaks)
108
+
109
+ # accessible attributes
110
+ self.scale_opt = np.ones((self.n_signals, 1), dtype=np.float32)
111
+ self.shift_opt = np.zeros((self.n_signals, 1), dtype=np.float32)
112
+ self.shift_values = np.zeros_like(self.shift_opt)
113
+
114
+ self.method = method
115
+ self.gaussian_ratio = ratio
116
+ self.gaussian_resolution = resolution
117
+ self.gaussian_width = width
118
+ self.n_iterations = iterations
119
+ self.grid_steps = grid_steps
120
+ if shift_range is None:
121
+ shift_range = [-100, 100]
122
+ self.shift_range = shift_range
123
+ if weights is None:
124
+ weights = np.ones(self.n_peaks)
125
+ self.weights = weights
126
+
127
+ # return shift vector
128
+ self._return_shifts = return_shifts
129
+ # If the number of points is equal to 1, then only shift
130
+ if self.n_peaks == 1:
131
+ only_shift = True
132
+ if only_shift and not align_by_index:
133
+ align_by_index = True
134
+ LOGGER.warning("Only computing shifts - changed `align_by_index` to `True`.")
135
+
136
+ # align signals by index rather than peak value
137
+ self._align_by_index = align_by_index
138
+ # align by index - rather than aligning to arbitrary non-integer values in the xvals, you can instead
139
+ # use index of those values
140
+ if self._align_by_index:
141
+ self.peaks = convert_peak_values_to_index(self.x, self.peaks)
142
+ self.x = np.arange(self.x.shape[0])
143
+ LOGGER.debug(f"Aligning by index - peak positions: {self.peaks}")
144
+ self._only_shift = only_shift
145
+
146
+ self._initialize()
147
+
148
+ @property
149
+ def method(self):
150
+ """Interpolation method."""
151
+ return self._method
152
+
153
+ @method.setter
154
+ def method(self, value: str):
155
+ if value not in METHODS:
156
+ raise ValueError(f"Method `{value}` not found in the method options: {METHODS}")
157
+ self._method = value
158
+
159
+ @property
160
+ def gaussian_ratio(self):
161
+ """Gaussian ratio."""
162
+ return self._gaussian_ratio
163
+
164
+ @gaussian_ratio.setter
165
+ def gaussian_ratio(self, value: float):
166
+ if value <= 0:
167
+ raise ValueError("Value of 'ratio' must be above 0!")
168
+ self._gaussian_ratio = value
169
+
170
+ @property
171
+ def gaussian_resolution(self):
172
+ """Gaussian resolution of every Gaussian pulse (number of points)."""
173
+ return self._gaussian_resolution
174
+
175
+ @gaussian_resolution.setter
176
+ def gaussian_resolution(self, value: float):
177
+ if value <= 0:
178
+ raise ValueError("Value of 'resolution' must be above 0!")
179
+ self._gaussian_resolution = value
180
+
181
+ @property
182
+ def gaussian_width(self):
183
+ """Width of the Gaussian pulse in std dev of the Gaussian pulses (in X)."""
184
+ return self._gaussian_width
185
+
186
+ @gaussian_width.setter
187
+ def gaussian_width(self, value: float):
188
+ self._gaussian_width = value
189
+
190
+ @property
191
+ def n_iterations(self):
192
+ """Total number of iterations - increase to improve accuracy."""
193
+ return self._n_iterations
194
+
195
+ @n_iterations.setter
196
+ def n_iterations(self, value: int):
197
+ if value < 1 or not isinstance(value, int):
198
+ raise ValueError("Value of 'iterations' must be above 0 and be an integer!")
199
+ self._n_iterations = value
200
+
201
+ @property
202
+ def grid_steps(self):
203
+ """Total number of iterations - increase to improve accuracy."""
204
+ return self._grid_steps
205
+
206
+ @grid_steps.setter
207
+ def grid_steps(self, value: int):
208
+ if value < 1 or not isinstance(value, int):
209
+ raise ValueError("Value of 'iterations' must be above 0 and be an integer!")
210
+ self._grid_steps = value
211
+
212
+ @property
213
+ def shift_range(self):
214
+ """Total number of iterations - increase to improve accuracy."""
215
+ return self._shift_range
216
+
217
+ @shift_range.setter
218
+ def shift_range(self, value: ty.Tuple[float, float]):
219
+ if len(value) != 2:
220
+ raise ValueError(
221
+ "Number of 'shift_values' is not correct. Shift range accepts" " numpy array with two values."
222
+ )
223
+ if np.diff(value) == 0:
224
+ raise ValueError("Values of 'shift_values' must not be the same!")
225
+ self._shift_range = np.asarray(value)
226
+
227
+ @property
228
+ def weights(self):
229
+ """Total number of iterations - increase to improve accuracy."""
230
+ return self._weights
231
+
232
+ @weights.setter
233
+ def weights(self, value: ty.Optional[ty.Iterable[float]]):
234
+ if value is None:
235
+ value = np.ones(self.n_peaks)
236
+ if not isinstance(value, ty.Iterable):
237
+ raise ValueError("Weights must be provided as an iterable.")
238
+ if len(value) != self.n_peaks:
239
+ raise ValueError("Number of weights does not match the number of peaks.")
240
+ self._weights = np.asarray(value)
241
+
242
+ def _initialize(self):
243
+ """Prepare dataset for alignment"""
244
+ # check that values for gaussian_width are valid
245
+ gaussian_widths = np.zeros((self.n_peaks, 1))
246
+ for i in range(self.n_peaks):
247
+ gaussian_widths[i] = self.gaussian_width
248
+
249
+ # set the synthetic target signal
250
+ corr_sig_x = np.zeros((self.gaussian_resolution + 1, self.n_peaks))
251
+ corr_sig_y = np.zeros((self.gaussian_resolution + 1, self.n_peaks))
252
+
253
+ gaussian_resolution_range = np.arange(0, self.gaussian_resolution + 1)
254
+ for i in range(self.n_peaks):
255
+ left_l = self.peaks[i] - self.gaussian_ratio * gaussian_widths[i] # noqa
256
+ right_l = self.peaks[i] + self.gaussian_ratio * gaussian_widths[i] # noqa
257
+ corr_sig_x[:, i] = left_l + (gaussian_resolution_range * (right_l - left_l) / self.gaussian_resolution)
258
+ corr_sig_y[:, i] = self.weights[i] * np.exp(
259
+ -np.square((corr_sig_x[:, i] - self.peaks[i]) / gaussian_widths[i]) # noqa
260
+ )
261
+
262
+ self._corr_sig_l = (self.gaussian_resolution + 1) * self.n_peaks
263
+ self._corr_sig_x = corr_sig_x.flatten("F")
264
+ self._corr_sig_y = corr_sig_y.flatten("F")
265
+
266
+ # set reduce_range_factor to take 5 points of the previous ranges or half of
267
+ # the previous range if grid_steps < 10
268
+ self._reduce_range_factor = min(0.5, 5 / self.grid_steps)
269
+
270
+ # set scl such that the maximum peak can shift no more than the limits imposed by shift when scaling
271
+ self._scale_range = 1 + self.shift_range / max(self.peaks)
272
+
273
+ if self._only_shift:
274
+ self._scale_range = np.array([1, 1])
275
+
276
+ # create the mesh-grid only once
277
+ mesh_a, mesh_b = np.meshgrid(
278
+ np.divide(np.arange(0, self.grid_steps), self.grid_steps - 1),
279
+ np.divide(np.arange(0, self.grid_steps), self.grid_steps - 1),
280
+ )
281
+ self._search_space = np.tile(
282
+ np.vstack([mesh_a.flatten(order="F"), mesh_b.flatten(order="F")]).T, [1, self._n_iterations]
283
+ )
284
+
285
+ def run(self, n_iterations: int = None):
286
+ """Execute the alignment procedure for each signal in the 2D array and collate the shift/scale vectors"""
287
+ self.n_iterations = n_iterations or self.n_iterations
288
+ # iterate for every signal
289
+ t_start = time.time()
290
+
291
+ # main loop: searches for the optimum values of Scale and Shift factors by search over a multi-resolution
292
+ # grid, getting better at each iteration. Increasing the number of iterations improves the shift and scale
293
+ # parameters
294
+ for n_signal, y in enumerate(self.array):
295
+ self.shift_opt[n_signal], self.scale_opt[n_signal] = self.compute(y)
296
+ LOGGER.debug(f"Processed {self.n_signals} signals " + time_loop(t_start, self.n_signals + 1, self.n_signals))
297
+ self._computed = True
298
+
299
+ def compute(self, y: np.ndarray) -> ty.Tuple[float, float]:
300
+ """Compute correction factors.
301
+
302
+ This function does not set value in any of the class attributes so can be used in a iterator where values
303
+ are computed lazily.
304
+ """
305
+ _scale_range = np.array([-0.5, 0.5])
306
+ scale_opt, shift_opt = 0.0, 1.0
307
+
308
+ # set to back to the user input arguments (or default)
309
+ _shift = self.shift_range.copy()
310
+ _scale = self._scale_range.copy()
311
+
312
+ # generate interpolation function for each signal - instantiation of the interpolator can be quite slow,
313
+ # so you can slightly increase the number of iterations without significant slowdown of the process
314
+ func = generate_function(self.method, self.x, y)
315
+
316
+ # iterate to estimate the shift and scale - at each iteration, the grid search is readjusted and the
317
+ # shift/scale values are optimized further
318
+ for n_iter in range(self.n_iterations):
319
+ # scale and shift search space
320
+ scale_grid = _scale[0] + self._search_space[:, (n_iter * 2) - 2] * np.diff(_scale)
321
+ shift_grid = _shift[0] + self._search_space[:, (n_iter * 2) + 1] * np.diff(_shift)
322
+ temp = (
323
+ np.reshape(scale_grid, (scale_grid.shape[0], 1)) * np.reshape(self._corr_sig_x, (1, self._corr_sig_l))
324
+ + np.tile(shift_grid, [self._corr_sig_l, 1]).T
325
+ )
326
+ # interpolate at each iteration. Need to remove NaNs which can be introduced by certain (e.g.
327
+ # PCHIP) interpolator
328
+ temp = np.nan_to_num(func(temp.flatten("C")).reshape(temp.shape))
329
+
330
+ # determine the best position
331
+ i_max = np.dot(temp, self._corr_sig_y).argmax()
332
+
333
+ # save optimum value
334
+ scale_opt = scale_grid[i_max]
335
+ shift_opt = shift_grid[i_max]
336
+
337
+ # readjust grid for next iteration_reduce_range_factor
338
+ _scale = scale_opt + _scale_range * np.diff(_scale) * self._reduce_range_factor
339
+ _shift = shift_opt + _scale_range * np.diff(_shift) * self._reduce_range_factor
340
+ return shift_opt, scale_opt
341
+
342
+ def apply(self, return_shifts: bool = None):
343
+ """Align the signals against the computed values"""
344
+ if not self._computed:
345
+ warnings.warn("Aligning data without computing optimal alignment parameters", UserWarning)
346
+ self._return_shifts = return_shifts if return_shifts is not None else self._return_shifts
347
+
348
+ if self._only_shift:
349
+ self.shift()
350
+ else:
351
+ self.align()
352
+
353
+ # return aligned data and shifts
354
+ if self._return_shifts:
355
+ return self.array_aligned, self.shift_values
356
+ # only return data
357
+ return self.array_aligned
358
+
359
+ def align(self, shift_opt=None, scale_opt=None):
360
+ """Realign array based on the optimized shift and scale parameters
361
+
362
+ Parameters
363
+ ----------
364
+ shift_opt: Optional[np.ndarray]
365
+ vector containing values by which to shift the array
366
+ scale_opt : Optional[np.ndarray]
367
+ vector containing values by which to rescale the array
368
+ """
369
+ t_start = time.time()
370
+ if shift_opt is None:
371
+ shift_opt = self.shift_opt
372
+ if scale_opt is None:
373
+ scale_opt = self.scale_opt
374
+
375
+ # realign based on provided values
376
+ for iteration, y in enumerate(self.array):
377
+ # interpolate back to the original domain
378
+ self.array_aligned[iteration] = self._apply(y, shift_opt[iteration], scale_opt[iteration])
379
+ self.shift_values = self.shift_opt
380
+
381
+ LOGGER.debug(f"Re-aligned {self.n_signals} signals " + time_loop(t_start, self.n_signals + 1, self.n_signals))
382
+
383
+ def _apply(self, y: np.ndarray, shift_value: float, scale_value: float):
384
+ """Apply alignment correction to array `y`."""
385
+ func = generate_function(self.method, (self.x - shift_value) / scale_value, y)
386
+ return np.nan_to_num(func(self.x))
387
+
388
+ def shift(self, shift_opt=None):
389
+ """Quickly shift array based on the optimized shift parameters.
390
+
391
+ This method does not interpolate but rather moves the data left and right without applying any scaling.
392
+
393
+ Parameters
394
+ ----------
395
+ shift_opt: Optional[np.ndarray]
396
+ vector containing values by which to shift the array
397
+ """
398
+ t_start = time.time()
399
+ if shift_opt is None:
400
+ shift_opt = np.round(self.shift_opt).astype(np.int32)
401
+
402
+ # quickly shift based on provided values
403
+ for iteration, y in enumerate(self.array):
404
+ self.array_aligned[iteration] = self._shift(y, shift_opt[iteration])
405
+ self.shift_values = shift_opt
406
+
407
+ LOGGER.debug(f"Re-aligned {self.n_signals} signals " + time_loop(t_start, self.n_signals + 1, self.n_signals))
408
+
409
+ @staticmethod
410
+ def _shift(y: np.ndarray, shift_value: float):
411
+ """Apply shift correction to array `y`."""
412
+ return shift(y, -int(shift_value),fill_value=y[-shift_value])