multineuronchat 2025.11.10.dev0__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.
- multineuronchat/InteractionDB/InteractionDB.py +67 -0
- multineuronchat/InteractionDB/InteractionDBRow.py +50 -0
- multineuronchat/InteractionDB/__init__.py +4 -0
- multineuronchat/MultiNeuronChat.py +447 -0
- multineuronchat/MultiNeuronChatObject.py +959 -0
- multineuronchat/__init__.py +28 -0
- multineuronchat/db/__init__.py +0 -0
- multineuronchat/loompy_utils.py +66 -0
- multineuronchat/masks.py +358 -0
- multineuronchat/normalize.py +177 -0
- multineuronchat/utils.py +159 -0
- multineuronchat/visualize.py +1029 -0
- multineuronchat-2025.11.10.dev0.dist-info/METADATA +117 -0
- multineuronchat-2025.11.10.dev0.dist-info/RECORD +17 -0
- multineuronchat-2025.11.10.dev0.dist-info/WHEEL +5 -0
- multineuronchat-2025.11.10.dev0.dist-info/licenses/LICENSE +674 -0
- multineuronchat-2025.11.10.dev0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from .MultiNeuronChatObject import MultiNeuronChatObject
|
|
2
|
+
|
|
3
|
+
from .InteractionDB import (
|
|
4
|
+
InteractionDB,
|
|
5
|
+
InteractionDBRow
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
from .MultiNeuronChat import (
|
|
9
|
+
compute_communication_score_matrix,
|
|
10
|
+
compute_subject_specific_communication_score_matrix,
|
|
11
|
+
compute_avg_expression,
|
|
12
|
+
compute_subject_specific_avg_expression
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .normalize import (
|
|
16
|
+
cell_wise_log_normalization,
|
|
17
|
+
subject_wise_max_normalization
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from .utils import (
|
|
21
|
+
filter_genes,
|
|
22
|
+
gene_filter_and_subject_wise_normalize_dataset
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ['MultiNeuronChatObject', 'InteractionDB', 'InteractionDBRow', 'compute_communication_score_matrix',
|
|
26
|
+
'compute_subject_specific_communication_score_matrix', 'compute_avg_expression',
|
|
27
|
+
'compute_subject_specific_avg_expression', 'cell_wise_log_normalization',
|
|
28
|
+
'subject_wise_max_normalization', 'filter_genes', 'gene_filter_and_subject_wise_normalize_dataset']
|
|
File without changes
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
import loompy
|
|
4
|
+
from loompy import timestamp
|
|
5
|
+
import h5py
|
|
6
|
+
|
|
7
|
+
from typing import Dict, Tuple, Optional
|
|
8
|
+
|
|
9
|
+
def create_empty_loom_file(
|
|
10
|
+
path_to_loom: str,
|
|
11
|
+
shape: Tuple[int, int],
|
|
12
|
+
row_attrs: Optional[Dict[str, np.ndarray]] = None,
|
|
13
|
+
col_attrs: Optional[Dict[str, np.ndarray]] = None,
|
|
14
|
+
dtype_to_use = np.float32
|
|
15
|
+
) -> None:
|
|
16
|
+
"""
|
|
17
|
+
Creates an empty loom file at the specified path with the given number of rows and columns.
|
|
18
|
+
|
|
19
|
+
Parameters
|
|
20
|
+
----------
|
|
21
|
+
path_to_loom: str
|
|
22
|
+
Path where the loom file should be created.
|
|
23
|
+
shape: Tuple[int, int]
|
|
24
|
+
Shape of the matrix to be created in the loom file, specified as (n_rows, n_cols).
|
|
25
|
+
row_attrs: Optional[Dict[str, np.ndarray]]
|
|
26
|
+
Dictionary of row attributes where keys are attribute names and values are arrays of attribute values.
|
|
27
|
+
col_attrs: Optional[Dict[str, np.ndarray]]
|
|
28
|
+
Dictionary of column attributes where keys are attribute names and values are arrays of attribute values.
|
|
29
|
+
dtype_to_use: np.dtype
|
|
30
|
+
Data type to use for the matrix in the loom file (default: np.float32).
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
None
|
|
35
|
+
"""
|
|
36
|
+
n_rows, n_cols = shape
|
|
37
|
+
|
|
38
|
+
with h5py.File(path_to_loom, 'w') as f:
|
|
39
|
+
f.create_group('/attrs') # v3.0.0
|
|
40
|
+
|
|
41
|
+
f.create_group('/layers')
|
|
42
|
+
f.create_group('/row_attrs')
|
|
43
|
+
f.create_group('/col_attrs')
|
|
44
|
+
f.create_group('/row_graphs')
|
|
45
|
+
f.create_group('/col_graphs')
|
|
46
|
+
|
|
47
|
+
f.create_dataset(
|
|
48
|
+
'/matrix',
|
|
49
|
+
shape=(n_rows, n_cols),
|
|
50
|
+
dtype=dtype_to_use,
|
|
51
|
+
chunks=True,
|
|
52
|
+
compression='gzip',
|
|
53
|
+
compression_opts=9,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
f.flush()
|
|
57
|
+
|
|
58
|
+
with loompy.connect(path_to_loom, 'r+') as ds:
|
|
59
|
+
ds.attrs['CreationDate'] = timestamp()
|
|
60
|
+
ds.attrs["LOOM_SPEC_VERSION"] = loompy.loom_spec_version
|
|
61
|
+
|
|
62
|
+
for key, vals in row_attrs.items():
|
|
63
|
+
ds.ra[key] = vals
|
|
64
|
+
|
|
65
|
+
for key, vals in col_attrs.items():
|
|
66
|
+
ds.ca[key] = vals
|
multineuronchat/masks.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
|
|
4
|
+
from .MultiNeuronChatObject import MultiNeuronChatObject
|
|
5
|
+
|
|
6
|
+
from typing import Optional, Union, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def compute_wasserstein_mask(
|
|
10
|
+
mnc_object: MultiNeuronChatObject,
|
|
11
|
+
top_n: Optional[int] = None,
|
|
12
|
+
top_percentile: Optional[float] = None,
|
|
13
|
+
p: Optional[float] = 1.0,
|
|
14
|
+
normalized: Optional[bool] = False,
|
|
15
|
+
exclude_zero_distributions: Optional[bool] = True,
|
|
16
|
+
return_wasserstein_distances: Optional[bool] = False
|
|
17
|
+
) -> Union[xr.DataArray, Tuple[xr.DataArray, xr.DataArray]]:
|
|
18
|
+
"""
|
|
19
|
+
Compute a mask for the hypotheses with the top_n or top_percentile largest Wasserstein distances between the
|
|
20
|
+
distributions of the communication scores for each hypothesis. The mask is returned as a xarray DataArray with
|
|
21
|
+
dimensions 'source_cell_type', 'receiver_cell_type', and 'interaction_type'.
|
|
22
|
+
|
|
23
|
+
See also: compute_earth_mover_mask
|
|
24
|
+
|
|
25
|
+
:param mnc_object: A MultiNeuronChatObject containing the communication scores
|
|
26
|
+
:param top_n: The number of hypotheses with the largest Wasserstein distances that should be included in the mask.
|
|
27
|
+
If None, top_percentile must be provided.
|
|
28
|
+
:param top_percentile: The percentile of the hypotheses with the largest Wasserstein distances that should be
|
|
29
|
+
included in the mask. If None, top_n must be provided.
|
|
30
|
+
:param normalized: If True, the Wasserstein distances are normalized by the range of the communication scores for
|
|
31
|
+
each hypothesis. This is done to ensure that the Wasserstein distances are comparable.
|
|
32
|
+
Default: True
|
|
33
|
+
:param exclude_zero_distributions: If True, hypotheses where both distributions consist solely of zeros are excluded.
|
|
34
|
+
:param return_wasserstein_distances: If True, the computed Wasserstein distances are returned as well.
|
|
35
|
+
:return: A boolean xarray DataArray with dimensions 'source_cell_type', 'receiver_cell_type', and 'interaction_type'
|
|
36
|
+
or a tuple with the mask and the Wasserstein distances as xarray DataArrays
|
|
37
|
+
"""
|
|
38
|
+
return compute_earth_mover_mask(
|
|
39
|
+
mnc_object=mnc_object,
|
|
40
|
+
top_n=top_n,
|
|
41
|
+
top_percentile=top_percentile,
|
|
42
|
+
p=p,
|
|
43
|
+
normalized=normalized,
|
|
44
|
+
exclude_zero_distributions=exclude_zero_distributions,
|
|
45
|
+
return_wasserstein_distances=return_wasserstein_distances
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compute_earth_mover_mask(
|
|
50
|
+
mnc_object: MultiNeuronChatObject,
|
|
51
|
+
top_n: Optional[int] = None,
|
|
52
|
+
top_percentile: Optional[float] = None,
|
|
53
|
+
p: Optional[float] = 1.0,
|
|
54
|
+
normalized: Optional[bool] = False,
|
|
55
|
+
exclude_zero_distributions: Optional[bool] = True,
|
|
56
|
+
return_wasserstein_distances: Optional[bool] = False
|
|
57
|
+
) -> Union[xr.DataArray, Tuple[xr.DataArray, xr.DataArray]]:
|
|
58
|
+
"""
|
|
59
|
+
Compute a mask for the hypotheses with the top_n or top_percentile largest Wasserstein distances between the
|
|
60
|
+
distributions of the communication scores for each hypothesis. The mask is returned as a xarray DataArray with
|
|
61
|
+
dimensions 'source_cell_type', 'receiver_cell_type', and 'interaction_type'.
|
|
62
|
+
|
|
63
|
+
:param mnc_object: A MultiNeuronChatObject containing the communication scores
|
|
64
|
+
:param top_n: The number of hypotheses with the largest Wasserstein distances that should be included in the mask.
|
|
65
|
+
If None, top_percentile must be provided.
|
|
66
|
+
:param top_percentile: The percentile of the hypotheses with the largest Wasserstein distances that should be
|
|
67
|
+
included in the mask. If None, top_n must be provided.
|
|
68
|
+
:param normalized: If True, the Wasserstein distances are normalized by the range of the communication scores for
|
|
69
|
+
each hypothesis. This is done to ensure that the Wasserstein distances are comparable.
|
|
70
|
+
Default: True
|
|
71
|
+
:param exclude_zero_distributions: If True, hypotheses where both distributions consist solely of zeros are excluded.
|
|
72
|
+
:param return_wasserstein_distances: If True, the computed Wasserstein distances are returned as well.
|
|
73
|
+
:return: A boolean xarray DataArray with dimensions 'source_cell_type', 'receiver_cell_type', and 'interaction_type'
|
|
74
|
+
or a tuple with the mask and the Wasserstein distances as xarray DataArrays
|
|
75
|
+
"""
|
|
76
|
+
if mnc_object is None:
|
|
77
|
+
raise ValueError('A MultiNeuronChatObject must be provided')
|
|
78
|
+
|
|
79
|
+
if mnc_object.communication_score_dict is None:
|
|
80
|
+
raise ValueError('The MultiNeuronChatObject must contain the computed communication scores!'
|
|
81
|
+
'Please run compute_communication_scores() first.')
|
|
82
|
+
|
|
83
|
+
n_source_cell_types = len(mnc_object.source_cell_types)
|
|
84
|
+
n_receiver_cell_types = len(mnc_object.receiver_cell_types)
|
|
85
|
+
n_interaction_types = len(mnc_object.interaction_names)
|
|
86
|
+
n_hypotheses = n_source_cell_types * n_receiver_cell_types * n_interaction_types
|
|
87
|
+
|
|
88
|
+
if top_n is None and top_percentile is None:
|
|
89
|
+
raise ValueError('Either top_n or top_percentile must be provided')
|
|
90
|
+
|
|
91
|
+
if top_n is not None and top_percentile is not None:
|
|
92
|
+
raise ValueError('Only one of top_n or top_percentile must be provided')
|
|
93
|
+
|
|
94
|
+
if top_n is not None:
|
|
95
|
+
if top_n <= 0:
|
|
96
|
+
raise ValueError('top_n must be a positive integer')
|
|
97
|
+
|
|
98
|
+
# Check if top_n is bigger than the number of hypotheses
|
|
99
|
+
# (product of number of source cell types, receiver cell, and interaction types)
|
|
100
|
+
if top_n > n_hypotheses:
|
|
101
|
+
raise ValueError(f'top_n must be smaller than the number of hypotheses ({n_hypotheses})')
|
|
102
|
+
|
|
103
|
+
if top_percentile is not None:
|
|
104
|
+
if top_percentile <= 0 or top_percentile >= 100:
|
|
105
|
+
raise ValueError('top_percentile must be a float between 0 and 100')
|
|
106
|
+
|
|
107
|
+
if p <= 0:
|
|
108
|
+
raise ValueError('p must be a positive value!')
|
|
109
|
+
|
|
110
|
+
# Compute the Wasserstein distance between the distributions of the communication scores for each hypothesis
|
|
111
|
+
wasserstein_distances = np.ones((n_source_cell_types, n_receiver_cell_types, n_interaction_types)) * np.nan
|
|
112
|
+
|
|
113
|
+
for s, source in enumerate(mnc_object.source_cell_types):
|
|
114
|
+
for r, receiver in enumerate(mnc_object.receiver_cell_types):
|
|
115
|
+
for lt, interaction in enumerate(mnc_object.interaction_names):
|
|
116
|
+
ctrl_dist = mnc_object.communication_score_dict[mnc_object.condition_names[0]][:, s, r, lt]
|
|
117
|
+
scz_dist = mnc_object.communication_score_dict[mnc_object.condition_names[1]][:, s, r, lt]
|
|
118
|
+
|
|
119
|
+
ctrl_dist = ctrl_dist[~np.isnan(ctrl_dist)]
|
|
120
|
+
scz_dist = scz_dist[~np.isnan(scz_dist)]
|
|
121
|
+
|
|
122
|
+
if len(ctrl_dist) == 0 or len(scz_dist) == 0:
|
|
123
|
+
continue
|
|
124
|
+
if exclude_zero_distributions and np.all(ctrl_dist == 0) and np.all(scz_dist == 0):
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
min_value: float = min(ctrl_dist.min(), scz_dist.min())
|
|
128
|
+
max_value: float = max(ctrl_dist.max(), scz_dist.max())
|
|
129
|
+
value_range: float = max_value - min_value
|
|
130
|
+
|
|
131
|
+
#wasserstein_distances[s, r, lt] = stats.wasserstein_distance(ctrl_dist, scz_dist)
|
|
132
|
+
wasserstein_distances[s, r, lt] = _cdf_distance(p, ctrl_dist, scz_dist)
|
|
133
|
+
|
|
134
|
+
if normalized and value_range > 0:
|
|
135
|
+
wasserstein_distances[s, r, lt] /= value_range
|
|
136
|
+
|
|
137
|
+
mask: np.array = np.zeros((n_source_cell_types, n_receiver_cell_types, n_interaction_types), dtype=bool)
|
|
138
|
+
|
|
139
|
+
if top_n is not None:
|
|
140
|
+
# Get the top_n indices with the smallest Wasserstein distances (which can include nan values)
|
|
141
|
+
top_n_indices = np.unravel_index(np.argsort(-wasserstein_distances, axis=None)[:top_n], wasserstein_distances.shape)
|
|
142
|
+
mask = np.zeros_like(wasserstein_distances, dtype=bool)
|
|
143
|
+
mask[top_n_indices] = True
|
|
144
|
+
else:
|
|
145
|
+
percentile = np.nanpercentile(wasserstein_distances, top_percentile)
|
|
146
|
+
mask = wasserstein_distances >= percentile
|
|
147
|
+
|
|
148
|
+
mask_xr = xr.DataArray(
|
|
149
|
+
mask,
|
|
150
|
+
dims=('source', 'receiver', 'interaction'),
|
|
151
|
+
coords={
|
|
152
|
+
'source': mnc_object.source_cell_types,
|
|
153
|
+
'receiver': mnc_object.receiver_cell_types,
|
|
154
|
+
'interaction': mnc_object.interaction_names
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
wasserstein_distances_xr = xr.DataArray(
|
|
159
|
+
wasserstein_distances,
|
|
160
|
+
dims=('source', 'receiver', 'interaction'),
|
|
161
|
+
coords={
|
|
162
|
+
'source': mnc_object.source_cell_types,
|
|
163
|
+
'receiver': mnc_object.receiver_cell_types,
|
|
164
|
+
'interaction': mnc_object.interaction_names
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
if return_wasserstein_distances:
|
|
169
|
+
return mask_xr, wasserstein_distances_xr
|
|
170
|
+
return mask_xr
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def compute_threshold_communication_score_mask(
|
|
174
|
+
mnc_object: MultiNeuronChatObject,
|
|
175
|
+
n_samples: Optional[int] = 1,
|
|
176
|
+
threshold: Optional[float] = 0.0,
|
|
177
|
+
) -> xr.DataArray:
|
|
178
|
+
"""
|
|
179
|
+
Compute a mask for the hypotheses where n_samples from each condition must have a communication score that is
|
|
180
|
+
higher than the threshold. The mask is returned as a xarray DataArray with dimensions 'source_cell_type',
|
|
181
|
+
'receiver_cell_type', and 'interaction_type'.
|
|
182
|
+
|
|
183
|
+
:param mnc_object: A MultiNeuronChatObject containing the communication scores
|
|
184
|
+
:param n_samples: The number of samples from each condition that must have a communication score higher than the threshold.
|
|
185
|
+
:param threshold: The threshold that the communication score must exceed.
|
|
186
|
+
:return: A boolean xarray DataArray with dimensions 'source_cell_type', 'receiver_cell_type', and 'interaction_type'
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
if mnc_object is None:
|
|
190
|
+
raise ValueError('A MultiNeuronChatObject must be provided')
|
|
191
|
+
|
|
192
|
+
if mnc_object.communication_score_dict is None:
|
|
193
|
+
raise ValueError('The MultiNeuronChatObject must contain the computed communication scores!'
|
|
194
|
+
'Please run compute_communication_scores() first.')
|
|
195
|
+
|
|
196
|
+
n_source_cell_types = len(mnc_object.source_cell_types)
|
|
197
|
+
n_receiver_cell_types = len(mnc_object.receiver_cell_types)
|
|
198
|
+
n_interaction_types = len(mnc_object.interaction_names)
|
|
199
|
+
|
|
200
|
+
mask: np.array = np.zeros((n_source_cell_types, n_receiver_cell_types, n_interaction_types), dtype=bool)
|
|
201
|
+
|
|
202
|
+
for s, source in enumerate(mnc_object.source_cell_types):
|
|
203
|
+
for r, receiver in enumerate(mnc_object.receiver_cell_types):
|
|
204
|
+
for lt, interaction in enumerate(mnc_object.interaction_names):
|
|
205
|
+
ctrl_dist = mnc_object.communication_score_dict[mnc_object.condition_names[0]][:, s, r, lt]
|
|
206
|
+
scz_dist = mnc_object.communication_score_dict[mnc_object.condition_names[1]][:, s, r, lt]
|
|
207
|
+
|
|
208
|
+
ctrl_mask = ctrl_dist > threshold
|
|
209
|
+
scz_mask = scz_dist > threshold
|
|
210
|
+
|
|
211
|
+
if np.sum(ctrl_mask) >= n_samples and np.sum(scz_mask) >= n_samples:
|
|
212
|
+
mask[s, r, lt] = True
|
|
213
|
+
|
|
214
|
+
mask_xr = xr.DataArray(
|
|
215
|
+
mask,
|
|
216
|
+
dims=('source', 'receiver', 'interaction'),
|
|
217
|
+
coords={
|
|
218
|
+
'source': mnc_object.source_cell_types,
|
|
219
|
+
'receiver': mnc_object.receiver_cell_types,
|
|
220
|
+
'interaction': mnc_object.interaction_names
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return mask_xr
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
########################################################################################################################
|
|
228
|
+
# Helper functions
|
|
229
|
+
########################################################################################################################
|
|
230
|
+
|
|
231
|
+
def _cdf_distance(p, u_values, v_values, u_weights=None, v_weights=None):
|
|
232
|
+
r"""
|
|
233
|
+
Compute, between two one-dimensional distributions :math:`u` and
|
|
234
|
+
:math:`v`, whose respective CDFs are :math:`U` and :math:`V`, the
|
|
235
|
+
statistical distance that is defined as:
|
|
236
|
+
|
|
237
|
+
.. math::
|
|
238
|
+
|
|
239
|
+
l_p(u, v) = \left( \int_{-\infty}^{+\infty} |U-V|^p \right)^{1/p}
|
|
240
|
+
|
|
241
|
+
p is a positive parameter; p = 1 gives the Wasserstein distance, p = 2
|
|
242
|
+
gives the energy distance.
|
|
243
|
+
|
|
244
|
+
Parameters
|
|
245
|
+
----------
|
|
246
|
+
u_values, v_values : array_like
|
|
247
|
+
Values observed in the (empirical) distribution.
|
|
248
|
+
u_weights, v_weights : array_like, optional
|
|
249
|
+
Weight for each value. If unspecified, each value is assigned the same
|
|
250
|
+
weight.
|
|
251
|
+
`u_weights` (resp. `v_weights`) must have the same length as
|
|
252
|
+
`u_values` (resp. `v_values`). If the weight sum differs from 1, it
|
|
253
|
+
must still be positive and finite so that the weights can be normalized
|
|
254
|
+
to sum to 1.
|
|
255
|
+
|
|
256
|
+
Returns
|
|
257
|
+
-------
|
|
258
|
+
distance : float
|
|
259
|
+
The computed distance between the distributions.
|
|
260
|
+
|
|
261
|
+
Notes
|
|
262
|
+
-----
|
|
263
|
+
The input distributions can be empirical, therefore coming from samples
|
|
264
|
+
whose values are effectively inputs of the function, or they can be seen as
|
|
265
|
+
generalized functions, in which case they are weighted sums of Dirac delta
|
|
266
|
+
functions located at the specified values.
|
|
267
|
+
|
|
268
|
+
References
|
|
269
|
+
----------
|
|
270
|
+
.. [1] Bellemare, Danihelka, Dabney, Mohamed, Lakshminarayanan, Hoyer,
|
|
271
|
+
Munos "The Cramer Distance as a Solution to Biased Wasserstein
|
|
272
|
+
Gradients" (2017). :arXiv:`1705.10743`.
|
|
273
|
+
|
|
274
|
+
"""
|
|
275
|
+
u_values, u_weights = _validate_distribution(u_values, u_weights)
|
|
276
|
+
v_values, v_weights = _validate_distribution(v_values, v_weights)
|
|
277
|
+
|
|
278
|
+
u_sorter = np.argsort(u_values)
|
|
279
|
+
v_sorter = np.argsort(v_values)
|
|
280
|
+
|
|
281
|
+
all_values = np.concatenate((u_values, v_values))
|
|
282
|
+
all_values.sort(kind='mergesort')
|
|
283
|
+
|
|
284
|
+
# Compute the differences between pairs of successive values of u and v.
|
|
285
|
+
deltas = np.diff(all_values)
|
|
286
|
+
|
|
287
|
+
# Get the respective positions of the values of u and v among the values of
|
|
288
|
+
# both distributions.
|
|
289
|
+
u_cdf_indices = u_values[u_sorter].searchsorted(all_values[:-1], 'right')
|
|
290
|
+
v_cdf_indices = v_values[v_sorter].searchsorted(all_values[:-1], 'right')
|
|
291
|
+
|
|
292
|
+
# Calculate the CDFs of u and v using their weights, if specified.
|
|
293
|
+
if u_weights is None:
|
|
294
|
+
u_cdf = u_cdf_indices / u_values.size
|
|
295
|
+
else:
|
|
296
|
+
u_sorted_cumweights = np.concatenate(([0],
|
|
297
|
+
np.cumsum(u_weights[u_sorter])))
|
|
298
|
+
u_cdf = u_sorted_cumweights[u_cdf_indices] / u_sorted_cumweights[-1]
|
|
299
|
+
|
|
300
|
+
if v_weights is None:
|
|
301
|
+
v_cdf = v_cdf_indices / v_values.size
|
|
302
|
+
else:
|
|
303
|
+
v_sorted_cumweights = np.concatenate(([0],
|
|
304
|
+
np.cumsum(v_weights[v_sorter])))
|
|
305
|
+
v_cdf = v_sorted_cumweights[v_cdf_indices] / v_sorted_cumweights[-1]
|
|
306
|
+
|
|
307
|
+
# Compute the value of the integral based on the CDFs.
|
|
308
|
+
# If p = 1 or p = 2, we avoid using np.power, which introduces an overhead
|
|
309
|
+
# of about 15%.
|
|
310
|
+
if p == 1:
|
|
311
|
+
return np.sum(np.multiply(np.abs(u_cdf - v_cdf), deltas))
|
|
312
|
+
if p == 2:
|
|
313
|
+
return np.sqrt(np.sum(np.multiply(np.square(u_cdf - v_cdf), deltas)))
|
|
314
|
+
return np.power(np.sum(np.multiply(np.power(np.abs(u_cdf - v_cdf), p),
|
|
315
|
+
deltas)), 1/p)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _validate_distribution(values, weights):
|
|
319
|
+
"""
|
|
320
|
+
Validate the values and weights from a distribution input of `cdf_distance`
|
|
321
|
+
and return them as ndarray objects.
|
|
322
|
+
|
|
323
|
+
Parameters
|
|
324
|
+
----------
|
|
325
|
+
values : array_like
|
|
326
|
+
Values observed in the (empirical) distribution.
|
|
327
|
+
weights : array_like
|
|
328
|
+
Weight for each value.
|
|
329
|
+
|
|
330
|
+
Returns
|
|
331
|
+
-------
|
|
332
|
+
values : ndarray
|
|
333
|
+
Values as ndarray.
|
|
334
|
+
weights : ndarray
|
|
335
|
+
Weights as ndarray.
|
|
336
|
+
|
|
337
|
+
"""
|
|
338
|
+
# Validate the value array.
|
|
339
|
+
values = np.asarray(values, dtype=float)
|
|
340
|
+
if len(values) == 0:
|
|
341
|
+
raise ValueError("Distribution can't be empty.")
|
|
342
|
+
|
|
343
|
+
# Validate the weight array, if specified.
|
|
344
|
+
if weights is not None:
|
|
345
|
+
weights = np.asarray(weights, dtype=float)
|
|
346
|
+
if len(weights) != len(values):
|
|
347
|
+
raise ValueError('Value and weight array-likes for the same '
|
|
348
|
+
'empirical distribution must be of the same size.')
|
|
349
|
+
if np.any(weights < 0):
|
|
350
|
+
raise ValueError('All weights must be non-negative.')
|
|
351
|
+
if not 0 < np.sum(weights) < np.inf:
|
|
352
|
+
raise ValueError('Weight array-like sum must be positive and '
|
|
353
|
+
'finite. Set as None for an equal distribution of '
|
|
354
|
+
'weight.')
|
|
355
|
+
|
|
356
|
+
return values, weights
|
|
357
|
+
|
|
358
|
+
return values, None
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
import loompy
|
|
6
|
+
from .loompy_utils import create_empty_loom_file
|
|
7
|
+
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cell_wise_log_normalization(
|
|
14
|
+
path_to_loom: str,
|
|
15
|
+
path_to_normalized_loom: Optional[str] = '',
|
|
16
|
+
chunk_size: Optional[int] = -1,
|
|
17
|
+
verbose: Optional[bool] = False
|
|
18
|
+
) -> str:
|
|
19
|
+
"""
|
|
20
|
+
Perform cell-wise log-normalization on the loom file at path_to_loom. The normalized loom file is saved at
|
|
21
|
+
path_to_normalized_loom. If path_to_normalized_loom is not provided, the normalized loom file is saved in the same
|
|
22
|
+
directory as the original loom file with the suffix '_CellWiseNormalized.loom'.
|
|
23
|
+
|
|
24
|
+
If the system you are running this on has limited memory, you can set the chunk_size to a lower value to reduce the
|
|
25
|
+
memory usage. If the chunk_size is set to zero or a negative number, the complete matrix is loaded into memory.
|
|
26
|
+
|
|
27
|
+
:param path_to_loom: Path to the loom file that should be normalized
|
|
28
|
+
:param path_to_normalized_loom: Path where the normalized loom file should be saved (default: same directory as the
|
|
29
|
+
original loom file with the suffix '_CellWiseNormalized.loom')
|
|
30
|
+
:param chunk_size: Number of columns that are loaded into memory at once (default: -1)
|
|
31
|
+
:param verbose: If True, the progress is printed to the console (default: False)
|
|
32
|
+
:return: Path to the cell-wise log-normalized loom file. If path_to_normalized_loom is provided, the same path is
|
|
33
|
+
returned.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
if not os.path.isfile(path_to_loom):
|
|
37
|
+
raise FileNotFoundError(f"File {path_to_loom} not found")
|
|
38
|
+
|
|
39
|
+
if not path_to_loom.endswith('.loom'):
|
|
40
|
+
raise ValueError('The file must be a loom file')
|
|
41
|
+
|
|
42
|
+
# if path_to_normalized_loom is not provided, save the normalized loom file in the same directory as the original
|
|
43
|
+
# loom file with the suffix '_CellWiseNormalized.loom'
|
|
44
|
+
if path_to_normalized_loom == '' or path_to_normalized_loom is None:
|
|
45
|
+
path_to_normalized_loom: str = path_to_loom.replace(".loom", "_CellWiseNormalized.loom")
|
|
46
|
+
|
|
47
|
+
with loompy.connect(path_to_loom, mode='r') as src:
|
|
48
|
+
n_rows, n_cols = src.shape
|
|
49
|
+
|
|
50
|
+
# if the chunk_size is set to zero or a negative number, we load the complete matrix into memory
|
|
51
|
+
if chunk_size <= 0:
|
|
52
|
+
chunk_size = n_cols
|
|
53
|
+
|
|
54
|
+
# Create a new loom file with the same row and column attributes as the original loom file
|
|
55
|
+
create_empty_loom_file(
|
|
56
|
+
path_to_loom=path_to_normalized_loom,
|
|
57
|
+
shape=(n_rows, n_cols),
|
|
58
|
+
row_attrs=src.ra,
|
|
59
|
+
col_attrs=src.ca,
|
|
60
|
+
dtype_to_use=np.float32
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# Connect to the new loom file in read/write mode and perform normalization
|
|
64
|
+
with loompy.connect(path_to_normalized_loom, mode='r+') as dst:
|
|
65
|
+
if verbose:
|
|
66
|
+
print('Cell-Wise Log-Normalization:')
|
|
67
|
+
|
|
68
|
+
for i in tqdm(range(0, n_cols, chunk_size), disable=(not verbose)):
|
|
69
|
+
end_i: int = min(i + chunk_size, n_cols)
|
|
70
|
+
|
|
71
|
+
data = src[:, i:end_i]
|
|
72
|
+
|
|
73
|
+
column_sum: np.array = np.sum(data, axis=0)
|
|
74
|
+
column_sum[column_sum == 0] = 1
|
|
75
|
+
log_norm: np.array = np.log1p(10_000 * (data / column_sum[None, :]))
|
|
76
|
+
|
|
77
|
+
dst[:, i:end_i] = log_norm
|
|
78
|
+
|
|
79
|
+
return path_to_normalized_loom
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def subject_wise_max_normalization(
|
|
83
|
+
path_to_loom: str,
|
|
84
|
+
subject_label_column: str,
|
|
85
|
+
path_to_normalized_loom: Optional[str] = '',
|
|
86
|
+
chunk_size: Optional[int] = -1
|
|
87
|
+
) -> str:
|
|
88
|
+
"""
|
|
89
|
+
Perform subject-wise max-normalization on the loom file at path_to_loom. The normalized loom file is saved at
|
|
90
|
+
path_to_normalized_loom. If path_to_normalized_loom is not provided, the normalized loom file is saved in the same
|
|
91
|
+
directory as the original loom file with the suffix '_MaxNormalized.loom'.
|
|
92
|
+
|
|
93
|
+
If the system you are running this on has limited memory, you can set the chunk_size to a lower value to reduce the
|
|
94
|
+
memory usage. If the chunk_size is set to zero or a negative number, the complete matrix is loaded into memory.
|
|
95
|
+
|
|
96
|
+
:param path_to_loom: Path to the loom file that should be normalized
|
|
97
|
+
:param subject_label_column: Column name in the column attributes that contains the subject IDs
|
|
98
|
+
:param path_to_normalized_loom: Path where the normalized loom file should be saved (default: same directory as the
|
|
99
|
+
original loom file with the suffix '_MaxNormalized.loom')
|
|
100
|
+
:param chunk_size: Number of columns that are loaded into memory at once (default: -1)
|
|
101
|
+
:return: Path to the subject-wise max-normalized loom file. If path_to_normalized_loom is provided, the same path is
|
|
102
|
+
returned.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
if not os.path.isfile(path_to_loom):
|
|
106
|
+
raise FileNotFoundError(f"File {path_to_loom} not found")
|
|
107
|
+
|
|
108
|
+
if not path_to_loom.endswith('.loom'):
|
|
109
|
+
raise ValueError('The file must be a loom file')
|
|
110
|
+
|
|
111
|
+
# if path_to_normalized_loom is not provided, save the normalized loom file in the same directory as the original
|
|
112
|
+
# loom file with the suffix '_MaxNormalized.loom'
|
|
113
|
+
if path_to_normalized_loom == '' or path_to_normalized_loom is None:
|
|
114
|
+
path_to_normalized_loom: str = path_to_loom.replace(".loom", "_MaxNormalized.loom")
|
|
115
|
+
|
|
116
|
+
with loompy.connect(path_to_loom, mode='r') as src:
|
|
117
|
+
n_rows, n_cols = src.shape
|
|
118
|
+
|
|
119
|
+
if chunk_size is None or chunk_size < 0:
|
|
120
|
+
chunk_size = n_cols
|
|
121
|
+
|
|
122
|
+
# Pass 0: collect subjects and build index groups (no I/O on matrix)
|
|
123
|
+
subjects = np.asarray(src.ca[subject_label_column])
|
|
124
|
+
# Make deterministic order of subjects for reproducibility
|
|
125
|
+
uniq_subjects, inv = np.unique(subjects, return_inverse=True)
|
|
126
|
+
# inv[j] gives the index in uniq_subjects for column j
|
|
127
|
+
|
|
128
|
+
# Pass 1: compute per-column maxima, streaming in chunks:
|
|
129
|
+
col_max = np.empty(n_cols, dtype=np.float32)
|
|
130
|
+
for i in tqdm(range(0, n_cols, chunk_size), desc='Computing column maxima'):
|
|
131
|
+
end_i = min(i + chunk_size, n_cols)
|
|
132
|
+
X = src[:, i:end_i].astype(np.float32, copy=False)
|
|
133
|
+
col_max[i:end_i] = np.max(X, axis=0)
|
|
134
|
+
del X
|
|
135
|
+
|
|
136
|
+
# Reduce per-subject: max across the subject's columns
|
|
137
|
+
subj_max = np.full(uniq_subjects.shape[0], -np.inf, dtype=np.float32)
|
|
138
|
+
|
|
139
|
+
# vectorized max-by-group using bincount trick
|
|
140
|
+
# We can’t max with bincount directly, so do a simple loop over subjects on col_max indices
|
|
141
|
+
# but that loop is tiny (n_subjects), not n_rows*n_cols
|
|
142
|
+
for s_idx in range(uniq_subjects.shape[0]):
|
|
143
|
+
# columns belonging to subject s_idx
|
|
144
|
+
mask = (inv == s_idx)
|
|
145
|
+
if not np.any(mask):
|
|
146
|
+
subj_max[s_idx] = 1 # just to be sure, but should not happen
|
|
147
|
+
else:
|
|
148
|
+
m = col_max[mask].max()
|
|
149
|
+
subj_max[s_idx] = m if m > 0 else 1 # m <= 0 should not happen, but just in case
|
|
150
|
+
|
|
151
|
+
# Map each column to its subject's max value
|
|
152
|
+
col_scale = subj_max[inv]
|
|
153
|
+
|
|
154
|
+
# Create the new loom file with the same row and column attributes as the original loom file
|
|
155
|
+
create_empty_loom_file(
|
|
156
|
+
path_to_loom=path_to_normalized_loom,
|
|
157
|
+
shape=(n_rows, n_cols),
|
|
158
|
+
row_attrs=src.ra,
|
|
159
|
+
col_attrs=src.ca,
|
|
160
|
+
dtype_to_use=np.float32
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Pass 2: write normalized data in chunks (in-place operation and in float32)
|
|
164
|
+
with loompy.connect(path_to_normalized_loom, mode='r+') as dst:
|
|
165
|
+
for i in tqdm(range(0, n_cols, chunk_size), desc='Normalizing data'):
|
|
166
|
+
end_i = min(i + chunk_size, n_cols)
|
|
167
|
+
X = src[:, i:end_i].astype(np.float32, copy=False)
|
|
168
|
+
|
|
169
|
+
# Normalize by the subject's max value
|
|
170
|
+
X /= col_scale[i:end_i][None, :].astype(np.float32, copy=False)
|
|
171
|
+
|
|
172
|
+
# Write the normalized data to the new loom file
|
|
173
|
+
dst[:, i:end_i] = X
|
|
174
|
+
|
|
175
|
+
del X
|
|
176
|
+
|
|
177
|
+
return path_to_normalized_loom
|