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.
@@ -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
@@ -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