Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8cd7688
feat(de): add ls_scheme to choose how the shared length scale is esti…
katosh Aug 7, 2026
487a02e
fix(de): a custom cov_func must not turn the default into `separate`
katosh Aug 7, 2026
3cfc904
feat(de): add the "condition2" ls_scheme, the mirror of the default
katosh Sep 21, 2026
082a702
docs(de): the condition1/condition2 equivalence has a precondition th…
katosh Sep 22, 2026
55d4ef2
docs(de): name the seed beside the 6-of-9 count, and say the default …
katosh Sep 22, 2026
2bff8e3
docs(de): replace the miss-count range with a per-comparison localisa…
katosh Sep 22, 2026
c907da9
test(da): differential abundance is swap-equivalent at its defaults
katosh Sep 22, 2026
f72573e
fix(docs): correct two over-reaching claims about differential abundance
katosh Sep 22, 2026
c962036
docs(da): the two swap remedies compose; move the precondition into t…
katosh Sep 22, 2026
9fc3794
feat: one GPSettings.param_scheme for de() and da(); remove ls_scheme
katosh Sep 22, 2026
241b36d
docs(tests): wrap two docstring lines to the 100-column limit
katosh Sep 22, 2026
8770879
test(da): take symmetric's reference bounds from the estimator recipe…
katosh Sep 22, 2026
e9f798b
docs(de): point the de() gp entry at param_scheme and its default
katosh Sep 22, 2026
298e032
fix(da): derive mu at a pinned d under every param_scheme; refuse fla…
katosh Sep 22, 2026
7e37ac6
refactor(da)!: remove sync_parameters; param_scheme takes its place
katosh Sep 23, 2026
12e1253
refactor(anndata): overwrite checks take param_scheme from their caller
katosh Sep 23, 2026
812c15b
docs: state only where DA differs; shorten the changelog
katosh Sep 23, 2026
52c8484
fix(da): n_landmarks >= n_combined fits the full GP, not the union as…
katosh Sep 23, 2026
6606ee2
docs(da): scope the #32 change in the changelog; landmark order reach…
katosh Sep 23, 2026
794bf7f
ci: bound and diagnose the intermittent 3.11 hang
katosh Sep 23, 2026
31315ea
fix: keep main's positional order in DifferentialExpression.fit and G…
katosh Sep 23, 2026
343486f
docs(da): the full-GP fallback equals n_landmarks=None only up to 500…
katosh Sep 23, 2026
da733b4
fix(cli): kompot dm validates its input before requiring Palantir
katosh Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ on:
jobs:
test:
runs-on: ubuntu-22.04
# A healthy job takes 20-31 min. Without this, a hung pytest holds the runner
# for GitHub's 360-min default and is reported as "cancelled".
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12']

Expand All @@ -27,15 +31,18 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest pytest-cov
pip install pytest pytest-cov pytest-timeout
# Install JAX CPU version to avoid GPU dependencies in CI
pip install --upgrade "jax[cpu]"
# Install package in development mode with all dependencies including dask
pip install -e ".[all]"

- name: Test with pytest and generate coverage report
run: |
pytest --cov=kompot --cov-report=xml --cov-report=term-missing:skip-covered
# The slowest test takes about 70 s. A test that exceeds 300 s is hung, so fail it
# and dump every thread's stack (thread method), so the log shows where it hung.
pytest --timeout=300 --timeout-method=thread --durations=25 \
--cov=kompot --cov-report=xml --cov-report=term-missing:skip-covered

- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
Expand Down
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,40 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- **`GPSettings.param_scheme`** chooses which cells the length scale is estimated from:
`"condition1"`, `"condition2"`, `"symmetric"`, `"pooled"` or `"separate"`. In `da()` it covers
`d` and `mu` as well. The default, `None`, keeps current behaviour: `"condition1"` in `de()`,
`"separate"` in `da()`. `"symmetric"` is swap-invariant. Both CLIs accept it as a config key.

### Removed

- `sync_parameters` on `DifferentialAbundance.fit` and `da()`; use `param_scheme="pooled"`.

### Fixed

- `da()` now honours `GPSettings.ls`, and warns when the expression-only fields `sigma`, `eps` or
`use_empirical_variance` are set.
- **Abundance uncertainty no longer depends on argument order when `n_landmarks` is at least the
number of cells** (settylab/kompot#32). That is `da(gp=GPSettings(...))` on data under 5000
cells, and the CLI whenever a GP key is set without `n_landmarks`. Each condition now gets its
full GP (the same as `n_landmarks=None` up to 5000 cells per condition, where mellon's own
landmark default starts). **This changes results there**: z-scores and PTPs move to the
full-GP values (on 400 + 400 cells, z by a median of 34%; the two orientations now agree exactly,
where they differed by a median of 82%), and fold changes and densities by at most 6e-4.
`da()` without `gp`, `DifferentialAbundance()`, and every run with fewer landmarks than cells are
unchanged.

### Documentation

- At the default, `de()` depends on argument order: the shared length scale comes from condition
1 alone. See `DifferentialExpression.fit`.
- Automatic landmarks and `"pooled"` make `da()` depend on argument order; see
`DifferentialAbundance.fit`.

## [0.8.0] - 2026-07-28

### Changed — statistics now match the manuscript
Expand Down
4 changes: 2 additions & 2 deletions docs/source/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -443,9 +443,9 @@ The table below shows how config keys correspond to Python Settings:
* - Config key
- Python equivalent
- Description
* - ``sigma``, ``ls``, ``ls_factor``, ``n_landmarks``, ``batch_size``, ``eps``, ``jit_compile``, ``random_state``, ``use_empirical_variance``
* - ``sigma``, ``ls``, ``ls_factor``, ``param_scheme``, ``n_landmarks``, ``batch_size``, ``eps``, ``jit_compile``, ``random_state``, ``use_empirical_variance``
- ``gp=GPSettings(...)``
- GP model parameters
- GP model parameters. In DA, ``sigma`` is the density estimator's and ``eps`` and ``use_empirical_variance`` do not apply. ``param_scheme`` defaults to ``condition1`` in DE and ``separate`` in DA
* - ``null_genes``, ``null_seed``, ``fdr_threshold``
- ``fdr=FDRSettings(...)``
- FDR / null distribution (DE)
Expand Down
7 changes: 7 additions & 0 deletions kompot/anndata/_da_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import pandas as pd
from typing import Optional

from ..differential.differential_abundance import DEFAULT_PARAM_SCHEME

logger = logging.getLogger("kompot")


Expand All @@ -25,6 +27,7 @@ def _check_da_overwrites(
condition2: str,
obsm_key: str,
ls_factor: float,
param_scheme: str,
):
"""Check for existing DA results and handle overwrite logic."""
from .utils import detect_output_field_overwrite
Expand Down Expand Up @@ -101,9 +104,13 @@ def _check_da_overwrites(
"condition2",
"obsm_key",
"ls_factor",
"param_scheme",
]:
curr_val = locals().get(param_name)
prev_val = _pg(prev_params, param_name)
if param_name == "param_scheme" and prev_val is None:
# Recorded before `param_scheme` existed.
prev_val = DEFAULT_PARAM_SCHEME
if curr_val != prev_val:
params_match = False
logger.debug(
Expand Down
18 changes: 16 additions & 2 deletions kompot/anndata/_de_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from typing import Optional, Dict, List, Tuple
from scipy import sparse

from ..differential.differential_expression import DEFAULT_PARAM_SCHEME
from .utils import check_underrepresentation, apply_cell_filter

logger = logging.getLogger("kompot")
Expand Down Expand Up @@ -41,6 +42,7 @@ def _check_overwrites(
obsm_key: str,
layer: Optional[str],
ls_factor: float,
param_scheme: str,
):
"""Check for existing results and handle overwrite logic.

Expand Down Expand Up @@ -134,9 +136,15 @@ def _check_overwrites(
"obsm_key",
"layer",
"ls_factor",
"param_scheme",
]:
curr_val = locals().get(param_name)
prev_val = _pg(prev_params, param_name)
# A run recorded before `param_scheme` existed used the default.
prev_val = _pg(
prev_params,
param_name,
DEFAULT_PARAM_SCHEME if param_name == "param_scheme" else None,
)
if curr_val != prev_val:
params_match = False
logger.debug(
Expand Down Expand Up @@ -189,9 +197,15 @@ def _check_overwrites(
"obsm_key",
"layer",
"ls_factor",
"param_scheme",
]:
curr_val = locals().get(param_name)
prev_val = _pg(prev_params, param_name)
# A run recorded before `param_scheme` existed used the default.
prev_val = _pg(
prev_params,
param_name,
DEFAULT_PARAM_SCHEME if param_name == "param_scheme" else None,
)
if curr_val != prev_val:
params_match = False

Expand Down
55 changes: 50 additions & 5 deletions kompot/anndata/differential_abundance.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
from typing import Union, Dict, Any

from ..differential import DifferentialAbundance
from ..differential.differential_abundance import DEFAULT_PARAM_SCHEME
from ..settings import (
resolve_param_scheme,
GPSettings,
DAThresholdSettings,
StorageSettings,
Expand Down Expand Up @@ -76,9 +78,16 @@ def da(
sample_col : str, optional
Column with biological-replicate labels.
gp : GPSettings, optional
GP model parameters (``ls_factor``, ``n_landmarks``,
``landmarks``, ``batch_size``, ``jit_compile``,
``random_state``).
GP model parameters. Used here: ``ls``, ``ls_factor``,
``param_scheme``, ``n_landmarks``, ``landmarks``, ``batch_size``,
``jit_compile`` and ``random_state``. ``param_scheme`` decides
whether the two density estimators share ``d``, ``mu`` and ``ls``
and where the shared values come from; ``None`` means
``"separate"``. See
:meth:`~kompot.differential.DifferentialAbundance.fit`.
``sigma``, ``eps`` and ``use_empirical_variance`` are
expression-only and are ignored with a warning when set to
anything but their defaults.
threshold : DAThresholdSettings, optional
Significance thresholds for abundance changes.
storage : StorageSettings, optional
Expand All @@ -90,7 +99,8 @@ def da(
``density_predictor1/2`` and ``variance_predictor1/2`` are used.
See :class:`~kompot.ModelSettings`.
**density_kwargs
Forwarded to :class:`~mellon.DensityEstimator`.
Forwarded to :class:`~mellon.DensityEstimator`. ``d``, ``mu`` and
``ls`` given here pin that parameter under any ``param_scheme``.

Returns
-------
Expand All @@ -107,6 +117,33 @@ def da(
batch_size = gp.batch_size if gp is not None else None
jit_compile = gp.jit_compile if gp is not None else False
random_state = gp.random_state if gp is not None else None
param_scheme = gp.param_scheme if gp is not None else None
if "param_scheme" in density_kwargs:
raise ValueError(
"`param_scheme` is a GPSettings field: pass "
"gp=GPSettings(param_scheme=...), not a keyword argument of da()."
)

if gp is not None:
_unused = [
name
for name in ("sigma", "eps", "use_empirical_variance")
if getattr(gp, name) != getattr(GPSettings, name)
]
if _unused:
logger.warning(
f"GPSettings field(s) {_unused} have no effect in da(): they "
"configure the expression model only."
)
if gp.ls is not None:
if "ls" in density_kwargs:
raise ValueError(
"`ls` given both as GPSettings.ls and as a density keyword "
"argument; pass it once."
)
density_kwargs["ls"] = gp.ls

param_scheme = resolve_param_scheme(param_scheme, DEFAULT_PARAM_SCHEME)

_threshold = threshold if threshold is not None else DAThresholdSettings()
log_fold_change_threshold = _threshold.lfc_threshold
Expand Down Expand Up @@ -157,6 +194,7 @@ def da(
condition2=condition2,
obsm_key=obsm_key,
ls_factor=ls_factor,
param_scheme=param_scheme,
)

# ---- 1. Copy if requested ----
Expand Down Expand Up @@ -199,6 +237,7 @@ def da(
condition1_sample_indices=data["condition1_sample_indices"],
condition2_sample_indices=data["condition2_sample_indices"],
allow_single_condition_variance=allow_single_condition_variance,
param_scheme=param_scheme,
**density_kwargs,
)

Expand Down Expand Up @@ -235,7 +274,9 @@ def da(
sample_col=sample_col,
),
gp=GPSettings(
ls=gp.ls if gp is not None else None,
ls_factor=ls_factor,
param_scheme=param_scheme,
n_landmarks=n_landmarks,
batch_size=batch_size,
jit_compile=jit_compile,
Expand All @@ -256,7 +297,11 @@ def da(
allow_single_condition_variance=allow_single_condition_variance,
progress=progress,
),
extra_kwargs=density_kwargs,
extra_kwargs={
k: v
for k, v in density_kwargs.items()
if not (k == "ls" and gp is not None and gp.ls is not None)
},
landmarks_provided=landmarks is not None,
)

Expand Down
15 changes: 15 additions & 0 deletions kompot/anndata/differential_expression.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
from typing import Union, Dict, Any

from ..differential import DifferentialExpression
from ..differential.differential_expression import DEFAULT_PARAM_SCHEME
from ..settings import resolve_param_scheme
from ..settings import (
GPSettings,
FDRSettings,
Expand Down Expand Up @@ -97,6 +99,10 @@ def de(
Column with biological-replicate labels.
gp : GPSettings, optional
GP model parameters (sigma, ls, n_landmarks, etc.).
``param_scheme`` sets which cells the shared length scale is
estimated from; ``None`` means ``"condition1"``, which makes the
result depend on which condition is passed first. See
:meth:`~kompot.differential.DifferentialExpression.fit`.
fdr : FDRSettings, optional
FDR / null-distribution parameters.
filter : FilterSettings, optional
Expand Down Expand Up @@ -132,6 +138,12 @@ def de(
sigma = _gp.sigma
ls = _gp.ls
ls_factor = _gp.ls_factor
if "param_scheme" in function_kwargs:
raise ValueError(
"`param_scheme` is a GPSettings field: pass "
"gp=GPSettings(param_scheme=...), not a keyword argument of de()."
)
param_scheme = resolve_param_scheme(_gp.param_scheme, DEFAULT_PARAM_SCHEME)
n_landmarks = _gp.n_landmarks
landmarks = _gp.landmarks
use_empirical_variance = _gp.use_empirical_variance
Expand Down Expand Up @@ -305,6 +317,7 @@ def de(
obsm_key=obsm_key,
layer=layer,
ls_factor=ls_factor,
param_scheme=param_scheme,
)

# ---- 2. Copy if requested ----
Expand Down Expand Up @@ -455,6 +468,7 @@ def de(
sigma=sigma,
ls=ls,
ls_factor=ls_factor,
param_scheme=param_scheme,
landmarks=landmarks,
condition1_sample_indices=condition1_sample_indices,
condition2_sample_indices=condition2_sample_indices,
Expand Down Expand Up @@ -635,6 +649,7 @@ def de(
sigma=sigma,
ls=ls,
ls_factor=ls_factor,
param_scheme=param_scheme,
n_landmarks=n_landmarks,
use_empirical_variance=use_empirical_variance,
batch_size=batch_size,
Expand Down
1 change: 1 addition & 0 deletions kompot/anndata/utils/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def params_to_call_args(
"sigma": ("gp", "sigma"),
"ls": ("gp", "ls"),
"ls_factor": ("gp", "ls_factor"),
"param_scheme": ("gp", "param_scheme"),
"n_landmarks": ("gp", "n_landmarks"),
"use_empirical_variance": ("gp", "use_empirical_variance"),
"batch_size": ("gp", "batch_size"),
Expand Down
26 changes: 17 additions & 9 deletions kompot/cli/da.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ def add_da_parser(subparsers) -> argparse.ArgumentParser:
return parser


#: Flat config keys the DA CLI routes into :class:`~kompot.settings.GPSettings`.
#: Every other key -- including ``sigma``, which here means mellon's density
#: noise rather than the expression model's -- is forwarded to ``da()`` and
#: from there to :class:`mellon.DensityEstimator`.
GP_CONFIG_KEYS = {
"ls",
"ls_factor",
"param_scheme",
"n_landmarks",
"landmarks",
"batch_size",
"jit_compile",
"random_state",
}


def run_da(args):
"""
Run differential abundance analysis.
Expand Down Expand Up @@ -292,15 +308,7 @@ def run_da(args):
sample_col = params.pop("sample_col", None)

# Build Settings from remaining params
gp_keys = {
"n_landmarks",
"landmarks",
"ls_factor",
"batch_size",
"jit_compile",
"random_state",
}
gp_kwargs = {k: params.pop(k) for k in list(params) if k in gp_keys}
gp_kwargs = {k: params.pop(k) for k in list(params) if k in GP_CONFIG_KEYS}
gp = GPSettings(**gp_kwargs) if gp_kwargs else None

threshold_kwargs = {}
Expand Down
Loading
Loading