Symbolic Math in Pure Python
Dear reader, I have experimented with symbolic math problems since the early 1980s when I discovered the Reduce math system installed on my Xerox 1108 Lisp Machine. A few months ago I wrote a symbolic math chapter for my Common Lisp book. Here in this chapter, I implement useful symbolic math functionality in Python.
1. Why Symbolic Computation Matters
A calculator can tell you that the derivative of x^2 at the point x = 0.8
is approximately 1.6. But it cannot tell you why, and it cannot hand you
back the general answer 2·x. That is the difference between numeric
mathematics and symbolic mathematics.
- Numeric computation operates on concrete values. It is fast and universal, but every answer is approximate, valid only at a single point, and carries floating-point rounding error.
- Symbolic computation operates on expressions themselves. It applies the rules of algebra (the product rule, the chain rule, the power rule) as transformations on expression trees, producing exact results such as
d/dx (x²) = 2xand∫ x² dx = x³/3.
Symbolic systems (computer algebra systems, or CAS) are the engine behind Mathematica, SymPy, and the equation solvers inside graphing calculators. At their heart is a single, powerful idea: represent a mathematical expression as a data structure, and implement the rules of calculus as functions over that data structure.
This chapter builds a miniature CAS from nothing but the Python standard
library. The result differentiates and integrates a useful subset of
elementary functions, simplifies the results algebraically, and crucially
verifies its own answers by comparing them against numeric finite-difference
approximations. No sympy, no numpy, nothing to install.
The whole system is four ingredients:
- A representation consists of expressions encoded as nested tuples.
- A simplifier object consists of algebraic identities applied bottom-up.
- A differentiator and an integrator are recursive rule tables.
- A verifier object is a numeric evaluation checked against finite differences.
The central design choice: expressions as data
Just as a compiler turns source text into an abstract syntax tree before generating code, a CAS turns a formula into an expression tree. In Python, the cheapest honest tree is a nested tuple, and every node is tagged with a string naming the operation. Here is the entire vocabulary:
1 ('num', value) constant (value is a fractions.Fraction)
2 ('var', name) variable, e.g. ('var', 'x')
3 ('add', a, b) a + b
4 ('mul', a, b) a * b
5 ('pow', base, exp) base ** exp
6 ('sin'|'cos'|'exp'|'log', arg)
A concrete formula is data you can print, inspect, and recurse over:
1 x^2 + 3*x becomes
2 ('add',
3 ('pow', ('var', 'x'), ('num', 2)),
4 ('mul', ('num', 3), ('var', 'x')))
Compare this to the numeric approach, where x^2 + 3*x would be compiled to a
function that only accepts a numeric x. In the symbolic approach the tree
itself is the object of study; for example you can ask “what is the derivative?” because
the structure of the expression is right there in the tuple, ready to be
pattern-matched.
One deliberately un-Pythonic choice will seem odd at first: we do not
overload + and * on a Symbol class. Instead we construct expressions
with small builder functions (add, mul, pow). This keeps every node a
plain tuple, which makes the code self-contained, trivially serializable, and
dead simple to read: three virtues that outweigh the loss of operator
sugar in a teaching implementation.
Why exact arithmetic via fractions.Fraction
If we stored 1/3 as the float 0.3333333333333333, then simplifying
∫ x² dx = (1/3)·x³ would quietly round the leading coefficient. After a few
dozen rules fired, error would accumulate and the self-checks in this chapter
would start failing. The fix is to store every numeric constant as a
fractions.Fraction, an exact rational type in the standard library.
Fraction(1, 3) is genuinely one third, not an approximation, so the
coefficient 1/3 emerges from integration exactly.
This is the first lesson of the chapter in miniature: choose the representation to preserve the properties your algorithms need. Differentiation and integration are exact operations, so constants must be exact too.
A tour of the algorithm
With the tree defined, everything else is a recursive function that walks it:
simplify(e)rewrites a tree bottom-up: folds constant arithmetic, removesx + 0and1·xnoise, and mergesx^a · x^bintox^(a+b).deriv(e, x)applies the calculus rules (sum, product, power, chain rule) and chains intosimplifyto keep output tidy.integrate(e, x)recognizes the limited family it can handle (polynomials, powers and trig/exp/log of linear arguments) and raisesNotImplementedErrorrather than guessing.evaluate(e, env)turns a tree back into a number, which lets the program numerically check that its own symbolic answers are correct.
We now descend into each component, seeing the code in full before and after explaining it.
2. The Expression Vocabulary
The file begins with a docstring that is also a grammar: a complete list of the six node shapes the whole program will ever construct. In a CAS this is the analogue of a language’s grammar where every function that follows is simply a case analysis over these tags.
1 #!/usr/bin/env python3
2 """Symbolic differentiation and integration (no third-party libraries).
3
4 Expressions are nested tuples:
5 ('num', value) constant (value is a Fraction)
6 ('var', name) variable
7 ('add', a, b) a + b
8 ('mul', a, b) a * b
9 ('pow', base, exp) base ** exp
10 ('sin'|'cos'|'exp'|'log', x)
11 """
12
13 from fractions import Fraction
Only one import is needed. Fraction is the exact-rational workhorse; math
is imported lazily later inside the numeric evaluator so that the symbolic
core never touches floats.
Builder functions: the mini “DSL”
Because raw tuples are verbose, the program provides one small function per
node type. These are the only constructors in the codebase, which means the
invariant “every num holds a Fraction” can be enforced in exactly one
place.
1 # ---------------------------------------------------------------- builders
2
3 def num(n):
4 return ('num', n if isinstance(n, Fraction) else Fraction(n))
5
6
7 def var(name):
8 return ('var', name)
9
10
11 def add(a, b):
12 return ('add', a, b)
13
14
15 def sub(a, b):
16 return ('add', a, ('mul', num(-1), b))
17
18
19 def mul(a, b):
20 return ('mul', a, b)
21
22
23 def div(a, b):
24 return ('mul', a, ('pow', b, num(-1)))
25
26
27 def pow(base, exp):
28 return ('pow', base, exp)
29
30
31 def neg(a):
32 return ('mul', num(-1), a)
33
34
35 def sin(a):
36 return ('sin', a)
37
38
39 def cos(a):
40 return ('cos', a)
41
42
43 def exp(a):
44 return ('exp', a)
45
46
47 def log(a):
48 return ('log', a)
49
50
51 ZERO = num(0)
52 ONE = num(1)
53
54
55 def is_num(e):
56 return e[0] == 'num'
57
58
59 def contains_var(e, x):
60 tag = e[0]
61 if tag == 'var':
62 return e[1] == x
63 if tag == 'num':
64 return False
65 return any(contains_var(c, x) for c in e[1:])
Why define sub, div, and neg at all? Because they are derived
forms, not new node types. sub(a, b) becomes a + (-1)·b, div(a, b)
becomes a · b^-1, and neg(a) becomes -1·a. Keeping only six node types
dramatically shrinks the later case analyses: the simplifier, differentiator,
and integrator never have to handle subtraction, division, or negation as
special cases; they fall out of add, mul, and pow for free. This is a
recurring theme in compilers and CAS alike: express features by desugaring
(translating them into simpler forms) rather than by adding new cases.
Notice that every builder is a pure function with no state. A num(3) built
anywhere is identical to every other num(3), which is why ZERO and ONE
are precomputed as module-level singletons and compared with == throughout
the code.
The two predicates round out the vocabulary. is_num(e) answers “is this a
constant?” contains_var(e, x) answers “does the variable x occur anywhere
in this tree?”, a structural question that drives the integration logic
later, where deciding whether a multiplier is a constant depends on whether it
mentions the variable of integration. The recursion is generic: for any
non-leaf node, check the node’s children (e[1:]). This pattern, a recursive
walk that terminates at leaves, is the backbone of every function in the file.
3. Simplification: the Algebraic Engine
Raw differentiation produces correct but ugly trees. Differentiating x^3
by the rules below yields, without cleanup, something like
1 1 · 3 · x^2 + 0
A human would never write that. Simplification exists to apply the identities everyone learns in school until the expression is in normal form: constants folded, additive and multiplicative identities removed, numeric factors normalized, and equal powers collected.
The workhorse is simplify, a single recursive dispatch over tags. Two small
helpers precede it.
Helper: factoring a term into (coefficient, rest)
1 # ---------------------------------------------------------------- simplify
2
3 def _decomp(t):
4 """Return (coeff, rest) such that t == coeff * rest."""
5 if is_num(t):
6 return (t, ONE)
7 if t[0] == 'mul' and is_num(t[1]):
8 return (t[1], t[2])
9 return (ONE, t)
10
11
12 def _merge_add(a, b):
13 ca, ra = _decomp(a)
14 cb, rb = _decomp(b)
15 if ra == rb:
16 return simplify(mul(simplify(add(ca, cb)), ra))
17 return None
_decomp splits a term into a numeric coefficient and “the rest”. For 3*x
it returns (3, x); for plain x it returns (1, x); for a constant like
4 it returns (4, 1). This matters because of collecting like terms:
3*x + 5*x should simplify to 8*x. _merge_add tries exactly that: it pulls
both terms apart, and if the “rest” is identical (x and x), it adds the
coefficients and rebuilds. If the rests differ (e.g. 3*x + 5*y), it returns
None, signalling “no merge possible”.
The main simplifier
1 def simplify(e):
2 if not isinstance(e, tuple):
3 raise TypeError(f"bad expr: {e!r}")
4 tag = e[0]
5
6 if tag in ('num', 'var'):
7 return e
8
9 if tag == 'add':
10 a = simplify(e[1])
11 b = simplify(e[2])
12 if is_num(a) and is_num(b):
13 return num(a[1] + b[1])
14 if a == ZERO:
15 return b
16 if b == ZERO:
17 return a
18 merged = _merge_add(a, b)
19 return merged if merged is not None else ('add', a, b)
20
21 if tag == 'mul':
22 a = simplify(e[1])
23 b = simplify(e[2])
24 if is_num(a) and is_num(b):
25 return num(a[1] * b[1])
26 if a == ZERO or b == ZERO:
27 return ZERO
28 if a == ONE:
29 return b
30 if b == ONE:
31 return a
32 # flatten numeric factors: c1 * (c2 * x)
33 if is_num(a) and b[0] == 'mul' and is_num(b[1]):
34 return simplify(mul(num(a[1] * b[1][1]), b[2]))
35 if a[0] == 'mul' and is_num(a[1]) and is_num(b):
36 return simplify(mul(num(a[1][1] * b[1]), a[2]))
37 # normalize numeric factor to the left
38 if is_num(b) and not is_num(a):
39 return ('mul', b, a)
40 # combine equal bases
41 if a[0] == 'pow' and b[0] == 'pow' and a[1] == b[1]:
42 return simplify(pow(a[1], add(a[2], b[2])))
43 if a[0] == 'var' and b[0] == 'var' and a[1] == b[1]:
44 return pow(a, num(2))
45 if a[0] == 'var' and b[0] == 'pow' and b[1] == ('var', a[1]):
46 return simplify(pow(a, add(ONE, b[2])))
47 if b[0] == 'var' and a[0] == 'pow' and a[1] == ('var', b[1]):
48 return simplify(pow(b, add(ONE, a[2])))
49 return ('mul', a, b)
50
51 if tag == 'pow':
52 base = simplify(e[1])
53 expo = simplify(e[2])
54 if is_num(base) and is_num(expo) and expo[1].denominator == 1:
55 return num(base[1] ** int(expo[1]))
56 if expo == ZERO:
57 return ONE
58 if expo == ONE:
59 return base
60 return ('pow', base, expo)
61
62 if tag in ('sin', 'cos', 'exp', 'log'):
63 return (tag, simplify(e[1]))
64
65 raise TypeError(f"unknown tag: {tag!r}")
Every case follows the same shape: first recursively simplify the children, then apply local rewrite rules. This bottom-up discipline guarantees that by the time a rule examines a child, that child is already in normal form.
Read each case as a small ordered list of identities:
add: fold2 + 3into5; drop0(both sides); otherwise try to collect like terms via_merge_add.mul: fold2 · 3; annihilate with0; drop·1; then a set of structural rewrites that flatten nested numeric factors (2·(3·x)becomes6·x), move any numeric factor to the left (x·3becomes3·x), and combine equal powers (x^a · x^bbecomesx^(a+b)). The last fourpow/varcases spell out the combinationsx·x,x·x^b, andx^a·xexplicitly.pow: evaluate2^3into8only when the exponent is a whole number (denominator == 1); otherwise1/2would be rounded; then applye^0 = 1ande^1 = e.sin/cos/exp/log: recurse into the argument only.
Two things are worth lingering on. First, the constant-folding guard
expo[1].denominator == 1 is the entire reason exactness survives: it refuses
to turn x^(1/2) into anything numeric, and only evaluates constant powers
whose exponent is an integer. Second, the simplification rules do not need
to be complete. The system does not attempt trigonometric identities
(sin² + cos² = 1) or full polynomial canonicalization. A simplifier in a
teaching CAS only has to be good enough that downstream output is readable
and that the self-checks can compare trees reliably. Over-simplifying is a
separate research project.
4. Pretty-Printing: Trees Back into Math
A CAS is judged as much by what it prints as by what it computes. The
printer’s job is to render a tree as a human-readable linear string,
2*x^3 - 5*x + 4, 1/3*log(3*x + 2), with the right parentheses and no
redundant + -, 1*, or *1.
1 # ---------------------------------------------------------------- printing
2
3 def _frac_str(n):
4 return str(n.numerator) if n.denominator == 1 else f"{n.numerator}/{n.denominator}"
5
6
7 def _paren(e):
8 s = to_str(e)
9 return f"({s})" if e[0] in ('add', 'mul') else s
10
11
12 def _flatten_add(e, out):
13 if e[0] == 'add':
14 _flatten_add(e[1], out)
15 _flatten_add(e[2], out)
16 else:
17 out.append(e)
18
19
20 def _exp_str(e):
21 if e[0] == 'num':
22 n = e[1]
23 return str(n.numerator) if n.denominator == 1 else f"({_frac_str(n)})"
24 if e[0] == 'var':
25 return e[1]
26 return f"({to_str(e)})"
27
28
29 def _is_neg(e):
30 if is_num(e):
31 return e[1] < 0
32 return e[0] == 'mul' and is_num(e[1]) and e[1][1] < 0
33
34
35 def _neg_str(e):
36 if is_num(e):
37 return to_str(num(-e[1]))
38 return to_str(mul(num(-e[1][1]), e[2]))
39
40
41 def to_str(e):
42 tag = e[0]
43 if tag == 'num':
44 return _frac_str(e[1])
45 if tag == 'var':
46 return e[1]
47 if tag == 'add':
48 terms = []
49 _flatten_add(e, terms)
50 s = to_str(terms[0])
51 for t in terms[1:]:
52 if _is_neg(t):
53 s += f" - {_neg_str(t)}"
54 else:
55 s += f" + {to_str(t)}"
56 return s
57 if tag == 'mul':
58 a, b = e[1], e[2]
59 if is_num(a):
60 c = a[1]
61 if c == -1:
62 return f"-{_paren(b)}"
63 if c == 1:
64 return _paren(b)
65 return f"{_frac_str(c)}*{_paren(b)}"
66 return f"{_paren(a)}*{_paren(b)}"
67 if tag == 'pow':
68 return f"{_paren(e[1])}^{_exp_str(e[2])}"
69 if tag in ('sin', 'cos', 'exp', 'log'):
70 return f"{tag}({to_str(e[1])})"
71 raise TypeError(f"unknown tag: {tag!r}")
The interesting decisions are all about notation, and each maps to a helper:
_frac_strrenders aFractionas3when it is whole and3/2otherwise, so the output never shows3.0or1_000_000/500_000._flatten_addconverts the left-leaning binary tree('add', ('add', a, b), c)into the flat list[a, b, c]. Sincesimplifybuilds sums as left-leaning binary trees, flattening gives the printer a clean term list for free, and subtraction is printed asa - bwhenever a term is recognized as negative by_is_neg._neg_strthen renders a negative term by flipping its sign._is_negreturns true for a negative constant or for a product whose left factor is a negative number. This is what produces2*x^3 - 5*x + 4(with an infix minus) rather than the correct-but-ugly2*x^3 + -5*x + 4._parenand_exp_strimplement minimal, conservative parenthesization: wrap a+/*child in parentheses when it appears as a factor or exponent, and wrap a fractional exponent such as1/2to avoid the ambiguousx^1/2.- The
mulcase drops the1coefficient entirely (1*xprints asx) and renders a coefficient of-1as a bare leading minus (-xinstead of-1*x).
Because every rule lives in one function, the printer can never disagree with
the simplifier about what a “number” is; they both check is_num and the
Fraction. The result is compact output that a reader could hand back to a
calculator and verify.
5. Differentiation: Rules as Recursion
Differentiation is the cleanest algorithm in the book, because calculus gives us a complete, compositional set of rules. If you know the derivative of the parts, you know the derivative of the whole. The implementation is therefore a one-to-one transcription of the table:
| Form | Rule |
|---|---|
| constant | 0 |
x |
1 (or 0 if a different variable) |
a + b |
a' + b' |
a · b |
a'·b + a·b' (product rule) |
f(x)^c |
c · f(x)^(c-1) · f'(x) (power rule) |
c^f(x) |
c^f(x) · log(c) · f'(x) |
sin u |
cos u · u' (chain rule) |
cos u |
-sin u · u' |
exp u |
exp u · u' |
log u |
u' / u |
1 # ---------------------------------------------------------------- derivative
2
3 def deriv(e, x):
4 tag = e[0]
5 if tag == 'num':
6 return ZERO
7 if tag == 'var':
8 return ONE if e[1] == x else ZERO
9 if tag == 'add':
10 return simplify(add(deriv(e[1], x), deriv(e[2], x)))
11 if tag == 'mul':
12 a, b = e[1], e[2]
13 return simplify(add(mul(deriv(a, x), b), mul(a, deriv(b, x))))
14 if tag == 'pow':
15 base, expo = e[1], e[2]
16 if not contains_var(expo, x):
17 return simplify(mul(mul(expo, pow(base, sub(expo, ONE))), deriv(base, x)))
18 if not contains_var(base, x):
19 return simplify(mul(mul(pow(base, expo), log(base)), deriv(expo, x)))
20 raise NotImplementedError(f"cannot differentiate {to_str(e)}")
21 inner = deriv(e[1], x)
22 if tag == 'sin':
23 return simplify(mul(cos(e[1]), inner))
24 if tag == 'cos':
25 return simplify(mul(neg(sin(e[1])), inner))
26 if tag == 'exp':
27 return simplify(mul(exp(e[1]), inner))
28 if tag == 'log':
29 return simplify(mul(div(ONE, e[1]), inner))
30 raise TypeError(f"unknown tag: {tag!r}")
Two details deserve emphasis, because they carry the whole design.
The power rule has two branches, distinguished by contains_var. When the
exponent is free of x (x^2, (2x+1)^3, √x), we use the familiar power
rule c · base^(c-1) · base'. When instead the base is free of x
(2^x, a^sin(x)), we use the exponential rule base^expo · log(base) · expo'. When both base and exponent mention x (x^x), differentiation is
genuinely harder and the honest answer is NotImplementedError, a decision
we will see echoed in the integrator. The guard
expo[1].denominator == 1 inside simplify guarantees that 2^3 (an integer
exponent) collapses numerically, while 2^x stays symbolic so the log(2)
branch can fire.
Every rule wraps its answer in simplify. Differentiation constructs
trees; simplification normalizes them. Without the wrapper, the derivative
of x^3 would be 3 · x^2 · 1 + ... (the ·1 and +0 terms from the
product and power rules). With it, the output is 3*x^2. This pairing,
generate, then normalize, is the standard idiom of symbolic computing, and it
is why the simplifier was built first.
The chain rule appears entirely inside the four leaf cases: each computes the
derivative of its argument (inner) exactly once and multiplies it onto the
outer derivative. Because deriv is recursive, nesting works automatically:
differentiating sin(3*x + 1) recurses through sin into add into mul
and assembles 3·cos(3x+1) on the way back out.
6. Integration: Recognizing Patterns
Integration is where the chapter’s honesty shows. Unlike differentiation, there is no simple compositional algorithm for antiderivatives in general; a complete method (Risch-style) is the subject of graduate study. So this implementation integrates only a pragmatic subset and, crucially, it detects the boundary of that subset and refuses politely rather than returning a wrong answer.
The subset is built on one recurring idea: linear arguments. Many
elementary antiderivatives are known in the form with x alone; the constant
∫ f(a·x + b) dx is handled by a change of variable that divides by a. The
function linear_arg is the test that recognizes the pattern a·x + b:
1 # ---------------------------------------------------------------- integrate
2
3 def linear_arg(e, x):
4 """Return (a, b) if e == a*x + b with a, b independent of x, else None."""
5 e = simplify(e)
6 if not contains_var(e, x):
7 return (ZERO, e)
8 if e[0] == 'var' and e[1] == x:
9 return (ONE, ZERO)
10 if e[0] == 'add':
11 la = linear_arg(e[1], x)
12 lb = linear_arg(e[2], x)
13 if la is not None and lb is not None:
14 return (simplify(add(la[0], lb[0])), simplify(add(la[1], lb[1])))
15 return None
16 if e[0] == 'mul':
17 a, b = e[1], e[2]
18 if not contains_var(a, x):
19 rb = linear_arg(b, x)
20 if rb is not None:
21 return (simplify(mul(a, rb[0])), simplify(mul(a, rb[1])))
22 if not contains_var(b, x):
23 ra = linear_arg(a, x)
24 if ra is not None:
25 return (simplify(mul(b, ra[0])), simplify(mul(b, ra[1])))
26 return None
linear_arg returns the tuple (a, b) meaning “this expression equals
a·x + b”, or None if it does not. A constant is 0·x + c; the variable
x is 1·x + 0; a sum is linear if both sides are, with coefficients added;
a product c·(a·x+b) is linear if the non-x factor is a constant and the
other side is linear, with c folded into both coefficients. This is exactly
the kind of “does this expression match this shape?” predicate that a CAS
centralizes, so that several integration rules can share it.
Integrating powers: f(x)^c and c^(a·x+b)
1 def integrate_pow(e, x):
2 base = simplify(e[1])
3 expo = simplify(e[2])
4
5 if not contains_var(expo, x): # f(x)^c
6 la = linear_arg(base, x)
7 if la is None:
8 raise NotImplementedError(f"cannot integrate {to_str(e)}")
9 a, _ = la
10 if a == ZERO:
11 return simplify(mul(e, var(x)))
12 if expo == num(-1):
13 return simplify(mul(div(ONE, a), log(base)))
14 return simplify(mul(div(ONE, mul(a, add(expo, ONE))), pow(base, add(expo, ONE))))
15
16 if not contains_var(base, x): # c^(a*x+b)
17 lb = linear_arg(expo, x)
18 if lb is None:
19 raise NotImplementedError(f"cannot integrate {to_str(e)}")
20 a, _ = lb
21 if a == ZERO:
22 return simplify(mul(e, var(x)))
23 return simplify(div(e, mul(a, log(base))))
24
25 raise NotImplementedError(f"cannot integrate {to_str(e)}")
The two branches mirror the two branches of deriv’s power case. In the first
(f(x)^c), if the base is linear, a change of variables gives
∫ (a·x+b)^c dx = (a·x+b)^(c+1) / (a·(c+1)), with the special case c = -1
handled separately as log(base)/a. The a == ZERO guard catches a base that
is actually constant (e.g. 3^x reduced to a base free of x; that can’t
happen here, but the guard keeps the code safe). In the second branch
(c^(a·x+b)), ∫ c^(a·x+b) dx = c^(a·x+b) / (a·log(c)).
Integrating sin/cos/exp/log of linear arguments
1 def integrate_linear_fn(e, x):
2 tag = e[0]
3 inner = e[1]
4 la = linear_arg(inner, x)
5 if la is None:
6 return None
7 a, _ = la
8 if a == ZERO:
9 return simplify(mul(e, var(x)))
10 inv = div(ONE, a)
11 if tag == 'sin':
12 return simplify(mul(neg(inv), cos(inner)))
13 if tag == 'cos':
14 return simplify(mul(inv, sin(inner)))
15 if tag == 'exp':
16 return simplify(mul(inv, exp(inner)))
17 if tag == 'log':
18 return simplify(mul(inv, sub(mul(inner, log(inner)), inner)))
19 return None
Each row of this table is a single calculus fact scaled by 1/a for the
linear argument:
∫ sin(a·x+b) dx = -cos(a·x+b)/a, ∫ cos(a·x+b) dx = sin(a·x+b)/a,
∫ exp(a·x+b) dx = exp(a·x+b)/a, and
∫ log(u) dx = (u·log(u) - u)/a where u = a·x+b.
The log case is worth pausing on: the antiderivative of log(u) is
u·log(u) - u (verify by the product rule and chain rule). This is the one
rule in the file whose result is not just “scale the outer function”; it
genuinely restructures the expression, and the self-check in the next section
will confirm it numerically.
The top-level dispatcher
1 def integrate(e, x):
2 e = simplify(e)
3 tag = e[0]
4
5 if tag == 'num':
6 return simplify(mul(e, var(x)))
7 if tag == 'var':
8 if e[1] == x:
9 return simplify(mul(num(Fraction(1, 2)), pow(e, num(2))))
10 return simplify(mul(e, var(x)))
11 if tag == 'add':
12 return simplify(add(integrate(e[1], x), integrate(e[2], x)))
13 if tag == 'mul':
14 a, b = e[1], e[2]
15 if not contains_var(a, x):
16 return simplify(mul(a, integrate(b, x)))
17 if not contains_var(b, x):
18 return simplify(mul(b, integrate(a, x)))
19 raise NotImplementedError(f"cannot integrate {to_str(e)}")
20 if tag == 'pow':
21 return integrate_pow(e, x)
22 if tag in ('sin', 'cos', 'exp', 'log'):
23 res = integrate_linear_fn(e, x)
24 if res is None:
25 raise NotImplementedError(f"cannot integrate {to_str(e)}")
26 return res
27
28 raise TypeError(f"unknown tag: {tag!r}")
The dispatcher has the same shape as deriv, with one crucial behavioral
difference: its mul case is only partially recursive. If one factor is a
constant (free of x), it pulls the constant out: ∫ c·f(x) dx = c·∫f(x) dx.
But if both factors mention x (as in x·exp(x)), it raises
NotImplementedError. That restriction is the honest boundary of the subset:
integrating x·exp(x) needs integration by parts, which is out of scope.
This “recognize or refuse” discipline is the most important idea in the
chapter after the representation itself. A symbolic integrator that guesses
is far worse than one that says no; a wrong “simplified” answer silently
corrupts everything downstream. The NotImplementedError carrying the
expression (cannot integrate x*exp(x)) makes the system fail loudly and
explain itself.
7. Numeric Evaluation: Closing the Loop
evaluate is the mirror image of the earlier functions: instead of building
trees, it destroys them into floats. It is the bridge between the symbolic
world and the numeric world, and it exists for one purpose: verification.
1 # ---------------------------------------------------------------- numeric eval
2
3 def evaluate(e, env):
4 e = simplify(e)
5 tag = e[0]
6 if tag == 'num':
7 return float(e[1])
8 if tag == 'var':
9 return env[e[1]]
10 if tag == 'add':
11 return evaluate(e[1], env) + evaluate(e[2], env)
12 if tag == 'mul':
13 return evaluate(e[1], env) * evaluate(e[2], env)
14 if tag == 'pow':
15 return evaluate(e[1], env) ** evaluate(e[2], env)
16 if tag == 'sin':
17 import math
18 return math.sin(evaluate(e[1], env))
19 if tag == 'cos':
20 import math
21 return math.cos(evaluate(e[1], env))
22 if tag == 'exp':
23 import math
24 return math.exp(evaluate(e[1], env))
25 if tag == 'log':
26 import math
27 return math.log(evaluate(e[1], env))
28 raise TypeError(f"unknown tag: {tag!r}")
The env argument is an ordinary dict mapping variable names to values,
e.g. {'x': 0.8, 'y': 1.3}. A var node simply looks itself up; a num
becomes a float (this is the one place exactness is deliberately sacrificed,
and only for the numeric check). The math functions are imported inside
the leaf cases so the symbolic core never imports math at all, a small
encapsulation that keeps the “pure” part of the library free of floats.
This function is deliberately trivial because its correctness is assumed and then used to test the more interesting functions. That inversion, using simple code to check clever code, is the theme of the next section.
8. The Self-Verifying Demo
The main() function is not just a demo; it is a test suite written as a
narrative. It builds thirty-one expressions, and for each one:
- prints the function and its symbolic derivative,
- checks the derivative against a central finite-difference estimate,
- integrates, prints the antiderivative,
- differentiates it back, and checks that
d/dx(∫f) = fnumerically.
1 # ---------------------------------------------------------------- main
2
3 def main():
4 x = var('x')
5 y = var('y')
6
7 examples = [
8 ("constant", num(7)),
9 ("x", x),
10 ("3*x", mul(num(3), x)),
11 ("x^2", pow(x, num(2))),
12 ("x^3", pow(x, num(3))),
13 ("x^2 + 3*x", add(pow(x, num(2)), mul(num(3), x))),
14 ("2*x^3 - 5*x + 4",
15 add(mul(num(2), pow(x, num(3))), add(mul(num(-5), x), num(4)))),
16 ("1/x", pow(x, num(-1))),
17 ("1/x^2", pow(x, num(-2))),
18 ("sqrt(x)", pow(x, num(Fraction(1, 2)))),
19 ("x^(3/2)", pow(x, num(Fraction(3, 2)))),
20 ("sin(x)", sin(x)),
21 ("cos(x)", cos(x)),
22 ("exp(x)", exp(x)),
23 ("log(x)", log(x)),
24 ("sin(2*x)", sin(mul(num(2), x))),
25 ("cos(3*x + 1)", cos(add(mul(num(3), x), num(1)))),
26 ("exp(-2*x)", exp(mul(num(-2), x))),
27 ("log(2*x + 3)", log(add(mul(num(2), x), num(3)))),
28 ("2*sin(x)", mul(num(2), sin(x))),
29 ("3*x^2", mul(num(3), pow(x, num(2)))),
30 ("x^2/2", mul(num(Fraction(1, 2)), pow(x, num(2)))),
31 ("(2*x + 1)^3", pow(add(mul(num(2), x), num(1)), num(3))),
32 ("1/(3*x + 2)", pow(add(mul(num(3), x), num(2)), num(-1))),
33 ("2^x", pow(num(2), x)),
34 ("y", y),
35 ("y*x", mul(y, x)),
36 ("exp(x) + sin(x)", add(exp(x), sin(x))),
37 ("x*exp(x)", mul(x, exp(x))), # needs integration by parts
38 ("x*sin(x)", mul(x, sin(x))), # needs integration by parts
39 ("x*cos(x) + sin(x)", add(mul(x, cos(x)), sin(x))), # d(x sin x)
40 ]
41
42 x0, tol = 0.8, 1e-6
43 env = {'x': x0, 'y': 1.3}
44
45 for name, f in examples:
46 f = simplify(f)
47 print(f"f = {name!r} : {to_str(f)}")
48
49 d = simplify(deriv(f, 'x'))
50 print(f" d/dx = {to_str(d)}")
51
52 # numeric check of derivative (central finite difference)
53 h = 1e-6
54 fd = (evaluate(f, {'x': x0 + h, 'y': 1.3})
55 - evaluate(f, {'x': x0 - h, 'y': 1.3})) / (2 * h)
56 ok = abs(evaluate(d, env) - fd) <= tol * max(1.0, abs(fd))
57 print(f" derivative check: {'OK' if ok else 'FAIL'}")
58
59 try:
60 i = integrate(f, 'x')
61 print(f" ∫ dx = {to_str(i)}")
62
63 back = simplify(deriv(i, 'x'))
64 print(f" d/dx(∫) = {to_str(back)}")
65 ok2 = abs(evaluate(back, env) - evaluate(f, env)) <= tol * max(1.0, abs(evaluate(f, env)))
66 print(f" integral check: {'OK' if ok2 else 'FAIL'}")
67 except NotImplementedError as err:
68 print(f" integrate: not supported ({err})")
69 print()
70
71
72 if __name__ == '__main__':
73 main()
Three engineering details here are the difference between “prints some output” and “actually proves correctness”.
Central finite differences. The derivative check estimates
f'(x₀) as (f(x₀+h) - f(x₀-h)) / (2h) with h = 1e-6. The central
form (as opposed to the one-sided (f(x₀+h)-f(x₀))/h) is second-order
accurate, meaning its error shrinks as h². At h = 1e-6 the error is around
1e-12 in the function values, comfortably inside the 1e-6 tolerance,
provided h is not so small that floating-point cancellation in the
subtraction round the difference to zero, which is why h = 1e-6 rather than
1e-30.
A relative tolerance. The check
abs(symbolic - numeric) <= tol * max(1.0, abs(numeric)) scales the tolerance
to the magnitude of the compared value. For large values (2^x at x = 0.8
times log 2, say) an absolute 1e-6 bound would be too strict relative to
floating-point roundoff; the max(1.0, ...) keeps the test meaningful for both
tiny and huge values.
Differentiating the antiderivative back. The integral is verified by the
fundamental theorem itself: compute ∫f, differentiate it, and confirm the
result equals f numerically. This is a beautifully self-contained check:
it never requires a reference CAS to compare against, only the two operations
the program already implements. If either the integrator or the differentiator
were broken in a way that affected these expressions, the OK/FAIL lines
would expose it.
9. Running the Code
Save the listing above as sym-math.py (the filename contains a hyphen, which
is why the README shows how to load it with importlib rather than a plain
import). Run it from the directory:
1 python3 sym-math.py
There is nothing to install; the program uses only fractions and math.
The output is long, thirty-one examples each a small block, so below we
reproduce it in full as the program actually prints it:
1 f = 'constant' : 7
2 d/dx = 0
3 derivative check: OK
4 ∫ dx = 7*x
5 d/dx(∫) = 7
6 integral check: OK
7
8 f = 'x' : x
9 d/dx = 1
10 derivative check: OK
11 ∫ dx = 1/2*x^2
12 d/dx(∫) = x
13 integral check: OK
14
15 f = '3*x' : 3*x
16 d/dx = 3
17 derivative check: OK
18 ∫ dx = 3/2*x^2
19 d/dx(∫) = 3*x
20 integral check: OK
21
22 f = 'x^2' : x^2
23 d/dx = 2*x
24 derivative check: OK
25 ∫ dx = 1/3*x^3
26 d/dx(∫) = x^2
27 integral check: OK
28
29 f = 'x^3' : x^3
30 d/dx = 3*x^2
31 derivative check: OK
32 ∫ dx = 1/4*x^4
33 d/dx(∫) = x^3
34 integral check: OK
35
36 f = 'x^2 + 3*x' : x^2 + 3*x
37 d/dx = 2*x + 3
38 derivative check: OK
39 ∫ dx = 1/3*x^3 + 3/2*x^2
40 d/dx(∫) = x^2 + 3*x
41 integral check: OK
42
43 f = '2*x^3 - 5*x + 4' : 2*x^3 - 5*x + 4
44 d/dx = 6*x^2 - 5
45 derivative check: OK
46 ∫ dx = 1/2*x^4 - 5/2*x^2 + 4*x
47 d/dx(∫) = 2*x^3 - 5*x + 4
48 integral check: OK
49
50 f = '1/x' : x^-1
51 d/dx = -x^-2
52 derivative check: OK
53 ∫ dx = log(x)
54 d/dx(∫) = x^-1
55 integral check: OK
56
57 f = '1/x^2' : x^-2
58 d/dx = -2*x^-3
59 derivative check: OK
60 ∫ dx = -x^-1
61 d/dx(∫) = x^-2
62 integral check: OK
63
64 f = 'sqrt(x)' : x^(1/2)
65 d/dx = 1/2*x^(-1/2)
66 derivative check: OK
67 ∫ dx = 2/3*x^(3/2)
68 d/dx(∫) = x^(1/2)
69 integral check: OK
70
71 f = 'x^(3/2)' : x^(3/2)
72 d/dx = 3/2*x^(1/2)
73 derivative check: OK
74 ∫ dx = 2/5*x^(5/2)
75 d/dx(∫) = x^(3/2)
76 integral check: OK
77
78 f = 'sin(x)' : sin(x)
79 d/dx = cos(x)
80 derivative check: OK
81 ∫ dx = -cos(x)
82 d/dx(∫) = sin(x)
83 integral check: OK
84
85 f = 'cos(x)' : cos(x)
86 d/dx = -sin(x)
87 derivative check: OK
88 ∫ dx = sin(x)
89 d/dx(∫) = cos(x)
90 integral check: OK
91
92 f = 'exp(x)' : exp(x)
93 d/dx = exp(x)
94 derivative check: OK
95 ∫ dx = exp(x)
96 d/dx(∫) = exp(x)
97 integral check: OK
98
99 f = 'log(x)' : log(x)
100 d/dx = x^-1
101 derivative check: OK
102 ∫ dx = x*log(x) - x
103 d/dx(∫) = log(x) + 1 - 1
104 integral check: OK
105
106 f = 'sin(2*x)' : sin(2*x)
107 d/dx = 2*cos(2*x)
108 derivative check: OK
109 ∫ dx = -1/2*cos(2*x)
110 d/dx(∫) = sin(2*x)
111 integral check: OK
112
113 f = 'cos(3*x + 1)' : cos(3*x + 1)
114 d/dx = -3*sin(3*x + 1)
115 derivative check: OK
116 ∫ dx = 1/3*sin(3*x + 1)
117 d/dx(∫) = cos(3*x + 1)
118 integral check: OK
119
120 f = 'exp(-2*x)' : exp(-2*x)
121 d/dx = -2*exp(-2*x)
122 derivative check: OK
123 ∫ dx = -1/2*exp(-2*x)
124 d/dx(∫) = exp(-2*x)
125 integral check: OK
126
127 f = 'log(2*x + 3)' : log(2*x + 3)
128 d/dx = 2*(2*x + 3)^-1
129 derivative check: OK
130 ∫ dx = 1/2*((2*x + 3)*log(2*x + 3) - (2*x + 3))
131 d/dx(∫) = 1/2*(2*log(2*x + 3) + (2*x + 3)*(2*(2*x + 3)^-1) - 2)
132 integral check: OK
133
134 f = '2*sin(x)' : 2*sin(x)
135 d/dx = 2*cos(x)
136 derivative check: OK
137 ∫ dx = -2*cos(x)
138 d/dx(∫) = 2*sin(x)
139 integral check: OK
140
141 f = '3*x^2' : 3*x^2
142 d/dx = 6*x
143 derivative check: OK
144 ∫ dx = x^3
145 d/dx(∫) = 3*x^2
146 integral check: OK
147
148 f = 'x^2/2' : 1/2*x^2
149 d/dx = x
150 derivative check: OK
151 ∫ dx = 1/6*x^3
152 d/dx(∫) = 1/2*x^2
153 integral check: OK
154
155 f = '(2*x + 1)^3' : (2*x + 1)^3
156 d/dx = 6*(2*x + 1)^2
157 derivative check: OK
158 ∫ dx = 1/8*(2*x + 1)^4
159 d/dx(∫) = (2*x + 1)^3
160 integral check: OK
161
162 f = '1/(3*x + 2)' : (3*x + 2)^-1
163 d/dx = -3*(3*x + 2)^-2
164 derivative check: OK
165 ∫ dx = 1/3*log(3*x + 2)
166 d/dx(∫) = (3*x + 2)^-1
167 integral check: OK
168
169 f = '2^x' : 2^x
170 d/dx = 2^x*log(2)
171 derivative check: OK
172 ∫ dx = 2^x*log(2)^-1
173 d/dx(∫) = (2^x*log(2))*log(2)^-1
174 integral check: OK
175
176 f = 'y' : y
177 d/dx = 0
178 derivative check: OK
179 ∫ dx = y*x
180 d/dx(∫) = y
181 integral check: OK
182
183 f = 'y*x' : y*x
184 d/dx = y
185 derivative check: OK
186 ∫ dx = y*(1/2*x^2)
187 d/dx(∫) = y*x
188 integral check: OK
189
190 f = 'exp(x) + sin(x)' : exp(x) + sin(x)
191 d/dx = exp(x) + cos(x)
192 derivative check: OK
193 ∫ dx = exp(x) - cos(x)
194 d/dx(∫) = exp(x) + sin(x)
195 integral check: OK
196
197 f = 'x*exp(x)' : x*exp(x)
198 d/dx = exp(x) + x*exp(x)
199 derivative check: OK
200 integrate: not supported (cannot integrate x*exp(x))
201
202 f = 'x*sin(x)' : x*sin(x)
203 d/dx = sin(x) + x*cos(x)
204 derivative check: OK
205 integrate: not supported (cannot integrate x*sin(x))
206
207 f = 'x*cos(x) + sin(x)' : x*cos(x) + sin(x)
208 d/dx = cos(x) + x*(-sin(x)) + cos(x)
209 derivative check: OK
210 integrate: not supported (cannot integrate x*cos(x))
Every example ends with OK on both checks, and the final three examples
report integrate: not supported, exactly as designed.
10. Interpreting the Results
The output a sequence of small mathematical arguments, each independently verified:
The derivative check: OK lines prove the symbolic differentiator agrees
with numeric reality. Every derivative (constant, power, product, chain
rule, the two power-rule branches, and the four transcendental functions) is
compared against a central finite-difference estimate at x = 0.8. If any
rule were implemented with a wrong sign or a missing chain factor, that single
numeric comparison would catch it. The fact that all thirty-one pass is
strong evidence that the rule table is correct, not just that the code parses.
The integral check: OK lines prove the integrator inverts the
differentiator, the deepest property of the whole system. The program does
not rely on a reference table of integrals. It computes ∫f, then differentiates
the answer, then confirms d/dx(∫f) = f at the same x. This is the
Fundamental Theorem of Calculus used as a runtime assertion. Notice that
this check exercises the two hardest rules jointly: for log(2*x + 3) the
printed antiderivative is 1/2*((2*x + 3)*log(2*x + 3) - (2*x + 3)), and its
rederivative, 1/2*(2*log(2*x + 3) + (2*x + 3)*(2*(2*x + 3)^-1) - 2), is
an ugly expression that only simplifies numerically to the original. The
OK on that line is genuine, non-trivial verification of the log integration
rule.
The output also demonstrates the two design properties we set out to
achieve. First, exactness: coefficients appear as 1/3, 2/5, 3/2,
rational numbers, not 0.333333…. This is the Fraction-based representation
paying off; nothing was ever rounded. Second, honesty at the boundary: the
last three expressions (x·exp(x), x·sin(x), x·cos(x) + sin(x)) each get a
correct derivative (x·exp(x) → exp(x) + x·exp(x)) but then print
integrate: not supported instead of a guessed answer. Those integrals require
integration by parts, deliberately out of scope, and the system says so in
plain text. A production CAS would integrate them; a teaching CAS is more
correct for refusing than for silently returning a wrong result.
The 2^x case quietly demonstrates both power-rule branches working in
tandem. Its derivative is 2^x*log(2) (the c^f(x) branch of deriv); its
integral is 2^x*log(2)^-1 (the c^(a·x+b) branch of integrate_pow). The
inverse relationship between the two is exactly the 1/log(2) factor flipping
between numerator and denominator, a one-line illustration that
differentiation and integration are true inverses here, confirmed numerically.
Two cosmetic oddities in the output are worth understanding, not
“fixing”. First, 1/x prints as x^-1 and its integral prints as log(x)
because negative exponents are kept as powers (they fall out of the power rule), so
the printer never writes them as fractions. Second, in the log(x) example the
line d/dx(∫) = log(x) + 1 - 1 shows an expression that is numerically log(x)
but not fully simplified, because the simplifier does not cancel the +1 - 1
that arises from the product rule applied to x·log(x). The numeric check
still passes; 1 - 1 cancels exactly in floating point; but it is a visible
reminder that the simplifier is a subset of a real CAS, not a complete one.
11. What to Build Next
The architecture makes each enhancement a local change, which is the real test of a clean symbolic design:
- More transcendental functions (
tan,asin,sqrtas a distinct node): add one builder, one derivative case, one integral case, and one printer case. - Integration by parts: recognize the
mul-of-two-x-dependent-factors pattern and apply∫u dv = uv - ∫v du; this would turn the threenot supportedlines into answers. - A full simplifier: normalize polynomials into sorted monomial form, cancel
+1 - 1, and combinelogfactors; this removes the cosmeticlog(x) + 1 - 1artifact. - A parser: turn the string
"x^2 + 3*x"into the tree; this invertsto_strand rounds out the builder DSL into a true little language.
Each suggestion extends the same loop: represent → simplify → differentiate → integrate → verify. That loop, not any single rule, is the enduring takeaway. A computer algebra system is not magic; it is a data structure, a handful of recursive rewrite rules, and the discipline to refuse what it does not know.