A small Python package for feature-grounded variance decomposition: given the latent codes from any encoder (a VAE, an autoencoder, CLIP, ...) and a bank of interpretable features (radiomic descriptors, hand-crafted measurements, metadata), it answers a simple question for every latent direction —
What fraction of this direction's variance do the interpretable features explain, and how does that explained variance split across feature groups and individual features?
Whatever the features cannot account for is reported as an explicit irreducible-noise budget, so each direction is summarized by a set of named shares plus a noise remainder that together sum to one. The result is a latent space whose axes carry interpretable labels instead of opaque indices.
pip install fvaimport fva
decomp = fva.FeatureGroundedDecomposition(df_latent, df_feat, feat_to_group)
scree_fig = decomp.scree()
global_table, rho, global_fig = decomp.global_budget()
shap_table, rho, shap_fig = decomp.shap()
johnson_table, rho, johnson_fig = decomp.johnson()See notebooks/tutorial.ipynb for a walkthrough of the whole workflow on a small synthetic dataset and the API details.
A budget answers which families explain a direction, which is worth asking only
once something explains it. fva.direction_r2 reports just the totals — the
table = fva.direction_r2(df_latent, df_feat) # no feat_to_group needed variance_fraction r2 r2_cv noise
direction
PC0 0.401 0.978 0.975 0.022
PC1 0.256 0.964 0.960 0.036
...
PC9 0.008 0.028 -0.009 0.972
weighted_sum 1.000 0.907 0.902 0.093
The weighted_sum row reproduces rho and rho_cv from a full decomposition of
the same inputs exactly, because both go through the same eigendecomposition and
the same Gram matrix. What it skips is the attribution — the
Read the r2_cv column rather than r2. A bank with no signal scores about zero
out of sample whatever its width, so negative entries are the ordinary appearance
of an unexplained direction rather than an error; the in-sample r2 for the same
direction sits at the
The same vectors come off a fitted decomposition as decomp.r2 and
decomp.r2_cv, so screening first costs nothing the full fit would not have
spent anyway.
The decomposition is a linear fit, so a categorical column and a wrapped angle
mean nothing in their raw form — grade = 1, 2, 3 is fitted as if the levels sat
equally spaced on a line, and 359° and 1° land at opposite ends of the range
despite being two degrees apart. encode_features re-represents both and returns
the grouping that keeps each expansion pooled into one budget entry:
df_feat, feat_to_group = fva.encode_features(df_raw)
decomp = fva.FeatureGroundedDecomposition(df_latent, df_feat, feat_to_group)String columns and angle/rotation columns within ±360 are claimed
automatically. The two things it will not guess have to be named:
df_feat, feat_to_group = fva.encode_features(
df_raw,
categorical=["grade"], # integer codes: nominal or ordinal is ambiguous
circular={"lesion_angle": 180}, # axial: θ and θ+180° are the same orientation
groups={"grade": "clinical", "lesion_angle": "shape"},
)One-hot expansion drops a reference level, since a full dummy set is collinear with the intercept once centred. The group budget is invariant to which level is dropped; only the per-feature Johnson row reads as a contrast against the reference.
There is deliberately no automatic basis expansion. The encodings above are
faithful re-representations that add no explanatory power a correctly-read column
did not already have, whereas a basis expansion widens the bank and raises R² —
and the noise remainder this package reports is only meaningful relative to a
fixed hypothesis class. Expansion is available, but as an explicit choice you
make and report: see below.
A feature often acts on the latent space smoothly but nonlinearly — a sprite's
position produces a localized pixel pattern the encoder folds nonlinearly into
the code — and a linear fit on the raw column then reports as noise a dependence
the named feature describes perfectly well. fva.basis widens the hypothesis
class to cover that case, and keeps the widening visible:
df_feat, feat_to_group, spec = fva.expand_features(
df_raw, mode="auto", spline_df=6, harmonics=3,
circular={"orientation": 2 * np.pi},
)
print(spec) # per column: which basis, how wide, how wide it asked to beNote the three-value unpack. encode_features returns two, because it made no
choice; expand_features returns the spec as well, because a rho from an
expanded bank cannot be compared with anything without it.
mode="auto" picks the basis per column, and which basis is right is decided by
the feature rather than by taste:
- a circular feature is genuinely periodic, so it gets the Fourier basis
sin/cos(2πkv/period)fork = 1..harmonics.harmonics=1is the pairencode_featuresalready emits. - a non-periodic continuous feature gets a cubic B-spline with
spline_dfcolumns, knots at quantiles. A Fourier basis is the wrong choice here even though hand-rolled feature banks usually reach for it: rescaling the column to a unit period forces the fitted function to take the same value at both ends of the range, and the raw column then has to be carried alongside purely to break that wrap-around. The spline has no wrap, has local support, is better conditioned, and atspline_df=1degenerates exactly to the raw column.
mode="spline" and mode="circular" force one basis onto everything, warning on
the columns they mis-handle, so the choice of basis can be ablated rather than
asserted. Every expansion stays pooled in its source column's group, so one
concept is one budget entry and the 2^g Shapley game does not grow.
A per-column basis, however wide, stays separable: it writes the latent
coordinate as a sum of a function of each feature and expresses no joint
dependence between two. Some joint dependences are structural — which pixels a
sprite occupies is set jointly by its two position coordinates, and no sum of a
function of x and a function of y localizes a two-dimensional bump — and a
separable bank reports them as noise. Name the pairs the features imply:
df_feat, feat_to_group, spec = fva.expand_features(
df_raw, spline_df=4, harmonics=2,
interactions=[("posX", "posY")],
)Each pair adds the tensor product of the two features' bases as one further
group, "posX x posY". It is a second widening on top of the per-column one, so
read the gain against the ladder rather than quoting it alone, and do not fish
over all pairs. Because each basis sums to a constant across its own columns, the
tensor product spans both main effects as well as the interaction; the two groups
are collinear by construction, which the Shapley budget handles, but the shared
mass is real.
Rather than fixing the capacity at one hidden setting, sweep it:
table, fig = fva.capacity_ladder(df_latent, df_raw, levels=(1, 2, 4, 6, 8))Level 1 is the unexpanded bank, so the first row is the baseline and every later
row is what widening bought. The curve separates the two ways rho can rise,
which look identical in a single fit: one that climbs and then flattens says the
feature really does act nonlinearly and the plateau is a property of the feature;
one that keeps climbing in step with the tabulated null_r2 says the bank is
just getting wide. Both rho and rho_cv are reported at every rung.
Two guards fire before anything is fitted. A "continuous" column with u
distinct values spans at most u - 1 dimensions once centered, so a wider basis
is rank deficient by construction and the requested width is clipped with a
warning naming the column. And the expanded width goes through the usual
sample-size check, so a bank that outgrew its sample says so.
The ladder asks whether a wider hypothesis class buys anything. The ceiling asks
the prior question: whether anything could. Samples carrying the same pattern
of labels within a family are indistinguishable to it and must receive the same
prediction, so grouping them by label pattern and predicting each group by its
own mean is the best any function of those labels can do. Its
ceil, alone, attainment, coverage = decomp.ceilings()alone is what each family explains fitted on its own, ceil is the most it
could, and attainment is the ratio. That ratio is what a small share needs in
order to be read: a family at 30% of its ceiling is under-fitted and the ladder
is the next move, while a family at 95% of a ceiling of 0.2 has said everything
it can and the rest of the direction is out of reach of that vocabulary rather
than of the model. coverage carries the label geometry behind each bound —
how many samples carry a label at all, how many distinct cells they form, the
smallest cell, and the ceiling that signal-free labels of the same shape would
reach anyway.
A ceiling below 1 has two causes and the report separates them. Labels defined on only part of the sample leave the unlabeled remainder to a single prediction, so its internal variance is unreachable by construction; labels defined everywhere but too coarse leave within-cell variance instead. The first is a property of the vocabulary, the second of its resolution.
The bound only exists because samples sharing a pattern share a prediction, so
continuous families are skipped with a warning naming them (bin a column first
for a bound that is exact over the binning). And the saturated fit is still a
fit: ceilings(cv=True)
scores both the ceilings and the family fits out of fold, and warnings fire on
the cell count either way.
Note that the ceiling bounds a family fitted alone, which is why alone is
reported rather than the Shapley share from shap(). The two differ when
families suppress one another, and a Shapley value can legitimately exceed what
its family reaches by itself.
For each latent direction the method produces an additive variance budget:
- a group-level (SHAP) budget — collinearity-aware Shapley shares of the explained variance per feature family (shape, texture, intensity, ...), plus the noise remainder;
- a per-feature (Johnson) budget — the same explained variance refined down to individual features;
- a single global budget that collapses all directions into one stacked bar, weighted by how much variance each direction carries.
A cross-validated
The shares always partition the total exactly — that is an algebraic property of
the Shapley game, not evidence that the total means anything. With too few
samples for the width of the feature bank the in-sample
- fewer than ~10 samples per feature, or outright
$q \ge n$ , where the in-sample budget is vacuous; - a large gap between the in-sample
rhoand the cross-validatedrho_cv; - a rank-deficient feature bank, which makes the per-feature Johnson split arbitrary among collinear columns (the group budget is unaffected);
- more feature groups than the exponential
$2^g$ Shapley game can afford, or more CV folds than the sample supports; - more label cells than the sample can support in
ceilings(), where the bound stops being tight and, at$k \ge n$ , stops being a bound.
These are standard UserWarnings, so warnings.simplefilter("error") promotes
them to failures in a pipeline.
The same quantity that drives the first of those warnings is also drawn on the
budget figures. A bank of null_line=True / False on shap(), johnson(), and
global_budget() forces it on or off — force it on when panels will be compared
side by side, so that a missing line means "no floor" rather than "a small one".
The floor is an in-sample bias, so shap(cv=True) declines to draw it.
shap(scree=True) adds the other half of the picture: the scree curve drawn
over the stacked bars as a black line, on the bars' own
Pass n_bootstrap=200 for percentile intervals
on each direction's decomp.r2_ci, which is how you tell a direction
the features genuinely explain from one whose share is not separable from zero.
Because the Shapley game costs tqdm progress bar; pass progress=False (or call
fva.set_progress(False)) to silence them.
This package accompanies the paper Naming the Directions of a Latent Space: Feature-Grounded Variance Decomposition with an Irreducible-Noise Budget by Joseph Rich, Raphi Kang, Pietro Perona, and Lior Pachter (DOI: forthcoming).

