Skip to content

feat: add drug_mean and drug_tissue_mean response transformations (conditional-mean residualization) - #465

Open
jfrog64 wants to merge 4 commits into
daisybio:developmentfrom
jfrog64:feat-drug-mean-response-transformation
Open

feat: add drug_mean and drug_tissue_mean response transformations (conditional-mean residualization)#465
jfrog64 wants to merge 4 commits into
daisybio:developmentfrom
jfrog64:feat-drug-mean-response-transformation

Conversation

@jfrog64

@jfrog64 jfrog64 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

PR Checklist for all PRs

  • This comment contains a description of changes (with reason)
  • Referenced issue is linked — no existing issue, came up while benchmarking baselines with response transformations
  • If you've fixed a bug or added code that should be tested, add tests!
  • Documentation in docs is updated — new module added to docs/API.rst, new options documented in docs/usage.rst

Changes

New features

--response_transformation currently offers standard, minmax and robust. All three are global monotone
rescalings
of the response. That means they cannot change any of the metrics DrEval reports: Spearman and Kendall
are rank-based and therefore invariant under a monotone map, and the Normalized * metrics subtract the
NaiveMeanEffectsPredictor baseline, which rescales along with everything else. Pearson/R² are invariant under an
affine map. So today the flag exists, but for the reported scores it is close to a no-op — the only effect is on the
optimizer's conditioning.

This adds two transformations that actually change what the model has to learn: drug_mean and drug_tissue_mean.

drevalpy/response_transformation.py (new) contains GroupMeanCenterer, a transformer that subtracts a
conditional mean — estimated on the training fold only — instead of a global constant:

class GroupMeanCenterer(BaseEstimator, TransformerMixin):
    requires_groups = True

    def __init__(self, group_fields: tuple[str, ...] = ("drug_ids",)): ...
    def fit(self, X, y=None, groups=None) -> "GroupMeanCenterer": ...
    def transform(self, X, groups=None) -> np.ndarray: ...
    def inverse_transform(self, X, groups=None) -> np.ndarray: ...

The model is then trained on the residuals rather than on the main effect, which is where the Normalized * metrics
look anyway. inverse_transform adds the group mean back, so predictions are written out on the original response
scale and every downstream evaluation is unaffected.

group_fields names the columns of DrugResponseDataset the group key is built from:

  • drug_mean("drug_ids",): removes the drug main effect, i.e. how sensitive cell lines are to this drug on
    average.
  • drug_tissue_mean("drug_ids", "tissue"): also removes the tissue-specific sensitivity of a drug, so the
    model is left with the within-tissue drug × cell line structure.

The means are nested, not flat. fit estimates a mean for every prefix of group_fields, and the lookup walks
them from specific to coarse: a (drug, tissue) combination that did not occur in the training fold falls back to the
mean of its drug, an unknown drug to the global training mean. Without that, drug_tissue_mean would be useless in
LTO, where every test tissue is unseen by construction and a flat fallback would push every test row onto the global
mean; with it, LTO simply degenerates to drug_mean.

Because the transformer needs to know which row belongs to which group, DrugResponseDataset supplies the columns —
and only for transformations that ask for them:

def _transformation_kwargs(self, response_transformation: TransformerMixin) -> dict:
    if not getattr(response_transformation, "requires_groups", False):
        return {}

    fields = getattr(response_transformation, "group_fields", ("drug_ids",))
    columns = []
    for field in fields:
        values = getattr(self, field, None)
        if values is None:
            raise ValueError(...)
        columns.append(np.asarray(values).astype(str))
    return {"groups": np.stack(columns, axis=1)}

transform, fit_transform and inverse_transform forward these kwargs. For StandardScaler, MinMaxScaler and
RobustScaler the dict is empty, so those calls are byte-for-byte the ones that ran before — no existing
behaviour changes
, and --response_transformation keeps its current default.

Details that matter for the eval modes DrEval cares about:

  • fit only ever sees the training fold, so no test-fold information leaks into the offsets.
  • Unseen groups never raise, they fall back one level at a time (LDO, LTO, cross-study). The lookup joins the fields
    with the ASCII unit separator and uses searchsorted per level, so this stays one vectorized pass per level
    instead of a per-row dict lookup.
  • tissue is optional on DrugResponseDataset. If a dataset has none, drug_tissue_mean raises with a message
    naming the missing field rather than silently behaving like drug_mean.

Wiring: get_response_transformation returns the transformer for both names, check_arguments accepts them, and the
typer help text plus docs/usage.rst list them.

Tests: tests/test_response_transformation.py (22 tests) covers

  • requires_groups is set on GroupMeanCenterer and absent on plain sklearn scalers
  • fit records the global mean and the per-group means, and both nesting levels for two group fields
  • transform centers every group on zero, and inverse_transform round-trips to the original values
  • shapes are preserved for both (n,) and (n, 1) input, since the dataset calls it with column vectors
  • unseen groups sorting before and after all fitted keys fall back to the global mean (this is the searchsorted
    clipping)
  • an unseen tissue falls back to the drug mean, an unseen drug to the global mean
  • the joined group keys cannot collide (("A", "B_C") vs ("A_B", "C"))
  • handing over fewer group fields than at fit time raises instead of centering on the wrong mean
  • without groups it degenerates to plain mean-centering, and an empty training fold does not raise
  • DrugResponseDataset.fit_transform/inverse_transform hand over drug ids and tissues, and transform the
    predictions with the same groups
  • the LTO situation end to end through the dataset API: fitted on one tissue, applied to another
  • a dataset without tissues raises for drug_tissue_mean
  • a group-unaware scaler is still called without a groups argument
  • get_response_transformation and check_arguments accept both names and still reject nonsense

Bug fixes

Maintenance

jfrog64 and others added 2 commits August 26, 2026 16:55
The shipped response transformations ("standard", "minmax", "robust")
are global monotone rescalings of the response. Since the reported
metrics are rank-based (Spearman) and partly mean-centered per drug and
per cell line, such rescalings cannot change any score.

GroupMeanCenterer subtracts a conditional mean instead: the per-drug
mean, estimated on the training fold only, so the model spends its
capacity on the residual drug x cell line structure rather than on the
drug main effect. inverse_transform adds the mean back, so predictions
stay on the original response scale. Drugs unseen during fit fall back
to the global training mean, which keeps the transformation safe for LDO
and cross-study prediction.

DrugResponseDataset supplies the drug ids as groups when the
transformation advertises requires_groups, so plain sklearn scalers keep
being called exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Add the new drevalpy.response_transformation module to the API page and
list drug_mean in the --response_transformation option and in the
"Available Response Transformations" section of the usage docs.

Also make the DrugResponseDataset cross-reference in the module
docstring fully qualified so sphinx can resolve it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov-commenter

codecov-commenter commented Aug 27, 2026

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.45%. Comparing base (7d24cd6) to head (4b8fbcf).
⚠️ Report is 75 commits behind head on development.
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.

Additional details and impacted files
@@               Coverage Diff               @@
##           development     #465      +/-   ##
===============================================
+ Coverage        80.34%   82.45%   +2.10%     
===============================================
  Files              101      122      +21     
  Lines             8171    10024    +1853     
===============================================
+ Hits              6565     8265    +1700     
- Misses            1606     1759     +153     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

GroupMeanCenterer takes the fields the group key is built from as
group_fields, and estimates a mean for every prefix of it. The lookup
walks the levels from specific to coarse, so an unseen (drug, tissue)
combination falls back to the mean of its drug and only an unknown drug
to the global training mean. Without that nesting the transformation
would be useless in LTO, where every test tissue is unseen by
construction.

DrugResponseDataset supplies the requested columns instead of always the
drug ids, and raises if it does not have one of them, so that a dataset
without tissues does not silently behave like drug_mean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jfrog64 jfrog64 changed the title feat: add drug_mean response transformation (per-drug residualization) feat: add drug_mean and drug_tissue_mean response transformations (conditional-mean residualization) Aug 27, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants