Skip to content

Bug: Biological Ceiling Constraint Suppresses Downregulation Signal in ApplyPropagation #15

@rootze

Description

@rootze

ISSUE

f0135ec

Description

The biological ceiling constraint introduced in the latest version of ApplyPropagation.R inadvertently reverses downregulation (knockdown) signals during network propagation. Rather than showing reduced expression post-perturbation, genes that are expected to be downregulated (e.g., APOE, CTSD) instead appear upregulated, which is the opposite of the intended effect.

Root Cause

The ceiling is applied unconditionally inside the iteration loop:

See the ceiling constraint in ApplyPropagation.R#L86-L87

exp_update <- methods::as(exp_update, "CsparseMatrix")
exp_update@x <- pmin(exp_update@x, max_obs[exp_update@i + 1])

For downregulated genes, delta starts negative L57(delta <- exp_per - exp), correctly reflecting reduced expression post-perturbation. Each iteration propagates this signal through the network L75(delta <- network %*% delta) and adds it to the perturbed expression L81(exp_update <- exp_per + delta).

The problem arises from the unconditional application of the ceiling constraint regardless of perturbation direction. First, L82 (exp_update[exp_update < 0] <- 0) correctly floors expression at zero. Then, L86 converts exp_update to a sparse matrix, dropping all zero entries from @x. At this point, the ceiling on L87 (exp_update@x <- pmin(exp_update@x, max_obs[exp_update@i + 1])) operates only on remaining non-zero values, artificially pulling them down toward max_obs, distorting what should be a naturally decaying signal.

By the next iteration, L90 (delta <- exp_update - exp_per) computes a new delta from this distorted exp_update, corrupting the negative signal that should carry the downregulation forward through subsequent iterations.

Expected Behavior

The ceiling constraint should only apply to knock-in / upregulation (perturb_sign > 0). Knockdown / downregulation should propagate freely downward, constrained only by the existing floor at zero.

Proposed Fix

Wrap the ceiling constraint in a perturb_sign check:

if (perturb_sign > 0) {
    exp_update <- methods::as(exp_update, "CsparseMatrix")
    exp_update@x <- pmin(exp_update@x, max_obs[exp_update@i + 1])
}

This preserves the biological intent of the ceiling (preventing upregulation beyond observed baseline max) while allowing downregulation to work correctly.

Steps to Reproduce (I tested this on the microglia data)

  1. Run the updated f0135ec ApplyPropagation with a knockdown perturbation (perturb_dir < 0)
  2. Plot Per Perturbation vs Post Perturbation expression distributions
  3. Observe that Post Perturbation distributions are not meaningfully smaller and compare with previous version

Evidence

APOE & CTSD (directly perturbed genes - downregulation ): Post-perturbation distributions appear identical to pre-perturbation, showing no downregulation signal across all cell types (Homeostatic, Immune-Primed_3, Stress-Response_1, ABeta-Associated, DAM).

JAZF1 (not in the perturbation module): Despite not being a direct target, JAZF1 shows unexpected expression changes post-perturbation — consistent with the ceiling constraint incorrectly interfering with propagated signal in downstream genes as well.

📎 See attached violin plot:

Image Image Image

This JAZF1 case is particularly telling because it demonstrates the bug affects not just directly perturbed genes, but also secondary propagation targets.

Final Notes

  • Old version correctly shows downregulation; new version does not

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workinginvalidThis doesn't seem right

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions