bootstrap2-sc 0.1.0__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.
bootstrap2/__init__.py
ADDED
bootstrap2/core.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Two-level batch--subject bootstrap, without cell-level resampling."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from numbers import Integral
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from joblib import Parallel, delayed, effective_n_jobs
|
|
11
|
+
from scipy import sparse
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _matrix(adata, layer):
|
|
15
|
+
if layer is not None and layer not in adata.layers:
|
|
16
|
+
raise ValueError(f"Layer {layer!r} is not present in adata.layers.")
|
|
17
|
+
counts = adata.X if layer is None else adata.layers[layer]
|
|
18
|
+
if counts is None:
|
|
19
|
+
raise ValueError("AnnData must contain a raw count matrix.")
|
|
20
|
+
matrix = sparse.csr_matrix(counts, copy=True)
|
|
21
|
+
if not np.isfinite(matrix.data).all() or (matrix.data < 0).any():
|
|
22
|
+
raise ValueError("Expression must contain finite, nonnegative values.")
|
|
23
|
+
if matrix.dtype.kind != "f":
|
|
24
|
+
matrix = matrix.astype(np.float32)
|
|
25
|
+
totals = np.asarray(matrix.sum(axis=1)).ravel()
|
|
26
|
+
if not np.isfinite(totals).all():
|
|
27
|
+
raise ValueError("Nonfinite cell library sizes; check expression values.")
|
|
28
|
+
zero = totals == 0
|
|
29
|
+
if zero.any():
|
|
30
|
+
warnings.warn(
|
|
31
|
+
f"{zero.sum()} cells have zero total counts and remain zero after CPM normalization.",
|
|
32
|
+
UserWarning, stacklevel=3,
|
|
33
|
+
)
|
|
34
|
+
scale = totals / 1e6
|
|
35
|
+
scale[zero] = 1
|
|
36
|
+
matrix.data /= np.repeat(scale, np.diff(matrix.indptr))
|
|
37
|
+
return matrix
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _prepare(adata, matrix, condition_col, sample_col, batch_col, condition):
|
|
41
|
+
for column in [condition_col, sample_col]:
|
|
42
|
+
if column not in adata.obs:
|
|
43
|
+
raise ValueError(f"Required metadata column {column!r} is missing.")
|
|
44
|
+
columns = [condition_col, sample_col]
|
|
45
|
+
if batch_col is not None and batch_col in adata.obs:
|
|
46
|
+
columns.append(batch_col)
|
|
47
|
+
if len(set(columns)) != len(columns):
|
|
48
|
+
raise ValueError("Condition, sample and batch columns must be different.")
|
|
49
|
+
obs = adata.obs[columns].copy()
|
|
50
|
+
if obs.isna().any().any():
|
|
51
|
+
raise ValueError("Condition, sample and batch labels must not be missing.")
|
|
52
|
+
# Reject labels whose string conversion would merge different identifiers.
|
|
53
|
+
for column in columns:
|
|
54
|
+
if obs[column].nunique() != obs[column].astype(str).nunique():
|
|
55
|
+
raise ValueError(f"Labels in {column!r} collide after conversion to strings.")
|
|
56
|
+
obs[column] = obs[column].astype(str)
|
|
57
|
+
batches = obs[batch_col] if batch_col in obs else pd.Series("single_batch", index=obs.index)
|
|
58
|
+
labels = obs[condition_col].unique().tolist()
|
|
59
|
+
if len(labels) != 2:
|
|
60
|
+
raise ValueError("Exactly two conditions are required; subset the AnnData first.")
|
|
61
|
+
if condition is None:
|
|
62
|
+
condition = tuple(labels)
|
|
63
|
+
else:
|
|
64
|
+
if isinstance(condition, (str, bytes)):
|
|
65
|
+
raise ValueError("condition must be a pair of condition labels, not a string.")
|
|
66
|
+
condition = tuple(str(value) for value in condition)
|
|
67
|
+
if len(condition) != 2 or condition[0] == condition[1] or set(condition) != set(labels):
|
|
68
|
+
raise ValueError("condition must contain the two observed conditions, numerator first.")
|
|
69
|
+
layout = pd.DataFrame({"sample": obs[sample_col], "condition": obs[condition_col], "batch": batches})
|
|
70
|
+
for column in ["condition", "batch"]:
|
|
71
|
+
if (layout.groupby("sample", sort=False)[column].nunique() > 1).any():
|
|
72
|
+
raise ValueError(
|
|
73
|
+
"Each Sample must belong to one condition and one batch. "
|
|
74
|
+
"Repeated/paired subjects or subjects spanning batches require a different design."
|
|
75
|
+
)
|
|
76
|
+
valid, dropped, group_a, group_b = [], [], {}, {}
|
|
77
|
+
for batch in layout["batch"].unique():
|
|
78
|
+
in_batch = layout["batch"] == batch
|
|
79
|
+
a = layout.loc[in_batch & (layout["condition"] == condition[0]), "sample"].unique()
|
|
80
|
+
b = layout.loc[in_batch & (layout["condition"] == condition[1]), "sample"].unique()
|
|
81
|
+
if len(a) and len(b):
|
|
82
|
+
valid.append(batch)
|
|
83
|
+
group_a[batch], group_b[batch] = a, b
|
|
84
|
+
else:
|
|
85
|
+
dropped.append(batch)
|
|
86
|
+
if dropped:
|
|
87
|
+
raise ValueError("Each batch must contain both conditions. Batches lacking one condition: " + ", ".join(dropped))
|
|
88
|
+
if not valid:
|
|
89
|
+
raise ValueError("No batch contains both conditions; condition and batch may be confounded.")
|
|
90
|
+
all_samples = [sample for batch in valid for group in (group_a, group_b) for sample in group[batch]]
|
|
91
|
+
counts = [sum(len(group[batch]) for batch in valid) for group in (group_a, group_b)]
|
|
92
|
+
if min(counts) < 2:
|
|
93
|
+
raise ValueError("At least two independent subjects per condition must remain.")
|
|
94
|
+
positions = {sample: i for i, sample in enumerate(all_samples)}
|
|
95
|
+
transposed = matrix.T.tocsc()
|
|
96
|
+
profiles = np.array([
|
|
97
|
+
np.asarray(transposed[:, np.flatnonzero(layout["sample"].to_numpy() == sample)].mean(axis=1)).ravel()
|
|
98
|
+
for sample in all_samples
|
|
99
|
+
])
|
|
100
|
+
indices_a = {batch: np.array([positions[s] for s in group_a[batch]]) for batch in valid}
|
|
101
|
+
indices_b = {batch: np.array([positions[s] for s in group_b[batch]]) for batch in valid}
|
|
102
|
+
return profiles, indices_a, indices_b
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _worker(profiles, indices_a, indices_b, fold_threshold, n_iters, seed):
|
|
106
|
+
rng = np.random.RandomState(seed)
|
|
107
|
+
up = np.zeros(profiles.shape[1], dtype=np.int64)
|
|
108
|
+
down = np.zeros(profiles.shape[1], dtype=np.int64)
|
|
109
|
+
batches_a, batches_b = np.array(list(indices_a)), np.array(list(indices_b))
|
|
110
|
+
for _ in range(n_iters):
|
|
111
|
+
sampled_a = rng.choice(batches_a, size=len(batches_a), replace=True)
|
|
112
|
+
sampled_b = rng.choice(batches_b, size=len(batches_b), replace=True)
|
|
113
|
+
a = np.concatenate([rng.choice(indices_a[b], size=len(indices_a[b]), replace=True) for b in sampled_a])
|
|
114
|
+
b = np.concatenate([rng.choice(indices_b[b], size=len(indices_b[b]), replace=True) for b in sampled_b])
|
|
115
|
+
mean_a, mean_b = profiles[a].mean(axis=0), profiles[b].mean(axis=0)
|
|
116
|
+
up += mean_a <= mean_b * fold_threshold
|
|
117
|
+
down += mean_b <= mean_a * fold_threshold
|
|
118
|
+
return up, down
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _adjust_pvalues(up, down, n_iters):
|
|
122
|
+
return (2.0 * np.minimum(up, down) + 1.0) / (n_iters + 1.0)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def bootstrap2h(
|
|
126
|
+
adata, *, condition=None, n_bootstraps=10_000, n_jobs=4, seed=0, lfc=0.0,
|
|
127
|
+
condition_col="Condition", sample_col="Sample", batch_col="Batch", layer=None,
|
|
128
|
+
):
|
|
129
|
+
"""Run two-level bootstrap on raw counts without modifying the input.
|
|
130
|
+
|
|
131
|
+
``condition`` is the numerator/denominator pair; if omitted, use label order
|
|
132
|
+
in ``adata.obs``. ``lfc`` is a nonnegative log2 threshold (0 for point null).
|
|
133
|
+
Metadata defaults to Condition, Sample and Batch; a missing Batch column
|
|
134
|
+
means one batch. ``layer`` optionally selects a raw-count layer instead of X.
|
|
135
|
+
``n_jobs`` is the worker count; -1 uses available CPUs.
|
|
136
|
+
Return a gene-indexed DataFrame with unadjusted ``p_val`` and ``lfc``.
|
|
137
|
+
"""
|
|
138
|
+
if adata.n_obs == 0 or adata.n_vars == 0:
|
|
139
|
+
raise ValueError("AnnData must contain cells and genes.")
|
|
140
|
+
if not adata.var_names.is_unique:
|
|
141
|
+
raise ValueError("Gene names must be unique.")
|
|
142
|
+
if isinstance(n_bootstraps, bool) or not isinstance(n_bootstraps, Integral) or n_bootstraps < 1:
|
|
143
|
+
raise ValueError("n_bootstraps must be a positive integer.")
|
|
144
|
+
if isinstance(n_jobs, bool) or not isinstance(n_jobs, Integral) or (n_jobs < 1 and n_jobs != -1):
|
|
145
|
+
raise ValueError("n_jobs must be a positive integer or -1.")
|
|
146
|
+
if isinstance(seed, bool) or not isinstance(seed, Integral) or not 0 <= seed < 2**32:
|
|
147
|
+
raise ValueError("seed must be an integer in [0, 2**32).")
|
|
148
|
+
if not np.isfinite(lfc) or lfc < 0:
|
|
149
|
+
raise ValueError("lfc must be finite and nonnegative.")
|
|
150
|
+
with np.errstate(over="ignore"):
|
|
151
|
+
fold_threshold = float(np.exp2(lfc))
|
|
152
|
+
if not np.isfinite(fold_threshold):
|
|
153
|
+
raise ValueError("lfc is too large.")
|
|
154
|
+
matrix = _matrix(adata, layer)
|
|
155
|
+
profiles, a, b = _prepare(
|
|
156
|
+
adata, matrix, condition_col, sample_col, batch_col, condition,
|
|
157
|
+
)
|
|
158
|
+
workers = min(effective_n_jobs(n_jobs), n_bootstraps)
|
|
159
|
+
iterations = [n_bootstraps // workers + (i < n_bootstraps % workers) for i in range(workers)]
|
|
160
|
+
seeds = np.random.RandomState(seed).randint(0, 1_000_000, size=workers)
|
|
161
|
+
results = Parallel(n_jobs=workers)(
|
|
162
|
+
delayed(_worker)(profiles, a, b, fold_threshold, count, int(worker_seed))
|
|
163
|
+
for count, worker_seed in zip(iterations, seeds)
|
|
164
|
+
)
|
|
165
|
+
up, down = np.sum([r[0] for r in results], axis=0), np.sum([r[1] for r in results], axis=0)
|
|
166
|
+
mean_a = profiles[np.concatenate(list(a.values()))].mean(axis=0)
|
|
167
|
+
mean_b = profiles[np.concatenate(list(b.values()))].mean(axis=0)
|
|
168
|
+
result = pd.DataFrame({
|
|
169
|
+
"p_val": _adjust_pvalues(up, down, n_bootstraps),
|
|
170
|
+
"lfc": np.log2((mean_a + 1e-9) / (mean_b + 1e-9)),
|
|
171
|
+
}, index=pd.Index(adata.var_names, name="gene"))
|
|
172
|
+
return result
|
|
173
|
+
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bootstrap2-sc
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Subject-aware two-level hierarchical bootstrap for single-cell differential expression
|
|
5
|
+
Author: ZW.G
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: anndata<1,>=0.10
|
|
9
|
+
Requires-Dist: joblib<2,>=1.3
|
|
10
|
+
Requires-Dist: numpy<3,>=1.24
|
|
11
|
+
Requires-Dist: pandas<4,>=2
|
|
12
|
+
Requires-Dist: scipy<2,>=1.10
|
|
13
|
+
|
|
14
|
+
# BOOTSTRAP2
|
|
15
|
+
|
|
16
|
+
Two-level hierarchical bootstrap for single-cell differential expression.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Python 3.10 or newer is required. Install with:
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
python -m pip install bootstrap2-sc
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
For installation from a local source checkout:
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
python -m pip install .
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Input
|
|
33
|
+
|
|
34
|
+
Provide an AnnData object containing raw counts in `adata.X`.
|
|
35
|
+
|
|
36
|
+
| `adata.obs` field | Description |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `Sample` | Globally unique biological subject identifier |
|
|
39
|
+
| `Condition` | Exactly two comparison groups |
|
|
40
|
+
| `Batch` | Batch identifier; optional for a single-batch design |
|
|
41
|
+
|
|
42
|
+
Each subject must belong to one condition and one batch. The method supports
|
|
43
|
+
the nested batch--subject design, not general covariate adjustment,
|
|
44
|
+
paired/repeated subjects or condition-batch confounding.
|
|
45
|
+
|
|
46
|
+
## Python API
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
import anndata as ad
|
|
50
|
+
from bootstrap2 import bootstrap2h
|
|
51
|
+
|
|
52
|
+
adata = ad.read_h5ad("counts.h5ad")
|
|
53
|
+
result = bootstrap2h(
|
|
54
|
+
adata,
|
|
55
|
+
condition=("SLE", "Control"),
|
|
56
|
+
n_bootstraps=10000,
|
|
57
|
+
n_jobs=4,
|
|
58
|
+
seed=0,
|
|
59
|
+
lfc=0.0,
|
|
60
|
+
)
|
|
61
|
+
result.to_csv("results.tsv", sep="\t")
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The input AnnData is not modified. Use `layer="counts"` to select a raw-count
|
|
65
|
+
layer in the API.
|
|
66
|
+
|
|
67
|
+
| Parameter | Default | Description |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `n_bootstraps` | 10000 | Number of bootstrap iterations |
|
|
70
|
+
| `n_jobs` | 4 | Worker processes; -1 uses available CPUs |
|
|
71
|
+
| `seed` | 0 | Random seed |
|
|
72
|
+
| `lfc` | 0 | Nonnegative log2 threshold; 0 gives the point null |
|
|
73
|
+
| `condition` | First-appearance order | Numerator then denominator condition |
|
|
74
|
+
|
|
75
|
+
With `condition=("SLE", "Control")`, a positive LFC indicates higher expression
|
|
76
|
+
in SLE. Use `condition_col`, `sample_col` and `batch_col` for alternative
|
|
77
|
+
metadata field names.
|
|
78
|
+
|
|
79
|
+
## Output
|
|
80
|
+
|
|
81
|
+
Returns a DataFrame indexed by gene, with `p_val` and `lfc` columns.
|
|
82
|
+
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
bootstrap2/__init__.py,sha256=mwNvjlA8T-s2XvDAdW2bXv1uFlOnaJHNAXtVisGdJ3Q,156
|
|
2
|
+
bootstrap2/core.py,sha256=T4nqg5SczdrttqZSwZCSA_-aCY9Buwk27WBNdHthA60,8792
|
|
3
|
+
bootstrap2_sc-0.1.0.dist-info/METADATA,sha256=j8_QzQcAHh-DJd6WBv875uucOV_KwB91PWk5-cC4Sk0,2255
|
|
4
|
+
bootstrap2_sc-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
5
|
+
bootstrap2_sc-0.1.0.dist-info/top_level.txt,sha256=z_wmfp_2Spmi3r1eJbQQy3GBSSYrYEdSyuL-SQ6TRQ0,11
|
|
6
|
+
bootstrap2_sc-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bootstrap2
|