LAD: Lightweight Automatic Differentiation - a small autodiff tool. Support reverse mode throught a Taper that records instruction an then performs back-propagation WRT a given variable.
Forward mode automatic differenciation computes
$x_1 + x_2 = a_1 + a_2 + \epsilon (b_1 + b_2)$ $x_1 x_2 = a_1 a_2 + \epsilon (a_1 b_2 + b_1 b_2)$ $\cos(a + \epsilon b) = \cos(a) - \epsilon b\sin(a)$ - ... This is called forward mode automatic differentiation because the value of the partial derivative (often called the seed) is set on the leaves of the expression tree and propagated to the root. Forward mode can thus compute a directional derivative with one function evaluation.
Here is how dual number are used with LAD: First, we define a function.
template<typename T> T f(const T& x, const T& y) { return x*x * sin(y); }The in our main.cpp
constexpr LAD::Dual<double> eps(0., 1.);
const LAD::Dual<double> x(1.);
const LAD::Dual<double> y(0.25*std::numbers::pi_v<double>);
// let us compute ∂f/∂x at (x=1, y=pi/4)
{
const LAD::Dual<double> fx = f(x + eps, y);
fmt::println("f(x,y) = {}", fx.getValue());
fmt::println("analytical ∂f/∂x(x,y) = {}", 2.*std::sin(y.getValue()));
fmt::println("estimated ∂f/∂x(x,y) = {}", fx.getDiff());
}
// now we compute ∂f/∂y at (x=1, y=pi/4)
{
const LAD::Dual<double> fx = f(x, y + eps);
fmt::println("f(x,y) = {}", fx.getValue());
fmt::println("analytical ∂f/∂x(x,y) = {}", std::cos(y.getValue()));
fmt::println("estimated ∂f/∂x(x,y) = {}", fx.getDiff());
}
// now we compute -2 ∂f/∂x + 3 ∂f/∂y at (x=1, y=pi/4)
{
const LAD::Dual<double> fx = f(x - 2.*eps, y + 3.*eps);
fmt::println("f(x,y) = {}", fx.getValue());
fmt::println("analytical directional derivatives = {}", -4.*std::sin(y.getValue()) + 3.*std::cos(y.getValue()));
fmt::println("estimated directional derivatives = {}", fx.getDiff());
}Forward mode can compute a directional derivative in one function evaluation at relatively low cost. However, evaluating the full gradient requires
Reverse mode automatic differentiation follows the opposite path and assumes the seed is set at the root of the call tree and then uses the chain rule to compute the derivatives at the leaves.
More precisely, assume
With, off course,
- The function is evaluated and all the operations and intermediate variables of the expression tree are stored into a "Tape"
- The expression tree is then traversed from root to leaves propagating the partial derivatives.
Here is how reverse mode is used in LAD:
// we create a "tape" that records the operations
// carried out upon the evaluation of our function.
LAD::Tape<double> tape;
// we create a scope that will be used by all subsequent variables.
// the scope grant access to our tape to all the temporaries
// created with our function.
LAD::TapeScope<double> scope(tape);
LAD::Variable<double> x(1.);
LAD::Variable<double> y(0.25*std::numbers::pi_v<double>);
const LAD::Variable<double> fxy = f(x, y);
// Reverse mode computes the gradient WRT all the variables, including temporaries
// involved in the computation of fxy.
// getDiffWRT return a std::range::view (a subset) of the gradient that corresponds
// to the specified variables.
// if another `getDiffWRT` is called or if the tape is destroyeed the grad will be invalidated.
// One can store the gradient into a container by calling:
// std::ranges::copy(grad, std::ranges::begin(dst))
const auto grad = fxy.getDiffWRT(x, y);
fmt::println("f(x,y) = {}", fxy.getValue());
fmt::println("analytical ∂f/∂x(x,y) = {}", 2.*std::sin(y.getValue()));
fmt::println("estimated ∂f/∂x(x,y) = {}", grad[0]);
fmt::println("analytical ∂f/∂y(x,y) = {}", std::cos(y.getValue()));
fmt::println("estimated ∂f/∂y(x,y) = {}", grad[1]);One can compute the product between the Hessain of a function at a given point
Whose gradient is
While LAD do not support this directly, one can implement it as follows:
LAD::Tape<double> tape;
LAD::TapeScope<double> scope(tape);
// we want to differentiate g(x,y) = -2 ∂f/∂x (x,y) + 3 ∂f/∂y (x,y)
// AKA H_f*[-2; 3]
const LAD::DualVariable<double> eps(0., 1.);
LAD::DualVariable<double> x(1.);
LAD::DualVariable<double> y(0.25*std::numbers::pi_v<double>);
// The forward mode gives us the directional derivative of J
const DirectionalDiff gradJ_d = J.getDiff();
// The reverse mode allows us to compute the gradient of the directional derivative of J
// i.e. the product between the Hessian of J and a direction d.
const auto hessProd = gradJ_d.getDiffWRT(x, y);
fmt::println("f(x,y) = {}", J.getValue());
fmt::println("analytical -2 ∂²f/∂x²2 (x,y) + 3 ∂²f/∂x∂y (x,y) = {}", -4.*std::sin(y.getValue()) + 6.*x.getValue()*std::cos(y.getValue()));
fmt::println("estimated -2 ∂²f/∂x²2 (x,y) + 3 ∂²f/∂x∂y (x,y) = {}", hessProd[0]);
fmt::println("analytical -2 ∂²f/∂x∂y (x,y) + 3 ∂²f/∂y² (x,y) = {}", -4.*x.getValue()*std::cos(y.getValue()) - 3.*x.getValue()*x.getValue()*std::sin(y.getValue()));
fmt::println("estimated -2 ∂²f/∂x∂y (x,y) + 3 ∂²f/∂y² (x,y) = {}", hessProd[1]);We define Rosenbrock function:
template<typename T>
inline T rosenbrock(const std::span<const T> x)
{
T J{};
for (size_t i=0; i!=size_t(x.size()-1); ++i)
{
J += 100.*(x[i+1] - x[i]*x[i])*(x[i+1] - x[i]*x[i]) + (x[i] - 1.)*(x[i] - 1.);
}
return J;
}Then we compute it's gradient at
const std::vector<LAD::Variable<double>> x(10, LAD::Variable<double>());
const LAD::Variable<double> J = rosenbrock(std::span(x));
const auto grad = J.getDiffWRT(x);
fmt::println("J({}) = {}", fmt::join(x, ", "), J.getValue()); // J(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) = 9
fmt::println("grad({}) = {}", fmt::join(x, ", "), grad); // grad(0, 0, 0, 0, 0, 0, 0, 0, 0, 0) = [-18, -18, -18, -18, -18, -18, -18, -18, -18, -18]Now let use compute its gradient at
const std::vector<LAD::Variable<double>> x(10, LAD::Variable<double>(1.));
const LAD::Variable<double> J = rosenbrock(std::span(x));
const auto grad = J.getDiffWRT(x);
fmt::println("J({}) = {}", fmt::join(x, ", "), J.getValue()); // J(1, 1, 1, 1, 1, 1, 1, 1, 1, 1) = 0
fmt::println("grad({}) = {}", fmt::join(x, ", "), grad); // grad(1, 1, 1, 1, 1, 1, 1, 1, 1, 1) = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]