bowside 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.
bowside/mixture.py ADDED
@@ -0,0 +1,408 @@
1
+ """Module for mixture models used in trajectory inference."""
2
+
3
+ from collections.abc import Collection
4
+ from typing import Union
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ from scipy.special import logsumexp
9
+ from scipy.stats import multivariate_normal
10
+
11
+
12
+ class GaussianMixture:
13
+ """
14
+ A multivariate Gaussian mixture model with frozen weights, means,
15
+ and covariances.
16
+
17
+ Parameters
18
+ ----------
19
+ weights : np.typing.ArrayLike
20
+ Array-like of shape (n_components,) containing the weights of
21
+ the mixture components.
22
+ means : np.typing.ArrayLike
23
+ Array-like of shape (n_components, n_var) containing the
24
+ mean vectors of the mixture components.
25
+ covariances : np.typing.ArrayLike
26
+ Array-like of shape (n_components, n_var, n_var)
27
+ containing the covariance matrices of the mixture components.
28
+ allow_singular : bool, optional
29
+ Whether to allow a singular covariance matrix. Default: False.
30
+
31
+ Raises
32
+ ------
33
+ ValueError
34
+ An error is raised if any of the input arrays is of the wrong shape.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ weights: np.typing.ArrayLike,
40
+ means: np.typing.ArrayLike,
41
+ covariances: np.typing.ArrayLike,
42
+ allow_singular: bool = False,
43
+ ) -> None:
44
+ self.weights = np.asarray(weights, dtype=float)
45
+ self.means = np.asarray(means, dtype=float)
46
+ self.covariances = np.asarray(covariances, dtype=float)
47
+
48
+ # Check input
49
+ if self.weights.ndim != 1:
50
+ raise ValueError("Weights must be 1-dimensional.")
51
+ if self.means.ndim != 2:
52
+ raise ValueError("Means must be 2-dimensional.")
53
+ if self.covariances.ndim != 3:
54
+ raise ValueError("Covariances must be 3-dimensional.")
55
+ if np.any(self.weights < 0.0):
56
+ raise ValueError("All weights must be positive.")
57
+ if not np.isclose(self.weights.sum(), 1.0):
58
+ raise ValueError("Weights must sum to 1.")
59
+ if self.means.shape[0] != self.weights.shape[0]:
60
+ raise ValueError("Weights and means have mismatching shapes.")
61
+ if self.covariances.shape[0] != self.weights.shape[0]:
62
+ raise ValueError("Weights and covariances have mismatching shapes.")
63
+ if self.covariances.shape[1] != self.means.shape[1]:
64
+ raise ValueError("Means and covariances have mismatching shapes.")
65
+ if self.covariances.shape[1] != self.covariances.shape[2]:
66
+ raise ValueError("Covariance matrices are not square.")
67
+
68
+ # Initialize components
69
+ self.n_components = self.means.shape[0]
70
+ self.n_var = self.means.shape[1]
71
+ self.components = [
72
+ multivariate_normal(
73
+ mean=self.means[i],
74
+ cov=self.covariances[i],
75
+ allow_singular=allow_singular,
76
+ )
77
+ for i in range(self.n_components)
78
+ ]
79
+
80
+ def component_pdfs(self, X: np.typing.ArrayLike) -> np.ndarray:
81
+ """
82
+ Evaluate the components' PDF for each sample.
83
+
84
+ Parameters
85
+ ----------
86
+ X : np.typing.ArrayLike
87
+ Array of shape (n_samples, n_var). A 1-dimensional array of
88
+ shape (n_var,) can be passed as well for a single sample.
89
+
90
+ Returns
91
+ -------
92
+ np.ndarray
93
+ Array of shape (n_samples, n_components) containing the PDF
94
+ value for each sample and component.
95
+ """
96
+ return np.exp(self.component_log_pdfs(X))
97
+
98
+ def component_log_pdfs(self, X: np.typing.ArrayLike) -> np.ndarray:
99
+ """
100
+ Evaluate the components' Log-PDF for each sample.
101
+
102
+ Parameters
103
+ ----------
104
+ X : np.typing.ArrayLike
105
+ Array of shape (n_samples, n_var). A 1-dimensional array of
106
+ shape (n_var,) can be passed as well for a single sample.
107
+
108
+ Returns
109
+ -------
110
+ np.ndarray
111
+ Array of shape (n_samples, n_components) containing the Log-
112
+ PDF value for each sample and component.
113
+ """
114
+ X = np.asarray(X, dtype=float)
115
+ if X.ndim == 1:
116
+ X = X[None, :]
117
+ return np.column_stack([component.logpdf(X) for component in self.components])
118
+
119
+ def _weighted_component_log_densities(self, X: np.typing.ArrayLike) -> np.ndarray:
120
+ """
121
+ Helper function for computing weighted log densitities.
122
+
123
+ Parameters
124
+ ----------
125
+ X : np.typing.ArrayLike
126
+ Array of shape (n_samples, n_var). A 1-dimensional array of
127
+ shape (n_var,) can be passed as well for a single sample.
128
+
129
+ Returns
130
+ -------
131
+ np.ndarray
132
+ Array of shape (n_samples, n_components) containing the
133
+ weighted log-densities for each sample and component.
134
+ """
135
+ component_log_density = self.component_log_pdfs(X)
136
+ log_weights = np.full_like(self.weights, -np.inf)
137
+ positive_weights = self.weights > 0.0
138
+ log_weights[positive_weights] = np.log(self.weights[positive_weights])
139
+ return component_log_density + log_weights
140
+
141
+ def predict_proba(self, X: np.typing.ArrayLike) -> np.ndarray:
142
+ """
143
+ Predict the components' probabilities for each sample.
144
+
145
+ Parameters
146
+ ----------
147
+ X : np.typing.ArrayLike
148
+ Array of shape (n_samples, n_var). A 1-dimensional array of
149
+ shape (n_var,) can be passed as well for a single sample.
150
+
151
+ Returns
152
+ -------
153
+ np.ndarray
154
+ Array of shape (n_samples, n_components) containing the
155
+ probabilities for each sample and component.
156
+ """
157
+ weighted_log_density = self._weighted_component_log_densities(X)
158
+ return np.exp(
159
+ weighted_log_density
160
+ - logsumexp(weighted_log_density, axis=1, keepdims=True)
161
+ )
162
+
163
+ def score_samples(self, X: np.typing.ArrayLike) -> np.ndarray:
164
+ """
165
+ Compute the log-likelihood of each sample.
166
+
167
+ Parameters
168
+ ----------
169
+ X : np.typing.ArrayLike
170
+ Array of shape (n_samples, n_var). A 1-dimensional array of
171
+ shape (n_var,) can be passed as well for a single sample.
172
+
173
+ Returns
174
+ -------
175
+ np.ndarray
176
+ Array of shape (n_samples, n_components) containing the
177
+ log-likelihood for each sample given the mixture model.
178
+ """
179
+ scores = logsumexp(self._weighted_component_log_densities(X), axis=1)
180
+ assert isinstance(scores, np.ndarray)
181
+ return scores
182
+
183
+
184
+ class GaussianMixtureTimeseries:
185
+ """
186
+ A (pseudo)time series of Gaussian mixtures describing expression trajectories.
187
+
188
+ Parameters
189
+ ----------
190
+ sequences : np.typing.ArrayLike
191
+ Array of shape (n_steps, n_sequences, n_var) containing gene
192
+ expression sequences.
193
+ time_points : np.typing.ArrayLike
194
+ Array of shape (n_steps,) containing the time points.
195
+ cluster_labels : np.typing.ArrayLike
196
+ An array of shape (n_sequences). The value at index i is the
197
+ cluster number to which sequence i belongs.
198
+ allow_singular : bool, optional
199
+ Whether to allow a singular covariance matrix. Default: False.
200
+
201
+ Raises
202
+ ------
203
+ ValueError
204
+ An error is raised if any of the input arrays is of the wrong shape.
205
+ """
206
+
207
+ def __init__(
208
+ self,
209
+ sequences: np.typing.ArrayLike,
210
+ time_points: np.typing.ArrayLike,
211
+ cluster_labels: np.typing.ArrayLike,
212
+ allow_singular: bool = False,
213
+ ) -> None:
214
+ sequences = np.asarray(sequences)
215
+ time_points = np.asarray(time_points)
216
+ cluster_labels = np.asarray(cluster_labels)
217
+
218
+ # Input checks
219
+ if sequences.ndim != 3:
220
+ raise ValueError(
221
+ "Parameter `sequences` must be 3-dimensional with shape "
222
+ "(n_steps, n_sequences, n_var)."
223
+ )
224
+ if time_points.ndim != 1:
225
+ raise ValueError("Parameter `time_points` must be 1-dimensional.")
226
+ if cluster_labels.ndim != 1:
227
+ raise ValueError("Parameter `cluster_labels` must be 1-dimensional.")
228
+ if time_points.shape[0] != sequences.shape[0]:
229
+ raise ValueError("Shape mismatch between `time_points` and `sequences`.")
230
+ if sequences.shape[1] != cluster_labels.shape[0]:
231
+ raise ValueError("Shape mismatch between `sequences` and `cluster_labels`.")
232
+
233
+ time_points.sort()
234
+ self.time_points = pd.Index(time_points)
235
+
236
+ # Compute component means
237
+ clusters, weights = np.unique(
238
+ cluster_labels,
239
+ return_counts=True,
240
+ )
241
+ self.weights = weights / weights.sum(keepdims=True)
242
+
243
+ # Compute cluster means and covariances for each time point
244
+ self.n_components = clusters.shape[0]
245
+ self.means = np.zeros(
246
+ (self.n_components, time_points.shape[0], sequences.shape[2]), dtype=float
247
+ )
248
+ self.covariances = np.zeros(
249
+ (
250
+ self.n_components,
251
+ time_points.shape[0],
252
+ sequences.shape[2],
253
+ sequences.shape[2],
254
+ )
255
+ )
256
+ for i, cluster in enumerate(clusters):
257
+ cluster_sequences = sequences[:, cluster_labels == cluster, :]
258
+ self.means[i] = cluster_sequences.mean(axis=1)
259
+ for t in range(time_points.shape[0]):
260
+ self.covariances[i, t, :, :] = np.cov(
261
+ cluster_sequences[t, :, :], bias=False, rowvar=False
262
+ )
263
+
264
+ # Generate Gaussian mixtures
265
+ self.gaussian_mixtures = [
266
+ GaussianMixture(
267
+ self.weights,
268
+ self.means[:, t, :],
269
+ self.covariances[:, t, :, :],
270
+ allow_singular=allow_singular,
271
+ )
272
+ for t in range(time_points.shape[0])
273
+ ]
274
+
275
+ def component_pdfs(
276
+ self,
277
+ X: np.typing.ArrayLike,
278
+ time_points: Union[int, float, Collection[Union[int, float]]],
279
+ ) -> np.ndarray:
280
+ """
281
+ Evaluate the components' PDF for each sample, using the Gaussian
282
+ mixture of the respective time point.
283
+
284
+ Parameters
285
+ ----------
286
+ X : np.typing.ArrayLike
287
+ Array of shape (n_samples, n_var). A 1-dimensional array of
288
+ shape (n_var,) can be passed as well for a single sample.
289
+ time_points : Union[int, float, Collection[Union[int, float]]]
290
+ List containing the time points for each sample. If just one
291
+ sample is given, a single numeric value can be given for the
292
+ corresponding time point as well.
293
+
294
+ Returns
295
+ -------
296
+ np.ndarray
297
+ Array of shape (n_samples, n_components) containing the PDF
298
+ value for each sample and component, given the time points.
299
+
300
+ Raises
301
+ ------
302
+ ValueError
303
+ An error is raised if the number of time points does not
304
+ match the number of samples.
305
+ """
306
+ X = np.asarray(X, dtype=float)
307
+ if X.ndim == 1:
308
+ X = X[None, :]
309
+ if isinstance(time_points, (int, float)):
310
+ time_points = [time_points]
311
+ if len(time_points) != X.shape[0]:
312
+ raise ValueError("Mismatching number of samples and time points.")
313
+ time_point_idx = np.asarray(
314
+ [self.time_points.get_loc(tp) for tp in time_points]
315
+ )
316
+ pdfs = np.zeros((X.shape[0], self.n_components))
317
+ for tp_index in np.unique(time_point_idx):
318
+ row_mask = time_point_idx == tp_index
319
+ pdfs[row_mask, :] = self.gaussian_mixtures[tp_index].component_pdfs(
320
+ X[row_mask, :]
321
+ )
322
+ return pdfs
323
+
324
+ def predict_proba(
325
+ self,
326
+ X: np.typing.ArrayLike,
327
+ time_points: Union[int, float, Collection[Union[int, float]]],
328
+ ) -> np.ndarray:
329
+ """
330
+ Predict the components' probabilities for each sample, using the
331
+ Gaussian mixture of the respective time point.
332
+
333
+ Parameters
334
+ ----------
335
+ X : np.typing.ArrayLike
336
+ Array of shape (n_samples, n_var). A 1-dimensional array of
337
+ shape (n_var,) can be passed as well for a single sample.
338
+ time_points : Union[int, float, Collection[Union[int, float]]]
339
+ List containing the time points for each sample. If just one
340
+ sample is given, a single numeric value can be given for the
341
+ corresponding time point as well.
342
+
343
+ Returns
344
+ -------
345
+ np.ndarray
346
+ Array of shape (n_samples, n_components) containing the
347
+ probabilities for each sample and component, given the time
348
+ points.
349
+ """
350
+ X = np.asarray(X, dtype=float)
351
+ if X.ndim == 1:
352
+ X = X[None, :]
353
+ if isinstance(time_points, (int, float)):
354
+ time_points = [time_points]
355
+ if len(time_points) != X.shape[0]:
356
+ raise ValueError("Mismatching number of samples and time points.")
357
+ time_point_idx = np.asarray(
358
+ [self.time_points.get_loc(tp) for tp in time_points]
359
+ )
360
+ probabilities = np.zeros((X.shape[0], self.n_components))
361
+ for tp_index in np.unique(time_point_idx):
362
+ row_mask = time_point_idx == tp_index
363
+ probabilities[row_mask, :] = self.gaussian_mixtures[tp_index].predict_proba(
364
+ X[row_mask, :]
365
+ )
366
+ return probabilities
367
+
368
+ def score_samples(
369
+ self,
370
+ X: np.typing.ArrayLike,
371
+ time_points: Union[int, float, Collection[Union[int, float]]],
372
+ ) -> np.ndarray:
373
+ """
374
+ Compute the log-likelihood of each sample, using the Gaussian
375
+ mixture of the respective time point.
376
+
377
+ Parameters
378
+ ----------
379
+ X : np.typing.ArrayLike
380
+ Array of shape (n_samples, n_var). A 1-dimensional array of
381
+ shape (n_var,) can be passed as well for a single sample.
382
+ time_points : Union[int, float, Collection[Union[int, float]]]
383
+ List containing the time points for each sample. If just one
384
+ sample is given, a single numeric value can be given for the
385
+ corresponding time point as well.
386
+
387
+ Returns
388
+ -------
389
+ np.ndarray
390
+ Array of shape (n_samples, n_components) containing the
391
+ log-likelihood for each sample given the mixture model of
392
+ the respective time points.
393
+ """
394
+ X = np.asarray(X, dtype=float)
395
+ if X.ndim == 1:
396
+ X = X[None, :]
397
+ if isinstance(time_points, (int, float)):
398
+ time_points = [time_points]
399
+ time_point_idx = np.asarray(
400
+ [self.time_points.get_loc(tp) for tp in time_points]
401
+ )
402
+ scores = np.zeros(X.shape[0])
403
+ for tp_index in np.unique(time_point_idx):
404
+ row_mask = time_point_idx == tp_index
405
+ scores[row_mask] = self.gaussian_mixtures[tp_index].score_samples(
406
+ X[row_mask, :]
407
+ )
408
+ return scores