Feature engineering
Turn raw data into signals a model can use.
Create a free account Try part of it here
Why it matters
A model can only learn from what you show it. Raw columns often hide the signal. Two counts can hide a rate. A date can hide "how long ago". One huge number can drown out all the others. Feature engineering means building new columns that make the signal easy to see. It's often worth more than switching to a fancier model. Marketers already think this way, with RFM scores and engagement rates. This lesson turns that habit into code. It also shows you how to test whether a new feature earns its place.
What you'll be able to do
- Create features from ratios, flags, logs and combinations of columns
- Build recency, frequency and monetary (RFM) features for customers
- Measure whether a new feature actually helps a model
- Know where hand-made features matter less (deep learning) and where they still dominate
What it covers
- Features you build yourself
- Worked example: a fair comparison
- Taming huge numbers with a log
- RFM: a marketer's feature set
- Worked example: one customer's RFM score
- RFM on 600 customers
- Does the feature help? Measure it
- Where this matters most
Features you build yourself
In the features and labels lesson, a feature was one input column a model learns from. The columns you're given aren't always the most useful ones. Often you can build better ones from them. That's feature engineering.
Think of a doctor checking your weight. Weight alone says little. Someone who weighs 200 pounds could be tall and lean, or short and heavy. So doctors combine weight and height into one number, the body mass index (BMI). That single new number tells them more than the two raw ones. The doctor is using an engineered feature.
A model faces the same problem. It sees each column as a bare number, with no idea what it means. A good feature hands it the meaning directly.
Common moves
In the table below, tenure means how long someone has been a customer.
| Move | Example | Why it helps |
|---|---|---|
| Ratio | orders per month of tenure | compares new and old customers fairly |
| Change | this month's orders minus last month's | a trend often predicts better than a level |
| Flag | recent = days_since_last_order < 30 | hands the model a cutoff it would otherwise have to find |
| Log | the log of (1 + lifetime value) | shrinks huge values so a few big spenders don't dominate |
| Bucket | tenure groups: 0–6, 6–24, 24+ months | lets a simple model give each group its own effect |
| Interaction | discount share × orders | captures two things that matter together |
| Text features | length, has a question mark, mentions "refund" | turns words into numbers (embeddings do this at scale) |
A flag is a yes-or-no column, stored as 1 or 0. The test < 30 gives True or False for each customer, and True counts as 1.
A bucket helps when an effect doesn't follow a straight line. Churn, the share of customers who leave, might be high in the first few months, then drop, then level off. Put tenure into groups, and even a simple model can treat each group differently.
A feature must exist at the moment you make the prediction.
Worked example: a fair comparison
Ana has been a customer for 24 months and placed 12 orders. Ben joined 3 months ago and has placed 6. Who buys more often?
ana_orders, ana_months = 12, 24 ben_orders, ben_months = 6, 3 print("Ana:", ana_orders / ana_months, "orders a month") print("Ben:", ben_orders / ben_months, "orders a month")
Ana has twice as many orders in total. But per month, Ben buys four times as often: 2.0 orders a month against her 0.5. The raw count rewards customers for being around longer. The ratio puts everyone on the same footing.
One catch: a brand-new customer can have 0 months of tenure, and dividing by 0 fails. A common fix is to divide by at least 1 month.
print(30 / 60, 8 / 4)
Show the answer
0.5 2.0
Cara orders 0.5 times a month and Dev 2 times. Dev has far fewer orders in total, but buys four times as often.
Taming huge numbers with a log
Lifetime value is the total a customer has spent with you. Most customers spend a little. A few spend thousands. Data like this has a long tail: a big crowd of small values and a thin trail of giant ones.
Many models, especially simple ones, get pulled around by those few giants. The biggest spenders, often called whales, end up steering the whole fit.
The usual fix is a log. You met logs in the exponentials and logarithms lesson. The natural log, written , asks one question: what power of (about 2.718) gives this number? It's the log that NumPy's np.log uses. Here is the feature:
Here is the original value, such as a customer's lifetime value.
In plain words: add 1 to the value, then take its natural log. Big values shrink a lot. Small values shrink only a little.
NumPy has a function for exactly this, np.log1p. The name means "log of 1 plus". Python's math module has one with the same name. Try $0, then three lifetime values that each grow about tenfold:
import math for value in [0, 49, 499, 4999]: print(value, round(math.log1p(value), 2))
The values become 0, 3.91, 6.21 and 8.52. Before, the top spender was about 100 times bigger than the $49 customer. After the log, it's 8.52 against 3.91. Each tenfold jump now adds about 2.3, so the whale is still on top, but no longer drowns everyone else out.
Go deeper Why add 1, and why a log works
The log of 0 doesn't exist: no power of gives 0. Some customers have spent $0, so the plain log would fail on them. Adding 1 first fixes that. Since , a value of 0 stays 0.
Logs also turn multiplying into adding. Take two positive numbers, and . Then:
Read it as: the log of a times b equals the log of a plus the log of b. With and , both sides come to about 6.9078.
import math print(round(math.log(10 * 100), 4), round(math.log(10) + math.log(100), 4))
So being ten times bigger always adds the same amount, about 2.3. That holds whether it's $50 against $500 or $500 against $5,000. Differences of "how many times bigger" become differences of "how much more". For big values, the extra 1 barely changes anything.
Show the answer
log(1 + value)
A log squeezes the long tail, so a few whales don't dominate the fit. Adding 1 first means a value of 0 stays 0.
RFM: a marketer's feature set
Recency, frequency, monetary (RFM) is a way to score customers. Shops used it long before machine learning. It asks three questions:
- Recency: how recently did they last buy?
- Frequency: how often do they buy?
- Monetary: how much do they spend? (This dataset uses the average order value.)
Each question gets a score from 1 to 5, where 5 is best. To score one question, line all the customers up from worst to best. Then cut the line into five equal groups, called quintiles (fifths). The bottom fifth scores 1 and the top fifth scores 5. Finally, add the three scores:
is the recency score. is the frequency score. is the monetary score. Each runs from 1 to 5, so the total runs from 3 to 15.
In plain words: an RFM score is the three 1-to-5 scores added up. A higher total means a more active, more valuable customer.
Worked example: one customer's RFM score
Say Maya bought 10 days ago, which puts her in the most recent fifth, so R = 5. She orders fairly often: F = 4. Her orders are small: M = 2. What's her total, and which band is she in? The bands are low (3 to 6), mid (7 to 10) and high (11 to 15).
r, f, m = 5, 4, 2 rfm = r + f + m if rfm <= 6: band = "low" elif rfm <= 10: band = "mid" else: band = "high" print(rfm, band)
5 + 4 + 2 = 11, which lands in the high band (11 to 15). Her small orders didn't sink her. Recent, regular buying carried her score. That's the point of combining three signals into one number.
RFM on 600 customers
The practice shop has 600 customers. The churned column is 1 if a customer left and 0 if they stayed. The code below builds the three scores, adds them up and sorts customers into bands. Then it prints the churn rate in each band:
import numpy as np import pandas as pd import requests df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) df["recency_score"] = pd.qcut(-df["days_since_last_order"], 5, labels=False) + 1 # 5 = most recent df["frequency_score"] = pd.qcut(df["orders_last_year"].rank(method="first"), 5, labels=False) + 1 df["monetary_score"] = pd.qcut(df["avg_order_value"], 5, labels=False) + 1 df["rfm"] = df["recency_score"] + df["frequency_score"] + df["monetary_score"] df["rfm_band"] = pd.cut(df["rfm"], bins=[2, 6, 10, 15], labels=["low (3-6)", "mid (7-10)", "high (11-15)"]) print(df.groupby("rfm_band", observed=True)["churned"].agg(["mean", "count"]).round(2))
Here's what each piece does:
pd.qcut(column, 5, labels=False)cuts a column into five equal-sized groups. It numbers them 0 to 4, so adding 1 makes them 1 to 5.pd.cutsorts the summed score into bands, using fixed edges. The edges 2, 6, 10 and 15 mean: above 2 up to 6, above 6 up to 10, and above 10 up to 15.- The last line prints two things per band.
meanis the share of churners, since the average of a 0/1 column is the share of 1s.countis how many customers are in the band.
Churn falls sharply from the low band to the high band. One engineered number tells much of the story.
Go deeper Why frequency uses .rank(method="first")
Many customers have the same number of orders. If dozens all have, say, 4 orders, two of the cut points between fifths can land on the same value. pd.qcut then stops with an error, because its group edges must all be different. .rank(method="first") gives every customer their own position, breaking ties by the order the rows appear in. After that, five equal groups are always possible.
Show the answer
So the most recent customers (fewest days) get the top score of 5
qcut puts the largest values in the top group. Fewer days means a more recent buyer, so flipping the sign turns the fewest days into the largest value. Those customers score 5.
Does the feature help? Measure it
A new feature can sound clever and still not help. It can even add noise. So test it. A feature belongs in the model only if it improves results on data the model didn't train on. This is called held-out data. A model can memorize its training data, so a better training score proves little.
The test below uses logistic regression as a measuring stick. You'll learn how that model works soon. First, the code builds three new columns:
never_subscribedis a flag. The email open rate is missing for people who never signed up for emails, so a missing value becomes 1.open_filledis the open rate with those gaps filled in as 0.log_daysis the log of (1 + days since the last order).
Then it compares four versions of the feature set. The first uses only the starting three columns. Each of the others adds more. trail_club is 1 for members of the shop's loyalty club and 0 for everyone else.
import numpy as np import pandas as pd import requests from sklearn.linear_model import LogisticRegression from sklearn.model_selection import cross_val_score from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler df = pd.DataFrame(requests.get("https://api.gradient.test/v1/customers").json()["data"]) df["never_subscribed"] = df["email_open_rate"].isna().astype(int) df["open_filled"] = df["email_open_rate"].fillna(0) df["log_days"] = np.log1p(df["days_since_last_order"]) start = ["orders_last_year", "avg_order_value", "support_tickets"] versions = { "starting features": start, "+ recency (days)": start + ["days_since_last_order"], "+ recency (log of days)": start + ["log_days"], "+ recency + engagement": start + ["days_since_last_order", "trail_club", "open_filled", "never_subscribed", "tenure_months"], } model = make_pipeline(StandardScaler(), LogisticRegression(max_iter=1000)) for name, cols in versions.items(): auc = cross_val_score(model, df[cols], df["churned"], cv=5, scoring="roc_auc").mean() print(f"{name:<26} AUC {auc:.3f}")
Two terms in that code get their own lessons later. AUC is a score of how well a model ranks churners above customers who stay. A score of 0.5 is no better than guessing, and 1 is perfect. Cross-validation (cv=5) splits the customers into five parts. Each part takes a turn as the held-out data, and the five scores are averaged.
Recency is the big win. The log version scores no better than the plain number here, so keep the simpler one. A log isn't automatically better: it has to prove itself like any other feature. Engagement signals (club membership, email opens, never subscribing) add a bit more. That's the habit to build. Every feature earns its place on held-out data, or it stays out.
Watch out: only build features from information you'd have at the moment you predict. A column like "clicked cancel" would score brilliantly in a test. In real use, you'd only learn it after the customer had already left.
Show the answer
Leave it out, because it doesn't help on new data
Only held-out results show whether a feature helps on customers the model hasn't seen. A training gain can come from memorizing.
Where this matters most
Hand-made features matter most for tabular data. That means rows and columns, like a spreadsheet or a database table. Most business data looks like this.
For images, audio and text, deep learning finds its own features. An embedding is one example: a list of numbers that a model learned to describe something. Even then, teams often give a model an embedding plus a few table columns, such as how long someone has been a customer and which plan they're on. That mix is a common design, and it works well.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
How do you know an engineered feature is worth keeping?