rectanglepy 0.0.39__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.
- rectanglepy/__init__.py +8 -0
- rectanglepy/data/hao1_annotations_small.csv +96 -0
- rectanglepy/data/hao1_counts_small.csv +6934 -0
- rectanglepy/data/small_fino_bulks.csv +3739 -0
- rectanglepy/pp/__init__.py +4 -0
- rectanglepy/pp/create_signature.py +456 -0
- rectanglepy/pp/rectangle_signature.py +80 -0
- rectanglepy/rectangle.py +117 -0
- rectanglepy/tl/__init__.py +3 -0
- rectanglepy/tl/deconvolution.py +333 -0
- rectanglepy-0.0.39.dist-info/METADATA +104 -0
- rectanglepy-0.0.39.dist-info/RECORD +14 -0
- rectanglepy-0.0.39.dist-info/WHEEL +4 -0
- rectanglepy-0.0.39.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from anndata import AnnData
|
|
4
|
+
from loguru import logger
|
|
5
|
+
from pandas import DataFrame, Series
|
|
6
|
+
from pydeseq2.dds import DeseqDataSet
|
|
7
|
+
from pydeseq2.ds import DeseqStats
|
|
8
|
+
from scipy.cluster.hierarchy import fcluster, linkage
|
|
9
|
+
from scipy.stats import pearsonr
|
|
10
|
+
from sklearn.metrics import silhouette_score
|
|
11
|
+
|
|
12
|
+
from rectanglepy.tl.deconvolution import solve_qp
|
|
13
|
+
|
|
14
|
+
from .rectangle_signature import RectangleSignatureResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _convert_to_cpm(count_sc_data):
|
|
18
|
+
return count_sc_data * 1e6 / np.sum(count_sc_data)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _create_condition_number_matrix(de_adjusted, pseudo_signature: pd.DataFrame, max_gene_number: int) -> pd.DataFrame:
|
|
22
|
+
genes = set()
|
|
23
|
+
for annotation in de_adjusted:
|
|
24
|
+
genes.update(_find_signature_genes(max_gene_number, de_adjusted[annotation]))
|
|
25
|
+
sliced_sc_data = pseudo_signature.loc[list(genes)]
|
|
26
|
+
return sliced_sc_data
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _create_condition_number_matrices(de_adjusted, pseudo_signature):
|
|
30
|
+
condition_number_matrices = []
|
|
31
|
+
de_adjusted_lengths = {annotation: len(de_adjusted[annotation]) for annotation in de_adjusted}
|
|
32
|
+
longest_de_analysis = max(de_adjusted_lengths.values())
|
|
33
|
+
|
|
34
|
+
loop_range = min(longest_de_analysis, 200)
|
|
35
|
+
range_minimum = 30
|
|
36
|
+
|
|
37
|
+
if loop_range < range_minimum:
|
|
38
|
+
range_minimum = 8
|
|
39
|
+
|
|
40
|
+
for i in range(range_minimum, loop_range):
|
|
41
|
+
condition_number_matrices.append(_create_condition_number_matrix(de_adjusted, pseudo_signature, i))
|
|
42
|
+
|
|
43
|
+
return condition_number_matrices, range_minimum
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _find_signature_genes(number_of_genes, de_result):
|
|
47
|
+
result_len = len(de_result)
|
|
48
|
+
if result_len > 0:
|
|
49
|
+
number_of_genes = min(number_of_genes, result_len)
|
|
50
|
+
de_result = de_result.sort_values(by=["log2_fc"], ascending=False)
|
|
51
|
+
return de_result["gene"][0:number_of_genes].values
|
|
52
|
+
return []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _create_linkage_matrix(signature: pd.DataFrame):
|
|
56
|
+
method = "complete"
|
|
57
|
+
metric = "euclidean"
|
|
58
|
+
return linkage((np.log(signature + 1)).T, method=method, metric=metric)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _calculate_silhouette_scores(signature, linkage_matrix, cluster_range):
|
|
62
|
+
scores = []
|
|
63
|
+
for num_clust in cluster_range:
|
|
64
|
+
scores.append(silhouette_score(signature, fcluster(linkage_matrix, t=num_clust, criterion="maxclust")))
|
|
65
|
+
return scores
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _calculate_cluster_range(number_of_cell_types: int) -> tuple[int, int]:
|
|
69
|
+
cluster_factor = 3
|
|
70
|
+
if number_of_cell_types > 12:
|
|
71
|
+
cluster_factor = 4
|
|
72
|
+
if number_of_cell_types > 20:
|
|
73
|
+
cluster_factor = 6
|
|
74
|
+
if number_of_cell_types > 50:
|
|
75
|
+
cluster_factor = 10
|
|
76
|
+
min_number_clusters = max(
|
|
77
|
+
3, number_of_cell_types - cluster_factor
|
|
78
|
+
) # we don't want to cluster too many cell types together
|
|
79
|
+
max_number_clusters = number_of_cell_types - 1 # we want to have at least one cluster wih multiple cell types
|
|
80
|
+
return min_number_clusters, max_number_clusters
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _create_fclusters(signature: pd.DataFrame, linkage_matrix) -> list[int]:
|
|
84
|
+
min_number_clusters, max_number_clusters = _calculate_cluster_range(len(signature.columns))
|
|
85
|
+
cluster_range = range(min_number_clusters, max_number_clusters)
|
|
86
|
+
|
|
87
|
+
scores = _calculate_silhouette_scores((np.log(signature + 1)).T, linkage_matrix, cluster_range)
|
|
88
|
+
cluster_number = scores.index(max(scores)) + min_number_clusters
|
|
89
|
+
clusters = fcluster(linkage_matrix, criterion="maxclust", t=cluster_number)
|
|
90
|
+
|
|
91
|
+
if len(set(clusters)) == 1:
|
|
92
|
+
# default clustering clustered all cell types in same cluster, fallback to distance metric
|
|
93
|
+
distance = linkage_matrix[0][2]
|
|
94
|
+
clusters = fcluster(linkage_matrix, criterion="distance", t=distance)
|
|
95
|
+
|
|
96
|
+
return list(clusters)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_fcluster_assignments(fclusters: list[int], signature_columns: pd.Index) -> list[int | str]:
|
|
100
|
+
assignments = []
|
|
101
|
+
clusters = list(fclusters)
|
|
102
|
+
for cluster, cell in zip(fclusters, signature_columns):
|
|
103
|
+
if clusters.count(cluster) > 1:
|
|
104
|
+
assignments.append(cluster)
|
|
105
|
+
else:
|
|
106
|
+
assignments.append(cell)
|
|
107
|
+
return assignments
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _create_annotations_from_cluster_labels(labels, annotations, signature):
|
|
111
|
+
label_dict = dict(zip(signature.columns, labels))
|
|
112
|
+
cluster_annotations = [str(label_dict[x]) for x in annotations]
|
|
113
|
+
return pd.Series(cluster_annotations, index=annotations.index)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _filter_de_analysis_results(de_analysis_result, p, logfc):
|
|
117
|
+
min_log2FC = logfc
|
|
118
|
+
max_p = p
|
|
119
|
+
de_analysis_result["log2_fc"] = de_analysis_result["log2FoldChange"]
|
|
120
|
+
de_analysis_result["gene"] = de_analysis_result.index
|
|
121
|
+
adjusted_result = de_analysis_result[
|
|
122
|
+
(de_analysis_result["pvalue"] < max_p) & (de_analysis_result["log2_fc"] > min_log2FC)
|
|
123
|
+
]
|
|
124
|
+
# if increase p-value and decrease log2FC until genes are found or the threshold is reached
|
|
125
|
+
while len(adjusted_result) < 10 and (min_log2FC > 0.5 and max_p < 0.05):
|
|
126
|
+
min_log2FC = max(min_log2FC - 0.1, 0.5)
|
|
127
|
+
max_p = min(max_p + 0.001, 0.05)
|
|
128
|
+
adjusted_result = de_analysis_result[
|
|
129
|
+
(de_analysis_result["pvalue"] < max_p) & (de_analysis_result["log2_fc"] > min_log2FC)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
return adjusted_result
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_deseq2(countsig: pd.DataFrame, n_cpus: int = None) -> dict[str | int, pd.DataFrame]:
|
|
136
|
+
results = {}
|
|
137
|
+
count_df = countsig[countsig.sum(axis=1) > 0].T
|
|
138
|
+
for i, cell_type in enumerate(countsig.columns):
|
|
139
|
+
logger.info(f"Running DE analysis for {cell_type}")
|
|
140
|
+
condition = np.zeros(len(countsig.columns))
|
|
141
|
+
condition[i] = 1
|
|
142
|
+
clinical_df = pd.DataFrame({"condition": condition}, index=countsig.columns)
|
|
143
|
+
dds = DeseqDataSet(counts=count_df, metadata=clinical_df, design_factors="condition", quiet=True, n_cpus=n_cpus)
|
|
144
|
+
dds.deseq2()
|
|
145
|
+
dds.varm["LFC"] = dds.varm["LFC"].round(4)
|
|
146
|
+
dds.varm["dispersions"] = dds.varm["dispersions"].round(3)
|
|
147
|
+
|
|
148
|
+
stat_res = DeseqStats(dds, n_cpus=n_cpus, quiet=True)
|
|
149
|
+
stat_res.summary(quiet=True)
|
|
150
|
+
stat_res.lfc_shrink()
|
|
151
|
+
results[cell_type] = stat_res.results_df
|
|
152
|
+
|
|
153
|
+
return results
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _de_analysis(
|
|
157
|
+
pseudo_count_sig, sc_data, annotations, p, lfc, optimize_cutoffs: bool, n_cpus: int = None, genes=None
|
|
158
|
+
) -> tuple[Series, dict[str, int] :, DataFrame | None]:
|
|
159
|
+
logger.info("Starting DE analysis")
|
|
160
|
+
deseq_results = _run_deseq2(pseudo_count_sig, n_cpus)
|
|
161
|
+
optimization_results = None
|
|
162
|
+
|
|
163
|
+
if optimize_cutoffs:
|
|
164
|
+
logger.info("Optimizing cutoff parameters p and lfc")
|
|
165
|
+
optimization_results = _optimize_parameters(sc_data, annotations, pseudo_count_sig, deseq_results, genes)
|
|
166
|
+
p, lfc = optimization_results.iloc[0, 0:2]
|
|
167
|
+
logger.info(f"Optimization done\n Best cutoffs p: {p} and lfc: {lfc}")
|
|
168
|
+
|
|
169
|
+
markers, marker_genes_per_cell_type = _get_marker_genes(deseq_results, lfc, p, pseudo_count_sig)
|
|
170
|
+
return pd.Series(markers), marker_genes_per_cell_type, optimization_results
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _get_marker_genes(deseq_results, logfc, p, pseudo_count_sig):
|
|
174
|
+
de_analysis_adjusted = {
|
|
175
|
+
annotation: _filter_de_analysis_results(result, p, logfc) for annotation, result in deseq_results.items()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
pseudo_cpm_sig = _convert_to_cpm(pseudo_count_sig)
|
|
179
|
+
condition_number_matrices, range_minimum = _create_condition_number_matrices(de_analysis_adjusted, pseudo_cpm_sig)
|
|
180
|
+
condition_numbers = [np.linalg.cond(np.linalg.qr(x)[1], 1) for x in condition_number_matrices]
|
|
181
|
+
optimal_condition_index = condition_numbers.index(min(condition_numbers))
|
|
182
|
+
|
|
183
|
+
optimal_condition_number = optimal_condition_index + range_minimum
|
|
184
|
+
logger.info(f"Optimal condition number: {optimal_condition_number}")
|
|
185
|
+
optimal_condition_matrix = condition_number_matrices[optimal_condition_index]
|
|
186
|
+
|
|
187
|
+
markers = optimal_condition_matrix.index
|
|
188
|
+
marker_genes_per_cell_type = _count_marker_genes_per_cell_type(de_analysis_adjusted, optimal_condition_number)
|
|
189
|
+
return markers, marker_genes_per_cell_type
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _count_marker_genes_per_cell_type(de_analysis_adjusted, optimal_condition_number: int) -> dict[str, int]:
|
|
193
|
+
marker_genes_per_cell_type = {}
|
|
194
|
+
for annotation, result in de_analysis_adjusted.items():
|
|
195
|
+
marker_genes_per_cell_type[annotation] = min(len(result), optimal_condition_number)
|
|
196
|
+
logger.info(f"Number of marker genes per cell type: {str(marker_genes_per_cell_type)}")
|
|
197
|
+
return marker_genes_per_cell_type
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _create_bias_factors(countsig: pd.DataFrame, sc_counts: pd.DataFrame | np.ndarray, annotations) -> pd.Series:
|
|
201
|
+
number_of_expressed_genes = (sc_counts > 0).sum(axis=0)
|
|
202
|
+
number_of_expressed_genes = np.squeeze(np.asarray(number_of_expressed_genes))
|
|
203
|
+
bias_factors = [
|
|
204
|
+
np.mean(number_of_expressed_genes[[annotation == x for x in annotations]]) for annotation in countsig.columns
|
|
205
|
+
]
|
|
206
|
+
mRNA_bias = bias_factors / np.min(bias_factors)
|
|
207
|
+
return pd.Series(mRNA_bias, index=countsig.columns)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _create_clustered_data(
|
|
211
|
+
pseudo_sig_cpm: pd.DataFrame, marker_genes, annotations: pd.Series, sc_counts: pd.DataFrame | np.ndarray, genes
|
|
212
|
+
) -> (pd.DataFrame, pd.Series, list[int | str]):
|
|
213
|
+
if len(pseudo_sig_cpm.columns) < 5:
|
|
214
|
+
logger.info("Not enough cell types to perform clustering, returning direct rectangle signature")
|
|
215
|
+
return pd.DataFrame(), pd.Series(), []
|
|
216
|
+
|
|
217
|
+
linkage_matrix = _create_linkage_matrix(pseudo_sig_cpm.loc[marker_genes])
|
|
218
|
+
|
|
219
|
+
clusters = _create_fclusters(pseudo_sig_cpm.loc[marker_genes], linkage_matrix)
|
|
220
|
+
|
|
221
|
+
if len(set(clusters)) <= 2:
|
|
222
|
+
logger.info("Not enough clusters to perform clustered signature analysis, returning direct rectangle signature")
|
|
223
|
+
return pd.DataFrame(), pd.Series(), []
|
|
224
|
+
|
|
225
|
+
logger.info("Starting clustered analysis")
|
|
226
|
+
|
|
227
|
+
assignments = _get_fcluster_assignments(clusters, pseudo_sig_cpm.columns)
|
|
228
|
+
clustered_annotations = _create_annotations_from_cluster_labels(assignments, annotations, pseudo_sig_cpm)
|
|
229
|
+
|
|
230
|
+
clustered_signature = _create_pseudo_count_sig(sc_counts, clustered_annotations, genes)
|
|
231
|
+
|
|
232
|
+
return clustered_signature, clustered_annotations, assignments
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def build_rectangle_signatures(
|
|
236
|
+
adata: AnnData,
|
|
237
|
+
cell_type_col: str = "cell_type",
|
|
238
|
+
bulks: pd.DataFrame = None,
|
|
239
|
+
*,
|
|
240
|
+
optimize_cutoffs=True,
|
|
241
|
+
layer: str = None,
|
|
242
|
+
raw: bool = False,
|
|
243
|
+
p=0.015,
|
|
244
|
+
lfc=1.5,
|
|
245
|
+
subsample: bool = False,
|
|
246
|
+
sample_size: int = 1500,
|
|
247
|
+
n_cpus: int = None,
|
|
248
|
+
) -> RectangleSignatureResult:
|
|
249
|
+
r"""Builds rectangle signatures based on single-cell count data and annotations.
|
|
250
|
+
|
|
251
|
+
Parameters
|
|
252
|
+
----------
|
|
253
|
+
adata
|
|
254
|
+
The single-cell count data as a DataFrame. DataFrame must have the genes as index and cell identifier as columns. Each entry should be in raw counts.
|
|
255
|
+
bulks
|
|
256
|
+
The bulk data as a DataFrame. DataFrame must have the bulk identifier as index and the genes as columns. Each entry should be in transcripts per million (TPM).
|
|
257
|
+
cell_type_col
|
|
258
|
+
The annotations corresponding to the single-cell count data. Series data should have the cell identifier as index and the annotations as values.
|
|
259
|
+
layer
|
|
260
|
+
The Anndata layer to use for the single-cell data. Defaults to None.
|
|
261
|
+
raw
|
|
262
|
+
A flag indicating whether to use the raw Anndata data. Defaults to False.
|
|
263
|
+
subsample : bool
|
|
264
|
+
A flag indicating whether to balance the single-cell data. Defaults to False.
|
|
265
|
+
sample_size : int
|
|
266
|
+
The number of cells to balance the single-cell data to. Defaults to 1500. If cell number is less than this number it takes the original number of cells.
|
|
267
|
+
optimize_cutoffs
|
|
268
|
+
Indicates whether to optimize the p-value and log fold change cutoffs using gridsearch. Defaults to True.
|
|
269
|
+
p
|
|
270
|
+
The p-value threshold for the DE analysis (only used if optimize_cutoffs is False).
|
|
271
|
+
lfc
|
|
272
|
+
The log fold change threshold for the DE analysis (only used if optimize_cutoffs is False).
|
|
273
|
+
n_cpus
|
|
274
|
+
The number of cpus to use for the DE analysis. Defaults to the number of cpus available.
|
|
275
|
+
|
|
276
|
+
Returns
|
|
277
|
+
-------
|
|
278
|
+
The result of the rectangle signature analysis which is of type RectangleSignatureResult.
|
|
279
|
+
"""
|
|
280
|
+
if bulks is not None:
|
|
281
|
+
genes = list(set(bulks.columns) & set(adata.var_names))
|
|
282
|
+
genes = sorted(genes)
|
|
283
|
+
assert len(genes) > 0, "No common genes between bulks and single-cell data"
|
|
284
|
+
logger.info(f"Using {len(genes)} common genes between bulks and single-cell data")
|
|
285
|
+
adata = adata[:, genes]
|
|
286
|
+
|
|
287
|
+
annotations = adata.obs[cell_type_col]
|
|
288
|
+
if subsample:
|
|
289
|
+
annotations = _even(annotations, sample_size)
|
|
290
|
+
adata = adata[annotations.index]
|
|
291
|
+
|
|
292
|
+
if layer is not None:
|
|
293
|
+
sc_counts = adata.layers[layer]
|
|
294
|
+
elif raw:
|
|
295
|
+
sc_counts = adata.raw.X
|
|
296
|
+
else:
|
|
297
|
+
sc_counts = adata.X
|
|
298
|
+
|
|
299
|
+
sc_counts = sc_counts.T
|
|
300
|
+
|
|
301
|
+
genes = adata.var_names
|
|
302
|
+
|
|
303
|
+
pseudo_sig_counts = _create_pseudo_count_sig(sc_counts, annotations, genes)
|
|
304
|
+
m_rna_biasfactors = _create_bias_factors(pseudo_sig_counts, sc_counts, annotations)
|
|
305
|
+
|
|
306
|
+
marker_genes, marker_genes_per_cell_type, optimization_result = _de_analysis(
|
|
307
|
+
pseudo_sig_counts, sc_counts, annotations, p, lfc, optimize_cutoffs, n_cpus, genes
|
|
308
|
+
)
|
|
309
|
+
pseudo_sig_cpm = _convert_to_cpm(pseudo_sig_counts)
|
|
310
|
+
logger.info("Starting rectangle cluster analysis")
|
|
311
|
+
|
|
312
|
+
clustered_signature, clustered_annotations, assignments = _create_clustered_data(
|
|
313
|
+
pseudo_sig_cpm, marker_genes, annotations, sc_counts, genes
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
if len(clustered_signature) == 0:
|
|
317
|
+
return RectangleSignatureResult(
|
|
318
|
+
signature_genes=marker_genes,
|
|
319
|
+
pseudobulk_sig_cpm=pseudo_sig_cpm,
|
|
320
|
+
bias_factors=m_rna_biasfactors,
|
|
321
|
+
marker_genes_per_cell_type=marker_genes_per_cell_type,
|
|
322
|
+
optimization_result=optimization_result,
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
clustered_biasfact = _create_bias_factors(clustered_signature, sc_counts, clustered_annotations)
|
|
326
|
+
clustered_genes = _de_analysis(clustered_signature, sc_counts, clustered_annotations, p, lfc, False)[0]
|
|
327
|
+
clustered_signature = _convert_to_cpm(clustered_signature)
|
|
328
|
+
return RectangleSignatureResult(
|
|
329
|
+
signature_genes=marker_genes,
|
|
330
|
+
pseudobulk_sig_cpm=pseudo_sig_cpm,
|
|
331
|
+
bias_factors=m_rna_biasfactors,
|
|
332
|
+
marker_genes_per_cell_type=marker_genes_per_cell_type,
|
|
333
|
+
optimization_result=optimization_result,
|
|
334
|
+
clustered_pseudobulk_sig_cpm=clustered_signature,
|
|
335
|
+
clustered_signature_genes=clustered_genes,
|
|
336
|
+
clustered_bias_factors=clustered_biasfact,
|
|
337
|
+
cluster_assignments=assignments,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _create_pseudo_count_sig(sc_counts: np.ndarray, annotations: pd.Series, var_names) -> pd.DataFrame:
|
|
342
|
+
unique_labels, label_indices = np.unique(annotations, return_inverse=True)
|
|
343
|
+
grouped_sum = np.zeros((len(unique_labels), sc_counts.shape[0]))
|
|
344
|
+
for i, _label in enumerate(unique_labels):
|
|
345
|
+
label_columns = label_indices == i
|
|
346
|
+
grouped_sum[i, :] = np.sum(sc_counts.T[label_columns, :], axis=0)
|
|
347
|
+
grouped_sum = grouped_sum.T
|
|
348
|
+
|
|
349
|
+
grouped_sum = pd.DataFrame(grouped_sum, index=var_names, columns=unique_labels).astype(int)
|
|
350
|
+
return grouped_sum
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _optimize_parameters(
|
|
354
|
+
sc_data: pd.DataFrame, annotations: pd.Series, pseudo_signature_counts: pd.DataFrame, de_results, genes=None
|
|
355
|
+
) -> pd.DataFrame:
|
|
356
|
+
# if there are many cell types we relax the cutoffs
|
|
357
|
+
lfcs = [x / 100 for x in range(140, 200, 10)]
|
|
358
|
+
ps = [x / 1000 for x in range(15, 20, 1)]
|
|
359
|
+
|
|
360
|
+
results = []
|
|
361
|
+
logger.info("generating pseudo bulks")
|
|
362
|
+
bulks, real_fractions = _generate_pseudo_bulks(sc_data, annotations, genes)
|
|
363
|
+
for p in ps:
|
|
364
|
+
for lfc in lfcs:
|
|
365
|
+
rmse, pearson_r = _assess_parameter_fit(lfc, p, bulks, real_fractions, pseudo_signature_counts, de_results)
|
|
366
|
+
logger.info(f"RMSE:{rmse}, Pearson R:{pearson_r} for p={p}, lfc={lfc}")
|
|
367
|
+
results.append({"p": p, "lfc": lfc, "rmse": rmse, "pearson_r": pearson_r})
|
|
368
|
+
|
|
369
|
+
results_df = pd.DataFrame(results)
|
|
370
|
+
results_df = results_df.sort_values(by=["pearson_r", "rmse"], ascending=[False, True])
|
|
371
|
+
|
|
372
|
+
return results_df
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _assess_parameter_fit(
|
|
376
|
+
lfc: float, p: float, bulks, real_fractions, pseudo_signature_counts, de_results
|
|
377
|
+
) -> (float, float):
|
|
378
|
+
estimated_fractions = _generate_estimated_fractions(pseudo_signature_counts, bulks, p, lfc, de_results)
|
|
379
|
+
|
|
380
|
+
real_fractions = real_fractions.sort_index()
|
|
381
|
+
|
|
382
|
+
estimated_fractions = estimated_fractions.sort_index()
|
|
383
|
+
|
|
384
|
+
rsme = np.sqrt(np.mean((real_fractions - estimated_fractions) ** 2))
|
|
385
|
+
pearson_r = pearsonr(real_fractions.values.flatten(), estimated_fractions.values.flatten())[0]
|
|
386
|
+
return rsme, pearson_r
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _generate_pseudo_bulks(sc_data, annotations, genes=None):
|
|
390
|
+
number_of_bulks = 50
|
|
391
|
+
split_size = 50
|
|
392
|
+
bulks = []
|
|
393
|
+
real_fractions = []
|
|
394
|
+
np.random.seed(42)
|
|
395
|
+
for _ in range(number_of_bulks):
|
|
396
|
+
indices = []
|
|
397
|
+
cell_numbers = []
|
|
398
|
+
for annotation in annotations.unique():
|
|
399
|
+
annotation_indices = annotations[annotations == annotation].index
|
|
400
|
+
upper_limit = min(split_size, len(annotation_indices))
|
|
401
|
+
number_of_cells = np.random.randint(0, upper_limit)
|
|
402
|
+
cell_numbers.append(number_of_cells)
|
|
403
|
+
random_annotations = np.random.choice(annotation_indices, number_of_cells, replace=False)
|
|
404
|
+
random_annotations_indices = [annotations.index.get_loc(x) for x in random_annotations]
|
|
405
|
+
indices.extend(random_annotations_indices)
|
|
406
|
+
|
|
407
|
+
random_cells = sc_data[:, indices] # Select columns by indices for ndarray
|
|
408
|
+
random_cells_sum = random_cells.sum(axis=1)
|
|
409
|
+
random_cells_sum = np.squeeze(np.asarray(random_cells_sum))
|
|
410
|
+
|
|
411
|
+
pseudo_bulk = random_cells_sum * 1e6 / np.sum(random_cells_sum)
|
|
412
|
+
bulks.append(pseudo_bulk)
|
|
413
|
+
|
|
414
|
+
cell_fractions = np.array(cell_numbers) / np.sum(cell_numbers)
|
|
415
|
+
cell_fractions = pd.Series(cell_fractions, index=annotations.unique())
|
|
416
|
+
real_fractions.append(cell_fractions)
|
|
417
|
+
bulks = pd.DataFrame(bulks).T
|
|
418
|
+
if genes is not None:
|
|
419
|
+
bulks.index = genes
|
|
420
|
+
return bulks, pd.DataFrame(real_fractions).T
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _generate_estimated_fractions(pseudo_bulk_sig, bulks, p, logfc, de_results):
|
|
424
|
+
marker_genes = pd.Series(_get_marker_genes(de_results, logfc, p, pseudo_bulk_sig)[0])
|
|
425
|
+
signature = (pseudo_bulk_sig * 1e6 / np.sum(pseudo_bulk_sig)).loc[marker_genes]
|
|
426
|
+
|
|
427
|
+
estimated_fractions = bulks.apply(lambda x: _solve_quadratic_programming(signature, x), axis=0)
|
|
428
|
+
estimated_fractions.index = signature.columns
|
|
429
|
+
|
|
430
|
+
return estimated_fractions
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _solve_quadratic_programming(signature, bulk):
|
|
434
|
+
genes = list(set(signature.index) & set(bulk.index))
|
|
435
|
+
signature = signature.loc[genes].sort_index()
|
|
436
|
+
bulk = bulk.loc[genes].sort_index().astype("double")
|
|
437
|
+
|
|
438
|
+
return solve_qp(signature, bulk)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _reduce_to_common_genes(bulks: pd.DataFrame, sc_data: pd.DataFrame):
|
|
442
|
+
genes = list(set(bulks.index) & set(sc_data.index))
|
|
443
|
+
sc_data = sc_data.loc[genes].sort_index()
|
|
444
|
+
bulks = bulks.loc[genes].sort_index()
|
|
445
|
+
return bulks, sc_data
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _even(annotations: pd.Series, number: int) -> pd.Series:
|
|
449
|
+
assert number > 0, "Number of cells must be greater than 0"
|
|
450
|
+
annotation_counts = annotations.value_counts()
|
|
451
|
+
selected_cells = []
|
|
452
|
+
for annotation in annotation_counts.index:
|
|
453
|
+
cells = annotations[annotations == annotation].index
|
|
454
|
+
cells = np.random.choice(cells, min(number, len(cells)), replace=False)
|
|
455
|
+
selected_cells.extend(cells)
|
|
456
|
+
return annotations.loc[selected_cells]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class RectangleSignatureResult:
|
|
5
|
+
"""Represents the result of a rectangle signature analysis (Created by the method pp.build_rectangle_signatures).
|
|
6
|
+
|
|
7
|
+
Parameters
|
|
8
|
+
----------
|
|
9
|
+
signature_genes
|
|
10
|
+
The signature genes as a pd.Series.
|
|
11
|
+
bias_factors
|
|
12
|
+
The mRNA bias factors associated with each cell type.
|
|
13
|
+
pseudobulk_sig_cpm
|
|
14
|
+
The pseudo bulk signature build from the single cell data, contains all genes. Normalized to CPM. Columns are cell types, rows are genes.
|
|
15
|
+
clustered_pseudobulk_sig_cpm
|
|
16
|
+
The clustered pseudo bulk signature build from the single cell data, contains all genes. Normalized to CPM. Columns are cell types, rows are genes.
|
|
17
|
+
clustered_bias_factors
|
|
18
|
+
The bias factors associated with each cell type cluster.
|
|
19
|
+
cluster_assignments
|
|
20
|
+
The assignments of signature cell-types to clusters, as a list of ints or strings. In the same order as the pseudobulk_sig_cpm columns.
|
|
21
|
+
marker_genes_per_cell_type
|
|
22
|
+
The number of marker genes per cell type, as a dictionary. Keys are cell type names, values are the number of marker genes.
|
|
23
|
+
optimization_result
|
|
24
|
+
The result of the p lfc cut off optimization, as a pd.DataFrame. Contains the following columns: p, lfc, pearson_r, rsme
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
signature_genes: pd.Series,
|
|
30
|
+
bias_factors: pd.Series,
|
|
31
|
+
pseudobulk_sig_cpm: pd.DataFrame,
|
|
32
|
+
marker_genes_per_cell_type: dict[str, int],
|
|
33
|
+
optimization_result: pd.DataFrame = None,
|
|
34
|
+
clustered_pseudobulk_sig_cpm: pd.DataFrame = None,
|
|
35
|
+
clustered_bias_factors: pd.Series = None,
|
|
36
|
+
clustered_signature_genes: pd.Series = None,
|
|
37
|
+
cluster_assignments: list[int or str] = None,
|
|
38
|
+
):
|
|
39
|
+
self.signature_genes = signature_genes
|
|
40
|
+
self.bias_factors = bias_factors
|
|
41
|
+
self.pseudobulk_sig_cpm = pseudobulk_sig_cpm
|
|
42
|
+
self.marker_genes_per_cell_type = marker_genes_per_cell_type
|
|
43
|
+
self.optimization_result = optimization_result
|
|
44
|
+
self.clustered_pseudobulk_sig_cpm = clustered_pseudobulk_sig_cpm
|
|
45
|
+
self.clustered_bias_factors = clustered_bias_factors
|
|
46
|
+
self.clustered_signature_genes = clustered_signature_genes
|
|
47
|
+
self.assignments = cluster_assignments
|
|
48
|
+
|
|
49
|
+
def cell_types_with_low_number_of_marker_genes(self) -> list[str]:
|
|
50
|
+
"""Returns the cell types with less than threshold marker genes.
|
|
51
|
+
|
|
52
|
+
Returns
|
|
53
|
+
-------
|
|
54
|
+
list[str]: The cell types with less than threshold marker genes.
|
|
55
|
+
|
|
56
|
+
"""
|
|
57
|
+
low_annotation_threshold = 30
|
|
58
|
+
return [
|
|
59
|
+
cell_type
|
|
60
|
+
for cell_type, count in self.marker_genes_per_cell_type.items()
|
|
61
|
+
if count < low_annotation_threshold
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
def get_signature_matrix(self, include_mrna_bias=True) -> pd.DataFrame:
|
|
65
|
+
"""Calculates the signature matrix by multiplying the pseudobulk_sig_cpm DataFrame subset by signature_genes and the bias_factors Series.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
include_mrna_bias
|
|
70
|
+
If True, the method includes mRNA bias in the calculation. Defaults to True.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
pandas.DataFrame: The signature matrix. Where columns are cell types and rows are genes.
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
if include_mrna_bias:
|
|
78
|
+
return self.pseudobulk_sig_cpm.loc[self.signature_genes] * self.bias_factors
|
|
79
|
+
else:
|
|
80
|
+
return self.pseudobulk_sig_cpm.loc[self.signature_genes]
|
rectanglepy/rectangle.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from anndata import AnnData
|
|
3
|
+
from loguru import logger
|
|
4
|
+
from pandas import DataFrame
|
|
5
|
+
from pkg_resources import resource_stream
|
|
6
|
+
|
|
7
|
+
from .pp import RectangleSignatureResult, build_rectangle_signatures
|
|
8
|
+
from .tl import deconvolution
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def rectangle(
|
|
12
|
+
adata: AnnData,
|
|
13
|
+
bulks: DataFrame,
|
|
14
|
+
cell_type_col: str = "cell_type",
|
|
15
|
+
*,
|
|
16
|
+
layer: str = None,
|
|
17
|
+
raw: bool = False,
|
|
18
|
+
subsample: bool = False,
|
|
19
|
+
sample_size: int = 1500,
|
|
20
|
+
consensus_runs: int = 1,
|
|
21
|
+
correct_mrna_bias: bool = True,
|
|
22
|
+
optimize_cutoffs=True,
|
|
23
|
+
p=0.015,
|
|
24
|
+
lfc=1.5,
|
|
25
|
+
n_cpus: int = None,
|
|
26
|
+
) -> tuple[DataFrame, RectangleSignatureResult]:
|
|
27
|
+
r"""Builds rectangle signatures based on single-cell count data and annotations.
|
|
28
|
+
|
|
29
|
+
Parameters
|
|
30
|
+
----------
|
|
31
|
+
adata
|
|
32
|
+
The single-cell count data as a DataFrame. DataFrame must have the genes as index and cell identifier as columns. Each entry should be in raw counts.
|
|
33
|
+
bulks
|
|
34
|
+
The bulk data as a DataFrame. DataFrame must have the bulk identifier as index and the genes as columns. Each entry should be in transcripts per million (TPM).
|
|
35
|
+
cell_type_col
|
|
36
|
+
The annotations corresponding to the single-cell count data. Series data should have the cell identifier as index and the annotations as values.
|
|
37
|
+
layer
|
|
38
|
+
The Anndata layer to use for the single-cell data. Defaults to None.
|
|
39
|
+
raw
|
|
40
|
+
A flag indicating whether to use the raw Anndata data. Defaults to False.
|
|
41
|
+
subsample : bool
|
|
42
|
+
A flag indicating whether to balance the single-cell data. Defaults to False.
|
|
43
|
+
sample_size : int
|
|
44
|
+
The number of cells to balance the single-cell data to. Defaults to 1500. If cell number is less than this number it takes the original number of cells.
|
|
45
|
+
consensus_runs : int
|
|
46
|
+
The number of consensus runs to perform. Defaults to 1 for a singular deconvolution run. Consensus runs are performed by subsampling the single-cell data and running the analysis multiple times. The results are then aggregated.
|
|
47
|
+
optimize_cutoffs
|
|
48
|
+
Indicates whether to optimize the p-value and log fold change cutoffs using gridsearch. Defaults to True.
|
|
49
|
+
p
|
|
50
|
+
The p-value threshold for the DE analysis (only used if optimize_cutoffs is False).
|
|
51
|
+
lfc
|
|
52
|
+
The log fold change threshold for the DE analysis (only used if optimize_cutoffs is False).
|
|
53
|
+
n_cpus
|
|
54
|
+
The number of cpus to use for the DE analysis. Defaults to the number of cpus available.
|
|
55
|
+
correct_mrna_bias : bool
|
|
56
|
+
A flag indicating whether to correct for mRNA bias. Defaults to True.
|
|
57
|
+
|
|
58
|
+
Returns
|
|
59
|
+
-------
|
|
60
|
+
DataFrame : The estimated cell fractions.
|
|
61
|
+
RectangleSignatureResult : The result of the rectangle signature analysis.
|
|
62
|
+
"""
|
|
63
|
+
assert isinstance(adata, AnnData), "adata must be an AnnData object"
|
|
64
|
+
assert isinstance(bulks, DataFrame), "bulks must be a DataFrame"
|
|
65
|
+
|
|
66
|
+
if bulks is not None:
|
|
67
|
+
genes = list(set(bulks.columns) & set(adata.var_names))
|
|
68
|
+
genes = sorted(genes)
|
|
69
|
+
adata = adata[:, genes]
|
|
70
|
+
bulks = bulks[genes]
|
|
71
|
+
|
|
72
|
+
if consensus_runs > 1:
|
|
73
|
+
logger.info(f"Running {consensus_runs} consensus runs with subsample size {sample_size}")
|
|
74
|
+
subsample = True
|
|
75
|
+
|
|
76
|
+
estimations = []
|
|
77
|
+
most_recent_signatures = None
|
|
78
|
+
|
|
79
|
+
for _i in range(consensus_runs):
|
|
80
|
+
logger.info(f"Running consensus run {_i + 1} of {consensus_runs}")
|
|
81
|
+
signatures = build_rectangle_signatures(
|
|
82
|
+
adata,
|
|
83
|
+
cell_type_col,
|
|
84
|
+
bulks=bulks,
|
|
85
|
+
optimize_cutoffs=optimize_cutoffs,
|
|
86
|
+
layer=layer,
|
|
87
|
+
raw=raw,
|
|
88
|
+
p=p,
|
|
89
|
+
lfc=lfc,
|
|
90
|
+
n_cpus=n_cpus,
|
|
91
|
+
subsample=subsample,
|
|
92
|
+
sample_size=sample_size,
|
|
93
|
+
)
|
|
94
|
+
cell_fractions = deconvolution(signatures, bulks, correct_mrna_bias, n_cpus)
|
|
95
|
+
estimations.append(cell_fractions)
|
|
96
|
+
most_recent_signatures = signatures
|
|
97
|
+
|
|
98
|
+
return pd.concat(estimations).groupby(level=0).median(), most_recent_signatures
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def load_tutorial_data() -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
|
|
102
|
+
"""Loads the single-cell count data, annotations, and bulk data from the tutorial.
|
|
103
|
+
|
|
104
|
+
Returns
|
|
105
|
+
-------
|
|
106
|
+
The single-cell count data, annotations, and bulk data.
|
|
107
|
+
"""
|
|
108
|
+
with resource_stream(__name__, "data/hao1_annotations_small.csv") as annotations_file:
|
|
109
|
+
annotations = pd.read_csv(annotations_file, index_col=0)["0"]
|
|
110
|
+
|
|
111
|
+
with resource_stream(__name__, "data/hao1_counts_small.csv") as counts_file:
|
|
112
|
+
sc_counts = pd.read_csv(counts_file, index_col=0).astype("int")
|
|
113
|
+
|
|
114
|
+
with resource_stream(__name__, "data/small_fino_bulks.csv") as bulks_file:
|
|
115
|
+
bulks = pd.read_csv(bulks_file, index_col=0)
|
|
116
|
+
|
|
117
|
+
return sc_counts.T, annotations, bulks.T
|