Feature engineering
Raw values rarely go straight into a model. Feature engineering reshapes them into forms that are easier to learn from. Three transforms cover most of the day-to-day:
- Normalise (min-max) — squash a number into the range
[0.0, 1.0]with(value - min) / (max - min). This stops a big-scale field (spend in the thousands) from drowning out a small-scale one (age in the tens). - Bucket (binning) — turn a continuous number into a category index by comparing it against sorted thresholds. "Age 27" becomes "bucket 1".
- One-hot encode — turn a category into a vector of 0s with a single 1. A model can't multiply by the word
"North", but it can multiply by{North: 1, South: 0, East: 0}.
Each is pure arithmetic and dict work. Watch the two edge cases: min-max divides by max - min, so guard against max == min (return 0.0); and an unknown category in one-hot should produce all zeros, not an error.
*In production these are sklearn.preprocessing.MinMaxScaler, KBinsDiscretizer, and OneHotEncoder. The maths underneath is exactly what's below.*
Syntax
def normalise(value, min_val, max_val):
if max_val == min_val:
return 0.0 # avoid divide-by-zero
return round((value - min_val) / (max_val - min_val), 4)
def bucket(value, boundaries): # boundaries sorted ascending
for i, threshold in enumerate(boundaries):
if value < threshold:
return i
return len(boundaries) # >= all thresholds → last bucket
def one_hot(value, categories):
return {cat: 1 if cat == value else 0 for cat in categories}Worked examples
Min-max normalise (and the flat-range guard)
def normalise(value, min_val, max_val):
if max_val == min_val:
return 0.0
return round((value - min_val) / (max_val - min_val), 4)
print(normalise(25, 0, 100)) # 0.25
print(normalise(100, 0, 100)) # 1.0
print(normalise(0, 0, 100)) # 0.0
print(normalise(5, 5, 5)) # 0.0 (max == min, guarded)
# Output:
# 0.25
# 1.0
# 0.0
# 0.0Bucket a value against sorted thresholds
def bucket(value, boundaries):
for i, threshold in enumerate(boundaries):
if value < threshold:
return i
return len(boundaries)
# boundaries [18, 35, 60] make four buckets: <18, <35, <60, >=60
print(bucket(15, [18, 35, 60])) # 0
print(bucket(25, [18, 35, 60])) # 1
print(bucket(75, [18, 35, 60])) # 3 (>= every threshold)
# Output:
# 0
# 1
# 3One-hot encode a category (unknown → all zeros)
def one_hot(value, categories):
return {cat: 1 if cat == value else 0 for cat in categories}
print(one_hot("North", ["North", "South", "East"]))
print(one_hot("West", ["North", "South", "East"])) # not in list
# Output:
# {'North': 1, 'South': 0, 'East': 0}
# {'North': 0, 'South': 0, 'East': 0}Common mistakes & gotchas
Min-max can divide by zero
If every value is the same, max == min and (value - min) / (max - min) is 0/0 → ZeroDivisionError. Always guard: if max_val == min_val: return 0.0. This happens more than you'd think on a column that's constant in a batch.
Buckets use < not <=
The transform returns the index of the first threshold the value is *less than*. With < threshold, a value exactly equal to a boundary falls into the HIGHER bucket: bucket(35, [18, 35, 60]) is 2, not 1. Decide which side the boundary belongs to and be consistent.
Min-max doesn't clamp out-of-range values
If a new value sits outside the min/max you computed (e.g. a test-set spend higher than anything in training), normalise returns a number above 1.0 or below 0.0. That's mathematically correct but often unwanted — clamp with max(0.0, min(1.0, result)) if your model expects a strict [0,1].
Why it matters
Models learn from the representation, not the raw value. Good features — scaled, bucketed, encoded — can make a simple model beat a fancy one fed raw data. Feature engineering is where most of the real accuracy gains in classical ML actually come from.