Rationalising Denominators 3: Sums
Posts in this series:
Introduction
In the previous
post we generalised our Power
type into a
Product
of arbitrarily-many powers multiplied together.
However, there are values which can’t be represented that way, such as
1+√7. Instead, these can be represented as a sum of such
products, which we’ll define in this post.
Sums
We define Sum
in a similar way to Product
,
as a list of Python values:
def Sum(*products: Product) -> List[Product]:
return list(products)
Arithmetic With Sums
Here are some useful constants:
= Sum(product_zero)
sum_zero = Sum(product_one)
sum_one = Sum(product_neg) sum_neg
Adding Sum
values is easy, just concatenate their
lists:
def add_sums(*sums) -> Sum:
return Sum(*sum(sums, []))
To multiply two Sum
values, we multiply each element of
the first with each element of the second, and take the
Sum
of all those. We can generalise this to any
number of Sum
values by starting with sum_one
and multiplying it by each of the given values one after another (the
order doesn’t matter, since we’re assuming it’s associative
and commutative).
In programming terms this is a fold
(which Python insists on calling “reduce
”…):
def multiply_sums(*sums) -> Sum:
return reduce(
lambda s1, s2: Sum(*[
multiply_products(p1, p2)for p1 in s1
for p2 in s2
]),
sums,
sum_one )
Finally, this helper tells us when a Sum
contains no
roots (i.e. no fractional powers):
def sum_is_rational(s: Sum) -> bool:
return all(product_is_rational(p) for p in s)
Normalising
Just like our other representations, we want to normalise
Sum
values, so that each number is represented in exactly
one way.
Permutations
The order of elements in a Sum
does not affect its
value, so we can sort them into a canonical order (the precise order
doesn’t matter, so long as elements are always sorted in the same
way):
def Sum(*products: Product) -> List[Product]:
return sorted(list(products))
Common Factors
When two elements of a Sum
have a common set of
fractional/irrational Power
s they can be combined into a
single Product
. We do this in two steps. First, we split
each Product
into a rational part (containing no fractional
exponent
s) and an irrational part (containing only
fractional exponent
s); if a Product
lacks such
Power
s, we use the number one instead:
def split_rationals(product: Product) -> Tuple[Product, Product]:
if product == product_zero:
return (product_zero, product_one)
= lambda power: reduce(
go
multiply_products,for base, exp in product],
[Product(Power(base, power(exp)))
product_one
)return (
lambda e: e // 1), # rationals
go(lambda e: e % 1) # irrationals
go( )
We can combine those products whose irrational parts are the same, by adding their rational parts:
def irrationals_coefficients(products_list: Sum):
= {}
result for product in products_list:
= split_rationals(product)
rationals, irrationals = frozenset(irrationals)
key if key not in result:
= 0
result[key] += eval_product_int(rationals)
result[key] return result
This will also take care of “cancelling out” positives and negatives,
like Sum(product_one, product_neg)
.
We use eval_product_int
(defined in the previous post)
to turn the rational part of a Product
into a raw Python
int
; this is possible since the rational part won’t contain
any roots (by definition), and hence is just some common multiple of the
base
int
egers.
To incorporate this into our normalisation, we turn each of these
int
values into a Power
with
exponent
one, and wrap in a Product
:
def Sum(*products: Product) -> List[Product]:
# This Sum has a term for each distinct irrational product, multiplied by
# the sum of all the rationals it appeared with in the input
= irrationals_coefficients(products)
coefficients return sorted([
multiply_products(*irrational),
Product(1, 1)))
Product(Power(coeff, Fraction(
)for irrational, coeff in coefficients.items()
])
Skipping Zeroes
Adding zero to a Sum
does not change its value, so we
filter out such elements (similar to how we filter out ones from a
Product
):
return = sorted([
multiply_products(sorted(list(irrational)),
1, 1)))
Product(Power(coeff, Fraction(
)for irrational, coeff in coefficients.items()
if coeff != 0
])
Notice that we define sum_zero = Sum(product_zero)
, but
this will actually get normalised to the empty sum
Sum()
, which is the normal form for the zero sum.
Fixed Point
Similar to Product
, we can keep repeating the
normalisation of a Sum
, until no more changes are made to
its elements:
= sorted([
result
multiply_products(*irrational),
Product(1, 1)))
Product(Power(coeff, Fraction(
)for irrational, coeff in coefficients.items()
if coeff != 0
])return result if list(result) == list(products) else Sum(*result)
Conclusion
We’ve defined a Sum
type, which is a pretty simple and
natural step up from our Product
type, and allows us to
represent more numbers. This Sum
type has no redundancy
(like Product
, but unlike Power
). To normalise
these values we split each of its elements into a rational part and an
irrational part, then add those rational parts as ordinary Python
int
egers.
In the next part we’ll introduce one more type by taking ratios of these sums!
Appendixes
Implementation
Here’s the final implementation of Sum
, assuming that we
have implementations of Product
and Power
defined:
def Sum(*products: Product) -> List[Product]:
# This Sum has a term for each distinct irrational product, multiplied by
# the sum of all the rationals it appeared with in the input
= irrationals_coefficients(products)
coefficients = sorted([
result
multiply_products(*irrational),
Product(1, 1)))
Product(Power(coeff, Fraction(
)for irrational, coeff in coefficients.items()
if coeff != 0
])return result if list(result) == list(products) else Sum(*result)
def add_sums(*sums) -> Sum:
return Sum(*sum(sums, []))
def multiply_sums(*sums) -> Sum:
return reduce(
lambda s1, s2: Sum(*[
multiply_products(p1, p2)for p1 in s1
for p2 in s2
]),
sums,
sum_one
)
def split_rationals(product: Product) -> Tuple[Product, Product]:
if product == product_zero:
return (product_zero, product_one)
= lambda power: reduce(
go
multiply_products,for base, exp in product],
[Product(Power(base, power(exp)))
product_one
)return (
lambda e: e // 1), # rationals,
go(lambda e: e % 1) # irrationals
go(
)
def irrationals_coefficients(s: Sum):
= {}
result for product in s:
= split_rationals(product)
rationals, irrationals = frozenset(irrationals)
key if key not in result:
= 0
result[key] += eval_product_int(rationals)
result[key] return result
def sum_is_rational(s: Sum) -> bool:
return all(product_is_rational(p) for p in s)
= Sum(product_zero)
sum_zero = Sum(product_one)
sum_one = Sum(product_neg) sum_neg
Property Tests
The following tests extend those for Product
, this time
checking various properties that should hold for our Sum
type:
@st.composite
def sums(draw, rational=None):
= draw(st.lists(
main # All terms of a rational sum are rational; irrational can be a mixture
=True if rational == True else None),
products(rational=0,
min_size=3
max_size
))= [draw(products(rational))] if rational == False else []
extra = Sum(*(main + extra))
normal if rational is None:
return normal
= sum_is_rational(normal)
is_rat == is_rat)
assume(rational return normal
@settings(deadline=None)
@given(st.data())
def test_strategy_sums(data):
data.draw(sums())
@given(sums(rational=True))
def test_strategy_sums_can_force_rational(s):
assert sum_is_rational(s)
@given(sums(rational=False))
def test_strategy_sums_can_force_irrational(s):
assert not sum_is_rational(s)
@given(products())
def test_split_rationals_preserves_terms(p: Product):
= split_rationals(p)
rationals, irrationals
for base, exp in rationals:
assert exp.denominator == 1
for base, exp in irrationals:
assert exp.denominator > 1
assert exp.numerator < exp.denominator
assert multiply_products(rationals, irrationals) == p
@given(products())
def test_coefficients_agree_for_singleton(p: Product):
= irrationals_coefficients([p])
coeffs assert len(coeffs) == 1
= split_rationals(p)
rationals, irrationals = frozenset(irrationals)
key assert key in coeffs
assert eval_product_int(rationals) == coeffs[key]
@given(products(), products())
def test_coefficients_commutes(p1: Product, p2: Product):
= irrationals_coefficients([p1, p2])
coeffs_12 = irrationals_coefficients([p2, p1])
coeffs_21 assert coeffs_12 == coeffs_21
@given(sums())
def test_coefficients_fractions_are_proper(s: Sum):
for irrationals, coeff in debug(coeffs=irrationals_coefficients(s)).items():
for base, exp in irrationals:
assert exp.denominator > 1
assert exp.numerator < exp.denominator
@settings(deadline=None)
@given(sums())
def test_coefficients_multiply_back_to_original(s: Sum):
"""Use output of irrationals_coefficients to reconstruct a Sum, it should
normalise to equal the input."""
= []
terms for irrationals, coeff in irrationals_coefficients(s).items():
abs(coeff) < 100) # Avoid crazy-big lists!
assume(= [power_neg] if coeff < 0 else []
neg = [list(irrationals) + neg] * abs(coeff)
extra =terms, irrationals=irrationals, coeff=coeff, extra=extra)
debug(terms+= extra
terms = Sum(*terms)
normal =terms, normal=normal)
debug(total_termsassert normal == s
@given(sums())
def test_normalise_sum_idempotent(s: Sum):
assert s == Sum(*s)
@given(sums())
def test_multiply_sums_identity(s: Sum):
assert multiply_sums(s, sum_one) == s, "1 is right identity"
assert multiply_sums(sum_one, s) == s, "1 is left identity"
@given(sums())
def test_multiply_sums_absorb(s: Sum):
assert multiply_sums(s, sum_zero) == sum_zero, "0 absorbs on right"
assert multiply_sums(sum_zero, s) == sum_zero, "0 absorbs on left"
@settings(deadline=90000)
@given(sums(), sums())
def test_multiply_sums_commutative(s1: Sum, s2: Sum):
= multiply_sums(s1, s2)
result_12 = multiply_sums(s2, s1)
result_21 assert result_12 == result_21
@settings(deadline=1000)
@given(sums(), sums(), sums())
def test_multiply_sums_associative(s1: Sum, s2: Sum, s3: Sum):
= multiply_sums(s1, s2)
result_12 = multiply_sums(s2, s3)
result_23 assert multiply_sums(result_12, s3) == multiply_sums(s1, result_23)
@settings(deadline=90000)
@given(sums(), sums(), sums())
def test_multiply_sums_distributive(s1: Sum, s2: Sum, s3: Sum):
= multiply_sums(s1, Sum(*(s2 + s3)))
s1_23 = Sum(
s12_13 *(multiply_sums(s1, s2) + multiply_sums(s1, s3))
)assert s1_23 == s12_13
@given(sums())
def test_negative_sums_cancel(s: Sum):
assert sum_zero == add_sums(
s,for p in s]
[multiply_products(p, product_neg)
)
@settings(deadline=30000)
@given(products(), st.integers(min_value=1, max_value=10))
def test_like_products_add(p: Product, n: int):
assert Sum(*([p] * n)) == Sum(multiply_products(p, factorise(n)))