Rank continuation — choosing which ranks to grow#
If you are fitting a Tucker tensor train and don’t know its ranks ahead of time, read this. Rank continuation grows the ranks gradually during optimization — solve at small ranks, grow, re-solve warm-started — and stops when more rank stops helping. The question this doc answers is which ranks to grow: instead of growing every rank together (“uniform”), grow the rank on each edge by an amount the data tells you it can use, read off the singular values of the current iterate’s unfoldings.
Math reference: Section 5.4 (“Rank continuation”) of Alger, Christierson, Chen & Ghattas (2026),
“Tucker Tensor Train Taylor Series” (arXiv:2603.21141) — §5.4.1 chooses the new ranks, §5.4.2 is the
warm start. (As elsewhere, the local t4s.pdf numbering is newer than the public arXiv.)
Worked, runnable example: examples/fit_varied_rank_tensor_newton_cg.py.
Why not just grow every rank together?#
A Tucker tensor train has many ranks — one Tucker rank nᵢ per mode and one TT bond rᵢ between cores.
Different edges often carry very different amounts of information. Uniform continuation (grow them
all to the same level r) then has to choose between two bad options: stop early and under-fit the
edges that need a high rank, or push r up and over-fit the edges that only needed a low one — wasting
degrees of freedom (hence data) and inviting overfitting. The example makes this concrete: a target with
true ranks (8,2,5,3) / bonds (1,8,8,3,1) is represented essentially exactly with ~370 degrees of
freedom, but the smallest uniform ranks that contain it (8 everywhere) need ~1216 — 3.3× more.
The idea (Section 5.4.1)#
After each fixed-rank fit, look at the singular values of the current iterate’s matrix unfoldings (from
the implicit T3-SVD). For each edge form its condition number κᵢ = σ₁ / σ_lastᵢ — the ratio of the
largest to the smallest retained singular value:
small
κᵢ→ that edge is well conditioned: its rank is being used efficiently and there is room for more;large
κᵢ→ that edge’s rank is already “used up” (the last singular value it kept is tiny); adding more there would just fit noise.
So grow only the well-conditioned edges — those whose κᵢ is at least a factor tau below the worst
edge’s. This brings all edges toward comparable conditioning, spending new ranks where they help. The
proposed ranks are then made non-degenerate (“useless-rank removal”, which only uses shape and ranks, no
linear algebra), with a uniform bump as a fallback when every edge is already comparably conditioned (and
to get off the ground from rank 1).
How to use it#
The library provides the rank-proposal step; you drive the continuation loop (which optimizer, how to
split validation data, when to stop). The proposal is one method on TuckerTensorTrain:
>>> import numpy as np
>>> import t3toolbox.tucker_tensor_train as t3
>>> np.random.seed(0)
>>> i, j, k = np.meshgrid(np.arange(8), np.arange(7), np.arange(6), indexing='ij')
>>> T = 1.0 / (1.0 + 0.05*i + 1.0*j + 8.0*k) # smooth, very different decay rate per mode
>>> X, _, _ = t3.TuckerTensorTrain.t3svd_dense(T, max_tucker_ranks=3, max_tt_ranks=3)
>>> print(X.tucker_ranks, X.tt_ranks)
(3, 3, 3) (1, 3, 3, 1)
>>> new_tucker_ranks, new_tt_ranks = X.continuation_ranks() # the ranks to grow to next
>>> print(new_tucker_ranks, new_tt_ranks) # only mode 1 is well conditioned enough to grow
(3, 4, 3) (1, 3, 3, 1)
and you warm-start the next fit by zero-padding the converged cores up to those ranks (§5.4.2) with
resize (this does not change the represented tensor):
>>> X0 = X.resize(X.shape, new_tucker_ranks, new_tt_ranks) # warm start at the grown ranks
>>> print(X0.tucker_ranks, X0.tt_ranks)
(3, 4, 3) (1, 3, 3, 1)
>>> print(bool(np.allclose(X0.to_dense(), X.to_dense()))) # zero padding: the same tensor
True
A complete continuation loop — solve, propose, warm-start, repeat — looks like this (see the example for the full version with a real Newton-CG fit):
import t3toolbox.tucker_tensor_train as t3
import t3toolbox.manifold as t3m
X = t3.TuckerTensorTrain.zeros(shape, (1,) * d, (1,) * (d + 1)) # start from rank 1
records = []
while True:
X = fixed_rank_fit(X, train_data) # your optimizer (e.g. Riemannian Newton-CG)
records.append((X, validation_error(X))) # held-out error, for model selection
new_tucker, new_tt = X.continuation_ranks() # Section 5.4.1: which ranks to grow
if (new_tucker, new_tt) == (X.tucker_ranks, X.tt_ranks):
break # nothing grew -> stop (maximal, or too ill-conditioned)
if n_train / t3m.manifold_dim((shape, new_tucker, new_tt)) < tau_data:
break # next model would be underdetermined -> stop
X = X.resize(shape, new_tucker, new_tt) # zero-padded warm start
X_best = min(records, key=lambda r: r[1])[0] # select the level with smallest validation error
Two standard outer-loop choices live in your loop, not the library: terminate when the next model
would become underdetermined (training data over manifold dimension falls below a threshold, tau_data
above), and select the rank level that minimized a held-out validation error.
Warm-start gotcha for the inner
newton_cgfit. Because eachfixed_rank_fitafter the first is warm-started (zero-padded from the converged lower-rank iterate), its initial gradient norm is already small — andnewton_cg’s stopping tests are relative to it, so the Newton stop over-tightens and CG slackens. Pin the reference to the problem’s true gradient scale viag0norm_newton/g0norm_cg(e.g. reuse the initial‖g‖from the first continuation stage), and optionally raisecg_forcing_powerabove0.5to spend more CG per Newton step when the retraction is expensive. Seefitting_and_optimization.md§5.
continuation_ranks returns the current ranks unchanged when continuation should stop — either the
structure is already maximal, or every edge is too ill conditioned to grow safely (see kappa_guard
below). Test for that as the loop does above.
Parameters#
All are optional with sensible defaults; the only one you are likely to touch is tau.
parameter |
what it does |
|---|---|
|
Sensitivity of the “well-conditioned” test. A knob you may need to experiment with: smaller grows more edges per round, larger grows fewer. The default |
|
How much to grow each chosen edge per round (default |
|
Cap on how many edges grow per round. |
|
An absolute conditioning safety net (default |
|
Passed to the internal T3-SVD. By default the current ranks are read from the iterate’s structural ranks; set these to continue from the numerical rank at a tolerance instead. |
On the uniform layer#
Rank continuation is a ragged-layer activity, deliberately. There is no uniform resize or
continuation_ranks, and that is not an oversight: continuation changes the ranks, while a uniform
object’s whole point is a fixed padded shape with the masks held loop-invariant. A uniform T3 is also
inherently a stack, and continuation_ranks already refuses a stacked ragged point for the same reason
— different stack elements would continue to different ranks.
The shipped pattern is therefore a round trip: keep the cheap bookkeeping on the ragged point, and drop
into the uniform layer only for the fit, one continuation level at a time. (The zero-padded warm start
is numerically rank-deficient, and the uniform frame handles that safely: the sweep’s pad-safe SVDs
keep every tangent direction — see uniform_equivalence_contract.md, “Gauge-carrying operations”.)
x = x0 # ragged
for level in range(max_levels):
ux = UniformTuckerTensorTrain.from_t3(x) # pack for the fit
ux, stats = newton_cg(UNIFORM_MANIFOLD, kind, sample, data, ux, g0norm_newton=g0)
x = ux.to_t3() # back to ragged for the bookkeeping
new_tucker, new_tt = x.continuation_ranks()
if (new_tucker, new_tt) == (x.tucker_ranks, x.tt_ranks):
break # nothing left to grow
x = x.resize(x.shape, new_tucker, new_tt)
examples/fit_hilbert_uniform_newton_cg.py runs this end to end.
Do not feed
ut3svdsingular values tocompute_continuation_ranks. It infers the current ranks fromlen(s[i]), and uniform spectra are padded to the nominal rank — so every length is the padded width, the padding zeros drivekappa_ito infinity, and the call type-checks and returns silently wrong ranks. In a measured case it proposed((2,2,2),(1,2,2,1))where the truth was((3,3,3),(1,3,3,1))— a spurious “stop continuing”. Convert with.to_t3()first, as above.
Working on raw .data tuples (no frontend)#
continuation_ranks is a thin wrapper: it computes the iterate’s T3-SVD and forwards the singular values
to the backend. If you bypass the OO frontend, call the backend directly with singular values you already
have (e.g. from t3svd):
>>> import t3toolbox.backend.ranks as ranks
>>> _, tucker_singular_values, tt_singular_values = X.t3svd() # or singular values you already have
>>> new_tucker, new_tt = ranks.compute_continuation_ranks(X.shape, tucker_singular_values, tt_singular_values)
>>> print((new_tucker, new_tt) == X.continuation_ranks()) # the frontend is exactly this call
True
>>> kappa_tucker, kappa_tt = ranks.edge_condition_numbers(tucker_singular_values, tt_singular_values)
>>> print(len(kappa_tucker), len(kappa_tt)) # d Tucker edges, d+1 TT bonds
3 4
>>> print(float(kappa_tt[0]), float(kappa_tt[-1])) # the length-1 boundary bonds are 1.0
1.0 1.0
ranks.edge_condition_numbers(tucker_singular_values, tt_singular_values) returns the per-edge condition
numbers themselves, if you want to inspect or report which edges are well conditioned.