pgalign 1.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.
- pgalign/AnnDataMALDI.py +476 -0
- pgalign/PeakCalling.py +282 -0
- pgalign/__init__.py +87 -0
- pgalign/cli.py +84 -0
- pgalign/data/mouse_pancreas_toy.npz +0 -0
- pgalign/datasets.py +39 -0
- pgalign/pipeline.py +153 -0
- pgalign/readers.py +130 -0
- pgalign/util.py +135 -0
- pgalign-1.1.0.dist-info/METADATA +318 -0
- pgalign-1.1.0.dist-info/RECORD +15 -0
- pgalign-1.1.0.dist-info/WHEEL +5 -0
- pgalign-1.1.0.dist-info/entry_points.txt +2 -0
- pgalign-1.1.0.dist-info/licenses/LICENSE +21 -0
- pgalign-1.1.0.dist-info/top_level.txt +1 -0
pgalign/PeakCalling.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Peak calling and peak grouping (PGAlign Steps 1 and 2).
|
|
2
|
+
|
|
3
|
+
Implements the "Peak calling" and "Peak grouping: a greedy algorithm"
|
|
4
|
+
subsections of the Methods in Deng, Zhang & Zhang (2026).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import warnings
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from .util import (
|
|
12
|
+
in_range_lookup,
|
|
13
|
+
comp_clusters,
|
|
14
|
+
get_unk_comp_clusters,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PeakCalling(object):
|
|
19
|
+
"""Two-spectrum peak calling and joint peak grouping.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
AnnDataUnk : anndata.AnnData
|
|
24
|
+
The "unknown" spectrum to be aligned.
|
|
25
|
+
AnnDataRef : anndata.AnnData
|
|
26
|
+
The reference spectrum.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, AnnDataUnk, AnnDataRef):
|
|
30
|
+
self.AnnDataUnk = AnnDataUnk
|
|
31
|
+
self.AnnDataRef = AnnDataRef
|
|
32
|
+
self.mz_valueUnk = AnnDataUnk.var
|
|
33
|
+
self.mz_valueRef = AnnDataRef.var
|
|
34
|
+
self.meanspectrumUnk = np.mean(AnnDataUnk.X, axis=0)
|
|
35
|
+
self.meanspectrumRef = np.mean(AnnDataRef.X, axis=0)
|
|
36
|
+
|
|
37
|
+
def get_peaks(self, mz, intensity, threshold):
|
|
38
|
+
deriv = np.divide(np.diff(intensity), np.diff(mz))
|
|
39
|
+
cutpoint = np.quantile(np.abs(deriv), threshold)
|
|
40
|
+
increase = deriv > cutpoint
|
|
41
|
+
increase = np.insert(increase, 0, False)
|
|
42
|
+
decrease = deriv < - cutpoint
|
|
43
|
+
decrease = np.append(decrease, False)
|
|
44
|
+
peak = np.logical_or(increase, decrease)
|
|
45
|
+
### Exclude the single peak
|
|
46
|
+
e_left = np.empty_like(peak)
|
|
47
|
+
e_right = np.empty_like(peak)
|
|
48
|
+
e_left[-1] = False
|
|
49
|
+
e_left[:-1] = peak[1:]
|
|
50
|
+
e_right[0] = False
|
|
51
|
+
e_right[1:] = peak[:-1]
|
|
52
|
+
simplet = np.logical_and(peak, np.logical_not(np.logical_or(e_left, e_right)))
|
|
53
|
+
d_left = np.empty_like(simplet)
|
|
54
|
+
d_right = np.empty_like(simplet)
|
|
55
|
+
d_left[-1] = False
|
|
56
|
+
d_left[:-1] = simplet[1:]
|
|
57
|
+
d_right[0] = False
|
|
58
|
+
d_right[1:] = simplet[:-1]
|
|
59
|
+
peak = np.logical_or(peak, np.logical_or(d_left, d_right))
|
|
60
|
+
return peak
|
|
61
|
+
|
|
62
|
+
def point_clusters(self, points, percentile=0.75):
|
|
63
|
+
points_sorted = np.sort(points)
|
|
64
|
+
dist_points = np.diff(points_sorted)
|
|
65
|
+
dist_points_sort = np.quantile(dist_points, percentile)
|
|
66
|
+
#
|
|
67
|
+
clusters = []
|
|
68
|
+
eps = dist_points_sort
|
|
69
|
+
curr_point = points_sorted[0]
|
|
70
|
+
curr_cluster = [curr_point]
|
|
71
|
+
for point in list(points_sorted[1:]):
|
|
72
|
+
if point <= curr_point + eps:
|
|
73
|
+
curr_cluster.append(point)
|
|
74
|
+
else:
|
|
75
|
+
clusters.append(curr_cluster)
|
|
76
|
+
curr_cluster = [point]
|
|
77
|
+
curr_point = point
|
|
78
|
+
clusters.append(curr_cluster)
|
|
79
|
+
return clusters
|
|
80
|
+
|
|
81
|
+
def peak_calling(self, threshold=0.9):
|
|
82
|
+
"""PGAlign Step 1: identify peaks in the unknown and reference mean spectra.
|
|
83
|
+
|
|
84
|
+
Default ``threshold=0.9`` corresponds to the 90% quantile (alpha in the paper).
|
|
85
|
+
"""
|
|
86
|
+
self.peakUnk = self.get_peaks(
|
|
87
|
+
mz=np.array(self.mz_valueUnk["m/z"]),
|
|
88
|
+
intensity=self.meanspectrumUnk,
|
|
89
|
+
threshold=threshold,
|
|
90
|
+
)
|
|
91
|
+
self.peakRef = self.get_peaks(
|
|
92
|
+
mz=np.array(self.mz_valueRef["m/z"]),
|
|
93
|
+
intensity=self.meanspectrumRef,
|
|
94
|
+
threshold=threshold,
|
|
95
|
+
)
|
|
96
|
+
self.meanspectrumUnkpeak = self.meanspectrumUnk[self.peakUnk]
|
|
97
|
+
self.meanspectrumRefpeak = self.meanspectrumRef[self.peakRef]
|
|
98
|
+
|
|
99
|
+
def callpeak(self, threshold=0.9):
|
|
100
|
+
warnings.warn(
|
|
101
|
+
"callpeak() is deprecated; use peak_calling() to match the manuscript terminology.",
|
|
102
|
+
DeprecationWarning,
|
|
103
|
+
stacklevel=2,
|
|
104
|
+
)
|
|
105
|
+
return self.peak_calling(threshold=threshold)
|
|
106
|
+
|
|
107
|
+
def peak_grouping(self, percentile=0.9):
|
|
108
|
+
"""PGAlign Step 2: partition adjacent peaks into joint peak groups.
|
|
109
|
+
|
|
110
|
+
Default ``percentile=0.9`` uses the top 10% of pairwise m/z distances
|
|
111
|
+
as cut points (peak grouping parameter in the paper).
|
|
112
|
+
"""
|
|
113
|
+
self.clusterUnk = self.point_clusters(
|
|
114
|
+
np.array(self.mz_valueUnk["m/z"][list(self.peakUnk)]),
|
|
115
|
+
percentile=percentile,
|
|
116
|
+
)
|
|
117
|
+
self.clusterRef = self.point_clusters(
|
|
118
|
+
np.array(self.mz_valueRef["m/z"][list(self.peakRef)]),
|
|
119
|
+
percentile=percentile,
|
|
120
|
+
)
|
|
121
|
+
self.mzcombine = np.concatenate((
|
|
122
|
+
np.array(self.mz_valueUnk["m/z"][list(self.peakUnk)]),
|
|
123
|
+
np.array(self.mz_valueRef["m/z"][list(self.peakRef)]),
|
|
124
|
+
))
|
|
125
|
+
self.jointcluster = self.point_clusters(np.sort(self.mzcombine), percentile)
|
|
126
|
+
|
|
127
|
+
def grouppeaks(self, percentile=0.9):
|
|
128
|
+
warnings.warn(
|
|
129
|
+
"grouppeaks() is deprecated; use peak_grouping() to match the manuscript terminology.",
|
|
130
|
+
DeprecationWarning,
|
|
131
|
+
stacklevel=2,
|
|
132
|
+
)
|
|
133
|
+
return self.peak_grouping(percentile=percentile)
|
|
134
|
+
|
|
135
|
+
def filter_clusters(self, mzboundary):
|
|
136
|
+
self.jointcluster = [clusteri for clusteri in self.jointcluster if clusteri[-1] < mzboundary]
|
|
137
|
+
|
|
138
|
+
def importpeak(self, peakUnk, peakRef):
|
|
139
|
+
self.peakUnk = np.zeros(self.mz_valueUnk.shape[0], dtype=bool)
|
|
140
|
+
self.peakRef = self.peakUnk
|
|
141
|
+
for i in range(peakUnk.shape[0]):
|
|
142
|
+
self.peakUnk[peakUnk[i]] = True
|
|
143
|
+
for j in range(peakRef.shape[0]):
|
|
144
|
+
self.peakRef[peakRef[j]] = True
|
|
145
|
+
self.meanspectrumUnkpeak = self.meanspectrumUnk[self.peakUnk]
|
|
146
|
+
self.meanspectrumRefpeak = self.meanspectrumRef[self.peakRef]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PeakCalling_single(PeakCalling):
|
|
150
|
+
def __init__(self, AnnData):
|
|
151
|
+
super().__init__(AnnData, AnnData)
|
|
152
|
+
|
|
153
|
+
def peak_calling(self, threshold=0.9):
|
|
154
|
+
"""Single-spectrum variant of Step 1 (reference only)."""
|
|
155
|
+
self.peakRef = self.get_peaks(
|
|
156
|
+
mz=np.array(self.mz_valueRef["m/z"]),
|
|
157
|
+
intensity=self.meanspectrumRef,
|
|
158
|
+
threshold=threshold,
|
|
159
|
+
)
|
|
160
|
+
self.meanspectrumRefpeak = self.meanspectrumRef[self.peakRef]
|
|
161
|
+
|
|
162
|
+
def callpeak(self, threshold=0.9):
|
|
163
|
+
warnings.warn(
|
|
164
|
+
"callpeak() is deprecated; use peak_calling() to match the manuscript terminology.",
|
|
165
|
+
DeprecationWarning,
|
|
166
|
+
stacklevel=2,
|
|
167
|
+
)
|
|
168
|
+
return self.peak_calling(threshold=threshold)
|
|
169
|
+
|
|
170
|
+
def peak_grouping(self, percentile=0.9):
|
|
171
|
+
"""Single-spectrum variant of Step 2 (reference only)."""
|
|
172
|
+
self.clusterRef = self.point_clusters(
|
|
173
|
+
np.array(self.mz_valueRef["m/z"][list(self.peakRef)]),
|
|
174
|
+
percentile,
|
|
175
|
+
)
|
|
176
|
+
self.jointcluster = self.clusterRef
|
|
177
|
+
|
|
178
|
+
def grouppeaks(self, percentile=0.9):
|
|
179
|
+
warnings.warn(
|
|
180
|
+
"grouppeaks() is deprecated; use peak_grouping() to match the manuscript terminology.",
|
|
181
|
+
DeprecationWarning,
|
|
182
|
+
stacklevel=2,
|
|
183
|
+
)
|
|
184
|
+
return self.peak_grouping(percentile=percentile)
|
|
185
|
+
|
|
186
|
+
def filter_clusters(self, mzboundary):
|
|
187
|
+
self.jointcluster = [clusteri for clusteri in self.jointcluster if clusteri[-1] < mzboundary]
|
|
188
|
+
|
|
189
|
+
def importpeak(self, peakUnk, peakRef):
|
|
190
|
+
self.peakRef = self.peakUnk
|
|
191
|
+
for j in range(peakRef.shape[0]):
|
|
192
|
+
self.peakRef[peakRef[j]] = True
|
|
193
|
+
self.meanspectrumRefpeak = self.meanspectrumRef[self.peakRef]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class PeakCallingmv(object):
|
|
197
|
+
"""Moving-average variant of PeakCalling."""
|
|
198
|
+
|
|
199
|
+
def __init__(self, AnnDataUnk, AnnDataRef):
|
|
200
|
+
self.AnnDataUnk = AnnDataUnk
|
|
201
|
+
self.AnnDataRef = AnnDataRef
|
|
202
|
+
self.mz_valueUnk = AnnDataUnk.var
|
|
203
|
+
self.mz_valueRef = AnnDataRef.var
|
|
204
|
+
self.meanspectrumUnk = np.mean(AnnDataUnk.X, axis=0)
|
|
205
|
+
self.meanspectrumRef = np.mean(AnnDataRef.X, axis=0)
|
|
206
|
+
self.meanspectrumUnkmv = np.convolve(self.meanspectrumUnk, np.ones(3) / 3, mode='valid')
|
|
207
|
+
self.meanspectrumRefmv = np.convolve(self.meanspectrumRef, np.ones(3) / 3, mode='valid')
|
|
208
|
+
self.mz_valueUnk_mv = self.mz_valueUnk[1:(self.mz_valueUnk.shape[0] - 1)]
|
|
209
|
+
self.mz_valueRef_mv = self.mz_valueRef[1:(self.mz_valueRef.shape[0] - 1)]
|
|
210
|
+
|
|
211
|
+
def get_peaks(self, mz, intensity, threshold):
|
|
212
|
+
deriv = np.divide(np.diff(intensity), np.diff(mz))
|
|
213
|
+
cutpoint = np.quantile(np.abs(deriv), threshold)
|
|
214
|
+
increase = deriv > cutpoint
|
|
215
|
+
increase = np.insert(increase, 0, False)
|
|
216
|
+
decrease = deriv < - cutpoint
|
|
217
|
+
decrease = np.append(decrease, False)
|
|
218
|
+
peak = np.logical_or(increase, decrease)
|
|
219
|
+
return peak
|
|
220
|
+
|
|
221
|
+
def point_clusters(self, points, percentile=0.75):
|
|
222
|
+
points_sorted = np.sort(points)
|
|
223
|
+
dist_points = np.diff(points_sorted)
|
|
224
|
+
dist_points_sort = np.quantile(dist_points, percentile)
|
|
225
|
+
#
|
|
226
|
+
clusters = []
|
|
227
|
+
eps = dist_points_sort
|
|
228
|
+
curr_point = points_sorted[0]
|
|
229
|
+
curr_cluster = [curr_point]
|
|
230
|
+
for point in list(points_sorted[1:]):
|
|
231
|
+
if point <= curr_point + eps:
|
|
232
|
+
curr_cluster.append(point)
|
|
233
|
+
else:
|
|
234
|
+
clusters.append(curr_cluster)
|
|
235
|
+
curr_cluster = [point]
|
|
236
|
+
curr_point = point
|
|
237
|
+
clusters.append(curr_cluster)
|
|
238
|
+
return clusters
|
|
239
|
+
|
|
240
|
+
def peak_calling(self, threshold=0.9):
|
|
241
|
+
"""Moving-average variant of Step 1."""
|
|
242
|
+
self.peakUnk = self.get_peaks(
|
|
243
|
+
mz=np.array(self.mz_valueUnk_mv["m/z"]),
|
|
244
|
+
intensity=self.meanspectrumUnkmv,
|
|
245
|
+
threshold=threshold,
|
|
246
|
+
)
|
|
247
|
+
self.peakRef = self.get_peaks(
|
|
248
|
+
mz=np.array(self.mz_valueRef_mv["m/z"]),
|
|
249
|
+
intensity=self.meanspectrumRefmv,
|
|
250
|
+
threshold=threshold,
|
|
251
|
+
)
|
|
252
|
+
self.meanspectrumUnkpeak = self.meanspectrumUnkmv[self.peakUnk]
|
|
253
|
+
self.meanspectrumRefpeak = self.meanspectrumRefmv[self.peakRef]
|
|
254
|
+
|
|
255
|
+
def callpeak(self, threshold=0.9):
|
|
256
|
+
warnings.warn(
|
|
257
|
+
"callpeak() is deprecated; use peak_calling() to match the manuscript terminology.",
|
|
258
|
+
DeprecationWarning,
|
|
259
|
+
stacklevel=2,
|
|
260
|
+
)
|
|
261
|
+
return self.peak_calling(threshold=threshold)
|
|
262
|
+
|
|
263
|
+
def peak_grouping(self, percentile=0.9):
|
|
264
|
+
"""Moving-average variant of Step 2."""
|
|
265
|
+
self.clusterUnk = self.point_clusters(np.array(self.mz_valueUnk_mv["m/z"][list(self.peakUnk)]))
|
|
266
|
+
self.clusterRef = self.point_clusters(np.array(self.mz_valueRef_mv["m/z"][list(self.peakRef)]))
|
|
267
|
+
self.mzcombine = np.concatenate((
|
|
268
|
+
np.array(self.mz_valueUnk_mv["m/z"][list(self.peakUnk)]),
|
|
269
|
+
np.array(self.mz_valueRef_mv["m/z"][list(self.peakRef)]),
|
|
270
|
+
))
|
|
271
|
+
self.jointcluster = self.point_clusters(np.sort(self.mzcombine), percentile)
|
|
272
|
+
|
|
273
|
+
def grouppeaks(self, percentile=0.9):
|
|
274
|
+
warnings.warn(
|
|
275
|
+
"grouppeaks() is deprecated; use peak_grouping() to match the manuscript terminology.",
|
|
276
|
+
DeprecationWarning,
|
|
277
|
+
stacklevel=2,
|
|
278
|
+
)
|
|
279
|
+
return self.peak_grouping(percentile=percentile)
|
|
280
|
+
|
|
281
|
+
def filter_clusters(self, mzboundary):
|
|
282
|
+
self.jointcluster = [clusteri for clusteri in self.jointcluster if clusteri[-1] < mzboundary]
|
pgalign/__init__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""PGAlign: peak group alignment of mass spectrometry data.
|
|
2
|
+
|
|
3
|
+
Implementation accompanying the Deng, Zhang & Zhang (2026) manuscript (under review).
|
|
4
|
+
The four pipeline steps in the paper map to these public objects:
|
|
5
|
+
|
|
6
|
+
Step 1 Peak Calling -> PeakCalling.peak_calling()
|
|
7
|
+
Step 2 Peak Grouping -> PeakCalling.peak_grouping()
|
|
8
|
+
Step 3 Peak Group Pairing -> AnnDataMALDI.peak_group_pairing()
|
|
9
|
+
Step 4 Fine Alignment Assessment -> AnnDataMALDI.fine_alignment_assessment()
|
|
10
|
+
|
|
11
|
+
align() runs all four with the manuscript's defaults and returns the aligned dataset.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
__version__ = "1.1.0"
|
|
15
|
+
|
|
16
|
+
from .util import (
|
|
17
|
+
prefilter_cells,
|
|
18
|
+
prefilter_genes,
|
|
19
|
+
prefilter_specialgenes,
|
|
20
|
+
save_clusterresults,
|
|
21
|
+
rotate,
|
|
22
|
+
group_range,
|
|
23
|
+
in_range_lookup,
|
|
24
|
+
comp_clusters,
|
|
25
|
+
find_nearest,
|
|
26
|
+
get_unk_comp_clusters,
|
|
27
|
+
spectrum_save,
|
|
28
|
+
GKernal,
|
|
29
|
+
gridding,
|
|
30
|
+
)
|
|
31
|
+
from .AnnDataMALDI import (
|
|
32
|
+
AnnDataMALDI,
|
|
33
|
+
MALDI_SIM,
|
|
34
|
+
PGmzalign,
|
|
35
|
+
)
|
|
36
|
+
from .PeakCalling import (
|
|
37
|
+
PeakCalling,
|
|
38
|
+
PeakCalling_single,
|
|
39
|
+
PeakCallingmv,
|
|
40
|
+
)
|
|
41
|
+
from .pipeline import (
|
|
42
|
+
AlignmentResult,
|
|
43
|
+
align,
|
|
44
|
+
)
|
|
45
|
+
from .readers import (
|
|
46
|
+
read_h5ad,
|
|
47
|
+
read_imzml,
|
|
48
|
+
read_scils_csv,
|
|
49
|
+
write_h5ad,
|
|
50
|
+
)
|
|
51
|
+
from . import datasets
|
|
52
|
+
|
|
53
|
+
__all__ = [
|
|
54
|
+
"__version__",
|
|
55
|
+
# One-call pipeline
|
|
56
|
+
"align",
|
|
57
|
+
"AlignmentResult",
|
|
58
|
+
# Readers and .h5ad I/O
|
|
59
|
+
"read_imzml",
|
|
60
|
+
"read_scils_csv",
|
|
61
|
+
"read_h5ad",
|
|
62
|
+
"write_h5ad",
|
|
63
|
+
# AnnDataMALDI core
|
|
64
|
+
"AnnDataMALDI",
|
|
65
|
+
"MALDI_SIM",
|
|
66
|
+
"PGmzalign",
|
|
67
|
+
# Peak calling
|
|
68
|
+
"PeakCalling",
|
|
69
|
+
"PeakCalling_single",
|
|
70
|
+
"PeakCallingmv",
|
|
71
|
+
# Toy data
|
|
72
|
+
"datasets",
|
|
73
|
+
# Utilities
|
|
74
|
+
"prefilter_cells",
|
|
75
|
+
"prefilter_genes",
|
|
76
|
+
"prefilter_specialgenes",
|
|
77
|
+
"save_clusterresults",
|
|
78
|
+
"rotate",
|
|
79
|
+
"group_range",
|
|
80
|
+
"in_range_lookup",
|
|
81
|
+
"comp_clusters",
|
|
82
|
+
"find_nearest",
|
|
83
|
+
"get_unk_comp_clusters",
|
|
84
|
+
"spectrum_save",
|
|
85
|
+
"GKernal",
|
|
86
|
+
"gridding",
|
|
87
|
+
]
|
pgalign/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Command line interface: ``pgalign align UNKNOWN REFERENCE -o ALIGNED.h5ad``."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from .pipeline import align
|
|
9
|
+
from .readers import read_h5ad, read_imzml, read_scils_csv, write_h5ad
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read(path, spots, increment, mz_range):
|
|
13
|
+
suffix = Path(path).suffix.lower()
|
|
14
|
+
if suffix == ".imzml":
|
|
15
|
+
return read_imzml(path, increment=increment, mz_range=mz_range)
|
|
16
|
+
if suffix == ".h5ad":
|
|
17
|
+
return read_h5ad(path)
|
|
18
|
+
if suffix == ".csv":
|
|
19
|
+
return read_scils_csv(path, spots)
|
|
20
|
+
raise ValueError(f"cannot read {path}: expected an .imzML, .h5ad or SCiLS .csv file")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parser():
|
|
24
|
+
parser = argparse.ArgumentParser(
|
|
25
|
+
prog="pgalign",
|
|
26
|
+
description="PGAlign: peak group alignment of mass spectrometry data.",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
29
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
30
|
+
cmd = commands.add_parser(
|
|
31
|
+
"align",
|
|
32
|
+
help="align an unknown dataset onto a reference",
|
|
33
|
+
description=(
|
|
34
|
+
"Align UNKNOWN onto REFERENCE with the full PGAlign pipeline. Each input is an "
|
|
35
|
+
".imzML, .h5ad or SCiLS Lab .csv export. Writes the aligned unknown spectra to "
|
|
36
|
+
"OUT, and next to it the reference on the same bins (<stem>_reference.h5ad), "
|
|
37
|
+
"the per-group shifts (<stem>_shifts.csv) and the ambiguity table "
|
|
38
|
+
"(<stem>_ambiguity.csv)."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
cmd.add_argument("unknown", help="the dataset to align")
|
|
42
|
+
cmd.add_argument("reference", help="the dataset to align onto")
|
|
43
|
+
cmd.add_argument("-o", "--out", required=True, help="path of the aligned .h5ad to write")
|
|
44
|
+
cmd.add_argument("--unknown-spots", help="Region Spots.csv for a SCiLS .csv unknown")
|
|
45
|
+
cmd.add_argument("--reference-spots", help="Region Spots.csv for a SCiLS .csv reference")
|
|
46
|
+
cmd.add_argument("--increment", type=float,
|
|
47
|
+
help="grid spacing in m/z for imzML input; required for processed imzML")
|
|
48
|
+
cmd.add_argument("--mz-range", type=float, nargs=2, metavar=("START", "END"),
|
|
49
|
+
help="m/z range of that grid, END excluded")
|
|
50
|
+
cmd.add_argument("--no-smooth", dest="smooth", action="store_false",
|
|
51
|
+
help="call peaks with PeakCalling instead of the moving average")
|
|
52
|
+
cmd.add_argument("--alpha", type=float, default=0.9, help="peak calling quantile (default 0.9)")
|
|
53
|
+
cmd.add_argument("--percentile", type=float, default=0.9,
|
|
54
|
+
help="peak grouping parameter (default 0.9)")
|
|
55
|
+
cmd.add_argument("--delta", type=float, default=0, help="pairing criterion (default 0)")
|
|
56
|
+
cmd.add_argument("--tau", type=float, default=0.1,
|
|
57
|
+
help="tolerance of the ambiguity diagnostic (default 0.1)")
|
|
58
|
+
cmd.add_argument("--no-normalize", dest="normalize", action="store_false",
|
|
59
|
+
help="skip the per-spectrum normalization")
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main(argv=None):
|
|
64
|
+
parser = _parser()
|
|
65
|
+
args = parser.parse_args(argv)
|
|
66
|
+
try:
|
|
67
|
+
unk = _read(args.unknown, args.unknown_spots, args.increment, args.mz_range)
|
|
68
|
+
ref = _read(args.reference, args.reference_spots, args.increment, args.mz_range)
|
|
69
|
+
res = align(unk, ref, smooth=args.smooth, alpha=args.alpha, percentile=args.percentile,
|
|
70
|
+
delta=args.delta, tau=args.tau, normalize=args.normalize)
|
|
71
|
+
except (ValueError, ImportError) as err:
|
|
72
|
+
parser.exit(2, f"pgalign: error: {err}\n")
|
|
73
|
+
out = Path(args.out)
|
|
74
|
+
stem = out.with_suffix("")
|
|
75
|
+
write_h5ad(res.aligned, out)
|
|
76
|
+
write_h5ad(res.reference, f"{stem}_reference.h5ad")
|
|
77
|
+
res.shifts.to_csv(f"{stem}_shifts.csv", index=False)
|
|
78
|
+
res.ambiguity.to_csv(f"{stem}_ambiguity.csv", index=False)
|
|
79
|
+
print(res.summary())
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
sys.exit(main())
|
|
Binary file
|
pgalign/datasets.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Toy datasets bundled with PGAlign for tutorials and tests."""
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import scanpy as sc
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_mouse_pancreas_toy():
|
|
11
|
+
"""Return two AnnData objects (unknown, reference) from a downsampled mouse-pancreas MALDI slice.
|
|
12
|
+
|
|
13
|
+
The data is a 120-spectrum, 601-bin slice of the public mouse pancreas dataset
|
|
14
|
+
(Zenodo `10.5281/zenodo.3607915`, Prade et al., 2020, CC BY 4.0), drawn from the
|
|
15
|
+
21 x 21 pixel window centred at (1636, 863) and the m/z range [100, 250] on a
|
|
16
|
+
0.25 m/z grid. The "unknown" companion is the same spectra shifted two bins higher
|
|
17
|
+
in m/z, with multiplicative lognormal noise (sigma 0.05), so PGAlign should recover
|
|
18
|
+
a shift of +2 bins. ``scripts/make_toy_data.py`` in the repository rebuilds it.
|
|
19
|
+
|
|
20
|
+
Returns
|
|
21
|
+
-------
|
|
22
|
+
unk : anndata.AnnData
|
|
23
|
+
The shifted "unknown" spectrum.
|
|
24
|
+
ref : anndata.AnnData
|
|
25
|
+
The unshifted reference spectrum.
|
|
26
|
+
"""
|
|
27
|
+
with resources.files(__package__).joinpath("data/mouse_pancreas_toy.npz").open("rb") as f:
|
|
28
|
+
npz = np.load(f)
|
|
29
|
+
X_ref = npz["X_ref"]
|
|
30
|
+
X_unk = npz["X_unk"]
|
|
31
|
+
mz = npz["mz"]
|
|
32
|
+
x = npz["x"]
|
|
33
|
+
y = npz["y"]
|
|
34
|
+
|
|
35
|
+
var = pd.DataFrame({"m/z": mz.astype(float)}, index=[str(v) for v in mz])
|
|
36
|
+
obs = pd.DataFrame({"x": x, "y": y}, index=[f"s{i}" for i in range(len(x))])
|
|
37
|
+
ref = sc.AnnData(X=X_ref, var=var, obs=obs)
|
|
38
|
+
unk = sc.AnnData(X=X_unk, var=var.copy(), obs=obs.copy())
|
|
39
|
+
return unk, ref
|
pgalign/pipeline.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""One-call interface to the PGAlign pipeline."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import scanpy as sc
|
|
6
|
+
import scipy.sparse as sp
|
|
7
|
+
|
|
8
|
+
from .AnnDataMALDI import AnnDataMALDI, PGmzalign
|
|
9
|
+
from .PeakCalling import PeakCalling, PeakCallingmv
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _check_inputs(unk, ref):
|
|
13
|
+
"""Raise a ValueError that names the problem and the fix for input pg.align cannot use."""
|
|
14
|
+
ranges = []
|
|
15
|
+
for name, adata in (("unk", unk), ("ref", ref)):
|
|
16
|
+
if "m/z" not in adata.var.columns:
|
|
17
|
+
hint = ("; a file saved with pg.write_h5ad stores them as 'mz', so read it with "
|
|
18
|
+
"pg.read_h5ad") if "mz" in adata.var.columns else ""
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"{name}.var has no 'm/z' column (columns: {list(adata.var.columns)}); "
|
|
21
|
+
f"store the m/z values as {name}.var['m/z']{hint}"
|
|
22
|
+
)
|
|
23
|
+
mz = adata.var["m/z"].to_numpy(dtype=float)
|
|
24
|
+
if not np.isfinite(mz).all():
|
|
25
|
+
raise ValueError(f"{name}.var['m/z'] contains missing or infinite values")
|
|
26
|
+
if np.any(np.diff(mz) <= 0):
|
|
27
|
+
raise ValueError(
|
|
28
|
+
f"{name}.var['m/z'] must be strictly increasing; sort the bins with "
|
|
29
|
+
f"{name} = {name}[:, np.argsort({name}.var['m/z'].to_numpy())]"
|
|
30
|
+
)
|
|
31
|
+
values = adata.X.data if sp.issparse(adata.X) else np.asarray(adata.X)
|
|
32
|
+
n_bad = int(np.count_nonzero(~np.isfinite(values)))
|
|
33
|
+
if n_bad:
|
|
34
|
+
raise ValueError(
|
|
35
|
+
f"{name}.X contains {n_bad} missing or infinite intensities; "
|
|
36
|
+
"replace them, for example with 0"
|
|
37
|
+
)
|
|
38
|
+
mean = np.asarray(adata.X.mean(axis=0)).ravel()
|
|
39
|
+
if np.ptp(mean) == 0:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
f"{name} carries no signal: its mean spectrum is constant at {mean[0]:g}, "
|
|
42
|
+
"so no peaks can be called"
|
|
43
|
+
)
|
|
44
|
+
ranges.append((mz[0], mz[-1]))
|
|
45
|
+
(unk_lo, unk_hi), (ref_lo, ref_hi) = ranges
|
|
46
|
+
if max(unk_lo, ref_lo) >= min(unk_hi, ref_hi):
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"the m/z ranges do not overlap: unk covers {unk_lo:g} to {unk_hi:g}, "
|
|
49
|
+
f"ref covers {ref_lo:g} to {ref_hi:g}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AlignmentResult(object):
|
|
54
|
+
"""The output of :func:`align`.
|
|
55
|
+
|
|
56
|
+
:ivar aligned: the unknown spectra on the reference m/z grid, as an
|
|
57
|
+
``anndata.AnnData``. ``var["m/z"]`` holds the reference m/z values and
|
|
58
|
+
``obs`` the unknown spectra. It is cut to the shorter of the two m/z axes,
|
|
59
|
+
so it can lack a few bins at the top of the range.
|
|
60
|
+
:ivar reference: the reference spectra on the same bins and the same scale as
|
|
61
|
+
``aligned``, so the two can be analysed jointly. Both carry the
|
|
62
|
+
reference's bin labels as ``var_names``.
|
|
63
|
+
:ivar shifts: a DataFrame with one row per paired peak group: ``unknown_group``,
|
|
64
|
+
``reference_group``, ``mz_start`` and ``mz_end`` (reference m/z range) and
|
|
65
|
+
``offset``, the shift in grid points applied in Step 4.
|
|
66
|
+
:ivar ambiguity: the table of ``AnnDataMALDI.flag_ambiguous_alignments``, one
|
|
67
|
+
row per shifted group.
|
|
68
|
+
:ivar peaks: the peak calling object of Steps 1 and 2.
|
|
69
|
+
:ivar steps: the ``AnnDataMALDI`` object of Steps 3 and 4.
|
|
70
|
+
:ivar tau: the tolerance the ambiguity table was computed with.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(self, aligned, reference, shifts, ambiguity, peaks, steps, tau):
|
|
74
|
+
self.aligned = aligned
|
|
75
|
+
self.reference = reference
|
|
76
|
+
self.shifts = shifts
|
|
77
|
+
self.ambiguity = ambiguity
|
|
78
|
+
self.peaks = peaks
|
|
79
|
+
self.steps = steps
|
|
80
|
+
self.tau = tau
|
|
81
|
+
|
|
82
|
+
def summary(self):
|
|
83
|
+
"""Return a one-line count of paired, shifted and flagged peak groups."""
|
|
84
|
+
n_flagged = int(self.ambiguity["flagged"].sum())
|
|
85
|
+
return (f"{len(self.shifts)} groups paired, {len(self.ambiguity)} shifted, "
|
|
86
|
+
f"{n_flagged} flagged at tau = {self.tau}")
|
|
87
|
+
|
|
88
|
+
def __repr__(self):
|
|
89
|
+
return f"AlignmentResult: {self.summary()}"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def align(unk, ref, smooth=True, alpha=0.9, percentile=0.9, delta=0, tau=0.1, normalize=True):
|
|
93
|
+
"""Align an unknown dataset onto a reference with the full PGAlign pipeline.
|
|
94
|
+
|
|
95
|
+
Runs the four steps of the Methods in order, then the diagnostic for ambiguous
|
|
96
|
+
alignments, and returns the aligned dataset. The defaults are those of the
|
|
97
|
+
Methods and of the atherosclerosis analysis in the manuscript. ``smooth=True``
|
|
98
|
+
calls peaks on the three-point moving average described in the Methods
|
|
99
|
+
("Peak calling"), with ``PeakCallingmv``; ``smooth=False`` uses
|
|
100
|
+
``PeakCalling``, as in the simulation study and the canine sarcoma analysis.
|
|
101
|
+
Step 4 applies the best offset to every paired group, as every analysis in the
|
|
102
|
+
manuscript did (``fine_alignment_assessment(ignore=True)``). The inputs are not
|
|
103
|
+
modified; a sparse intensity matrix is accepted and densified on the copy.
|
|
104
|
+
Input that cannot be aligned (no ``var["m/z"]``, m/z not increasing, missing
|
|
105
|
+
intensities, m/z ranges that do not overlap) raises a ``ValueError`` saying
|
|
106
|
+
what to fix.
|
|
107
|
+
|
|
108
|
+
:param unk: the unknown dataset, an ``anndata.AnnData`` with the m/z values in
|
|
109
|
+
``var["m/z"]``.
|
|
110
|
+
:param ref: the reference dataset, in the same form.
|
|
111
|
+
:param smooth: call peaks on the moving average (``PeakCallingmv``) or on the
|
|
112
|
+
raw mean spectrum (``PeakCalling``). Defaults to True.
|
|
113
|
+
:param alpha: the peak calling quantile (alpha in the paper). Defaults to 0.9.
|
|
114
|
+
:param percentile: the peak grouping parameter. Defaults to 0.9.
|
|
115
|
+
:param delta: the pairing criterion (delta in the paper). Defaults to 0.
|
|
116
|
+
:param tau: the tolerance of the diagnostic for ambiguous alignments (tau in the
|
|
117
|
+
paper). Defaults to 0.1.
|
|
118
|
+
:param normalize: scale each spectrum to the median total intensity before
|
|
119
|
+
aligning (Methods, "Data normalization"), on copies of the inputs.
|
|
120
|
+
``aligned`` and ``reference`` are returned on this normalized scale.
|
|
121
|
+
Defaults to True.
|
|
122
|
+
:return: an :class:`AlignmentResult`.
|
|
123
|
+
"""
|
|
124
|
+
_check_inputs(unk, ref)
|
|
125
|
+
unk = unk.copy()
|
|
126
|
+
ref = ref.copy()
|
|
127
|
+
for adata in (unk, ref):
|
|
128
|
+
if sp.issparse(adata.X):
|
|
129
|
+
adata.X = adata.X.toarray()
|
|
130
|
+
if normalize:
|
|
131
|
+
sc.pp.normalize_total(unk)
|
|
132
|
+
sc.pp.normalize_total(ref)
|
|
133
|
+
peaks = (PeakCallingmv if smooth else PeakCalling)(unk, ref)
|
|
134
|
+
peaks.peak_calling(threshold=alpha)
|
|
135
|
+
peaks.peak_grouping(percentile=percentile)
|
|
136
|
+
steps = AnnDataMALDI(unk, ref)
|
|
137
|
+
steps.get_corr_peakgroup_refined(peaks.jointcluster)
|
|
138
|
+
steps.peak_group_pairing(criteria=delta)
|
|
139
|
+
steps.fine_alignment_assessment(ignore=True)
|
|
140
|
+
steps.summarize()
|
|
141
|
+
ambiguity = steps.flag_ambiguous_alignments(delta=tau)
|
|
142
|
+
aligned = PGmzalign(steps).getAnnSim()
|
|
143
|
+
reference = ref[:, :aligned.n_vars].copy()
|
|
144
|
+
aligned.var_names = reference.var_names
|
|
145
|
+
mz_ref = ref.var["m/z"].to_numpy(dtype=float)
|
|
146
|
+
shifts = pd.DataFrame({
|
|
147
|
+
"unknown_group": steps.align_group[:, 0].astype(int),
|
|
148
|
+
"reference_group": steps.align_group[:, 1].astype(int),
|
|
149
|
+
"mz_start": [mz_ref[group[0]] for group in steps.aligned_mz_clusters_ref],
|
|
150
|
+
"mz_end": [mz_ref[group[-1]] for group in steps.aligned_mz_clusters_ref],
|
|
151
|
+
"offset": np.asarray(steps.changerecord, dtype=int),
|
|
152
|
+
})
|
|
153
|
+
return AlignmentResult(aligned, reference, shifts, ambiguity, peaks, steps, tau)
|