🔬 Precision models: double, multiple, adaptive

Bertini can track in three precision models, and ZeroDimSolver selects between them with mptype:

  • 'double' – hardware double precision (≈16 digits). Fastest; fine when the paths are well-conditioned.

  • 'multiple' – a fixed multiprecision, the same number of digits throughout.

  • 'adaptive' – adaptive multiprecision (AMP, the default): the precision rises and falls along each path as the conditioning demands. The most robust, and what makes a near-singular path solvable.

This tutorial is about how the three models differ in the interface – the types you get back and how you set the precision. For why and when you reach for adaptive precision (the conditioning story), see 🎚️ When double precision is not enough.

x, y = bertini.Variable('x'), bertini.Variable('y')
system = bertini.System()
system.add_function(x*x + y*y - 1)
system.add_function(x + y)
system.add_variable_group(bertini.VariableGroup([x, y]))

names = {mptype: type(bertini.ZeroDimSolver(system, mptype=mptype)).__name__
         for mptype in ('double', 'multiple', 'adaptive')}
assert names['double']   == 'ZeroDimSolverCauchyDoublePrecision'
assert names['multiple'] == 'ZeroDimSolverCauchyFixedMultiplePrecision'
assert names['adaptive'] == 'ZeroDimSolverCauchyAdaptivePrecision'

# The solver type encodes the endgame and the precision model, but NOT the start system -- that is
# a construction choice now (the algorithm holds the start system polymorphically), so it no longer
# appears as a suffix on the class name.

Reading solutions: convert with complex()

The model changes the type of the numbers you get back. A double solve returns NumPy complex128; a multiprecision solve returns arrays of bertini.complex_mp (NumPy arrays with dtype complex_mp). The portable habit – works in every model – is to convert each coordinate with complex():

bertini.random.set_random_seed(2)

dbl = bertini.ZeroDimSolver(system, mptype='double'); dbl.solve()
assert dbl.all_solutions()[0].dtype == np.complex128

amp = bertini.ZeroDimSolver(system, mptype='adaptive'); amp.solve()
assert str(amp.all_solutions()[0].dtype) == 'complex_mp'     # bertini.complex_mp

# the same code reads either one:
def to_python(solution):
    return np.array([complex(c) for c in solution])

for solver in (dbl, amp):
    pts = sorted(tuple(np.round(to_python(s).real, 4)) for s in solver.all_solutions())
    assert pts == [(-0.7071, 0.7071), (0.7071, -0.7071)]

Setting the precision

A fixed multiple solve works at one precision everywhere: the system, the tracker, and the points must all agree. So set the default precision and lift the system to it before constructing the solver:

bertini.default_precision(40)                  # 40 digits for this solve
system.precision(40)                           # the system must match
m = bertini.ZeroDimSolver(system, mptype='multiple')
m.solve()
assert len(m.all_solutions()) == 2

bertini.default_precision(30)                  # restore a modest default
system.precision(30)

(Mismatch is reported, not silently tolerated: constructing a multiple-precision solve whose system sits at a different precision raises rather than tracking at the wrong precision.)

An adaptive solve manages precision itself; its knobs live in the AMP config – most usefully maximum_precision, the ceiling above which a path is declared to have failed:

from bertini.tracking import AMPConfig
amp = bertini.ZeroDimSolver(system, mptype='adaptive')
assert amp.get_tracker().get_config(AMPConfig).maximum_precision == 300
amp.get_tracker().update(maximum_precision=200)     # tighten the ceiling
assert amp.get_tracker().get_config(AMPConfig).maximum_precision == 200

The config surface is the same shape across models

Apart from the precision-specific tracker knobs (the amp config exists only for an adaptive tracker), the configs are the same across precision models – tolerances, zero_dim, stepping, and so on are not precision-stamped. That is exactly why a settings bundle carries between models (see ⚙️ Carrying settings across related solves):

shared = {'tolerances', 'zero_dim', 'post_processing', 'auto_retrack'}
for mptype in ('double', 'multiple', 'adaptive'):
    names = set(bertini.ZeroDimSolver(system, mptype=mptype).config_names())
    assert shared <= names

So the rule of thumb: reach for 'adaptive' by default; drop to 'double' for speed when the problem is well-conditioned; use 'multiple' when you want a fixed, known precision throughout. And read every solution through complex() so your code does not care which model produced it.

Complete example

The whole tutorial as one runnable script – assemble nothing, just run it:

precision_models.py
"""Precision models: double, multiple, adaptive -- Bertini 2 tutorial.

The three precision models differ in the interface: the types you get back
and how you set the precision.
Run:  python precision_models.py
"""

import numpy as np
import bertini


def build_system():
    """Build the polynomial system used throughout the tutorial."""
    x, y = bertini.Variable('x'), bertini.Variable('y')
    system = bertini.System()
    system.add_function(x*x + y*y - 1)
    system.add_function(x + y)
    system.add_variable_group(bertini.VariableGroup([x, y]))
    return system


def solver_types(system):
    """mptype selects the solver class (endgame + precision model)."""
    names = {mptype: type(bertini.ZeroDimSolver(system, mptype=mptype)).__name__
             for mptype in ('double', 'multiple', 'adaptive')}
    assert names['double']   == 'ZeroDimSolverCauchyDoublePrecision'
    assert names['multiple'] == 'ZeroDimSolverCauchyFixedMultiplePrecision'
    assert names['adaptive'] == 'ZeroDimSolverCauchyAdaptivePrecision'


def reading_solutions(system):
    """The model changes the type of the numbers; convert with complex()."""
    bertini.random.set_random_seed(2)

    dbl = bertini.ZeroDimSolver(system, mptype='double'); dbl.solve()
    assert dbl.all_solutions()[0].dtype == np.complex128

    amp = bertini.ZeroDimSolver(system, mptype='adaptive'); amp.solve()
    assert str(amp.all_solutions()[0].dtype) == 'complex_mp'     # bertini.complex_mp

    # the same code reads either one:
    def to_python(solution):
        return np.array([complex(c) for c in solution])

    for solver in (dbl, amp):
        pts = sorted(tuple(np.round(to_python(s).real, 4)) for s in solver.all_solutions())
        assert pts == [(-0.7071, 0.7071), (0.7071, -0.7071)]


def setting_precision(system):
    """Fixed multiple works at one precision everywhere; adaptive manages its own."""
    bertini.default_precision(40)                  # 40 digits for this solve
    system.precision(40)                           # the system must match
    m = bertini.ZeroDimSolver(system, mptype='multiple')
    m.solve()
    assert len(m.all_solutions()) == 2

    bertini.default_precision(30)                  # restore a modest default
    system.precision(30)

    from bertini.tracking import AMPConfig
    amp = bertini.ZeroDimSolver(system, mptype='adaptive')
    assert amp.get_tracker().get_config(AMPConfig).maximum_precision == 300
    amp.get_tracker().update(maximum_precision=200)     # tighten the ceiling
    assert amp.get_tracker().get_config(AMPConfig).maximum_precision == 200


def shared_config_surface(system):
    """Apart from precision-specific knobs, the configs are the same across models."""
    shared = {'tolerances', 'zero_dim', 'post_processing', 'auto_retrack'}
    for mptype in ('double', 'multiple', 'adaptive'):
        names = set(bertini.ZeroDimSolver(system, mptype=mptype).config_names())
        assert shared <= names


def main():
    system = build_system()
    solver_types(system)
    reading_solutions(system)
    setting_precision(system)
    shared_config_surface(system)


if __name__ == '__main__':
    main()