diffsvg 0.1.0__tar.gz → 0.2.0__tar.gz
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.
- {diffsvg-0.1.0 → diffsvg-0.2.0}/PKG-INFO +1 -1
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/model.py +169 -22
- {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_samples.py +59 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.codecov.yaml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.cruft.json +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.editorconfig +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/dependabot.yml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/workflows/build.yaml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/workflows/release.yaml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/workflows/test.yaml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.gitignore +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.pre-commit-config.yaml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.readthedocs.yaml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.vscode/extensions.json +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.vscode/launch.json +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/.vscode/settings.json +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/CHANGELOG.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/LICENSE +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/README.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/exact_speed.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/.gitignore +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/compare.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/queue.sh +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/run.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/biome.jsonc +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/_static/.gitkeep +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/_static/css/custom.css +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/_templates/.gitkeep +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/api.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/changelog.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/conf.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/contributing.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/index.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/method.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/notebooks/full_tutorial.ipynb +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/notebooks/single_slide_test.ipynb +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/notebooks/test_comparison_legnini.ipynb +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/references.bib +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/references.md +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/pyproject.toml +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/__init__.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/__init__.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/plot_clusters.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/plot_results.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/plot_stability.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pp/__init__.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pp/utils.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/py.typed +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/__init__.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/_exact.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/clustering.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/stability.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_clustering.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_exact.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_parallel_curves.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_statistical_fixes.py +0 -0
- {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_weighted_bins.py +0 -0
|
@@ -20,6 +20,7 @@ import pandas as pd
|
|
|
20
20
|
from joblib import Parallel, delayed
|
|
21
21
|
from scipy import optimize
|
|
22
22
|
from scipy.stats import chi2, f, gamma, ncf, ncx2
|
|
23
|
+
from scipy.stats import t as student_t
|
|
23
24
|
from tqdm.auto import tqdm
|
|
24
25
|
|
|
25
26
|
from diffsvg.pp.utils import BinnedData, _bspline_basis_01, _expression, spline_basis_at
|
|
@@ -320,6 +321,8 @@ class _FitShared:
|
|
|
320
321
|
sample_index: np.ndarray | None = None
|
|
321
322
|
n_samples: int | None = None
|
|
322
323
|
df_denominator: int | None = None
|
|
324
|
+
# The anchor range the tests are computed over, see `_anchor_support`.
|
|
325
|
+
support: tuple = (0.0, 1.0)
|
|
323
326
|
|
|
324
327
|
|
|
325
328
|
@dataclass
|
|
@@ -825,15 +828,17 @@ def _wald_gene_stats(draws0, draws1, idata, shared, moments=None):
|
|
|
825
828
|
`draws0`/`draws1` are posterior draws of the mean curves, so the covariance is that of the
|
|
826
829
|
coefficients behind ``d(x) = f1(x) - f0(x)`` (rank ``n_bsplines``: the level plus the shape directions).
|
|
827
830
|
"""
|
|
831
|
+
x = np.asarray(shared.bin_centers, dtype=float)
|
|
832
|
+
inside = (x >= shared.support[0] - 1e-9) & (x <= shared.support[1] + 1e-9)
|
|
828
833
|
if moments is None:
|
|
829
834
|
delta = draws1 - draws0
|
|
830
835
|
delta_mean = delta.mean(axis=0)
|
|
831
|
-
cov = np.cov(delta, rowvar=False)
|
|
836
|
+
cov = np.cov(delta[:, inside], rowvar=False)
|
|
832
837
|
else:
|
|
833
838
|
A0, A1 = _condition_maps(shared)
|
|
834
839
|
A = A1 - A0
|
|
835
|
-
delta_mean, cov = A @ moments[0], A @ moments[1] @ A.T
|
|
836
|
-
wald_stat, wald_pval, wald_df = _wald_from_moments(delta_mean, cov, df_denominator=shared.df_denominator)
|
|
840
|
+
delta_mean, cov = A @ moments[0], A[inside] @ moments[1] @ A[inside].T
|
|
841
|
+
wald_stat, wald_pval, wald_df = _wald_from_moments(delta_mean[inside], cov, df_denominator=shared.df_denominator)
|
|
837
842
|
T = float(np.mean(delta_mean**2))
|
|
838
843
|
row = {"wald_stat": wald_stat, "wald_pval": wald_pval, "wald_df": wald_df, "T": T}
|
|
839
844
|
if shared.df_denominator is not None:
|
|
@@ -841,19 +846,47 @@ def _wald_gene_stats(draws0, draws1, idata, shared, moments=None):
|
|
|
841
846
|
return row, delta_mean
|
|
842
847
|
|
|
843
848
|
|
|
844
|
-
def
|
|
845
|
-
"""
|
|
849
|
+
def _anchor_support(binned, coverage=0.96):
|
|
850
|
+
"""The anchor range the condition tests are computed over: where the cells are.
|
|
851
|
+
|
|
852
|
+
The central `coverage` of the cells (2nd to 98th percentile by default), cut to the grid points where both
|
|
853
|
+
conditions have cells. Outside it a curve is extrapolated from the spline and the prior, so a level, slope
|
|
854
|
+
or shape averaged over all of ``[0, 1]`` would be dominated by the prior there; with slides that makes the
|
|
855
|
+
test far too confident. Returns ``(lo, hi)``, or ``(0, 1)`` when the cell counts per grid point are unknown
|
|
856
|
+
(overlapping bins, fits on cells).
|
|
857
|
+
"""
|
|
858
|
+
counts = getattr(binned, "n_obs_grid", None)
|
|
859
|
+
if counts is None:
|
|
860
|
+
return 0.0, 1.0
|
|
861
|
+
counts = np.asarray(counts, dtype=float)
|
|
862
|
+
x = np.asarray(binned.bin_centers, dtype=float)
|
|
863
|
+
both = (counts > 0).all(axis=0) if counts.shape[0] > 1 else counts[0] > 0
|
|
864
|
+
total = counts.sum(axis=0) * both
|
|
865
|
+
if total.sum() == 0:
|
|
866
|
+
return 0.0, 1.0
|
|
867
|
+
cdf = np.cumsum(total) / total.sum()
|
|
868
|
+
tail = (1 - coverage) / 2
|
|
869
|
+
lo, hi = x[np.searchsorted(cdf, tail)], x[min(np.searchsorted(cdf, 1 - tail), len(x) - 1)]
|
|
870
|
+
return (float(lo), float(hi)) if hi > lo else (0.0, 1.0)
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _level_weights(bin_centers, support=(0.0, 1.0)):
|
|
874
|
+
"""Trapezoid weights that turn a curve on the grid into its mean over `support`."""
|
|
846
875
|
x = np.asarray(bin_centers, dtype=float)
|
|
876
|
+
inside = (x >= support[0] - 1e-9) & (x <= support[1] + 1e-9)
|
|
877
|
+
xi = x[inside]
|
|
847
878
|
w = np.zeros(len(x))
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
879
|
+
wi = np.zeros(len(xi))
|
|
880
|
+
dx = np.diff(xi)
|
|
881
|
+
wi[:-1] += dx / 2
|
|
882
|
+
wi[1:] += dx / 2
|
|
883
|
+
w[inside] = wi / (xi[-1] - xi[0])
|
|
884
|
+
return w
|
|
852
885
|
|
|
853
886
|
|
|
854
887
|
def _level_gene_stats(draws0, draws1, idata, shared, moments=None):
|
|
855
888
|
"""Test of the level difference ``mean_x (f1 - f0)``, ``H0: |level| <= tau``, plus its credible interval."""
|
|
856
|
-
a = _level_weights(shared.bin_centers)
|
|
889
|
+
a = _level_weights(shared.bin_centers, shared.support)
|
|
857
890
|
level_draws = (draws1 - draws0) @ a
|
|
858
891
|
if moments is None:
|
|
859
892
|
delta_mean = (draws1 - draws0).mean(axis=0)
|
|
@@ -878,6 +911,77 @@ def _level_gene_stats(draws0, draws1, idata, shared, moments=None):
|
|
|
878
911
|
return row, delta_mean
|
|
879
912
|
|
|
880
913
|
|
|
914
|
+
def _two_stage_stats(shared, Y, genes, test, tau, ridge=1e-3):
|
|
915
|
+
"""Two-stage test for data with slides: a curve per slide, then a comparison of the slides' summaries.
|
|
916
|
+
|
|
917
|
+
Stage 1 fits each slide's curve ``a_s + B(x) b_s`` by weighted least squares on its own rows (a small
|
|
918
|
+
ridge keeps it defined where a slide covers few grid points). Stage 2 compares a summary of these curves
|
|
919
|
+
between the conditions with every slide counted once: the level (mean over ``[0, 1]``) or the slope
|
|
920
|
+
(least-squares slope) by a two-sample t-test, the shape (the spline coefficients) by Hotelling's T^2.
|
|
921
|
+
This is the summary-statistics approach to repeated measures: it makes no assumption on how slides vary,
|
|
922
|
+
which the joint model's random curve (one scale for every spline direction) does.
|
|
923
|
+
"""
|
|
924
|
+
n_s = shared.n_samples
|
|
925
|
+
X = np.column_stack([np.ones(len(shared.B)), shared.B])
|
|
926
|
+
w = np.ones(len(X)) if shared.weights is None else np.asarray(shared.weights, dtype=float)
|
|
927
|
+
Ymat = np.column_stack([np.asarray(Y[g], dtype=float) for g in genes])
|
|
928
|
+
coefs, cond = [], np.empty(n_s)
|
|
929
|
+
for s in range(n_s):
|
|
930
|
+
rows = np.asarray(shared.sample_index) == s
|
|
931
|
+
Xs, ws = X[rows], w[rows]
|
|
932
|
+
coefs.append(
|
|
933
|
+
np.linalg.solve(Xs.T @ (ws[:, None] * Xs) + ridge * np.eye(X.shape[1]), Xs.T @ (ws[:, None] * Ymat[rows]))
|
|
934
|
+
)
|
|
935
|
+
cond[s] = np.asarray(shared.condition_fit)[rows][0]
|
|
936
|
+
coefs = np.stack(coefs) # (slides, 1 + K, genes)
|
|
937
|
+
curves = np.einsum("pk,skg->spg", np.column_stack([np.ones(len(shared.B_pred)), shared.B_pred]), coefs)
|
|
938
|
+
g1, g0 = cond == 1, cond == 0
|
|
939
|
+
n1, n0 = int(g1.sum()), int(g0.sum())
|
|
940
|
+
df = n1 + n0 - 2
|
|
941
|
+
rows = {}
|
|
942
|
+
if test in ("level", "slope"):
|
|
943
|
+
a = _level_weights(shared.bin_centers, shared.support)
|
|
944
|
+
if test == "level":
|
|
945
|
+
func = a
|
|
946
|
+
else:
|
|
947
|
+
xc = shared.bin_centers - a @ shared.bin_centers
|
|
948
|
+
func = a * xc / (a @ xc**2)
|
|
949
|
+
summ = np.einsum("p,spg->sg", func, curves) # (slides, genes)
|
|
950
|
+
diff = summ[g1].mean(0) - summ[g0].mean(0)
|
|
951
|
+
pooled = (((summ[g1] - summ[g1].mean(0)) ** 2).sum(0) + ((summ[g0] - summ[g0].mean(0)) ** 2).sum(0)) / df
|
|
952
|
+
var = pooled * (1 / n1 + 1 / n0)
|
|
953
|
+
half = student_t.ppf(0.97, df) * np.sqrt(var)
|
|
954
|
+
for i, g in enumerate(genes):
|
|
955
|
+
stat = diff[i] ** 2 / var[i] if var[i] > 0 else np.inf
|
|
956
|
+
rows[g] = {
|
|
957
|
+
f"{test}_diff": diff[i], f"{test}_diff_ci_low": diff[i] - half[i], f"{test}_diff_ci_high": diff[i] + half[i],
|
|
958
|
+
f"{test}_stat": stat, f"{test}_pval": _one_df_pval(stat, var[i], tau, df) if var[i] > 0 else 0.0,
|
|
959
|
+
f"{test}_df_denominator": df,
|
|
960
|
+
} # fmt: skip
|
|
961
|
+
elif test == "parallel":
|
|
962
|
+
x = np.asarray(shared.bin_centers, dtype=float)
|
|
963
|
+
inside = (x >= shared.support[0] - 1e-9) & (x <= shared.support[1] + 1e-9)
|
|
964
|
+
cols = np.abs(np.asarray(shared.B_pred)[inside]).max(axis=0) > 1e-8 # basis functions seen on the support
|
|
965
|
+
coefs = np.concatenate([coefs[:, :1], coefs[:, 1:][:, cols]], axis=1)
|
|
966
|
+
K = coefs.shape[1] - 1
|
|
967
|
+
if df - K + 1 < 1:
|
|
968
|
+
raise ValueError(f"The two-stage shape test needs more than {K + 1} slides, found {n_s}; use test='slope'.")
|
|
969
|
+
for i, g in enumerate(genes):
|
|
970
|
+
b = coefs[:, 1:, i]
|
|
971
|
+
d = b[g1].mean(0) - b[g0].mean(0)
|
|
972
|
+
S = (
|
|
973
|
+
(b[g1] - b[g1].mean(0)).T @ (b[g1] - b[g1].mean(0))
|
|
974
|
+
+ (b[g0] - b[g0].mean(0)).T @ (b[g0] - b[g0].mean(0))
|
|
975
|
+
) / df
|
|
976
|
+
T2 = float(d @ np.linalg.pinv(S) @ d) * n1 * n0 / (n1 + n0)
|
|
977
|
+
F = T2 * (df - K + 1) / (df * K)
|
|
978
|
+
rows[g] = {"parallel_stat": T2, "parallel_pval": float(f.sf(F, K, df - K + 1)), "parallel_df": K,
|
|
979
|
+
"parallel_df_denominator": df - K + 1} # fmt: skip
|
|
980
|
+
else:
|
|
981
|
+
return None
|
|
982
|
+
return pd.DataFrame.from_dict(rows, orient="index")
|
|
983
|
+
|
|
984
|
+
|
|
881
985
|
def _level_panel_stats(stats_df, null_quantile):
|
|
882
986
|
stats_df["level_pval_adj"] = _benjamini_hochberg(stats_df["level_pval"].to_numpy())
|
|
883
987
|
|
|
@@ -1083,6 +1187,7 @@ def fit_model(
|
|
|
1083
1187
|
predictive_seed=None,
|
|
1084
1188
|
inference="auto",
|
|
1085
1189
|
coefs_key="dsvg_coefs",
|
|
1190
|
+
two_stage=False,
|
|
1086
1191
|
**pymc_kwargs,
|
|
1087
1192
|
):
|
|
1088
1193
|
"""Fit every per-gene model from `setup_model`, in parallel, and summarize the result.
|
|
@@ -1195,6 +1300,14 @@ def fit_model(
|
|
|
1195
1300
|
the population-level ones. Fit on bins (NUTS is fine) or on cells
|
|
1196
1301
|
(``use_binned=False``).
|
|
1197
1302
|
|
|
1303
|
+
Every test is computed over the anchor range where the cells are:
|
|
1304
|
+
the central 96% of them, cut to where both conditions have cells
|
|
1305
|
+
(``anchor_support`` in the stored test parameters). Outside it the
|
|
1306
|
+
curves are extrapolated from the spline and the prior, and averaging
|
|
1307
|
+
over that part would make the test overconfident, most of all for
|
|
1308
|
+
cell types that occupy only part of the anchor range. Slopes and
|
|
1309
|
+
shape differences stay in units of the full ``[0, 1]`` range.
|
|
1310
|
+
|
|
1198
1311
|
Both tests also write ``integral`` (the area between the curves),
|
|
1199
1312
|
the ``categorical_beta`` summary and the sampler diagnostics.
|
|
1200
1313
|
Binned fits with overlapping windows (`diffsvg.pp.bin_obs`'s default
|
|
@@ -1211,6 +1324,18 @@ def fit_model(
|
|
|
1211
1324
|
Defaults to ``0.1`` for pooled data and to ``0`` for data binned with
|
|
1212
1325
|
`sample`, where the variation between slides already calibrates the
|
|
1213
1326
|
test and a positive `tau` only costs power.
|
|
1327
|
+
two_stage
|
|
1328
|
+
With data binned with `sample`: compute the test from one curve per
|
|
1329
|
+
slide, comparing the slides' summaries between the conditions (level
|
|
1330
|
+
or slope: t-test; shape: Hotelling's T^2; ``n_slides - 2``
|
|
1331
|
+
denominator degrees of freedom). Every slide counts once and nothing
|
|
1332
|
+
is assumed about how slides differ, so the error rate holds when the
|
|
1333
|
+
joint model's random curve is misspecified. The fitted curves and
|
|
1334
|
+
bands still come from the joint model. ``False`` (default) uses the
|
|
1335
|
+
joint model's posterior for the test as well, which has more power
|
|
1336
|
+
with few slides. Use ``True`` when the cells cover only part of the
|
|
1337
|
+
anchor range (e.g. a cell type confined to some cortical layers);
|
|
1338
|
+
`fit_model` warns then.
|
|
1214
1339
|
predictive_seed
|
|
1215
1340
|
Deprecated, has no effect. The curves, their bands, ``integral`` and
|
|
1216
1341
|
the Wald test are now computed from the posterior draws of the mean
|
|
@@ -1289,6 +1414,15 @@ def fit_model(
|
|
|
1289
1414
|
if use_binned and binned.condition is not None and binned.n_obs_grid is not None:
|
|
1290
1415
|
_warn_unsupported_range(binned)
|
|
1291
1416
|
fit_genes = _fittable_genes(binned.genes, Y)
|
|
1417
|
+
support = _anchor_support(binned) if use_binned else (0.0, 1.0)
|
|
1418
|
+
if n_samples is not None and not two_stage and support[1] - support[0] < 0.75:
|
|
1419
|
+
warnings.warn(
|
|
1420
|
+
f"The cells cover only {support[0]:.2f} to {support[1]:.2f} of the anchor range. With slides, the "
|
|
1421
|
+
"joint model's test can be overconfident there (random slide splits of cell types confined to part of "
|
|
1422
|
+
"the range gave false hits); `two_stage=True` stays calibrated.",
|
|
1423
|
+
UserWarning,
|
|
1424
|
+
stacklevel=2,
|
|
1425
|
+
)
|
|
1292
1426
|
shared = _FitShared(
|
|
1293
1427
|
B=B,
|
|
1294
1428
|
B_masked=B_masked,
|
|
@@ -1304,8 +1438,11 @@ def fit_model(
|
|
|
1304
1438
|
condition_pred_1=binned.condition_pred_1,
|
|
1305
1439
|
test=test,
|
|
1306
1440
|
tau=float(tau),
|
|
1307
|
-
shape_metric=shape_metric(binned.n_bsplines, binned.degree) if test == "parallel" else None,
|
|
1308
|
-
slope_weights=
|
|
1441
|
+
shape_metric=shape_metric(binned.n_bsplines, binned.degree, support=support) if test == "parallel" else None,
|
|
1442
|
+
slope_weights=(
|
|
1443
|
+
_slope_weights(binned.n_bsplines, binned.degree, support=support) if test in ("parallel", "slope") else None
|
|
1444
|
+
),
|
|
1445
|
+
support=support,
|
|
1309
1446
|
weights=weights,
|
|
1310
1447
|
inference=inference,
|
|
1311
1448
|
sample_index=sample_index,
|
|
@@ -1338,8 +1475,14 @@ def fit_model(
|
|
|
1338
1475
|
curve_rows[gene] = curve_row
|
|
1339
1476
|
|
|
1340
1477
|
stats_df = pd.DataFrame(stats_rows).set_index("gene").reindex(binned.genes)
|
|
1478
|
+
if two_stage and n_samples is not None:
|
|
1479
|
+
two = _two_stage_stats(shared, Y, list(fit_genes), test, tau)
|
|
1480
|
+
if two is not None:
|
|
1481
|
+
stats_df.loc[two.index, two.columns] = two
|
|
1341
1482
|
_TESTS[test][1](stats_df, null_quantile)
|
|
1342
1483
|
test_params = {"test": test, "tau": float(tau)} if test in ("parallel", "level", "slope") else {"test": test}
|
|
1484
|
+
test_params["two_stage"] = bool(two_stage and n_samples is not None)
|
|
1485
|
+
test_params["anchor_support"] = [float(v) for v in support]
|
|
1343
1486
|
|
|
1344
1487
|
# Column labels must be strings (not e.g. plain floats) so that
|
|
1345
1488
|
# `adata.varm[...]` round-trips through h5ad, whose DataFrame writer
|
|
@@ -1717,15 +1860,15 @@ def _wald_from_moments(mu, Sigma, ridge=0.0, df_denominator=None):
|
|
|
1717
1860
|
return W, pval, df
|
|
1718
1861
|
|
|
1719
1862
|
|
|
1720
|
-
def _shape_grid(n_bsplines, degree, n_grid):
|
|
1721
|
-
"""Fine grid on
|
|
1722
|
-
x = np.linspace(0
|
|
1863
|
+
def _shape_grid(n_bsplines, degree, n_grid, support=(0.0, 1.0)):
|
|
1864
|
+
"""Fine grid on `support`, its trapezoid weights (summing to 1) and the uncentred basis on it."""
|
|
1865
|
+
x = np.linspace(support[0], support[1], n_grid)
|
|
1723
1866
|
w = np.full(n_grid, 1.0 / (n_grid - 1))
|
|
1724
1867
|
w[[0, -1]] /= 2
|
|
1725
1868
|
return x, w, _bspline_basis_01(x, n_bsplines, degree)
|
|
1726
1869
|
|
|
1727
1870
|
|
|
1728
|
-
def shape_metric(n_bsplines=10, degree=3, n_grid=2001):
|
|
1871
|
+
def shape_metric(n_bsplines=10, degree=3, n_grid=2001, support=(0.0, 1.0)):
|
|
1729
1872
|
"""Quadratic form `Q` that measures how far two condition curves are from parallel.
|
|
1730
1873
|
|
|
1731
1874
|
For interaction coefficients `b` (``beta_1`` of the per-gene model),
|
|
@@ -1745,6 +1888,9 @@ def shape_metric(n_bsplines=10, degree=3, n_grid=2001):
|
|
|
1745
1888
|
As in `diffsvg.pp.bin_obs`.
|
|
1746
1889
|
n_grid
|
|
1747
1890
|
Grid points for the numerical integral.
|
|
1891
|
+
support
|
|
1892
|
+
The part of ``[0, 1]`` the shape is measured over (see `fit_model`).
|
|
1893
|
+
Slopes stay in units of the full anchor range.
|
|
1748
1894
|
|
|
1749
1895
|
Returns
|
|
1750
1896
|
-------
|
|
@@ -1752,15 +1898,16 @@ def shape_metric(n_bsplines=10, degree=3, n_grid=2001):
|
|
|
1752
1898
|
`Q`, shape ``(n_bsplines, n_bsplines)``, positive semi-definite with
|
|
1753
1899
|
rank ``n_bsplines - 1``.
|
|
1754
1900
|
"""
|
|
1755
|
-
_, w, B = _shape_grid(n_bsplines, degree, n_grid)
|
|
1901
|
+
_, w, B = _shape_grid(n_bsplines, degree, n_grid, support)
|
|
1756
1902
|
centred = B - w @ B
|
|
1757
|
-
return 12.0 * (centred * w[:, None]).T @ centred
|
|
1903
|
+
return 12.0 / (support[1] - support[0]) ** 2 * (centred * w[:, None]).T @ centred
|
|
1758
1904
|
|
|
1759
1905
|
|
|
1760
|
-
def _slope_weights(n_bsplines, degree, n_grid=2001):
|
|
1761
|
-
"""`g` with ``g @ b`` = the least-squares slope over
|
|
1762
|
-
x, w, B = _shape_grid(n_bsplines, degree, n_grid)
|
|
1763
|
-
|
|
1906
|
+
def _slope_weights(n_bsplines, degree, n_grid=2001, support=(0.0, 1.0)):
|
|
1907
|
+
"""`g` with ``g @ b`` = the least-squares slope over `support` of the difference curve for coefficients `b`."""
|
|
1908
|
+
x, w, B = _shape_grid(n_bsplines, degree, n_grid, support)
|
|
1909
|
+
xc = x - w @ x
|
|
1910
|
+
return ((xc * w) @ B) / (w @ xc**2)
|
|
1764
1911
|
|
|
1765
1912
|
|
|
1766
1913
|
def parallel_curves_test(beta_1, metric, tau=0.1, cov=None, df_denominator=None):
|
|
@@ -163,3 +163,62 @@ def test_cells_without_a_sample_are_left_out_of_the_binning():
|
|
|
163
163
|
)
|
|
164
164
|
assert b.n_samples == 9 and "s3" not in b.sample_labels
|
|
165
165
|
assert b.n_obs.sum() == adata.n_obs - 200
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def test_two_stage_slope_test_on_slides():
|
|
169
|
+
"""Slides with random slopes and no condition effect stay null; a real slope difference is found."""
|
|
170
|
+
rng = np.random.default_rng(1)
|
|
171
|
+
rows = []
|
|
172
|
+
for s in range(12):
|
|
173
|
+
cond = "a" if s < 6 else "b"
|
|
174
|
+
x = rng.random(400)
|
|
175
|
+
slope = rng.normal(1.0, 0.3) + (0.8 if cond == "b" else 0.0)
|
|
176
|
+
null = rng.normal(0, 0.3) + rng.normal(1.0, 0.3) * x # random slide slope, no condition effect
|
|
177
|
+
eff = slope * x
|
|
178
|
+
for xi, n_i, e_i in zip(x, null, eff, strict=True):
|
|
179
|
+
rows.append((xi, cond, f"s{s}", n_i + rng.normal(0, 0.2), e_i + rng.normal(0, 0.2)))
|
|
180
|
+
df = pd.DataFrame(rows, columns=["anchor", "condition", "slide", "null", "effect"])
|
|
181
|
+
adata = ad.AnnData(df[["null", "effect"]].to_numpy(np.float32), var=pd.DataFrame(index=["null", "effect"]))
|
|
182
|
+
adata.obsm["design_matrix"] = df[["anchor", "condition", "slide"]].set_index(adata.obs_names)
|
|
183
|
+
dvg.pp.bin_obs(adata, anchor="anchor", condition="condition", n_bins=N_BINS, window_radius=None, sample="slide")
|
|
184
|
+
stats, _, _ = dvg.tl.fit_model(
|
|
185
|
+
adata, dvg.tl.setup_model(adata), test="slope", inplace=False, progressbar=False, two_stage=True
|
|
186
|
+
)
|
|
187
|
+
assert stats.loc["effect", "slope_pval"] < 0.01 and stats.loc["null", "slope_pval"] > 0.01
|
|
188
|
+
assert abs(stats.loc["effect", "slope_diff"] - 0.8) < 0.3
|
|
189
|
+
assert stats.loc["effect", "slope_df_denominator"] == 10
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def test_tests_use_the_anchor_range_where_the_cells_are():
|
|
193
|
+
"""Cells only in the middle of the anchor range: the tests use that range and stay calibrated."""
|
|
194
|
+
rng = np.random.default_rng(3)
|
|
195
|
+
rows = []
|
|
196
|
+
for s in range(10):
|
|
197
|
+
cond = "a" if s < 5 else "b"
|
|
198
|
+
x = rng.uniform(0.4, 0.8, 300)
|
|
199
|
+
tilt = rng.normal(0, 0.3) # slide-level slope, no condition effect
|
|
200
|
+
for xi in x:
|
|
201
|
+
rows.append((xi, cond, f"s{s}", tilt * (xi - 0.6) + rng.normal(0, 0.2)))
|
|
202
|
+
df = pd.DataFrame(rows, columns=["anchor", "condition", "slide", "y"])
|
|
203
|
+
df.loc[df.index[:2], "anchor"] = [0.0, 1.0] # bin_obs scales by the full range; keep [0, 1] as the anchor range
|
|
204
|
+
adata = ad.AnnData(df[["y"]].to_numpy(np.float32), var=pd.DataFrame(index=["y"]))
|
|
205
|
+
adata.obsm["design_matrix"] = df[["anchor", "condition", "slide"]].set_index(adata.obs_names)
|
|
206
|
+
dvg.pp.bin_obs(adata, anchor="anchor", condition="condition", n_bins=N_BINS, window_radius=None, sample="slide")
|
|
207
|
+
with pytest.warns(UserWarning, match="cover only"):
|
|
208
|
+
stats, _, _ = dvg.tl.fit_model(
|
|
209
|
+
adata, dvg.tl.setup_model(adata), test="slope", inplace=False, progressbar=False, two_stage=False
|
|
210
|
+
)
|
|
211
|
+
assert stats.loc["y", "slope_pval"] > 0.01
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_anchor_support_is_the_central_cells_where_both_conditions_are():
|
|
215
|
+
from types import SimpleNamespace
|
|
216
|
+
|
|
217
|
+
from diffsvg.tl.model import _anchor_support
|
|
218
|
+
|
|
219
|
+
x = np.linspace(0, 1, 11)
|
|
220
|
+
counts = np.zeros((2, 11))
|
|
221
|
+
counts[:, 3:9] = 100 # both conditions at 0.3-0.8
|
|
222
|
+
counts[0, 1] = 50 # one condition only: not part of the support
|
|
223
|
+
assert np.allclose(_anchor_support(SimpleNamespace(n_obs_grid=counts, bin_centers=x)), (0.3, 0.8))
|
|
224
|
+
assert _anchor_support(SimpleNamespace(n_obs_grid=None, bin_centers=x)) == (0.0, 1.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|