API
Solvers
nlls_gram.RidgeLevenbergMarquardt
Bases: LevenbergMarquardtBase
Levenberg-Marquardt for the ridge objective
F(x) = ||r(x, args, p)||^2 + ridge * q(x) with
q(x) = ||x_m||_W^2 over a JAX pytree x, built for underdetermined
interpolation problems where the minimum-seminorm root
argmin q s.t. r = 0 is the target.
The flattened parameter vector splits as x = [x_m; x_f]: the METRIC
BLOCK x_m (the leading metric.size coordinates, penalized by the
positive-definite :class:~nlls_gram.Metric W) and the FREE BLOCK
x_f (the remaining n_f = len(x) - metric.size >= 0 coordinates,
unpenalized -- the full-space penalty is PSD by design). The metric is
supplied through factor callbacks for W = F'F
(:class:~nlls_gram.IdentityMetric is plain ridge,
:class:~nlls_gram.RepeatedFactorMetric the kernel workhorse), and the
solver runs entirely in the whitened variable y = F_bar x with
F_bar = blockdiag(F, I_{n_f}) -- an exact, constant linear change of
variables, never a matrix: only the factor ops are ever applied.
The selection lives in the objective: under free-block identification at
the solution, each fixed-ridge problem has an isolated minimizer
x_ridge with x_ridge -> x* = argmin q s.t. r = 0 at O(ridge)
error as ridge -> 0 (nonlinear Tikhonov theory:
Engl-Kunisch-Neubauer 1989; Engl-Hanke-Neubauer 1996 Ch. 10; the seminorm
formulation is Elden 1982). Plain Gauss-Newton on the underdetermined
system converges to some root without selecting the minimal-norm one
(Campbell-Kunkel-Bobinyec 2012; Pes-Rodriguez 2022), which is why the
package's alternative is metric-damped
:class:~nlls_gram.LevenbergMarquardt; this solver instead makes every
inner problem a well-posed NLLS. Annealing ridge per stationarity
event (:class:AnnealRidge) is the iteratively regularized
Gauss-Newton method (Bakushinskii 1992; Kaltenbacher-Neubauer-Scherzer
2008). For kernel metrics each inner step is a kernel ridge regression
of the relinearized equations (Chen-Hosseini-Owhadi-Stuart 2021).
Everywhere in the implementation the equivalent augmented-residual view
is used, posed in y: standard EUCLIDEAN LM on
R(y) = [r(x); sqrt(ridge) y_m] with J~ = J F_bar^{-1},
A = [J~; sqrt(ridge) [I 0]], and step
(J~'J~ + ridge E + mu I) delta_y = -(J~'r + ridge [y_m; 0]) where
E = blockdiag(I_{n_m}, 0); the x-space step is
delta_x = F_bar^{-1} delta_y and the iterate is stored in x. The
penalty rows are affine (constant, even) in y, so geodesic
acceleration (Transtrum-Sethna 2012), accept/reject, and the implicit-AD
rule reduce to the standard formulas with second-derivative contributions
from the penalty identically zero; the trial penalty uses the linearity
F_bar(x + delta_x) = y + delta_y, so no second factor application is
ever needed. damping (mu) is a plain trust-region parameter in
the whitened geometry (mu ||delta_y||^2 = mu ||delta_x_m||_W^2 plus
the Euclidean free block), fully decoupled from ridge (lambda):
mu moves every step by accept/reject, lambda only when a callback
replaces lm_state.ridge. The whitened normal matrix
J~'J~ + ridge E carries a clean spectral floor at ridge on the
metric block regardless of W's conditioning -- the reason the default
Cholesky() path stays accurate at deep ridge.
ridge is STRICTLY POSITIVE by contract -- the constructor validates
it, callbacks must keep it positive, and continuation floors are positive
-- so J~'J~ + ridge E is positive definite under the identification
condition at every reachable state. ridge=None resolves at init
to sqrt(finfo(dtype).eps) of the residual dtype (float64 ~ 1.5e-8,
float32 ~ 3.5e-4). The metric's factorization never involves the ridge
weight, so continuation composes unchanged.
linear_solver picks the algebra for
(J~'J~ + ridge E + damping I) delta_y = -g through a typed config
(:class:~nlls_gram.Cholesky /
:class:~nlls_gram.QR / :class:~nlls_gram.CG -- each method's
knobs live on its own config); every path shares its factorization or
inner-solve setup between the velocity and geodesic-acceleration solves:
Cholesky()(the default): dense normal equations.J'is materialized perjacobian_mode,J~'follows by one batchedfactor_solve_transpose, and the assembledG = J~'J~ + ridge E(ridgeadded on the metric-block diagonal only) is cached across rejected steps (only the damping shift re-factors).QR(): MINPACK-structured damping-row QR (More 1978), stable at smallridge/dampingwhere formingGsquares the condition number. One QR of the augmented stack[J~, sqrt(ridge) [I 0] | b~]withb~ = [r; sqrt(ridge) y_m]is cached per(x, ridge)(the extra column carries theQ-transformed residual); each update re-factors[R; sqrt(damping) I]with itsQretained and solves the velocity as a backward-stable least-squares problem atcond(A), nevercond(A)^2. The geodesic-acceleration RHS reuses the damped factor through corrected semi-normal equations with one fixed iterative-refinement pass (Bjorck 1987; Bjorck 1996 Sec. 6.6.5) -- the second-order correction tolerates the squared conditioning.CG(preconditioner, ...): matrix-free preconditioned CG on the damped normal operatorJ~'J~ + ridge E + damping Iitself -- the same SPD system theCholesky()path factors, with products throughjvp/vjpand the metric's factor callbacks instead of an assembledG. The config requires a typed :class:~nlls_gram.Preconditioner(:class:~nlls_gram.IdentityPreconditioneropts out): itsapply(v, damping, ctx)-- an SPD approximation of the damped inverse, handed the same :class:~nlls_gram.SolverContextas the metric ops -- sits in CG'sMslot with the live damping. The operator carries theridgespectral floor on the metric block, so the preconditioner only has to captureJ~'J~'s structure.
Stopping (solve): a ridge solve has two phases -- the residual drops
to its floor fast, then the iterate slides along the interpolation set
resolving the null-space (selection) component while ||r|| stays
essentially constant. A pure-residual test is blind to phase 2, so
gtol bounds the whitened ridge stationarity info.grad_norm =
||J~'r + ridge [y_m; 0]|| (the dual W^{-1}-norm of the half-gradient)
and xtol the accepted whitened step norm ||delta_y|| (the W-norm
of the step) -- they mean "done with the current fixed-ridge problem" --
while atol is a CONJUNCTIVE filter on the TRUE residual: convergence
requires (gtol or xtol fired) AND (atol == 0 or
sqrt(resid_loss) <= atol). atol alone never stops the solve -- a
pure-residual test would stop at step 0 from any interpolating start
before the seminorm is minimized -- so solve rejects atol > 0
without a positive gtol or xtol. Calibrating gtol is clean in
the whitened geometry: info.penalty_grad_norm = sqrt(q(x)), so
gtol ~ 1e-3 * ridge * sqrt(q(x*)) resolves the selection to ~1e-3
relative accuracy, with sqrt(q(x*)) (the solution's seminorm) usually
known to an order of magnitude before any pilot run. In a continuation
run the callback keeps lowering ridge, so intermediate stationarity
with a large residual correctly does not stop the loop.
solve(...).x has a custom implicit AD rule with respect to p:
Gauss-Newton implicit differentiation of the ridge stationarity, posed on
the whitened variable -- (J~'J~ + ridge E) y_dot = -J~'(dr/dp) p_dot
then x_dot = F_bar^{-1} y_dot -- with ridge frozen
(stop-gradient) at the returned state's value; the continuation
schedule's and multi-start selection's dependence on p is
deliberately ignored, and no damping enters the AD matrix. Exact
differentiation carries two extra terms (sum_i r_i * d2r_i/dx2 in the
matrix and (dJ'/dp) r on the right); both are exactly zero for an
affine residual and first order in ||r|| otherwise, so the rule is
exact when the converged TRUE residual vanishes. The caveat to state
plainly: the first-order-in-||r|| absolute error translates to a
small RELATIVE tangent error only under conditioning assumptions
(J'J bounded below). On a genuinely underdetermined CURVED system the
null-space block of the exact tangent carries the constraint-curvature
term sum_i nu_i d2r_i with multiplier nu = r/ridge -- which does
not vanish relative to the ridge-scaled penalty as ridge -> 0 --
so tangent components read off null-space directions retain an
O(1)-in-ridge Gauss-Newton bias proportional to the residual
curvature. ad_solver=Cholesky() assembles and factors;
:class:~nlls_gram.CG runs matrix-free CG on the same operator with its
preconditioner hook (PD does not mean well-conditioned --
unpreconditioned CG degrades as ridge shrinks). ad_solver=None
(the default) matches the forward path: Cholesky() for the dense
forwards, and a CG forward keeps its family AND its preconditioner
-- the typed apply is damping-analytic, so the forward hook serves the
undamped system at zero damping exactly (hooks marked
requires_positive_damping fall back to unpreconditioned), while the
AD tolerance and iteration budget stay at the AD defaults; pass
ad_solver=CG(...) to pin those. Failed statuses
return exact zero tangents for result.x/result.aux and evaluate
the masked tangent program at stop-gradient copies of the caller's
original inputs and the INITIAL ridge (never a possibly-invalid
callback-produced value).
The init/update/solve protocol, callback contract
(:class:~nlls_gram.LMContext ->
:class:~nlls_gram.LMAction), multi_start, save_steps, and
:class:~nlls_gram.LMSolveResult are shared with
:class:~nlls_gram.LevenbergMarquardt; code written against that solver
ports by changing the constructor (its damping metric -> this metric)
and reading info.resid_loss where it means equation error, since
info.loss here includes the penalty. Multi-start ranking uses the
ridge objective at each lane's own final ridge -- comparable across lanes
when they share a continuation schedule.
Source code in src/nlls_gram/ridge_lm.py
231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 | |
init(x0, args=None, *, p=None)
Build the initial :class:LMState at x0.
One residual evaluation types damping and resolves
ridge=None to the dtype default, and sizes the Jacobian/normal/QR
cache buffers for the configured dense path. hyper stays None
so manual update loops carry no extra buffers; solve
populates it for its callbacks.
Source code in src/nlls_gram/ridge_lm.py
update(x, lm_state, args=None, p=None)
One LM step on the ridge objective: returns (x_new, state, info).
Source code in src/nlls_gram/ridge_lm.py
639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 | |
nlls_gram.LevenbergMarquardt
Bases: LevenbergMarquardtBase
Levenberg-Marquardt least squares over a JAX pytree x.
residual_fn takes (x), (x, args), or (x, args, p) and
returns a residual pytree (or (residual, aux) with has_aux=True).
args is solver-inert auxiliary data; p is what solve(...).x
carries an implicit derivative with respect to.
metric (default None -- Euclidean) is a
:class:~nlls_gram.Metric covering the leading metric.size
coordinates of the flattened iterate, with the rest a free block. It
defines the damping geometry: the subproblem is
min ||r + J s||^2 + damping ||s||_W^2, so the damping -> 0 limit
is the minimum-W-norm correction.
linear_solver is a typed config -- :class:~nlls_gram.Cholesky (the
default; form="auto" factors the smaller of the m x m dual and the
n x n normal system), :class:~nlls_gram.QR (damping-row QR, stable
at tiny damping and rank-safe), :class:~nlls_gram.CG (matrix-free in
parameter space), or :class:~nlls_gram.GramCG (matrix-free in residual
space, the m << n form). ad_solver takes None (match the
forward family), Cholesky(), :class:~nlls_gram.SVD (the
pseudoinverse rule, for a rank-deficient undamped tangent), CG(...),
or GramCG(...).
init/update/solve, the callback protocol, save_steps,
multi_start, and implicit AD are shared with the ridge solver;
info.loss here is the plain sum of squared residuals. Stopping is
disjunctive: atol on the residual norm, gtol on the whitened
stationarity info.grad_norm, xtol on an accepted step's whitened
norm; any one firing reports CONVERGED.
Source code in src/nlls_gram/metric_lm.py
71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 | |
init(x0, args=None, *, p=None)
Build the initial :class:~nlls_gram.LMState at x0.
One residual evaluation types damping and sizes the Jacobian and
linear-solver cache buffers. hyper stays None so manual
update loops carry no extra buffers; solve populates it.
Source code in src/nlls_gram/metric_lm.py
update(x, lm_state, args=None, p=None)
One LM step: returns (x_new, lm_state, info).
Source code in src/nlls_gram/metric_lm.py
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 | |
nlls_gram.AnnealRidge
dataclass
Ridge-continuation solve callback: multiply lm_state.ridge by
decrease whenever the current level has yielded what it can, never
below ridge_floor.
A level has yielded what it can when it is
- stationary:
info.grad_normfell belowgrad_rtolrelative to its reference value at the current ridge level (the level's first observed gradient), or - stalled (opt-in,
stall_rtol > 0): an ACCEPTED step improved the gradient by less than a factorstall_rtol. The escape hatch for a frozen anneal: the per-level references COMPOUND -- with closely spaced levels each new reference is already small, the effective demand approachesgrad_rtol ** levelstimes the initial gradient, and the anneal can freeze below the problem's noise floor while steps keep being accepted with negligible progress. Enable withstall_rtol ~ 0.99when that happens. Off by default because it cannot distinguish a converged level from an accepted micro-step under temporarily high damping early in a hard solve -- where a false advance collapses the schedule prematurely; wideningdecrease(e.g.0.01) is the alternative fix for a frozen anneal, since larger jumps keep the per-level references generous.
ridge_floor is REQUIRED and strictly positive (ridge = 0 is out
of the solver's contract). Usage::
anneal = AnnealRidge(ridge_floor=1e-10)
result = solver.solve(x0, callback=anneal,
user_state=anneal.init_state(),
gtol=1e-8, atol=1e-8)
The solved-out continuation path converges to the minimum-seminorm
solution by nonlinear Tikhonov theory (Engl-Kunisch-Neubauer 1989;
Engl-Hanke-Neubauer 1996), while annealing per accepted stationarity
event rather than per fully solved level is the iteratively regularized
Gauss-Newton method (Bakushinskii 1992; Blaschke-Neubauer-Scherzer 1997;
Kaltenbacher-Neubauer-Scherzer 2008), whose theory wants exactly this
kind of monotone, boundedly geometric schedule. Pair the schedule with
the conjunctive stopping rule: choose atol BETWEEN the ridge-floor
residual and the last intermediate level's residual (they differ by
roughly 1 / decrease), so the solve can only stop at the floor even
when gtol must sit above a variant-dependent stationarity noise
floor -- intermediate levels are stationary too, and atol is what
rules them out.
HOW IT WORKS, for composing the schedule into a callback of your own
(data re-draws, damping resets, a preconditioner refresh): the per-level
reference and previous gradient ride in user_state as two
fixed-shape scalars (init_state builds them; a lax.while_loop
carry cannot grow from None mid-loop), with +inf marking "no
observation at this level yet" -- the first step after a decrease sets
the reference and can never read as stalled. Both trackers reset to
+inf exactly when the level advances; at the floor the ridge stops
changing, so the solver stops suppressing convergence and gtol/
atol can fire. All comparisons run at the ridge dtype and the
returned trackers are cast back to the user_state dtype. A wrapping
callback calls the instance, inspects action.lm_state.ridge <
ctx.lm_state.ridge for a level advance, and returns a
dataclasses.replace of the action -- e.g. gating an expensive
preconditioner rebuild on the advance with jax.lax.cond so the
eigendecomposition is paid only when it fires (a jnp.where merge
would pay it every step)::
def driver_callback(ctx):
action = anneal(ctx)
advanced = action.lm_state.ridge < ctx.lm_state.ridge
precond = jax.lax.cond(
advanced,
lambda: BlockEigenPreconditioner(families(ctx.x), PERM),
lambda: ctx.lm_state.preconditioner,
)
return dataclasses.replace(
action,
lm_state=dataclasses.replace(
action.lm_state, preconditioner=precond
),
)
A frozen dataclass with scalar fields rather than a closure because
solve marks the callback a jit STATIC argument: equal schedules
compare equal and share one compiled loop, while a fresh closure per
construction would key a fresh compilation. ridge_floor must be a
concrete float for that sharing -- a traced value is unhashable and
falls back to identity.
Source code in src/nlls_gram/ridge_lm.py
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | |
init_state(dtype=None)
The initial user_state: both trackers at +inf. dtype
defaults to the JAX default float; pass the problem dtype explicitly
for a float32 program under enabled x64.
Source code in src/nlls_gram/ridge_lm.py
Linear solvers
nlls_gram.Cholesky
dataclass
Bases: LinearSolver
Dense factorization of the damped subproblem.
form="normal" factors the n x n whitened normal system
G = J~'J~ + ridge E (cached across rejected steps, where only the
damping changed, so a reject pays the n^3/3 refactor without the GEMM
and without re-materializing J~') and solves
(G + damping I) u = -g.
form="gram" factors the m x m dual D = J~ J~' instead and
takes the step u = -J~'(D + damping I)^{-1} r. For damping > 0 the
two produce the same step in exact arithmetic, by the push-through
identity B'(BB' + lam I)^{-1} = (B'B + lam I)^{-1}B'.
In FLOATING POINT they part company when m > n: the dual then carries
m - n structural zero eigenvalues, so its condition number is
sigma_max^2 / damping however well conditioned J~ is, and the gram
step loses digits as damping falls (measured ~1e-2 relative at
damping=1e-14 where the normal form holds 1e-15). form="auto" (the
default) never picks gram there -- it takes gram only when n > m, where
the normal system is the singular one -- so the default is safe and the
choice is a cost one. Forcing form="gram" on a tall problem is not.
Source code in src/nlls_gram/linear_solvers.py
238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 | |
nlls_gram.QR
dataclass
Bases: LinearSolver
Damping-row QR of the augmented whitened stack.
One QR of [J~; sqrt(ridge) [I 0] | b~] (the penalty rows and the
b~ tail only for the ridge solver) is cached per (x, ridge): its
leading columns are the stack's R factor and its last column carries
Q'b, so the velocity is a backward-stable least-squares solve with NO
normal equations -- More 1978's damping-row structure, accurate at
cond(A) rather than cond(A)^2. Each step re-factors only the
damping rows, and those rows keep the system full rank for any
damping > 0, so a rank-deficient Jacobian is handled rather than
producing a non-finite step. No knobs.
Forward only: the implicit-AD system is undamped, so the damping rows that
make this path well posed vanish there. Use SVD() for a rank-deficient
tangent.
Source code in src/nlls_gram/linear_solvers.py
342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 | |
nlls_gram.CG
dataclass
Bases: _KrylovConfig
Matrix-free preconditioned CG on the whitened NORMAL operator, in parameter space.
As linear_solver it solves the damped forward subproblem
(J~'J~ + ridge E + damping I) delta_y = -g -- the same SPD system
:class:Cholesky factors -- with the preconditioner in CG's M slot
at the live damping. As ad_solver it solves the undamped implicit-AD
system, with the preconditioner applied at zero damping (subclasses marked
requires_positive_damping are rejected for that role) and penalty
optionally adding a small ridge that stabilizes a rank-deficient tangent.
preconditioner is REQUIRED in the forward role -- nobody should run
Krylov methods without a preconditioning decision, so
:class:~nlls_gram.IdentityPreconditioner is the explicit opt-out and a
custom one is a small registered dataclass implementing
apply(v, damping, ctx). In the ad_solver role,
preconditioner=None inherits the CARRIED forward instance at the
solution while pinning the AD tolerance and budget. On rank-deficient
problems the preconditioner must map range(B') into itself or the
minimum-norm selection is silently lost; the identity, polynomials in the
operator, and exact shifted inverses are safe, and on full-column-rank
problems the condition is vacuous.
tol=None resolves to a dtype default (1e-10 in float64, 1e-6
in float32); maxiter must be set when both tolerances are explicitly
zero, since an uncapped zero-tolerance CG loop has no stopping rule.
Source code in src/nlls_gram/linear_solvers.py
465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 | |
nlls_gram.GramCG
dataclass
Bases: _KrylovConfig
Matrix-free preconditioned CG on the DUAL operator, in residual space.
Applies y -> J~ J~' y + damping y on m-vectors and takes the step
u = -J~'y, so the Krylov iteration lives in residual dimension -- the
matrix-free form for the m << n regime this package targets, where
:class:CG's n-dimensional iteration is the expensive one. At inner
convergence the step matches :class:CG's, and a budget-truncated step
still lies in range(J~'), so the minimum-metric-norm structure
survives truncation.
preconditioner acts on residual-space vectors -- an SPD approximation
of (J~J~' + damping I)^{-1} -- which is the only difference from
:class:CG's contract, and the reason the two are separate configs rather
than one with a flag. The ridge penalty rows have no dual analogue, so
this config serves :class:~nlls_gram.LevenbergMarquardt alone.
Source code in src/nlls_gram/linear_solvers.py
nlls_gram.LU
dataclass
Bases: LinearSolver
Direct nonsymmetric solve of a SQUARE system, ad_solver role only.
When the whitened Jacobian is square the implicit-AD system B u = -dr/dp
has a unique solution, so there is no norm being minimized and the metric
does not select among answers. That makes the plain factorization of B
available, and it is strictly better than routing through a normal or dual
operator: cond(B'B) = cond(B)^2, so Cholesky loses half the significant
digits of the tangent for nothing. One factorization also serves both
directions -- forward mode solves with B, reverse mode with B'.
This is why ad_solver=None resolves here for a square dense problem.
It requires squareness and says so: a rectangular system needs a selection
rule (minimum-metric-norm) that a plain solve cannot express, so
:class:SVD or the Cholesky forms own that case.
The forward subproblem is damped and therefore symmetric positive definite at every shape, so this config has no forward role.
Source code in src/nlls_gram/linear_solvers.py
nlls_gram.SVD
dataclass
Bases: LinearSolver
Spectral-filter pseudoinverse, for the ad_solver role only.
The implicit-AD system is UNDAMPED, so it is singular whenever the
whitened Jacobian is rank deficient -- padded zero residuals make it so by
construction. This rule truncates at max(m, n) * eps * sigma_max and
returns the minimum-metric-norm tangent, which is the right answer there
rather than a NaN or a silent pseudo-solve. It assembles, so it is the
dense fallback rather than the default.
Source code in src/nlls_gram/linear_solvers.py
Metrics
nlls_gram.Metric
Positive-definite metric W, via factor callbacks.
x = [x_m; x_f] splits into the metric block x_m -- the leading
size coordinates, covered by W -- and a free block x_f. The
metric is supplied through callbacks for an invertible factor F with
W = F'F, the whitened variable being F x_m and
||v||_W = ||F v||_2. F should be upper triangular; the canonical
example is F = jnp.linalg.cholesky(K, upper=True). The solver extends
it to F_bar = blockdiag(F, sqrt(free_scale) I) over the whole vector
and never materializes either.
Subclasses implement the ops on metric-block vectors (or matrices whose
LEADING axis is size; columns are batched):
factor_apply(v, ctx):F vfactor_solve(v, ctx):F^{-1} vfactor_solve_transpose(v, ctx):F^{-T} vnorm(v, ctx):||v||_W, defaulted to||factor_apply(v)||_2. Provided for callers; the solvers measure in the whitened variable and never call it.
Every op receives a :class:~nlls_gram.SolverContext carrying the
solver's live state, so an exotic metric can key off the iterate through
ctx.x and ctx.lm_state.
Metric instances are JAX PYTREES: array fields are traced leaves, the
type plus its static fields are structure. Every concrete class must be
registered with
:func:~nlls_gram.register_pytree_dataclass -- the solvers reject
unregistered instances. The instance rides inside the solver state
(lm_state.metric), so a solve callback replaces the metric by
constructing a new instance of the same type (same static fields, same
leaf shapes and dtypes) -- pure traced ops, no recompilation. Equal-config
instances with fresh arrays share one compiled solve loop.
dataclasses.replace works too: metric constructors only validate and
derive shapes, so re-running them under trace is cheap.
free_scale weights the free block in the whitened variable: 1.0
(the default) leaves it Euclidean. The ridge solver never penalizes the
free block whatever the scale -- free_scale only changes its
trust-region geometry -- while for the metric solver it IS that block's
damping weight. It is a traced leaf, canonicalized by each constructor to
the factor's float dtype, so changing it never recompiles.
Contracts: the factor must be EXACT. The solver hardcodes the identity
penalty block in the whitened variable, so an approximate factor silently
changes the objective (unlike a CG preconditioner, which may be sloppy).
The ridge weight never enters the factorization, so ridge continuation
composes unchanged. How a subclass fulfills the ops -- prefactorized
storage, factorize-in-__init__, fully matrix-free -- is its
constructor's business, and the constructor must be traceable when the
metric is rebuilt inside a jitted callback.
Source code in src/nlls_gram/metrics.py
factor_apply(v, ctx)
factor_solve(v, ctx)
factor_solve_transpose(v, ctx)
nlls_gram.IdentityMetric
dataclass
Bases: Metric
The identity metric W = I on size coordinates -- plain ridge for
the ridge solver, Euclidean damping for the metric solver.
F = I: every factor op is the identity and norm is the Euclidean
norm, with no special-casing anywhere downstream.
Source code in src/nlls_gram/metrics.py
nlls_gram.CholeskyMetric
dataclass
Bases: Metric
Dense metric W = L L' from its lower-triangular Cholesky factor.
L is the factor as jnp.linalg.cholesky returns it, so the upper
factor this class works with is F = L'. Triangularity and positive
definiteness are assumed, not validated -- the entries may be traced, and
a singular factor propagates NaN loudly through the triangular solves.
Source code in src/nlls_gram/metrics.py
nlls_gram.DiagonalMetric
dataclass
Bases: Metric
The diagonal metric W = diag(weights); F = diag(sqrt(weights)).
weights must be a positive 1-D array. Positivity is not validated
because the values may be traced. Every op is elementwise.
Source code in src/nlls_gram/metrics.py
nlls_gram.RepeatedFactorMetric
dataclass
Bases: Metric
repeats copies of one block factor: W = blockdiag(F'F, ...).
F is an upper-triangular invertible block factor -- e.g.
jnp.linalg.cholesky(K, upper=True) for a positive-definite Gram matrix
K, giving the repeated kernel seminorm sum_j alpha_j' K alpha_j
over repeats coefficient blocks. The constructor takes the FACTOR, not
K (callers typically already hold it); a positive-SEMIdefinite K
needs a shift first: jnp.linalg.cholesky(K + epsilon * I, upper=True).
Triangularity and positive definiteness are assumed, not validated.
All repeated blocks (and all batched columns) share a single triangular
product or solve: the ops reshape the metric block into the columns of one
(block, repeats * cols) matrix, so no repeated factor or full block
diagonal is ever formed. size = repeats * F.shape[0].
Source code in src/nlls_gram/metrics.py
Preconditioners
nlls_gram.Preconditioner
SPD preconditioner for the solvers' CG paths.
apply(v, damping, ctx) returns an SPD approximation of the damped
operator's inverse -- (J~'J~ + ridge E + damping I)^{-1} in parameter
space under :class:~nlls_gram.CG, (J~J~' + damping I)^{-1} in
residual space under :class:~nlls_gram.GramCG. In the forward role it
sits in CG's M slot with the live damping; in the ad_solver role
the implicit system is undamped and damping is zero. ctx is the
same :class:~nlls_gram.SolverContext the metric factor ops receive.
Preconditioner instances are JAX PYTREES: array fields are traced
leaves, the type plus its static fields are structure. Every concrete
class must be registered with
:func:~nlls_gram.register_pytree_dataclass -- the solvers reject
unregistered instances. The instance rides inside the solver state
(lm_state.preconditioner), so a solve callback refreshes it by
calling the CONSTRUCTOR again with fresh arrays -- same type, same leaf
shapes and dtypes, pure traced ops, no recompilation. Never
dataclasses.replace a preconditioner: replace re-runs
__init__, re-paying any eigendecomposition or sketch, and
construction-time-only inputs are not stored to re-supply. A stale
instance only changes the CG iteration path, never the converged step,
so refreshing rarely (or never) is always safe.
Implement a custom one as a small registered frozen dataclass::
@dataclass(frozen=True, eq=False)
class JacobiPreconditioner(Preconditioner):
diagonal: jax.Array
def apply(self, v, damping, ctx):
return v / (self.diagonal + damping)
register_pytree_dataclass(JacobiPreconditioner, data_fields=("diagonal",))
Subclasses whose apply divides by the live damping must set
requires_positive_damping = True; the constructor rejects them for
the AD role, where damping is zero.
Source code in src/nlls_gram/preconditioners.py
nlls_gram.IdentityPreconditioner
dataclass
Bases: Preconditioner
The identity map as an explicit "no preconditioner" choice for CG.
Nobody should run Krylov methods without thinking about preconditioning,
so opting out is an explicit, greppable decision rather than a silent
default. Stateless: every instance compares equal, so equal CG
configs share one compiled solve loop.
Source code in src/nlls_gram/preconditioners.py
nlls_gram.BlockEigenPreconditioner
dataclass
Bases: Preconditioner
Block-diagonal eigenbasis preconditioner over grouped whitened coordinates.
The workhorse for structured whitened normal operators
J~'J~ + ridge E + damping I built from repeated interacting blocks
(multiple "agents" coupled through shared equations): approximate the
operator by a block-diagonal matrix over a chosen grouping of the
whitened coordinates, eigendecompose each block, and apply the exact
inverse of the shifted approximation
v -> V ((V' v) / (Lambda + ridge_weight * ridge + damping)) V'
per block -- analytic in both the live damping (traced; it changes per
LM step) and the live ridge (read from ctx.lm_state.ridge, so
ridge continuation composes with no refresh).
families is a sequence of (blocks, ridge_weight) pairs:
blocks has shape (groups, size, size) -- the stacked diagonal
blocks of J~'J~ restricted to that family's coordinate groups, in
permuted order. Families in the metric block set ridge_weight = 1
(their diagonal carries the ridge spectral floor); free-block
families set 0 (damping-only, so the zero-damping AD role applies
their plain inverse -- positive definite whenever the free block is
identified). Blocks are symmetrized and eigendecomposed HERE, once;
positive semidefiniteness is assumed, not validated (entries may be
traced). permutation is the 1-D integer array reordering the
flattened whitened parameter vector into family-major order
(v_permuted = v[permutation]); families are consumed in sequence and
must cover it exactly. jnp.arange(n) serves when the natural layout
already is family-major.
The constructor is fully traceable, so a solve callback refreshes
the preconditioner from the live iterate by constructing a new instance
-- gate the rebuild with jax.lax.cond (e.g. on a ridge-continuation
level advance) so the eigendecomposition is paid only when it fires; a
jnp.where merge would pay it every step.
Source code in src/nlls_gram/preconditioners.py
87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | |
nlls_gram.NystromPreconditioner
dataclass
Bases: Preconditioner
Randomized Nystrom preconditioner (Frangella-Tropp-Udell) for a PSD
operator given only through matvec.
Sketches A with a rank-rank Nystrom approximation
A_hat = U diag(lam) U' -- a thin-QR'd Gaussian test matrix, one block
application Y = A Omega, and the shifted Cholesky/SVD recovery of
arXiv:2110.02820 Algorithm 2.1 -- then applies their eq. 5.3::
v -> U ((U'v) / (lam + damping)) + (v - U U'v) / (rho + damping)
where rho is the smallest retained eigenvalue: directions the sketch
resolved are inverted against the live shift, and the unresolved
complement is treated as sitting at rho rather than at zero. That
balance is what carries the FTU condition-number guarantee for
fast-decaying spectra.
The target use is neural-network least squares under the identity metric,
where the dual operator is the m x m empirical NTK Gram J J' --
fast spectral decay plus the LM damping shift is exactly the FTU regime.
matvec must apply a symmetric PSD operator to (n, k) matrices; an
indefinite one silently produces NaN through the Cholesky square root. It
is consumed at construction -- the build costs rank operator
applications plus an O(n rank^2) QR/SVD, and only the sketch is
stored, so for a nonlinear problem the instance approximates the dual at
the linearization point it was built from (staleness is safe; a callback
refreshes by constructing a new instance from a fresh matvec). Each
apply is two (n, rank) matvecs.
key is an explicit PRNG key; the same key reproduces the same
preconditioner. dtype=None uses the JAX default float -- pass the
operator dtype explicitly for a float32 problem under enabled x64.
Source code in src/nlls_gram/preconditioners.py
nlls_gram.ShermanMorrisonPreconditioner
dataclass
Bases: Preconditioner
Dual preconditioner for B = A + weight * u u' from a solve with A.
Applies B^{-1} v = y - A^{-1}u (u' y) / (1/weight + u' A^{-1} u) with
y = A^{-1} v by the Sherman-Morrison identity; A^{-1}u and the
scalar denominator are precomputed at construction. This is the natural
shape for kernel-collocation dual operators, where a metric weight m
on a scalar parameter injects an exactly known rank-1 spike
(c^2/m) u u' into J M^{-1} J'. The live damping is ignored --
spectral closeness to the damped operator is all a preconditioner needs --
which also makes it valid in the zero-damping ad_solver role.
solve applies A^{-1} and is called on every apply, so it is a
STATIC field: a fixed hashable callable whose identity enters the
instance's pytree structure, with anything it closes over entering the
compiled program as constants. This class is a setup-scope object for a
fixed dual operator, not a callback-refresh target.
Source code in src/nlls_gram/preconditioners.py
nlls_gram.WoodburyPreconditioner
dataclass
Bases: Preconditioner
Dual preconditioner for B = A + U diag(weights) U'.
The rank-k generalization of :class:ShermanMorrisonPreconditioner:
applies B^{-1} v = y - A^{-1}U C^{-1}(U' y) with y = A^{-1} v and
capacitance C = diag(1/weights) + U' A^{-1} U; A^{-1}U (one matrix
solve) and the Cholesky factor of the k x k capacitance are precomputed.
weights must be positive -- not validated, since inputs may be traced.
Like Sherman-Morrison it ignores damping and so serves the AD role
too, and its solve is the same STATIC always-called field.
Source code in src/nlls_gram/preconditioners.py
nlls_gram.PaddedPreconditioner
dataclass
Bases: Preconditioner
Extend a dual preconditioner to a residual padded with exact zeros.
The fixed-residual-shape pattern appends identically-zero entries to an
n_real-entry residual so compiled shapes stay stable across problem
instances. Padded rows have zero Jacobian rows, so the dual operator is
exactly block diagonal::
[ J P J' + damping I 0 ]
[ 0 damping I ]
and this applies base on the first n_real coordinates and the exact
1/damping inverse on the padded block. That second block must NOT be
zeroed -- that would make the preconditioner singular rather than SPD, even
though zeros can appear to work when the padded coordinates are never
excited. Because the padded block divides by the live damping, this serves
only the damped forward solve; the undamped dual is singular there, which
ad_solver=SVD() handles exactly.
Source code in src/nlls_gram/preconditioners.py
State and results
nlls_gram.LMState
dataclass
Carried solver state threaded through init/update/solve.
Only damping is always live; every other field is populated by the
configuration that needs it and stays None otherwise. A solve
callback that rebuilds the state must PRESERVE the fields it does not mean
to change -- use dataclasses.replace(ctx.lm_state, ...).
The callback-owned fields are damping, ridge, metric,
preconditioner, and the hyper group (replaceable as a unit);
everything else is solver-owned bookkeeping.
Attributes:
| Name | Type | Description |
|---|---|---|
damping |
Array
|
|
ridge |
Array | None
|
|
resid |
Array | None
|
cached residual at the current |
Jt |
Array | None
|
cached transpose-Jacobian |
jacobian_valid |
Array | None
|
|
aux |
Any
|
residual aux pytree at the current |
hyper |
LMHyperparams | None
|
per-step :class: |
solver_cache |
Any
|
the linear solver's own reject-step cache, whose pytree
structure is fixed by the static |
metric |
Any
|
the carried :class: |
preconditioner |
Any
|
the carried :class: |
Source code in src/nlls_gram/lm_types.py
nlls_gram.LMInfo
dataclass
Per-step diagnostics returned by update and by each solve step.
The loss/damping fields report the accept/reject outcome of the step, while
grad_norm/step_norm/aux are evaluated at the PRE-step x --
the iterate the step was computed from.
loss is always the objective the running solver minimizes: the sum of
squared residuals for LevenbergMarquardt, and the RIDGE OBJECTIVE
||r||^2 + ridge * ||x_m||_W^2 (penalty included) for
RidgeLevenbergMarquardt -- ridge code that means equation error must
read resid_loss.
BOTH solvers run in the whitened variable y = F_bar x, so
grad_norm, step_norm, and penalty_grad_norm are Euclidean in
y: steps measured in the W-norm, gradients in the dual W^{-1}-norm.
With the default Euclidean metric they are the plain quantities. Objective
values are unaffected -- whitening is a linear bijection of the same
objective.
Attributes:
| Name | Type | Description |
|---|---|---|
loss |
Array
|
objective at the retained iterate, |
loss_old |
Array
|
objective at the pre-step |
loss_candidate |
Array
|
objective at the trial point. |
accepted |
Array
|
|
damping |
Array
|
|
damping_factor |
Array
|
|
used_geodesic |
Array
|
|
acceleration_ratio |
Array
|
|
grad_norm |
Array
|
|
step_norm |
Array
|
|
ridge |
Array | None
|
|
resid_loss |
Array | None
|
|
penalty_value |
Array | None
|
|
penalty_grad_norm |
Array | None
|
|
aux |
Any
|
residual aux output at the pre-step |
Source code in src/nlls_gram/lm_types.py
nlls_gram.LMStatus
Bases: IntEnum
Integer status codes returned by solve.
Members are real ints (IntEnum): they work as dict keys, compare
against status arrays, and LMStatus(int(result.status)).name recovers
the label for logging. Callbacks may return bare members (or any weak
integer value) as LMAction.status -- the solver canonicalizes to
int32 at the boundary, so no explicit dtype casts are needed.
Source code in src/nlls_gram/lm_types.py
nlls_gram.LMHyperparams
dataclass
Per-step LM hyperparameters, carried in LMState.hyper.
All fields are traced, so a solve callback can reset them -- e.g. grow
the inner CG budget as the loss falls -- via
dataclasses.replace(ctx.lm_state, hyper=dataclasses.replace(
ctx.lm_state.hyper, iterative_maxiter=...)). A field constructed as
None (backend-default iterative_maxiter) is compiled out and stays
None. Static configuration -- the linear solver, the metric,
geodesic_acceleration, cache_jacobian, has_aux -- shapes the
compiled program and lives on the solver.
Source code in src/nlls_gram/lm_types.py
nlls_gram.LMContext
dataclass
Information passed to a solve callback after each LM update.
Source code in src/nlls_gram/lm_types.py
nlls_gram.LMAction
dataclass
Optional callback action for solve.
A field left as None is unchanged. status is used only when
stop is true. stop and status are canonicalized by the solver
(to bool and int32), so callbacks may return Python bools, bare
LMStatus members, or weak-typed arrays without explicit casts.
Source code in src/nlls_gram/lm_types.py
nlls_gram.LMSolveResult
dataclass
Final result returned by solve.
Source code in src/nlls_gram/lm_types.py
nlls_gram.SolverContext
dataclass
What the solver knows at a metric, preconditioner, or linear-solver
call site -- the inner algebra's context, as opposed to
:class:LMContext, which a per-step user callback receives.
Fields are None where the call site has nothing to offer:
x: the current FLATTENED iterate (the whole parameter vector, not just the metric block).lm_state: the live :class:LMState(damping, ridge, caches, and the carried metric/preconditioner instances). In the implicit-AD rule this is the returned state understop_gradient-- inert conditioning data, like the ridge.args/p: the residual's auxiliary data and differentiation parameters as passed tosolve/update.
Source code in src/nlls_gram/lm_types.py
nlls_gram.register_pytree_dataclass(cls, *, data_fields, meta_fields=())
Register a frozen dataclass as a pytree whose unflatten bypasses
__init__/__post_init__.
data_fields become traced leaves (subtrees, if a field holds a
container); meta_fields become static structure and must hold
hashable values. Unflatten rebuilds the instance with object.__new__
plus object.__setattr__, so constructors are free to compute derived
leaves and validate eagerly -- reconstruction inside jit, vmap, or a
loop carry restores the stored fields verbatim without re-running any of
it. Static values are type-tagged, so treedefs compare and hash by value
with jit's strict-type semantics (1, 1.0, and True stay
distinct). Every dataclass field must appear in exactly one of the two
lists. Returns cls.
Every concrete :class:~nlls_gram.Metric and
:class:~nlls_gram.Preconditioner class must be registered this way
(subclassing a registered base does not register the subclass); the
solvers reject unregistered instances at construction.
Source code in src/nlls_gram/utilities.py
Multi-start
nlls_gram.MultiStart
dataclass
Multi-start configuration for solve(multi_start=...).
draw(key, x, args) -> (x_new, args_new) generates a fresh initial
condition; it must be traceable and type-stable (returning the same pytree
structure, shapes, and dtypes as its (x, args) inputs). accept(key,
result) -> bool optionally overrides the success test (default:
CONVERGED plus MAX_STEPS when the solve's
max_steps_is_success=True); it receives its own key so it can draw fresh
validation data, and may return any scalar boolean-like value.
Sequential mode (parallel=False) solves from (x0, args) and retries
on failure, chaining each attempt's initial values into the next
draw; parallel mode solves all num_starts lanes under vmap
(lane 0 = the caller's (x0, args), the rest drawn from the originals)
and selects the accepted lane with the lowest loss. The key schedule is
draw_key, accept_key = jax.random.split(jax.random.fold_in(key, k))
for attempt k.
draw and accept enter the jit cache by identity (like
callback): define them once at setup scope, not inline per call.
MultiStart is not a pytree -- solve unpacks it before tracing, with
key the only traced field.
Source code in src/nlls_gram/multi_start.py
nlls_gram.MultiStartInfo
dataclass
Diagnostics attached to LMSolveResult.multi_start by a multi-start solve.
attempt is the winning attempt/lane index (0 = the caller's
(x0, args)), accepted whether the winner passed the success test
(MultiStart.accept, or the solve's max_steps_is_success policy), and
attempts_run how many starts were solved (sequential mode stops at the
first success; parallel mode always runs num_starts). loss is the
ranking objective used for selection -- the sum of squared residuals at the
returned solution for LevenbergMarquardt, the ridge objective for
RidgeLevenbergMarquardt -- masked to +inf when nonfinite. Note
accepted describes the multi-start success test, not LMInfo.accepted
(last-step acceptance).
Source code in src/nlls_gram/multi_start.py
nlls_gram.DrawNNXModule
Multi-start draw hook re-initializing a flax nnx.Module from a fresh key.
Given a MultiStart retry key, builds
module_cls(*args, rngs=nnx.Rngs(key), **kwargs) and returns its nnx.Param
state as the new solver start, passing args through unchanged. Use it instead
of hand-rolling a re-init closure per driver::
draw = DrawNNXModule(SequentialMLP, settings, dtype=dtype)
ms = MultiStart(key=key, num_starts=5, draw=draw)
The drawn parameter state must be type-stable against the solver's x0 (same
pytree structure, shapes, and dtypes) -- construct the module with a matching
param_dtype/dtype (e.g. pass dtype= through). The paired
nnx.GraphDef used by the residual's nnx.merge must come from the same
module_cls(*args, **kwargs) spec.
Value-hashable on (module_cls, args, kwargs) with jit's strict-type semantics
(1, 1.0, and True key distinct compilations): equal specs compare equal
and share one jit compilation instead of recompiling per instance (a fresh closure
would not). args/kwargs must be hashable for that sharing, and their values
must not be mutated after construction (a stale key would reuse the wrong compile);
unhashable specs still work but recompile per instance. Requires flax installed
(imported lazily on first draw).