6 · Why Line Search Matters#

A Newton direction \(-H^{-1}\nabla E\) points the right way, but the step length still matters. Too large a step overshoots, inverts elements, and the energy explodes instead of decreasing.

Backtracking line search starts from a full step and shrinks it until the energy actually decreases (the Armijo condition). To make the failure easy to watch, we move the handle slowly right and back and take a few Newton iterations per frame — once with a fixed, too-large step, once with line search.

%matplotlib inline
import numpy as np
import scipy as sp
import matplotlib.pyplot as plt
import simkit
from simkit import backtracking_line_search
import utils

A thick, stiff, nearly-incompressible beam#

Same objective as tutorial 5 but thicker (so bending stores more energy) and nearly incompressible (\(\nu = 0.49\) — resisting volume change makes the energy much stiffer), so an over-eager step bites fast and the value of line search is obvious. The handle (the full right edge) follows a there-and-back ramp.

BeamObjective composes the now-familiar terms — the Neo-Hookean material, a fixed pin spring on the left edge, and a movable handle spring on the right edge — and sums them one per line in energy / gradient / hessian.

class BeamObjective:
    """Minimize  elastic(x) + pin spring + handle spring  for a thick beam."""

    def __init__(self, X, T, K=1e6):
        mu, lam = simkit.ympr_to_lame(50.0, 0.49)            # high Poisson ratio
        self.p   = utils.precompute(X, T, mu=mu, lam=lam)    # J, vol, masses, ...
        self.psi = utils.make_material("Neo-Hookean")        # elastic term
        self.pin    = utils.PenaltySpring(self.p.n, self.p.dim, K)
        self.handle = utils.PenaltySpring(self.p.n, self.p.dim, K)

    def set_pin(self, idx, targets):    self.pin.set(idx, targets);    return self
    def set_handle(self, idx, targets): self.handle.set(idx, targets); return self

    def energy(self, x):
        E_elastic = self.psi.energy(x, self.p)
        E_pin     = self.pin.energy(x)
        E_handle  = self.handle.energy(x)
        return E_elastic + E_pin + E_handle

    def gradient(self, x):
        g_elastic = self.psi.gradient(x, self.p)
        g_pin     = self.pin.gradient(x)
        g_handle  = self.handle.gradient(x)
        return g_elastic + g_pin + g_handle

    def hessian(self, x):
        H_elastic = self.psi.hessian(x, self.p)
        H_pin     = self.pin.hessian(x)
        H_handle  = self.handle.hessian(x)
        return H_elastic + H_pin + H_handle
X, T = utils.triangulated_grid(nx=16, ny=7, width=2.0, height=0.6)   # thick beam
n, dim = X.shape

pin_idx   = np.where(X[:, 0] <= X[:, 0].min() + 1e-6)[0]
right_idx = np.where(X[:, 0] >= X[:, 0].max() - 1e-6)[0]

ramp = np.concatenate([np.linspace(0, 1, 22), np.linspace(1, 0, 22)])    # right then back
offsets = [np.array([1.0 * r, 0.0]) for r in ramp]

The quasi-static driver#

At each handle position we take a few Newton iterations. The only difference between the two runs is the step: a fixed STEP (deliberately too big) versus the backtracking step that guarantees the energy decreases.

We solve the linear system \(H\,\Delta x = -g\) for the Newton direction, then either take a fixed multiple of it or let backtracking_line_search pick a safe step length \(\alpha\).

DIVERGE = 1e3

def drive(use_line_search, STEP=2.0, iters_per_frame=2):
    beam = BeamObjective(X, T).set_pin(pin_idx, X[pin_idx])
    x = X.flatten().reshape(-1, 1).copy()
    states, blew_at = [], None
    for k, off in enumerate(offsets):
        beam.set_handle(right_idx, X[right_idx] + off)
        for _ in range(iters_per_frame):
            g = beam.gradient(x)
            H = beam.hessian(x)
            dx = sp.sparse.linalg.spsolve(H.tocsc(), -g).reshape(-1, 1)
            if use_line_search:
                alpha = backtracking_line_search(beam.energy, x, g, dx)[0]
            else:
                alpha = STEP
            x = x + alpha * dx
        states.append(x.reshape(n, dim).copy())
        if not np.isfinite(np.abs(x).max()) or np.abs(x).max() > DIVERGE:
            blew_at = k
            break
    return states, blew_at

fixed_states, blew = drive(use_line_search=False, STEP=2.0)
ls_states,    _    = drive(use_line_search=True)
print("fixed step (no line search): blew up at frame", blew, "of", len(offsets))
print("line search: completed all", len(ls_states), "frames")
fixed step (no line search): blew up at frame 9 of 44
line search: completed all 44 frames

Without line search: the solver explodes (fixed step = 2x the Newton step)#

lims = ((-1.3, 2.2), (-1.6, 1.6))
fig, anim = utils.animate_mesh(fixed_states, T, lims=lims,
    title="Fixed step, NO line search  ->  explodes", pin_pts=X[pin_idx],
    handle_traj=[s[right_idx] for s in fixed_states], fps=12)
utils.show_video(fig, anim, "media/06_no_line_search.mp4", fps=12)

With line search: smooth and stable#

fig, anim = utils.animate_mesh(ls_states, T, lims=lims,
    title="Backtracking line search  ->  stable", pin_pts=X[pin_idx],
    handle_traj=[s[right_idx] for s in ls_states], fps=20)
utils.show_video(fig, anim, "media/06_line_search.mp4", fps=20)

Takeaways#

  • A good Newton direction isn’t enough — an unchecked step length can diverge.

  • Backtracking line search shrinks the step until the energy decreases.

  • Cheap insurance: a few extra energy evaluations buy a solver that won’t blow up.