def newton_iterate(evaluate_fn, s, t): r"""Perform a Newton iteration. In this function, we assume that :math:`s` and :math:`t` are nonzero, this makes convergence easier to detect since "relative error" at ``0.0`` is not a useful measure. There are several tolerance / threshold quantities used below: * :math:`10` (:attr:`MAX_NEWTON_ITERATIONS`) iterations will be done before "giving up". This is based on the assumption that we are already starting near a root, so quadratic convergence should terminate quickly. * :math:`\tau = \frac{1}{4}` is used as the boundary between linear and superlinear convergence. So if the current error :math:`\|p_{n + 1} - p_n\|` is not smaller than :math:`\tau` times the previous error :math:`\|p_n - p_{n - 1}\|`, then convergence is considered to be linear at that point. * :math:`\frac{2}{3}` of all iterations must be converging linearly for convergence to be stopped (and moved to the next regime). This will only be checked after 4 or more updates have occurred. * :math:`\tau = 2^{-42}` (:attr:`NEWTON_ERROR_RATIO`) is used to determine that an update is sufficiently small to stop iterating. So if the error :math:`\|p_{n + 1} - p_n\|` smaller than :math:`\tau` times size of the term being updated :math:`\|p_n\|`, then we exit with the "correct" answer. It is assumed that ``evaluate_fn`` will use a Jacobian return value of :data:`None` to indicate that :math:`F(s, t)` is exactly ``0.0``. We **assume** that if the function evaluates to exactly ``0.0``, then we are at a solution. It is possible however, that badly parameterized curves can evaluate to exactly ``0.0`` for inputs that are relatively far away from a solution (see issue #21). Args: evaluate_fn (Callable[Tuple[float, float], tuple]): A callable which takes :math:`s` and :math:`t` and produces an evaluated function value and the Jacobian matrix. s (float): The (first) parameter where the iteration will start. t (float): The (second) parameter where the iteration will start. Returns: Tuple[bool, float, float]: The triple of * Flag indicating if the iteration converged. * The current :math:`s` value when the iteration stopped. * The current :math:`t` value when the iteration stopped. """ # Several quantities will be tracked throughout the iteration: # * norm_update_prev: ||p{n} - p{n-1}|| = ||dp{n-1}|| # * norm_update : ||p{n+1} - p{n} || = ||dp{n} || # * linear_updates : This is a count on the number of times that # ``dp{n}`` "looks like" ``dp{n-1}`` (i.e. # is within a constant factor of it). norm_update_prev = None norm_update = None linear_updates = 0 # Track the number of "linear" updates. current_s = s current_t = t for index in range(MAX_NEWTON_ITERATIONS): jacobian, func_val = evaluate_fn(current_s, current_t) if jacobian is None: return True, current_s, current_t singular, delta_s, delta_t = _py_helpers.solve2x2( jacobian, func_val[:, 0] ) if singular: break norm_update_prev = norm_update norm_update = np.linalg.norm([delta_s, delta_t], ord=2) # If ||p{n} - p{n-1}|| > 0.25 ||p{n-1} - p{n-2}||, then that means # our convergence is acting linear at the current step. if index > 0 and norm_update > 0.25 * norm_update_prev: linear_updates += 1 # If ``>=2/3`` of the updates have been linear, we are near a # non-simple root. (Make sure at least 5 updates have occurred.) if index >= 4 and 3 * linear_updates >= 2 * index: break # Determine the norm of the "old" solution before updating. norm_soln = np.linalg.norm([current_s, current_t], ord=2) current_s -= delta_s current_t -= delta_t if norm_update < NEWTON_ERROR_RATIO * norm_soln: return True, current_s, current_t return False, current_s, current_t
def _call_function_under_test(lhs, rhs): from bezier.hazmat import helpers return helpers.solve2x2(lhs, rhs)
def newton_refine(s, nodes1, t, nodes2): r"""Apply one step of 2D Newton's method. .. note:: There is also a Fortran implementation of this function, which will be used if it can be built. We want to use Newton's method on the function .. math:: F(s, t) = B_1(s) - B_2(t) to refine :math:`\left(s_{\ast}, t_{\ast}\right)`. Using this, and the Jacobian :math:`DF`, we "solve" .. math:: \left[\begin{array}{c} 0 \\ 0 \end{array}\right] \approx F\left(s_{\ast} + \Delta s, t_{\ast} + \Delta t\right) \approx F\left(s_{\ast}, t_{\ast}\right) + \left[\begin{array}{c c} B_1'\left(s_{\ast}\right) & - B_2'\left(t_{\ast}\right) \end{array}\right] \left[\begin{array}{c} \Delta s \\ \Delta t \end{array}\right] and refine with the component updates :math:`\Delta s` and :math:`\Delta t`. .. note:: This implementation assumes the curves live in :math:`\mathbf{R}^2`. For example, the curves .. math:: \begin{align*} B_1(s) &= \left[\begin{array}{c} 0 \\ 0 \end{array}\right] (1 - s)^2 + \left[\begin{array}{c} 2 \\ 4 \end{array}\right] 2s(1 - s) + \left[\begin{array}{c} 4 \\ 0 \end{array}\right] s^2 \\ B_2(t) &= \left[\begin{array}{c} 2 \\ 0 \end{array}\right] (1 - t) + \left[\begin{array}{c} 0 \\ 3 \end{array}\right] t \end{align*} intersect at the point :math:`B_1\left(\frac{1}{4}\right) = B_2\left(\frac{1}{2}\right) = \frac{1}{2} \left[\begin{array}{c} 2 \\ 3 \end{array}\right]`. However, starting from the wrong point we have .. math:: \begin{align*} F\left(\frac{3}{8}, \frac{1}{4}\right) &= \frac{1}{8} \left[\begin{array}{c} 0 \\ 9 \end{array}\right] \\ DF\left(\frac{3}{8}, \frac{1}{4}\right) &= \left[\begin{array}{c c} 4 & 2 \\ 2 & -3 \end{array}\right] \\ \Longrightarrow \left[\begin{array}{c} \Delta s \\ \Delta t \end{array}\right] &= \frac{9}{64} \left[\begin{array}{c} -1 \\ 2 \end{array}\right]. \end{align*} .. image:: ../images/newton_refine1.png :align: center .. testsetup:: newton-refine1, newton-refine2, newton-refine3 import numpy as np import bezier from bezier._py_intersection_helpers import newton_refine machine_eps = np.finfo(np.float64).eps def realroots(*coeffs): all_roots = np.roots(coeffs) return all_roots[np.where(all_roots.imag == 0.0)].real .. doctest:: newton-refine1 >>> nodes1 = np.asfortranarray([ ... [0.0, 2.0, 4.0], ... [0.0, 4.0, 0.0], ... ]) >>> nodes2 = np.asfortranarray([ ... [2.0, 0.0], ... [0.0, 3.0], ... ]) >>> s, t = 0.375, 0.25 >>> new_s, new_t = newton_refine(s, nodes1, t, nodes2) >>> 64.0 * (new_s - s) -9.0 >>> 64.0 * (new_t - t) 18.0 .. testcleanup:: newton-refine1 import make_images curve1 = bezier.Curve(nodes1, degree=2) curve2 = bezier.Curve(nodes2, degree=1) make_images.newton_refine1(s, new_s, curve1, t, new_t, curve2) For "typical" curves, we converge to a solution quadratically. This means that the number of correct digits doubles every iteration (until machine precision is reached). .. image:: ../images/newton_refine2.png :align: center .. doctest:: newton-refine2 >>> nodes1 = np.asfortranarray([ ... [0.0, 0.25, 0.5, 0.75, 1.0], ... [0.0, 2.0 , -2.0, 2.0 , 0.0], ... ]) >>> nodes2 = np.asfortranarray([ ... [0.0, 0.25, 0.5, 0.75, 1.0], ... [1.0, 0.5 , 0.5, 0.5 , 0.0], ... ]) >>> # The expected intersection is the only real root of >>> # 28 s^3 - 30 s^2 + 9 s - 1. >>> expected, = realroots(28, -30, 9, -1) >>> s_vals = [0.625, None, None, None, None] >>> t = 0.625 >>> np.log2(abs(expected - s_vals[0])) -4.399... >>> s_vals[1], t = newton_refine(s_vals[0], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[1])) -7.901... >>> s_vals[2], t = newton_refine(s_vals[1], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[2])) -16.010... >>> s_vals[3], t = newton_refine(s_vals[2], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[3])) -32.110... >>> s_vals[4], t = newton_refine(s_vals[3], nodes1, t, nodes2) >>> np.allclose(s_vals[4], expected, rtol=6 * machine_eps, atol=0.0) True .. testcleanup:: newton-refine2 import make_images curve1 = bezier.Curve(nodes1, degree=4) curve2 = bezier.Curve(nodes2, degree=4) make_images.newton_refine2(s_vals, curve1, curve2) However, when the intersection occurs at a point of tangency, the convergence becomes linear. This means that the number of correct digits added each iteration is roughly constant. .. image:: ../images/newton_refine3.png :align: center .. doctest:: newton-refine3 >>> nodes1 = np.asfortranarray([ ... [0.0, 0.5, 1.0], ... [0.0, 1.0, 0.0], ... ]) >>> nodes2 = np.asfortranarray([ ... [0.0, 1.0], ... [0.5, 0.5], ... ]) >>> expected = 0.5 >>> s_vals = [0.375, None, None, None, None, None] >>> t = 0.375 >>> np.log2(abs(expected - s_vals[0])) -3.0 >>> s_vals[1], t = newton_refine(s_vals[0], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[1])) -4.0 >>> s_vals[2], t = newton_refine(s_vals[1], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[2])) -5.0 >>> s_vals[3], t = newton_refine(s_vals[2], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[3])) -6.0 >>> s_vals[4], t = newton_refine(s_vals[3], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[4])) -7.0 >>> s_vals[5], t = newton_refine(s_vals[4], nodes1, t, nodes2) >>> np.log2(abs(expected - s_vals[5])) -8.0 .. testcleanup:: newton-refine3 import make_images curve1 = bezier.Curve(nodes1, degree=2) curve2 = bezier.Curve(nodes2, degree=1) make_images.newton_refine3(s_vals, curve1, curve2) Unfortunately, the process terminates with an error that is not close to machine precision :math:`\varepsilon` when :math:`\Delta s = \Delta t = 0`. .. testsetup:: newton-refine3-continued import numpy as np import bezier from bezier._py_intersection_helpers import newton_refine nodes1 = np.asfortranarray([ [0.0, 0.5, 1.0], [0.0, 1.0, 0.0], ]) nodes2 = np.asfortranarray([ [0.0, 1.0], [0.5, 0.5], ]) .. doctest:: newton-refine3-continued >>> s1 = t1 = 0.5 - 0.5**27 >>> np.log2(0.5 - s1) -27.0 >>> s2, t2 = newton_refine(s1, nodes1, t1, nodes2) >>> s2 == t2 True >>> np.log2(0.5 - s2) -28.0 >>> s3, t3 = newton_refine(s2, nodes1, t2, nodes2) >>> s3 == t3 == s2 True Due to round-off near the point of tangency, the final error resembles :math:`\sqrt{\varepsilon}` rather than machine precision as expected. .. note:: The following is not implemented in this function. It's just an exploration on how the shortcomings might be addressed. However, this can be overcome. At the point of tangency, we want :math:`B_1'(s) \parallel B_2'(t)`. This can be checked numerically via .. math:: B_1'(s) \times B_2'(t) = 0. For the last example (the one that converges linearly), this is .. math:: 0 = \left[\begin{array}{c} 1 \\ 2 - 4s \end{array}\right] \times \left[\begin{array}{c} 1 \\ 0 \end{array}\right] = 4 s - 2. With this, we can modify Newton's method to find a zero of the over-determined system .. math:: G(s, t) = \left[\begin{array}{c} B_0(s) - B_1(t) \\ B_1'(s) \times B_2'(t) \end{array}\right] = \left[\begin{array}{c} s - t \\ 2 s (1 - s) - \frac{1}{2} \\ 4 s - 2\end{array}\right]. Since :math:`DG` is :math:`3 \times 2`, we can't invert it. However, we can find a least-squares solution: .. math:: \left(DG^T DG\right) \left[\begin{array}{c} \Delta s \\ \Delta t \end{array}\right] = -DG^T G. This only works if :math:`DG` has full rank. In this case, it does since the submatrix containing the first and last rows has rank two: .. math:: DG = \left[\begin{array}{c c} 1 & -1 \\ 2 - 4 s & 0 \\ 4 & 0 \end{array}\right]. Though this avoids a singular system, the normal equations have a condition number that is the square of the condition number of the matrix. Starting from :math:`s = t = \frac{3}{8}` as above: .. testsetup:: newton-refine4 import numpy as np from bezier.hazmat import helpers def modified_update(s, t): minus_G = np.asfortranarray([ [t - s], [0.5 - 2.0 * s * (1.0 - s)], [2.0 - 4.0 * s], ]) DG = np.asfortranarray([ [1.0, -1.0], [2.0 - 4.0 * s, 0.0], [4.0, 0.0], ]) DG_t = np.asfortranarray(DG.T) LHS = helpers.matrix_product(DG_t, DG) RHS = helpers.matrix_product(DG_t, minus_G) delta_params = np.linalg.solve(LHS, RHS) delta_s, delta_t = delta_params.flatten() return s + delta_s, t + delta_t .. doctest:: newton-refine4 >>> s0, t0 = 0.375, 0.375 >>> np.log2(0.5 - s0) -3.0 >>> s1, t1 = modified_update(s0, t0) >>> s1 == t1 True >>> 1040.0 * s1 519.0 >>> np.log2(0.5 - s1) -10.022... >>> s2, t2 = modified_update(s1, t1) >>> s2 == t2 True >>> np.log2(0.5 - s2) -31.067... >>> s3, t3 = modified_update(s2, t2) >>> s3 == t3 == 0.5 True Args: s (float): Parameter of a near-intersection along the first curve. nodes1 (numpy.ndarray): Nodes of first curve forming intersection. t (float): Parameter of a near-intersection along the second curve. nodes2 (numpy.ndarray): Nodes of second curve forming intersection. Returns: Tuple[float, float]: The refined parameters from a single Newton step. Raises: ValueError: If the Jacobian is singular at ``(s, t)``. """ # NOTE: We form -F(s, t) since we want to solve -DF^{-1} F(s, t). func_val = _py_curve_helpers.evaluate_multi( nodes2, np.asfortranarray([t]) ) - _py_curve_helpers.evaluate_multi(nodes1, np.asfortranarray([s])) if np.all(func_val == 0.0): # No refinement is needed. return s, t # NOTE: This assumes the curves are 2D. jac_mat = np.empty((2, 2), order="F") jac_mat[:, :1] = _py_curve_helpers.evaluate_hodograph(s, nodes1) jac_mat[:, 1:] = -_py_curve_helpers.evaluate_hodograph(t, nodes2) # Solve the system. singular, delta_s, delta_t = _py_helpers.solve2x2(jac_mat, func_val[:, 0]) if singular: raise ValueError("Jacobian is singular.") return s + delta_s, t + delta_t