Skip to main content

Data Handling

This tutorial describes how to start working with the Proximity Network Assay (PNA) output data format. The primary data output file of pixelator is the PXL file. Here, you will learn how to read and interact with it. We will go through the different components of a single sample PXL file and also how to aggregate results from multiple samples. You can read more about the PXL file format here.

After completing this tutorial, you will be able to:

  • Build Seurat objects from PXL files and access the multi-modal PNA data including protein abundance data, spatial data and additional metadata.
  • Access spatial scores which quantify protein clustering and colocalization (co-clustering) patterns.
  • Access the edge list representing component (cell) graphs.
  • Aggregate multiple PNA datasets into a single Seurat object.

Setup​

First, we will load some packages needed for the tutorial.

library(pixelatorR)
library(SeuratObject)
library(dplyr)
library(stringr)
library(here)
library(tibble)

Loading data​

We begin by locating the output from the pixelator pipeline. The input for downstream analysis is the PXL file contained in the layout directory.

DATA_DIR <- "path/to/local/folder"
Sys.setenv("DATA_DIR" = DATA_DIR)

Next, run the following code in R to download the dataset to your local machine.

BASEURL <- "https://pixelgen-technologies-datasets.s3.eu-north-1.amazonaws.com/pna-datasets/v1"

pxl_file <- "PNA062_unstim_PBMCs_1000cells_S02_S2.layout.pxl"

if (!file.exists(file.path(DATA_DIR, pxl_file))) {
download.file(paste0(BASEURL, "/", pxl_file), destfile = file.path(DATA_DIR, pxl_file), method = "curl")
}

And then proceed to load the PNA data into Seurat object.

# Collect the path to the PXL file
filename <- file.path(DATA_DIR, "PNA062_unstim_PBMCs_1000cells_S02_S2.layout.pxl")

# Load the PXL file using the read function in the pixelatorR package
pg_data <- ReadPNA_Seurat(filename)
Accessing the function documentation

If you like to know more about the functions provided in the pixelatorR package, you can access the function documentation by running for example ?ReadPNA_Seurat in the R console.

Component meta data​

In PNA data, the term “component” generally corresponds to an individual cell. However, it’s important to note that in some instances, a component might represent other entities such as cell doublets, fragments of cells, or even antibody aggregates. For the sake of simplicity in these tutorials, we will primarily use “component” and “cell” as synonymous terms.

In addition to counts and spatial information, the Seurat object includes a component-level metadata table. This table contains key quality control metrics such as antibody counts (n_umi), sequencing depth (reads_in_component), and the number of detected proteins (n_antibodies).

as_tibble(pg_data[[]], rownames = "component") %>% 
head() %>%
knitr::kable()
componentn_umi1n_umi2n_edgesreads_in_componentn_antibodiesn_umiisotype_fractionintracellular_fractiontau_typetaudisqualified_for_denoisingnumber_of_nodes_removed_in_denoisesampleantibodiesaverage_k_corek_core_1k_core_2k_core_3k_core_4k_core_5k_core_6k_core_7svd_var_expl_s1svd_var_expl_s2svd_var_expl_s3
f1b52a4758932fc7133551831193749246004133316660.00031580normal0.9741447FALSE1343PNA062_unstim_PBMCs_1000cells_S02_S21333.37747157733009542984029053000.28441670.15680930.0893406
eca4983191f3647f1731123376105495273174139406870.00022120normal0.9753554FALSE2072PNA062_unstim_PBMCs_1000cells_S02_S21392.925185791951809614179740000.25355750.19298130.1464334
f98240c66b2e49fc118271493478611225033158267610.00179370normal0.9468124FALSE1545PNA062_unstim_PBMCs_1000cells_S02_S21583.37308047063152355381527198000.23958670.18788600.1207094
b5f8b192a0cc2603214502811197857296160158495610.00040350normal0.9495831FALSE3489PNA062_unstim_PBMCs_1000cells_S02_S21582.31161612780129201949843630000.34932610.23720260.1163323
7994e60e303c6dbb1576720905126994357256144366720.00021820normal0.9520865FALSE1715PNA062_unstim_PBMCs_1000cells_S02_S21443.855803593723893245765714342310200.29226360.17307810.1403489
63649c2a30940d6c1598621763122143330860147377490.00045030normal0.9778780FALSE1678PNA062_unstim_PBMCs_1000cells_S02_S21473.582135694229164898721115782000.26409280.14470630.1126697

Count data​

The antibody count matrix can be accessed from the Seurat object using the LayerData method.

# Display the 6 first rows and 6 first columns of the antibody counts
LayerData(pg_data, layer = "counts")[1:6, 1:6]
6 x 6 sparse Matrix of class "dgCMatrix"
f1b52a4758932fc7 eca4983191f3647f f98240c66b2e49fc b5f8b192a0cc2603
HLA-ABC 1643 1719 1029 1984
B2M 3402 3587 1922 4153
CD11b 1 . 13 74
CD11c 3 1 7 287
CD18 118 281 124 547
CD82 376 781 45 307
7994e60e303c6dbb 63649c2a30940d6c
HLA-ABC 2065 2326
B2M 4303 5978
CD11b 2 17
CD11c 6 22
CD18 97 152
CD82 40 1124

Spatial data​

Beyond protein abundance, the Proximity Network Assay (PNA) generates spatial data that enables the analysis of protein proximity patterns both within and between cells. Moreover, by leveraging graph drawing methods, it is even possible to create 3D layouts of the cells for visualization.

The PXL file includes a table of proximity scores, quantifying the spatial clustering of individual proteins and the co-localization of protein pairs on each cell.

pixelatorR Assay classes​

PixelatorR introduces two important Seurat extensions to store PNA data: PNAAssay (based on Assay from Seurat v3) and PNAAssay5 (based on Assay5 from Seurat v5). These two classes are used to store abundance data, just like an Assay or Assay5, but also spatial metrics and CellGraph objects. When a Seurat object is created from a PXL file with ReadPNA_Seurat, the data is automatically converted into a PNAAssay5 object named “PNA” which can be accessed as follows:

pg_data[["PNA"]]
PNAAssay (v5) data with 158 features for 1083 cells
Top 10 variable features:
HLA-ABC, B2M, CD11b, CD11c, CD18, CD82, CD8, TCRab, HLA-DR, CD45
Layers:
counts
Loaded CellGraph objects:
0

In the output above, we see that there are 158 proteins and 1084 cells available and that the object currently contains 0 loaded CellGraph objects. More about that later.

PNA Proximity scores​

The spatial metrics can be accessed from the PNAAssay5 object with:

ProximityScores(pg_data) %>% 
head() %>%
knitr::kable()
marker_1marker_2join_countjoin_count_expected_meanjoin_count_expected_sdjoin_count_zjoin_count_pcomponentlog2_ratio
CD33CD3300.250.5198096-0.250000.401293791984005700471b20.0000000
CD33CD5953.021.95907631.010680.156084791984005700471b20.7273795
CD33CD45RB00.080.2726599-0.080000.468118691984005700471b20.0000000
CD33KLRG110.140.34873510.860000.194894591984005700471b20.0000000
CD33VISTA00.200.5124707-0.200000.420740391984005700471b20.0000000
CD33CD62P00.160.4197161-0.160000.436440591984005700471b20.0000000

Edgelist​

The edgelist table lists linked protein pairs. Each row represents an edge, identified by two integer-encoded UMI barcodes (UMI1 and UMI2). Each UMI uniquely maps to a single protein (UMI1 to marker_1, UMI2 to marker_2).

This edgelist is a form of graph representation where UMI1 and UMI2 represent distinct sets of nodes (vertices), and a pair of UMI1/UMI2 denote edges (links) between them. This bipartite structure, inherent to how relationships are formed in the Proximity Network Assay, means a UMI1 barcode can only connect to a UMI2 barcode, and vice versa. In more technical terms, a PNA component graph is a simple, undirected bipartite graph. Given that edges typically form between spatially proximal proteins, the PNA graph is also a spatial graph.

Importantly, the edgelist is organized by component (individual cell), with no edges spanning across components. While a PXL file’s edgelist can be large (e.g., over 100 million rows in our sample), making direct manipulation cumbersome, pixelatorR offers tools to convert these component edgelists into more manageable graph representations for analysis.

Using the ReadPNA_edgelist function, we can for example lazy load the edgelist from the PXL file without loading it into memory. The resulting object is a tbl_lazy object, which can be manipulated with most dplyr verbs (thanks to the dbplyr R package) and materialized in memory when needed with collect.

edge_list <- ReadPNA_edgelist(filename)
edge_list %>%
head() %>%
collect() %>%
knitr::kable()
marker_1marker_2umi1umi2read_countuei_countcorrected_read_countcomponent
CD29CD43555638967335511282269015718903299208cd2f4585f9102dd
B2MCD441592993435260922338995199598510773208cd2f4585f9102dd
B2MCD51772702303642965232285919261075236218cd2f4585f9102dd
CD45CD451905030979812053149769111108698503208cd2f4585f9102dd
CD82CD82192788742310147386792409149002761108cd2f4585f9102dd
CD11aCD432083036410634253105880340693588152108cd2f4585f9102dd

Seurat objects and PXL files​

For some analyses, we will need to access information from the edgelist. For instance, if we want to load component graphs for visualization, we fetch the relevant component edges. It is important to note that the edgelist is not stored in the Seurat object, it’s only available in the PXL file. The PNAAssay5, mentioned earlier in this tutorial, stores the path(s) to the PXL file(s) from which the Seurat object was built from so that we can access the edgelist when needed. This has the advantage that we can keep the size of the Seurat object relatively small until we decide to load the graphs. On the down side, it also means that the path(s) to the PXL file(s) need to remain unchanged. If the PXL file(s) are moved or renamed, we can no longer access them.

This has an important implication when saving and sharing Seurat objects containing PNA data. If you want to export a Seurat object as an RDS file and share it with someone else, you should also share the associated PXL file(s). In addition, the path(s) to the PXL file(s) has to be updated in the Seurat object. If you want to see the PXL file path(s) or update them, you can use the FSMap method (see ?FSMap for more details):

# Get the path to the PXL file
FSMap(pg_data)
# A tibble: 1 × 3
id_map sample pxl_file
<list> <int> <chr>
1 <tibble [1,083 × 2]> 1 data/PNA062_unstim_PBMCs_1000cells_S02_S2.layout.…

If this is unclear at the moment, don’t worry. We will come back to this topic in the tutorial about cell visualization.

Aggregating data​

For cases when we want to process and analyze multiple PNA datasets, we can load the corresponding PXL files as Seurat objects and merge them. The cell IDs should be unique across different samples, but you can provide a unique prefix to each sample by passing a character vector to the add.cell.ids parameter in merge.

In the code chunk below, we will create a merged object from two PXL files. These PXL files represent a resting and PHA stimulated PBMC sample. We will create a merged object Seurat and update the object’s metadata to keep information about the condition. The resulting object will be explored further in the next tutorial on Quality Control.

BASEURL <- "https://pixelgen-technologies-datasets.s3.eu-north-1.amazonaws.com/pna-datasets/v1"

pxl_files <- c("PNA062_unstim_PBMCs_1000cells_S02_S2.layout.pxl", "PNA062_PHA_PBMCs_1000cells_S04_S4.layout.pxl")

for (f in pxl_files) {
if (!file.exists(file.path(DATA_DIR, f))) {
download.file(paste0(BASEURL, "/", f), destfile = file.path(DATA_DIR, f), method = "curl")
}
}
# The folders the data is located in:
data_files <- c(
# Resting
resting = file.path(DATA_DIR, "PNA062_unstim_PBMCs_1000cells_S02_S2.layout.pxl"),
# PHA
stimulated = file.path(DATA_DIR, "PNA062_PHA_PBMCs_1000cells_S04_S4.layout.pxl")
)

# Read PXL files as a list of Seurat objects
obj_list <- lapply(data_files, ReadPNA_Seurat)

# Combine files into a single Seurat object
pg_data_combined <- merge(obj_list[[1]], y = obj_list[-1], add.cell.ids = names(obj_list))

# Add meta data describing the sample and donor origin
pg_data_combined <- AddMetaData(
pg_data_combined,
metadata = data.frame(id = colnames(pg_data_combined)) %>%
mutate(condition = str_remove(id, "_.*")) %>%
column_to_rownames("id")
)
pg_data_combined
An object of class Seurat 
158 features across 2137 samples within 1 assay
Active assay: PNA (158 features, 158 variable features)
2 layers present: counts.1, counts.2

As you can see in the object description above, the count data in the merged Seurat object is separated in multiple matrices (“counts.1”, “counts.2”, …). Our PNA data is stored in a PNAAssay5 object which extends the Assay5 object introduced in Seurat v5 which keeps the sample count matrices separated after merging. For many downstream applications, one needs a single merged count matrix.

One option is to set options(Seurat.object.assay.version = "v3") and rerun this tutorial in which case the PNA data will be stored in a PNAAssay (Seurat v3) instead. Alternatively, you can join the data using the JoinLayers method. After joining, you should see that there’s now only 1 layer present.

pg_data_combined <- JoinLayers(pg_data_combined)
pg_data_combined
An object of class Seurat 
158 features across 2137 samples within 1 assay
Active assay: PNA (158 features, 158 variable features)
1 layer present: counts

Save data​

After combining the samples, we can save the merged data to an RDS file to use for later. Remember that a Seurat object containing PNA data is associated with one or several PXL file(s), so you need to share the PXL file(s) along with the RDS file to ensure that all pixelatorR features can be used by the recipient. If the component graphs are not needed for subsequent analysis, the PXL file(s) are not needed.

# This is an optional pause step; if you prefer to continue to the next tutorial without saving the object, that will also work.
saveRDS(pg_data_combined, file.path(DATA_DIR, "combined_data.rds"))

We have now learned how to load multiple PNA datasets, inspect its key properties and prepare the data for downstream analysis. In the next tutorial we will apply these skills and demonstrate how to quality control PNA data.