When the Tail Owns the Model: log1p Recipes for Counts, Money, and Time
Plot your revenue, claim_amount, or followers column and you will usually see a wall of small values with a thin tail stretching far right. Ordinary least squares treats a $50,000 residual as 1,000 times the problem of a $50 one; when a handful of rows live in the tail, the tail quietly owns the fit. A log transform fixes the asymmetry, and it fixes the story: on log-revenue, a doubling costs the same at every scale, which is how these quantities actually behave.
The transform you want is log1p — log(1 + x) — not log:
import numpy as np
import pandas as pd
df['log_revenue'] = np.log1p(df['revenue']) # defined at 0; log(0) is not
df['log_revenue'].describe()
# mean 8.2 std 1.9 skew 0.4 <- was: mean 5400 std 41000 skew 7.8
Two properties matter downstream. The residual distribution stops being dominated by tail rows, so the fitted line represents the majority without pretending the tail does not exist. And leverage collapses: a point at 8.2 versus 9.9 on the log scale is strong signal, not a hijacked gradient.
Where the recipe applies — and where it does not:
| Column type | Transform | Why |
|---|---|---|
| money, counts, durations | log1p |
multiplicative world, heavy right tail |
| percentages in [0, 1] | logit, or nothing | bounded on both sides; plain log is wrong |
| already symmetric and centered | nothing | the transform costs interpretability for no gain |
| left-tailed (time-to-solve with a floor) | reflect, then log | log assumes the right side is the stretching one |
Put the transform in the pipeline rather than in a notebook cell, so it applies identically on every fold and at serving time:
from sklearn.pipeline import Pipeline
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import FunctionTransformer
log_step = FunctionTransformer(np.log1p, feature_names_out='one-to-one')
pre = ColumnTransformer([('log', log_step, ['revenue', 'claims'])],
remainder='passthrough')
This composes with the scaling discipline from Feature Scaling and Normalization: log first, then standardize, both inside the same ColumnTransformer — the standard recipe for monetary features.
Interpretation shifts with the transform, and you should be able to state the change cleanly. In the raw model, a coefficient on revenue is dollars-of-target per extra dollar of revenue. In the log-log model it is an elasticity: a 1% rise in revenue moves the target by β%. Elasticity is often the truer business statement anyway — nobody at a pricing review talks in absolute dollars across a range spanning three orders of magnitude.
One caution on inverse predictions, because it is a reliable interview differentiator: predicting on a log target and reporting np.expm1(pred) recovers the median of the conditional distribution, not the mean. Recovering means requires a smearing correction (Duan's smearing, or a fitted bias factor). Notice it, name it, and you have separated yourself from most candidates.
Next, the columns most people ignore completely: raw timestamps, which hide day-of-week, seasonality, and recency signal that no amount of scaling will manufacture.
For a log transform, inspect zeros and negatives before applying log1p. The transformed coefficient describes a change on the transformed scale, so communicate predictions in the original units when that is what the user needs.
For a log transform, inspect zeros and negatives before applying log1p. The transformed coefficient describes a change on the transformed scale, so communicate predictions in the original units when that is what the user needs.
For a log transform, inspect zeros and negatives before applying log1p. The transformed coefficient describes a change on the transformed scale, so communicate predictions in the original units when that is what the user needs.
From Timestamp to Signal: Calendar Parts, Cyclical Encodings, and Recency
A raw signup_ts column is refused by every sklearn estimator, but the moment you decompose it you usually find three or four of the strongest predictors in the dataset. Retail, banking, and ride-hailing models all lean on when as much as what.
The first recipe is mechanical decomposition:
ts = pd.to_datetime(df['signup_ts'], utc=True)
df['dow'] = ts.dt.dayofweek # 0=Mon ... 6=Sun
df['hour'] = ts.dt.hour
df['month'] = ts.dt.month
df['is_weekend'] = df['dow'].isin([5, 6]).astype(int)
df['is_month_end'] = ts.dt.is_month_end.astype(int) # payday effects
Day-of-week and hour routinely beat hand-built ratios in behavioral targets — support tickets spike Monday morning, fraud spikes at 2 a.m., retail peaks Thursday evening. This is the cheapest feature engineering that exists: the column was already there.
The second recipe handles the trap hidden inside calendar parts: day-of-week 6 sits next to day-of-week 0, and an integer code tells no model that. Cyclic encoding says it with trigonometry:
df['dow_sin'] = np.sin(2 * np.pi * df['dow'] / 7)
df['dow_cos'] = np.cos(2 * np.pi * df['dow'] / 7)
# Sunday (6) and Monday (0) land adjacent on the circle
Use it for genuinely circular variables (hour, day-of-week, month, compass direction); skip it for variables that only look cyclic — day-of-month restarts, but businesses rarely treat the 31st as adjacent to the 1st, so a plain integer or bins do better.
The third recipe is time-as-distance: how long ago something happened. This is where signal and leakage meet, so the reference moment matters — "now" must be the scoring timestamp, never the training run's date:
now = pd.Timestamp('2026-01-01', tz='UTC')
df['acct_age_days'] = (now - ts).dt.days
df['days_since_last'] = (now - df.groupby('user_id')['event_ts']
.transform('max')).dt.days
| Date feature family | Example columns | Typical use |
|---|---|---|
| Calendar parts | dow, hour, month, is_weekend | behavioral seasonality |
| Cyclical parts | dow_sin/cos, hour_sin/cos | models needing smooth adjacency |
| Recency and age | days_since_last, account_age | churn, credit, lifetime value |
| Lags and rolling | last-7-day order count | demand forecasting, anomaly baselines |
Lag and rolling features carry one hard rule: shift before you aggregate. A 'orders in the last 7 days' column whose window includes the current row's own day has already peeked at the answer:
hist = (df.sort_values('event_ts')
.groupby('user_id')['order_amount']
.rolling('7D', closed='left') # excludes the current timestamp
.sum().reset_index())
closed='left' — or an explicit .shift(1) after daily groupby aggregation — guarantees the feature only sees strictly earlier rows. The same discipline applies to 'mean demand per product' style features: computed over the whole table, they average in the future. Get this wrong and you have built a model that scores 0.98 locally and 0.62 in production; the full anatomy of that failure is Data Leakage Prevention, the next topic.
One operational detail to keep honest: recency features drift with calendar time. A days_since_last column computed against a fixed 'now' during training will be wrong for every row scored later unless the serving code recomputes it against the live scoring timestamp — a feature that rots silently in production.
Create interaction features only when the combined effect is meaningful. Centering numeric inputs can make lower-order coefficients easier to interpret, but it does not eliminate confounding or replace validation.
Create interaction features only when the combined effect is meaningful. Centering numeric inputs can make lower-order coefficients easier to interpret, but it does not eliminate confounding or replace validation.
Create interaction features only when the combined effect is meaningful. Centering numeric inputs can make lower-order coefficients easier to interpret, but it does not eliminate confounding or replace validation.
Ratios, Bins, and Interactions: What Trees Find Alone and Linear Models Need Built
A gradient-boosted tree will discover on its own that price / square_meter matters, that very high tenure combined with low balance flags churn, and that amount behaves differently above $500. A linear model discovers none of these — it sees columns, not relationships between them — and this is where feature engineering closes the gap.
Ratios and differences. The most durable recipe: divide two features that share a unit, or subtract one from another.
df['price_per_sqm'] = df['price'] / df['area'].replace(0, np.nan)
df['balance_per_year'] = df['balance'] / df['tenure_months'].clip(lower=1)
df['capacity_used'] = df['used_gb'] / df['quota_gb'].clip(lower=1e-9)
Guard every denominator — a single zero produces inf and poisons an entire fold, and SimpleImputer downstream will not save you if the column arrives as infinity. Prefer ratios whose meaning you can state in one sentence; a ratio nobody can interpret is usually noise with a division sign.
Binning. Turn a continuous feature into intervals when the effect is stepped rather than smooth — insurance deductibles, tax brackets, and 'first purchase over $500 gets a coupon' all have cliffs:
from sklearn.preprocessing import KBinsDiscretizer
kbd = KBinsDiscretizer(n_bins=5, encode='ordinal', strategy='quantile')
df['amount_bin'] = kbd.fit_transform(df[['amount']])
Quantile strategy gives equal-count bins — robust to the skew you failed to log away — and the estimator stores the edges, so serving-time values bin by the same boundaries. The cost is real: binning discards within-bin resolution, so keep the raw column alongside and let the model weight both. (The same trap from ordinal encoding applies: the bin code is an order, the bin edges are the information.)
Interactions and polynomials. The explicit version for linear models:
from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(degree=2, interaction_only=True, include_bias=False)
# [a, b, c] -> [a, b, c, ab, ac, bc]
interaction_only=True is the setting that matters: products of distinct features without the squares, keeping column count at k(k−1)/2 instead of the full quadratic blow-up. Two engagement columns multiplied can be powerful (visits x minutes-on-site), but every interaction is another coefficient to estimate — with 5,000 rows, a 40-feature interaction set already overfits. Generate them inside cross-validation and keep them only if folds improve, not the training score.
| Recipe | What it gives linear models | Do trees still benefit? |
|---|---|---|
| log1p | linearity in log space | yes — fewer split candidates wasted on tail parsing |
| ratios | the multiplicative relationship, directly | modestly |
| binning | stepped effects via ordered codes | little — trees already split |
| interaction terms | pairwise products | little — trees already split |
The theme across every recipe here: engineering is hypothesis work. State what the feature asserts about the world, then let fold scores judge the assertion. What the folds cannot judge, however, is a feature that leaked its answer in — computing any of these transforms on the full dataset before splitting is exactly the crime dissected in Data Leakage Prevention.
At serving time, use the same calendar, timezone, and missing-date policy as training. Monitor feature distributions and null rates so a change in upstream data does not silently alter the model's behavior.
At serving time, use the same calendar, timezone, and missing-date policy as training. Monitor feature distributions and null rates so a change in upstream data does not silently alter the model's behavior.
At serving time, use the same calendar, timezone, and missing-date policy as training. Monitor feature distributions and null rates so a change in upstream data does not silently alter the model's behavior.
Quiz
1. A `revenue` column runs from 0 to tens of millions with mean roughly 30x its median. Which treatment best prepares it for a linear model?
2. Why is sin/cos encoding used for day-of-week instead of the plain integer 0–6?
3. You compute 'orders in the last 7 days' per user with a rolling window that includes the current row. What have you built?
4. For a linear model on 5,000 rows, you generate all pairwise interactions of 30 features. What is the main risk and the standard mitigation?
Flashcards
Question
Why log1p instead of log for money, counts, and durations?
Click to reveal answer
Answer
These columns contain zeros (and ones), where log is undefined; log1p = log(1 + x) handles them and behaves like log for large values. It converts multiplicative spread into additive structure, tames skew, and collapses the leverage of tail rows — at the cost of an elasticity (percent-change) interpretation rather than raw units.
Question
When is sin/cos encoding appropriate, and when is it the wrong tool?
Click to reveal answer
Answer
Appropriate for genuinely circular variables — hour, day-of-week, month, direction — where the top and bottom of the scale are adjacent; sin/cos maps them onto a circle so distance in feature space reflects real adjacency. Wrong for linear-scale variables that merely restart (day-of-month), where plain integers or bins are more honest.
Question
State the hard rule for lag and rolling features.
Click to reveal answer
Answer
Shift before you aggregate: the window must contain only rows strictly earlier than the row being predicted (rolling with closed='left', or shift(1) after daily groupby). Also fix the time reference: 'now' at serving time must be the scoring timestamp, not the training date, or recency features rot in production.
Question
Which feature recipes do tree models gain little from, and why?
Click to reveal answer
Answer
Binning and explicit interaction terms: trees already choose split points anywhere on a continuous feature (so bin edges add nothing) and already test each feature against the target, discovering cross-effects through successive splits. Log transforms still help trees modestly by compressing the tail into usable split regions.
Question
How should engineered ratio features be built and guarded?
Click to reveal answer
Answer
Divide or difference two features that share a unit (price per square meter, balance per tenure year), and guard the denominator against zeros with replace(0, np.nan) or clip(lower=...) so a single zero does not produce inf and poison a fold. Keep only ratios whose meaning you can state in one sentence.
Revision Notes
Key Takeaways
- 1. log1p is the default fix for heavy-tailed money/count columns: zeros-safe, collapses tail leverage, and shifts interpretation from raw units to elasticity
- 2. Timestamps hold some of the strongest signal available — calendar parts, cyclical encodings, recency, and lags — for the cost of a few pandas lines
- 3. Rolling and lag features must exclude the current row (closed='left' or shift), or the feature contains the answer and the model memorizes the future
- 4. Ratios, bins, and interactions give linear models what trees find alone — but every added column is a new coefficient, so generate inside cross-validation and keep only what improves folds
- 5. The feature's time reference at serving must match training: 'now' is the scoring timestamp, or recency features silently rot in production
Interview Tips
- • "Your revenue column is extremely skewed — what do you do and what changes?" — log1p (zeros-safe), which converts multiplicative spread to additive and turns coefficients into elasticities; add the inverse-prediction caveat (expm1 gives the median, smearing recovers the mean) for real depth
- • "How do you build features from a timestamp?" — give the four families (calendar parts, cyclical sin/cos for genuinely circular variables, recency against the scoring timestamp, lag/rolling with shift); then state the leakage rule for windows, which shows you have shipped one
- • "A tree model doesn't need feature engineering, true?" — mostly true for binning and interactions (trees find splits and cross-effects themselves), but logs still help by compressing tails, dates still must be decomposed, and domain ratios give faster, more stable splits
- • "How do you decide whether an engineered feature is worth keeping?" — it encodes a hypothesis about the world, so it is judged by out-of-fold scores inside the pipeline; anything fitted on the full dataset is judged by leakage, not quality — bridge to the leakage question deliberately
Cheat Sheet
Feature Engineering Recipes
Heavy tails:
df['log_spend'] = np.log1p(df['spend']) # zeros-safe
# log-log model coefficient = elasticity: +1% x -> +beta% y
# expm1(pred) returns the conditional MEDIAN, not mean (smear to correct)
Dates:
| Family | Examples | Rule |
|---|---|---|
| calendar parts | dow, hour, month, is_month_end | cheapest signal in the table |
| cyclical | dow_sin/cos | only for truly circular variables |
| recency | days_since_last, acct_age | 'now' = scoring timestamp |
| lags/rolling | 7-day sums | closed='left' or shift(1) — never include the current row |
Cross-features:
df['price_per_sqm'] = df['price'] / df['area'].replace(0, np.nan) # guard denominators
KBinsDiscretizer(n_bins=5, strategy='quantile') # stepped effects
PolynomialFeatures(degree=2, interaction_only=True) # k(k-1)/2 products
Discipline: state the hypothesis each feature encodes; judge it with fold scores; build everything as a transformer inside the pipeline so folds (and future data) never leak in.