Allow standardizing a DenseMatrix by shifting the data - #540
abelianbee wants to merge 3 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.") |
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