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