Skip to content

Center dense data inside the sandwich kernel - #560

Open
Marc-Antoine Schmidt (MarcAntoineSchmidtQC) wants to merge 5 commits into
mainfrom
centered-dense-sandwich
Open

Marc-Antoine Schmidt (MarcAntoineSchmidtQC) wants to merge 5 commits into
mainfrom
centered-dense-sandwich

Conversation

@MarcAntoineSchmidtQC

@MarcAntoineSchmidtQC Marc-Antoine Schmidt (MarcAntoineSchmidtQC) commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

Fixes #414, as an alternative to #540 that does not copy the data.

StandardizedMatrix computes its products by expanding X * mult + shift into uncentered terms. These cancel when a column's mean is large relative to its std, which can lose all significant digits. This PR subtracts the column means inside the C++ kernels while they read the dense data, so the products are computed on centered data without a copy:

  • sandwich: the dense sandwich kernel centers while packing its panels. For a SplitMatrix, the sparse × dense and categorical × dense cross kernels do the same.
  • matvec / transpose_matvec: the dense matrix-vector kernels take a center and are vectorized along the unrestricted direction, so restricted products also get faster.
  • standardize() keeps the exact means. For a StandardizedMatrix built directly, they are recovered from shift / mult.

Also fixes StandardizedMatrix.astype dropping mult, and transpose_matvec ignoring the order of cols when it lists all columns in a different order.

Benchmarks

float64 on an M1 Pro, against main and #540 with materialize_shift=True.

Accuracy at mean/std = 1e6 (max error / max |value|, against data centered in float64):

main #540 + flag this PR
dense sandwich 3.6e-3 2.9e-11 5.8e-16
dense transpose_matvec 3.2e-9 3.2e-10 1.4e-15
split sandwich 2.0e-3 4.6e-11 4.1e-15
split transpose_matvec 3.3e-9 2.7e-10 3.7e-15
StandardizedMatrix built directly, sandwich 9.9e-3 9.9e-3 2.3e-11

Speed (ms) and memory, 1M×50 unless noted:

main #540 + flag this PR
standardize 16 75 17
sandwich, F / C order 30 / 57 23 / 27 25 / 28
matvec, F order 6.0 4.6 7.4
transpose_matvec, F / C order 8.1 / 32 5.9 / 33 7.7 / 6.8
matvec with cols, 100k×500 5.4 5.8 2.7
split sandwich (50 dense, 20 sparse, 2 categoricals) 104 103 108
extra memory held 0 381 MB 0

#540 is faster for unrestricted matvec / transpose_matvec on F-order data, because it can call BLAS on its copy. On C-order data, OpenBLAS's X.T @ v is slow; this PR's kernel avoids it.

glum fits (time / iterations / coefficient error against a fit on centered data):

main #540 + flag this PR
dense 1M×50 binomial, means ~1e6, irls-ls fails (singular Hessian) 0.57 s / 3 / 1e-12 0.30 s / 3 / 3e-16
same, irls-cd 42 s / 200, no convergence 0.57 s / 3 / 1e-12 0.30 s / 3 / 6e-16
mixed DataFrame poisson, dense means ~1e6, irls-ls 0.51 s / 9 / 4e-9 0.44 s / 8 / 4e-11 0.43 s / 8 / 2e-15

#540 only helps if glum passes the flag; this PR fixes glum's current call. The reproducer from Quantco/glum#872 converges in 12 IRLS iterations (0.9 s) instead of hitting the 100-iteration cap (300 s). glum's test suite, including the golden masters, passes against this branch.

Checklist

  • Added a CHANGELOG.rst entry

StandardizedMatrix.sandwich expanded the product into uncentered terms that
cancel when a column's mean is large relative to its std. For a dense
matrix, the sandwich kernel now subtracts the column means while packing its
panels, so the product is computed on centered data without copying it.
Also fix StandardizedMatrix.astype dropping mult.
For a SplitMatrix, the dense columns are now centered in all kernels that
read them: the dense sandwich, the sparse x dense and categorical x dense
cross products, and the dense column sums. Their part of the shift then
vanishes, so the remaining expansion only involves the sparse and
categorical shifts.
StandardizedMatrix.matvec and transpose_matvec now center the dense columns
inside the kernels, like sandwich, instead of adding the shift to the
uncentered product. glum's gradient and the intercept row of its Hessian go
through transpose_matvec.

The dense matrix-vector kernels take an optional center and vectorize along
the direction that is not restricted, so that the centered products keep
about the speed of BLAS; small products run serially.
- SplitMatrix.sandwich, matvec and transpose_matvec share one loop with
  their centered versions, and the cross blocks with the dense part go
  through the usual _cross_sandwich dispatch with a dense_center argument.
- MatrixBase has default _centered_* methods, so StandardizedMatrix calls
  them on any matrix; _dense_centering centers column by column and always
  returns (center, shift).
- The dense kernels always get a center (zeros if none), validated by one
  Cython helper; the cross kernels keep a null center for their uncentered
  path, which would otherwise be slower.
- Fix transpose_matvec returning the columns in their original order when
  cols lists all of them in a different order.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Active correctness bugs discard meaningful shift residuals, break products on standardized dense slices, and return columns in the wrong order for unsigned indexers.

4 open issues
What changed in this PR

Improves numerical stability of standardized matrix products by centering dense data inside the kernels, addressing #414 without copying the matrix.

Changes:

  • Adds in-kernel centering for dense products and sparse/categorical cross-products.
  • Vectorizes dense matrix-vector kernels and fixes scaling preservation and permuted-column ordering.
  • Adds numerical-accuracy and regression tests, plus release notes.
File Description
tests/​test_standardized_mat.py Tests accuracy, slicing, casting, and column ordering.
tests/​test_fast_sandwich.py Tests centered kernels across layouts and restrictions.
src/​tabmat/​standardized_mat.py Tracks centers and dispatches centered products.
src/​tabmat/​split_matrix.py Routes centers through dense blocks and cross-products.
src/​tabmat/​sparse_matrix.py Passes dense centers into sparse cross-products.
src/​tabmat/​matrix_base.py Adds centered-product hooks and preserves exact means.
src/​tabmat/​ext/​split.pyx Exposes centering for categorical–dense kernels.
src/​tabmat/​ext/​sparse.pyx Exposes centering for sparse–dense kernels.
src/​tabmat/​ext/​sparse_helpers-tmpl.cpp Centers dense values during cross-product packing.
src/​tabmat/​ext/​dense.pyx Validates centers and dispatches dense kernels.
src/​tabmat/​ext/​dense_helpers-tmpl.cpp Implements centering and vectorized matrix-vector operations.
src/​tabmat/​ext/​cat_split_helpers-tmpl.cpp Centers dense values in categorical cross-products.
src/​tabmat/​dense_matrix.py Adds centered-product dispatch and column-order handling.
src/​tabmat/​categorical_matrix.py Forwards centers to categorical–dense kernels.
CHANGELOG.rst Documents accuracy fixes and performance changes.

Review effort: Balanced

Comment thread src/tabmat/dense_matrix.py Resolved
Comment thread src/tabmat/standardized_mat.py Resolved
Comment thread src/tabmat/standardized_mat.py Resolved
Comment thread src/tabmat/standardized_mat.py Resolved
- transpose_matvec compares cols with the identity directly; np.diff wraps
  around for unsigned indices, so permuted uint32 cols counted as
  unrestricted.
- Slicing a StandardizedMatrix squeezes the center like shift and mult, so
  the (1, k) column indexer of np.ix_ no longer leaves it two-dimensional.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Standardize DenseMatrix by shifting data

2 participants