Rationalising Denominators 3: Sums
Posts in this series:
A word on notation
This page deals with a few different concepts, which I’ve tried to use in a consistent way: “numbers” are the platonic entities we want to talk about; mathematical notation is used to describe numbers; and Python code is used for our specific, concrete implementation. I’ve annotated the mathematical notation with its corresponding Python code, where relevant.
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)
The underlying representation will change later, but this is enough to define some useful constants:
= Sum(product_zero)
sum_zero = Sum(product_one)
sum_one = Sum(product_neg) sum_neg
The following function will also come in handy:
def is_zero(s: Sum) -> bool:
return len(s) == 0
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[int, Product]:
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.
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 = Power(1, fractional.get(1, zero))
base, exp if exp >= half:
# Shift a factor of 1^(1/2) from irrationals to rationals, since
# neg_power is rational
= 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
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 define a normalise_sum
function to hold our
subsequent processing and call it from the top-level Sum
constructor. The first thing this
function does is remove any terms that have a coefficient of zero, to
simplify the rest of the processing.
def Sum(*products: Product) -> Counter:
return normalise_sum(irrationals_coefficients(products))
def normalise_sum(s: Sum) -> Sum:
# Remove any terms that are zero
= Counter({k: v for k, v in s.items() if v != zero})
s
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
Normalising subsets
One way to simplify a complex Sum
is to break it down into smaller
parts, normalise them individually, and then combine the results. We
could try every possible subset of terms (for example, by holding out
one term at a time and recursing), but that would be very inefficient
since the number of subsets grows exponentially. A better approach is to
be more selective, and only try to normalise subsets of terms that are
likely to simplify, such as those which share common factors.
To do this, we’ll need a way to add Sum
s together, for when we re-combine a
normalised subset with the rest of the terms. We can implement this by
accumulating the contents of their Counter
s:
def add_sums(*sums, normalise=True) -> 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) if normalise else result
Notice the normalise=True
parameter; this is a useful
flag to prevent recursively calling normalise_sum
when
we’re already in the middle of a normalisation step.
Next, we need a helper function to identify promising subsets of
terms. The following _partial_sums
function will find groups
of terms that are “connected” by shared factors. For example, in a sum
like AB + BC + CD + EF
, the terms AB
,
BC
, and CD
are connected (since
AB
shares B
with BC
, and
BC
shares C
with CD
), but
EF
is not connected to them. The function will return these
groups, largest first, and will skip any group that is a subset of a
larger one that’s already been returned.
def _partial_sums(s: Sum):
# Collect up terms which share the same base
= defaultdict(set)
by_base for prod in s:
for base, _ in prod:
by_base[base].add(prod)if 1 in by_base and () in s:
1].add(())
by_base[
# Drop any base which occurs in *every* term, since it doesn't give us a
# (proper) subset
= len(s)
total for base, terms in list(by_base.items()):
if len(terms) == total:
del(by_base[base])
= lambda combo: {x for base in combo for x in by_base[base]}
terms
# Combine bases if they have terms in common
= {frozenset([base]) for base in by_base}
combos = False
stable while not stable:
= True
stable = list(combos)
old for i, combo1 in enumerate(old):
for combo2 in list(old[i+1:]):
= combo1 | combo2
new if new not in combos and terms(combo1) & terms(combo2):
combos.add(new)= False
stable del(stable)
# Again, remove those which aren't proper subsets
for combo in list(combos):
if len(terms(combo)) == total:
combos.remove(combo)
# Sort combos by size, so that supersets get checked first
= sorted(
result list(combos),
=True,
reverse=lambda combo: len(terms(combo))
key
)del(combos)
# Remove any that are subsets of earlier ones, since they're redundant
= 0
i while i < len(result):
= terms(result[i])
these if any(
these.issubset(terms(result[j]))for j in range(i)
):
result.pop(i)else:
+= 1
i del(i)
return terms, result
Now we can use this in normalise_sum
. We’ll iterate through
the subsets it gives us. For each one, we’ll try normalising it in
isolation. If it simplifies, we’ll add the simplified version back to
the terms we didn’t touch, and restart the whole normalisation process
from the top.
# Look for (strict) subsets which share factors, and try normalising those
# in isolation
= _partial_sums(s)
terms, bases = set(s.keys())
keys for combo in bases:
= terms(combo)
these = Counter({
within
term: s[term]for term in these
})= normalise_sum(within)
norm if not sums_are_same(within, norm):
= Counter({
without
term: s[term]for term in keys.difference(these)
})return add_sums(norm, without)
This divide-and-conquer strategy allows us to simplify smaller, more
manageable parts of a complex Sum
in isolation. By focusing on these connected subsets, we increase our
chances of being able to apply other simplification techniques, such as
extracting common factors, which we’ll explore next.
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 complements the normalisation of subsets we implemented
above: firstly because we choose subset in order to find common factors,
and secondly because dividing-out common factors reduces the remaining
subsets that need to be checked (since trying subsets is more expensive,
we try factorising first).
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, normalise=True) -> Sum:
# Skip multiplication by 1
= [s for s in sums if s != Sum(Product())]
remaining if len(remaining) == 0:
# Empty product
return Sum(Product())
*rest = remaining
first, # Short-circuit if we're only multiplying one term
if len(rest) == 0:
return first
= reduce(
result lambda s1, s2: irrationals_coefficients([
multiply_products(p1, p2)for p1 in _sum_to_products(s1)
for p2 in _sum_to_products(s2)
]),
rest,
first
)return normalise_sum(result) if normalise else result
Like add_sums
, this function
also has a normalise=True
parameter to avoid redundant
recursion.
Next we’ll need to identify common factors:
def common_factors_of_sum(s: Sum) -> Product:
if is_zero(s):
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 which 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
, and a way to subtract it back out
again:
def _split_rational(s: Sum) -> Tuple[Sum, int]:
= Counter(s)
coeffs = coeffs[tuple()]
rational del(coeffs[tuple()])
return (coeffs, rational)
def _subtract_int_from_sum(s: Sum, i: int):
= s.copy()
result tuple()] -= i
result[if result[tuple()] == 0:
del(result[tuple()])
return result
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 is_zero(to_factor_coeffs):
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 is_zero(factored_coeffs):
# 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!
The full code for this post is available in the
fraction_sums
module of my
conjugate repo.