Polynomials, Horner, stable quadratic roots
Overview
Section titled “Overview”| Module | M00.4 · build · Python · Pass 2 · 2 to 3 h |
| You build | python/tinyllm/num/poly.py: horner, quadratic_roots |
| Contract | course/contracts/py/tinyllm/num/poly.pyi |
| Tests | course/tests/M00.4/test_poly.py (what they check: section 4) |
| Needs | nothing to call. Reading: M00.1 (exponents), lang.01 (numpy arrays) |
| Used by | M02.1 evaluates its range-reduced Taylor polynomial for with horner · later M09.6 ports that code to C as tl_expf |
| Milestone | MS-P2 (the Pass 2 gate) |
| Optional depth | OpenStax, 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) |
Key Takeaways
Section titled “Key Takeaways”- A polynomial is its list of coefficients; in this course they are stored in ascending order of power, so
coeffs[k]multiplies (test_coefficients_are_ascending). - Horner’s rule evaluates a degree- polynomial with multiplications and additions by nesting, , and its error is bounded by (
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 (
test_horner_exact_on_integer_polynomials). - The textbook quadratic formula cancels when : the small root comes out with few or no correct digits. Computing and the roots and never subtracts nearly equal numbers (
test_hand_example_roots,test_roots_golden_cancellation). - Two edge cases break naive code: must count as , and overflows for unless the coefficients are scaled first (
test_b_zero,test_huge_b_does_not_overflow).
How to work this chapter
Section titled “How to work this chapter”ol start M00.4 # stubs python/tinyllm/num/poly.py into your repool tests M00.4 # read the test catalog first: rung R0, you write no tests hereol check M00.4 # exit code is the verdictol diff M00.4 # after passing: your code against the reference1. Why now
Section titled “1. Why now”Every activation function and softmax in your models calls , 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 to a small range, then evaluate a polynomial that approximates 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 entirely, and a two-line rewrite fixes it.
2. Principles
Section titled “2. Principles”| Symbol | Meaning | Type / shape |
|---|---|---|
| the degree: the highest power with a nonzero coefficient | int | |
| coefficients, ascending: multiplies | Sequence[float] | |
| the polynomial | NDArray | |
| a root: a number with | float | |
| the coefficients of the quadratic , | float | |
| the discriminant, | float | |
| the two roots, | float | |
| the stable intermediate | float | |
| float64 unit roundoff, | ||
| , the bound on rounded operations | float |
2.1 Polynomials and their roots
Section titled “2.1 Polynomials and their roots”A polynomial of degree is with . It is determined by its coefficient list, which this course writes in ascending order: is . (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 with . The factor theorem says exactly when divides : for a polynomial of degree . Dividing out one root at a time shows a degree- polynomial has at most roots. Example: is 0 at , and dividing by leaves .
2.2 Horner’s rule
Section titled “2.2 Horner’s rule”Evaluating term by term computes every power separately. Factoring out repeatedly gives the nested form
evaluated from the inside out:
acc = 0for k = n, n-1, ..., 0: # highest power first acc = acc * x + c[k]That is multiplications and 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 when you evaluate at , with the remainder last: this is “synthetic division”. Starting from acc = 0 makes the empty coefficient list the zero polynomial, and works the same for a scalar or a whole array of points.
2.3 How accurate is Horner?
Section titled “2.3 How accurate is Horner?”Each step rounds twice, once for the multiplication and once for the addition, each with relative error at most . Following those errors through the steps (Higham, ch. 5) bounds the computed value :
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 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 , no step rounds at all and Horner is exact; the tests check both facts.
2.4 The quadratic formula
Section titled “2.4 The quadratic formula”For with , divide by and complete the square:
gives two real roots, a double root , none (two complex roots, which quadratic_roots rejects). Expanding and matching coefficients gives Vieta’s formulas, true for any quadratic:
2.5 Cancellation and the stable formula
Section titled “2.5 Cancellation and the stable formula”When , is very close to . One of the two signs in then subtracts two nearly equal numbers. Each has a rounding error of about , 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,
where the second root comes from Vieta: and give . Neither step subtracts. Two details make it robust. must be when (numpy.sign(0) is 0, which makes for ); then only when and , the double root 0. And overflows to infinity once : dividing , , 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, .
3. Worked example by hand
Section titled “3. Worked example by hand”Horner. , coefficients , at :
| step | acc * x + c[k] | acc | |
|---|---|---|---|
| 1 | 2 | 3 | |
| 2 | 1 | 8 | |
| 3 | 0 | 17 |
Check: . Two multiplications, two additions. At the values are : test_hand_example_horner.
A friendly quadratic. : , so , . Roots and , sorted ; Vieta: , .
The cancellation case. . Exactly, and the roots are , about and . In float64:
- . Near consecutive float64 values are apart, so it rounds to .
- Textbook small root: . The true value is : 25% wrong, with every digit lost to the subtraction.
- Stable: to 16 digits, so and , correct to the last digit.
Both quadratics are test_hand_example_roots.
4. The interface
Section titled “4. The interface”def horner(coeffs: Sequence[float], x: ArrayLike) -> NDArray: ... # ascending coeffs, float64, x's shapedef quadratic_roots(a: float, b: float, c: float) -> tuple[float, float]: ... # x1 <= x2, no cancellationquadratic_roots raises ValueError when (a linear equation), when (complex roots), and when a coefficient is NaN or infinite.
What the tests check
Section titled “What the tests check”| Test | KIND | Checks | Why it matters downstream |
|---|---|---|---|
test_hand_example_horner | unit, smoke | section 3: , and at 0, 1, | you and the tests agree on the order |
test_hand_example_roots | unit, smoke | , and to float64 precision | the cancellation case by hand |
test_coefficients_are_ascending | boundary | is 1, is , at 3 is 29 | M02.1 and M09.6 store Taylor coefficients ascending |
test_horner_exact_on_integer_polynomials | property | random integer polynomials agree with Python’s exact integers | no rounding when nothing needs rounding |
test_horner_within_error_bound | property | real coefficients stay within $\gamma_{2n}\sum | c_k |
test_horner_golden | golden | Taylor polynomials of and and against mpmath | the polynomials M02.1 and M09.6 evaluate |
test_horner_shapes | boundary | scalars, 2-D arrays, float32 input, no coefficients, one coefficient | called on whole arrays of points |
test_roots_golden_cancellation | golden | 15 quadratics with against mpmath at 80 digits | both roots right to float64 precision |
test_b_zero | boundary | and give | |
test_special_roots | unit | a zero root, a double root, , | the second root is |
test_roots_ascending | unit | for every sign pattern | callers rely on the order |
test_vieta_relations | property | and on 200 random quadratics | an independent check of both roots |
test_huge_b_does_not_overflow | boundary | gives | scaling before squaring |
test_rejects_non_quadratics | boundary | , , NaN, and infinity raise ValueError | errors, not NaN pairs |
5. Pitfalls
Section titled “5. Pitfalls”| Pitfall | Symptom | Caught by |
|---|---|---|
1. reading the coefficients in descending order (the np.polyval convention) | evaluates ; Taylor polynomials come out reversed | test_coefficients_are_ascending (mutant s01) |
| 2. the textbook formula | the small root of is 25% wrong; with it is 0 | test_hand_example_roots, test_roots_golden_cancellation (mutant s02) |
| 3. | returns , or divides by zero | test_b_zero (mutant s03) |
| 4. squaring unscaled | returns | test_huge_b_does_not_overflow (mutant s08) |
| 5. taking the second root as | correct only when ; is wrong | test_special_roots (mutant s04) |
| 6. returning the roots in the order computed | whenever | test_roots_ascending (mutant s05) |
6. Where it’s used next
Section titled “6. Where it’s used next”| Direction | Module | How it uses this |
|---|---|---|
| Back | M00.1 | exponents and powers (reading) |
| Back | lang.01 | numpy arrays and broadcasting (reading) |
| Forward | M02.1 | exp_range_reduced evaluates the Taylor polynomial of with horner after range reduction |
| Forward | M09.6 | tl_expf in C: range reduction, then a degree-5 or 6 polynomial by the same Horner loop, within 4 ulp |
| Forward | M09.2 | stable rewrites of formulas that cancel, the theme section 2.5 starts (reading) |
Going further
Section titled “Going further”| Your piece | Production equivalent | What it adds | Where to look |
|---|---|---|---|
horner | Cephes polevl and p1evl | the Horner loop behind many C math libraries, with descending coefficients and an implicit leading 1 | Cephes polevl.c |
horner in an expf | musl and glibc expf | a short polynomial after range reduction, evaluated with Estrin-style pairing for instruction-level parallelism | musl src/math/expf.c |
horner over arrays | numpy.polynomial.polynomial.polyval | ascending coefficients like yours, plus fitting, roots, and Chebyshev bases | numpy numpy/polynomial/ |
quadratic_roots | Numerical Recipes section 5.6; Kahan’s notes on the discriminant | the same trick, and an extra-precise for nearly double roots | Kahan, “On the Cost of Floating-Point Computation Without Extra-Precise Arithmetic” (2004) |