๐Ÿ—ƒ๏ธ A dataframe of solutions: filtering and plotting with pandasยถ

A solve gives you more than a pile of points. Every solution carries metadata โ€“ is it finite, is it real, is it singular, what is its multiplicity and condition number โ€“ and once you have all of that in one table, sorting the solutions into categories and drawing them becomes a matter of one-line filters. That table is what to_dataframe() returns: the whole solve as a pandas DataFrame, one row per solution.

We will solve the intersection of two plane curves chosen to show every kind of root at once โ€“ real and complex, simple and singular โ€“ and then use the DataFrame to plot them.

Two plane curvesยถ

The first curve is a nodal cubic, \(y^2 = x^3 + x^2\). It crosses itself at the origin: that self-crossing is a node, a singular point of the curve. The second is another cubic, \(y = x^3 - 2x\), threaded through that node.

import bertini
from bertini import ZeroDimSolver

x, y = bertini.Variable('x'), bertini.Variable('y')
sys = bertini.System()
sys.add_function(y*y - x**3 - x**2)     # nodal cubic   y^2 = x^3 + x^2  (node at the origin)
sys.add_function(y - x**3 + 2*x)        # second cubic  y = x^3 - 2x     (passes through it)
sys.add_variable_group(bertini.VariableGroup([x, y]))

solver = ZeroDimSolver(sys, mptype='adaptive')
solver.solve()

Because the second curve runs through the node โ€“ where the first curveโ€™s gradient vanishes โ€“ the two curves meet there singularly: two homotopy paths arrive at the same point, giving it multiplicity two. Away from the node they meet transversally, in a mix of real and complex points.

One row per solutionยถ

to_dataframe() lays the solve out as a table. By default it keeps only the finite solutions (omit_infinite=True); each row is a solution โ€“ the whole point in a single solution column, followed by every metadata field.

import pandas as pd

df = solver.to_dataframe()
assert len(df) == 5                         # five distinct finite solutions
assert {'solution', 'is_real', 'is_singular', 'multiplicity'} <= set(df.columns)
assert len(df['solution'].iloc[0]) == 2     # each cell is the whole (x, y) point

The solution cell is the whole point (a copied vector), not exploded into x0, x1, โ€ฆ columns โ€“ if you want per-coordinate columns, you split it yourself (we do exactly that for plotting, below).

One row per distinct solution. A multiplicity-\(m\) solution arrives from the solver as \(m\) coincident endpoints โ€“ the node here is the end of two paths. Carrying \(m\) identical rows around is a nuisance, so to_dataframe() merges each cluster to one representative row by default (merge_multiplicities=True); the multiplicity column still records the \(m\). The clustering is the solverโ€™s own (the same C++ test that computes multiplicity), not a re-derivation here. Pass merge_multiplicities=False to get every endpoint:

assert len(solver.to_dataframe(merge_multiplicities=False)) == 6     # the node counted twice

The last column, system, is a reference to the (target) system these solutions satisfy โ€“ cheap, since every row shares the one object rather than a copy, and the natural key when you stack the frames from several solves into one database and need to know which solve each row came from.

assert df['system'].iloc[0] is df['system'].iloc[-1]   # one shared reference, not a per-row copy

Now each category is a filter on the frame โ€“ and the metadata rides along with the points, so you never have to line up two parallel lists by index:

real_simple    = df[df.is_real & ~df.is_singular]      # transverse real crossings
complex_simple = df[~df.is_real & ~df.is_singular]      # a complex-conjugate pair
singular       = df[df.is_singular]                     # the node, one row, multiplicity 2

assert len(real_simple) == 2
assert len(complex_simple) == 2
assert len(singular) == 1 and (singular.multiplicity == 2).all()

(The three paths that diverged are not in the frame; solver.to_dataframe(omit_infinite=False) keeps them, and solver.infinite_solutions() returns just those at-infinity endpoints.)

The catch: a complex root does not live in the planeยถ

Here is the honest difficulty in drawing these solutions. A solution is a pair \((x, y)\) of complex numbers โ€“ four real numbers โ€“ but the page has only two axes. Only the real solutions are genuine points of the real \((x, y)\) plane; the complex ones are not there at all. So we draw two different kinds of picture.

To plot, pull the two coordinates out of the solution cell โ€“ each is a bertini.complex_mp โ€“ and lift them to Python complex so we can take real and imaginary parts. This is the โ€œsplit it yourselfโ€ step: one column per coordinate, made on demand.

df['x'] = [complex(p[0]) for p in df.solution]
df['y'] = [complex(p[1]) for p in df.solution]
df['x_re'], df['x_im'] = df.x.map(lambda z: z.real), df.x.map(lambda z: z.imag)
df['y_re'], df['y_im'] = df.y.map(lambda z: z.real), df.y.map(lambda z: z.imag)

real_simple, singular = df[df.is_real & ~df.is_singular], df[df.is_singular]

The real planeยถ

The two curves and the real roots live here. Draw the curves as zero-contours and drop the real solutions on top โ€“ circles for the transverse crossings, a star for the singular node:

import matplotlib.pyplot as plt

gx, gy = np.meshgrid(np.linspace(-2.4, 2.4, 500), np.linspace(-3.6, 3.6, 500))
fig, ax = plt.subplots(figsize=(6, 6))
ax.contour(gx, gy, gy**2 - gx**3 - gx**2, levels=[0], colors='C0')   # y^2 = x^3 + x^2
ax.contour(gx, gy, gy - gx**3 + 2*gx,     levels=[0], colors='C1')   # y = x^3 - 2x
ax.scatter(real_simple.x_re, real_simple.y_re, c='k', marker='o', s=70, label='real, simple')
ax.scatter(singular.x_re,    singular.y_re,    c='r', marker='*', s=300, label='real, singular')
ax.legend(); ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_aspect('equal')
plt.show()
../../../_images/solution_dataframe_real_plane.svg

The two curves and the real roots. The complex roots are simply absent โ€“ they are not points of this plane.ยถ

The complex planesยถ

To see every root โ€“ complex ones included โ€“ give each coordinate its own complex plane. One panel plots every solutionโ€™s \(x\)-value as a point in \(\mathbb{C}\) (real part against imaginary part); the other does the same for \(y\). Real roots land on the horizontal (real) axis; a complex-conjugate pair sits mirror-imaged across it. The same pandas subsets color the points by category:

complex_simple = df[~df.is_real & ~df.is_singular]
cats = [('real, simple',    real_simple,    'k',  'o',  70),
        ('complex, simple', complex_simple, 'C2', 's',  70),
        ('singular',        singular,       'r',  '*', 300)]

fig, axes = plt.subplots(1, 2, figsize=(11, 5))
for ax, (re, im, title) in zip(axes, [('x_re', 'x_im', 'x in C'), ('y_re', 'y_im', 'y in C')]):
    ax.axhline(0, color='0.8'); ax.axvline(0, color='0.8')
    for label, sub, color, marker, size in cats:
        ax.scatter(sub[re], sub[im], c=color, marker=marker, s=size, label=label)
    ax.set_title(title); ax.set_xlabel('real part'); ax.set_ylabel('imaginary part')
axes[0].legend()
plt.show()
../../../_images/solution_dataframe_complex_planes.svg

Each coordinate in its own complex plane. The two real roots lie on the real axis; the complex pair is mirrored across it; the node (a real point) sits on the axis with multiplicity two.ยถ

Why this scalesยถ

Nothing above touched a path index or a parallel metadata list โ€“ the DataFrame carries the points and their classification together, so a category is a filter and a plot is the filtered columns. The same table is the natural unit to accumulate: concatenate the frames from many related solves (a parameter sweep, the stages of a decomposition) and you have a growing database of solutions to query, group, and plot with the whole of pandas at hand.

Complete exampleยถ

The whole tutorial as one runnable script โ€“ it saves the figure(s) shown above:

solution_dataframe.pyยถ
"""A database of solutions: filtering and plotting with pandas.

Assembled from the ``.. testcode::`` blocks of the solution_dataframe tutorial.
Solves the intersection of two plane curves (a nodal cubic and a second cubic
threaded through its node), lays the solve out as a pandas DataFrame, filters the
solutions by category, and draws two figures: the real plane and the complex planes.

Run:  python solution_dataframe.py
"""

import os

import matplotlib
matplotlib.use('Agg')            # headless: no display needed
import matplotlib.pyplot as plt

import numpy as np
import pandas as pd

import bertini
from bertini.nag_algorithm import ZeroDimSolver

_OUT = os.path.dirname(os.path.abspath(__file__))


def two_plane_curves():
    """Build and solve the intersection of the two cubics."""
    x, y = bertini.Variable('x'), bertini.Variable('y')
    sys = bertini.System()
    sys.add_function(y*y - x**3 - x**2)     # nodal cubic   y^2 = x^3 + x^2  (node at the origin)
    sys.add_function(y - x**3 + 2*x)        # second cubic  y = x^3 - 2x     (passes through it)
    sys.add_variable_group(bertini.VariableGroup([x, y]))

    solver = ZeroDimSolver(sys, mptype='adaptive')
    solver.solve()
    return solver


def one_row_per_solution(solver):
    """Lay the solve out as a DataFrame, one row per distinct finite solution."""
    df = solver.to_dataframe()
    assert len(df) == 5                         # five distinct finite solutions
    assert {'solution', 'is_real', 'is_singular', 'multiplicity'} <= set(df.columns)
    assert len(df['solution'].iloc[0]) == 2     # each cell is the whole (x, y) point

    assert len(solver.to_dataframe(merge_multiplicities=False)) == 6     # the node counted twice

    assert df['system'].iloc[0] is df['system'].iloc[-1]   # one shared reference, not a per-row copy

    # each category is a filter on the frame
    real_simple    = df[df.is_real & ~df.is_singular]      # transverse real crossings
    complex_simple = df[~df.is_real & ~df.is_singular]      # a complex-conjugate pair
    singular       = df[df.is_singular]                     # the node, one row, multiplicity 2

    assert len(real_simple) == 2
    assert len(complex_simple) == 2
    assert len(singular) == 1 and (singular.multiplicity == 2).all()

    return df


def split_coordinates(df):
    """Pull the two coordinates out of the solution cell into their own columns."""
    df['x'] = [complex(p[0]) for p in df.solution]
    df['y'] = [complex(p[1]) for p in df.solution]
    df['x_re'], df['x_im'] = df.x.map(lambda z: z.real), df.x.map(lambda z: z.imag)
    df['y_re'], df['y_im'] = df.y.map(lambda z: z.real), df.y.map(lambda z: z.imag)

    real_simple, singular = df[df.is_real & ~df.is_singular], df[df.is_singular]
    return real_simple, singular


def plot_real_plane(real_simple, singular):
    """The two curves and the real roots, in the real (x, y) plane."""
    gx, gy = np.meshgrid(np.linspace(-2.4, 2.4, 500), np.linspace(-3.6, 3.6, 500))
    fig, ax = plt.subplots(figsize=(6, 6))
    ax.contour(gx, gy, gy**2 - gx**3 - gx**2, levels=[0], colors='C0')   # y^2 = x^3 + x^2
    ax.contour(gx, gy, gy - gx**3 + 2*gx,     levels=[0], colors='C1')   # y = x^3 - 2x
    ax.scatter(real_simple.x_re, real_simple.y_re, c='k', marker='o', s=70, label='real, simple')
    ax.scatter(singular.x_re,    singular.y_re,    c='r', marker='*', s=300, label='real, singular')
    ax.legend(); ax.set_xlabel('x'); ax.set_ylabel('y'); ax.set_aspect('equal')
    fig.savefig(os.path.join(_OUT, 'solution_dataframe_real_plane.svg'))
    fig.savefig(os.path.join(_OUT, 'solution_dataframe_real_plane.png'), dpi=150)


def plot_complex_planes(df, real_simple, singular):
    """Each coordinate in its own complex plane, colored by category."""
    complex_simple = df[~df.is_real & ~df.is_singular]
    cats = [('real, simple',    real_simple,    'k',  'o',  70),
            ('complex, simple', complex_simple, 'C2', 's',  70),
            ('singular',        singular,       'r',  '*', 300)]

    fig, axes = plt.subplots(1, 2, figsize=(11, 5))
    for ax, (re, im, title) in zip(axes, [('x_re', 'x_im', 'x in C'), ('y_re', 'y_im', 'y in C')]):
        ax.axhline(0, color='0.8'); ax.axvline(0, color='0.8')
        for label, sub, color, marker, size in cats:
            ax.scatter(sub[re], sub[im], c=color, marker=marker, s=size, label=label)
        ax.set_title(title); ax.set_xlabel('real part'); ax.set_ylabel('imaginary part')
    axes[0].legend()
    fig.savefig(os.path.join(_OUT, 'solution_dataframe_complex_planes.svg'))
    fig.savefig(os.path.join(_OUT, 'solution_dataframe_complex_planes.png'), dpi=150)


def main():
    solver = two_plane_curves()
    df = one_row_per_solution(solver)
    real_simple, singular = split_coordinates(df)
    plot_real_plane(real_simple, singular)
    plot_complex_planes(df, real_simple, singular)


if __name__ == '__main__':
    main()