Beyond the basics: explore MaxAbs Scaling, Power and Quantile Transformations, Vector Normalization, and custom scaling strategies. Includes Python and R code examples with a focus on sparse data and practical ML use cases.
Fundamentals
Data Preprocessing
Scaling
Sparse Data
Series: Feature Scaling
Author
January Tabaka
Published
August 27, 2025
In the previous post we covered standardization, normalization and robust scaling. In this post we will take a look at other scaling techniques.
MaxAbs Scaling
While similar to Min-Max Scaling, this method is purpose-built for data where the zero point is meaningful. It excels with sparse data (e.g., TF-IDF vectors), as it preserves the crucial zero entries, and with naturally centered data like audio waveforms (where 0 means silence) or financial price changes (where 0 means no change).
Principle: It scales each feature by its maximum absolute value so that the largest absolute value becomes 1 (or −1). The actual range depends on the data’s signs.
If your feature values are all non-negative, the scaled values will be in the range [0, 1].
If your feature values are all non-positive, the scaled values will be in the range [-1, 0].
\(X\) - is the vector of original values (the transformation is applied element-wise).
When to use it: Its primary advantage is that it does not shift or center the data. This is crucial for sparse datasets (matrices with many zeros), as you wouldn’t want to turn all the zeros into a non-zero number. Centering (subtracting mean/median) breaks sparsity.
Caution
In scikit-learn, use StandardScaler(with_mean=False) or RobustScaler(with_centering=False) to avoid densifying. Centering a sparse matrix is not supported and raises an error.
from sklearn.preprocessing import MaxAbsScalerimport numpy as np# Data with both positive and negative values, and a zerodata = np.array([[-20], [-10], [0], [0], [0], [10], [100]])# Instantiate the scaler and fit-transform datamaxabs_scaler = MaxAbsScaler()scaled_data = maxabs_scaler.fit_transform(data)print("Scaled Data: ", scaled_data.T)
Scaled Data: [[-0.2 -0.1 0. 0. 0. 0.1 1. ]]
Manual MaxAbs Scaling
# Find the maximum absolute value in the data# np.abs() gets the absolute value of each element firstmax_abs_val = np.max(np.abs(data))# Apply the formula: x / max(|x|)scaled_data = data / max_abs_valprint("Maximum Absolute Value:", max_abs_val,"\nManually Scaled Data (MaxAbsScaler): ", scaled_data.T)
Maximum Absolute Value: 100
Manually Scaled Data (MaxAbsScaler): [[-0.2 -0.1 0. 0. 0. 0.1 1. ]]
library(tidymodels)library(dplyr)data <-tibble(feature =c(-20, -10, 0, 0, 0, 10, 100))# For a self-contained example we compute from 'data'.# In practice: compute max_abs_val on TRAIN split# and reuse for validation/testmax_abs_val <-max(abs(data$feature))# Define recipe (scale by max absolute value)maxabs_recipe <-recipe(~ feature, data = data) %>%step_mutate(feature = feature / max_abs_val)# Prep and bakeprepared_maxabs_recipe <-prep(maxabs_recipe, training = data)scaled_data <-bake(prepared_maxabs_recipe, new_data = data)cat("Scaled Data: [", paste(round(scaled_data$feature, 3), collapse =" "), "]")
Scaled Data: [ -0.2 -0.1 0 0 0 0.1 1 ]
Deep Learning vs. Classical ML
Deep Learning: Rarely used. Neural nets usually stick with Min-Max (e.g. image pixels → [0,1]) or Standardization (e.g. embeddings, continuous features).
Classical ML: Highly relevant when working with sparse features (TF-IDF, user-item matrices). Keeps zeros - crucial for efficiency.
Power Transformation
This is a more advanced, non-linear transformation. Its goal is not just to rescale but to change the very distribution of the variable to make it more Gaussian-like (bell-curve shaped).
Principle: It applies mathematical transformations like Box-Cox (for positive data only) or Yeo-Johnson (for any data) to stabilize variance and minimize skewness.
When to use it: When you want to stabilize variance and reduce skew so that model residuals behave better (common for linear/GLM families). For heavily skewed features (e.g., incomes), power transforms can improve fit and inference.
A Deeper Look: How Power Transformations Work
At the heart of both the Box-Cox and Yeo-Johnson transformations is a parameter called lambda (\(\lambda\)), which controls the “shape” of the power transformation applied to the data. The algorithm’s goal is to automatically find the optimal \(\lambda\) that makes your data’s distribution as close to a Gaussian (bell-curve) shape as possible.
Box-Cox is a powerful and well-established method, but has one important limitation: it only works on strictly positive data.
Yeo-Johnson is a more modern and flexible version that can handle both positive and negative values, which is why it’s the default in scikit-learn’s PowerTransformer.
The underlying math involves a family of functions that includes logarithmic and power transformations. For a detailed look at the formulas, the Wikipedia article on Power Transforms is an excellent resource.
The key takeaway is that you don’t need to choose \(\lambda\) yourself; the fit() method does all the work of finding the best value for your data.
import numpy as npfrom sklearn.preprocessing import PowerTransformer# Skewed data (like an exponential distribution)data = np.array([[-2], [-1], [0], [1], [2], [5], [10],[100]])# method='yeo-johnson' is the default and works with any data# standardize=True makes the output have a mean of 0 and std of 1power_scaler = PowerTransformer(standardize=True)transformed_data = power_scaler.fit_transform(data)print("Transformed Data:\n", np.round(transformed_data.T, 3))
PowerTransformer automatically standardizes the data by default after applying the transformation. This is controlled by the standardize parameter which defaults to True. If you only need de-skewing (not zero-mean/unit-variance), set standardize=False.
You can see that the R’s recipe for this transformation includes two steps (step_YeoJohnson followed by step_normalize), it is there to ensure that our example produces a standardized result. If you only need de-skewing (not zero-mean/unit-variance) you can eliminate step_normalize step.
If all values were strictly positive, step_BoxCox(feature) could also be used.
Figure 1: On the left, a heavily skewed distribution. On the right, the same data after a power transformation, now resembling a normal distribution.
Deep Learning vs. Classical ML
Deep Learning: Almost never used — neural nets don’t need features to be Gaussian; they learn non-linear mappings themselves.
Classical ML: Very useful in regression/classical models (linear regression, GLMs) where skewness can harm performance or violate assumptions.
Quantile-Based Transformation
This is another powerful non-linear transformation that can be very effective, especially with outliers.
Principle: It transforms the features to follow a uniform or a normal distribution. It does this based on the quantile (or rank) of each data point. This means that outliers are often squeezed closer to the other data points.
When to use it: When you have outliers and you want to spread out the most frequent values. It can be very effective at transforming a feature’s distribution into something more manageable for the model.
import numpy as npfrom sklearn.preprocessing import QuantileTransformer# Outlier datasetdata = np.array([[10], [20], [30], [40], [500]])# output_distribution can be 'uniform' (default) or 'normal'# n_quantiles is how many quantiles to use for the mappingquantile_scaler = QuantileTransformer(n_quantiles=5, output_distribution='normal')transformed_data = quantile_scaler.fit_transform(data)# Values are now mapped to approximately normal quantilesprint("Transformed Data: ", np.round(transformed_data.T, 3))
step_orderNorm always aims for normal distribution (not uniform). It’s the closest R-native equivalent of scikit-learn’s QuantileTransformer.
Figure 2: On the left, data with a dense cluster and extreme outliers. In the middle, a uniform transformation spreads the data evenly. On the right, a normal transformation maps the data to a bell curve, effectively mitigating the outliers.
Deep Learning vs. Classical ML
Deep Learning: Rarely used. Quantile-based methods break the relative scale of distances between points, which is not ideal for gradient-based optimizers.
Classical ML: Anomaly detection, and any model sensitive to heavy tails/outliers.
Vector Normalization
This one is often confused with the others, but it is fundamentally different.
Key Difference: StandardScaler, MinMaxScaler, etc., all operate column-wise (on each feature independently). Normalizer operates row-wise (on each sample independently).
Principle: It scales each sample (row) so that it has a unit norm (L1 or L2). This is about changing the length of the feature vector for a given sample, not about comparing values across samples.
When to use it: When the direction of the data vector matters more than its magnitude. The most common use case is in text classification or clustering with features like TF-IDF, where you want to know if two documents are about the same topic, regardless of how long the documents are.
There’s no direct row-wise Normalizer in recipes. In R, this is usually done manually with base operations, since ML workflows rarely use row normalization outside of text analysis.
Explanation:
- Each row is scaled independently so its L2 norm = 1.
- Direction of the vector is preserved, but magnitude is ignored.
- This is why it’s useful in text similarity: documents are compared by angle (cosine similarity), not length.
Deep Learning vs. Classical ML
Deep Learning: Not a preprocessing step — instead, it’s often used after training on embeddings (e.g. L2-normalizing word embeddings before cosine similarity).
Classical ML: Critical in text mining, clustering, search/retrieval — makes cosine similarity comparisons meaningful.
Summary
Scaler/Transformer
Principle
Works On
Primary Use Case
Sensitivity to Outliers
MaxAbs Scaling
Scales by max absolute value (range depends on sign of data: [-1, 1], [0, 1], or [-1, 0])
Columns
Sparse data (preserves zero entries).
High
Power Transformation
Non-linear transform to make data more Gaussian-like
Columns
Stabilizing variance and handling skewed data.
Varies
Quantile-Based Transformation
Maps data distribution to a uniform or normal one
Columns
Spreading out dense clusters; handling outliers.
Low
Vector Normalization
Scales each sample to unit norm (length=1)
Rows
When vector direction matters more than magnitude (e.g., text analysis).
All the scalers so far are built-in. But what if you need something custom? That’s where FunctionTransformer (Python) or step_mutate (R) comes in. For example two very practical custom transforms are:
Log-scaling (e.g., log1p) for strictly positive, heavily skewed features (counts, income).
Clipping / Winsorization to cap extreme values before scaling.
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook. On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.
Sparse note: If you pass a sparse matrix, ensure your function can handle it (e.g., use scipy.sparse - aware ops). FunctionTransformer will pass the matrix through, your function must not densify it unintentionally.
You’ve profiled a feature and know the pathology (e.g., extreme skew or a few absurd outliers).
Built-ins don’t match your exact need (e.g., clip first, then scale).
You want the step to live inside your pipeline/recipe so it’s reproducible and applied consistently to train/validation/test.
Rule of Thumb: Apply log1p for positive, heavy-tailed counts; clip/winsorize before scaling when a handful of wild points distort ranges or standard deviations.
Deep Dive: Sparse Data
What is Sparse Data?
In the simplest terms, sparse data is data that is mostly made up of zeros.
Its opposite is dense data, where most of the values are non-zero.
There isn’t a strict mathematical rule, but if a very high percentage (e.g., >90%, >99%) of your data’s entries are zero, you are dealing with a sparse dataset.
An Intuitive Analogy: The Movie Rental Store
Imagine a huge movie rental store with 50,000 unique movies. This represents features.
Now, imagine we track the rental history of one customer, “Alice.” Alice has only ever rented 15 different movies. This is the sample.
If we were to represent Alice’s rental history as a row of data with 50,000 columns (one for each movie), it would look something like this:
This row has 50,000 values, but only 15 of them are non-zero. The other 49,985 values are zero. This is a highly sparse vector.
Why Sparsity is Special and Important
Sparse data is handled differently for two main reasons:
Storage and Memory Efficiency: It would be incredibly wasteful to store all those zeros. Instead of a standard NumPy array, sparse data is stored in special structures (like a scipy.sparse.csr_matrix) that only keep track of the non-zero elements and their locations (row and column index).
This can reduce memory usage from gigabytes down to megabytes.
The Problem with Standard Scalers:
This is why we need special scalers for sparse data. Let’s see what happens if you incorrectly use StandardScaler on sparse data.
The formula is \(z = \frac{x - \mu}{\sigma}\).
In a sparse dataset, the vast majority of values are 0, so the mean (\(\mu\)) will be a very small number close to zero (but not exactly zero). Let’s say \(\mu = 0.01\).
What happens when you scale a value that is 0? \(z = \frac{0 - 0.01}{\sigma}\)
The result is a non-zero number!
You have just taken a matrix that was 99% zeros and was stored efficiently, and you’ve turned it into a 100% dense matrix. The memory usage will explode.
How to Handle Sparse Data Correctly
This is where MaxAbsScaler and Normalizer come in.
MaxAbsScaler: The formula is \(x_{scaled} = \frac{x_i}{max(|x|)}\).
If a value \(x\) is \(0\), the result is \(\frac{0}{max(|x|)} = 0\).
It keeps the zeros as zeros! It preserves the sparsity of your data while still scaling the non-zero values.
Normalizer: This scaler works row-by-row, so it also inherently handles sparsity correctly by only considering the non-zero elements in its calculations for that row.
Text data (Bag-of-Words), user-item interactions (e.g., who bought what).
Storage
Standard NumPy arrays.
Specialized sparse matrix formats (scipy.sparse).
Appropriate Scalers
StandardScaler, MinMaxScaler, RobustScaler.
MaxAbsScaler, Normalizer.
Example 1: Text Data with Bag-of-Words
This is the most common source of sparse data in machine learning. The “Bag-of-Words” (BoW) model represents text by counting the occurrence of each known word, disregarding grammar and word order.
Scenario: We have a small corpus of three simple documents (sentences), and we want to convert them into numerical vectors.
import pandas as pdfrom sklearn.feature_extraction.text import CountVectorizer# Collection of text documentscorpus = ['The Ring must go to Mordor','Frodo takes the Ring to Mordor','Sauron rules in Mordor']# Initialize the vectorizer# This object will learn the vocabulary and transform the textvectorizer = CountVectorizer()# Fit the vectorizer to the corpus and transform the data# .fit_transform() learns the vocabulary# and converts the corpus into a word-count matrix.X = vectorizer.fit_transform(corpus)# Display the results in a human-readable format# Get the learned vocabulary (features)feature_names = vectorizer.get_feature_names_out()# Create a pandas DataFrame to visualize the sparse matrixdf = pd.DataFrame(X.toarray(), columns=feature_names, index=['Doc 1', 'Doc 2', 'Doc 3'])print("Learned Vocabulary (Features):", feature_names,"\nBag-of-Words Matrix (Dense Representation):")
frodo go in mordor must ring rules sauron takes the to
Doc 1 0 1 0 1 1 1 0 0 0 1 1
Doc 2 1 0 0 1 0 1 0 0 1 1 1
Doc 3 0 0 1 1 0 0 1 1 0 0 0
print(f"\nShape of the matrix: {X.shape}",f"Number of non-zero elements: {X.nnz}")
Shape of the matrix: (3, 11) Number of non-zero elements: 16
recipes does not yet support sparse matrices. In R, sparse workflows typically use the Matrix package, which represents large sparse datasets efficiently (e.g., dgCMatrix).
library(text2vec)library(Matrix)corpus <-c("The Ring must go to Mordor","Frodo takes the Ring to Mordor","Sauron rules in Mordor")# Create an iterator over tokenstokens <-space_tokenizer(corpus)# Create a vocabularyit <-itoken(tokens)vocab <-create_vocabulary(it)# Vectorizervectorizer <-vocab_vectorizer(vocab)# Create document-term matrix (sparse)dtm <-create_dtm(it, vectorizer)print(dtm)
As you can see from the output DataFrame, the result is a matrix where: * Each row is a document. * Each column is a unique word from the vocabulary. * The value in each cell is the count of how many times that word appeared in that document.
Notice the high number of zeros. Even in this tiny example, most cells are zero. Now, imagine a real-world vocabulary of 50,000 words. A single sentence might only use 10-20 of those words, meaning its vector representation would have over 49,980 zero entries. This is a sparse matrix. The scikit-learnCountVectorizer intelligently stores X in a scipy.sparse format to save memory. We call .toarray() only for the purpose of visualization.
Example 2: User-Item Interactions
This is the bedrock of recommendation systems (like those used by Netflix or Amazon). The goal is to represent which users have interacted with (e.g., bought, viewed, or rated) which items.
Scenario: We have a small e-commerce site. We want to create a matrix representing which of our 4 users have bought which of our 6 products.
import pandas as pd# Raw data of user-item interactions (e.g., from a database)# A '1' indicates a purchase.data = {'user': ['Alice', 'Bob', 'Alice', 'Charlie', 'Alice', 'David', 'Bob'],'item': ['Laptop', 'Mouse', 'Keyboard', 'Mouse', 'Monitor', 'Keyboard', 'Webcam']}df_interactions = pd.DataFrame(data)df_interactions['purchased'] =1print("Original Interaction Data:")
Original Interaction Data:
print(df_interactions)
user item purchased
0 Alice Laptop 1
1 Bob Mouse 1
2 Alice Keyboard 1
3 Charlie Mouse 1
4 Alice Monitor 1
5 David Keyboard 1
6 Bob Webcam 1
# Create the user-item interaction matrix using pivot_table# This transforms the "long" data format into a "wide" matrix format.# We fill non-interactions with 0.user_item_matrix = df_interactions.pivot_table( index='user', columns='item', values='purchased', fill_value=0)print("\nUser-Item Sparse Matrix:")
User-Item Sparse Matrix:
print(user_item_matrix)
item Keyboard Laptop Monitor Mouse Webcam
user
Alice 1.0 1.0 1.0 0.0 0.0
Bob 0.0 0.0 0.0 1.0 1.0
Charlie 0.0 0.0 0.0 1.0 0.0
David 1.0 0.0 0.0 0.0 0.0
Note
Sparse-friendly scalers are rare in R; most workflows here rely on packages like Matrix, text2vec, or specialized recommender frameworks rather than recipes.
# A tibble: 7 × 3
user item purchased
<chr> <chr> <dbl>
1 Alice Laptop 1
2 Bob Mouse 1
3 Alice Keyboard 1
4 Charlie Mouse 1
5 Alice Monitor 1
6 David Keyboard 1
7 Bob Webcam 1
4 x 5 sparse Matrix of class "dgCMatrix"
Keyboard Laptop Monitor Mouse Webcam
Alice 1 1 1 . .
Bob . . . 1 1
Charlie . . . 1 .
David 1 . . . .
Look at the resulting “User-Item Sparse Matrix”.
Each row is a user.
Each column is an item available for sale.
A 1 means the user has bought that item. A 0 means they have not.
Again, the matrix is mostly zeros. Alice has bought 3 out of the 6 available items, so her row is 50% sparse. In a real system like Amazon with millions of users and millions of products, any given user will have purchased an infinitesimally small fraction of the total items available. Their corresponding row in the user-item matrix would be incredibly sparse.
This sparsity is fundamental to how collaborative filtering algorithms work. They use this matrix to find users with similar patterns of non-zero entries to make recommendations.
This first example is dense on purpose to show the numeric effect; the next block shows true CSR sparsity.
import numpy as npfrom sklearn.preprocessing import MaxAbsScaler, StandardScaler# A DENSE array used to illustrate scaling# Each row is a document, each column is a word countdata = np.array([ [5, 0, 0, 1, 0], [0, 1, 0, 0, 0], [0, 0, 0, 0, 3], [1, 0, 2, 0, 0]])# --- CORRECT SCALING with MaxAbsScaler ---maxabs_scaler = MaxAbsScaler()maxabs_scaled = maxabs_scaler.fit_transform(data)print("Scaled with MaxAbsScaler (zeros are preserved):\n", maxabs_scaled)
# --- CORRECT: preserves sparsityX_maxabs = MaxAbsScaler().fit_transform(X)print("number of non-zeros after MaxAbs:", X_maxabs.nnz)
number of non-zeros after MaxAbs: 6
# --- CORRECT for sparse: no centeringX_std = StandardScaler(with_mean=False).fit_transform(X)print("number of non-zeros after StandardScaler(with_mean=False):", X_std.nnz)
number of non-zeros after StandardScaler(with_mean=False): 6
# --- INCORRECT: centering on sparse is not supportedtry: _ = StandardScaler(with_mean=True).fit_transform(X)exceptValueErroras e:print("Centering sparse matrices is not supported:", str(e)[:62], "…")
Centering sparse matrices is not supported: Cannot center sparse matrices: pass `with_mean=False` instead. …
R:text2vec — text feature extraction (DTMs/TF-IDF) with sparse matrices.
What’s Next: Bringing It All Together
Across this three-part journey, we’ve explored the entire landscape of feature scaling—from the foundational “why” in Part 1, to the everyday workhorses in Part 2, and now to the powerful specialists in Part 3.
But theory is one thing, and seeing the impact is another.
In the final, concluding post of this series, we will bring everything together. We will build a complete summary table of all the scalers we’ve discussed, provide downloadable Python and R notebooks with all the code in one place, and, most importantly, we will walk through a practical example. We’ll train a simple machine learning model on unscaled data, see how it performs, and then apply the right scaling technique to see the tangible improvement. This will be the practical demonstration of why scaling is a crucial preprocessing step when the model demands it.