Skip to content

Polynomials, Horner, stable quadratic roots

ModuleM00.4 · build · Python · Pass 2 · 2 to 3 h
You buildpython/tinyllm/num/poly.py: horner, quadratic_roots
Contractcourse/contracts/py/tinyllm/num/poly.pyi
Testscourse/tests/M00.4/test_poly.py (what they check: section 4)
Needsnothing to call. Reading: M00.1 (exponents), lang.01 (numpy arrays)
Used byM02.1 evaluates its range-reduced Taylor polynomial for exe^x with horner · later M09.6 ports that code to C as tl_expf
MilestoneMS-P2 (the Pass 2 gate)
Optional depthOpenStax, College Algebra 2e (free), ch. 5 (polynomial functions); Higham, Accuracy and Stability of Numerical Algorithms, ch. 5 (Horner’s error bound); Press et al., Numerical Recipes, section 5.6 (quadratic and cubic equations)
  • A polynomial is its list of coefficients; in this course they are stored in ascending order of power, so coeffs[k] multiplies xkx^k (test_coefficients_are_ascending).
  • Horner’s rule evaluates a degree-nn polynomial with nn multiplications and nn additions by nesting, c0+x(c1+x(c2+⋯ ))c_0 + x(c_1 + x(c_2 + \cdots)), and its error is bounded by γ2n∑k∣ck∣∣x∣k\gamma_{2n}\sum_k |c_k||x|^k (test_hand_example_horner, test_horner_within_error_bound).
  • On integer coefficients and points it is exact as long as every partial result stays below 2532^{53} (test_horner_exact_on_integer_polynomials).
  • The textbook quadratic formula cancels when b2≫4acb^2 \gg 4ac: the small root comes out with few or no correct digits. Computing q=−(b+sign⁡(b)b2−4ac)/2q = -(b + \operatorname{sign}(b)\sqrt{b^2 - 4ac})/2 and the roots q/aq/a and c/qc/q never subtracts nearly equal numbers (test_hand_example_roots, test_roots_golden_cancellation).
  • Two edge cases break naive code: sign⁡(0)\operatorname{sign}(0) must count as +1+1, and b2b^2 overflows for ∣b∣≈10200|b| \approx 10^{200} unless the coefficients are scaled first (test_b_zero, test_huge_b_does_not_overflow).
Terminal window
ol start M00.4 # stubs python/tinyllm/num/poly.py into your repo
ol tests M00.4 # read the test catalog first: rung R0, you write no tests here
ol check M00.4 # exit code is the verdict
ol diff M00.4 # after passing: your code against the reference

Every activation function and softmax in your models calls exe^x, and so far numpy has computed it for you. In Pass 6 your C kernels (L9.2 softmax, L9.6 SiLU) need their own tl_expf, and M09.6 builds it the way every math library does: reduce xx to a small range, then evaluate a polynomial that approximates exe^x there. M02.1 first derives those polynomials (Taylor series) and evaluates them in Python. Both evaluate with Horner’s rule, the subject of this module, and both depend on how evaluation order changes the rounding error. The second half of the module is the smallest example of the course’s numerical theme: a formula that is correct on paper and wrong in floating point. The quadratic formula you learned in school loses the small root of x2−108x+1x^2 - 10^8 x + 1 entirely, and a two-line rewrite fixes it.

SymbolMeaningType / shape
nnthe degree: the highest power with a nonzero coefficientint
c0,…,cnc_0, \ldots, c_ncoefficients, ascending: ckc_k multiplies xkx^kSequence[float]
p(x)p(x)the polynomial ∑k=0nckxk\sum_{k=0}^n c_k x^kNDArray
rra root: a number with p(r)=0p(r) = 0float
a,b,ca, b, cthe coefficients of the quadratic ax2+bx+cax^2 + bx + c, a≠0a \ne 0float
DDthe discriminant, b2−4acb^2 - 4acfloat
x1,x2x_1, x_2the two roots, x1≤x2x_1 \le x_2float
qqthe stable intermediate −12(b+sign⁡(b)D)-\tfrac12(b + \operatorname{sign}(b)\sqrt D)float
uufloat64 unit roundoff, 2−53≈1.1×10−162^{-53} \approx 1.1 \times 10^{-16}
γk\gamma_kku/(1−ku)ku/(1 - ku), the bound on kk rounded operationsfloat

A polynomial of degree nn is p(x)=c0+c1x+⋯+cnxnp(x) = c_0 + c_1 x + \cdots + c_n x^n with cn≠0c_n \ne 0. It is determined by its coefficient list, which this course writes in ascending order: [1,2,3][1, 2, 3] is 1+2x+3x21 + 2x + 3x^2. (numpy’s np.polyval takes the opposite, descending order; numpy.polynomial.polynomial.polyval takes ascending. Mixing the two is the first pitfall.)

A root is an rr with p(r)=0p(r) = 0. The factor theorem says p(r)=0p(r) = 0 exactly when (x−r)(x - r) divides pp: p(x)=(x−r) s(x)p(x) = (x - r)\,s(x) for a polynomial ss of degree n−1n - 1. Dividing out one root at a time shows a degree-nn polynomial has at most nn roots. Example: x3−6x2+11x−6x^3 - 6x^2 + 11x - 6 is 0 at x=1x = 1, and dividing by x−1x - 1 leaves x2−5x+6=(x−2)(x−3)x^2 - 5x + 6 = (x - 2)(x - 3).

Evaluating ∑ckxk\sum c_k x^k term by term computes every power separately. Factoring xx out repeatedly gives the nested form

p(x)=c0+x(c1+x(c2+⋯+x(cn−1+x cn))),p(x) = c_0 + x\Bigl(c_1 + x\bigl(c_2 + \cdots + x(c_{n-1} + x\,c_n)\bigr)\Bigr),

evaluated from the inside out:

acc = 0
for k = n, n-1, ..., 0: # highest power first
acc = acc * x + c[k]

That is nn multiplications and nn additions (the first step multiplies 0), the fewest possible for a general polynomial, and one fused multiply-add per coefficient in C. Horner’s intermediate values are also the coefficients of the quotient p(x)/(x−r)p(x)/(x - r) when you evaluate at x=rx = r, with the remainder p(r)p(r) last: this is “synthetic division”. Starting from acc = 0 makes the empty coefficient list the zero polynomial, and works the same for a scalar xx or a whole array of points.

Each step rounds twice, once for the multiplication and once for the addition, each with relative error at most uu. Following those errors through the nn steps (Higham, ch. 5) bounds the computed value p^\hat p:

∣p^(x)−p(x)∣≤γ2n∑k=0n∣ck∣ ∣x∣k,γ2n=2nu1−2nu≈2nu.|\hat p(x) - p(x)| \le \gamma_{2n} \sum_{k=0}^n |c_k|\,|x|^k, \qquad \gamma_{2n} = \frac{2nu}{1 - 2nu} \approx 2nu .

The right side is the size of the largest terms, not of the result. When terms of opposite sign nearly cancel, near a root, the relative error of p^\hat p can be large: that comes from the polynomial being ill-conditioned there, and no evaluation order fixes it. When all the inputs are integers and every partial result acc stays below 2532^{53}, no step rounds at all and Horner is exact; the tests check both facts.

For ax2+bx+c=0ax^2 + bx + c = 0 with a≠0a \ne 0, divide by aa and complete the square:

(x+b2a)2=b2−4ac4a2⟹x=−b±D2a,D=b2−4ac.\left(x + \frac{b}{2a}\right)^2 = \frac{b^2 - 4ac}{4a^2} \quad\Longrightarrow\quad x = \frac{-b \pm \sqrt{D}}{2a}, \qquad D = b^2 - 4ac .

D>0D > 0 gives two real roots, D=0D = 0 a double root −b/2a-b/2a, D<0D < 0 none (two complex roots, which quadratic_roots rejects). Expanding a(x−x1)(x−x2)a(x - x_1)(x - x_2) and matching coefficients gives Vieta’s formulas, true for any quadratic:

x1+x2=−ba,x1x2=ca.x_1 + x_2 = -\frac{b}{a}, \qquad x_1 x_2 = \frac{c}{a} .

When b2≫4∣ac∣b^2 \gg 4|ac|, D\sqrt D is very close to ∣b∣|b|. One of the two signs in −b±D-b \pm \sqrt D then subtracts two nearly equal numbers. Each has a rounding error of about u∣b∣u|b|, the difference is tiny, and the error is a large fraction of it: catastrophic cancellation. The other sign adds two numbers of the same sign and is accurate. So compute only the safe one,

q=−12(b+sign⁡(b)D),x=qa,x=cq,q = -\tfrac12\left(b + \operatorname{sign}(b)\sqrt D\right), \qquad x = \frac{q}{a}, \qquad x = \frac{c}{q},

where the second root comes from Vieta: x1x2=c/ax_1 x_2 = c/a and x1=q/ax_1 = q/a give x2=c/(ax1)=c/qx_2 = c/(a x_1) = c/q. Neither step subtracts. Two details make it robust. sign⁡(b)\operatorname{sign}(b) must be +1+1 when b=0b = 0 (numpy.sign(0) is 0, which makes q=0q = 0 for x2−4x^2 - 4); then q=0q = 0 only when b=0b = 0 and D=0D = 0, the double root 0. And b2b^2 overflows to infinity once ∣b∣>10154|b| > 10^{154}: dividing aa, bb, cc by the same power of two first (exact in binary floating point, and the roots do not change) keeps every intermediate in range. Return the roots sorted, x1≤x2x_1 \le x_2.

Horner. p(x)=1+2x+3x2p(x) = 1 + 2x + 3x^2, coefficients [1,2,3][1, 2, 3], at x=2x = 2:

stepkkacc * x + c[k]acc
120⋅2+30 \cdot 2 + 33
213⋅2+23 \cdot 2 + 28
308⋅2+18 \cdot 2 + 117

Check: 1+4+12=171 + 4 + 12 = 17. Two multiplications, two additions. At x=0,1,−1x = 0, 1, -1 the values are 1,6,21, 6, 2: test_hand_example_horner.

A friendly quadratic. x2−5x+6x^2 - 5x + 6: D=25−24=1D = 25 - 24 = 1, b=−5<0b = -5 < 0 so sign⁡(b)=−1\operatorname{sign}(b) = -1, q=−12(−5−1)=3q = -\tfrac12(-5 - 1) = 3. Roots q/a=3q/a = 3 and c/q=6/3=2c/q = 6/3 = 2, sorted (2,3)(2, 3); Vieta: 2+3=5=−b/a2 + 3 = 5 = -b/a, 2⋅3=6=c/a2 \cdot 3 = 6 = c/a.

The cancellation case. x2−108x+1x^2 - 10^8 x + 1. Exactly, D=1016−4D = 10^{16} - 4 and the roots are 5⋅107±25⋅1014−15 \cdot 10^7 \pm \sqrt{25 \cdot 10^{14} - 1}, about 10810^8 and 10−810^{-8}. In float64:

  • 1016−4=1081−4⋅10−16≈108−2⋅10−8\sqrt{10^{16} - 4} = 10^8 \sqrt{1 - 4 \cdot 10^{-16}} \approx 10^8 - 2 \cdot 10^{-8}. Near 10810^8 consecutive float64 values are 1.49×10−81.49 \times 10^{-8} apart, so it rounds to 108−1.49×10−810^8 - 1.49 \times 10^{-8}.
  • Textbook small root: (108−(108−1.49×10−8))/2=7.45×10−9(10^8 - (10^8 - 1.49 \times 10^{-8}))/2 = 7.45 \times 10^{-9}. The true value is 1.00×10−81.00 \times 10^{-8}: 25% wrong, with every digit lost to the subtraction.
  • Stable: q=−12(−108−(108−1.49×10−8))=108q = -\tfrac12(-10^8 - (10^8 - 1.49 \times 10^{-8})) = 10^8 to 16 digits, so x=q/a=108x = q/a = 10^8 and x=c/q=1/108=10−8x = c/q = 1/10^8 = 10^{-8}, correct to the last digit.

Both quadratics are test_hand_example_roots.

python/tinyllm/num/poly.py
def horner(coeffs: Sequence[float], x: ArrayLike) -> NDArray: ... # ascending coeffs, float64, x's shape
def quadratic_roots(a: float, b: float, c: float) -> tuple[float, float]: ... # x1 <= x2, no cancellation

quadratic_roots raises ValueError when a=0a = 0 (a linear equation), when D<0D < 0 (complex roots), and when a coefficient is NaN or infinite.

TestKINDChecksWhy it matters downstream
test_hand_example_hornerunit, smokesection 3: p(2)=17p(2) = 17, and pp at 0, 1, −1-1you and the tests agree on the order
test_hand_example_rootsunit, smoke(2,3)(2, 3), and (10−8,108)(10^{-8}, 10^8) to float64 precisionthe cancellation case by hand
test_coefficients_are_ascendingboundary[1,0][1, 0] is 1, [0,1][0, 1] is xx, 2+x32 + x^3 at 3 is 29M02.1 and M09.6 store Taylor coefficients ascending
test_horner_exact_on_integer_polynomialspropertyrandom integer polynomials agree with Python’s exact integersno rounding when nothing needs rounding
test_horner_within_error_boundpropertyreal coefficients stay within $\gamma_{2n}\sumc_k
test_horner_goldengoldenTaylor polynomials of exp⁡\exp and cos⁡\cos and (x−1)⋯(x−5)(x-1)\cdots(x-5) against mpmaththe polynomials M02.1 and M09.6 evaluate
test_horner_shapesboundaryscalars, 2-D arrays, float32 input, no coefficients, one coefficientcalled on whole arrays of points
test_roots_golden_cancellationgolden15 quadratics with b2≫4acb^2 \gg 4ac against mpmath at 80 digitsboth roots right to float64 precision
test_b_zeroboundaryx2−4x^2 - 4 and 2x2−82x^2 - 8 give (−2,2)(-2, 2)sign⁡(0)\operatorname{sign}(0)
test_special_rootsunita zero root, a double root, a<0a < 0, a≠1a \ne 1the second root is c/qc/q
test_roots_ascendingunitx1≤x2x_1 \le x_2 for every sign patterncallers rely on the order
test_vieta_relationspropertyx1+x2=−b/ax_1 + x_2 = -b/a and x1x2=c/ax_1 x_2 = c/a on 200 random quadraticsan independent check of both roots
test_huge_b_does_not_overflowboundaryx2+10200x+1x^2 + 10^{200}x + 1 gives (−10200,−10−200)(-10^{200}, -10^{-200})scaling before squaring
test_rejects_non_quadraticsboundarya=0a = 0, D<0D < 0, NaN, and infinity raise ValueErrorerrors, not NaN pairs
PitfallSymptomCaught by
1. reading the coefficients in descending order (the np.polyval convention)[1,2,3][1, 2, 3] evaluates x2+2x+3x^2 + 2x + 3; Taylor polynomials come out reversedtest_coefficients_are_ascending (mutant s01)
2. the textbook formula (−b±D)/2a(-b \pm \sqrt D)/2athe small root of x2−108x+1x^2 - 10^8x + 1 is 25% wrong; with b=1015b = 10^{15} it is 0test_hand_example_roots, test_roots_golden_cancellation (mutant s02)
3. sign⁡(0)=0\operatorname{sign}(0) = 0x2−4x^2 - 4 returns (0,0)(0, 0), or divides by zerotest_b_zero (mutant s03)
4. squaring bb unscaledx2+10200x+1x^2 + 10^{200}x + 1 returns (−∞,0)(-\infty, 0)test_huge_b_does_not_overflow (mutant s08)
5. taking the second root as c/x1c/x_1correct only when a=1a = 1; 2x2−10x+122x^2 - 10x + 12 is wrongtest_special_roots (mutant s04)
6. returning the roots in the order computedx1>x2x_1 > x_2 whenever b<0b < 0test_roots_ascending (mutant s05)
DirectionModuleHow it uses this
BackM00.1exponents and powers (reading)
Backlang.01numpy arrays and broadcasting (reading)
ForwardM02.1exp_range_reduced evaluates the Taylor polynomial of exe^x with horner after range reduction
ForwardM09.6tl_expf in C: range reduction, then a degree-5 or 6 polynomial by the same Horner loop, within 4 ulp
ForwardM09.2stable rewrites of formulas that cancel, the theme section 2.5 starts (reading)
Your pieceProduction equivalentWhat it addsWhere to look
hornerCephes polevl and p1evlthe Horner loop behind many C math libraries, with descending coefficients and an implicit leading 1Cephes polevl.c
horner in an expfmusl and glibc expfa short polynomial after range reduction, evaluated with Estrin-style pairing for instruction-level parallelismmusl src/math/expf.c
horner over arraysnumpy.polynomial.polynomial.polyvalascending coefficients like yours, plus fitting, roots, and Chebyshev basesnumpy numpy/polynomial/
quadratic_rootsNumerical Recipes section 5.6; Kahan’s notes on the discriminantthe same qq trick, and an extra-precise b2−4acb^2 - 4ac for nearly double rootsKahan, “On the Cost of Floating-Point Computation Without Extra-Precise Arithmetic” (2004)