Index Ai Academy
Phase 3 · Machine learning · Foundations

Feature engineering

Turn raw data into signals a model can use.

About 30 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Features you build yourself
  2. Worked example: a fair comparison
  3. Taming huge numbers with a log
  4. RFM: a marketer's feature set
  5. Worked example: one customer's RFM score
  6. RFM on 600 customers
  7. Does the feature help? Measure it
  8. 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.

MoveExampleWhy it helps
Ratioorders per month of tenurecompares new and old customers fairly
Changethis month's orders minus last month'sa trend often predicts better than a level
Flagrecent = days_since_last_order < 30hands the model a cutoff it would otherwise have to find
Logthe log of (1 + lifetime value)shrinks huge values so a few big spenders don't dominate
Buckettenure groups: 0–6, 6–24, 24+ monthslets a simple model give each group its own effect
Interactiondiscount share × orderscaptures two things that matter together
Text featureslength, 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.

Try it now

A feature must exist at the moment you make the prediction.

Worked example

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")
It runs right here in your browser, with nothing to install.

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.

Quick check
Cara has 30 orders over 60 months. Dev has 8 orders over 4 months. What does this print?
print(30 / 60, 8 / 4)
  1. 0.5 0.5
  2. 0.5 2.0
  3. 2.0 0.5
  4. 30 8
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 ln⁡\ln, asks one question: what power of ee (about 2.718) gives this number? It's the log that NumPy's np.log uses. Here is the feature:

new value=ln⁡(1+x)\text{new value} = \ln(1 + x)

Here xx 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 ee gives 0. Some customers have spent $0, so the plain log would fail on them. Adding 1 first fixes that. Since ln⁡1=0\ln 1 = 0, a value of 0 stays 0.

Logs also turn multiplying into adding. Take two positive numbers, aa and bb. Then:

ln⁡(a×b)=ln⁡a+ln⁡b\ln(a \times b) = \ln a + \ln b

Read it as: the log of a times b equals the log of a plus the log of b. With a=10a = 10 and b=100b = 100, 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.

Quick check
Lifetime value ranges from $0 to $9,000, with a few huge customers. What transformation usually helps a linear model use it?
  1. Squaring it
  2. log(1 + value)
  3. Dropping the large customers
  4. One-hot encoding it
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:

RFM=R+F+M\text{RFM} = R + F + M

RR is the recency score. FF is the frequency score. MM 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

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)
In the lesson you can change this and run it.

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.cut sorts 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. mean is the share of churners, since the average of a 0/1 column is the share of 1s. count is 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.

Quick check
In the recency line, why is there a minus sign in front of df["days_since_last_order"]?
  1. To mark the customers who churned
  2. Because qcut only works on negative numbers
  3. To turn days into weeks
  4. So the most recent customers (fewest days) get the top score of 5
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_subscribed is a flag. The email open rate is missing for people who never signed up for emails, so a missing value becomes 1.
  • open_filled is the open rate with those gaps filled in as 0.
  • log_days is 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.

Quick check
A new feature raises the model's score on its training data, but not on held-out data. What should you do?
  1. Leave it out, because it doesn't help on new data
  2. Keep it, because any gain counts
  3. Keep it and judge the model on its training score
  4. Keep it if it sounds meaningful to the business
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

Create a free account

Try a quiz question

How do you know an engineered feature is worth keeping?

  1. It improves the model's score on held-out data
  2. It has a high correlation with another feature
  3. It makes the dataset wider
  4. It sounds meaningful to the business

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson