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, 187 — self.designs_.append(self._bspline_basis(xi, knots))
pretab/transformers/splines/natural_cubic.py:192, 200 — same pattern
pretab/transformers/splines/tensor_product.py:219 — self.X_design_ = design
The only consumers read a shape:
cubic.py:189 / natural_cubic.py:202 — self.n_basis_ = [d.shape[1] for d in self.designs_]
cubic.py:223 — n_basis = self.designs_[feature_index].shape[1] inside
get_penalty_matrix, where self.n_basis_[feature_index] is equivalent
tensor_product.py:243 — sizes = [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:262 — np.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:
- 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.
- Keep them as lazily-recomputed properties, so the attribute still resolves but nothing is
stored. Requires retaining the training input, which defeats the purpose.
- 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)
Summary
CubicSplineTransformer,NaturalCubicSplineTransformerandTensorProductSplineTransformerstore the full training design matrix on the fitted object(
designs_/X_design_) and never use it for anything but reading.shape[1]. Fittedobjects therefore carry a copy of the training data, scaled by
n_samples, into memory andinto every pickle.
Reproduction
psplineproduces an identically-shaped basis and serialises to 0.01 MB, which shows theretained 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
transformand 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, 187—self.designs_.append(self._bspline_basis(xi, knots))pretab/transformers/splines/natural_cubic.py:192, 200— same patternpretab/transformers/splines/tensor_product.py:219—self.X_design_ = designThe only consumers read a shape:
cubic.py:189/natural_cubic.py:202—self.n_basis_ = [d.shape[1] for d in self.designs_]cubic.py:223—n_basis = self.designs_[feature_index].shape[1]insideget_penalty_matrix, whereself.n_basis_[feature_index]is equivalenttensor_product.py:243—sizes = [b.shape[1] for b in self.bases_]inget_feature_names_out(bases_, likewise retained, is the per-marginal training basis)tensor_product.py:262—np.eye(b.shape[1])inget_penalty_matricesEvery one of these needs a width, not the matrix.
Suggested fix
Compute the widths during
fitand drop the matrices — e.g. incubic.py, replace thedesigns_accumulation with a direct width computation and switchget_penalty_matrixtoself.n_basis_[feature_index]. Fortensor_product.py, storeself.marginal_sizes_ = [b.shape[1] for b in bases]and dropbases_/X_design_.This is an API-visible change.
designs_andX_design_are documented publicattributes (
cubic.py:80,natural_cubic.py:83,tensor_product.py:102), andbases_isdocumented at
tensor_product.py:95. Options:1.0.0has notshipped yet, this is the natural moment — after 1.0.0 it needs a deprecation cycle.
stored. Requires retaining the training input, which defeats the purpose.
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 fittedPreprocessoralongside a model artifact.Environment
main@ 51c3043)