Repository navigation
feat: GPSettings.param_scheme for de() and da(); remove sync_parameters; fix #32 - #22
Conversation
…mated
The two conditions have always been smoothed at a shared length scale so
that their fitted surfaces are comparable. That part is right: giving each
condition its own makes the two surfaces differently smooth, and on an
exchangeable null the mismatch alone roughly doubles the false-positive
rate -- worse than any shared value.
What was undocumented is that the shared value is estimated from condition
1's cells only (`ls_for_model2 = self.model1.ls`), so it is a function of
n_condition1 and de(adata, condition1=X, condition2=Y) is not equivalent to
de(adata, condition1=Y, condition2=X). Measured on a cell-level exchangeable
null (2,917 cells, 16,285 genes, truth = 0 differential genes), the two
orientations of one partition differ in false-positive rate by 0.059 at 2:1,
0.082 at 3:1 and 0.079 at 6:1, against a floor of ~0.004.
`ls_scheme` makes the choice explicit without changing anything by default:
"condition1" (default) exactly today's behaviour
"symmetric" size-weighted geometric mean of the two conditions'
own estimates -- the same estimator, with neighbours
looked up within each condition rather than across
the union; swap-invariant
"pooled" estimated from both conditions' cells together; also
swap-invariant, but systematically smaller, since the
union is denser than either condition and the
estimate tracks nearest-neighbour distance
"separate" each condition estimates its own; diagnostics only
It is ignored, with a log line, when `ls` or a `cov_func` is supplied. It
also joins the run-parameter match list, so re-running under a different
scheme is not mistaken for a matching rerun.
Refs settylab/dotto-nexus#310
The `ls_scheme` gate excluded `cov_func` / `cov_func_curry` from the automatic path. Under the default scheme that dropped the historical `ls_for_model2 = self.model1.ls` inheritance, so a caller supplying a custom kernel and never touching `ls_scheme` silently got `separate` behaviour -- the worst-measured scheme in the comparison this branch is based on (tail mass 0.322 vs 0.172 at 2:1 on an exchangeable null). `function_kwargs` is public and the CLI forwards into it, so the path is reachable from the shipped API. The gate is now exactly the historical one -- `ls is None and "ls" not in function_kwargs`. A supplied kernel no longer disables scheme resolution: the resolved value is handed to the custom kernel via `estimator_defaults["ls"]`, which is what the old inheritance did too, so both conditions keep a shared scale. Verified against 19ee1a1 in a clean worktree, default scheme, `ls=None`: no function_kwargs old 92.589588/92.589588 new 92.589588/92.589588 cov_func_curry=Matern52 old 92.589588/92.589588 new 92.589588/92.589588 Three tests added covering the curry path for the default, the shared schemes and `separate`. Caught by an independent skeptic review, which also noted that 17/17 green missed it because no test exercised `function_kwargs`.
Regression fixed —
|
function_kwargs |
old model1.ls / model2.ls |
new |
|---|---|---|
| none | 92.589588 / 92.589588 | 92.589588 / 92.589588 |
cov_func_curry=Matern52 |
92.589588 / 92.589588 | 92.589588 / 92.589588 |
Three tests added for the curry path (default, shared schemes, separate); 20/20 green. The
reason 17/17 missed it is that no test exercised function_kwargs at all — only explicit ls.
The docstring's "ignored when ... cov_func is supplied" was true of the scheme and false of the
behaviour; it now says the resolved value is handed to the custom kernel.
Full skeptic findings are on settylab/dotto-nexus#310. Everything else in the review reproduced or
strengthened the measurements; this was the one merge-blocking item.
|
@settylab-dotto-bot also implement the |
|
Picked up — dispatched to a worker on this branch. The shape it is building to, so the intent is on the record before the code lands: That equivalence is also the reason the 236-run null benchmark is not being re-run. If the mirror property holds exactly, the published It has also been pointed at the gate that |
"condition1" estimates the shared length scale from condition 1's cells and
gives it to model 2. "condition2" does the reverse. It is not a fifth
alternative -- it is as asymmetric as the default, deliberately -- and it
earns its place because of an exact equivalence:
de(X, Y, ls_scheme="condition1") == de(Y, X, ls_scheme="condition2")
as the same computation with the labels exchanged. That makes the default's
swap-dependence demonstrable from a single call site, without swapping
condition1/condition2 at the call site and re-deriving which sign is which.
Measured, not argued (120+40 cells, seed 0, identical landmarks; max_rel =
max |u-v| / max(|u|,|v|)):
condition1_smoothed <-> condition2_smoothed 0.000e+00
condition1_std <-> condition2_std 0.000e+00
mahalanobis_distances 0.000e+00
neg_log10_ptp 0.000e+00
fold_change vs NEGATED mirror 0.000e+00
mean_log_fold_change vs NEGATED mirror 0.000e+00
fold_change_zscores vs NEGATED mirror 0.000e+00
control: unflipped fold_change max_rel = 2.000e+00 (the signature of
u = -v), max|fold_change| = 0.5054, so the arrays are not near zero.
The signed quantities flip sign because fold_change is condition2 -
condition1. The unsigned ones do not, because the fold change enters the
Mahalanobis form quadratically over a cov1 + cov2 denominator that is
symmetric in the two models.
WHAT DOES NOT MIRROR, and it is not about ls_scheme: fit() derives automatic
landmarks from np.vstack([X_condition1, X_condition2]), whose ROW ORDER
differs between the two orientations. compute_landmarks is order-sensitive
(measured: different sets at random_state=42, and not even run-to-run stable
at random_state=None, the DifferentialExpression default). With automatic
landmarks that is the ONLY remaining disagreement between the orientations
-- max_rel 1.5e-05 on the smoothed surfaces, 1.8e-06 on the Mahalanobis
distances -- so the equivalence is stated as "given identical landmarks".
A pre-existing, scheme-independent swap asymmetry that no ls_scheme removes.
Implementation note. "condition1" resolves lazily -- model1 estimates
internally, model2 inherits self.model1.ls -- and "condition2" cannot,
because model1 is fitted first. It resolves eagerly through _auto_ls, so the
mirror holds only while the eager and lazy estimates agree exactly. They do,
to the last bit, and that is now pinned by a test rather than assumed.
The cov_func gate that 487a02e fixed is value-blind (ls is None and "ls" not
in function_kwargs), so condition2 inherits the fix rather than needing its
own -- now a measurement, not a reading.
Also fixed: two readers of ls_scheme that could not see it.
- params_get could not read it back. build_params_dict stores it nested at
params["gp"]["ls_scheme"], _check_overwrites compares it through
params_get, and params_get resolves nested keys through _LEGACY_MAP,
which did not list it. So prev_val was always None and params_match was
False for EVERY rerun, including a byte-identical one.
- The DE CLI silently misrouted a flat ls_scheme config key. An
unrecognised key is not rejected; it falls through to
de(**function_kwargs) and is handed to mellon. gp_keys is now the
module-level GP_CONFIG_KEYS, asserted against GPSettings' own fields, so
a future field added without a CLI route fails a test instead of going
quietly into mellon.
tests/test_ls_scheme.py: 20 -> 33 tests. Negative control: planting
_auto_ls(X_condition2) -> _auto_ls(X_condition1) flips exactly the 7
mirror tests and correctly leaves the other two condition2 tests green.
Refs settylab/dotto-nexus#310
|
…e defaults violate
Skeptic finding, and it is the right one. The equivalence was asserted
UNCONDITIONALLY in settings.py, in the fit() docstring and in the CHANGELOG --
the "given identical landmarks" clause existed only in the PR body, the commit
message and a TEST NAME. A boundary that lives in a test name is invisible to
everyone the boundary protects.
And the configuration that violates it is the LIBRARY DEFAULT.
GPSettings(n_landmarks=5000, landmarks=None) computes landmarks from
np.vstack([X_condition1, X_condition2]), whose ROW ORDER differs between the two
orientations, so the two runs select different landmark sets. Measured at the
toy scale with landmarks=None:
6 of 9 mirrored comparisons MISS assert_allclose(rtol=1e-6, atol=1e-8)
-- the tolerance this package's own tests use --
and 0 of 9 miss it at n_landmarks=0.
Silently. No error, no log line, nothing downstream that signals it: the numbers
come back close instead of equal.
"IT IS SMALL" WAS ONE CONFIGURATION GENERALISED. Landmark-fraction sweep, 800
cells, RMS-relative gap ||u-v||_2 / ||u||_2:
frac n_lm smoothed mahalanobis
0.25 200 1.662e-06 1.624e-06
0.125 100 6.056e-06 4.135e-06
0.05 40 3.587e-05 1.677e-05
0.025 20 7.577e-05 4.136e-05
control, ONE shared landmark array: 0.000e+00 at both ends of the sweep
So it grows ~45x (smoothed) and ~25x (Mahalanobis) as the fraction falls 10x,
and the control pins the growth on landmark SELECTION rather than on the
fraction itself. A default 5,000-landmark run on a large dataset sits at the
LOW-fraction end, which is the bad end. An independent measurement in a separate
clone agreed in direction with a somewhat larger factor.
Three doc sites now carry the clause and say what happens when it does NOT hold,
not only when it does:
- kompot/settings.py GPSettings.ls_scheme
- kompot/differential/differential_expression.py fit(), as a .. warning::
- CHANGELOG.md the [Unreleased] Added entry
Plus enforcement, because docs are not a mechanism: fit() logs a warning when
ls_scheme="condition2" runs with automatic landmarks. It is scoped to that
scheme deliberately -- condition2's ONLY purpose is the cross-orientation
comparison, so anyone who reaches that branch is relying on the equivalence.
Four tests, and the positive one earned its place immediately: it FAILED on the
first run, because kompot/__init__.py configures the logger with
propagate=False, so pytest's caplog sees an EMPTY record list. That does not
fail loudly -- it makes every "did not warn" assertion pass for a reason
unrelated to the code. The three negative tests were vacuous until the positive
one flushed it out. caplog is now replaced by a handler attached directly to the
kompot logger, with a positive control for the helper itself.
Mutation control: forcing the warning to fire unconditionally flips exactly the
three negative tests and leaves the two positives green.
tests/test_ls_scheme.py: 33 -> 38.
No behaviour change beyond the added log line.
Refs settylab/dotto-nexus#310
…is worse
Skeptic follow-up, and it is right that a bare count invites a reader to
reproduce it and conclude one of us is wrong.
`6 of 9` is a `random_state=0` measurement. `GPSettings.random_state` defaults
to None, and the landmark draw is then not reproducible from one run to the
next -- so the count at the documented default is not a fixed number at all.
Measured, three consecutive runs at `random_state=None`, same toy data, same
`assert_allclose(rtol=1e-6, atol=1e-8)`:
run 1 7 of 9 MISS
run 2 8 of 9 MISS
run 3 7 of 9 MISS
The skeptic reported a stable 7 across its three runs; mine were 7, 8, 7. Both
observations are of the same thing -- at `random_state=None` the count is a
draw, not a constant -- so the disclosure now states the seed beside the count
AND that the default is worse and unstable, rather than quoting a second bare
number that a reader could also fail to reproduce.
`mean_log_fold_change` is the only comparison that never missed in any run.
Three sites updated in step: the fit() warning block, the CHANGELOG entry, and
the docstring of test_condition2_warns_when_the_landmarks_are_not_shared.
Refs settylab/dotto-nexus#310
…tion
My previous commit published "7, 8 and 7 over three consecutive runs" at
random_state=None. That is refuted, and the lower bound was wrong in the
direction that undermines the disclosure: a 6 occurs at the default too.
Twelve independent draws, random_state=None, n_landmarks=40, toy scale, against
the suite's own assert_allclose(rtol=1e-6, atol=1e-8):
12/12 condition1_smoothed <-> condition2_smoothed
12/12 condition2_smoothed <-> condition1_smoothed
8/12 condition1_std <-> condition2_std
2/12 condition2_std <-> condition1_std
12/12 fold_change vs NEGATED
1/12 mean_log_fold_change vs NEGATED
12/12 fold_change_zscores vs NEGATED
12/12 mahalanobis_distances
12/12 neg_log10_ptp
per-draw totals: 6 x4, 7 x6, 8 x1, 9 x1 (observed range 6-9, not 7-8)
random_state=0 reference: total 6, and THE SAME SIX comparisons
So a range quoted from three samples was three samples dressed as a range. What
is actually stable is WHICH comparisons miss, and that is what is now published:
six always-miss, the two posterior standard-deviation comparisons carrying
nearly all the variance, and mean_log_fold_change nearly exempt because
averaging over cells cancels most of the landmark noise -- nearly, not
immune: it missed once in twelve, so "never" would have been the same mistake
one level down.
Six is therefore a FLOOR, not a typical value, and it is also the seeded
figure with the identical comparison set -- so a reader who runs once at the
default and sees 6 learns nothing about the seed either way. Stating the
localisation rather than a count makes the seed parenthetical nearly
unnecessary, and it is reproducible where a range is not.
An independent review reported a stable 7 across three draws and 0/10 for
mean_log_fold_change; both are consistent with a draw whose modal value is 7
and whose tails are thin, and neither supports a range.
Refs settylab/dotto-nexus#310
Corrections —
|
| comparison | draws missed |
|---|---|
condition1_smoothed ↔ condition2_smoothed |
12/12 |
condition2_smoothed ↔ condition1_smoothed |
12/12 |
condition1_std ↔ condition2_std |
8/12 |
condition2_std ↔ condition1_std |
2/12 |
fold_change vs negated |
12/12 |
mean_log_fold_change vs negated |
1/12 |
fold_change_zscores vs negated |
12/12 |
mahalanobis_distances |
12/12 |
neg_log10_ptp |
12/12 |
Per-draw totals 6 (×4), 7 (×6), 8 (×1), 9 (×1) — so the observed range is 6–9, and my
published 7–8 was wrong at both ends. Six occurs at the default, and it is the same six
comparisons random_state=0 gives, so a reader who runs once at the default and sees 6 learns
nothing about the seed either way. Six is a floor, not a typical value.
The docs now publish the localisation rather than a count: six always-miss, the two posterior
standard-deviation comparisons carrying nearly all the variance, and mean_log_fold_change
nearly exempt because averaging over cells cancels most of the landmark noise. Nearly — it
missed once in twelve, so "never" would have been the same mistake one level down.
And the presentation itself was part of the defect. The two comparisons carrying all the
instability are exactly the pair the PR table folded into one "condition1_std /
condition2_std, role-swapped" row. That fold made a reviewer enumerate eight comparisons where
the population is nine, dropping a passing one and producing a short count. The table is now
unfolded and numbered. A collapsed table row is not a population, and here the rendering hid the
member and the variance together.
A note on the new tests, because the failure mode generalises. The positive "it warns" test
failed on its first run: kompot/__init__.py sets propagate=False on the logger, so pytest's
caplog sees an empty record list. That does not fail loudly — it makes every "did not warn"
assertion pass for a reason unrelated to the code, and my three negative tests were vacuous
until the positive one flushed it out. Replaced with a handler attached directly to the kompot
logger, plus a positive control for the helper. Forcing the warning to fire unconditionally flips
exactly the three negatives.
That is worth a look beyond this PR: tests/test_groupwise_fdr_integration.py::test_no_length_mismatch_warnings
asserts an absence through caplog.records on kompot's logger and therefore cannot fail —
measured, caplog.records = 0 with a canary WARNING emitted immediately before. Not touched here,
because once that assertion can see records it may start failing, and that belongs in its own
change rather than inside a PR about length scales. tests/test_volcano_de_rendering.py already
carries a correct helper for this with a docstring explaining the trap.
Numbers throughout are my own runs with a stated metric, not transcriptions: the skeptic's counts
came from its clone and its own metric, and where we differ — its ~100× growth factor, its stable
7, its 0/10 for mean_log_fold_change — the figures published here are the ones measured with
the fraction varied alone and the draw repeated twelve times.
Unchanged: the implementation survived the attack. The mirror reproduces bit-identically in an
independent clone, the bit-identity is structural rather than platform luck, the cov_func gate
is not re-opened, and the derived swap-invariance row remains correctly labelled derived with its
warrant named. tests/test_ls_scheme.py 33 → 38.
Tracking: settylab/dotto-nexus#310.
|
@settylab-dotto-bot make sure differential abundance works equivalently. Also keep the changelog concise, similar to how we wrote it for prior releases. |
|
Both taken, and the abundance one is a real gap rather than a restatement — flagging that rather than just acknowledging. Differential abundance. The Changelog. Trimming to match the register of the prior entries rather than to a guess at "concise". The landmark precondition currently in the entry is a real caveat — automatic landmarks are computed from the two conditions' cells stacked in the given order, so the two orientations select different landmark sets and the equivalence is only approximate under the defaults — so it moves to the docstring, where a user meets it at the point of use, rather than being dropped. The changelog entry will point at it. One note on scope: the exact equivalence measured so far is conditional on identical landmarks in both runs. That condition is not satisfied by the defaults, and the same question applies to abundance. |
The operator asked to make sure differential abundance works equivalently. It already does, structurally, and no ls_scheme analogue is warranted -- establishing that, rather than adding a knob, is the answer. WHY IT HOLDS. DifferentialAbundance.fit builds BOTH density estimators from the same estimator_defaults dict, with no `ls_for_model2 = self.model1.ls` analogue anywhere in the module. There is no condition-1 inheritance to be asymmetric about, so the defect ls_scheme exists to expose in differential EXPRESSION has no counterpart in abundance. Read at 082a702. The argument rests on one line, so it was measured rather than asserted: `**d` unpacks into a fresh dict, but the VALUES are shared references, and an in-place mutation of the landmarks array by estimator 1's fit would make the result luck rather than structure. Measured -- estimator_defaults is byte-identical after CONSTRUCTING estimator 1 and again after FITTING it, landmark object identity stable, and both estimators' landmarks equal the dict's. MEASURED at the DA defaults (n_landmarks=None, sync_parameters=False; 400+400 cells, 2-D, seed 0). The configuration is named because it is the REASON: with no landmarks there is no np.vstack row-order dependence to be sensitive to. log_density_c1(A) vs log_density_c2(B) 0.000e+00 log_density_c2(A) vs log_density_c1(B) 0.000e+00 log_fold_change_uncertainty 0.000e+00 neg_log10_fold_change_ptp 0.000e+00 log_fold_change vs NEGATED 0.000e+00 log_fold_change_zscore vs NEGATED 0.000e+00 direction: A vs SWAPPED(B) 800/800 agree direction: A vs UNSWAPPED(B) 0/800 agree That is one dataset at one configuration. It generalises through the mechanism above, not through the zero. TWO CONFIGURATIONS ARE ONLY APPROXIMATE, and they are findings, not footnotes -- both relatively WORSE than the 1.5e-05 landmark gap measured for DE: sync_parameters=True log_fold_change 1.220e-02, zscore 1.223e-02 n_landmarks=40 (automatic) uncertainty 5.634e-02, zscore 2.858e-02 Same root cause, wider blast radius: np.vstack([X_condition1, X_condition2]) is row-order dependent, and under sync_parameters it feeds compute_d_factal / compute_mu / compute_ls as well as compute_landmarks. So the landmark precondition documented for DE is in scope for DA and has a SECOND instance there. Sharing one landmark array restores exactness; that is tested. THE POSITIVE CONTROL IS WHY THESE TESTS ARE WORTH ANYTHING. My first two direction checks read 160/160 and 600/600 agree and were VACUOUS: every cell was 'neutral' (max|lfc| 0.07 then 0.42 against the package's own default threshold of 1.0), and two all-neutral arrays agree perfectly whether or not the labels are swapped. A single arm reading 600/600 looks like a triumph; only running the UNSWAPPED arm exposed it. The generalisable part is why a ported test could not have caught it: differential expression has no categorical output, so a control built for the DE family structurally under-covers the member being added. A ported shape inherits the green without earning it. The direction test therefore asserts both arms and requires 'up' and 'down' to actually occur; separation was increased until |log_fold_change| clears the DEFAULT threshold, and no threshold was tuned. tests/test_da_swap_equivalence.py: 8 tests, all passing. Refs settylab/dotto-nexus#310
Differential abundance: already equivalent —
|
| quantity | comparison | max_rel |
|---|---|---|
log_density_condition1 / condition2 |
role-exchanged | 0.000e+00 |
log_fold_change_uncertainty |
vs swapped | 0.000e+00 |
neg_log10_fold_change_ptp |
vs swapped | 0.000e+00 |
log_fold_change, log_fold_change_zscore |
vs negated swapped | 0.000e+00 |
log_fold_change_direction |
vs label-exchanged swapped | 800/800 |
One dataset, one configuration. It generalises through the mechanism, not through the zero.
Two configurations are only approximate, and both are relatively worse than expression's
1.5e-05 landmark gap — sync_parameters=True at 1.22e-02 on the log fold change, and
automatic n_landmarks=40 at 5.6e-02 on the uncertainty. Same root cause with a wider blast
radius: np.vstack([X_condition1, X_condition2]) is row-order dependent, and under
sync_parameters it feeds compute_d_factal / compute_mu / compute_ls as well as
compute_landmarks. So the landmark precondition documented for expression is in scope for
abundance and has a second instance there. Sharing one landmarks array restores exactness,
and that is tested.
The control is why these tests are worth anything
My first two direction checks read 160/160 and 600/600 agree. Both were vacuous — every
cell was 'neutral' (max|lfc| 0.07, then 0.42, against the package's own default threshold of
1.0), and two all-'neutral' arrays agree perfectly whether or not the labels are swapped. A
single arm reading 600/600 looks like a triumph; only the unswapped arm exposed it.
Why a ported test could not have caught it, which is the part that generalises: differential
expression has no categorical output, so a control built for the expression family structurally
under-covers the member being added. A ported shape inherits the green without earning it. The
direction test now asserts both arms and requires 'up' and 'down' to occur; separation was
raised until |log_fold_change| clears the default threshold, and no threshold was tuned.
Changelog
Cut from 71 lines to 42, with the register derived from the 0.8.0 entries in the file rather than
guessed — bolded lead, one to three sentences. The landmark precondition is not deleted: it lives
in DifferentialExpression.fit (a .. warning:: block) and GPSettings.ls_scheme, where a user
meets it at the point of use, and the changelog points there.
Also, from the wider sweep
tests/test_groupwise_fdr_integration.py::test_no_length_mismatch_warnings asserts
len(length_warnings) == 0 over caplog.records, and cannot fail: kompot/__init__.py sets
propagate=False, so caplog sees an empty list. Measured — caplog.records = 0 with a canary
WARNING emitted immediately before. An AST sweep of tests/ finds 12 functions taking caplog, 3
with absence-shaped assertions, 2 of which are unrelated false positives; that one is the only real
instance. Note its assertion text contains no caplog — the binding is two statements upstream,
so only data flow finds it.
Not fixed here: once it can see records it may start failing, which would be a live disclosure in
the groupwise-FDR path landing inside a PR about length scales. It wants its own change.
tests/test_volcano_de_rendering.py already carries a correct helper for this with a docstring
explaining the trap.
One measurement outstanding, named rather than glossed: the last full-suite run predates
tests/test_da_swap_equivalence.py, so its 8 tests pass in isolation but not yet inside a
whole-suite run. No library source changed in the commits that added them, but a new test file can
affect others through import or fixture side effects, so the re-run is pending rather than assumed
unnecessary.
Tracking: settylab/dotto-nexus#310.
Both corrections are the same move: a correct local measurement generalised
past the condition that made it true. Found by an independent review pass.
CORRECTION A -- "at its defaults" named no entry point, and the entry point
users configure through does not have those defaults.
anndata/differential_abundance.py:104 takes n_landmarks=None only when gp is
None, and settings.py:82 defaults GPSettings.n_landmarks to 5000. So
da(adata, gp=GPSettings(...)) lands in the automatic-landmark branch the same
paragraph calls approximate. GPSettings being the documented way to configure
da(), the exact configuration is arguably the one a user is LEAST likely to be
in. Said at that strength rather than softened to a caveat.
CORRECTION B -- "pass one landmarks array to both runs to restore it" was
applied to BOTH approximate configurations. It is measured for the landmark
half only, and it is now measured FALSE for the other: sync_parameters=True
with 40 shared landmarks reads 4.784e-03 against a 4.720e-03 reference with no
landmarks at all. Indistinguishable, and the reference arm is what makes that a
number rather than an impression. mu and ls come from the union's
nearest-neighbour distances, which landmarks do not enter.
THE REMEDY, WITH ITS SIZE BOUNDARY IN THE SAME SENTENCE, because a boundary in
a following clause is one that gets quoted away:
pass mu and ls explicitly -- and d as well when the combined data exceeds
500 cells, because compute_d_factal subsamples 500 INDICES above that and
becomes row-order dependent. Below 500, mu and ls suffice. Passing all
three is always correct.
measured: d+mu+ls explicit, 400+400 -> 0.000e+00
mu+ls only, 400+400 -> 6.085e-04 (d 1.4972 vs 1.4983)
mu+ls only, 200+200 -> 0.000e+00
AND THE BOUNDARY WAS ONE ARM FROM BEING MISSED. My own earlier note that "d is
identical across orientations" was measured at 160 combined cells. True there;
false above 500. Had the mu+ls arm been run at a single size, "pass mu and ls"
would have shipped as the remedy and been wrong for nearly every real dataset.
That is a THIRD instance of the same move in one PR, and the test module
docstring now records it beside the boundary so the next reader meets the trap
with the fact.
Sites corrected: CHANGELOG.md (both), tests/test_da_swap_equivalence.py module
docstring and test_shared_landmarks_restore_the_exact_swap (scoped to the
landmark half). The PR body and a retraction comment follow; the previously
posted comment is not silently editable, so its retraction is stated in the new
one rather than edited around.
Docstrings and CHANGELOG only -- test file verified AST-identical to HEAD with
docstrings stripped.
Refs settylab/dotto-nexus#310
Skeptic pass — differential-abundance swap equivalence, kompot PR #22 @
|
Retraction — two over-reaching claims corrected in
|
| configuration | combined cells | log_fold_change |
|---|---|---|
d + mu + ls explicit |
800 | 0.000e+00 |
mu + ls only |
800 | 6.085e-04 (d reads 1.4972 vs 1.4983) |
mu + ls only |
400 | 0.000e+00 |
That boundary was one arm from being missed, and the near-miss is the same defect again. My
own earlier note that "d is identical across orientations" was measured at 160 combined cells.
True there; false above 500. Had the mu+ls arm been run at a single size, "pass mu and ls"
would have shipped as the remedy and been wrong for nearly every real dataset — a corrected
prescription that was itself unscoped.
2. "Differential abundance is swap-equivalent at its defaults" did not say WHICH defaults, and
the entry point users configure through does not have them.
da() takes n_landmarks=None only when gp is None
(anndata/differential_abundance.py:104), and any GPSettings carries n_landmarks=5000
(settings.py:82). So da(adata, gp=GPSettings(...)) lands in the automatic-landmark branch that
the same paragraph calls approximate. GPSettings being the documented way to configure da(),
the configuration that is exact is arguably the one a user is least likely to be in. The
measurement was never wrong; the sentence around it claimed a reach it did not have.
And at that configuration it is not merely "approximate": when n_landmarks >= n_combined the
landmark set becomes the whole union and the uncertainty-derived quantities are numerically
unstable between orientations. That is pre-existing, traces to the 0.8.0 gp_type="fixed"
change, and is not caused by this PR — tracked separately as #32.
What is unchanged
The measurements themselves. The abundance mirror is still bit-exact at
n_landmarks=None, sync_parameters=False; condition2 still mirrors condition1 bit-exactly
under shared landmarks; the swap-invariance row is still derived with its warrant named. Nothing
retracted here is a number — all three are sentences that carried a number past its scope.
Three instances of one habit on one PR — "at its defaults", "shared landmarks restore
exactness", and "d is identical" — which is why they are being reported together rather than
patched quietly. The pattern is worth more than any of them individually: a measurement is true
of the configuration it was taken at, and the sentence around it is where that gets lost.
Corrected in f72573e: CHANGELOG.md, the PR body above, tests/test_da_swap_equivalence.py
(module docstring and test_shared_landmarks_restore_the_exact_swap, now scoped to the landmark
half). The test file is otherwise unchanged — verified AST-identical with docstrings stripped.
Tracking: settylab/dotto-nexus#310.
|
@settylab-dotto-bot this whole setup makes the API of da and de somewhat inconsistent, both allow parameter synchronization but under different mechanisms. What would be the cleanest way to make this consistent, easy to understand and well integrated into our API? |
Making
|
| today | becomes |
|---|---|
de: ls_scheme=X, any of the five |
param_scheme=X (the same code, renamed) |
de: default ("condition1") |
param_scheme=None → "condition1" |
da: sync_parameters=False (default) |
param_scheme=None → "separate" |
da: sync_parameters=True |
param_scheme="pooled" |
The two trade-offs that decide it:
- One concept instead of two, and what you give up is "share
lsbut notd/mu" inda. I weighed the alternative of two fields, a source plus a scope (none|ls|all). Indethe scope field would have only one effective value, becauselsis all there is. Indanobody has measured whether sharinglsalone is ever what you want. A scope field can be added later without breaking anything. Removing one after release cannot be done so cleanly. - Defaults stay different, but now in plain sight. Under one vocabulary the real inconsistency is one line of documentation:
dedefaults to"condition1"anddadefaults to"separate". Whetherdashould share by default is a scientific question. The harm from unshared length scales is measured for DE only, so I would not settle it as a side effect of tidying the API. ANonedefault is what lets the field keep each entry point's current behaviour. It is also the convention thesettings.pymodule docstring already states: "Nonemeans 'use kompot's default'" (kompot/settings.py:10).
One timing point: ls_scheme is still under [Unreleased], so renaming it now costs nothing. After the next release it needs a deprecation cycle. sync_parameters has shipped, so it becomes a deprecated alias on DifferentialAbundance.fit that maps True/False onto the table above.
Evidence (code at f72573e; probe on synthetic data: 700 + 300 cells, 3-D, seed 0)
-
sync_parameters=Truecomputes the same length scale aspooled, to the bit.dausescompute_ls(compute_nn_distances(vstack(...))) * ls_factor(kompot/differential/differential_abundance.py:265,276-277), andpooleduses the same expression through_auto_ls(kompot/differential/differential_expression.py:30,55-57). Measured41.72104835510254for both. On the constructor side,sync_parameters=Truehands the samed,muandlsto bothDensityEstimators, whileFalsehands neither any of them, so each estimates its own. That is exactly"separate". -
da()silently ignores twoGPSettingsfields.kompot.da()reads onlyn_landmarks,landmarks,ls_factor,batch_size,jit_compileandrandom_statefromgp(kompot/anndata/differential_abundance.py:104-109). Measured against the default run,max|lfc − default|is:GPSettings(ls=0.05): 1.8e-15GPSettings(ls_scheme="pooled"): 1.8e-15- the same
ls=0.05passed through**density_kwargs: 1.65 (the positive control)
sync_parametersitself is not named anywhere onda(), in its docs or in the DA CLI keys (kompot/cli/da.py:295-301). It reaches the model only by falling through**density_kwargs(line 202), where it does take effect: 3.0e-2. So the thing to integrate isda()honouringGPSettings, not a new parameter. The implementation should makeda()honour or reject everyGPSettingsfield, not only the new one. -
Not every value carries over to
dayet.separateandpooledare exact translations of today's behaviour.condition1andcondition2generalise directly, since each just estimatesd,muandlsfrom one condition.symmetricis a geometric mean of two length scales (differential_expression.py:73) and has no definition fordormu. I would havedareject it with an explicit error until it has a definition and a measurement, rather than invent one. -
Swap behaviour differs by source, and the docs should say so per value.
symmetricis swap-invariant by construction: the sharedlsis bit-identical when the inputs are exchanged (49.74640341937494in both orientations).pooledis not, because it runs an approximate nearest-neighbour search over the stacked union, whose row order depends on orientation:41.7210vs41.7112, a relative gap of 2.4e-4 at this size.compute_d_factalsubsamples 500 query cells above 500 combined cells, so underpooleddis order-dependent as well: 1.98779 vs 1.98875 at 1,000 cells. -
Out of scope for the scheme, and the same shape of defect: automatic landmarks come from the stacked union regardless of scheme, as discussed above. There is also the entry-point default split, where
da()usesn_landmarks=Nonewithoutgpand 5000 with one. It is the same problem as thels_schemedefault: aGPSettingsdefault that is really a per-entry-point default. TheNonesentinel fixes both the same way, so I'd do them together.
Scope: this is a design answer, not a change to this PR, which remains documentation corrections on the length-scale feature. If this shape works for you, I can open a dedicated issue for the implementation: the GPSettings field, da() honouring gp.ls and the scheme, the sync_parameters deprecation alias, and the CLI keys on both sides. The one decision to make before the next release is the name, since ls_scheme ships under whatever name it has then.
Skeptic verdict on
|
| arm | combined cells | log_fold_change |
density |
|---|---|---|---|
| control: class defaults | 400+400 | 0 | 0 |
sync_parameters=True, no landmarks (reference) |
400+400 | 5.1e-01 | 2.767e-03 |
| same, one shared landmark array | 400+400 | 5.5e-01 | 2.771e-03 |
mu+ls explicit |
400+400 | 5.6e-01 | 2.834e-03 |
d+mu+ls explicit |
400+400 | 0 | 0 |
mu+ls explicit |
200+200 / 250+250 / 150+350 | 0 / 0 / 0 | 0 |
mu+ls explicit |
251+250 / 150+650 | 2.4e-01 / 1.9e+00 | 1.5e-04 / 3.0e-03 |
d+mu+ls explicit |
150+650 | 0 | 0 |
(log_fold_change max-relative is inflated here by cells where it crosses zero. The zero/non-zero pattern is what the claims rest on.)
- (a) holds. Sharing landmarks leaves the sync half where it was (2.771e-03 vs 2.767e-03). The fit log shows why:
lsitself differs between orientations (12.8327 vs 12.8268), not justd. - (b) The 500 boundary is exact and correctly worded. mellon's
compute_d_factalsubsamples onlyif n < x.shape[0], wherexis the stacked union. So the cutoff is strictly more than 500, counted over combined cells rather than per condition. 500 exactly is exact, 501 is not, and an unequal 150+350 is exact. "mu+lssuffice below 500" is now measured at three sizes, not just argued. - The AST-identity claim holds.
tests/test_da_swap_equivalence.pyatc907da9vsf72573e: the ASTs are equal once module, class and function docstrings are stripped. Positive control: the unstripped ASTs differ, so the comparison can see a difference. - The retracted wording survives nowhere. Across the CHANGELOG, the PR body, the module docstring and the test docstring, the remedy is scoped to the landmark half and the corrected sentence is present at all four sites.
Findings:
- "Passing all three is always correct" is true only for the
sync_parametershalf, and the configuration where it fails is the one the same paragraph calls the common one. Withsync_parameters=Trueplus automatic landmarks, passing all three stays approximate. The measurement isd+mu+lswithn_landmarks=40at 400+400: uncertainty gap 8.4e-01, density 5.2e-04. With one shared landmark array plus all three, the gap is 0. The two remedies compose, and neither suffices alone. This configuration is reachable from the documented entry point:da(adata, gp=GPSettings(...), sync_parameters=True)gets automatic landmarks, andsync_parametersreachesfitthrough**density_kwargs(established by reading the code, not measured throughda()). A user following the sentence literally there is not exact. Fix: at all four sites (the retraction comment carries the same sentence), write "withsync_parameters=True, passing all three is always correct. If landmarks are automatic, also pass onelandmarksarray: both remedies are needed together." - The CHANGELOG is no longer concise, and the remedy exists only there.
[Unreleased]went from 49 to 65 lines. The DA bullet is now 24 lines in three paragraphs, carries a raw probe number (4.784e-03 vs 4.720e-03) and editorial ("arguably the one you are least likely to be in"). None of the 0.8.0 entries does any of this; their long form is reserved for user-visible impact. Meanwhile no library docstring carries the precondition: thesync_parametersandlandmarksentries inDifferentialAbundance.fitare silent on argument order, andsync_parameterseven says it ensures "consistent density estimation across conditions". Fix: do what was already done for DE. Put the precondition and both remedies inDifferentialAbundance.fit'ssync_parameters/landmarksdocstrings, and cut the CHANGELOG bullet to about four lines that point there. - The PR body's suite section is stale and garbled. It still says the whole-suite run including the 8 DA tests is "outstanding". That run happened: 2131 collected, 1 failed / 2074 passed / 56 skipped, with the DA file 8/8 and the same single pre-existing
test_dm_missing_pcafailure. Just before that, a sentence from an earlier revision survives spliced in ("…not merely the count. The+13 passedis exactly the 13 … and the failing-ID set is identical, not merely the count"). Fix: replace it with the final totals. - The body's two numbers for
sync_parameters=Truewithout landmarks come from different datasets, and neither is labelled. The "Two configurations are only approximate" table (1.220e-02) sits directly under a paragraph naming 400+400 in 2-D. The archived probe behind it actually used 120+40 cells in 3-D, which is below the 500-cell boundary this same section then introduces. The 4.720e-03 "reference" a few lines later is presumably the 400+400 run. The same configuration therefore shows two different numbers with no stated reason. Fix: name the dataset beside the table.
Scope note: only the corrections were reviewed. The API-design question is being answered separately.
…he docstrings
Skeptic round on the corrections themselves (lsc2sk3). Four fixes, one bundle.
(1) "PASSING ALL THREE IS ALWAYS CORRECT" WAS FALSE WITH AUTOMATIC LANDMARKS.
It fixes the PARAMETER half and leaves the LANDMARK half untouched. Measured at
400+400:
sync + AUTO landmarks + d,mu,ls explicit -> uncertainty 8.4e-01
sync + SHARED landmarks + d,mu,ls explicit -> 0
So the two remedies COMPOSE and neither substitutes for the other. Every site
now says so: with sync_parameters=True passing all three is always correct, AND
if landmarks are automatic pass one landmarks array as well.
This is the fourth instance on this PR of one habit -- a correct local result
generalised past the condition that made it true -- and the first where the
measurement I over-generalised was not even my own. I transcribed a peer's
number, which had been taken with landmarks controlled, and stated it
unconditionally. Re-reading it would not have helped; the condition is not
visible in the number.
(2) The CHANGELOG had grown 49 -> 65 lines against a request to keep it
concise. The DA precondition and both remedies now live in
DifferentialAbundance.fit, under `sync_parameters` and `landmarks`, which said
NOTHING about order dependence before -- so this is a move into the place a
user meets it, not a deletion. The bullet is 9 lines and points there;
[Unreleased] is back to 50.
(3)(4) belong to the PR body: the suite section still called the DA whole-suite
run outstanding and carried a spliced duplicate sentence, and the 1.220e-02
table was unlabelled and read as the 400+400 run when it is 120+40 in 3-D.
Verified: kompot/differential/differential_abundance.py and
tests/test_da_swap_equivalence.py are both AST-identical to f72573e with
docstrings stripped. tests/test_da_swap_equivalence.py + tests/test_ls_scheme.py
46 passed. Docstrings and CHANGELOG only, so no full-suite re-run.
Refs settylab/dotto-nexus#310
Correction to the retraction above —
|
| configuration | uncertainty, between orientations |
|---|---|
sync_parameters=True + automatic landmarks + d,mu,ls explicit |
8.4e-01 |
sync_parameters=True + shared landmarks + d,mu,ls explicit |
0 |
The two remedies compose and neither substitutes for the other. With sync_parameters=True,
passing all three is always correct — and if landmarks are automatic, pass one landmarks array
as well.
Corrected in the PR body, CHANGELOG.md, and tests/test_da_swap_equivalence.py. The precondition
and both remedies now also live in DifferentialAbundance.fit, under sync_parameters and
landmarks, which said nothing about order dependence before — that is where a user meets the
problem, and the CHANGELOG bullet is now a nine-line pointer rather than a transplanted essay.
This is the fourth instance on this PR of one habit, and the first where the over-generalised
measurement was not my own: I transcribed a number taken with landmarks controlled and published
it unconditionally. Re-reading it would not have helped — the condition a measurement was taken
under is not visible in the number, which is why each of these has been caught by someone
re-measuring rather than re-reading, and never by the person who made the claim.
The other three, for the record: "at its defaults" (true of the class, not the entry point users
configure through), "shared landmarks restore exactness" (true of the landmark half, not the
sync half), and "d is identical" (true at ≤ 500 combined cells, not above).
Also corrected in the same commit: the suite section quoted a superseded run and called the
abundance whole-suite check outstanding — it has since run, 1 failed / 2074 passed / 56
skipped, the single failure pre-existing and identical to base — and the 1.220e-02 comparison
table was unlabelled and read as the 400 + 400 run when it is 120 + 40 in 3-D.
Tracking: settylab/dotto-nexus#310.
Skeptic re-check of fixes 1–4 at
|
|
@settylab-dotto-bot fully implement the suggestion with a new worker and make sure everything is complete, consistent and we resolve all remnants of the previous implementation. |
Replaces the unreleased `ls_scheme` and the shipped `sync_parameters` with a
single `GPSettings.param_scheme` ("condition1" | "condition2" | "symmetric" |
"pooled" | "separate"), read by both entry points. The model decides what it
covers: `ls` in differential expression, `d`, `mu` and `ls` in differential
abundance. `None` keeps each entry point's default ("condition1" for de,
"separate" for da), so no existing call changes its numbers.
- `ls_scheme` is removed outright (never released): parameter, field, CLI
key, template, docs, CHANGELOG, tests (test_ls_scheme.py -> test_param_scheme.py).
- `sync_parameters` is a deprecated alias on DifferentialAbundance.fit and a
named, deprecated parameter on da() (it used to arrive only via
**density_kwargs): True -> "pooled", False -> "separate"; both at once raises.
- da() gains "condition1", "condition2" and "symmetric"; one-sided schemes share
exactly the values that condition's estimator derives on its own, and
"symmetric" is bit-identical under a swap by construction.
- Fix: da() silently ignored GPSettings.ls. It now honours ls and param_scheme,
warns on the expression-only sigma/eps/use_empirical_variance, and the DA CLI
routes ls and param_scheme into GPSettings.
…t param_scheme - With `d` pinned (alone), "condition1", "condition2" and "symmetric" computed the shared `mu` at the fractal `d` rather than the pinned one, so the shared value was not what the estimator derives. A pinned `d` now feeds `mu` (and, for "symmetric", replaces the pooled d). "pooled" was already correct. - Tests pin `d` alone and `ls` alone for each one-sided/symmetric scheme; the d-alone tests fail at e9f798b and pass here. - `param_scheme` passed to de()/da() as a flat keyword now raises a ValueError pointing at gp=GPSettings(param_scheme=...) instead of a TypeError. - CHANGELOG: the argument-order bullet moves from Added to Documentation.
Skeptic findings fixed:
|
|
Skeptic re-check: CREDIBLE at
Regression: |
|
@settylab-dotto-bot we don't need to state "DE also" as this is the expected case and only the opposite would need mentioning. Why is |
No alias and no deprecation shim. param_scheme="pooled" is the former sync_parameters=True. param_scheme occupies the removed argument's position in DifferentialAbundance.fit, so allow_single_condition_variance keeps the position it has on main.
_check_da_overwrites and _check_overwrites carried their own hard-coded
defaults ("separate", "condition1"), a second copy of each entry point's
default that could drift from it. param_scheme is now a required
argument, and a run recorded before param_scheme existed reads back as
DEFAULT_PARAM_SCHEME.
|
All four points are addressed in Why Default behaviour is unchanged. I compared
Controls:
The only difference at the defaults is metadata: the run record in
A caller who still passes it gets Python's ordinary error, measured at head through Wording. Docstrings, Tests. Full suite, both from branch-checked-out worktrees: Pre-existing and out of scope: at base and head alike, |
kompot-pscheme-followupI pushed the operator's four follow-ups on kompot PR #22 as |
… landmarks With as many landmarks as cells, DifferentialAbundance used the stacked union [X_condition1; X_condition2] as the landmark array. mellon's diagonal Laplace uncertainty depends on the landmarks' row order, and that order is set by which condition is passed first, so da(X, Y) and da(Y, X) disagreed on the uncertainty (median 97%) while the fold changes agreed to 1e-10. Any GPSettings routes da() here on data under 5000 cells. No approximation was asked for, so each condition now gets its full GP on its own cells, as n_landmarks=None does. Fixes #32
…es the uncertainty
A job timeout of 60 min, fail-fast off, and pytest-timeout at 300 s with the thread method, which dumps every thread's stack on a hang; plus --durations=25. This bounds the hang and captures evidence; it does not cure it.
|
#32 is fixed on this PR at Cause
FixWhen What changes, entry point by entry point
Inside the regime, measured on the #32 fixture (400 + 400 cells,
Outside the regime, The CLI row with a GP key is measured: with Tests
Mutants, with predictions written first. All four came out as predicted:
Full suite at Not fixed hereWith fewer landmarks than cells, the uncertainty still depends on argument order, and by as much. On this fixture, with k-means at 100 landmarks, it differs by a median of 87%. This is the same mellon mechanism, applied to k-means centroids whose order also depends on the input. Passing one CI, separate commit |
|
Skeptic verdict: CREDIBLE at I used my own harness: Default behaviour
The #32 fix
Other checks
Findings
Regression tests. I ran the full suite on detached worktrees and compared the sets of failing test IDs:
On each tree the only failure is |
…PSettings param_scheme was inserted ahead of landmarks in DifferentialExpression.fit and ahead of n_landmarks in GPSettings, so positional calls that work on main bound the wrong parameter. It is now the last parameter of each. DifferentialAbundance.fit keeps param_scheme in the removed sync_parameters slot, so allow_single_condition_variance keeps its position.
…0 cells per condition
|
@settylab-dotto-bot fix the last failing test, file the Mellon issue for our consideration, investigate implications on accuracy and runtime through our benchmarks. |
run_dm imported Palantir first, so without it installed a missing PCA key was never reported and test_dm_missing_pca failed in any environment without the optional dependency. CI installs .[all], which includes Palantir, so it passed there. The import now follows the input checks; a new test covers the Palantir-missing error with valid input.
|
Follow-ups: the skeptic's two items (
Full suite at |
|
Skeptic re-check: CREDIBLE at
|
|
Benchmark check of this PR: accuracy and runtime, The head has since moved to Both kompot trees were installed from 1. Reference benchmarks: no output changesEvery benchmark kompot call goes through
2. Targeted probe of the #32 regime (not a benchmark)The benchmarks don't cover the path this PR changes, so I probed it directly:
Independent validation of these numbers is pending. Corrections, if any, will follow in this thread. |
|
@settylab-dotto-bot runtime reports without JIT please, and assess prediction accuracy on the semisynthetic spike in data. |
|
Follow-up: runtime without compilation, and accuracy against spike-in ground truth ( Correction to my previous comment: "one scenario would not build at the 500-positive setting" was wrong. I used a retired setting for that scenario. With the correct setting it builds and is bitwise identical main vs PR (AUROC 0.9762 / AUPRC 0.6658 on both). So all 7 spike-in scenarios are bitwise identical at both spike-set sizes. "Bitwise" means same node throughout: see the noise floor note at the end. 1. Runtime without compilation
Plain The runtime benchmark (DE, which this PR doesn't touch) behaves the same: at 5k cells, 59.1 → 59.0 s cold and 39.5 → 39.3 s warm (compilation ≈ 33%). At 20k cells, 101.8 → 101.8 s cold and 97.3 → 95.0 s warm, where the 2.3 s gap is inside the 3.1 s within-tree repeat range. 2. Accuracy against spike-in ground truth
Noise floor, two levels: repeats on one node are deterministic (exactly 0 below 5,000 cells, ~1e-6 on the landmark path above it). Across node types, identical inputs give z values that differ by up to 0.2–0.8 on both trees, including exact-GP |
|
Independent validation: CREDIBLE. Both benchmark comments hold. I re-measured them (not re-read) on the current head Re-measured, in my own clone with my own drivers and comparison scripts, main vs
Accuracy (planted-truth design): fair, and correctly bounded. Main and PR get identical inputs, seeds and node. The spike-in data carries no abundance truth, because both conditions are the same cells, and the follow-up says so rather than overclaiming. From its per-draw records:
Corrections, all minor:
The first comment's scenario error has been corrected explicitly in the follow-up. It is now verified: all 7 scenarios are bitwise identical at both spike-set sizes. |
GPSettings.param_schemechooses which cells the length scale is estimated from. Inda()it also coversdandmu.param_schemechanges no default behaviour. The default,None, resolves to each entry point's current behaviour:"condition1"inde()and"separate"inda(). This PR also removessync_parameters, whichparam_scheme="pooled"replaces. It also fixes #32, which knowingly changesda()z-scores and PTPs whenn_landmarksis at least the number of cells (see below).Fixes #32
None"condition1"forde(),"separate"forda()"condition1"/"condition2""symmetric""pooled""separate"An explicit value (
ls=, ord=/mu=passed toda()) pins that parameter under any scheme.Default behaviour: bit-identical to
mainexcept in the #32 regimeBase
19ee1a1(the merge base onmain) and head812c15bwere both exported withgit archive, selected byPYTHONPATH(asserted viakompot.__file__), and run in one Slurm job. Every entry point was called at its defaults:de(),da(),da(gp=GPSettings()),DifferentialExpression().fit/predict,DifferentialAbundance().fit/predict, the deprecatedcompute_differential_*()wrappers, and the CLI (kompot deandkompot dawith flags only, and with each tree's shipped config template). The DE template fails identically on both trees, a pre-existing defect: the DE CLI does not route itslandmarks: nullintoGPSettings.812c15b(before the DA: uncertainty is numerically unstable when n_landmarks >= n_combined (the GPSettings default on <5000 cells) #32 fix): 75 of 75 arrays bit-identical. Determinism control, base against a second base run: 75 of 75.6606ee2: 70 of 75. The 5 that differ are allda(gp=GPSettings())on 450 cells, which is in the DA: uncertainty is numerically unstable when n_landmarks >= n_combined (the GPSettings default on <5000 cells) #32 regime: z up to 6.3e-2, PTP up to 3.7e-2, fold changes and densities up to 4.7e-6. Direction labels are unchanged.de()with"symmetric"on 8 of 9 arrays,da()with"pooled"on 5 of 6, and a relative 1e-7 change tols_factoron 8 of 9 (max 2.1e-07).unsgainsgp.param_scheme.Why
de()needs a schemeCondition 2 inherits condition 1's fitted length scale, so the default result depends on argument order. On an exchangeable null (2,917 cells, 236 runs), exchanging the conditions moves the fraction of p < 0.05 by 0.06–0.08 at 2:1 and beyond under the default. Under
"symmetric"or"pooled"it moves by at most 0.0061."separate"roughly doubles the false-positive tail (0.322 vs 0.172 at 2:1) and is documented as diagnostic only."pooled"shrinks the length scale purely with cell count."symmetric"does not, because it uses the size-weighted geometric mean of the per-condition estimates. This PR does not change which scheme is the default.de(X, Y, "condition1")andde(Y, X, "condition2")are the same computation with the labels exchanged, provided both runs use the same landmarks. Automatic landmarks are row-order dependent, so pass onelandmarksarray orn_landmarks=0.fit()warns when"condition2"runs with automatic landmarks.Differential abundance
"separate"without landmarks,da()is swap-equivalent, and a test covers this."condition1"/"condition2"reproduce that condition's own estimator bit for bit and share the result."symmetric"pools within-condition statistics: a geometric-meanls, adaveraged over each condition's query cells, andmuover both conditions' nearest-neighbour distances at thatd. It is bit-identical under a swap by construction. Its calibration has not been benchmarked."pooled"is order-dependent, assync_parameters=Truealways was. Two remedies are needed, and neither substitutes for the other: pind,muandls, and share onelandmarksarray. SeeDifferentialAbundance.fit.da()silently ignoredGPSettings.ls; it now honours it. It also warns when the expression-onlysigma,epsoruse_empirical_varianceis set. Its regression test fails before this change and passes after it.Fixed: #32, abundance uncertainty depended on argument order
At
n_landmarks >= n_combined,DifferentialAbundance.fitused the stacked union[X_condition1; X_condition2]as the landmark array. mellon's uncertainty, a diagonal Laplace approximation in Cholesky-whitened coordinates, depends on the landmarks' row order, soda(X, Y)andda(Y, X)disagreed on z by a median of 82% (PTP 96%), while fold changes agreed to 1e-10. Each condition now gets its full GP on its own cells, the same asn_landmarks=None.da()withoutgp;DifferentialAbundance();compute_differential_abundance(); CLI flags-only or shipped templateda(gp=GPSettings(...))on ≤ 5000 cells; CLI with any GP key but non_landmarksde()On 400 + 400 cells, z moves by a median of 34%, and fold changes and densities by at most 6.0e-4. The result matches
gp=Noneto 3e-14. The regression test fails at812c15band passes here, and four mutants flip exactly as predicted.The upstream part belongs to mellon. With fewer landmarks than cells, k-means centroid order still reaches the uncertainty: on the same fixture at 100 landmarks it differs by a median of 87%. Sharing one
landmarksarray removes the dependence, which is already documented and tested. An order-invariant uncertainty would have to come from mellon, and is proposed for consideration in settylab/Mellon#22.Fixed:
kompot dmreported a missing PCA only when Palantir was installedrun_dmimported Palantir before validating its input, sotest_dm_missing_pcafailed in any environment without that optional dependency. CI installs.[all]and passed. The import now comes after the input checks.Removed
sync_parametersonDifferentialAbundance.fitandda(), with no alias.git grep -n sync_parametersfinds only the CHANGELOG line recording the removal. A caller who still passes it getsTypeError: DensityEstimator.__init__() got an unexpected keyword argument 'sync_parameters'.param_schemetakes its argument position, soallow_single_condition_variancestays where it is onmain.Verification
da733b4: 1 failed, 2112 passed, 56 skipped. The only failure is the worktree-only provenance test (_provenance: sha does not resolve in a git worktree with a branch checked out #33), which does not fail in CI's checkout.test_dm_missing_pcahas left the failing set.condition2reading condition 1's cells, a non-commutativesymmetricweight, and droppinggp.lsinda(). Each flips exactly the tests predicted.CI
794bf7fbounds and diagnoses the intermittent 3.11 hang. It sets a 60-min job timeout andfail-fast: false, and adds pytest-timeout at 300 s with thread stack dumps, plus--durations=25. It does not cure the hang.Not decided here
n_landmarksdefault split inda():Nonewithoutgp, 5000 with it.