sparse column shift encoding. (#1502)

Many of our matrices are log normalized, which tends to eliminate
the number of non zero values (if there were any).  This prevents
the matrix from being stored as a sparse matrix.  The solution here
is to use a simple transformation to make it sparse again.  The most
common value from each column is subtracted from that column.  These
values that were subtracted are saved in an array called X_col_shift.

The cellxgene code needs to understand how to undo the transformation when
operating over the X matrix.

- added script to create a synthetic dataset for testing
- added a script to convert an existing CXG dataset to a sparse CXG dataset
This commit is contained in:
bmccandless
2020-06-02 08:23:52 -07:00
committed by GitHub
parent d0577b94af
commit 76523d4f32
8 changed files with 297 additions and 63 deletions
+97 -19
View File
@@ -8,8 +8,11 @@ The organization of the TileDB structure is:
├─ obs TileDB array containing cell (row) attributes, one attribute per
│ dataframe column, shape (n_obs,)
├─ var TileDB array containing gene (column) attributes, with one attribute per
│ dataframe column, shape (n_obs,)
│ dataframe column, shape (n_var,)
├─ X Main count matrix as a 2D TileDB array, single unnamed numeric attribute
├─ X_col_shift TilebDB Array used in column shift encoding, shape (n_var,), dtype = X.dtype.
│ Single unnamed numeric attribute. If this array is sparse, and X_col_shift exists,
│ then all values in the i'th column were subtracted by X_col_shift[i].
├─ emb TileDB group, storing optional embeddings (group may be empty)
│ └─ <name1> TileDB Array, single anon attribute, ND numeric array, shape (n_obs, N)
└─ cxg_group_metadata Empty array used only to stash metadata about the overall object.
@@ -70,6 +73,7 @@ import argparse
import numpy as np
from os.path import splitext, basename
import json
from scipy.stats import mode
from server.common.colors import convert_anndata_category_colors_to_cxg_category_colors
from server.common.errors import ColorFormatException
@@ -205,7 +209,7 @@ def write_cxg(
log(1, "\t...embeddings created")
# X matrix
save_X(container, adata, ctx, sparse_threshold)
save_X(container, adata.X, ctx, sparse_threshold)
log(1, "\t...X created")
@@ -437,72 +441,146 @@ def create_X(X_name, shape, is_sparse):
tiledb.DenseArray.create(X_name, schema)
def check_sparse_X(adata, sparse_threshold):
shape = adata.X.shape
def evaluate_for_sparse_encoding(xdata, sparse_threshold):
"""
This function determines if the X matrix has a sparsity below the sparse_threshold.
This function also returns the number of non-zeros encountered and number
of elements evaluated. This function may return before evaluating the whole X matrix
if it can be determined that X is not sparse enough.
"""
shape = xdata.shape
stride = min(int(np.power(10, np.around(np.log10(1e9 / shape[1])))), 10_000)
nnz = 0
maxnnz = int(shape[0] * shape[1] * sparse_threshold / 100)
for row in range(0, shape[0], stride):
lim = min(row + stride, shape[0])
a = adata.X[row:lim, :]
a = xdata[row:lim, :]
if type(a) is not np.ndarray:
a = a.toarray()
nnz += np.count_nonzero(a)
if nnz > maxnnz:
return (nnz, lim * shape[1])
log(2, "\t...rows", row, "to", lim, "nnz", nnz, "nnz percent %5.2f%%" % (100 * nnz / (lim * shape[1])))
return (False, nnz, lim * shape[1])
log(2, "\t...rows", lim, "of", shape[0], "nnz", nnz, "nnz percent %5.2f%%" % (100 * nnz / (lim * shape[1])))
return (nnz, shape[0] * shape[1])
is_sparse = (100.0 * nnz / (shape[0] * shape[1])) < sparse_threshold
return (is_sparse, nnz, shape[0] * shape[1])
def save_X(container, adata, ctx, sparse_threshold):
def evaluate_for_sparse_column_shift_encoding(xdata, sparse_threshold):
"""Column shift encoding works by taking the most common value in each column, then
subtracting that value from each element of the column. If each column mostly contains
its most common value, then the resulting matrix can be very sparse.
This function determines if column shift encoding can be used to transform
the X matrix into a sparse matrix with a sparsity below the sparse_threshold.
If so, return the col_shift array that stores this encoding.
This function also returns the number of non-zeros encountered and number
of elements evaluated. This function may return before evaluating the whole X matrix
if it can be determined that X cannot benefit from column shift encoding.
"""
shape = xdata.shape
stride = max(1, 128_000_000 // shape[0])
col_shift = np.zeros(shape[1])
nnz = 0
maxnnz = int(shape[0] * shape[1] * sparse_threshold / 100)
for col in range(0, shape[1], stride):
lim = min(col + stride, shape[1])
a = xdata[:, col:lim]
if type(a) is not np.ndarray:
a = a.toarray()
m = mode(a)
col_shift[col:lim] = m.mode
nnz += shape[0] * (lim - col) - np.sum(m.count)
if nnz > maxnnz:
return (None, nnz, shape[0] * lim)
log(2, "\t...cols", lim, "of", shape[1], "nnz",
nnz, "nnz percent %5.2f%%" % (100 * nnz / (lim * shape[0])))
is_sparse = (100.0 * nnz / (shape[0] * shape[1])) < sparse_threshold
return (col_shift if is_sparse else None, nnz, shape[0] * shape[1])
def save_X(container, xdata, ctx, sparse_threshold, expect_sparse=False):
# Save X count matrix
X_name = f"{container}/X"
shape = adata.X.shape
shape = xdata.shape
log(1, "\t...shape:", str(shape))
col_shift = None
if sparse_threshold == 100:
is_sparse = True
elif sparse_threshold == 0:
is_sparse = False
else:
nnz, nelem = check_sparse_X(adata, sparse_threshold)
is_sparse, nnz, nelem = evaluate_for_sparse_encoding(xdata, sparse_threshold)
percent = 100.0 * nnz / nelem
if nelem != shape[0] * shape[1]:
log(1, "\t...shape:", str(shape), "non-zeros percent (estimate): %6.2f" % percent)
log(1, "\t...sparse=", is_sparse, "non-zeros percent (estimate): %6.2f" % percent)
else:
log(1, "\t...shape:", str(shape), "non-zeros:", nnz, "percent: %6.2f" % percent)
log(1, "\t...sparse=", is_sparse, "non-zeros:", nnz, "percent: %6.2f" % percent)
is_sparse = percent < sparse_threshold
if not is_sparse:
col_shift, nnz, nelem = evaluate_for_sparse_column_shift_encoding(xdata, sparse_threshold)
is_sparse = col_shift is not None
percent = 100.0 * nnz / nelem
if nelem != shape[0] * shape[1]:
log(1, "\t...sparse=", is_sparse, "col shift non-zeros percent (estimate): %6.2f" % percent)
else:
log(1, "\t...sparse=", is_sparse, "col shift non-zeros:", nnz, "percent: %6.2f" % percent)
if expect_sparse is True and is_sparse is False:
return False
create_X(X_name, shape, is_sparse)
stride = min(int(np.power(10, np.around(np.log10(1e9 / shape[1])))), 10_000)
if is_sparse:
log(1, "\t...output X as sparse matrix")
if col_shift is not None:
log(1, "\t...output X as sparse matrix with column shift encoding")
X_col_shift_name = f"{container}/X_col_shift"
filters = tiledb.FilterList([tiledb.ZstdFilter()])
attrs = [tiledb.Attr(dtype=np.float32, filters=filters)]
domain = tiledb.Domain(tiledb.Dim(domain=(0, shape[1] - 1), tile=min(shape[1], 5000), dtype=np.uint32))
schema = tiledb.ArraySchema(domain=domain, attrs=attrs)
tiledb.DenseArray.create(X_col_shift_name, schema)
with tiledb.DenseArray(X_col_shift_name, mode="w", ctx=ctx) as X_col_shift:
X_col_shift[:] = col_shift
tiledb.consolidate(X_col_shift_name, ctx=ctx)
else:
log(1, "\t...output X as sparse matrix")
with tiledb.SparseArray(X_name, mode="w", ctx=ctx) as X:
nnz = 0
for row in range(0, shape[0], stride):
lim = min(row + stride, shape[0])
a = adata.X[row:lim, :]
a = xdata[row:lim, :]
if type(a) is not np.ndarray:
a = a.toarray()
if col_shift is not None:
a = a - col_shift
indices = np.nonzero(a)
trow = indices[0] + row
nnz += indices[0].shape[0]
X[trow, indices[1]] = a[indices[0], indices[1]]
log(2, "\t...rows", row, "to", lim)
tiledb.consolidate(X_name, ctx=ctx)
log(2, "\t...rows", lim, "of", shape[0], "nnz", nnz, "sparse", nnz / (lim * shape[1]))
else:
log(1, "\t...output X as dense matrix")
with tiledb.DenseArray(X_name, mode="w", ctx=ctx) as X:
for row in range(0, shape[0], stride):
lim = min(row + stride, shape[0])
a = adata.X[row:lim, :]
a = xdata[row:lim, :]
if type(a) is not np.ndarray:
a = a.toarray()
X[row:lim, :] = a
log(2, "\t...rows", row, "to", lim)
tiledb.consolidate(X_name, ctx=ctx)
if hasattr(tiledb, "vacuum"):
tiledb.vacuum(X_name)
return is_sparse
def save_metadata(container, metadata_dict):
+62
View File
@@ -0,0 +1,62 @@
"""
Script to create a sparse dataset in CXG format based on an input dataset in CXG format.
The input dataset is not modified.
"""
import os
import shutil
import tiledb
import argparse
import sys
import server.converters.cxgtool as cxgtool
def main():
parser = argparse.ArgumentParser()
parser.add_argument("input", help="input cxg directory")
parser.add_argument("output", help="output cxg directory")
parser.add_argument("--overwrite", action="store_true", help="replace output cxg directory")
parser.add_argument("--verbose", "-v", action="count", default=0, help="verbose output")
parser.add_argument(
"--sparse-threshold",
"-s",
type=float,
default=5.0, # default is 5% non-zero values
help="The X array will be sparse if the percent of non-zeros falls below this value",
)
args = parser.parse_args()
if os.path.exists(args.output):
print("output dir exists:", args.output)
if args.overwrite:
print("output dir removed:", args.output)
shutil.rmtree(args.output)
else:
print("use the overwrite option to remove the output directory")
sys.exit(1)
if not os.path.isdir(args.input):
print("input is not a directory", args.input)
sys.exit(1)
shutil.copytree(args.input, args.output,
ignore=shutil.ignore_patterns("X", "X_col_shift"))
ctx = tiledb.Ctx(
{
"sm.num_reader_threads": 32,
"sm.num_writer_threads": 32,
"sm.consolidation.buffer_size": 1 * 1024 * 1024 * 1024,
}
)
with tiledb.DenseArray(os.path.join(args.input, "X"), mode="r", ctx=ctx) as X_in:
is_sparse = cxgtool.save_X(args.output, X_in, ctx, args.sparse_threshold, expect_sparse=True)
if is_sparse is False:
print("The array is not sparse, cleaning up, abort.")
shutil.rmtree(args.output)
sys.exit(1)
if __name__ == "__main__":
main()