Skip to content

fix: consume pdex's log2_fold_change directly (stop double-logging)#234

Merged
LeonHafner merged 5 commits into
ArcInstitute:mainfrom
LeonHafner:leonhafner/fix_log2fc
May 4, 2026
Merged

fix: consume pdex's log2_fold_change directly (stop double-logging)#234
LeonHafner merged 5 commits into
ArcInstitute:mainfrom
LeonHafner:leonhafner/fix_log2fc

Conversation

@LeonHafner
Copy link
Copy Markdown
Collaborator

⚠️ Do not merge before ArcInstitute/pdex#74 is merged AND pdex 0.2.2 is published to PyPI.

This PR depends on the new log2_fold_change column that pdex 0.2.2 emits. Until that release is on PyPI, uv sync in this repo cannot resolve dependencies (pdex>=0.2.2 is unsatisfiable) and CI will fail.


Summary

Cell-eval was double-logging fold changes. DEResults.__post_init__ applied log(base=2).fill_nan(0.0) to the fold_change column on the assumption it was a linear ratio. Since pdex 0.2.0, fold_change has actually held log2(target/ref) — so we were computing log2(log2(FC)), which produces NaN for every down regulated gene (negatives) and was then zeroed by fill_nan(0.0). Result: roughly half of all DE values silently set to zero, corrupting every metric that ranks by abs_log2_fold_change (overlap_at_N, precision_at_N, de_direction_match, DESpearmanLFC).

Discussion: ArcInstitute/cell-eval#232.

The upstream fix in pdex#74 adds an explicit log2_fold_change column. This PR consumes it directly and stops touching fold_change — so cell-eval also survives pdex 0.3.0, which removes the fold_change alias entirely.

Changes

  • src/cell_eval/_types/_de.py
    • log2_fold_change is now the required column; fold_change is no longer read.
    • Removed the buggy pl.col(fold_change).log(base=2).fill_nan(0.0) derivation.
    • abs_log2_fold_change is derived from log2_fold_change (when not already provided).
    • Dropped the fold_change_col kwarg from DEResults and initialize_de_comparison. It pointed at a column cell-eval no longer reads or requires; silent-ignore would have been a footgun.
    • Integrity-check logging now refers to log2 fold change.
  • src/cell_eval/_types/_enums.py
    • Renamed DESortBy.FOLD_CHANGEDESortBy.LOG2_FOLD_CHANGE and DESortBy.ABS_FOLD_CHANGEDESortBy.ABS_LOG2_FOLD_CHANGE. The values were already "log2_fold_change" / "abs_log2_fold_change"; the
      member names were misleading.
  • src/cell_eval/metrics/_de.py
    • DESpearmanLFC now reads log2_fold_change_col instead of fold_change_col. The class name says LFC; using fold_change was only "correct" because of the pdex bug. DEDirectionMatch already used
      log2_fold_change_col — no change.
  • Overlap / precision metrics
    • No code change. They route through DESortBy, whose values are already "log2_fold_change" and "abs_log2_fold_change". The corruption was purely in the column values; once __post_init__ is fixed they
      auto-recover.
  • pyproject.toml
    • pdex>=0.2.0pdex>=0.2.2.
    • cell-eval 0.7.00.7.1.
  • Tests (tests/test_de_float_types.py)
    • Existing test ported to use log2_fold_change.
    • Added a regression test for the original bug: feeding log2_fold_change = [-1.0, 0.0, 1.0, 2.0] must yield abs_log2_fold_change = [1.0, 0.0, 1.0, 2.0] (pre-fix the negative was zeroed via the NaN path).

Breaking changes

  • DESortBy.FOLD_CHANGE and DESortBy.ABS_FOLD_CHANGE are renamed to DESortBy.LOG2_FOLD_CHANGE and DESortBy.ABS_LOG2_FOLD_CHANGE. No deprecation aliases — pre-1.0 cleanup.
  • The fold_change_col kwarg is removed from DEResults and initialize_de_comparison. Callers that explicitly passed it must drop the argument.
  • DE tables passed via MetricsEvaluator(de_pred=..., de_real=...) (CSV/parquet) must now contain a log2_fold_change column. Tables with only a linear fold_change will fail with ValueError: Missing required columns: {'log2_fold_change'}. This is intentional — silently re-deriving was the original bug, and cell-eval cannot infer the semantics of an external fold_change column.

Test plan

  • Blocked on: pdex 0.2.2 available on PyPI
  • uv sync resolves
  • uv run pytest -v — all tests pass, including the regression test
  • uv run ruff format clean
  • End-to-end smoke: run an existing eval pipeline with a fresh pdex 0.2.2 output frame and confirm metric values shift toward their pre-bug expectation (no zero-cluster in abs_log2_fold_change, finite DESpearmanLFC correlations on frames containing negative log2 FC)

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request updates the cell-eval package to version 0.7.1 and transitions the differential expression (DE) logic to use log2 fold change as the primary metric instead of raw fold change. Key changes include removing the fold_change_col parameter from DEResults and initialization functions, updating integrity checks to validate log2 fold change data, and adjusting the Spearman correlation metric. A new test case was added to ensure negative log2 fold change values are correctly handled. Feedback highlights potential AttributeErrors due to renamed enum members, an opportunity to optimize integrity checks using a single select pass, and a suggestion to improve type safety by explicitly casting columns before numeric operations.

f"Identified {fc_num_nan} NaN fold change values ({self.name})"
f"Identified {lfc_num_nan} NaN log2 fold change values ({self.name})"
)
logger.info(f"DE data integrity check complete. ({self.name})")
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The PR summary mentions renaming DESortBy.FOLD_CHANGE to LOG2_FOLD_CHANGE and ABS_FOLD_CHANGE to ABS_LOG2_FOLD_CHANGE. However, several call sites in this file (e.g., lines 153 and 219) still use the old names in the provided context. Please ensure all references are updated to match the new enum definition to avoid AttributeError.

Copy link
Copy Markdown
Collaborator Author

@LeonHafner LeonHafner May 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Outdated — lines 153 and 219 are updated in this PR (DESortBy.LOG2_FOLD_CHANGE / DESortBy.ABS_LOG2_FOLD_CHANGE). Search the file for the old names returns no hits.

Comment on lines +79 to +87
lfc_num_null = self.data.filter(
pl.col(self.log2_fold_change_col).is_null()
).height
lfc_num_inf = self.data.filter(
pl.col(self.log2_fold_change_col).is_infinite()
).height
lfc_num_nan = self.data.filter(
pl.col(self.log2_fold_change_col).is_nan()
).height
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The integrity check performs three separate filter operations on the dataframe, which is inefficient for large datasets. Additionally, is_infinite() and is_nan() will raise an error if the column is not already a floating-point type. Since type enforcement happens later in __post_init__, it is more efficient and safer to compute these counts in a single select pass while ensuring the column is cast to a numeric type for the check.

        lfc_stats = self.data.select(
            null_count=pl.col(self.log2_fold_change_col).is_null().sum(),
            inf_count=pl.col(self.log2_fold_change_col).cast(pl.Float32).is_infinite().sum(),
            nan_count=pl.col(self.log2_fold_change_col).cast(pl.Float32).is_nan().sum(),
        )
        lfc_num_null = lfc_stats.item(0, "null_count")
        lfc_num_inf = lfc_stats.item(0, "inf_count")
        lfc_num_nan = lfc_stats.item(0, "nan_count")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope. This block predates this PR — we only changed the column it inspects (fold_change_col → log2_fold_change_col). It's one-time init logging on an in-memory frame of at most ~10k×500, so the perf gain is negligible. The "cast to numeric for safety" half isn't needed either: the column is required and gets cast to Float32 further down, and is_nan/is_infinite don't error on numeric input.

Comment on lines 104 to 107
self.data = self.data.with_columns(
pl.col(self.fold_change_col)
.log(base=2)
.alias(self.log2_fold_change_col)
.fill_nan(0.0)
).with_columns(
pl.col(self.log2_fold_change_col)
.abs()
.alias(self.abs_log2_fold_change_col)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure type safety when calculating the absolute values, it is recommended to cast the column to a numeric type here, especially since the global type enforcement occurs later in the method.

Suggested change
self.data = self.data.with_columns(
pl.col(self.fold_change_col)
.log(base=2)
.alias(self.log2_fold_change_col)
.fill_nan(0.0)
).with_columns(
pl.col(self.log2_fold_change_col)
.abs()
.alias(self.abs_log2_fold_change_col)
self.data = self.data.with_columns(
pl.col(self.log2_fold_change_col)
.cast(pl.Float32)
.abs()
.alias(self.abs_log2_fold_change_col)
)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled. The trailing block in post_init casts every column in numeric_cols to Float32, and abs_log2_fold_change_col is in that list. Adding an explicit cast here would be redundant.

@review-notebook-app
Copy link
Copy Markdown

Check out this pull request on  ReviewNB

See visual diffs & provide feedback on Jupyter Notebooks.


Powered by ReviewNB

@LeonHafner
Copy link
Copy Markdown
Collaborator Author

Two typing chore commits in here that are unrelated to the log2_fold_change fix. Flagging them here so they don't surprise anyone in review.

  1. chore: switch # type: ignore[code] → # ty: ignore[code] — 7 sites used the mypy-style # type: ignore[<code>], which ty doesn't honor. Swapped to # ty: ignore[<code>].
  2. chore(typing): suppress new pl.DataFrame type error — newer polars stubs reject pl.DataFrame(matrix) in _anndata.py because matrix has a wide AnnData union type. Added a # ty: ignore and dropped
    a now-unused bare suppression two lines down.

Both surfaced because uv.lock is gitignored, so CI does a fresh resolve every run and pulled stricter stubs than main's last green typing run.

@LeonHafner LeonHafner marked this pull request as ready for review May 4, 2026 22:06
@LeonHafner LeonHafner merged commit c1d0d05 into ArcInstitute:main May 4, 2026
8 checks passed
@LeonHafner LeonHafner deleted the leonhafner/fix_log2fc branch May 4, 2026 22:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Incorrect fold_change semantics in pdex >=0.2.0 break cell-eval metrics

2 participants