Skip to content

Add PCA dimensionality reduction to the anvil pipeline - #592

Merged
smcolby merged 60 commits into
mainfrom
feat/pca-featurizer
Sep 9, 2026
Merged

Add PCA dimensionality reduction to the anvil pipeline#592
smcolby merged 60 commits into
mainfrom
feat/pca-featurizer

Conversation

@smcolby

@smcolby smcolby commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Description

Adds PCA dimensionality reduction as a first-class step in the anvil pipeline. PCATransform occupies a new transform slot in the procedure section, between featurization and training. With an integer n_components it runs a single PCA over the full feature matrix; with a dict it reduces each featurizer block to its own component count (e.g. fingerprints to 256 dims and descriptors to 32), resolving the block layout from the featurizer via a new feature_blocks protocol. A per-block count of null passes that block through unreduced, for blocks whose columns carry meaning one by one and would only be rotated by a PCA; every block still needs an entry, so passthrough is explicit and a mistyped key is still caught. An optional impute_strategy handles NaNs ahead of the reduction since PCA cannot see NaNs itself.

Fitting happens on the train partition only, the transform is applied to every partition, and the fitted transform is persisted beside the model (schema-versioned transform.pickle) so that decoupled inference sees exactly the features training saw: predict() loads the artifact and applies it, and raises loudly instead of silently skipping when a model trained with a transform has no artifact. The transform section accepts a single transform or an ordered list, so column operations such as median imputation followed by per-block PCA can be stacked. Transforms are consumed by tabular-input workflows only: the sklearn workflow rejects featurizers that emit DataLoaders, and the lightning workflow rejects transforms, so ChemProp pairings fail at construction with a clear message.

The transform and its block protocol

  • New PCATransform in the transforms registry: int or per-block n_components (an int, or null for passthrough), optional mean/median imputation, per-entry random_seed; validates block keys against the featurizer layout, exact width coverage of the raw matrix, and component counts against the fitting data, where a count may go up to the block rank as sklearn itself allows
  • fit_transforms / transform_features helpers to fit and apply a single transform or an ordered sequence, threading each output into the next
  • FeaturizerBase.feature_blocks reporting the raw featurizer's column layout keyed by registry name; FeatureConcatenator flattens nested concatenations in emit order

Training and inference wiring

  • Anvil workflow: probes the block layout on train rows, fits the transform sequence on train only, applies it to train/val/test/full, and saves the fitted transforms beside the model
  • Anvil specification and YAML parsing: transform accepts a TransformSpec or a list of them; the global seed fills entries that set none
  • Inference: load_anvil_model_and_metadata returns the fitted transform as a fifth element (existing in-package callers updated); predict() applies it after featurization, normalizes 1D single-row output, and checks that row count is preserved
  • FeatureConcatenator accepts the list entry form used in recipe YAML and rejects duplicate featurizer classes, whose identical block keys could not be disambiguated
  • Post-hoc comparison label builder handles the list feature section form
  • ImputeTransform.fit returns self as its docstring always claimed (chained usage was previously broken)

Tests and docs

  • Unit tests for PCATransform, the sequence helpers, block layouts, workflow/specification wiring, and inference; two new CPU integration recipes (single PCA over ECFP, and stacked imputation plus per-block PCA over a fingerprint plus 2D-descriptor concatenation)
  • Docs: API pages for the transforms module and a transform section (single and per-block examples) in the anvil reference

Notes for reviewers

  • Model directories trained with a transform by an early dev build of this feature lack transform.pickle and will now hard-error at inference until retrained (previously inference would have consumed the wrong features silently).
  • Per-block PCA assumes a stable column layout between train and inference batches; featurizers whose width is batch-dependent (mordred in some environments) trip the fit-time width guard, by design and documented in the docstring.

Quality Assurance & AI Policy

To maintain project quality and respect maintainer bandwidth, please confirm the following:

  • Manual Verification: I have manually reviewed and tested the code in this PR.
  • AI-Assisted Content: If AI tools were used (e.g., Copilot, ChatGPT), I have personally verified the logic, edge cases, and compliance with the existing codebase. I confirm the code is not a "blind" AI generation.
  • Minimal Review: I believe this PR is in a state that requires minimal intervention or correction from maintainers.
  • Scoped Change: This PR addresses a single, well-scoped issue rather than multiple unrelated changes.

Status

  • Ready to go (Checking this signals to maintainers that the PR is ready for final review)

Developers Certificate of Origin


Note to Contributors: We reserve the right to close PRs without review if they appear to lack human validation or do not meet the quality standards described in our CONTRIBUTING.md.

PCA dimension reduction was missing from the anvil pipeline while
feature matrices keep growing (2048-bit fingerprints, descriptor
blocks), so models train slowly on redundant dimensions.

PCATransform fits one PCA over the whole matrix (int n_components) or
one PCA per feature block (dict of block key to component count,
keys validated two-way against the featurizer block layout) and can
impute NaNs ahead of the PCA since PCA itself cannot see NaN. Fit
validates block keys, duplicate keys, exact width coverage, and
component counts against the train data; transform validates the
fitted column layout so wider or narrower matrices fail loudly.

TransformBase gains an accepts_feature_blocks flag so workflows can
forward block layouts only to transforms that use them, and the
module-level helpers fit_transforms / transform_features fit or
apply a single transform or an ordered sequence by threading each
element's output into the next. ImputeTransform.fit now returns self
as its docstring always claimed, so chained usage works.
Per-block transforms (e.g. per-featurizer PCA with different
dimensionalities) need to know where each source featurizer's columns
sit inside a concatenated matrix, but featurizers only exposed the
merged array.

FeaturizerBase.feature_blocks reports the column layout as (key,
width) pairs in matrix order, defaulting to a single block keyed by
the registry name whose width is probed from the first featurizable
row; the probe skips rows that fail featurization and raises only
when nothing probes. FeatureConcatenator flattens child layouts
recursively in concatenation order so nested concatenators stay
aligned with the emitted columns.

While there, FeatureConcatenator accepts the {type, params} list
entry form already used by anvil section YAMLs (single-key entries
and instances keep working) and rejects same-class featurizers, which
would produce same-key blocks that per-key transforms cannot
disambiguate.
Transforms were fit and re-applied by hand and were lost in
decoupled inference: predict only featurized, so any model trained
with a transform consumed the wrong features after training.

The sklearn workflow now probes the featurizer block layout on the
train rows, fits the transform sequence on the train partition only
(no fit leakage into val/test), applies it to val/test/full, and
persists the fitted transforms next to the model as a schema-versioned
pickle; the recipe YAML carries configuration only. Transform
sections accept a single transform or an ordered list, with the
global seed filling entries that set none.

Inference loads the saved artifact when the recipe declares a
transform (missing artifact or unknown schema is a hard error, never
a silent skip), returns it from load_anvil_model_and_metadata
(4-tuple becomes a 5-tuple), and predict applies it before the model
with a row-count boundary check and single-row 1D normalization.
The sklearn workflow rejects DataLoader-emitting featurizers with a
clear message, and the posthoc comparison label builder handles the
list feature section form.
The transform path had no end-to-end coverage on a real dataset. Two
CPU recipes train LGBM on the 1000-row CYP multitask set: a single
256-component PCA over ECFP-4 fingerprints, and a stacked
ImputeTransform plus per-block PCA (256 fingerprint components, 32
descriptor components) over a 2D-descriptor-plus-fingerprint
concatenation, which also exercises the list feature form and the
NaN-imputation step.
@codecov-commenter

codecov-commenter commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.65625% with 6 lines in your changes missing coverage. Please review.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

pre-commit-ci Bot and others added 25 commits August 17, 2026 18:29
A rule pass over the 44 new tests found four groups expressing one
contract across several bodies: n_components construction validation,
the to_workflow seeding behavior, and the transform section parse
shapes, plus a list-entry-form test whose mixed list proved nothing
beyond the per-form parses. Each is now a single parametrized test
(44 functions reduce to 41) that reuses the existing spec builder
helper.

The same pass showed the boolean n_components guard is unreachable:
pydantic coerces True to 1 during int-or-dict type validation, before
the field validator runs, so the guard and its test case are removed
rather than testing a path that cannot fail. fit and transform
docstrings gain *args/**kwargs descriptions for the D417 gate.
The ruff 0.16.2 pinned by pre-commit formats this pre-existing cell
differently (single to double quotes), which blocked pre-commit run
--all-files for the whole branch.
Both the single-transform and transform-sequence cases now flow through
the same (section_spec, component) seeded_sections loop instead of the
sequence case separately checking a None sentinel on the built
component. TransformSpec already carries its own params dict, so
_section_sets_seed works per-item once transform_spec/transform are
normalized to lists.
…ntion

TransformSpec.transform and AnvilWorkflowBase.transform were typed as
T | list[T] | None, forcing single-vs-list branching everywhere they were
consumed (to_workflow, seed-fill, saved recipe YAML). Every other optional
section in these models (ensemble, param_paths, serial_paths) follows the
same shape instead: None for absence, otherwise always the collection type.

Both fields are now list[T] | None, with a mode="before" validator that
wraps a bare mapping or transform instance into a one-element list, so
existing single-transform YAML and direct Python construction keep working
unchanged. to_workflow's transform handling and the seed-fill block from
the previous commit both simplify since transform is always a list or None,
never a bare instance.
target_cols already had a mode="before" validator wrapping a bare string
into a one-element list, so self.target_cols was always a list[str] by
construction time. The Union[str, list[str]] annotation and the two
isinstance branches in _read_multiple_resources/_read_single_resource were
defending against a shape that could no longer occur post-validation.
The one-line summary didn't explain why the sklearn workflow needs a
numpy-producing featurizer or what failure mode this catches (a deep
learning featurizer like ChemProp's paired with an sklearn-family model
like XGBoost previously failed late inside the trainer instead of at
spec-construction time).
Clarify why label_and_task_name_from_anvil handles both the dict and
ordered-list forms of FeatureConcatenator.featurizers when parsing a
saved anvil_recipe.yaml.
_parse_featurizer_entry hand-rolled shape detection and error raising for
the two accepted list-entry forms. FeaturizerEntry (a BaseModel with
extra="forbid" and a before-validator coercing the single-key form)
matches the same normalize-via-before-validator pattern already used for
TransformSpec and DataSpec.target_cols, and gets extra-key rejection for
free instead of a manual set-difference check.
feature_blocks previously rediscovered each featurizer's column width by
featurizing single probe inputs one at a time, skipping ones that failed.
Only FeatureConcatenator ever consumes block boundaries (per-block PCA
needs them; a bare leaf featurizer never does, since int n_components
never checks feature_blocks), so the probing lived on FeaturizerBase for
no real reason and duplicated featurization work FeatureConcatenator was
already doing in its own featurize() loop.

feature_blocks is now a thin accessor on FeatureConcatenator that returns
the block list recorded as a side effect of the most recent featurize()
call, and raises if featurize() hasn't run yet. FeaturizerBase no longer
defines feature_blocks at all.
FeaturizerEntry only ever normalized a list-form featurizer entry inside
validate_featurizers and was never constructed or imported anywhere else,
so a full BaseModel with its own validator and extra="forbid" config was
more machinery than the two-branch dict-shape check needed. The dropped
extra="forbid" check wasn't protecting much either: FeaturizerBase
subclasses don't set extra="forbid" themselves, so a typo inside params
was already silently ignored, same as a typo would now be at the wrapper
level.
hasattr(self.feat, 'feature_blocks') was the only capability check in the
codebase testing for method presence instead of a declared ClassVar[bool]
flag; accepts_feature_blocks, pickleable, and is_cross_val all follow the
declared-flag pattern instead. provides_feature_blocks brings this check
in line with that convention: False on FeaturizerBase, True on
FeatureConcatenator, the only current producer.
…olled parsing

FeatureConcatenator was the only place in anvil that reimplemented the
type-string-to-class resolution that AnvilSection.to_class() already
provides, and that hand-rolled path had accumulated a third accepted
entry shape (single-key dicts) that no config actually used.

List entries now resolve via FeatureSpec.to_class(), and the single-key
shape is dropped. FeatureSpec runs with pydantic's default extra=ignore,
so an entry missing 'type' would parse as type=None and fail later with
a confusing message; a guard rejects it up front instead.

The whole-field dict form is kept but now warns, since saved recipe
YAMLs still use it and cannot be rewritten retroactively.
The test asserted that a nested entry inherits AnvilSection's alias
mapping, using a featurizer that declares no random_seed field. That
behavior is incidental to the entry-resolution contract this module
covers, and warning toward a field the class does not define is a
defect rather than something to pin. Entry resolution through
FeatureSpec is already covered by test_concatenator_list_entry_forms.
The Featurization section still taught the whole-field dict form for
`FeatureConcatenator.featurizers`, which now emits a DeprecationWarning,
while the new Transform section used the supported `{type, params}` wrapper
list. Switch the concatenator example to the wrapper form so both sections
agree, and note that entries take the same shape as the `feat` section
itself.

Transforms were described as row-wise; PCA reduces columns and imputation
fills from per-column statistics, so drop the axis claim in both
`anvil_reference.rst` and `_api/api/transforms/index.rst`. Fix the "depth
learning" typo and add the `constant` strategy missing from the
`ImputeTransform` row.

Every `code-block:: yaml` directive in `anvil_reference.rst` was missing the
blank line that separates a directive from its content, so docutils rejected
the argument list and rendered an error in place of the YAML. Add it to all
eighteen.
Three parse errors, each of which dropped content rather than merely
rendering it oddly.

`**Parameters**` sat directly against `.. list-table::` in the metadata and
data sections, so docutils read the table as an indented block under the
paragraph and rejected it.

The `train_resource`, `val_resource`, and `test_resource` rows in the data
table were indented one space deeper than the rest of the table, and their
description continuation lines deeper still, so the three rows were not part
of the table at all.

The report section's `Example` heading used the level-5 underline that the
rest of the file reserves for subsections of `~` headings, skipping a level
below the `^` of `Report`.
Both are unconditional third-party dependencies with no import cycle to
break, and the module already imports torch and lightning at top, so
deferring them to the transform-persistence block saved nothing.
FeatureSpec.to_class() unwraps to `get_featurizer_class(type)(**params)`
plus the AnvilSection validators, of which only the deprecated
`random_state` alias has any effect. Calling the registry directly says the
same thing in one step, drops the function-local spec import that existed
only to avoid a cycle, and makes both the list and deprecated-dict branches
resolve the same way.

The cost is that a nested entry carrying `random_state` loses its seed
rather than mapping it. Both featurizers declaring `random_seed`
(ChemPropFeaturizer, PairwiseFeaturizer) emit DataLoaders and cannot be
concatenated, so the gap is unreachable in a configuration that otherwise
works, and #596 closes it by moving the alias onto the classes.
A bare `TypeName:` with nothing after it parses as None, not an empty
mapping, so splatting it raised TypeError. The list form already guards
this; apply the same treatment to the dict form.
Leaving None in the type kept an unseeded, unreproducible fit reachable, which
is not a mode worth offering on a transform whose loadings are persisted and
re-applied at inference. The field is now int, so every fit is reproducible by
construction. Nothing passed None: no recipe sets a null seed and no caller
relied on the optional form.
The missing/unexpected comparison was written out twice, once at workflow
construction against the featurizer's declared blocks and once inside
PCATransform.fit as the backstop for transforms fitted outside a workflow.
Both said the same thing in slightly different words, so hoist the comparison
and its error into check_block_keys in transform_base and have each caller
name its own subject: 'n_components' from the transform, the transform's class
name from the workflow.
Whether a transform uses feature blocks was answered in two places: a
hand-maintained ClassVar declaring that fit takes the kwarg, and
required_block_keys reporting the keys the instance is configured against.
Nothing kept the two in agreement, and a transform that needed the layout but
forgot the flag was silently never given it, fitting over the whole matrix
instead.

fit_transforms is the only caller of a transform's fit, so it now passes
feature_blocks unconditionally and transforms that ignore the layout absorb
the kwarg. A transform whose fit cannot accept it now fails loudly instead.
required_block_keys is left as the single answer to whether an instance is
configured per block.
@smcolby
smcolby requested a review from dwwest August 21, 2026 17:18
The fit-time guard rejected n_components at min(train rows, block width),
one stricter than sklearn's own bound, which admits a full-rank PCA. A
four-column block could therefore ask for at most three components, losing a
dimension to a check rather than to a modeling decision.
Per-block n_components required a count for every block, so a block that
wanted no reduction had only a full-rank entry, which centers and rotates the
columns into linear combinations. That is the wrong shape for a narrow block
whose columns each mean something, such as the per-task predictions of a
pretrained model used as features.

A null value records the block with no pipeline and slices it straight into
the output at fit and transform alike. Every block still needs an entry, so
passthrough stays explicit and check_block_keys still rejects a mistyped
block name.

@dwwest dwwest 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.

I need to chat about the blocks again I think, I'm not sure I totally understand that.

Comment thread docs/anvil_reference.rst Outdated
Comment thread openadmet/models/anvil/specification.py
Comment thread openadmet/models/anvil/specification.py
Comment thread openadmet/models/features/combine.py
Comment thread openadmet/models/transforms/pca.py Outdated

@dwwest dwwest 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.

Looks good, I think mostly comments to make things clearer. Example use-cases would really help

Comment thread docs/anvil_reference.rst
Comment thread openadmet/models/anvil/workflow.py Outdated
Comment thread openadmet/models/anvil/workflow_base.py Outdated
Comment thread openadmet/models/transforms/pca.py
Comment thread openadmet/models/transforms/pca.py Outdated
ProcedureSpec and WorkflowBase both accept either one transform or an
ordered list, and each carried its own byte-identical validator body to
normalize the single form. Move that into anvil/utils.ensure_list so the
rule lives in one place.

The helper goes in a new module rather than either existing file because
workflow_base imports specification while specification defers an import
back from workflow; a neutral module keeps the extraction out of that cycle.

DataSpec.check_target_cols_input is deliberately left alone. It wraps only
str and passes every other value through for pydantic to reject, which is
not the same rule.
Six comment blocks added by this branch ran to three lines each, restating
what the code below them already showed. Cut each to the part that is not
readable from the code, and drop the trailing period on the width guard.
Four comments added by this branch sat as the first line inside an if or
else body while describing the decision that selected the branch. Move each
above its gate, and drop the one in the deprecated-dict path that only
restated the warnings.warn call below it.

The 1D note in predict stays inside the branch, since it explains the
atleast_2d call rather than the decision to transform at all.
The class docstring described the per-block mechanism without ever saying
what it buys. A single PCA over a concatenated matrix spends its components
on whichever block carries the most raw variance, so a 2048-bit fingerprint
crowds a couple of hundred descriptors out of the retained dimensions;
per-block fitting gives each featurizer its own budget.

_check_2d likewise only restated its own condition. Record what it catches:
featurizers emit 1D for single-row input, which block slicing would meet
with a bare IndexError.
The docstring said the sort had moved to an after-validator without saying
why anything is sorted at all, which is the question a reader arrives with.
Record that the emitted column order is part of the featurizer's contract,
that deriving it from the featurizer set keeps two recipes naming the same
featurizers interchangeable, and that per-block transforms depend on it
because they apply their pipelines in emit order.
The docstring stated the rule abstractly. Add the case a reader hits: a
PCATransform naming only FingerprintFeaturizer against a concatenator that
emits a descriptor block too, why that is rejected, and the two ways to
satisfy it, including a null count for passthrough.
The section stated the rule and showed a recipe without ever saying what
comes out the other side. Give the concrete widths for the recipe above it,
223 descriptor columns and 2000 fingerprint columns reducing to 288, and
name the emitted column order.

That ordering was undocumented anywhere and is genuinely surprising: blocks
emit in featurizer class-name order, so the descriptor block precedes the
fingerprint block even though the recipe writes the fingerprint first.

Also drops the doubled featurized/featurization in the section opener.
Second pass over the same threads. The prose had drifted into narrating
rationale where a label was wanted, so cut the comments to what they name
and drop the history from the sort docstring in favour of current state.

Restores a deprecation label on the dict path, this time above the gate
rather than inside it, and states the same-class featurizer limit in the
per-block PCA docs, since keying blocks by class name is what causes it.
Both docstrings buried the operational modes in a running paragraph. Break
them into labeled entries: global, per-block, and passthrough for the
transform itself, and global versus per-block plus the one-to-one key
contract for the workflow check.
The file was named for a concept rather than a module, and there is no
transforms/transform_sequence.py. Everything it exercises, to_transform_list,
fit_transforms, and transform_features, lives in transform_base, so the name
now matches the one-test-file-per-module layout the suite follows elsewhere.
transforms/impute.py had no test file, so its only coverage was incidental,
as a supporting component inside the sequence tests. Cover the strategies
against hand-computed column statistics, that fitted statistics are reused
on a later batch rather than recomputed, the unfitted guard, seeded
reproducibility for the iterative imputer, and construction-time rejection
of an unknown strategy or imputer.

Includes a direct regression test for fit returning self. The chained calls
in the sequence tests already depended on it, but nothing named it.
anvil/utils.py arrived without a test module. Pin the three branches plus
the cases the wrapper exists to get right: a mapping counts as one entry
rather than an iterable of keys, and falsy values are wrapped rather than
treated as absent.
Comment thread openadmet/models/transforms/pca.py Outdated
make_intake_cat.ipynb arrived with the first intake ingestion work and was
never runnable: it passes a string where Catalog wants a dict of user
parameters, so two cells carry committed AttributeError tracebacks and the
cell that would write example_intake.yaml sits below them. Nothing imports
or executes it, and the tracebacks embed a contributor's local conda path.

The catalog it was meant to generate is committed separately and stays.
datafiles.py exposes example_intake.yaml as intake_cat and eight recipe
YAMLs resolve it through ANVIL_DIR.

Removing it also takes the file out of ruff's notebook formatting, which is
what pulled it into this branch in the first place.
The entry ran six lines and was the longest random_seed description in the
repo, while being the only one that omitted the deprecated-alias sentence
every other one carries. TransformSpec subclasses AnvilSection, so
PCATransform does accept random_state in recipe params like any other
section.

Adopt the wording used by split_base, tabpfn, cross_validation, and impute.
The default and the meaning of None are already visible in the signature,
and the sklearn randomized-solver detail describes PCA rather than this field.
@smcolby
smcolby merged commit 0603021 into main Sep 9, 2026
5 checks passed
@smcolby
smcolby deleted the feat/pca-featurizer branch September 9, 2026 16:53
smcolby added a commit that referenced this pull request Sep 10, 2026
conftest.py conflicted only by adjacency: main appended
chemeleon_foundation_checkpoint where this branch appended
fingerprint_model_dir. Both fixtures are kept.

The rest was a silent API break rather than a textual conflict. #592 gave
load_anvil_model_and_metadata a fitted-transform return, so the featurizer's
four-way unpack raised, and a pretrained model trained in transform space
would have been predicted on with raw features. TrainedModelFeaturizer now
caches and applies that transform, reproducing the pretrained model's
inference path in full, guarded by transform_model_dir and a test that fails
when the transform is skipped.

#592 also deprecated the whole-field dict form for FeatureConcatenator and
dropped its class-name sort, so the concatenator test moves to the list form
and its comment now describes configured order.
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.

3 participants