Data Matrices

DataMatrix objects — annotated matrices where rows are variables and columns are observations — are central in SingleCellProjections.jl.

Let's load a sample to see what a DataMatrix looks like:

using SingleCellProjections
import SingleCellProjections as SCP
using ReproducibleJobs
using SparseArrays

sample_path = joinpath("samples", "AML28.h5")
"samples/AML28.h5"
counts = SCP.load_counts(sample_path; sample_names="AML28")
c = fetch!(counts)
DataMatrix (32738 variables and 6905 observations)
  Block Matrix (32×7)
  Variables: id, name, feature_type, genome, read, pattern, sequence
  Observations: cell_id, sample_name, barcode

The display shows the matrix size (number of variables and observations), a brief description of the matrix contents, and an overview of available variable and observation annotations.

Variables

Variables, or var for short, are typically genes, features (such as CITE-seq features), or variables after dimension reduction (e.g. "UMAP1"). The variables are stored as a DataFrame and can be accessed by:

c.var[1:6, :]
6×7 DataFrame
Rowidnamefeature_typegenomereadpatternsequence
StringStringStringStringStringStringString
1ENSG00000243485MIR1302-10Gene Expressionhg19
2ENSG00000237613FAM138AGene Expressionhg19
3ENSG00000186092OR4F5Gene Expressionhg19
4ENSG00000238009RP11-34P13.7Gene Expressionhg19
5ENSG00000239945RP11-34P13.8Gene Expressionhg19
6ENSG00000237683AL627309.1Gene Expressionhg19

Observations

Observations, or obs for short, are typically cells, but can also be pseudobulk samples, for example. The observations are stored as a DataFrame and can be accessed by:

c.obs[1:6, :]
6×3 DataFrame
Rowcell_idsample_namebarcode
StringStringString
1AML28_AAACCCACAAATCCCA-1AML28AAACCCACAAATCCCA-1
2AML28_AAACCCACAAGAAACT-1AML28AAACCCACAAGAAACT-1
3AML28_AAACCCACAAGCGCAA-1AML28AAACCCACAAGCGCAA-1
4AML28_AAACCCACACCTAAAC-1AML28AAACCCACACCTAAAC-1
5AML28_AAACCCATCCGGGACT-1AML28AAACCCATCCGGGACT-1
6AML28_AAACCCATCCTCTGCA-1AML28AAACCCATCCTCTGCA-1

IDs

Each variable and each observation must have a unique ID. The first column of the var and obs DataFrames is always the ID column.

Most of the time, IDs are handled automatically by SingleCellProjections.jl. Sometimes, you need to make sure IDs are unique when loading or merging data matrices. In particular, when loading a DataMatrix that should be projected onto another DataMatrix, the user must ensure that variable IDs match.

Matrix

The matrix can be accessed by data.matrix. Depending on the stage of analysis, different kinds of matrices (or matrix-like objects) are used. Most of this complexity is hidden from the user, but internally SingleCellProjections.jl depends on this functionality to be fast and to reduce memory usage.

Read-only

SingleCellProjections.jl will reuse matrices when possible, in order to reduce memory usage. E.g. normalize_matrix will reuse and extend the Matrix Expression of the source DataMatrix, without creating a copy of the actual data. When matrices are reused/copied is considered an implementation detail, and can change at any time. Users of SingleCellProjections.jl should thus consider the matrices to be "read-only". This should rarely present problems in practice.

Roughly, the matrix types used at different stages are:

  1. Counts — SparseMatrixCSC (often in a blocked format)
  2. Transformed and normalized data — Matrix Expressions
  3. PCA result — Matrix{Float64}
  4. ForceLayout/UMAP/t-SNE result — Matrix{Float64}

Jobs and DataMatrices

A DataMatrix Job is internally split into three component Jobs: one for the matrix, one for var, and one for obs. You can access these with:

  • SCP.get_matrix(job) — the matrix component
  • SCP.get_var(job) — the variable annotations
  • SCP.get_obs(job) — the observation annotations

Operations that only affect one component leave the others unchanged. For example, SCP.logtransform only transforms the matrix — it passes var and obs through without modification. This means the var and obs Jobs are literally the same object (identical by ===) before and after the transformation:

transformed = SCP.logtransform(counts)

# var is unchanged by logtransform — same Job object
forward!(SCP.get_var(transformed)) === forward!(SCP.get_var(counts))
true
# matrix is different — logtransform created a new matrix Job
forward!(SCP.get_matrix(transformed)) === forward!(SCP.get_matrix(counts))
false

This splitting is what makes the caching and projection system efficient — unchanged components are never recomputed or stored redundantly.

Do not mutate results

Since components are shared across Jobs, mutating a fetched result (e.g., modifying a column in data.obs) would corrupt other Jobs that reference the same underlying object. Always treat results from fetch! as read-only.