Repository navigation
Allow standardizing a DenseMatrix by shifting the data - #540
abelianbee wants to merge 4 commits into
Conversation
Expanding a centered second moment from uncentered sums loses the significant digits when a column's mean is large relative to its standard deviation. Add an opt-in materialize_shift flag that folds the shift and multiplier into a copy of the data instead, so sandwich never forms outer(shift, shift) * sum(d). Sparse and categorical matrices ignore the flag and are never densified. unstandardize() stays exact via a reference to the pre-standardization matrix.
Marc-Antoine Schmidt (MarcAntoineSchmidtQC)
left a comment
There was a problem hiding this comment.
Thanks for your contribution!
I tested it on a few examples and the benefits are real, especially in terms of convergence for previously ill-conditioned problems.
I see at least one problem with the state of this PR. There are a few places in the code where a method returns a StandardizedMatrix (e.g.__getitem__, astype, getcol). Each of those output will be incorrect because the concept of the cached unstandardized is not passed along.
Two other things:
- We should make it clear in the documentation that this doubles the memory requirements (because the copy needs to be stored).
- Can you share your benchmarking code and confirm that the speedup includes the increased time it take for the original call to
standardize.
StandardizedMatrix.__getitem__, getcol and astype each built a new StandardizedMatrix without passing the unstandardized matrix along, so unstandardize() on the result returned the shifted copy. Thread it through, and note in the standardize docstring that materialize_shift keeps both the original and the copy.
astype passed only mat and shift to the new StandardizedMatrix, so a scaled matrix lost its column multipliers on cast and toarray() changed value.
|
Thanks a lot for taking the time to test this, and for the careful read. Good catch on While in
Materializing costs 2-4x more in The wheel build failure is docker failing to pull the manylinux image from quay.io, unrelated to the change. Let me know if you'd like anything else changed. benchmark script"""End-to-end timing for tabmat#414: standardize() + sandwich(), expansion vs materialized.
Answers the review question of whether the speedup includes the cost of the
standardize() call itself. Medians of 5 trials. Apple M1 Max.
"""
import time
import numpy as np
import tabmat as tm
def med(f, trials=5):
ts = []
for _ in range(trials):
t0 = time.perf_counter()
f()
ts.append(time.perf_counter() - t0)
return float(np.median(ts))
rng = np.random.default_rng(0)
# (n, p, dtype, column offset). Same shapes as the table in the PR description.
cases = [
(5_000, 1_000, np.float64, 1e3),
(1_000_000, 100, np.float64, 1e3),
(200_000, 50, np.float64, 1e6),
(5_000, 1_000, np.float32, 1e3),
]
hdr = f"{'n':>9} {'p':>5} {'dtype':>7} | {'std exp':>8} {'std mat':>8} | {'sand exp':>8} {'sand mat':>8} {'per call':>8} | {'std+1':>6} {'brkeven':>7} {'std+10':>6}"
print(hdr)
print("-" * len(hdr))
for n, p, dt, off in cases:
X = (rng.standard_normal((n, p)) + off).astype(dt)
w = np.full(n, 1.0 / n, dtype=dt)
d = rng.uniform(0.5, 1.5, n).astype(dt)
M = tm.DenseMatrix(X)
def std_exp():
return M.standardize(w, center_predictors=True, scale_predictors=True)[0]
def std_mat():
return M.standardize(
w, center_predictors=True, scale_predictors=True, materialize_shift=True
)[0]
Se, Sm = std_exp(), std_mat()
t_se, t_sm = med(std_exp), med(std_mat)
t_ae, t_am = med(lambda: Se.sandwich(d)), med(lambda: Sm.sandwich(d))
per_call = t_ae / t_am
one = (t_se + t_ae) / (t_sm + t_am)
ten = (t_se + 10 * t_ae) / (t_sm + 10 * t_am)
extra, save = t_sm - t_se, t_ae - t_am
be = 1 if extra <= 0 else ("inf" if save <= 0 else int(np.ceil(extra / save)))
print(
f"{n:>9,} {p:>5} {np.dtype(dt).name:>7} | {t_se*1e3:7.1f}ms {t_sm*1e3:7.1f}ms | "
f"{t_ae*1e3:7.1f}ms {t_am*1e3:7.1f}ms {per_call:7.2f}x | {one:5.2f}x {str(be):>7} {ten:5.2f}x"
)
print()
print("std = one standardize() call, sand = one sandwich() call.")
print("per call = sandwich-only speedup. std+1 / std+10 = standardize plus that many sandwich calls.")
print("brkeven = sandwich calls needed before materializing is a net win.") |
|
Sorry for the delayed response as I got busy with other tasks last week. I will review this tomorrow. |
|
One thing before completing this: while this fixes the issue for a pure DenseMatrix, it doesn't fix it for SplitMatrix, and SplitMatrix are quite important for our downstream project (glum). I want to make sure that the roadmap to implement it for SplitMatrix doesn't require us to rework or restructure what has been done in this PR. I will try to work on this in the coming days, but feel free to suggest fixes for SplitMatrix if you want. |
_materialize_standardization returned either a fully standardized matrix or None, so a SplitMatrix had no way to say that it had absorbed some columns but not others and the flag was silently a no-op for it. It now returns the matrix along with a mask of the columns that absorbed the shift, and standardize zeroes the shift and multiplier only there. SplitMatrix delegates to its parts, so the dense ones fold the standardization in and the sparse and categorical ones keep the shift representation. A partly absorbed matrix still has a non-zero shift, so it takes the same expansion path as before, but the shift that is left is small and the cancellation goes away.
|
No rework needed. I've pushed it here, happy to pull it into its own PR if you'd rather keep this one as reviewed. The only thing that had to change is the hook.
|
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Cached shift state can produce incorrect results after mutation, and materialization retains avoidable numerical cancellation.
Review effort: Balanced
Findings: 1
Open (4)
What changed in this PR
Adds opt-in materialized standardization to improve dense sandwich-product accuracy and performance.
Changes:
- Materializes shifts for dense matrix data, including dense SplitMatrix components.
- Optimizes zero-shift operations and preserves unstandardized matrices.
- Adds regression tests and release documentation.
| File | Description |
|---|---|
src/tabmat/matrix_base.py |
Adds the materialize_shift API and hook. |
src/tabmat/dense_matrix.py |
Implements dense materialization. |
src/tabmat/split_matrix.py |
Materializes supported matrix components. |
src/tabmat/standardized_mat.py |
Optimizes zero-shift operations and preserves originals. |
tests/test_matrices.py |
Tests representation, operations, slicing, and accuracy. |
CHANGELOG.rst |
Documents the feature and optimizations. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| # True when the standardization is already baked into ``mat``, either | ||
| # because no centering was requested or because it was materialized by | ||
| # :meth:`MatrixBase.standardize`. The expansions below all collapse in | ||
| # that case. | ||
| self._shift_is_zero = not shift_arr.any() | ||
| # When the standardization has been materialized into a copy, this | ||
| # holds the matrix it was derived from so that ``unstandardize`` can | ||
| # still hand back the original data. | ||
| self._unstandardized = unstandardized |
| if mult is not None: | ||
| standardized = array * mult | ||
| else: | ||
| standardized = array.astype(array.dtype, copy=True) | ||
| standardized += shifter |
|
|
||
| **New feature:** | ||
|
|
||
| - Added a ``materialize_shift`` option to :meth:`MatrixBase.standardize`. When set, the standardization is applied to a copy of the data instead of being stored as a shift and a multiplier, which makes :meth:`StandardizedMatrix.sandwich` both faster per call and much more accurate for columns whose mean is large relative to their standard deviation, at the cost of keeping a second copy of the data. Matrix types that would have to change their storage to absorb the standardization, such as :class:`SparseMatrix` and :class:`CategoricalMatrix`, ignore the option and are never densified. A :class:`SplitMatrix` applies it to the parts that can absorb it and leaves the others as they were. |
| If ``materialize_shift`` is set, the standardization is instead applied | ||
| to a copy of the data, and the returned StandardizedMatrix carries a | ||
| zero shift. This costs one O(n * k) copy up front and avoids the | ||
| expansion that :meth:`StandardizedMatrix.sandwich` would otherwise | ||
| perform, which is both faster per call and much more accurate: the | ||
| expansion adds ``outer(shift, shift) * sum(d)`` to the uncentered | ||
| second moment, and for a column whose mean is large relative to its | ||
| standard deviation those two quantities nearly cancel. | ||
|
|
||
| The original matrix is kept alongside the copy so that | ||
| :meth:`StandardizedMatrix.unstandardize` can still return it, so | ||
| this roughly doubles the memory held for the design matrix. |
|
I had more time to work on this issue. I was able to create a solution that does not copy the data. It relies on the low-level C++ kernel to subtract the mean when the data is in memory. Benefits:
The tradeoff here is that the low-level C++ code is less readable, but I think the benefits overweights this. |



Closes #414.
standardizekeepsshift = -mean/stdandmult = 1/stdon theStandardizedMatrix, sosandwichexpands the product into four terms, one of themouter(shift, shift) * sum(d). That is a centered second moment computed from uncentered sums, so when a column's mean is large relative to its standard deviation those terms are orders of magnitude larger than their sum and the significant digits cancel away. No regrouping of the algebra avoids it. Centering the data does.This adds an opt-in
materialize_shiftflag tostandardize. When set, the shift and multiplier are folded into a copy of the data and the returned matrix carries a zero shift, so the expansion never happens. The default is unchanged._materialize_standardizationreturnsNoneby default, soSparseMatrixandCategoricalMatrixignore the flag and are never densified.StandardizedMatrixholds a reference to the pre-standardization matrix, sounstandardize()stays exact and free, whichglumrelies on in_glm.py.Accuracy against a
longdoubleoracle, and timing end-to-end including thestandardizecall, medians of 5, M1 Max:sandwichper callstandardize+ 10sandwichMaterializing pays for a copy in
standardize, so a singlesandwichcall is not a win on tall matrices. It breaks even after 2-3 calls and is 1.2-1.4x by 10, since each call skips three p x p outer products and an extratranspose_matvecin place of one BLAS-3 call on contiguous data. The accuracy gain does not depend on the number of calls.Tests cover representation equivalence across all seven matrix fixtures with both flag values, operation equivalence for
sandwich/matvec/transpose_matvec, and an accuracy regression against alongdoublereference. 5666 passed, 45 skipped, 4 xpassed.I moved the unreleased changelog section from 4.2.2 to 4.3.0, since this adds functionality. Happy to put it back if you would rather keep the number.
Checklist
CHANGELOG.rstentry