|
| 1 | +"""Second-order derivatives: the compiler must FIND the algebra. |
| 2 | +
|
| 3 | +These tests pin kernel-count ceilings, not just values: hessian(x^T A x) |
| 4 | +must compile to the closed form A + A^T (no x-compute at all), and |
| 5 | +hessian(logsumexp) to softmax + rank-1 correction. torch.func/jax compute |
| 6 | +these in O(n^3); the point of symbolic differentiation + consolidation is |
| 7 | +that the closed form falls out. |
| 8 | +""" |
| 9 | + |
| 10 | +import re |
| 11 | + |
| 12 | +import sympy |
| 13 | +import torch |
| 14 | + |
| 15 | +import tensorgrad.functions as F |
| 16 | +from tensorgrad import Variable |
| 17 | +from tensorgrad.compiler import compile_to_callable |
| 18 | + |
| 19 | +torch.set_num_threads(2) |
| 20 | +n = sympy.Symbol("n") |
| 21 | +N = 13 |
| 22 | + |
| 23 | + |
| 24 | +def _source_and_out(H, feed, dims): |
| 25 | + prog = compile_to_callable(H) |
| 26 | + out = prog(feed, dims) |
| 27 | + src = next(iter(prog._specializations.values()))._source |
| 28 | + stmts = [ln for ln in src.splitlines() |
| 29 | + if not ln.lstrip().startswith(("def ", "return", "del ", "#")) and ln.strip()] |
| 30 | + return out, stmts |
| 31 | + |
| 32 | + |
| 33 | +def test_quadratic_hessian_is_closed_form(): |
| 34 | + x = Variable("x", n) |
| 35 | + A = Variable("A", i=n, j=n) |
| 36 | + f = (x.rename(n="i") @ A) @ x.rename(n="j") |
| 37 | + H = f.grad(x, {"n": "di"}).grad(x, {"n": "dj"}) |
| 38 | + xv, Av = torch.randn(N), torch.randn(N, N) |
| 39 | + out, stmts = _source_and_out(H, {x: xv.rename("n"), A: Av.rename("i", "j")}, {n: N}) |
| 40 | + with torch.enable_grad(): |
| 41 | + ref = torch.func.hessian(lambda xx: (xx @ Av @ xx))(xv) |
| 42 | + torch.testing.assert_close(out.align_to("di", "dj").rename(None), ref) |
| 43 | + # the closed form A + A^T: one statement, and x is never touched |
| 44 | + assert len(stmts) <= 2, stmts |
| 45 | + assert not any("x" == s.strip() for s in stmts) |
| 46 | + |
| 47 | + |
| 48 | +def test_logsumexp_hessian_is_softmax_rank1(): |
| 49 | + x = Variable("x", n) |
| 50 | + f = F.log(F.sum(F.exp(x))) |
| 51 | + H = f.grad(x, {"n": "di"}).grad(x, {"n": "dj"}) |
| 52 | + xv = torch.randn(N) |
| 53 | + out, stmts = _source_and_out(H, {x: xv.rename("n")}, {n: N}) |
| 54 | + with torch.enable_grad(): |
| 55 | + ref = torch.func.hessian(lambda xx: torch.logsumexp(xx, 0))(xv) |
| 56 | + torch.testing.assert_close(out.align_to("di", "dj").rename(None), ref) |
| 57 | + # softmax + diag-minus-outer: a handful of cheap statements, ONE softmax, |
| 58 | + # and crucially no second exp/sum recomputation |
| 59 | + assert len(stmts) <= 8, stmts |
| 60 | + assert sum("softmax" in s for s in stmts) == 1 |
| 61 | + assert not any("torch.exp" in s for s in stmts) |
| 62 | + |
| 63 | + |
| 64 | +def test_mlp_hessian_matches_torch_func(): |
| 65 | + m = sympy.Symbol("m") |
| 66 | + x = Variable("x", n) |
| 67 | + W1 = Variable("W1", n=n, m=m) |
| 68 | + W2 = Variable("W2", m=m) |
| 69 | + f = F.sum(F.tanh(x @ W1) * W2) |
| 70 | + H = f.grad(x, {"n": "di"}).grad(x, {"n": "dj"}) |
| 71 | + xv, W1v, W2v = torch.randn(N), torch.randn(N, 7) / N**0.5, torch.randn(7) |
| 72 | + out, stmts = _source_and_out( |
| 73 | + H, {x: xv.rename("n"), W1: W1v.rename("n", "m"), W2: W2v.rename("m")}, {n: N, m: 7} |
| 74 | + ) |
| 75 | + with torch.enable_grad(): |
| 76 | + ref = torch.func.hessian(lambda xx: (torch.tanh(xx @ W1v) * W2v).sum())(xv) |
| 77 | + torch.testing.assert_close(out.align_to("di", "dj").rename(None), ref) |
| 78 | + # dense Hessian but shared work: tanh computed once, no n^3 contraction |
| 79 | + assert sum("tanh" in s for s in stmts) == 1 |
0 commit comments