Scientific ML Studio
Learn/ Physics-Informed Neural…/ 5.1
5.1 · Differentiating a network

Three ways to take a derivative

By hand, by finite differences, or automatically. Only the third is both exact and cheap for a neural network, and the reasons why are the reasons a PINN is possible.

A PINN's loss contains derivatives of the network with respect to its inputs: $u_x$, $u_t$, $u_{xx}$. They have to be computed at thousands of points, at every training step, accurately enough that the residual means something. There are three ways a computer can take a derivative. This section tries all three on the same functions and measures what each one costs and how wrong it is.

By hand, on a computer: symbolic differentiation

A computer algebra system such as SymPy applies the rules of calculus to a formula and returns a new formula. The answer is exact. The trouble is size. Write a tiny network, one input, two hidden layers of three tanh neurons and one output, as a formula with its weights filled in, and differentiate it repeatedly:

import numpy as np
import sympy as sp

rng = np.random.default_rng(0)
x = sp.symbols("x")
W1, b1 = np.round(rng.normal(size=3), 2), np.round(rng.normal(size=3), 2)
W2, b2 = np.round(rng.normal(size=(3, 3)), 2), np.round(rng.normal(size=3), 2)
W3 = np.round(rng.normal(size=3), 2)

h1 = [sp.tanh(sp.Float(W1[i]) * x + sp.Float(b1[i])) for i in range(3)]
h2 = [sp.tanh(sum(sp.Float(W2[i, j]) * h1[j] for j in range(3)) + sp.Float(b2[i])) for i in range(3)]
u = sum(sp.Float(W3[j]) * h2[j] for j in range(3))

for k in range(4):
    print(f"derivative {k}: {sp.count_ops(sp.diff(u, x, k)):5d} operations")
derivative 0:    53 operations
derivative 1:   116 operations
derivative 2:   329 operations
derivative 3:  1070 operations

Every derivative is a longer formula than the last, because the product and chain rules turn one term into several. This is called expression swell. A network of the size a PINN uses, with four layers of forty neurons, would produce formulas far too large to be useful. Symbolic differentiation is the right tool for manufacturing a source term (Section 4.4) and the wrong one for a network.

By finite differences

The second way needs no formula at all, only the ability to evaluate the function. The central differences

$$u'(x) \approx \frac{u(x + h) - u(x - h)}{2h}, \qquad u''(x) \approx \frac{u(x + h) - 2u(x) + u(x - h)}{h^2}$$

have errors proportional to $h^2$, so a smaller step should be better. It is, up to a point. Each evaluation of $u$ carries a rounding error of about $\epsilon\,|u|$, where $\epsilon$ is the machine precision ($2.2\times10^{-16}$ in 64-bit floating point, $1.2\times10^{-7}$ in 32-bit), and the formulas divide that error by $h$ or by $h^2$. Shrinking $h$ trades truncation error for rounding error. Here is the trade, measured on $u = \sin(\pi x)$, whose derivatives we know, as the worst error over 91 points:

xs = np.linspace(0.05, 0.95, 91)
exact1, exact2 = np.pi * np.cos(np.pi * xs), -np.pi**2 * np.sin(np.pi * xs)

print("     h    first, float64  second, float64  first, float32  second, float32")
for h in [1e-1, 1e-2, 1e-3, 1e-4, 1e-5, 1e-6, 1e-7]:
    row = []
    for dtype in (np.float64, np.float32):
        x_, h_, pi = xs.astype(dtype), dtype(h), dtype(np.pi)
        f = lambda z: np.sin(pi * z)
        d1 = (f(x_ + h_) - f(x_ - h_)) / (2 * h_)
        d2 = (f(x_ + h_) - 2 * f(x_) + f(x_ - h_)) / (h_ * h_)
        row += [np.abs(d1 - exact1).max(), np.abs(d2 - exact2).max()]
    print(f"{h:7.0e}  " + "  ".join(f"{e:15.1e}" for e in row))
     h    first, float64  second, float64  first, float32  second, float32
  1e-01          5.1e-02          8.1e-02          5.1e-02          8.1e-02
  1e-02          5.1e-04          8.1e-04          5.1e-04          2.6e-03
  1e-03          5.1e-06          8.1e-06          8.2e-05          2.8e-01
  1e-04          5.1e-08          1.0e-07          7.9e-04          2.3e+01
  1e-05          5.2e-10          4.6e-06          7.1e-03          2.1e+03
  1e-06          2.1e-10          4.7e-04          8.3e-02          2.4e+05
  1e-07          3.0e-09          4.5e-02          1.5e+00          2.7e+07

Read down each column (Figure 5.1 draws them). In 64-bit arithmetic the first derivative improves until $h \approx 10^{-6}$ and the second until $h \approx 10^{-4}$, then both get worse. In 32-bit arithmetic, which is PyTorch's default, the second derivative is never better than about $10^{-3}$, that is $10^{-4}$ relative to its size, and at $h = 10^{-5}$ it is wrong by more than a thousand. A PINN that formed $u_{xx}$ this way in float32 would be fitting rounding noise.

Log-log plot of the error of central finite differences against step size h, for the first and second derivative in 64-bit and 32-bit arithmetic. Each curve falls as h shrinks, reaches a minimum and then rises steeply. Horizontal lines mark the much smaller errors of automatic differentiation in each precision.
Figure 5.1. Finite differences on $\sin(\pi x)$: truncation error falls with $h$ (left side), rounding error grows as $h$ shrinks (right side). The dotted lines are automatic differentiation, which has no $h$ at all.

There is a second cost. Every finite-difference derivative needs extra evaluations of the function: two for a first derivative, three for a second, more in several dimensions, and each is a full forward pass of the network.

Automatically

The third way is automatic differentiation. It does not manipulate formulas and it does not take small steps. A program that evaluates a network is a sequence of elementary operations: multiply, add, tanh. Each has a derivative that is known exactly. Automatic differentiation records the sequence while it runs and then applies the chain rule to it, operation by operation, with numbers rather than formulas (Baydin et al., 2018; Griewank & Walther, 2008). The result is the exact derivative of the function the program computes, wrong only by rounding:

import torch

for dtype in (torch.float64, torch.float32):
    x_ = torch.tensor(xs, dtype=dtype, requires_grad=True)
    u_ = torch.sin(torch.pi * x_)
    ux, = torch.autograd.grad(u_.sum(), x_, create_graph=True)
    uxx, = torch.autograd.grad(ux.sum(), x_)
    print(f"autograd, {str(dtype)[6:]:8s}  first {np.abs(ux.detach().numpy() - exact1).max():.1e}"
          f"   second {np.abs(uxx.numpy() - exact2).max():.1e}")
autograd, float64   first 0.0e+00   second 1.8e-15
autograd, float32   first 7.1e-07   second 2.1e-06

The errors are at the level of the arithmetic itself, in either precision, for the first derivative and the second alike. There is no step to choose. The cost is a small multiple of one evaluation, as Section 3.4 found for gradients with respect to the weights, and Section 5.2 shows why.

Method Accuracy Cost per derivative Works on a network?
Symbolic exact grows with the size of the formula only for tiny ones
Finite differences $h^2$ truncation plus rounding amplified by $1/h$ or $1/h^2$ extra evaluations per point and per direction yes, inaccurately
Automatic exact up to rounding a small multiple of one evaluation yes

Where this goes in a PINN

"Exact" here means the exact derivative of the network, not of the true solution. Automatic differentiation removes the error of taking the derivative; it does nothing about the network being an approximation. That second error is what training reduces, and what Chapter 6 measures. Without the first error gone, the second could not even be measured. It is why Raissi et al. (2019) built PINNs on automatic differentiation and not on a finite-difference stencil.

Exercises

  1. For a central first difference in 64-bit arithmetic, the truncation error is about $h^2|u'''|/6$ and the rounding error about $\epsilon|u|/h$. Find the $h$ that balances them for $u = \sin(\pi x)$, roughly. Does it match the table?
  2. Why is a 32-bit second difference so much worse than a 32-bit first difference at the same $h$?
  3. A colleague proposes computing $u_{xx}$ for a PINN by finite differences in float64 with $h = 10^{-4}$, to avoid "complicated autograd". Give two reasons against.
Answers
  1. Setting $h^2\pi^3/6 \approx \epsilon/h$ gives $h^3 \approx 6\epsilon/\pi^3 \approx 4\times10^{-17}$, so $h \approx 3\times10^{-6}$. The table's best first derivative is at $h = 10^{-6}$ to $10^{-5}$. Yes.
  2. The rounding error of the second difference is divided by $h^2$, not $h$, and 32-bit rounding is about $5\times10^{8}$ times larger than 64-bit. At $h = 10^{-3}$, $\epsilon/h^2 \approx 10^{-7}/10^{-6} = 0.1$: an error of order one tenth, as the table shows.
  3. It costs three network evaluations per point and per direction instead of one pass and a backward sweep, and its accuracy (about $10^{-7}$ at best) puts a floor under the residual that training cannot get below. It also forces the whole network into float64, which costs memory and time, whereas autograd is accurate to about $10^{-6}$ even in float32.

Recap

  • Symbolic differentiation is exact but its formulas swell; finite differences are simple but trade truncation against rounding, disastrously for second derivatives in 32-bit arithmetic.
  • Automatic differentiation applies the chain rule to the recorded computation: exact up to rounding, for a small multiple of the cost of one evaluation.
  • It gives the exact derivative of the network. Whether the network is close to the solution is a separate question, and the one training answers.

References

  1. Baydin, A. G., Pearlmutter, B. A., Radul, A. A., & Siskind, J. M. (2018). Automatic differentiation in machine learning: a survey. Journal of Machine Learning Research, 18(153), 1–43. https://jmlr.org/papers/v18/17-468.html
  2. Griewank, A., & Walther, A. (2008). Evaluating derivatives: Principles and techniques of algorithmic differentiation. Society for Industrial and Applied Mathematics. 2nd edition. doi:10.1137/1.9780898717761
  3. Raissi, M., Perdikaris, P., & Karniadakis, G. E. (2019). Physics-informed neural networks: A deep learning framework for solving forward and inverse problems involving nonlinear partial differential equations. Journal of Computational Physics, 378, 686–707. doi:10.1016/j.jcp.2018.10.045