Notebook 5 — Feature Engineering for CA Professionals
Goal: Create new, useful columns ("features") from existing data — so that charts, dashboards and machine learning models can give better answers.
You will learn:
- What is a "feature" and why we create new ones
- Math features — ratios, growth rates, age
- Date features — month, weekday, festival flag
- Binning — turning numbers into buckets (e.g. invoice size: Small/Medium/Large)
- Encoding categorical columns into numbers
- Scaling numeric columns
- Aggregated features — per-customer summaries
- A short mini-project
1. What is "feature engineering"?
A feature is just a column in your dataset. Feature engineering means creating new columns that make the underlying signal more obvious.
A CA-style example:
| Existing columns | New (engineered) feature |
|---|---|
Revenue, Expense |
Profit_Margin = (R-E)/R |
Loan_Amount, Annual_Income |
Loan_to_Income_Ratio |
Invoice_Date, Today |
Days_Outstanding |
Amount |
Size_Bucket = Small / Medium / Large |
City |
encoded numbers (for ML) |
Better features → better insights → better models.
import pandas as pd
import numpy as np
print("ready")
ready
2. Math features — ratios and percentages
sales = pd.read_csv("data/monthly_sales.csv")
sales["Profit"] = sales["Revenue_NPR"] - sales["Expense_NPR"]
sales["Profit_Margin"] = (sales["Profit"] / sales["Revenue_NPR"] * 100).round(2)
sales["Expense_Ratio"] = (sales["Expense_NPR"] / sales["Revenue_NPR"] * 100).round(2)
sales["Avg_Price"] = (sales["Revenue_NPR"] / sales["Units_Sold"]).round(0)
sales.head()
| Month | Revenue_NPR | Expense_NPR | Units_Sold | Profit | Profit_Margin | Expense_Ratio | Avg_Price | |
|---|---|---|---|---|---|---|---|---|
| 0 | Shrawan | 1250000 | 920000 | 420 | 330000 | 26.40 | 73.60 | 2976.0 |
| 1 | Bhadra | 1380000 | 980000 | 465 | 400000 | 28.99 | 71.01 | 2968.0 |
| 2 | Ashwin | 1120000 | 870000 | 380 | 250000 | 22.32 | 77.68 | 2947.0 |
| 3 | Kartik | 1450000 | 1050000 | 498 | 400000 | 27.59 | 72.41 | 2912.0 |
| 4 | Mangsir | 1620000 | 1180000 | 540 | 440000 | 27.16 | 72.84 | 3000.0 |
Practice 1
Load data/loans.csv and create a new feature Loan_to_Income = Loan_Amount ÷ Annual_Income. Print the first 5 rows of Loan_ID, Loan_Amount, Annual_Income, Loan_to_Income.
# Your turn:
Show solution
loans = pd.read_csv("data/loans.csv")
loans["Loan_to_Income"] = (loans["Loan_Amount"] / loans["Annual_Income"]).round(2)
loans[["Loan_ID","Loan_Amount","Annual_Income","Loan_to_Income"]].head()
| Loan_ID | Loan_Amount | Annual_Income | Loan_to_Income | |
|---|---|---|---|---|
| 0 | L2000 | 100000 | 180000 | 0.56 |
| 1 | L2001 | 500000 | 360000 | 1.39 |
| 2 | L2002 | 1000000 | 180000 | 5.56 |
| 3 | L2003 | 100000 | 180000 | 0.56 |
| 4 | L2004 | 100000 | 180000 | 0.56 |
3. Growth / change features
pct_change() calculates row-over-row % change — handy for month-over-month growth.
sales["MoM_Growth_%"] = (sales["Revenue_NPR"].pct_change() * 100).round(2)
sales[["Month","Revenue_NPR","MoM_Growth_%"]]
| Month | Revenue_NPR | MoM_Growth_% | |
|---|---|---|---|
| 0 | Shrawan | 1250000 | NaN |
| 1 | Bhadra | 1380000 | 10.40 |
| 2 | Ashwin | 1120000 | -18.84 |
| 3 | Kartik | 1450000 | 29.46 |
| 4 | Mangsir | 1620000 | 11.72 |
| 5 | Poush | 1780000 | 9.88 |
| 6 | Magh | 1550000 | -12.92 |
| 7 | Falgun | 1420000 | -8.39 |
| 8 | Chaitra | 1680000 | 18.31 |
| 9 | Baishakh | 1910000 | 13.69 |
| 10 | Jestha | 2030000 | 6.28 |
| 11 | Ashadh | 2150000 | 5.91 |
Practice 2
Compute the month-over-month % change of Units_Sold in the sales DataFrame.
# Your turn:
Show solution
sales["Units_Growth_%"] = (sales["Units_Sold"].pct_change() * 100).round(2)
sales[["Month","Units_Sold","Units_Growth_%"]]
| Month | Units_Sold | Units_Growth_% | |
|---|---|---|---|
| 0 | Shrawan | 420 | NaN |
| 1 | Bhadra | 465 | 10.71 |
| 2 | Ashwin | 380 | -18.28 |
| 3 | Kartik | 498 | 31.05 |
| 4 | Mangsir | 540 | 8.43 |
| 5 | Poush | 605 | 12.04 |
| 6 | Magh | 515 | -14.88 |
| 7 | Falgun | 478 | -7.18 |
| 8 | Chaitra | 560 | 17.15 |
| 9 | Baishakh | 640 | 14.29 |
| 10 | Jestha | 690 | 7.81 |
| 11 | Ashadh | 720 | 4.35 |
4. Date features
Most date columns hide useful information: month, day of week, weekend or not, quarter, year-end indicator. Once parsed, you can extract any of these.
inv = pd.read_csv("data/invoices.csv")
inv["Date"] = pd.to_datetime(inv["Date"])
inv["Year"] = inv["Date"].dt.year
inv["Month"] = inv["Date"].dt.month
inv["Quarter"] = inv["Date"].dt.quarter
inv["Weekday"] = inv["Date"].dt.day_name()
inv["Is_Weekend"] = inv["Date"].dt.weekday >= 5
# Days outstanding — assuming today is 2025-04-01
today = pd.Timestamp("2025-04-01")
inv["Days_Old"] = (today - inv["Date"]).dt.days
inv[["Date","Year","Month","Quarter","Weekday","Is_Weekend","Days_Old"]].head()
| Date | Year | Month | Quarter | Weekday | Is_Weekend | Days_Old | |
|---|---|---|---|---|---|---|---|
| 0 | 2024-07-16 | 2024 | 7 | 3 | Tuesday | False | 259 |
| 1 | 2024-07-18 | 2024 | 7 | 3 | Thursday | False | 257 |
| 2 | 2024-07-20 | 2024 | 7 | 3 | Saturday | True | 255 |
| 3 | 2024-07-22 | 2024 | 7 | 3 | Monday | False | 253 |
| 4 | 2024-07-24 | 2024 | 7 | 3 | Wednesday | False | 251 |
Practice 3
Load data/customers.csv and use the Onboarded_Date column to compute, for each customer, how many days they've been a customer (use pd.Timestamp("2025-04-01") as "today").
# Your turn:
Show solution
cust = pd.read_csv("data/customers.csv")
cust["Onboarded_Date"] = pd.to_datetime(cust["Onboarded_Date"])
cust["Customer_Age_Days"] = (pd.Timestamp("2025-04-01") - cust["Onboarded_Date"]).dt.days
cust[["Customer_ID","Onboarded_Date","Customer_Age_Days"]].head()
| Customer_ID | Onboarded_Date | Customer_Age_Days | |
|---|---|---|---|
| 0 | C500 | 2024-03-29 | 368 |
| 1 | C501 | 2023-05-31 | 671 |
| 2 | C502 | 2023-06-28 | 643 |
| 3 | C503 | 2023-05-06 | 696 |
| 4 | C504 | 2024-05-10 | 326 |
5. Binning — turning numbers into categories
Sometimes a category is more useful than the raw number. For example, we might want every invoice classified as Small / Medium / Large.
pd.cut() does this for you.
bins = [0, 50000, 200000, np.inf] # the cut-points
labels = ["Small", "Medium", "Large"]
inv["Size_Bucket"] = pd.cut(inv["Amount_NPR"], bins=bins, labels=labels)
inv["Size_Bucket"].value_counts()
Size_Bucket
Medium 94
Small 80
Large 26
Name: count, dtype: int64
Practice 4
Bin the Credit_Score column in loans into 3 buckets:
| Range | Label |
|---|---|
| 300 – 580 | Poor |
| 581 – 720 | Fair |
| 721 – 850 | Good |
Then count how many loans fall in each bucket.
# Your turn:
Show solution
loans["Score_Bucket"] = pd.cut(loans["Credit_Score"],
bins=[299, 580, 720, 850],
labels=["Poor","Fair","Good"])
print(loans["Score_Bucket"].value_counts())
6. Encoding categorical columns into numbers
Machine learning models only understand numbers. So a column like Payment_Status = "Paid" / "Pending" / "Overdue" must be converted.
Two common methods:
| Method | When to use |
|---|---|
map() |
A small fixed list (e.g. Yes/No, M/F) |
pd.get_dummies() |
Many unordered categories (one column per value) |
# Method 1 — simple mapping
status_map = {"Paid": 0, "Pending": 1, "Overdue": 2}
inv["Status_Code"] = inv["Payment_Status"].map(status_map)
inv[["Payment_Status","Status_Code"]].head()
| Payment_Status | Status_Code | |
|---|---|---|
| 0 | Paid | 0 |
| 1 | Overdue | 2 |
| 2 | Paid | 0 |
| 3 | Paid | 0 |
| 4 | Paid | 0 |
# Method 2 — one-hot encoding (good for unordered categories)
city_dummies = pd.get_dummies(inv["City"], prefix="City").astype(int)
city_dummies.head()
| City_Bhaktapur | City_Biratnagar | City_Birgunj | City_Butwal | City_Hetauda | City_Janakpur | City_Kathmandu | City_Lalitpur | City_Nepalgunj | City_Pokhara | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 |
| 2 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
| 3 | 0 | 0 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 4 | 1 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Practice 5
In the loans DataFrame:
- Map
Has_Collateralto numbers:"Yes"→ 1,"No"→ 0. Store asHas_Coll_Code. - One-hot encode the
Sectorcolumn with prefix"Sec"and show the first 5 rows.
# Your turn:
Show solution
loans["Has_Coll_Code"] = loans["Has_Collateral"].map({"Yes": 1, "No": 0})
sec = pd.get_dummies(loans["Sector"], prefix="Sec").astype(int)
print(loans[["Has_Collateral","Has_Coll_Code"]].head())
sec.head()
| Sec_Agriculture | Sec_Manufacturing | Sec_Personal | Sec_Service | Sec_Trade | |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 1 |
| 1 | 0 | 0 | 0 | 1 | 0 |
| 2 | 0 | 0 | 0 | 1 | 0 |
| 3 | 0 | 0 | 0 | 0 | 1 |
| 4 | 0 | 0 | 1 | 0 | 0 |
7. Scaling numeric columns
When one column is in lakhs and another in percentage points, models can get confused by the difference in magnitude.
The fix is to scale all numeric columns to a similar range.
| Scaler | Output range |
|---|---|
| Min-Max | 0 to 1 |
| Standard (Z-score) | mean 0, std 1 |
from sklearn.preprocessing import MinMaxScaler, StandardScaler
# Pick the numeric columns we want to scale
num_cols = ["Loan_Amount", "Annual_Income", "Credit_Score"]
mm = MinMaxScaler()
loans_mm = pd.DataFrame(mm.fit_transform(loans[num_cols]),
columns=[c+"_mm" for c in num_cols])
sd = StandardScaler()
loans_sd = pd.DataFrame(sd.fit_transform(loans[num_cols]),
columns=[c+"_z" for c in num_cols])
pd.concat([loans[num_cols].head(),
loans_mm.head(),
loans_sd.head().round(2)], axis=1)
| Loan_Amount | Annual_Income | Credit_Score | Loan_Amount_mm | Annual_Income_mm | Credit_Score_mm | Loan_Amount_z | Annual_Income_z | Credit_Score_z | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 100000 | 180000 | 300 | 0.000000 | 0.000000 | 0.000000 | -0.75 | -0.81 | -1.78 |
| 1 | 500000 | 360000 | 451 | 0.081633 | 0.081081 | 0.275046 | -0.39 | -0.51 | -0.82 |
| 2 | 1000000 | 180000 | 521 | 0.183673 | 0.000000 | 0.402550 | 0.06 | -0.81 | -0.38 |
| 3 | 100000 | 180000 | 403 | 0.000000 | 0.000000 | 0.187614 | -0.75 | -0.81 | -1.13 |
| 4 | 100000 | 180000 | 750 | 0.000000 | 0.000000 | 0.819672 | -0.75 | -0.81 | 1.06 |
8. Aggregated features — per-customer summaries
Sometimes the right "feature" lives at a higher level. For example: instead of looking at each invoice, build a row per customer summarising their entire behaviour.
cust_feat = inv.groupby("Customer").agg(
Total_Invoices = ("Invoice_No", "count"),
Total_Amount = ("Amount_NPR", "sum"),
Avg_Amount = ("Amount_NPR", "mean"),
Max_Amount = ("Amount_NPR", "max"),
Pending_Count = ("Payment_Status", lambda s: (s != "Paid").sum()),
).round(0)
cust_feat["Pending_Ratio"] = (cust_feat["Pending_Count"] /
cust_feat["Total_Invoices"]).round(2)
cust_feat.head()
| Total_Invoices | Total_Amount | Avg_Amount | Max_Amount | Pending_Count | Pending_Ratio | |
|---|---|---|---|---|---|---|
| Customer | ||||||
| Annapurna Stores | 19 | 2042877.0 | 107520.0 | 359325.0 | 5 | 0.26 |
| Biratnagar Enterprise | 23 | 1337239.0 | 58141.0 | 215209.0 | 8 | 0.35 |
| Birgunj Cargo | 19 | 1779339.0 | 93649.0 | 269451.0 | 9 | 0.47 |
| Everest Suppliers | 17 | 1669287.0 | 98193.0 | 273714.0 | 6 | 0.35 |
| Himalayan Traders | 23 | 2056517.0 | 89414.0 | 243441.0 | 10 | 0.43 |
Practice 6
Build a per-City summary from inv containing: Total_Invoices, Total_Amount, Avg_Amount. Sort by Total_Amount (largest first).
# Your turn:
Show solution
city_feat = inv.groupby("City").agg(
Total_Invoices = ("Invoice_No","count"),
Total_Amount = ("Amount_NPR","sum"),
Avg_Amount = ("Amount_NPR","mean"),
).round(0).sort_values("Total_Amount", ascending=False)
city_feat
| Total_Invoices | Total_Amount | Avg_Amount | |
|---|---|---|---|
| City | |||
| Bhaktapur | 26 | 2308814.0 | 88801.0 |
| Biratnagar | 15 | 2152410.0 | 143494.0 |
| Kathmandu | 26 | 2035189.0 | 78276.0 |
| Hetauda | 20 | 1924936.0 | 96247.0 |
| Lalitpur | 16 | 1791479.0 | 111967.0 |
| Butwal | 18 | 1767790.0 | 98211.0 |
| Birgunj | 21 | 1760326.0 | 83825.0 |
| Janakpur | 18 | 1748640.0 | 97147.0 |
| Nepalgunj | 20 | 1711640.0 | 85582.0 |
| Pokhara | 20 | 1533608.0 | 76680.0 |
9. Mini-Project — Customer Feature Set
Goal: prepare a per-customer feature table ready for the regression / classification notebooks.
Steps:
1. Load data/invoices.csv and parse the Date column.
2. For each customer build these features:
- Total_Invoices
- Total_Amount
- Avg_Amount
- First_Invoice_Date
- Last_Invoice_Date
- Days_Active = Last - First in days
- Pending_Ratio (share of invoices not paid)
3. Bin Total_Amount into Small / Medium / Large buckets (decide your own cut-points).
4. Save the result as customer_features.csv.
df = pd.read_csv("data/invoices.csv")
df["Date"] = pd.to_datetime(df["Date"])
# Your code here
Show solution
df = pd.read_csv("data/invoices.csv")
df["Date"] = pd.to_datetime(df["Date"])
cf = df.groupby("Customer").agg(
Total_Invoices = ("Invoice_No","count"),
Total_Amount = ("Amount_NPR","sum"),
Avg_Amount = ("Amount_NPR","mean"),
First_Invoice = ("Date","min"),
Last_Invoice = ("Date","max"),
Pending_Cnt = ("Payment_Status", lambda s: (s != "Paid").sum()),
).round(0)
cf["Days_Active"] = (cf["Last_Invoice"] - cf["First_Invoice"]).dt.days
cf["Pending_Ratio"] = (cf["Pending_Cnt"] / cf["Total_Invoices"]).round(2)
cf["Customer_Size"] = pd.cut(cf["Total_Amount"],
bins=[0, 1_000_000, 5_000_000, np.inf],
labels=["Small","Medium","Large"])
cf.to_csv("customer_features.csv")
cf.head()
| Total_Invoices | Total_Amount | Avg_Amount | First_Invoice | Last_Invoice | Pending_Cnt | Days_Active | Pending_Ratio | Customer_Size | |
|---|---|---|---|---|---|---|---|---|---|
| Customer | |||||||||
| Annapurna Stores | 19 | 2042877.0 | 107520.0 | 2024-07-28 | 2025-07-19 | 5 | 356 | 0.26 | Medium |
| Biratnagar Enterprise | 23 | 1337239.0 | 58141.0 | 2024-07-20 | 2025-08-10 | 8 | 386 | 0.35 | Medium |
| Birgunj Cargo | 19 | 1779339.0 | 93649.0 | 2024-07-26 | 2025-07-25 | 9 | 364 | 0.47 | Medium |
| Everest Suppliers | 17 | 1669287.0 | 98193.0 | 2024-08-17 | 2025-08-16 | 6 | 364 | 0.35 | Medium |
| Himalayan Traders | 23 | 2056517.0 | 89414.0 | 2024-08-27 | 2025-08-06 | 10 | 344 | 0.43 | Medium |
What's next?
You now know how to shape raw data into model-ready features.
In 06_Regression we will use these features to predict numbers (like next month's revenue),
and in 07_Classification we will predict categories (like loan default).