Skip to content

perf(splines): cubic/natural/tensor retain the training design matrix #19

Description

@ChrisW09

Summary

CubicSplineTransformer, NaturalCubicSplineTransformer and
TensorProductSplineTransformer store the full training design matrix on the fitted object
(designs_ / X_design_) and never use it for anything but reading .shape[1]. Fitted
objects therefore carry a copy of the training data, scaled by n_samples, into memory and
into every pickle.

Reproduction

import pickle
import numpy as np, pandas as pd
from pretab import Preprocessor

rng = np.random.default_rng(0)
df = pd.DataFrame({f"x{i}": rng.normal(size=20_000) for i in range(5)})
y = rng.normal(size=20_000)
for m in ("ple", "pspline", "cubicspline", "naturalspline", "tensorspline"):
    pre = Preprocessor(numerical_method=m, target_aware=False,
                       placement_strategy="uniform").fit(df, y)
    print(f"{m:14s} fitted pickle: {len(pickle.dumps(pre)) / 1e6:7.2f} MB")
ple            fitted pickle:    0.01 MB
pspline        fitted pickle:    0.01 MB
cubicspline    fitted pickle:    5.61 MB
naturalspline  fitted pickle:    5.61 MB
tensorspline   fitted pickle:    5.61 MB

pspline produces an identically-shaped basis and serialises to 0.01 MB, which shows the
retained matrices are not needed for transform. The gap grows linearly with the training-set
size — at 200k rows the same configuration would carry ~56 MB.

Expected

A fitted transformer retains only what transform and the penalty/inspection API need
(knots, basis counts), so its size is a function of the configuration rather than of the
training-set size.

Actual

The training design matrix is retained indefinitely and serialised.

Root cause

  • pretab/transformers/splines/cubic.py:180, 187self.designs_.append(self._bspline_basis(xi, knots))
  • pretab/transformers/splines/natural_cubic.py:192, 200 — same pattern
  • pretab/transformers/splines/tensor_product.py:219self.X_design_ = design

The only consumers read a shape:

  • cubic.py:189 / natural_cubic.py:202self.n_basis_ = [d.shape[1] for d in self.designs_]
  • cubic.py:223n_basis = self.designs_[feature_index].shape[1] inside
    get_penalty_matrix, where self.n_basis_[feature_index] is equivalent
  • tensor_product.py:243sizes = [b.shape[1] for b in self.bases_] in
    get_feature_names_out (bases_, likewise retained, is the per-marginal training basis)
  • tensor_product.py:262np.eye(b.shape[1]) in get_penalty_matrices

Every one of these needs a width, not the matrix.

Suggested fix

Compute the widths during fit and drop the matrices — e.g. in cubic.py, replace the
designs_ accumulation with a direct width computation and switch get_penalty_matrix to
self.n_basis_[feature_index]. For tensor_product.py, store
self.marginal_sizes_ = [b.shape[1] for b in bases] and drop bases_ / X_design_.

This is an API-visible change. designs_ and X_design_ are documented public
attributes (cubic.py:80, natural_cubic.py:83, tensor_product.py:102), and bases_ is
documented at tensor_product.py:95. Options:

  1. Remove them and note it in the changelog as a breaking change. Given 1.0.0 has not
    shipped yet, this is the natural moment — after 1.0.0 it needs a deprecation cycle.
  2. Keep them as lazily-recomputed properties, so the attribute still resolves but nothing is
    stored. Requires retaining the training input, which defeats the purpose.
  3. Keep the current behaviour and document the memory cost.

Option 1 looks right if it can land before the 1.0.0 tag.

Impact

Not a correctness problem — output is unaffected. It shows up as unexpectedly large pickles
and resident memory for Preprocessor(numerical_method="cubicspline" | "naturalspline" | "tensorspline"), scaling with training-set size. Relevant for anyone persisting a fitted
Preprocessor alongside a model artifact.

Environment

  • pretab 0.1.0 (main @ 51c3043)
  • Python 3.11.15, numpy 2.4.6, pandas 2.3.3, scikit-learn 1.9.0, scipy 1.17.1
  • macOS (darwin 25.5.0)

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions