Skip to content

Correct the held-out slice scale, and hold out half of each mode - #552

Merged
aarmey merged 2 commits into
mainfrom
fix/bicv-holdout-scaling
Sep 12, 2026
Merged

Correct the held-out slice scale, and hold out half of each mode#552
aarmey merged 2 commits into
mainfrom
fix/bicv-holdout-scaling

Conversation

@fishidaho

Copy link
Copy Markdown
Contributor

PARAFAC2 models slice k as X_k ≈ P_k (B diag(a_k)) Cᵗ, where P_k has orthonormal columns (P_kᵗ P_k = I). Because P_k is orthonormal irrespective of how many cells the slice holds, the reconstruction's norm

‖P_k (B diag(a_k)) Cᵗ‖_F  =  ‖(B diag(a_k)) Cᵗ‖_F

is independent of n_k. All slice energy lives in a_k.

a_k is fitted against n_train_k cells, then applied unchanged to the n_test_k held-out cells. Since real slice energy grows like sqrt(n_k), the held-out block's magnitude is overstated by sqrt(n_train_k / n_test_k)2× at the default held_out_cell_frac=0.2.

Noiseless, exactly-rank-4 data, fit at the true rank 4:

metric value
Train Block R2X (in-sample) 0.9996
BiCV R2X (held-out) −0.034
‖prediction‖ / ‖actual‖ 1.99
R² after one global rescale 0.970

The direction is right; only the magnitude is wrong. And the factor tracks the cell-count ratio exactly — noiseless rank-4 data, varying only held_out_cell_frac:

held_out_frac n_train/cond n_test/cond sqrt(ratio) observed ratio R2X as-is
0.10 180 20 3.0000 3.0551 −3.3154
0.20 160 40 2.0000 2.0060 −0.0702
0.35 130 70 1.3628 1.3890 0.8247
0.50 100 100 1.0000 1.0095 0.9900
0.75 50 150 0.5774 0.5781 0.8139

The score is correct only at held_out_cell_frac = 0.5, where the ratio is 1.

The fix

Simple 6 line fix to multiply each condition's held-out loadings by sqrt(n_test_k / n_train_k). Keeps the orthonormal convention and corrects the known factor. Assumes per-cell energy is homogeneous within a condition which should be true under BiCV's random split.

It is also important to note that both held-out fractions now default to one half which follows the [Owen and Perry] (https://arxiv.org/abs/0908.2062) paper this whole method is inspired by. They reported even in their abstract that "in simulated examples we find that a method leaving out half the rows and half the columns performs well". That is also the one split at which the old score happened to be unbiased, so it is the setting under which older numbers remain comparable, and it recovers the true rank in every simulated scenario tested -- which the correction alone did not at 0.2.

Measured after the change: noiseless rank-4 data at its own rank scores 0.97-0.99 at every fraction (was -0.034); the spread across fractions 0.1-0.7 falls from 3.75 to 0.02; argmax lands on the true rank for (4, noise 0.3), (6, noise 0.3) and (4, noise 1.0); pure noise still scores negative. Row-order invariance from the alignment fix underneath is undisturbed, agreeing to 8.9e-16 between contiguous and fully interleaved conditions.

@fishidaho
fishidaho added this pull request to stack #553 September 12, 2026 00:58
@aarmey
aarmey force-pushed the fix/bicv-holdout-scaling branch from de71f7a to 3a68802 Compare September 12, 2026 01:17
Base automatically changed from fix/bicv-alignment-and-diagnostics to main September 12, 2026 01:19
fishidaho and others added 2 commits September 11, 2026 18:20
PARAFAC2 writes slice k as `P_k (B diag(a_k)) C^T` with `P_k`
orthonormal, so the reconstruction's norm is `||(B diag(a_k)) C^T||`
regardless of how many cells the slice holds: all of a slice's energy
sits in `a_k`. BiCV fit `a_k` against the training cells and then reused
it unchanged for the held-out cells, and since a slice's energy grows
like sqrt(n_k) that overstated the predicted block by
sqrt(n_train_k / n_test_k) -- a factor of two at the old default split.

The symptom was a metric that got worse as the model got better. On
noiseless, exactly-rank-4 data fit at the true rank, the training block
scored 0.9996 while the held-out block scored -0.034: the fit had
recovered the structure exactly and the score still called it worse than
predicting the mean. On the IBD cohort the curve was negative at every
rank and fell monotonically as rank rose, putting its argmax at rank 1
and firing the "rank at the edge of tested ranks" warning. The error was
purely one of magnitude -- a single global rescale of the prediction
recovered R^2 = 0.970.

`_holdout_scale` corrects each condition by sqrt(n_test_k / n_train_k),
which needs only the cell counts and is exact when the two halves of a
condition have comparable per-cell energy -- true here, since the split
is random within each condition. Three candidates were measured
(this one, re-fitting `a_k` per held-out slice from the train genes, and
unconstrained least-squares cell loadings); all three fixed the bug and
none scored above zero on pure noise, so the narrowest was taken.

Both held-out fractions now default to one half, following Owen and
Perry (https://arxiv.org/abs/0908.2062), who report that "in simulated
examples we find that a method leaving out half the rows and half the
columns performs well". That is also the one split at which the old
score happened to be unbiased, so it is the setting under which older
numbers remain comparable, and it recovers the true rank in every
simulated scenario tested -- which the correction alone did not at 0.2.

Measured after the change: noiseless rank-4 data at its own rank scores
0.97-0.99 at every fraction (was -0.034); the spread across fractions
0.1-0.7 falls from 3.75 to 0.02; argmax lands on the true rank for
(4, noise 0.3), (6, noise 0.3) and (4, noise 1.0); pure noise still
scores negative. Row-order invariance from the alignment fix underneath
is undisturbed, agreeing to 8.9e-16 between contiguous and fully
interleaved conditions.

The dense reference in the tests derives the same factor independently
from the counts, so it still pins the streaming algebra rather than the
old behaviour. The tutorial's "peaks" claim is softened to describe the
plateau the curve actually forms, since past the plateau neighbouring
ranks differ only in the fourth decimal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VJRB1RMUAb1id11XZ6KWS
@aarmey
aarmey force-pushed the fix/bicv-holdout-scaling branch from 3a68802 to 38ddb41 Compare September 12, 2026 01:20

@aarmey aarmey left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very nice catch!

@aarmey
aarmey merged commit 83ecb3b into main Sep 12, 2026
6 checks passed
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.

2 participants