Scaling Sparse Data
A sparse matrix saves space by leaving most zero entries unstored. For example, a document may contain only a few words from a large vocabulary. Its absent-word counts are zero, so storing every zero would waste memory.
Dividing a zero by a positive scale leaves it zero. Subtracting a nonzero mean does not. This small difference determines whether a scaling operation can preserve sparse storage. Read standardization first if the mean and standard deviation are new.
Start with nine cells and only three stored values
Our training matrix has three rows and three features. Its nonzero entries are 2, 4, and 6, each in a different column. Every other cell is the number zero; it is not a missing observation.
The column means are approximately [2, 0.6667, 1.3333]. The column standard deviations are [2.8284, 0.9428, 1.8856]. Compare dividing by those standard deviations with subtracting the means first:
| Row | Feature 1 | Feature 2 | Feature 3 |
|---|---|---|---|
| A | 0 | 2 | 0 |
| B | 0 | 0 | 4 |
| C | 6 | 0 | 0 |
In the first column, subtracting the mean 2 turns [0, 0, 6] into [−2, −2, 4]. Dividing afterward cannot restore the original zeros. A much larger matrix can suffer the same expansion, even though centering itself is mathematically valid.
A column whose mean is zero is an exception: centering it changes nothing. The storage problem arises wherever an implicit zero receives a nonzero offset.
The zeros still count when fitting the scale
StandardScaler(with_mean=False) means “do not subtract the fitted mean during transformation.” It does not mean “estimate variance from the stored values alone.” The first column contains three observations, [0, 0, 6], with mean 2.
Using the population-variance convention from the standardization chapter, call this first column’s variance $\sigma_1^2$. Its value is
The divisor is therefore √8, approximately 2.8284. Looking only at the stored value [6] would incorrectly suggest zero variance. An efficient sparse routine accounts for the omitted zeros without constructing them.
After division alone, the column is [0, 0, 2.1213]. Its variance is 1, but its mean is about 0.7071. Unit variance does not require a zero mean. The scaler still records a mean to compute the variance; it simply does not subtract it from inputs.
How a sparse transform touches only stored values
Compressed Sparse Row storage, or CSR, represents this matrix with three arrays. The row and column indices below start at zero:
[2, 4, 6]The three values that actually occupy storage.[1, 2, 0]Value 2 belongs to column 1; value 4 to column 2; value 6 to column 0.[0, 1, 2, 3]Row 0 uses slot 0. Row 1 uses slot 1. Row 2 uses slot 2. The final boundary is just after the last slot.data to approximately [2.1213, 2.1213, 2.1213]. The column indices and row boundaries stay the same.For row i, its stored entries run from indptr[i] up to, but not including, indptr[i + 1]. For stored slot k, indices[k] tells us which column’s divisor to use. There is no need to loop over every cell in the full rectangular matrix.
Why nnz may differ from the number of nonzero cells
SciPy’s nnz counts stored entries, including explicitly stored zeros. count_nonzero() counts actual nonzero values. Our example has no explicitly stored zeros, so both counts are 3.
Manually built sparse input can also contain duplicate entries for one position. Combine duplicates before computing statistics; SciPy provides sum_duplicates(). Canonical CSR has sorted column indices within each row and no duplicates. eliminate_zeros() removes stored zeros when they are unnecessary.
Fit on sparse training data, then reuse the scales
This example keeps both training and future inputs sparse. The new row [0, 1, 0] reuses the second column’s fitted divisor 0.9428, producing a single stored value of approximately 1.0607.
Run the SciPy and scikit-learn example
import numpy as np
from scipy.sparse import csr_matrix, issparse
from sklearn.preprocessing import StandardScaler
# The dense literal is just for this nine-cell teaching example.
train = csr_matrix([[0., 2., 0.], [0., 0., 4.], [6., 0., 0.]])
scaler = StandardScaler(with_mean=False).fit(train)
scaled = scaler.transform(train)
future = scaler.transform(csr_matrix([[0., 1., 0.]]))
print(scaler.mean_.round(4).tolist())
print(scaler.scale_.round(4).tolist())
print(scaled.data.round(4).tolist())
print(scaled.indices.tolist(), scaled.indptr.tolist())
print(future.data.round(4).tolist())
assert issparse(scaled) and scaled.nnz == 3
assert np.allclose(scaler.inverse_transform(scaled).toarray(), train.toarray())
[2.0, 0.6667, 1.3333]
[2.8284, 0.9428, 1.8856]
[2.1213, 2.1213, 2.1213]
[1, 2, 0] [0, 1, 2, 3]
[1.0607]
The final assertion converts only these tiny matrices to dense arrays to check the inverse. Avoid using toarray() on a production matrix merely to inspect it. Shape, stored-entry count, and a few stored values usually answer the storage question.
Choose the operation and check the whole pipeline
| Need | Operation to consider |
|---|---|
| Per-column variance scaling | StandardScaler(with_mean=False): preserves zeros while learning scales from all training rows. |
| Per-column maximum-magnitude scaling | MaxAbsScaler: divides by fitted absolute maxima; an extreme value can dominate the divisor. |
| Direction rather than row length | Row normalization: preserves zeros but removes each nonzero row’s overall magnitude. |
Sparse storage does not decide which transform is useful for the task. For example, rescaling one-hot features changes the relative weight of rare and frequent categories. Compare that choice using the model’s validation procedure; do not assume every sparse column needs unit variance.
A later step can still create a dense result. Check the representation after feature combination and before the estimator, as well as after scaling. The one-hot encoding notes explain the memory tradeoff. Keep every learned vocabulary and scaler inside the training-fold boundary.
C++: apply saved scales without creating the zeros
The following routine performs only the transform step. It takes already-fitted divisors, including the effect of implicit zeros. Its loop visits stored values, and the example prints their coordinates directly. Valid CSR structure and finite values are preconditions; this is not a general sparse-input parser.
Compile the CSR column-scaling example
#include <cmath>
#include <iomanip>
#include <iostream>
#include <stdexcept>
#include <vector>
struct CSR {
std::vector<double> data;
std::vector<std::size_t> indices, indptr;
};
// Requires valid CSR storage, finite values, and valid column indices.
// Divisors were already fitted on training rows, including implicit zeros.
void scale_columns(CSR& x, const std::vector<double>& scale) {
for (double divisor : scale)
if (!(divisor > 0) || !std::isfinite(divisor))
throw std::invalid_argument("positive finite divisors required");
for (std::size_t k = 0; k < x.data.size(); ++k)
x.data[k] /= scale[x.indices[k]];
}
int main() {
CSR x{{2., 4., 6.}, {1, 2, 0}, {0, 1, 2, 3}};
const std::vector<double> scale = {
std::sqrt(8.), std::sqrt(8. / 9), std::sqrt(32. / 9)
};
scale_columns(x, scale);
std::cout << std::fixed << std::setprecision(4);
for (std::size_t row = 0; row + 1 < x.indptr.size(); ++row)
for (std::size_t k = x.indptr[row]; k < x.indptr[row + 1]; ++k)
std::cout << "row " << row << ", column " << x.indices[k]
<< ": " << x.data[k] << "\n";
}
row 0, column 1: 2.1213
row 1, column 2: 2.1213
row 2, column 0: 2.1213References and further reading
- Scikit-learn: StandardScaler — the separate centering and variance-scaling options, stored statistics, and sparse-input restriction.
- Scikit-learn: scaling sparse data — why additive centering can destroy sparse storage.
- SciPy: CSR representation — the data, indices, and row-pointer arrays; stored zeros and canonical form.