ADR 002: Aggregator as a Sufficiency-Reduction Transformer and sklearn.pipeline.Pipeline Contract¶
- Status: Accepted
- Date: 2026-10-14
Context¶
We want Aggregator and BaseEstimator to compose seamlessly inside a scikit-learn Pipeline:
pipe = Pipeline(
[
("agg", Aggregator(target=ds.var("K_locus"), strata=[ds.var("region")])),
("est", HierarchicalSVIEstimator()),
]
)
pipe.fit(ds)
Auditing the previous Aggregator implementation revealed two critical issues:
- Broken
transform()/fit_transform()Mechanics: Aggregator.transform(X)was implemented asreturn self.fit(X).- Because
sklearn.base.TransformerMixin.fit_transform(X)callsself.fit(X).transform(X), callingpipe.fit(ds)executed the full Polars aggregation twice. -
Worse, calling
pipe.predict(ds_test)invokedXt = agg.transform(ds_test), which mutated the fittedAggregatorin-place (overwritingself.data_withds_test) and returnedself. Downstream inBaseEstimator.predict(Xt), the identity checkif X is self.aggregator_:evaluated toTrue(sinceXtwas the mutatedself), causingpipe.predict(ds_test)to silently return the cached training estimates instead of predicting onds_test! -
Mathematical & Statistical ML Ethos (Change of Observational Unit):
- In standard row-wise
scikit-learntransformers (\(T: \mathcal{X}_{N \times D} \to \mathcal{X}'_{N \times D'}\)), sample cardinality \(N\) is preserved (\(|T(X)| = |X| = N\)). - Mathematically,
Aggregatoris a sufficiency reduction operator: it maps \(N\) isolate-level records (\(i \in \{1, \dots, N\}\)) to \(G \times K\) stratum-by-target binomial count cells \((k_{g,t}, n_g)\). - While
Pipeline.fit(ds)andPipeline.predict(ds_test)do not enforce \(|T(X)| = |X|\), standard row-wisesklearncross-validators (cross_val_score(pipe, ds, y)) expect prediction arrays of length \(N_{\text{test}}\) rather than \(G \times K\).
Decision¶
- Retain
Aggregatoras ansklearnTransformer(BaseEstimator, TransformerMixin) with Non-Mutatingtransform(): fit(X: Dataset, y=None) -> Self: AggregatesX, storesself.data_,self.variables_,self.target_,self.strata_, and marksself.is_fitted_ = True.fit_transform(X: Dataset, y=None) -> Self: Overridden explicitly toreturn self.fit(X, y)soPipeline.fit(ds)executes aggregation strictly once.-
transform(X: Dataset) -> Self: Validatesself.check_is_fitted(), creates a shallow clone (cloned = copy.copy(self)), runscloned.fit(X)on the clone, and returnscloned.- This guarantees
transform(X_test)is stateless/non-mutating on the trainingAggregator(self.data_remains untouched) and ensuresXt is not self.aggregator_inBaseEstimator.predict(Xt).
- This guarantees
-
Strict
sklearnTrailing-Underscore Convention: - Remove the non-underscored
@property def dataand@property def variablesfromAggregator. -
Fitted state is accessed strictly via
agg.data_andagg.variables_(matchingest.estimates_andest.variables_onBaseEstimator). -
Cross-Validation Boundary:
- Chaining
Pipeline([("agg", agg), ("est", est)])is endorsed for end-to-end fitting (.fit(ds)) and out-of-sample prediction (.predict(ds_test)). - Cross-validation stability analyses (e.g.,
Formulation) operate on the aggregated cell matrixagg.data_(splitting across strata/groups) rather than row-wise isolatecross_val_score.
Consequences¶
- Positive:
Pipeline([("agg", agg), ("est", est)]).fit(ds)runs in \(1\times\) time instead of \(2\times\). - Positive:
pipe.predict(ds_test)accurately computes predictions onds_testwithout corrupting the trainingAggregatoror hitting the cached training short-circuit. - Positive: Full compliance with
scikit-learntrailing-underscore conventions (data_,estimates_,variables_).