Skip to content

Target Transforms

A skewed target often trains better in transformed space — log10 on a concentration, a solubility, a percent unbound. The transform belongs on the model, not on the FeatureSet:

  • No FeatureSet to rebuild, and one FeatureSet feeds models with different transforms
  • Metrics stay on the original scale, so a transformed model compares directly against an untransformed one
  • A transformed model goes through promotion like any other

Training applies the transform to the target column; inference inverts it. Predictions come back in the units you measured.

Available for XGBoost, PyTorch, ChemProp, TabICL and Atom-JEPA, regression only.

Setting one

Two hyperparameters on an otherwise normal to_model() call:

from workbench.api import FeatureSet, ModelType, ModelFramework

fs = FeatureSet("mppb_raw")

model = fs.to_model(
    name="mppb-xgb-log10",
    model_type=ModelType.UQ_REGRESSOR,
    model_framework=ModelFramework.XGBOOST,
    target_column="mppb",
    feature_list=features,
    hyperparameters={
        "target_transform": {"name": "log10", "offset": 0.1},
        "prediction_bounds": [0.0, 100.0],
    },
)

end = model.to_endpoint()
end.inference(eval_df)   # percent unbound, not log space
hyperparameter default what it does
target_transform None (identity) the map applied to the target during training and inverted at inference
prediction_bounds None [low, high] clip on the predictions, applied after the inverse

The model stores what it was fitted with, and inference reads that back — so the inverse is always exactly the forward that trained it, never a separately-passed value that can drift.

log10 and its offset

log10 is log10(y + offset), and offset defaults to 0. A bare "log10" is therefore a plain log10(y); log10(y + 1) is written out:

"target_transform": "log10"                             # log10(y)
"target_transform": {"name": "log10", "offset": 1.0}    # log10(y + 1)

The offset belongs just above the assay's reporting floor — the smallest value the assay actually reports. Below that floor the assay cannot separate values, so resolution spent there is spent on noise.

Zeros pin it down more precisely. A target containing 0 raises without an offset, and those zeros are normally below-quantitation rows; the offset decides where they land relative to the real readings. Keep them within a few reporting steps of the floor — near enough to read as "under the lowest measurement," rather than the ten or more steps that turn a handful of rows into a detached cluster for the loss to chase.

Both numbers come out of the data:

import numpy as np

y = fs.pull_dataframe()["mppb"]
readings = np.sort(y[y > 0].unique())
floor = readings[0]                                   # smallest real reading
step = np.median(np.diff(np.log10(readings[:10])))    # typical step, in log units

The same rule lands on very different offsets, because assays report to different precision:

reporting floor typical step offset zeros sit
0.1 (MPPB) 0.079 log units 0.1 3.8 steps below the floor
0.002 0.125 log units 0.005 1.2 steps below the floor

A 50x difference in offset for two readings of the same quantity, so copying a number from another model is the one reliable way to get this wrong.

As a sanity check, an offset large relative to the target's range stops being a transform at all. Share of the transformed output range given to the bottom decile of the data, where a linear map gives 10%:

target range offset 1.0 offset 0.1 offset 0.01
0 – 1 13.8% 28.9% 52.0%
0 – 87.6 (MPPB) 50.8% 66.2% 74.6%

So offset=1.0 over a fraction is barely a transform at all, while the same offset over a percentage is a strong one.

Prediction bounds

prediction_bounds is independent of any transform — a non-negative or percentage target wants it under the identity transform too. Either side may be None to leave it unbounded.

Clipped values pile up as a mass point at the bound, which destroys ordering among them, so bound only targets whose predictions should not reach the bound.

Multi-target models

A single spec applies to every target. Since a multi-target model usually carries targets in different units, a {target_column: spec} mapping sets them one at a time, and targets it omits get identity:

hyperparameters={
    "target_transform": {"ksol": "log10", "mppb": {"name": "log10", "offset": 0.1}},
    "prediction_bounds": {"mppb": [0.0, 100.0]},
}

What inference gives back

point prediction inverse(mean) — the conditional median, which is what MAE scores
prediction_std std of the inverted ensemble members, so the spread is in original units
metrics always the original scale
rank metrics identical with and without a transform

That last row is the contract: the transform is monotonic, so Spearman and Kendall cannot move. Only placement-dependent metrics — MAE, RMSE, R² — change, and they change because the model learned something different, not because the scale shifted.

What a transform buys

MPPB percent unbound, 1302 compounds, out-of-fold, all metrics on the original scale:

model MAE RMSE spearman MAE under 1%
no transform 6.784 10.630 0.785 3.210
log10, offset 0.1 6.602 11.145 0.791 1.826

Error on the clinically interesting tail drops 43% while RMSE rises 5%. Squared error in original space is dominated by the few high-unbound compounds; the transform spends its resolution on the low end instead. That trade is the whole reason to reach for one — and the reason to check the tail rather than the headline number.

Limits

  • Regression only — a transform on a classifier raises
  • Not available with a scikit-learn model_class
  • Cannot be HPO-searched. The target is transformed once, before the search runs, so a searched transform would differ between the trials and the winner's saved config that inference reads back. Set it as a top-level hyperparameter and search around it.

Questions?

The SuperCowPowers team is happy to answer any questions you may have about AWS® and Workbench.