learn-feature-engineering-with-phoebe / Practitioner session 4 of 10
Learn Feature Engineering with Phoebe · Practitioner track · Session 4 of 10

Datetime and cyclical features

A timestamp is the most under-used column in almost every table you will ever open. It sits there looking like data, and to a linear model it is close to useless in its raw form. Tonight you take one column - Lumen's signup_date - and pull three whole families of features out of it, learn the sine-cosine trick that stops midnight from looking maximally far from 11pm, and meet the one leakage rule that is specific to time. On the workbench, that single column is worth +0.021 of held-out AUC.

🟡 Practitioner track DS · analysts · ML engineers Python · live workbench on key pages 45 min
0-3 · Welcome 3-18 · What a timestamp is hiding 18-42 · Build-along: decompose Lumen's signup_date 42-45 · Q&A
Part 1 · covers Kaggle Learn "Creating Features", Lumen canon

A timestamp is not a feature 7 min live

Hand a model a raw datetime and it receives an epoch number: a monotonic counter that climbs steadily from the first row of the table to the last. What that counter mostly encodes is when the dataset was collected. It is a row index wearing a calendar costume. Everything you actually care about - the day of the week, the season, how long this customer has been around - is inside it, and none of it is available until you take it out.

ONE COLUMN signup_date 2025-11-03 23:41 CALENDAR PARTS year · month · day_of_week · hour · week_of_year · quarter BOOLEAN FLAGS is_weekend · is_holiday · is_business_hours · is_month_end RELATIVE QUANTITIES · the lane teams forget tenure · recency (time since last event) · time_to_event The model can only use what you extract. Raw, the timestamp offers none of these.
🔍 Click to zoom - one column, three families of features
LiveThe three lanes, and which one gets skipped5 min▶

Almost every team gets the first lane. Most get the second. The third is where the signal usually hides.

  • Calendar parts answer where in the cycle this event sits: year, month, day of week, hour, quarter. Cheap to compute, immediately interpretable, and the raw material for the cyclical encoding in Part 2.
  • Boolean flags answer what kind of moment this was: weekend, public holiday, business hours, month end, promo window. A flag is a human judgement compressed into one bit, which is exactly what Rule #20 means by "human-understandable ways."
  • Relative quantities answer how far from now: tenure (prediction time minus signup), recency (prediction time minus last event), time to event. These are the ones teams forget, and they are usually the strongest, because behaviour depends far more on "signed up nine days ago" than on "signed up in November."
Real world

The raw-epoch trap. If you leave signup_date as a number and your training rows run January to June while production runs in July, every live row carries an epoch value larger than anything the model ever saw. A tree splits on a threshold it can no longer distinguish; a linear model extrapolates a trend it was never entitled to. The column that looked harmless was quietly encoding "how late in the data collection this row arrived."

Self-studyWhich parts are worth building on Lumen3 min read▶

The workbench keeps this deliberately small - two features, one from each of the last two lanes - so you can see the lift attributable to each idea rather than to a pile of columns.

FeatureLaneWhat it lets the model notice
tenureRelativeHow long this account has existed at the moment of the session
weekendFlagWhether the session happened on a different kind of day
session_hourCalendarNothing useful - this is the noise column intro-ML exposed, retired properly in b8

Two features, +0.021 held-out AUC. Note what that is not: it is not a new algorithm, a bigger dataset, or a tuning run. It is one column that was already in the table, read properly.

Part 2 · covers sklearn "Preprocessing data" - splines and binning

Midnight is next to 11pm 8 min live

Here is the problem with calendar parts as plain integers. Hour 23 and hour 0 are one minute apart in reality and 23 units apart in your feature matrix - the maximum possible distance. Month 12 and month 1 have the same defect, as do day 7 and day 1. You have handed the model a ruler that says December and January are the two furthest-apart months of the year.

AS A PLAIN INTEGER 0 12 23 distance 23 - the maximum possible AS A sin/cos PAIR 0 23 6 12 18 0 and 23 land next to each other, as they should Two columns, never one: sine alone is ambiguous - two different hours share the same value. Cosine tells them apart.
🔍 Click to zoom - the same 24 hours, on a line and on a circle
LiveThe sine-cosine pair, in six lines4 min▶

Map the cycle onto a circle and read off both coordinates. Any periodic part works the same way - just change the period.

import numpy as np

hour = df["signup_date"].dt.hour
df["hour_sin"] = np.sin(2 * np.pi * hour / 24)
df["hour_cos"] = np.cos(2 * np.pi * hour / 24)

month = df["signup_date"].dt.month
df["month_sin"] = np.sin(2 * np.pi * month / 12)
df["month_cos"] = np.cos(2 * np.pi * month / 12)

Why two columns and not one. Sine is symmetric about its peak, so hour 6 and hour 18 both give a sine value that cannot be told apart by sign alone, and hours either side of noon collide the same way. One column collapses two distinct moments into one number. The cosine breaks the tie: together the pair gives every hour a unique position on the circle, and hours that are close in time end up close in feature space.

LiveTwo alternatives from the sklearn docs4 min▶

The sine-cosine pair is not the only honest option, and sklearn ships both of the others.

from sklearn.preprocessing import SplineTransformer, KBinsDiscretizer

# smooth, periodic basis - the ends of the cycle join up
SplineTransformer(degree=3, n_knots=13, extrapolation="periodic")

# coarse time-of-day buckets - the model learns a weight per bucket
KBinsDiscretizer(n_bins=4, strategy="uniform", encode="onehot-dense")
  • Splines with periodic extrapolation give a smooth basis that wraps, and the docs prefer splines over polynomials generally because they avoid "oscillatory behaviour at the boundaries... known as Runge's phenomenon" and extrapolate better. Worth knowing: "The SplineTransformer treats each feature separately, i.e. it won't give you interaction terms."
  • Binning is the blunt, readable option: night, morning, afternoon, evening. Google's guidance on when to bin fits time of day well - bin when "the overall linear relationship between the feature and the label is weak or nonexistent" or when "feature values are clustered", and remember "the model learns separate weights for each bin." A degree-0 spline is exactly a KBinsDiscretizer(encode='onehot-dense') with n_bins = n_knots - 1.
The choice depends on the model, which is the b1 lesson coming back Tree models can approximate a cyclical split from raw integers reasonably well, because they can carve out "hour >= 22 or hour <= 2" with a couple of splits. Linear models cannot: they get one weight per column, so a straight integer forces a straight-line story about time. Encode cyclically when the model is linear or distance-based. With trees, the pair is often a small refinement rather than a rescue.
Part 3 · sets up b5 and leader session a4

Relative to when, exactly? 6 min live

The relative lane is the strongest of the three and the easiest to poison. Every relative feature is a subtraction, and the whole question is what you subtract from. Compute tenure as "today minus signup" while you build the training set and you have just written today's knowledge into a row from eight months ago. The row now knows something it could not have known.

The rule, in one sentence Every relative feature must be computed relative to that row's prediction time, never to "now" at training time. If a column changes value when you rerun the notebook tomorrow, it is not a feature, it is a timestamp of your build.
LiveWhat the broken version looks like in code4 min▶

The wrong version is shorter, which is part of why it keeps happening.

# WRONG - "now" is the moment you ran the cell, not the moment
# the prediction would have been made
df["tenure_days"] = (pd.Timestamp.now() - df["signup_date"]).dt.days

# RIGHT - every row carries the timestamp of its own decision point
ref = df["prediction_ts"]
df["tenure_days"] = (ref - df["signup_date"]).dt.days
df["days_since_last_order"] = (ref - df["last_order_ts"]).dt.days

The damage is subtle. Both versions produce plausible numbers, both train fine, and the broken one often scores better on the held-out set, because "now minus signup" is partly encoding how old the row is, and row age correlates with the outcome distribution in most datasets. That is the sklearn definition arriving on schedule: "information that would not be available at prediction time is used when building the model."

Self-studyWhere this goes next3 min read▶
  • b5, Leakage: the session. Temporal leakage is one of the four families, and this is its cleanest example. b5 also gives you the three rules that prevent all four, and sklearn's worked result where a leaked step scored 0.76 accuracy on pure noise against an honest 0.50.
  • b6, aggregations. The same rule with a bigger blast radius: a group aggregate computed over the whole table leaks the future into every row of the group.
  • Leader session a4, feature governance. Doing this correctly at scale has a name - point-in-time correctness - and it is one of the stated reasons feature stores exist. Feast's own docs put it in the value list: "Avoid data leakage by generating point-in-time correct feature sets."
  • Not this course. Lag and rolling-window features for forecasting are a related but separate craft, and they live in learn-timeseries-forecasting-with-phoebe.
Build-along

Turn on the datetime lever ★ 12 min · everyone runs it

The workbench opens where b3 left you: numerics and encoding on, held-out AUC 0.785. Now add the two features you just designed and watch a column you already owned pay for itself.

Confirm the current rung. Numerics and encoding are on; press Score on held-out and read 0.785. Nothing about time is in the model yet, even though signup_date has been sitting in the table since b1.

Switch on Datetime and press Score on held-out again: 0.806. That is +0.021 from a column that was already there. No new data was collected, no new algorithm was chosen.

Look at what the lever actually added: tenure and weekend, both derived from signup_date. One relative quantity, one boolean flag. Two features, and the third-largest jump on the whole ladder.

Compare the size of the win to the effort. Encoding (b3) bought 0.089 and required real thought about cardinality. Datetime bought 0.021 for roughly twenty minutes of decomposition work, which is a very good hourly rate.

How to read the workbench Training AUC is always visible; held-out stays masked until you ask for it. The model is a real logistic regression trained by gradient descent in your browser on 1,800 Lumen sessions and scored on 1,200 it never saw. Only the data is synthetic - the split, the fit, and every AUC are computed live.
Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Official sources covered

This session teaches the working core of the periodic-encoding material in sklearn's preprocessing guide and Google's binning guidance, applied to one column of the running Lumen case. Certificates and graded exercises stay with the official providers.

✓sklearn "Preprocessing data" - SplineTransformer with periodic extrapolation, KBinsDiscretizerPart 2 · the scaler family and skew handling were b2
◐Google MLCC "Working with numerical data" - binning criteriaApplied to time-of-day buckets · the full binning treatment is b2
◐Google "Rules of Machine Learning" - rule 20Flags as human-understandable derived features · crosses in b6
✓fe-live canon - the datetime rung of the Lumen ladder0.785 to 0.806 · tenure and weekend from signup_date
◐Kaggle Learn "Feature Engineering" - Creating FeaturesDecomposition patterns · the full lesson is used in b6
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why does cyclical encoding need two columns rather than one?

The pair places each moment at a unique point on a circle. With one coordinate you know the height but not which side of the circle you are on, so distinct hours collide. With both, hours that are close in time are close in feature space, including 23 and 0.

2 · What does a raw timestamp, handed to a model as a number, mostly encode?

Epoch values climb steadily from the first row to the last, so the column tracks position in the collection window rather than anything meaningful about the event. Worse, production rows carry values above everything in training. The calendar parts, flags, and relative quantities are all in there, but only if you extract them.

3 · What is the leakage rule specific to relative features such as tenure and recency?

"Now minus signup" writes today's knowledge into an eight-month-old row, which is exactly information "that would not be available at prediction time." The tell is that the column changes value every time you rebuild. Doing this correctly at scale is called point-in-time correctness (leader session a4), and the wider family is b5.

Practitioner session 4 cheat sheet · pin this

A timestamp is not a featureRaw, it is a monotonic counter that mostly encodes when the row was collected. Everything useful has to be extracted.
Three lanesCalendar parts (year, month, day_of_week, hour) · flags (weekend, holiday, business hours) · relative quantities (tenure, recency, time_to_event).
The forgotten laneRelative quantities are usually the strongest and the most often skipped. "Signed up nine days ago" beats "signed up in November."
The cyclical problemHour 23 and hour 0 are adjacent in reality, 23 apart as integers. Month 12 and month 1 likewise.
The sin/cos fixhour_sin = sin(2*pi*hour/24), hour_cos = cos(2*pi*hour/24). Always the pair - one column alone is ambiguous.
AlternativesSplineTransformer with extrapolation="periodic" for a smooth wrapping basis; KBinsDiscretizer for coarse time-of-day buckets.
Model-dependentTrees can carve cyclical splits from raw integers reasonably well. Linear models cannot. The encoding choice follows the model (b1).
The time leakage ruleRelative features are computed against each row's prediction time, never "now". Point-in-time correctness (a4); full family in b5.