↓ Skip to main content

Polynomials with NumPy

·3534 words·17 mins·
 Author
Author
Francisco Bustamante
Chemist, data scientist and Python programmer.
Table of Contents
From Symbol to Number - This article is part of a series.
Part 5: This Article

In 1959, the English mathematician James H. Wilkinson was investigating why root-finding algorithms, considered correct on paper, behaved badly on the first digital computers. To test a hypothesis, he built a deliberately harmless polynomial, now known as Wilkinson’s polynomial:

$$ (x-1)(x-2)(x-3)\cdots(x-20) $$

Degree 20, twenty integer roots, from 1 to 20, all well separated from one another. There is no more “polite” polynomial than this one, or so it seemed. Wilkinson expanded the product into its 21 coefficients and fed them to a numerical algorithm.

And those coefficients are monstrous. The constant term is \(20!\), a 19-digit integer; the largest of all, the coefficient of \(x^2\), exceeds \(1.38 \times 10^{19}\). Keep that number in mind: the largest integer that a float64 represents without any error is \(2^{53} \approx 9 \times 10^{15}\), about a thousand times smaller. In other words, a good part of these coefficients does not even fit exactly in a float: the damage starts before any algorithm runs.

The result left Wilkinson perplexed enough that, decades later, he described that moment as “the most traumatic experience in my career as a numerical analyst”.

What happened? That is what we will reconstruct in this article, with real code, after learning to handle polynomials as objects in NumPy.

Why Do Polynomials as Objects Matter?
#

  • Numerical analysis and scientific computing: any method that approximates a function by a polynomial (interpolation, quadrature, differential equation solvers) depends on knowing that high-degree polynomials can amplify small errors dramatically, the very problem Wilkinson discovered.
  • Control engineering: the stability of a linear system (electrical, mechanical) usually boils down to where the roots of a characteristic polynomial are. Knowing that these roots can be sensitive to measurement errors in the coefficients is a practical concern.
  • Computer graphics: Bézier curves and splines, used in most vector editors and game engines, are polynomials handled numerically: evaluated, differentiated, and fitted thousands of times per second.
  • Signal processing: digital filters are described by a ratio of polynomials (the transfer function); the location of the roots of the denominator polynomial (the poles) determines whether the filter is stable.

SymPy Polynomials, Revisited in NumPy
#

In the article Polynomials with SymPy we saw the Poly class, which treats a polynomial as an object (with methods for coefficients, degree, roots, derivative and integral) instead of a mere symbolic expression. NumPy has its equivalent: numpy.polynomial.Polynomial. The essential difference is that here the coefficients are not exact symbols but floating-point numbers (float64), which seems like an irrelevant detail and, as we will see at the end of this article, is not.

Example 1: Recreating a polynomial from the SymPy article

Statement: The SymPy article used \(Q(x) = x^2 - 25\) as a recurring example. Build the same polynomial with numpy.polynomial.Polynomial and compare the coefficients with SymPy’s.

Solution: Polynomial takes the coefficients in increasing order of degree, that is, the first element is the constant term, not the one of highest degree (more on this in the next section):

import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
Q_sym = sympy.Poly(x**2 - 25, x)
Q_np = Polynomial([-25, 0, 1])  # -25 + 0x + 1x^2

print("SymPy all_coeffs (descending):", Q_sym.all_coeffs())
print("NumPy .coef (ascending):", Q_np.coef)
SymPy all_coeffs (descending): [1, 0, -25]
NumPy .coef (ascending): [-25.   0.   1.]

Same polynomial, two opposite orders for storing the coefficients.

Trap No. 1: Coefficient Order
#

Three different conventions, same polynomial

NumPy actually has two ways of representing a polynomial, and neither of them agrees with SymPy:

Representation Coefficient order Example for \(Q(x)=x^2-25\)
numpy.poly1d (legacy) Descending [1, 0, -25]
numpy.polynomial.Polynomial (modern) Ascending [-25, 0, 1]
sympy.Poly.all_coeffs() Descending [1, 0, -25]

poly1d is NumPy’s old API: it still works, but it is considered legacy; the documentation recommends Polynomial for new code. The risk lies in mixing them without noticing, since no convention is the “right” one: passing the coefficient list the wrong way to the wrong class raises no error at all and silently returns a different polynomial.

As a check, the two NumPy constructors arrive at the same roots for the same \(Q(x)\), despite the opposite coefficient order:

import numpy as np
from numpy.polynomial import Polynomial

Q_np = Polynomial([-25, 0, 1])  # -25 + 0x + 1x^2
p_legacy = np.poly1d([1, 0, -25])  # descending
print("poly1d.roots:", p_legacy.roots)
print("Polynomial.roots():", Q_np.roots())
poly1d.roots: [-5.  5.]
Polynomial.roots(): [-5.  5.]

Roots, Including Complex Ones
#

Example 2: Roots of a degree-3 polynomial

Statement: The SymPy article also used \(A(x) = x^3 + 2x + 3\), which has one real root and two complex ones. Find the roots with Polynomial.roots().

Solution:

from numpy.polynomial import Polynomial

A_np = Polynomial([3, 2, 0, 1])  # 3 + 2x + 0x^2 + 1x^3
print(A_np.roots())
[-1. +0.j         0.5-1.6583124j  0.5+1.6583124j]

SymPy needed two separate methods for this (all_roots() for all the roots, real_roots() for the real ones only), because, for SymPy, a real root and a complex root are mathematically distinct objects. Polynomial.roots() makes no such distinction and returns a single numeric array, real when all the roots are real and complex (with zero imaginary part in the real roots) when there is any complex root, because for NumPy a polynomial is, above all, an array of numeric coefficients, the same philosophy as numpy.roots already used in Solving Equations Numerically with SciPy, now applied to polynomials as objects.

Derivative, Integral and Division
#

Example 3: Derivative, integral and division of polynomials

Statement: Compute the derivative of \(Q(x)=x^2-25\) at \(x=2\), the integral of \(Q(x)\) at \(x=1\) (integration constant zero), and the quotient and remainder of dividing \(A(x)=x^3+2x+3\) by \(Q(x)\).

Solution:

from numpy.polynomial import Polynomial

Q_np = Polynomial([-25, 0, 1])   # -25 + x^2
A_np = Polynomial([3, 2, 0, 1])  # 3 + 2x + x^3

print("Q'(2):", Q_np.deriv()(2))
print("integral of Q up to 1:", Q_np.integ()(1))

quotient, remainder = divmod(A_np, Q_np)
print("quotient:", quotient)
print("remainder:", remainder)
Q'(2): 4.0
integral of Q up to 1: -24.666666666666668
quotient: 0.0 + 1.0·x
remainder: 3.0 + 27.0·x

The same results as in the SymPy article: \(Q^{\prime}(2)=4\), \(\int_0^1 Q dx = -\frac{74}{3} \approx -24.667\), and dividing \(A\) by \(Q\) gives quotient \(x\) and remainder \(27x+3\). As always in a division, \(Q \cdot \text{quotient} + \text{remainder} = A\).

divmod here is Python’s built-in divmod

divmod(A_np, Q_np) is not a NumPy function: it is the language’s built-in divmod(). It works because Polynomial implements the special method __divmod__ (along with +, -, *, //, %, **, ==), returning two new Polynomial objects (quotient and remainder). The NumPy documentation on the polynomial convenience classes explicitly lists these overloaded operators.

Where Did SymPy’s Exactness Go?
#

In the SymPy introduction article, the integer coefficients of a polynomial were exact: sympy.Integer, with no rounding at all. Here, Q_np.coef is already numpy.float64 from the moment of construction. For a low-degree polynomial like \(Q\) or \(A\), this changes nothing visible. But as the degree grows, the difference between “exact” and “floating point” stops being cosmetic; it is time to go back to the polynomial that opened this article.

Back to Wilkinson’s Polynomial
#

What Wilkinson discovered has a name, and it is worth defining it before reproducing the experiment:

Ill-conditioning

A numerical problem is ill-conditioned when a tiny change in the input data produces a disproportionately large change in the answer, even using a perfectly correct algorithm. It is not a code bug; it is a property of the problem itself.

Time to reproduce Wilkinson’s experiment, step by step.

Step 1: the exact coefficients, with SymPy
#

First, we expand \((x-1)(x-2)\cdots(x-20)\) symbolically (the same tool used in the previous articles of the series) to get the 21 coefficients with no rounding error:

import sympy

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
wilkinson_poly_sym = sympy.Poly(wilkinson_expr, x)

coefs_desc = wilkinson_poly_sym.all_coeffs()  # descending, exact integers
print("coefficient of x^19:", coefs_desc[1])
print("constant term (20!):", coefs_desc[-1])
coefficient of x^19: -210
constant term (20!): 2432902008176640000

Notice the size of these numbers: the constant term is already \(20!\), a 19-digit integer. All 21 coefficients, at this point, are still exact.

The following figure shows the magnitude of all of them, on a logarithmic scale, with the line of the largest integer that a float64 stores without any error:

import matplotlib.pyplot as plt
import numpy as np
import sympy

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
magnitudes = [abs(int(c)) for c in reversed(coefs_desc)]  # ascending, still exact

degrees = np.arange(21)
fig, ax = plt.subplots(figsize=(8, 5))
ax.bar(degrees, [float(m) for m in magnitudes], color="C0")
ax.axhline(2.0**53, color="C1", linestyle="--", linewidth=2,
           label=r"$2^{53}$: largest exact integer in float64")
ax.set_yscale("log")
ax.set_xlabel("degree of the term")
ax.set_ylabel(r"$|$coefficient$|$")
ax.legend()

print(f"{sum(m > 2**53 for m in magnitudes)} of the 21 coefficients exceed 2^53")
10 of the 21 coefficients exceed 2^53

Bar chart on a logarithmic scale with the magnitude of the twenty-one coefficients of Wilkinson’s polynomial: the bars rise from degree 20 to a peak near degree 2, and a horizontal dashed line marks two to the power of fifty-three, with ten of the bars above it
Ten of the twenty-one coefficients exceed 2⁵³, the largest integer that a float64 represents exactly. The damage starts at the conversion, before any algorithm runs.

Ten of the twenty-one coefficients are above the line. Each of them already reaches float64 with error, not because of any algorithm, but because the format cannot hold the number.

Step 2: converting to float64, and error already appears
#

Now comes the part that any NumPy Polynomial requires: converting these exact integers to float64. That alone is already a perturbation:

import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers

wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

With the polynomial built, we compute the roots and compare them with the expected values (the integers from 1 to 20):

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

roots = W.roots()
max_error = np.max(np.abs(np.sort(roots.real) - np.arange(1, 21)))
print(f"maximum error, with no deliberate perturbation: {max_error:.4f}")
print("roots computed for the expected values 14 and 15:")
print(np.sort_complex(roots)[13:15])
maximum error, with no deliberate perturbation: 0.5185
roots computed for the expected values 14 and 15:
[14.518475-0.20853241j 14.518475+0.20853241j]

It is worth rereading that output carefully, because it is more dramatic than it looks: the roots stopped being real. Where two integers should be, \(14\) and \(15\), there is a pair of complex conjugates, \(14.518 \pm 0.209i\).

And none of this was perturbed on purpose. The only “insult” to the polynomial was converting its exact integer coefficients to float64, which is unavoidable, since it is what any NumPy Polynomial requires.

Beware of .roots().real

It is tempting to write W.roots().real to “clean up” the result. But .real silently discards the imaginary part: the conjugate pair above becomes two identical values, \(14.518\) and \(14.518\), and the reader concludes that the roots merely “collided”. The most important information, that they left the real axis, vanishes without warning.

When you want only the truly real roots, filter instead of truncating: roots[np.abs(roots.imag) < tol].real.

To measure how fragile this is, here is an experiment that perturbs nothing on purpose: building the same polynomial by two different paths. One is the one we just used (expand with SymPy, convert to float64); the other is Polynomial.fromroots, which multiplies the factors directly in floating point.

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

W_via_sympy = W
W_via_fromroots = Polynomial.fromroots(np.arange(1, 21))

print("are the coefficients equal?", np.allclose(W_via_sympy.coef, W_via_fromroots.coef))
print("largest relative difference:",
      np.max(np.abs(W_via_sympy.coef - W_via_fromroots.coef) /
             np.maximum(np.abs(W_via_sympy.coef), 1)))
print()
print("roots 14/15, via SymPy    :", np.sort_complex(W_via_sympy.roots())[13:15])
print("roots 14/15, via fromroots:", np.sort_complex(W_via_fromroots.roots())[13:15])
are the coefficients equal? True
largest relative difference: 1.5911824568028567e-16

roots 14/15, via SymPy    : [14.518475-0.20853241j 14.518475+0.20853241j]
roots 14/15, via fromroots: [14.51819694-0.20459637j 14.51819694+0.20459637j]

Read these numbers slowly. The coefficients of the two polynomials differ, at most, by \(1.6\times10^{-16}\) relative, that is, by a single bit in the last digit of the float64. It is the smallest difference that exists between two different numbers in this representation; any smaller and they would be identical.

And the roots differ in the fourth decimal place: the imaginary part goes from \(0.2085\) to \(0.2046\), almost \(2\%\).

Neither path is wrong, and no algorithm failed. Simply, in float64, there is no single answer to the question “what are the roots of this polynomial”: the question is too sensitive for the format in which it was asked. It is this phenomenon that has a name, and it is what we will perturb on purpose next, to measure it.

Step 3: Wilkinson’s classic perturbation
#

Now, deliberately, we decrease a single coefficient, the one of \(x^{19}\), which is exactly \(-210\), by just \(2^{-23} \approx 1.19\times10^{-7}\). This exponent is the machine epsilon of single precision, the 32-bit format with 23 significand bits that we detailed in the article on IEEE 754. It is an error smaller than that of any reasonable experimental measurement.

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

perturbed_coefs = np.array(wilkinson_coefs, dtype=float)
perturbed_coefs[19] -= 2**-23
W_perturbed = Polynomial(perturbed_coefs)

Step 4: what happened to the roots
#

With the perturbed polynomial built, we recompute the roots and compare them with the original ones:

import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

perturbed_coefs = np.array(wilkinson_coefs, dtype=float)
perturbed_coefs[19] -= 2**-23
W_perturbed = Polynomial(perturbed_coefs)

perturbed_roots = W_perturbed.roots()
max_imag_part = np.max(np.abs(perturbed_roots.imag))
real_roots = perturbed_roots[np.abs(perturbed_roots.imag) < 1e-6].real
root_that_was_20 = real_roots[np.argmin(np.abs(real_roots - 20))]

print(f"largest imaginary part that appeared: {max_imag_part:.4f}")
print(f"root that was 20 became: {root_that_was_20:.4f}")
largest imaginary part that appeared: 2.8127
root that was 20 became: 20.8469

Ten of the twenty roots stop being real. The root that was exactly 20 rises to \(\approx 20.85\), a deviation of \(4.2\%\) caused by a perturbation of \(10^{-7}\) in a single coefficient out of twenty.

Step 5: visualizing the scattering of the roots
#

The following plot puts side by side the original roots, all real (circles, aligned on the x axis), and the perturbed roots, scattered across the complex plane (x):

import matplotlib.pyplot as plt
import numpy as np
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

perturbed_coefs = np.array(wilkinson_coefs, dtype=float)
perturbed_coefs[19] -= 2**-23
perturbed_roots = Polynomial(perturbed_coefs).roots()

fig, ax = plt.subplots(figsize=(8, 7))
ax.scatter(
    np.arange(1, 21), np.zeros(20),
    label="theoretical roots (integers 1 to 20)", zorder=3, s=60,
)
ax.scatter(
    perturbed_roots.real, perturbed_roots.imag,
    marker="x", label="roots after perturbing 1 coefficient by $2^{-23}$",
    zorder=3, s=60,
)
ax.axhline(0, color="black", linewidth=0.8, zorder=1)
ax.set_xlabel("Real part")
ax.set_ylabel("Imaginary part")
ax.set_title("Sensitivity of the Wilkinson polynomial roots", pad=12)
ax.legend(loc="upper center", bbox_to_anchor=(0.5, -0.12), ncol=1)

Scatter plot in the complex plane: the twenty original roots of Wilkinson’s polynomial, all real and aligned on the x axis, against the perturbed roots, with half of them forming pairs of symmetric points above and below the real axis
A perturbation of 2⁻²³ in a single coefficient out of twenty throws ten roots off the real axis.

This is what Wilkinson saw in 1959, and the reason his reaction was not an exaggeration.

Why This Happens
#

So far the article has shown that it happens. The why is missing, and it fits in a formula.

Perturb the coefficient \(a_k\) by a small amount \(\delta\). Each root \(x_j\) shifts, to first order, by:

$$ \frac{\partial x_j}{\partial a_k} = -\frac{x_j^{k}}{p'(x_j)} $$

The derivation is short: if \(p(x_j) = 0\) and we perturb \(a_k\), the new root satisfies \(p(x_j + \Delta) + \delta (x_j+\Delta)^k = 0\). Expanding in a Taylor series and keeping the first order, \(p^{\prime}(x_j)\Delta + \delta x_j^k \approx 0\), which gives the ratio above.

This fraction explains everything, because its two pieces pull in opposite directions as \(j\) grows:

  • The numerator \(x_j^{19}\) explodes. For \(x_1 = 1\) it is 1; for \(x_{20} = 20\) it is \(5.2 \times 10^{24}\).
  • The denominator \(p^{\prime}(x_j)\) plummets in the middle. Since \(p^{\prime}(x_j) = \prod_{i \neq j}(x_j - x_i)\), it is the product of the 19 distances from \(x_j\) to the other roots. At the ends (\(j=1\) or \(j=20\)) this product is \(19! \approx 10^{17}\), enormous. In the middle (\(j \approx 10\)), half of the differences are small and the product drops to \(10^{12}\).
import sympy
from numpy.polynomial import Polynomial

x = sympy.Symbol("x")
wilkinson_expr = sympy.prod([x - k for k in range(1, 21)])
coefs_desc = sympy.Poly(wilkinson_expr, x).all_coeffs()  # descending, exact integers
wilkinson_coefs = [float(c) for c in reversed(coefs_desc)]
W = Polynomial(wilkinson_coefs)

dW = W.deriv()
print(f"{'j':>3} {'|p_prime(x_j)|':>15} {'x_j^19':>12} {'sensitivity':>15}")
for j in (1, 2, 5, 10, 16, 20):
    xj = float(j)
    print(f"{j:>3} {abs(dW(xj)):>15.2e} {xj**19:>12.2e} {abs(xj**19 / dW(xj)):>15.2e}")
  j  |p_prime(x_j)|       x_j^19     sensitivity
  1        1.22e+17     1.00e+00        8.22e-18
  2        6.40e+15     5.24e+05        8.19e-11
  5        3.14e+13     1.91e+13        6.08e-01
 10        1.32e+12     1.00e+19        7.60e+06
 16        3.14e+13     7.56e+22        2.41e+09
 20        1.22e+17     5.24e+24        4.31e+07

The last column varies over 27 orders of magnitude. The root \(x_1 = 1\) is essentially immune (\(8.2 \times 10^{-18}\)); the root \(x_{16}\) is \(2.4 \times 10^{9}\) times more sensitive than the applied perturbation.

The following figure shows that whole column, one bar per root:

Bar chart on a logarithmic scale with the sensitivity of each of the twenty roots: a staircase that rises from ten to the power of minus eighteen at root 1 to about ten to the power of nine at the central roots, with a horizontal dashed line marking the threshold above which the displacement exceeds half a unit
The sensitivity of each root to the coefficient of x¹⁹, on a log scale. The roots above the dashed line are the ones that the 2⁻²³ perturbation visibly displaces, and they are exactly the ones that break in the experiment.

Compare this figure with the result of the experiment: the roots that survived intact are the small ones, and the ones that became complex pairs are those from the middle up. The figure predicted which ones would break, before running roots().

This is ill-conditioning, not a bad algorithm

The distinction is the moral of the article. numpy.roots computes the eigenvalues of the companion matrix with a norm-wise stable algorithm (backward stable): the result it returns is the exact answer to a problem very close to the one you passed. It does not err: it answers accurately a question slightly different from yours.

For well-conditioned problems, that is enough: the exact answer to a nearby problem is a nearby answer. For Wilkinson’s polynomial, it is not enough, because “close in the coefficients” and “close in the roots” stop being the same thing. Changing the algorithm would not help; the problem is in the question, not in who answers it.

It Is Not Just Proximity: Multiplicity Too
#

It might seem that the problem is having roots that are “too close”. The opposite extreme shows that it is not:

import numpy as np
from numpy.polynomial import Polynomial

P_mult = Polynomial.fromroots(np.ones(20))   # (x-1)^20
mult_roots = P_mult.roots()
print(f"radius of the scatter around 1: {np.max(np.abs(mult_roots - 1)):.4f}")
print("how many stopped being real:", int((np.abs(mult_roots.imag) > 1e-8).sum()))
radius of the scatter around 1: 0.4352
how many stopped being real: 18

\((x-1)^{20}\) has one root, repeated 20 times. roots() returns 20 values scattered on a circle of radius \(0.44\) around 1, and 18 of them are not even real. It makes sense by the same formula: at a multiple root, \(p^{\prime}(x_j) = 0\) exactly, and the sensitivity is infinite.

The Practical Lesson
#

High-degree roots seem impossible to tame, but the formula suggests the opposite. It points to the culprit, and the culprit is avoidable:

Do not expand to find roots

The loss happens at the moment the factors \((x-1)(x-2)\cdots\) become a list of coefficients. Before that, the roots were written in plain sight. Practical rules:

  • Keep the factored form whenever it exists. Polynomial.fromroots and roots() are inverse operations, but the forward path loses information.
  • Prefer another basis. The power basis \(1, x, x^2, \ldots\) is terrible on intervals far from the origin. The next article shows that the same polynomial, written in the Chebyshev basis with the domain mapped to \([1, 20]\), becomes millions of times less sensitive.
  • If the problem comes from a matrix (eigenvalues, system stability), solve the matrix directly instead of building and solving the characteristic polynomial. The path through the polynomial destroys conditioning for free.

In other words: Wilkinson’s ill-conditioning is not a property of the set of roots \(\{1, \ldots, 20\}\). It is a property of representing it as a list of coefficients in the power basis.

Summary Table
#

Operation Syntax Note
Construction Polynomial([c0, c1, ..., cn]) Ascending order of degree
Construction (legacy) numpy.poly1d([cn, ..., c1, c0]) Descending order, avoid in new code
Roots .roots() Real array if all roots are real; complex if there is any complex root
Derivative .deriv() Returns another Polynomial
Integral .integ() Integration constant zero by default
Division divmod(A, B) Returns (quotient, remainder)
Exact coefficients Does not exist Every coefficient is float64; ill-conditioned at high degree (Wilkinson)

Next Steps
#

This article covered polynomials as objects we already knew, built from coefficients or roots we had in advance. But most real-world data does not come with a polynomial attached: it comes as a cloud of noisy experimental points, and it is up to us to find the polynomial that best describes those points. SymPy does not do that: there is no symbolic regression. In the next article, we use Polynomial.fit() to fit curves to real data from chemistry, physics and engineering, and we see why a higher degree is not always a better fit: Runge’s phenomenon, a sort of cousin of Wilkinson’s ill-conditioning, but for interpolation instead of roots.

From Symbol to Number - This article is part of a series.
Part 5: This Article

Related

Numerical Integration with SciPy

The Gaussian function has no elementary antiderivative, and that is a theorem of Liouville. When the antiderivative does not exist, or all you have is a table of measurements, the way forward is numerical: quad, trapezoid and Simpson, from scipy.integrate, with the care each one demands.

Curve Fitting with NumPy

·5574 words·27 mins
Experimental data rarely falls on a curve. We will see how NumPy fits polynomials to noisy data, what SymPy cannot do, why extrapolating is dangerous, and why Runge’s phenomenon comes from equally spaced nodes, not from high degree.