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:

  1. What is a "feature" and why we create new ones
  2. Math features — ratios, growth rates, age
  3. Date features — month, weekday, festival flag
  4. Binning — turning numbers into buckets (e.g. invoice size: Small/Medium/Large)
  5. Encoding categorical columns into numbers
  6. Scaling numeric columns
  7. Aggregated features — per-customer summaries
  8. 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())
Score_Bucket Poor 140 Fair 86 Good 74 Name: count, dtype: int64

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:

  1. Map Has_Collateral to numbers: "Yes" → 1, "No" → 0. Store as Has_Coll_Code.
  2. One-hot encode the Sector column 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()
Has_Collateral Has_Coll_Code 0 No 0 1 Yes 1 2 Yes 1 3 Yes 1 4 Yes 1
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()
/var/folders/cm/07z8g9cd5r3092d58mr51l3w0000gn/T/ipykernel_59834/1824703694.py:11: UserWarning: obj.round has no effect with datetime, timedelta, or period dtypes. Use obj.dt.round(...) instead. ).round(0)
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).