Newton fractal in Julia using Symbolics.jl

The most recent video of 3blue1brown covered Newton fractals, a family of fractal curves that are obtained by applying Newton's method to complex numbers. Since I am covering Newton's method in my optimization course this week, it might be an entertaining illustration to play around with this topic.

Newton's method

Newton's method is a root-finding algorithm. We can use it to find roots of a function \(f\), i.e., inputs \(x\) for which it holds that \(f(x)=0\).

When executing Newton's method, we start with an initial \(x\) and iteratively apply the following rule till convergence:

\[ x \mapsto x - \frac{f(x)}{f'(x)}\,. \]

This rule can be derived by taking the first-order Taylor approximation of \(f(x+h)\) in \(x\) and solving for the step \(h\) that sets the approximation to zero.

Let us apply this to a simple function!

f(x) = x^3 - 1;

For most functions, we can compute its derivative, and hence Newton update, easily enough. However, let us be lazy and use this opportunity to use Julia's new CAS Symbolics.jl. We write a function that takes a function as an input and computes the map above:

using Symbolics

function get_map(f)
    # define variable
    @variables x
    # define derivative operator
    Dx = Differential(x)
    map = x - f(x) / Dx(f(x)) |> expand_derivatives
    # now we expand and compile to a Julia function
    update_expr = build_function(map, x)
    return eval(update_expr)
end

update = get_map(f)
#1 (generic function with 1 method)

Yes! We get a new function: the update map! Let us try it on a value \(x=2\)

update(2.0)  # apply map once
1.4166666666666665
update(update(2.0))  # apply map twice
UndefVarError: `update` not defined
update(update(update(2.0)))  # apply map thrice
UndefVarError: `update` not defined
update(update(update(update(2.0))))  # apply map four times
UndefVarError: `update` not defined

We see that after a couple of steps, the Newton method converges to the root \(x=1\).

For our convenience, let us define a function that applies this map \(n\) times.

function applyiteratively(x, update; n=100)
    for i in 1:n
        x = update(x)
    end
    return x
end

applyiteratively(2.0, update)  # apply 100 times
UndefVarError: `update` not defined

Complex roots

Astute readers might have noticed that \(x^3-1\) has three roots: one real (\(x=1\)) and two complex ones (\(x=-1/2+\sqrt{3}i/2\) and \(x=-1/2-\sqrt{3}i/2\)). Would our method also work with complex inputs?

applyiteratively(-2.0 - 2.0im, update)
UndefVarError: `update` not defined

We see that this converges to a different (complex) root! Other values might converge to different roots!

applyiteratively(2.0 + 2.0im, update)
UndefVarError: `update` not defined

The Newton fractal

Imagine that we try this process for many complex numbers in an interval. Depending on the initial starting input, we end up in a different root. By colouring the results according to the root we end up in, we obtain the Newton fractal.

lower = -2 - 2im
upper = 2 + 2im

step = 0.5e-2

# generate a range of complex values
Z0 = [a+b*im for b in real(lower):step:real(upper),
                    a in imag(lower):step:imag(upper)]

# apply the update 100 times
Z100 = applyiteratively.(Z0, update);
UndefVarError: `update` not defined

This results in a large complex matrix. We might define a colourmap for complex numbers as done here, though plotting the angle of the complex number in polar coordinates suffices.

using Plots

heatmap(angle.(Z100), colorbar=false, color=:rainbow, ticks=false)
Failed to precompile Plots [91a5bcdd-55d7-5caf-9e0b-520d859cae80] to "/Users/michielstock/.julia/compiled/v1.9/Plots/jl_VlYjhH".

Below are some other examples.

\[ f(x) = x^8 + 15x^4 -16 \]
UndefVarError: `get_map` not defined
\[ f(x) = \cosh(x) - 1 \]
UndefVarError: `get_map` not defined