๐Ÿ–ผ๏ธ The classic continuation cartoon, from real dataยถ

Every introduction to homotopy continuation draws the same picture: smooth start points at \(t=1\) on the right, paths flowing left to the targetโ€™s solutions at \(t=0\), past an endgame boundary; the endpoints come in three flavors โ€” nonsingular, singular, and โ€œat infinityโ€ (paths that diverge). Here is the hand-drawn version:

the classic (hand-drawn) homotopy-continuation cartoon

This tutorial reproduces that drawing from a real solve โ€” every curve is genuine tracked data โ€” with each path styled by the kind of endpoint it reaches. It builds on the observer machinery from ๐Ÿ‘€ Watching the paths: observers and path data.

A small system with one of each flavorยถ

We want only a handful of paths, with a singular endpoint, a few nonsingular ones, and some that diverge. This system delivers exactly that:

\[f_1 = (x y - 3x + 2)(x - 4), \qquad f_2 = y - x^2 .\]

Substituting \(y = x^2\) gives \((x-1)^2 (x+2)(x-4) = 0\): a double root at \(x=1\) โ€” a singular endpoint reached by two paths โ€” and simple roots at \(x=-2, 4\) (nonsingular). The total-degree start system has 6 paths, so the remaining two diverge to infinity (the homogenizing coordinate goes to zero).

Collecting the paths, classified by endpointยถ

Attach a SolutionPathCollector to the solver; it captures each path (the whole journey to \(t \to 0\)). After solving, the per-path solution_metadata() tells us each endpointโ€™s flavor, so we can style each curve:

def classify(meta):
    if not meta.is_finite:
        return "infinite"
    return "singular" if meta.is_singular else "nonsingular"


def height(df):
    """A fixed GENERIC complex projection of the dehomogenized point down to one real number.

    A single coordinate's real part collapses distinct points onto a few heights; mixing all
    coordinates with fixed complex weights separates them.  ``df`` is one path's samples (columns
    ``t``, ``z0`` = homogenizing coord, ``z1..`` = the rest).
    """
    import numpy as np
    hom = df["z{}".format(HOMVAR_INDEX)].to_numpy()
    n_aff = sum(c.startswith("z") for c in df.columns) - 1   # affine coords (drop homvar)
    with np.errstate(divide="ignore", invalid="ignore"):
        proj = np.zeros(len(df), dtype=complex)
        for k in range(n_aff):
            proj += PROJECTION[k % len(PROJECTION)] * (df["z{}".format(k + 1)].to_numpy() / hom)
    return np.real(proj)


SEED = 12  # chosen for a clean picture: start points well separated, finite paths not grazing infinity,
           # and BOTH diverging paths leaving upward (so their infinity markers sit above the plot)


def collect(seed=SEED):
    """Solve and return [(flavor, DataFrame), ...], one per path."""
    pb.random.set_random_seed(seed)
    # total-degree LINEAR-PRODUCT start: its start points are intersections of random linear forms,
    # generically separated -- unlike the binomial total-degree start's scaled roots of unity, which
    # can land on top of each other under the height projection.
    zd = ZeroDimSolver(target_system(), mptype="adaptive", startsystem="linearproduct")
    coll = nobs.SolutionPathCollector()
    zd.add_observer(coll)
    zd.solve()
    md = zd.solution_metadata()
    out = []
    for series in coll.series:
        if len(series) == 0:
            continue
        out.append((classify(md[series.path_index]), series.as_dataframe()))
    return out

Drawing itยถ

A few choices turn the data into the cartoon:

  • height is a fixed generic complex projection of the dehomogenized coordinates down to one real number. A single coordinateโ€™s real part would make distinct points land on top of each other; a generic projection separates them.

  • the start system is the total-degree linear product (start points are intersections of random linear forms), so the start points on the right are generically separated rather than piling up.

  • the vertical axis auto-fits the finite paths โ€” robustly (the central bulk of every finite path, so a path momentarily grazing infinity does not blow up the window), so swapping the system needs no manual axis tweaking. The two paths that diverge to infinity run off the top; each is drawn up to where it leaves the window and cut there, with an \(\infty\) just outside the axis at that exit point (no endpoint marker) โ€” the cartoonโ€™s โ€œburstโ€.

  • each flavor gets a distinct line style and color and endpoint marker (so it survives grayscale / color-blind viewing): nonsingular solid โ–ฝ, singular dashed โ˜…, infinite dash-dot.

  • a bullseye marks the target \(f(z)=0\) at \(t=0\), the start points (gold) sit on the right at \(t=1\), and the endgame boundary is drawn at \(t=0.1\).

Run it (needs matplotlib):

python python/docs/source/tutorials/observing_metadata_more/classic_continuation_cartoon/classic_continuation_cartoon.py .
the classic continuation cartoon recreated from real tracking data

The same shape as the hand drawing โ€” start points on the right, three flavors of endpoint on the left, two paths funnelling into the single singular point, two diverging to infinity โ€” but now every wiggle is a real tracked path of an actual homotopy.

Note

The total-degree start points being scaled roots of unity (a structured set, not generic) is exactly why a naive single-coordinate height collapses them. That structure is a known wart in the current TotalDegreeLinearProduct start system; a genuinely randomized (linear-product) total-degree start would put them in general position.