Quick start

import rectanglepy as rectangle
import pandas as pd
from anndata import AnnData, read_h5ad

Creating the input data

The tutorial data set contains single-cell RNA-seq counts as a pandas DataFrame, with the cell type annotations as a pandas Series.

The scRNA-seq data must come in the form of unnormalized gene counts, together with the associated cell-type annotations for the cell types of interest. The bulk RNA-seq data should be normalized as transcripts per million (TPM) –or counts per millions (CPM) in case of tag-based spatial transcriptomics. The gene nomenclature used for the single-cell and bulk datasets must match (e.g., gene synonyms) to provide meaningful results.

To see an example dataset, we can load the (small) tutorial data provided by Rectangle:

sc_counts, annotations, bulks  = rectangle.load_tutorial_data()

The tutorial data set contains (scRNA-seq data counts as a pandas DataFrame (cell by genes), with the cell-type annotations as a pandas Series (cell-type label for every cell ID).

sc_counts[['FCGR3A', 'RPL5', 'SF3B4','S100A10', 'ZBTB7B']].head()
FCGR3A RPL5 SF3B4 S100A10 ZBTB7B
E2L4_GATGCTACAGGCACAA 37 23 0 10 0
L5_AACAACCAGGAACTAT 0 15 0 12 2
L5_TCCTTCTGTACTCCGG 0 5 0 1 0
L2_GCCCGAACACGTATAC 0 15 1 12 1
E2L2_ATGCATGTCACACCCT 0 24 1 18 3
annotations.head()
E2L4_GATGCTACAGGCACAA    Monocytes
L5_AACAACCAGGAACTAT      Monocytes
L5_TCCTTCTGTACTCCGG      Monocytes
L2_GCCCGAACACGTATAC      Monocytes
E2L2_ATGCATGTCACACCCT    Monocytes
Name: 0, dtype: object

And the bulk RNA-seq data as a pandas DataFrame, where the index is the sample ID and the columns are the genes.

bulks[['FCGR3A', 'RPL5', 'SF3B4','S100A10', 'ZBTB7B']].head()
FCGR3A RPL5 SF3B4 S100A10 ZBTB7B
pbmc_1 145.144880 2505.165434 78.708398 415.135 49.318210
pbmc_10 137.999249 2296.208027 79.041928 475.642 53.047700
pbmc_12 456.966440 2544.939500 74.853167 443.532 45.043961

The count dataframe with its annotations can be easily convert into an AnnData object.

sc_adata = AnnData(sc_counts, obs=annotations.to_frame(name="cell_type"))

Single-step deconvolution

To deconvolute the bulk data in a single step, use the “rectangle()” function. This method returns the results of the signature-building step (signature_result) and a tuple with the estimated cell type proportions for every sample of the deconvolved bulk dataset (estimations)

estimations, signature_result = rectangle.rectangle(sc_adata, bulks)

The rectangle method returns two objects:

  1. estimations: a pandas DataFrame with the estimated cell type proportions for each bulk sample.

  2. signature_result: a RectangleSignatureResult object containing additional information about the signature and the unknown content.

#subset for better visualisation in the documentation
estimations.iloc[:, -5:]
T cells CD8 Tregs mDC pDC Unknown
pbmc_1 0.161062 0.426049 0.029880 0.002178 0.000000
pbmc_10 0.118129 0.392704 0.024096 0.004327 0.000000
pbmc_12 0.274570 0.255928 0.014791 0.003015 0.017726
signature_result
RectangleSignatureResult
──────────────────────────────────────────────────
  Total Signature genes: 143
  Cell types: 11
    B cells marker genes: ['ADAM28', 'BLK', 'CD22', 'CD37', 'CD79A', 'FCER2', 'FCRL1', 'HVCN1', 'P2RX5', 'PLPP5', 'RALGPS2', 'SNX2', 'STRBP', 'VPREB3', 'ZCCHC7']
    ILC marker genes: ['C16orf54', 'CD7', 'HTATSF1', 'IL18R1']
    Monocytes marker genes: ['ADAP2', 'AGTRAP', 'ALDH3B1', 'ATG16L2', 'BCL6', 'BEST1', 'BST1', 'CAMKK2', 'CD300E', 'CD300LF', 'CD302', 'CEBPB', 'CFD', 'CLEC12A', 'CLEC7A', 'CPPED1', 'CREG1', 'CSF3R', 'DUSP1', 'DUSP6', 'EMILIN2']
    NK cells marker genes: ['C1orf21', 'CEP78', 'CST7', 'CTBP2', 'CX3CR1', 'FCGR3A', 'FGFBP2', 'GNLY', 'GNPTAB', 'GZMH', 'IL2RB', 'ITGAL', 'KLRC1', 'MYBL1', 'SAMD3', 'SPON2', 'SYNE1', 'TBX21']
    Plasma cells marker genes: ['ARSA', 'ATP13A1', 'CALR', 'CDK14', 'CKAP4', 'COPB2', 'DEF8', 'EIF2AK3', 'ERLEC1', 'FAM136A', 'FAM3C', 'FKBP2', 'FNDC3B', 'GMPPA', 'GORASP2', 'HIBCH', 'HM13', 'LAX1', 'MAN1A1', 'MANF', 'MLEC']
    Platelet marker genes: ['ACRBP', 'CCL5', 'CMTM5', 'CTDSPL', 'DAB2', 'DAPP1', 'FERMT3', 'GRAP2', 'ILK', 'KIF2A', 'LEPROT', 'MAP3K7CL', 'MAX', 'MGAT4B', 'MMD', 'MPP1', 'MTURN', 'OAZ1', 'PF4', 'PGRMC1', 'PRKAR2B']
    T cells CD4 conv marker genes: ['MAL']
    T cells CD8 marker genes: ['CD8A']
    Tregs marker genes: ['MAL']
    mDC marker genes: ['ANXA2', 'ATP1B1', 'BATF3', 'CACNA2D3', 'CIITA', 'EMILIN2', 'FCGR2B', 'FCGRT', 'GPAT3', 'GRASP', 'HLA-DPB1', 'HLA-DQB1', 'HLA-DRB5', 'IGSF6', 'IL13RA1', 'IL18', 'JAML', 'LGALS1', 'LGALS2', 'PAK1', 'RAB34']
    pDC marker genes: ['AEBP1', 'ARHGAP27', 'ASIP', 'ATG101', 'BCL7A', 'C12orf75', 'CBX4', 'CCDC186', 'CHML', 'CLN8', 'CRYBG3', 'CUEDC1', 'CXorf21', 'CYP46A1', 'EIF4A3', 'EPHB1', 'FAM129C', 'FLT3', 'FMNL3', 'GAS6', 'GPM6B']
  Cluster assignments: ['B cells', 'ILC', 6, 2, 'Plasma cells', 'Platelet', 1, 2, 1, 6, 'pDC']
  Unknown cell content analysis: Yes
  DGE Optimization result available:
    Best cutoffs: p: 0.05, lfc: 1.6

2-step deconvolution

Rectangle can also be run in two steps, first creating the signature and then deconvolving the bulk data.

1. Create Signature result

signature_result = rectangle.pp.build_rectangle_signatures(sc_adata, bulks=bulks)

This creates a RectangleSignatureResult object.

2. Deconvolve bulk data

We can then use the signature result to deconvolve the bulk data. This is done using the deconvolution method of the RectangleSignatureResult object, which should be fed with the signature_result object obtained at the previous step and the bulk dataset to be deconvolved

estimations, _ = rectangle.tl.deconvolution(signature_result, bulks)

This call returns a pandas DataFrame with the estimated cell type proportions.

#subset for better visualisation in the documentation
estimations.iloc[:, -5:]
T cells CD8 Tregs mDC pDC Unknown
pbmc_1 0.161062 0.426049 0.029880 0.002178 0.000000
pbmc_10 0.118129 0.392704 0.024096 0.004327 0.000000
pbmc_12 0.274570 0.255928 0.014791 0.003015 0.017726

Bulk RNA-seq deconvolution

To apply Rectangle to a more meaningful use case, we will perform the deconvolution of 9 bulk RNA-seq samples from blood-cell mixtures generated in the [FMP+19] study. We can access this dataset from deconvData[DMP+24]

url = "https://figshare.com/ndownloader/files/44877523"
bulks_fino = pd.read_csv(url, index_col=0)
bulks_fino.T[['LYZ', 'CD14', 'FCGR3A', 'CD8A', 'CD3D']].head()
LYZ CD14 FCGR3A CD8A CD3D
pbmc_1 7912.41 645.8500 145.144880 123.580148 357.85446
pbmc_10 9331.48 800.9088 137.999249 99.817693 258.58275
pbmc_12 9506.39 512.9779 456.966440 179.161160 328.40886
pbmc_2 6048.82 505.6502 158.455478 87.202916 320.96350
pbmc_4 3547.27 288.5666 314.124590 175.999580 267.81102

To train rectangle and build the signature matrix, we will use single-cell dataset human peripheral blood mononuclear cells (PBMC) profiled with the CITE-seq technology in the Hao et al. study [HHAN+21]. We will use a reduced and reannotated version of this dataset which is accessible through deconvData

# the files are originaly in an rds format,
# we skip the conversion step here and load an AnnData object directly
haoSub_adata = read_h5ad("../data/haoSub_adata.h5ad")
haoSub_adata
AnnData object with n_obs × n_vars = 14744 × 24049
    obs: 'cell_type'

We can now use the rectangle method to deconvolute the bulk data guided by the single-cell expression profiles and annotations using just a single command.

estimations, signature_result = rectangle.rectangle(haoSub_adata, bulks_fino.T)

The estimated cell-type fractions can be visualized in Python as a stacked barplot using matplotlib:

import matplotlib.pyplot as plt

fig, ax = plt.subplots(dpi=200)  # Increased resolution
colors = plt.get_cmap('tab20').colors  # More distinct colors
estimations.plot(kind='bar', stacked=True, ax=ax, color=colors)
ax.set_title('Rectangle deconvolution Results (Finotello dataset)', fontsize=16)
ax.set_ylabel('Cell fractions (%)', fontsize=14)
ax.set_xlabel('Bulk sample', fontsize=14)
ax.legend(title='Cell type', bbox_to_anchor=(1.05, 1), loc='upper left', fontsize=12)
plt.tight_layout()
plt.show()
../_images/3d1ab99673ff06ccb023529ed85850579e3f7d60ad1fad1291482e58700e4b26.png

The Finotello dataset also provides ground-truth cell fractions obtained via fluorescence-activated cell sorting (FACS) from all the analyzed bulk samples, which can be used to validate Rectangle estimates.

So let us further explore the estimation performance by plotting the estimated cell type proportions against the true cell type proportions we can again obtain from deconvData.

url = "https://figshare.com/ndownloader/files/44877526"
finotello_true = pd.read_csv(url, index_col=0).T
# rename the columns to match the cell types in the estimations
finotello_true.columns = ["NK cells", 'B cells', 'Tregs', 'mDC', 'Monocytes', 'Neutrophils', 'T cells CD8',
                         'T cells CD4 conv', 'Other']

First, we need to do some data wrangling to make the data compatible.

# funtion to match the dataframes by index and columns
# and sort them by index and columns
def match_dfs(df1, df2):
    common_rows = df1.index.intersection(df2.index)
    common_cols = df1.columns.intersection(df2.columns)
    df1_matched = df1.loc[common_rows, common_cols].sort_index().sort_index(axis=1)
    df2_matched = df2.loc[common_rows, common_cols].sort_index().sort_index(axis=1)
    return df1_matched, df2_matched

# function to create a dataframe for evaluation
# it takes the expected and estimated dataframes and returns a melted dataframe
def create_evaluation_df(expected_df, estimated_df):
    evaluation_df = expected_df.T.melt()
    estimated_df_e = estimated_df.T.melt()
    evaluation_df['result'] = estimated_df_e['value']
    evaluation_df.columns = ['Cell type','True Fractions' , 'Estimated Fractions']
    return evaluation_df

true_m, finotello_m = match_dfs(finotello_true.T, estimations.T)
true_m = true_m.sort_index(key=lambda x: x.str.lower())
finotello_m = finotello_m.sort_index(key=lambda x: x.str.lower())

evaluation_df = create_evaluation_df(true_m, finotello_m)

Next, we quantitatively compare Rectangle estimates to the ground-truth cell fractions using Pearson correlation (r) and root-mean square error (RMSE), as well as cell-fraction scatter plots:

import numpy as np
from scipy.stats import pearsonr
import seaborn as sns

def evaulate_total_df(df):
    rsme_total = np.sqrt(np.mean((df['Estimated Fractions'] - df['True Fractions']) ** 2))
    r_value_total, p_value_total =  pearsonr(df['Estimated Fractions'], df['True Fractions'])
    return rsme_total, r_value_total, p_value_total

rsme_finotello_rectangle, r_finotello_rectangle, p_finotello_rectangle = evaulate_total_df(evaluation_df)

def create_scatter_plot_total(df, rsme, p_value, r_value, title="Total", y_axis_label="Estimated Fractions", x_axis_label="True Fractions", hue="Cell type"):
    sns.set_style("white")
    fig, ax = plt.subplots(dpi=200)  # Increased resolution
    g = sns.scatterplot(x="True Fractions", y="Estimated Fractions",hue=hue, data=df, ax=ax)
    sns.regplot(x="True Fractions", y="Estimated Fractions", data=df, scatter=False, color='#444444', ci=None, ax=ax)
    text = f"r = {round(r_value, 3)}, p = {round(p_value, 10)} \nRMSE = {round(rsme, 3)}\nn = {len(df)}"
    g.set(xlabel=x_axis_label, ylabel=y_axis_label)
    g.legend(loc='center left', bbox_to_anchor=(1, 0.5))
    g.annotate(text, xy=(0.01, 0.95), xycoords="axes fraction",
                xytext=(5, -5), textcoords="offset points",
                ha="left", va="top")
    g.set_xlim(0, g.get_xlim()[1])
    g.set_ylim(0, g.get_ylim()[1])
    return g

rsme_finotello_rectangle, r_finotello_rectangle, p_finotello_rectangle = evaulate_total_df(evaluation_df)
create_scatter_plot_total(evaluation_df, rsme_finotello_rectangle, p_finotello_rectangle, r_finotello_rectangle)
<Axes: xlabel='True Fractions', ylabel='Estimated Fractions'>
../_images/eddd6de306eed92bb0393bce8772443fdfd3a80899f0b0dd1fb48fad6ad82540.png

Spatial deconvolution

Rectangle can be also effectively applied to spot-based spatial transcriptomics data, seamlessly integrating with the spatialdata package. See the spatialdata documentation for more details.

The mouse brain slide “ST8059048” was downloaded from ArrayExpress, with access number E-MTAB-11114 [HYH+22]. Find the dataset in https://figshare.com/articles/dataset/Tutorial_data/30104548?file=58967194

import spatialdata_io
import pandas as pd
import numpy as np
from anndata import AnnData, read_h5ad

s_data = spatialdata_io.visium(path='../data/spatial_ST8059048', dataset_id='ST8059048', scalefactors_file='scalefactors_json.json', tissue_positions_file='tissue_positions_list.csv', counts_file='ST8059048_filtered_feature_bc_matrix.h5')

Single-cell data: scRNA-seq data from the mouse brain was downloaded from Array Express, with accession number E-MTAB-11115 [KSD+22]. You can find a description of the preprocessing steps in https://figshare.com/articles/dataset/Tutorial_data/30104548?file=58978408

Find the processed data in https://figshare.com/articles/dataset/Tutorial_data/30104548?file=58978435

adata = read_h5ad('../data/cell2location_single_cell_processed.h5ad')

We can run Rectangle on the spatial data using the rectangle method, by using the ‘table’ object of the SpatialData object. As the spatial expression data is summarized as raw gene counts, we will first convert it to CPM.

data_table = s_data['table']
bulks = data_table.to_df()
bulks_cpm = bulks.div(bulks.sum(axis=1), axis=0) * 1e6
estimations, signature_result = rectangle.rectangle(adata, bulks_cpm)
# remove disconnected spots
disconnected_spots = ["AATCGAGGTCTCAAGG-1", "ACCACACGGTTGATGG-1",
                   "ACCGAGTCTCCTTATT-1", "AGCAGTCGAAGCATGC-1",
                   "AGTACAGAAGCTTATA-1", "CCTACAGTTGAGGGAG-1",
                   "CGCTTTCCGCCAAGGT-1", "CGGTATGGGCACTCTG-1",
                   "CGGTTGGGCAGGGTCC-1", "CTATCACAACGCTGGA-1",
                   "CTCTAACACCGGCAGC-1", "CTTACATAGATTTCTT-1",
                   "CTTCATCACCAGGGCT-1", "GAAGGGTCATTAAGAC-1",
                   "GACCACACTTCCCTTT-1", "GCACGCCGATTCCCGC-1",
                   "GCGGCTTTAGCAAGTT-1", "GCGTCGTAACATGGTC-1",
                   "GGATTAATCATGGACC-1", "GTACTACGGCCTCGTT-1",
                   "TACACAGCCGTGGTGC-1", "TCAGCGCACGCCGTTT-1",
                   "TGCCCGATAGTTAGAA-1", "TGTAGCCAATTCCGTT-1"]
estimations = estimations.drop(index=disconnected_spots)

We can add our estimations to the spatial data by creating a new AnnData object from the estimations and adding it to the SpatialData object.

from spatialdata.models import TableModel

# Create AnnData from estimations
table_data = AnnData(estimations)

# Add the required metadata columns
table_data.obs['region'] = 'ST8059048'
table_data.obs['instance_id'] = table_data.obs.index

adata_for_sdata = TableModel.parse(
    table_data,
    region='ST8059048',
    region_key='region',
    instance_key='instance_id'
)

s_data.tables["rectangle_results"] = adata_for_sdata