Transforming Shape
Transforming Shape
Scaling is a linear map: subtract, divide. It moves a distribution and stretches it, and it cannot change its shape β a right-skewed column standardizes to a right-skewed column with an identical skew coefficient.
When the shape itself is the problem, you need a non-linear transform. And the first thing to get right is what "the problem" means, because the usual answer is wrong.
What a transform is for
The reflex is: transform until the histogram looks symmetric. That criterion is measurable, satisfying, and mostly beside the point.
A transform is not for the histogram. It is for the relationship between the feature and the target.
The one you want is the one that straightens that relationship, and the shape of the feature on its own cannot tell you which that is. The lab below shows the skew-minimizing transform being the worst available choice.
Seeing it
Pick a relationship the data really has, then pick a transform, and watch the cloud straighten or refuse to.
The rank transform is the most symmetric thing on offer β it forces the column to be perfectly uniform β and it is never the best option, and is much the worst when the target is linear in \(x\).
The candidates
| Transform | Formula | Handles | Watch out for |
|---|---|---|---|
| log | \(\log(x)\) | right skew, multiplicative effects | \(x \le 0\) is undefined |
| log1p | \(\log(1 + x)\) | the same, with zeros allowed | still fails on negatives |
| sqrt | \(\sqrt{x}\) | mild right skew, counts | milder than log; \(x \ge 0\) only |
| reciprocal | \(1/x\) | rates and durations ("time per unit" β "units per time") | sign flip; \(x = 0\) |
| Box-Cox | \(\dfrac{x^\lambda - 1}{\lambda}\), \(\lambda\) fitted | skew, chosen automatically | strictly positive data only |
| Yeo-Johnson | a Box-Cox variant | the same, and negatives | \(\lambda\) is a fitted parameter β it belongs in the Pipeline |
| Quantile | rank, then map to a target shape | anything, including outliers | destroys all distance information |
| logit | \(\log\frac{p}{1-p}\) | proportions piled against 0 or 1 | undefined at exactly 0 and 1 |
from sklearn.preprocessing import FunctionTransformer, PowerTransformer, QuantileTransformer
FunctionTransformer(np.log1p, inverse_func=np.expm1) # explicit and cheap
PowerTransformer(method='yeo-johnson') # picks lambda for you, handles negatives
QuantileTransformer(output_distribution='normal') # the sledgehammer
PowerTransformer and QuantileTransformer learn parameters
\(\lambda\) is estimated from the data, and the quantile map is the data. Fit either on the full dataset before the split and you have leaked. FunctionTransformer(np.log1p) is the exception β it has nothing to learn β which is a good reason to prefer it when you already know the right transform.
Which one, measured
transform | skew_after | y~log(x) | y~x | y~sqrt(x) |
|---|---|---|---|---|
identity | 4.53 | 0.539 | 0.935 | 0.811 |
log1p | 0.15 | 0.941 | 0.591 | 0.856 |
sqrt | 1.64 | 0.827 | 0.816 | 0.938 |
yeo_johnson | 0.01 | 0.943 | 0.563 | 0.837 |
quantile_normal | 0.00 | 0.933 | 0.571 | 0.836 |
"""The transform that makes the histogram symmetric is not the transform you want.
One skewed input column, three different truths about how the target depends on
it, five candidate transforms. `skew_after` is the usual criterion β how close
to symmetric the column looks once transformed β and the three columns beside
it are what a straight line through the transformed column actually achieves.
Read `skew_after` against the three, and the usual criterion falls apart:
yeo_johnson and quantile_normal symmetrize best and are the *worst* option when
the target is linear in x. The transform to want is the one that straightens
your relationship, and a histogram cannot tell you which that is.
Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""
import numpy as np
from scipy.stats import skew
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import KFold, cross_val_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import (FunctionTransformer, PowerTransformer,
QuantileTransformer, StandardScaler)
N = 800
rng = np.random.default_rng(23)
x = np.exp(rng.normal(3.0, 1.0, N)) # a lognormal column: heavily right-skewed
X = x.reshape(-1, 1)
truths = {
"y~log(x)": 2.0 * np.log(x),
"y~x": 0.05 * x,
"y~sqrt(x)": 0.6 * np.sqrt(x),
}
transforms = {
"identity": FunctionTransformer(),
"log1p": FunctionTransformer(np.log1p),
"sqrt": FunctionTransformer(np.sqrt),
"yeo_johnson": PowerTransformer(method="yeo-johnson"),
"quantile_normal": QuantileTransformer(
output_distribution="normal", n_quantiles=400, random_state=0),
}
targets = {name: base + 0.25 * np.std(base) * rng.normal(size=N) for name, base in truths.items()}
cv = KFold(5, shuffle=True, random_state=0)
print("| `transform` | `skew_after` | `" + "` | `".join(targets) + "` |")
print("|---|---:|" + "---:|" * len(targets))
for name, transform in transforms.items():
after = skew(transform.fit_transform(X).ravel())
scores = []
for y in targets.values():
model = make_pipeline(transform, StandardScaler(), LinearRegression())
scores.append(cross_val_score(model, X, y, cv=cv, scoring="r2").mean())
best = max(scores)
cells = " | ".join(f"**{s:.3f}**" if s == best else f"{s:.3f}" for s in scores)
print(f"| `{name}` | {after:.2f} | {cells} |")
One skewed column, three different truths about how \(y\) depends on it. Read skew_after against the three columns beside it:
yeo_johnsonandquantile_normalwin the symmetry contest outright β skew 0.01 and 0.00.- When the target is linear in \(\log x\), they are also the best predictors (0.943, 0.933), which is why the reflex survives: on skewed data the relationship often is logarithmic, and then the two criteria happen to agree.
- When the target is linear in \(x\), they collapse to 0.563 and 0.571, while doing nothing at all scores 0.935. The most symmetric transform is the worst one available.
- When the target is linear in \(\sqrt x\),
sqrtwins at 0.938 β and it leaves a skew of 1.64, which by the usual criterion is a failure.
The winner tracks the truth, not the histogram. Down the diagonal, each transform wins exactly when it is the inverse of the relationship in the data.
How to actually choose
- Plot \(y\) against \(x\) and against \(\log x\), \(\sqrt x\), \(1/x\). Whichever looks straightest is your answer, and it takes a minute.
- If the mechanism is known, use it. Effects that multiply want a log. Areas want a square root. Times and rates are reciprocals of each other.
- If you genuinely do not know, put two or three candidates in a
GridSearchCVand let cross-validation pick β that is a legitimate hyperparameter. - For a deep network with plenty of data, remember the measurement on the distributions page: the transform is worth a lot at \(n = 100\) and almost nothing at \(n = 2000\). It is not where your accuracy is hiding, unless you need to extrapolate.
Binning
Discretization cuts a continuous column into intervals and treats each as a category. It is the transform with the most confused reputation, and the lab separates the two cases cleanly.
model | raw_column | 4_bins | 10_bins | 32_bins |
|---|---|---|---|---|
linear_regression | -0.002 | 0.191 | 0.776 | 0.956 |
small_network | 0.975 | 0.191 | 0.776 | 0.956 |
"""Binning buys a linear model what it lacks, and takes from a network what it had.
One input, uniform on [0, 10]; a target that follows a sine of it, so the
relationship is smooth and strongly non-monotone. The feature is given to each
model raw, and then cut into 4, 10 and 32 quantile bins and one-hot encoded.
A straight line cannot follow a sine at all β RΒ² of about zero β and binning
rescues it completely, because a one-hot block of bins turns a linear model
into a piecewise-constant one. A network needed no rescuing: it fits the curve
from the raw column, and every bin boundary you impose is resolution taken away
from it.
Most preprocessing advice about discretization was written for the first row.
Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.model_selection import KFold, cross_val_score
from sklearn.neural_network import MLPRegressor
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import KBinsDiscretizer, StandardScaler
N, BIN_COUNTS = 2000, (4, 10, 32)
rng = np.random.default_rng(7)
X = rng.uniform(0, 10, N).reshape(-1, 1)
y = 3 * np.sin(1.6 * X.ravel()) + 0.3 * rng.normal(size=N)
models = {
"linear_regression": lambda: LinearRegression(),
"small_network": lambda: MLPRegressor(hidden_layer_sizes=(32,), max_iter=3000, random_state=0),
}
cv = KFold(5, shuffle=True, random_state=0)
score = lambda pipe: cross_val_score(pipe, X, y, cv=cv, scoring="r2").mean()
print("| `model` | `raw_column` | `" + "` | `".join(f"{b}_bins" for b in BIN_COUNTS) + "` |")
print("|---|---:|" + "---:|" * len(BIN_COUNTS))
for name, make in models.items():
row = [score(make_pipeline(StandardScaler(), make()))]
for bins in BIN_COUNTS:
cut = KBinsDiscretizer(n_bins=bins, encode="onehot-dense", strategy="quantile",
quantile_method="averaged_inverted_cdf")
row.append(score(make_pipeline(cut, make())))
best = max(row) # mark whichever actually won
cells = " | ".join(f"**{v:.3f}**" if v == best else f"{v:.3f}" for v in row)
print(f"| `{name}` | {cells} |")
The relationship is a sine β smooth, and strongly non-monotone.
- Linear regression scores β0.002 on the raw column. It cannot follow a sine at all. Cut the column into 32 bins and one-hot them and it reaches 0.956, because a block of bin indicators turns a linear model into a piecewise-constant one. Binning bought it the flexibility it structurally lacked.
- The network scores 0.975 on the raw column and every binning makes it worse: 0.956 at 32 bins, 0.776 at 10, 0.191 at 4. It never needed the help, and each bin boundary is resolution taken away from it.
Most discretization advice was written for the first row
Binning is a way of giving a model non-linearity it cannot produce on its own. A network produces non-linearity for a living. Cutting age into decades before feeding a network throws away everything inside each decade in exchange for a flexibility the network already had.
The legitimate reasons to bin in deep learning are different ones: the boundaries are externally meaningful (a legal age threshold, a clinical cut-off), the column is already ordinal and pretending otherwise is a fiction, or you need the output to be explainable in terms somebody outside the team can act on.
Features you build rather than transform
The transforms above rewrite one column. These make new ones, and for a network the calculus is again different from the linear-model case.
| Move | Example | Worth it for a network? |
|---|---|---|
| Ratios | debt / income, price / area | often yes β a ratio is a division, and a network approximates division badly |
| Differences | end - start, value - baseline | yes if the difference is the meaningful quantity |
| Interactions | \(x_1 x_2\) | rarely β hidden layers find products they need |
| Polynomials | PolynomialFeatures(degree=2) | rarely, and it explodes the column count |
| Domain aggregates | spend_last_30d, visits_per_month | yes β this is where the real gains live |
| Cyclical | sin/cos of an hour | yes β see feature types |
The pattern: a network is very good at composing smooth functions of its inputs and bad at arithmetic it was never given the pieces for. Handing it a ratio saves it work; handing it \(x_1 x_2\) usually does not.
Anything computed across rows is a leak waiting to happen
spend_last_30d per customer, a group mean, a target encoding β all of these aggregate over rows, and computing them before the split lets test rows contribute to training features. Inside the Pipeline, or computed strictly from the training fold. The leakage chapter measures what that costs.