Solving tips
- Scale each column independently: reduce along axis=0 so min and max are per-feature vectors of shape (n_features,).
- A constant column has max == min, so the denominator is zero — decide its output up front instead of dividing.
- Broadcasting (X - col_min) / col_range works when col_min and col_range have shape (n_features,).
Implement min-max scaling from scratch with NumPy, without using sklearn.preprocessing.MinMaxScaler. Min-max scaling is a standard preprocessing step that rescales each feature to a fixed range so that features on different scales contribute comparably to distance-based and gradient-based models.
Definition
For each column independently, with per-column minimum min and maximum max:
x_scaled = (x - min) / (max - min)
When a column is constant (max == min), the denominator is zero; map every value in that column to 0.
Task
Complete min_max_scale(X) so it returns a new array of the same shape where every column is scaled to [0, 1] using the formula above. Scale each column independently. Constant columns become all zeros. Do not call sklearn or any built-in scaler.
Example
X = np.array([[1.0, 10.0, 5.0],
[2.0, 20.0, 5.0],
[3.0, 30.0, 5.0]])
min_max_scale(X)
# -> array([[0. , 0. , 0. ],
# [0.5, 0.5, 0. ],
# [1. , 1. , 0. ]])
Columns 0 and 1 range from their min to max linearly onto [0, 1]. Column 2 is constant (5, 5, 5), so it maps to all zeros.
Constraints
1 <= n_samples, 1 <= n_features; use vectorized NumPy, not Python loops over elements.
- Values fit in float64.
- The output must not modify the input array in place.
Approach
Reduce along axis=0 to get the per-column min and max as vectors of shape (n_features,), then broadcast (X - col_min) / col_range across rows. The only subtlety is constant columns, where col_range is zero: temporarily replace those zeros with one to avoid dividing by zero, then force the affected columns to 0, which matches the required mapping for a constant feature.
Solution
import numpy as np
def min_max_scale(X: np.ndarray) -> np.ndarray:
X = np.asarray(X, dtype=np.float64)
col_min = X.min(axis=0) # shape (n_features,)
col_max = X.max(axis=0) # shape (n_features,)
col_range = col_max - col_min
# Avoid divide-by-zero for constant columns.
safe_range = np.where(col_range == 0, 1.0, col_range)
scaled = (X - col_min) / safe_range
# Constant columns map to 0 regardless of their value.
scaled[:, col_range == 0] = 0.0
return scaled
Walkthrough
On the example with columns [1,2,3], [10,20,30], [5,5,5]:
col_min = [1, 10, 5], col_max = [3, 30, 5], so col_range = [2, 20, 0].
safe_range = [2, 20, 1] (the zero for column 2 is replaced with 1).
(X - col_min) / safe_range gives column 0 [0, 0.5, 1], column 1 [0, 0.5, 1], and column 2 [0, 0, 0] (since X - col_min is already all zeros there).
- The boolean mask
col_range == 0 selects column 2 and sets it to 0.0, which it already is here but guarantees the rule when the constant value is nonzero.
Result:
[[0.0, 0.0, 0.0],
[0.5, 0.5, 0.0],
[1.0, 1.0, 0.0]]
Complexity & notes
- Time O(n_samples * n_features), space O(n_samples * n_features) — a couple of full-array passes for the reductions and the elementwise scaling.
np.asarray(..., dtype=np.float64) both avoids integer-division surprises and returns a new array, so the input is never modified in place.
- The
np.where trick keeps the code fully vectorized; the alternative of masking after the division would emit a divide-by-zero warning and produce nan/inf that you would then have to clean up.
- In a real pipeline you would fit
col_min and col_max on the training set and reuse them to transform validation and test data, rather than recomputing per split, to prevent leakage.