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 Product
s, which we’ll define in this
post.
Sums
Our initial definition of Sum
will begin in a similar way to how we approached Product
, as a list of values:
def Sum(*products: Product) -> List[Product]:
return list(products)
Normalising
Just like our other representations, we want to normalise Sum
values, so that each number is
represented in exactly one way.
Combining multiples
The first problem with our representation is that a Sum
containing N copies of the same
Product
should be the same as a
Sum
containing that Product
once, multiplied by a factor N.
More generally, a Sum
containing
Product
values of the form P×M₁
and P×M₂, should be the same as a Sum
which instead contains
P×(M₁+M₂).
The key to making this tractable is that a Sum
can only have a rational, whole
number of terms. Hence we can begin by restricting our attention to
those factors M₁, M₂, etc. in each Product
which are rational, whole
numbers. The following function will split a Product
into a whole part (containing
all of the whole exponents) and a fractional/irrational part (containing
any remaining fractions of exponents):
def split_fractionals(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.items()],
[Product(Power(base, power(exp)))
product_one
)return (
lambda e: e // 1), # whole exponents
go(lambda e: e % 1), # fractional exponents
go( )
Since the part with whole-number exponents is guaranteed to be a
whole number, we can instead return
that as
a native Python int
eger (which
also makes it easier to distinguish which part is which):
def split_fractionals(product: Product) -> Tuple[Product, int]:
if product == product_zero:
return (0, product_one)
= lambda power: reduce(
go
multiply_products,for base, exp in product.items()],
[Product(Power(base, power(exp)))
product_one
)return (
lambda e: e // 1)), # whole exponents
eval_product_int(go(lambda e: e % 1), # fractional exponents
go( )
Now we can check if any terms have the same fractional parts, and if
so combine their whole-number parts; just like the example of P×M₁ +
P×M₂ = P×(M₁+M₂). We’ll use a Counter
, provided by Python’s collections
module, to add up all of
the whole number multiples (AKA coefficients) for each Product
of fractional exponents (AKA
irrationals):
def irrationals_coefficients(s: List[Product]) -> Counter:
= Counter()
result for p in s:
= split_fractionals(p)
count, fractional # The key for our Counter must be hashable, so make a tuple. Sort it, to
# normalise all permutations of factors into the same order.
= tuple(sorted(list(Product(*fractional.items()).items())))
irrational += count # Counters begin at 0 if not yet assigned
result[irrational] return result
We’ll use this Counter
as our
representation of Sum
, rather
than the naïve list we started with:
def Sum(*products: Product) -> Counter:
return irrationals_coefficients(products)
Negatives
So far, we’ve seen that rational, whole number factors are important.
So far our irrationals_coefficients
function is
assuming that this means factors with whole-numbered exponents; but
we’ve previously decided to encode negative one as
√1, which is a rational,
whole number that has a fractional exponent!
We’ll leave the split_fractionals
function as-is, and
account for negative one by post-processing its result in irrationals_coefficients
(this is why I
named them differently, one with “fractional” and one with “irrational”,
since they’re handling subtley different concepts):
def irrationals_coefficients(s: List[Product]) -> Counter:
= Counter()
result for p in s:
= split_fractionals(p)
count, fractional if fractional.get(1, zero) >= half:
# Shift a factor of 1^(1/2) from irrationals to rationals, since
# neg_power is rational
= Power(1, fractional.get(1, zero))
base, exp if exp < one and exp >= half:
= count * (-1)
count -= half
exp = fractional | {1: exp}
fractional if fractional.get(1, None) == zero:
# Avoid redundant factors appearing in .items()
del(fractional[1])
= tuple(sorted(list(Product(*fractional.items()).items())))
irrational += count
result[irrational] return result
Skipping zeroes
We can add a final tweak to the end of irrationals_coefficients
, to remove any
elements that end up with a count of zero (otherwise Counter
may keep them around):
+= count
result[irrational] if result[irrational] == 0:
del(result[irrational])
return result
Notice that with this change, our definition of sum_zero = Sum(product_zero)
will normalise to the
empty sum, as if we had defined it as Sum()
instead!
Normalising recursively
To simplify later steps, we’ll implement a couple of passes through
the structure of a Sum
; allowing
smaller parts to be normalised in isolation. First we’ll need a way to
compare two Sum
values, to see if
they’ve reached a fixed-point of our normalisation process. We can do
that by putting their contents into a sequence, and sorting that into
ascending order:
def s_items(coefficients):
return tuple(sorted(coefficients.items()))
def sums_are_same(x, y):
return s_items(x) == s_items(y)
Next we’ll defining a normalise_sum
function to hold our
subsequent processing and call it from the top-level Sum
constructor:
def Sum(*products: Product) -> Counter:
return normalise_sum(irrationals_coefficients(products))
def normalise_sum(s: Sum) -> Sum:
if len(s) < 2:
# If we have 0 or 1 terms, there's no summing to do.
return s
# Other normalisation steps can go here
return s
Holding out terms
One way to isolate part of a Sum
is to remove some terms, normalise
without them, then add them back after. That last part requires an
addition function for Sum
s, which
we can implement by accumulating the contents of their Counter
s:
def add_sums(*sums) -> Sum:
= Counter()
result for s in sums:
# Annoyingly, we can't just result += s, since that ignores negatives
for k in s:
+= s[k]
result[k] return normalise_sum(result)
Then we can add the following loop into normalise_sum
, which will hold out each
term individually and see if the remaining terms normalise any more
without it: if so, add the held-out term to that new result and recurse;
otherwise carry on to the next iteration. This combination of looping
and recursing ensures that we’ll try to normalise every distinct subset
of terms before giving up.
# Try holding-out each term at a time. Recursion will take care of trying
# each combination of held-out terms.
for term, count in s.items():
# Make a copy of s with this term deleted
= Counter(s)
init del(init[term])
# See if normalising that subset changes its contents
= normalise_sum(init)
norm if not sums_are_same(init, norm):
# Normalising the subset made progress; add it to the held-out term
= Product(power_one if count >= 0 else power_neg, *term)
signed return add_sums(norm, Counter({term: count}))
Holding out common factors
The second way we can hold-out part of a Sum
is to divide-through by its common
factors, normalise what’s left, then multiply the result by the held-out
factors. This is especially powerful thanks to the holding-out of terms
we just implemented above; since we’re more likely to find common
factors in a subset of terms.
We’ll start with a multiplication function for Sum
s; although that’s easier to
implement for our original list-of-Product
representation, so we’ll
include a function to convert into that format as well:
def _sum_to_products(coefficients):
return _sort([
Product(abs(coeff), one),
Power(if coeff < 0 else power_one,
power_neg *irrational
)for irrational, coeff in coefficients.items()
if coeff != 0
])
def _sort(s: Sum) -> Sum:
return sorted(list(s), key=lambda p: sorted(p.items()))
def multiply_sums(*sums: Sum) -> Sum:
# Skip multiplication by 1
= [s for s in sums if s != sum_one]
remaining if len(remaining) == 0:
return sum_one # Empty product
*rest = remaining
first, # Short-circuit if we're only multiplying one term
if len(rest) == 0:
return first
return reduce(
lambda s1, s2: Sum(*[
multiply_products(p1, p2)for p1 in _sum_to_products(s1)
for p2 in _sum_to_products(s2)
]),
rest,
first )
Next we’ll need to identify common factors:
def common_factors_of_sum(s: Sum) -> Product:
if s == sum_zero:
return product_one
*rest = _sum_to_products(s)
first, = reduce(
found lambda common, product: {
min(common[base], augmented[base])
base: for base in common
for augmented in [{1: one} | product]
if base in augmented
},
rest,
{
base: expfor base, exp in ({1: one} | first).items()
}
)# Avoid factors of 1^0 AKA 1^1
if found.get(1, None) in [zero, one]: del(found[1])
return found
And we need a way to “divide-through” by a common factor. Since our
numeric representation doesn’t support arbitrary divisions yet, we’ll
make a specialised function that relies on the divisor being a factor.
To avoid infinite recursion we’ll give this function a bool
ean
parameter called raw
, to indicate
when we don’t want it to attempt further normalisation:
def remove_common_factor(s: Sum, p: Power, raw=False) -> Sum:
if p == power_one:
return s # Divide by 1 is a no-op
= p
base, exp = _sum_to_products(s)
old_products
# Check our precondition, that p is a common factor of the terms in s
= all(base in product for product in old_products)
has_base if not (has_base or base == 1):
raise AssertionError(
f"remove_common_factor({s}, {p}, {raw})"
)
= irrationals_coefficients([
coeffs *(
Product(
[- exp if b == base else e) for b, e in product.items()
Power(b, e + ([] if base != 1 or base in product else [Power(1, one - exp)])
]
))for product in old_products
])return coeffs if raw else normalise_sum(coeffs)
Finally we can use these in our normalise_sum
function to hold-out
common factors:
# Try holding-out each common factor at a time. Recursion will take care of
# trying each combination of held-out factors.
= common_factors_of_sum(s)
factors = factors.items()
factors_list for (base, exp) in factors_list:
= remove_common_factor(s, (base, exp), raw=True)
rest = normalise_sum(rest)
norm if not sums_are_same(norm, rest):
return multiply_sums(norm, Sum(Product(Power(base, exp))))
Allowing remainders
The above implementation of common_factors_of_sum
is actually quite
limited, since it will only accept factors divide exactly into
every element of a Sum
. However,
it will sometimes be useful to hold-out factors which leave a (whole,
rational) remainder after division. To support that, we’ll need a way to
split the rational part off a Sum
:
def _split_rational(s: Sum) -> Tuple[Sum, int]:
= Counter(s)
coeffs = coeffs[tuple()]
rational del(coeffs[tuple()])
return (coeffs, rational)
Now we can handle the rational part separately when looking for common factors:
def common_factors_of_sum(s: Sum, allow_remainder=False) -> (Product, int):
"""Returns a Product whose Powers appear in every term of the given Sum.
This takes into account bases with differing exponents, by returning the
smallest. If allow_remainder, rationals (Powers of 1^0 or 1^0.5) are allowed
to leave a remainder, which is returned as the second part of the tuple."""
if allow_remainder:
= _split_rational(s)
to_factor_coeffs, const else:
= s
to_factor_coeffs = 0
const
if to_factor_coeffs == sum_zero:
if const:
return (
multiply_products(abs(const), one)),
Product(Power(if const >= 0 else product_neg,
product_one
),0
)else:
return (product_one, 0)
*rest = _sum_to_products(to_factor_coeffs)
first, = reduce(
found lambda common, product: {
min(common[base], augmented[base])
base: for base in common
for augmented in [{1: one} | product]
if base in augmented
},
rest,
{
base: expfor base, exp in ({1: one} | first).items()
}
)# Avoid factors of 1^0 AKA 1^1
if found.get(1, None) in [zero, one]: del(found[1])
if const:
# If we held out the rational part, see if we can divide it by found
= _split_rational(
factored_coeffs, remainder
irrationals_coefficients([found])
)if factored_coeffs == sum_zero:
# No irrational part, so we can divide our held-out const
return (found, const % remainder)
return (found, const)
Here’s the corresponding change in normalise_sum
:
# Try holding-out each common factor at a time. Recursion will take care of
# trying each combination of held-out factors.
= common_factors_of_sum(s, allow_remainder=True)
factors, remainder = _subtract_int_from_sum(s, remainder)
reduced = factors.items()
factors_list for (base, exp) in factors_list:
= remove_common_factor(reduced, (base, exp), raw=True)
rest = normalise_sum(rest)
norm if not sums_are_same(norm, rest):
return add_sums(
Sum(multiply_products(abs(remainder), 1)),
Product(Power(if remainder < 0 else product_one
product_neg
)),
multiply_sums(norm, Sum(Product(Power(base, exp)))) )
Conclusion
We’ve defined a Sum
type,
which is a natural progression from our Product
type, and allows us to
represent more numbers. Our representation normalises many redundant
aspects of a Sum
, including
permutations of the order of terms, skipping terms which are zero,
combining multiple copies of the same term (including those appearing
with extra factors), and having negative multiples cancel-out positive
ones.
The normalisation of Sum
is
actually overkill for the techniques seen in this blog post, but will
prove invaluable when we implement roots of unity in a
subsequent installment.
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) -> Counter:
"""This Sum has a term for each distinct irrational product, multiplied by
the sum of all the rationals it appeared with in the input."""
# Combine coefficients of like terms
return normalise_sum(irrationals_coefficients(products))
def _sort(s: Sum) -> Sum:
return sorted(list(s), key=lambda p: sorted(p.items()))
def items(s: Sum) -> List[tuple[Power]]:
"""Convert the Products in a Sum into lists for comparison."""
return sorted(map(p_items, s))
def p_items(p: Product):
return sorted(p.items())
def s_items(coefficients):
return tuple(sorted(coefficients.items()))
def normalise_sum(s: Sum) -> Sum:
if len(s) < 2:
# If we have 0 or 1 terms, there's no summing to do.
return s
for coeff in s.values():
check_size(coeff)
# Try holding-out each common factor at a time. Recursion will take care of
# trying each combination of held-out factors.
= common_factors_of_sum(s, allow_remainder=True)
factors, remainder = _subtract_int_from_sum(s, remainder)
reduced = factors.items()
factors_list for (base, exp) in factors_list:
= remove_common_factor(reduced, (base, exp), raw=True)
rest = normalise_sum(rest)
norm if not sums_are_same(norm, rest):
return add_sums(
Sum(multiply_products(abs(remainder), 1)),
Product(Power(if remainder < 0 else product_one
product_neg
)),
multiply_sums(norm, Sum(Product(Power(base, exp))))
)
# Try holding-out each term at a time. Recursion will take care of trying
# each combination of held-out terms.
for term, count in s.items():
= Counter(s)
init del(init[term])
= normalise_sum(init)
norm if not sums_are_same(init, norm):
= Product(power_one if count >= 0 else power_neg, *term)
signed return add_sums(norm, Counter({term: count}))
# Otherwise, we're finished; just remove anything that's become zero
for k in [k for k, v in s.items() if v == zero]:
del(s[k])
return s
def sums_are_same(x, y):
return s_items(x) == s_items(y)
def _subtract_int_from_sum(s: Sum, i: int):
= Counter(s)
result tuple()] -= i
result[if result[tuple()] == 0:
del(result[tuple()])
return result
def _split_rational(s: Sum) -> Tuple[Sum, int]:
= Counter(s)
coeffs = coeffs[tuple()]
rational del(coeffs[tuple()])
return (coeffs, rational)
def _sum_to_products(coefficients):
return _sort([
Product(abs(coeff), one),
Power(if coeff < 0 else power_one,
power_neg *irrational
)for irrational, coeff in coefficients.items()
if coeff != 0
])
def add_sums(*sums) -> Sum:
= Counter()
result for s in sums:
# Annoyingly, we can't just result += s, since that ignores negatives
for k in s:
+= s[k]
result[k] return normalise_sum(result)
def multiply_sums(*sums: Sum) -> Sum:
# Skip multiplication by 1
= [s for s in sums if s != sum_one]
remaining if len(remaining) == 0:
# Empty product
return sum_one
*rest = remaining
first, # Short-circuit if we're only multiplying one term
if len(rest) == 0:
return first
return reduce(
lambda s1, s2: Sum(*[
multiply_products(p1, p2)for p1 in _sum_to_products(s1)
for p2 in _sum_to_products(s2)
]),
rest,
first
)
def split_fractionals(product: Product) -> Tuple[Product, int]:
if product == product_zero:
return (0, product_one)
= lambda power: reduce(
go
multiply_products,for base, exp in product.items()],
[Product(Power(base, power(exp)))
product_one
)return (
lambda e: e // 1)), # whole exponents
eval_product_int(go(lambda e: e % 1), # fractional exponents
go(
)
def irrationals_coefficients(s: List[Product]) -> Counter:
= Counter()
result for p in s:
= split_fractionals(p)
count, fractional if fractional.get(1, zero) >= half:
# Shift a factor of 1^(1/2) from irrationals to rationals, since
# neg_power is rational
= Power(1, fractional.get(1, zero))
base, exp if exp < one and exp >= half:
= count * (-1)
count -= half
exp = fractional | {1: exp}
fractional if fractional.get(1, None) == zero:
# Avoid redundant factors appearing in .items()
del(fractional[1])
= tuple(sorted(list(Product(*fractional.items()).items())))
irrational += count
result[irrational] if result[irrational] == 0:
del(result[irrational])
return result
= cached(
irrationals_coefficients
irrationals_coefficients,=_key_products
to_key
)
def is_zero(s: Sum) -> bool:
for k in [k for k in s if s[k] == 0]:
del(s[k])
return s == sum_zero
def common_factors_of_sum(s: Sum, allow_remainder=False) -> (dict, int):
"""Returns a Product whose Powers appear in every term of the given Sum.
This takes into account bases with differing exponents, by returning the
smallest. If allow_remainder, rationals (Powers of 1^0 or 1^0.5) are allowed
to leave a remainder, which is returned as the second part of the tuple."""
if allow_remainder:
= _split_rational(s)
to_factor_coeffs, const else:
= s
to_factor_coeffs = 0
const
if to_factor_coeffs == sum_zero:
if const:
return (
multiply_products(abs(const), one)),
Product(Power(if const >= 0 else product_neg,
product_one
),0
)else:
return (product_one, 0)
*rest = _sum_to_products(to_factor_coeffs)
first, = reduce(
found lambda common, product: {
min(common[base], augmented[base])
base: for base in common
for augmented in [{1: one} | product]
if base in augmented
},
rest,
{
base: expfor base, exp in ({1: one} | first).items()
}
)# Avoid factors of 1^0 AKA 1^1
if found.get(1, None) in [zero, one]: del(found[1])
if const:
# If we held out the rational part, see if we can divide it by found
= _split_rational(
factored_coeffs, remainder
irrationals_coefficients([found])
)if factored_coeffs == sum_zero:
# No irrational part, so we can divide our held-out const
return (found, const % remainder)
return (found, const)
def remove_common_product(s: Sum, p: Product, raw=False) -> Sum:
return reduce(
lambda s2, p2: remove_common_factor(
s2,
p2,=raw
raw
),
p.items(),
s
)
def remove_common_factor(s: Sum, p: Power, raw=False) -> Sum:
if p == power_one:
return s
= p
base, exp = _sum_to_products(s)
old_products = all(base in product for product in old_products)
has_base if not (has_base or base == 1):
raise AssertionError(
f"remove_common_factor({s}, {p}, {raw})"
)
= irrationals_coefficients([
coeffs *(
Product(
[- exp if b == base else e) for b, e in product.items()
Power(b, e + ([] if base != 1 or base in product else [Power(1, one - exp)])
]
))for product in old_products
])return coeffs if raw else normalise_sum(coeffs)
def sum_is_rational(s: Sum) -> bool:
if s == sum_zero:
return True
if len(s) == 1:
return tuple() in s
return False
def evaluate_sum(s: Sum) -> float:
return sum([evaluate_product(p) for p in _sum_to_products(s)], 0.0)
= Counter()
sum_zero = Counter([tuple()])
sum_one = Counter({tuple(): -1}) 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)))