Chapter 5 · Data / ML Engineer

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.0

Bucket 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
# 3

One-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/0ZeroDivisionError. 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.