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.
Files changed (60) hide show
  1. {diffsvg-0.1.0 → diffsvg-0.2.0}/PKG-INFO +1 -1
  2. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/model.py +169 -22
  3. {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_samples.py +59 -0
  4. {diffsvg-0.1.0 → diffsvg-0.2.0}/.codecov.yaml +0 -0
  5. {diffsvg-0.1.0 → diffsvg-0.2.0}/.cruft.json +0 -0
  6. {diffsvg-0.1.0 → diffsvg-0.2.0}/.editorconfig +0 -0
  7. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  8. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  9. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  10. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/dependabot.yml +0 -0
  11. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/workflows/build.yaml +0 -0
  12. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/workflows/release.yaml +0 -0
  13. {diffsvg-0.1.0 → diffsvg-0.2.0}/.github/workflows/test.yaml +0 -0
  14. {diffsvg-0.1.0 → diffsvg-0.2.0}/.gitignore +0 -0
  15. {diffsvg-0.1.0 → diffsvg-0.2.0}/.pre-commit-config.yaml +0 -0
  16. {diffsvg-0.1.0 → diffsvg-0.2.0}/.readthedocs.yaml +0 -0
  17. {diffsvg-0.1.0 → diffsvg-0.2.0}/.vscode/extensions.json +0 -0
  18. {diffsvg-0.1.0 → diffsvg-0.2.0}/.vscode/launch.json +0 -0
  19. {diffsvg-0.1.0 → diffsvg-0.2.0}/.vscode/settings.json +0 -0
  20. {diffsvg-0.1.0 → diffsvg-0.2.0}/CHANGELOG.md +0 -0
  21. {diffsvg-0.1.0 → diffsvg-0.2.0}/LICENSE +0 -0
  22. {diffsvg-0.1.0 → diffsvg-0.2.0}/README.md +0 -0
  23. {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/exact_speed.py +0 -0
  24. {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/.gitignore +0 -0
  25. {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/compare.py +0 -0
  26. {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/queue.sh +0 -0
  27. {diffsvg-0.1.0 → diffsvg-0.2.0}/benchmarks/fix_validation/run.py +0 -0
  28. {diffsvg-0.1.0 → diffsvg-0.2.0}/biome.jsonc +0 -0
  29. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/_static/.gitkeep +0 -0
  30. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/_static/css/custom.css +0 -0
  31. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/_templates/.gitkeep +0 -0
  32. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/api.md +0 -0
  33. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/changelog.md +0 -0
  34. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/conf.py +0 -0
  35. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/contributing.md +0 -0
  36. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/index.md +0 -0
  37. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/method.md +0 -0
  38. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/notebooks/full_tutorial.ipynb +0 -0
  39. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/notebooks/single_slide_test.ipynb +0 -0
  40. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/notebooks/test_comparison_legnini.ipynb +0 -0
  41. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/references.bib +0 -0
  42. {diffsvg-0.1.0 → diffsvg-0.2.0}/docs/references.md +0 -0
  43. {diffsvg-0.1.0 → diffsvg-0.2.0}/pyproject.toml +0 -0
  44. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/__init__.py +0 -0
  45. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/__init__.py +0 -0
  46. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/plot_clusters.py +0 -0
  47. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/plot_results.py +0 -0
  48. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pl/plot_stability.py +0 -0
  49. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pp/__init__.py +0 -0
  50. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/pp/utils.py +0 -0
  51. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/py.typed +0 -0
  52. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/__init__.py +0 -0
  53. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/_exact.py +0 -0
  54. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/clustering.py +0 -0
  55. {diffsvg-0.1.0 → diffsvg-0.2.0}/src/diffsvg/tl/stability.py +0 -0
  56. {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_clustering.py +0 -0
  57. {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_exact.py +0 -0
  58. {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_parallel_curves.py +0 -0
  59. {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_statistical_fixes.py +0 -0
  60. {diffsvg-0.1.0 → diffsvg-0.2.0}/tests/test_weighted_bins.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: diffsvg
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Differential spatially variable genes
5
5
  Project-URL: Documentation, https://diffsvg.readthedocs.io/
6
6
  Project-URL: Homepage, https://github.com/theislab/diffsvg
@@ -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 _level_weights(bin_centers):
845
- """Trapezoid weights that turn a curve on the grid into its mean over ``[0, 1]``."""
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
- dx = np.diff(x)
849
- w[:-1] += dx / 2
850
- w[1:] += dx / 2
851
- return w / (x[-1] - x[0])
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=_slope_weights(binned.n_bsplines, binned.degree) if test in ("parallel", "slope") else None,
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 ``[0, 1]``, its trapezoid weights (summing to 1) and the uncentred basis on it."""
1722
- x = np.linspace(0.0, 1.0, n_grid)
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 ``[0, 1]`` of the difference curve for coefficients `b`."""
1762
- x, w, B = _shape_grid(n_bsplines, degree, n_grid)
1763
- return 12.0 * ((x - 0.5) * w) @ B
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