← All Posts
Deep Learning · Data Preparation and Preprocessing · Feature Scaling

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:

Watch the zero cells
RowFeature 1Feature 2Feature 3
A020
B004
C600
Original: 3 nonzero cells out of 9. The six zeros can be left unstored.
Colored cells are nonzero. “Center + divide” illustrates the result using a tiny dense calculation; scikit-learn rejects centering a sparse input instead of silently making this conversion.

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

$$\sigma_1^2=\frac{(0-2)^2+(0-2)^2+(6-2)^2}{3}=8.$$

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:

Values · data[2, 4, 6]The three values that actually occupy storage.
Columns · indices[1, 2, 0]Value 2 belongs to column 1; value 4 to column 2; value 6 to column 0.
Row boundaries · indptr[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.
Scale the values; keep their addresses. Dividing by the fitted column scales changes 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

NeedOperation to consider
Per-column variance scalingStandardScaler(with_mean=False): preserves zeros while learning scales from all training rows.
Per-column maximum-magnitude scalingMaxAbsScaler: divides by fitted absolute maxima; an extreme value can dominate the divisor.
Direction rather than row lengthRow 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.1213

References and further reading