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 Products, 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_zero = Sum(product_zero)
sum_one = Sum(product_one)
sum_neg = Sum(product_neg)The following function will also come in handy:
def is_zero(s: Sum) -> bool:
return len(s) == 0Normalising
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)
go = lambda power: reduce(
multiply_products,
[Product(Power(base, power(exp))) for base, exp in product.items()],
product_one
)
return (
go(lambda e: e // 1), # whole exponents
go(lambda e: e % 1), # fractional exponents
)Since the part with whole-number exponents is guaranteed to be a
whole number, we can instead return that as
a native Python integer (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)
go = lambda power: reduce(
multiply_products,
[Product(Power(base, power(exp))) for base, exp in product.items()],
product_one
)
return (
eval_product_int(go(lambda e: e // 1)), # whole exponents
go(lambda e: e % 1), # fractional exponents
)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:
result = Counter()
for p in s:
count, fractional = split_fractionals(p)
# The key for our Counter must be hashable, so make a tuple. Sort it, to
# normalise all permutations of factors into the same order.
irrational = tuple(sorted(list(Product(*fractional.items()).items())))
result[irrational] += count # Counters begin at 0 if not yet assigned
return resultWe’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:
result = Counter()
for p in s:
count, fractional = split_fractionals(p)
base, exp = Power(1, fractional.get(1, zero))
if exp >= half:
# Shift a factor of 1^(1/2) from irrationals to rationals, since
# neg_power is rational
count = count * (-1)
exp -= half
fractional = fractional | {1: exp}
if fractional.get(1, None) == zero:
# Avoid redundant factors appearing in .items()
del(fractional[1])
irrational = tuple(sorted(list(Product(*fractional.items()).items())))
result[irrational] += count
return resultSkipping 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):
result[irrational] += count
if result[irrational] == 0:
del(result[irrational])
return resultNotice 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
s = Counter({k: v for k, v in s.items() if v != zero})
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 sNormalising 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 Sums 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 Counters:
def add_sums(*sums, normalise=True) -> Sum:
result = Counter()
for s in sums:
# Annoyingly, we can't just result += s, since that ignores negatives
for k in s:
result[k] += s[k]
return normalise_sum(result) if normalise else resultNotice 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
by_base = defaultdict(set)
for prod in s:
for base, _ in prod:
by_base[base].add(prod)
if 1 in by_base and () in s:
by_base[1].add(())
# Drop any base which occurs in *every* term, since it doesn't give us a
# (proper) subset
total = len(s)
for base, terms in list(by_base.items()):
if len(terms) == total:
del(by_base[base])
terms = lambda combo: {x for base in combo for x in by_base[base]}
# Combine bases if they have terms in common
combos = {frozenset([base]) for base in by_base}
stable = False
while not stable:
stable = True
old = list(combos)
for i, combo1 in enumerate(old):
for combo2 in list(old[i+1:]):
new = combo1 | combo2
if new not in combos and terms(combo1) & terms(combo2):
combos.add(new)
stable = False
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
result = sorted(
list(combos),
reverse=True,
key=lambda combo: len(terms(combo))
)
del(combos)
# Remove any that are subsets of earlier ones, since they're redundant
i = 0
while i < len(result):
these = terms(result[i])
if any(
these.issubset(terms(result[j]))
for j in range(i)
):
result.pop(i)
else:
i += 1
del(i)
return terms, resultNow 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
terms, bases = _partial_sums(s)
keys = set(s.keys())
for combo in bases:
these = terms(combo)
within = Counter({
term: s[term]
for term in these
})
norm = normalise_sum(within)
if not sums_are_same(within, norm):
without = Counter({
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 Sums; 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(
Power(abs(coeff), one),
power_neg if coeff < 0 else power_one,
*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
remaining = [s for s in sums if s != Sum(Product())]
if len(remaining) == 0:
# Empty product
return Sum(Product())
first, *rest = remaining
# Short-circuit if we're only multiplying one term
if len(rest) == 0:
return first
result = reduce(
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 resultLike 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
first, *rest = _sum_to_products(s)
found = reduce(
lambda common, product: {
base: min(common[base], augmented[base])
for base in common
for augmented in [{1: one} | product]
if base in augmented
},
rest,
{
base: exp
for 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 foundAnd 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 boolean
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
base, exp = p
old_products = _sum_to_products(s)
# Check our precondition, that p is a common factor of the terms in s
has_base = all(base in product for product in old_products)
if not (has_base or base == 1):
raise AssertionError(
f"remove_common_factor({s}, {p}, {raw})"
)
coeffs = irrationals_coefficients([
Product(*(
[
Power(b, e - exp if b == base else e) for b, e in product.items()
] + ([] 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.
factors = common_factors_of_sum(s)
factors_list = factors.items()
for (base, exp) in factors_list:
rest = remove_common_factor(s, (base, exp), raw=True)
norm = normalise_sum(rest)
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]:
coeffs = Counter(s)
rational = coeffs[tuple()]
del(coeffs[tuple()])
return (coeffs, rational)
def _subtract_int_from_sum(s: Sum, i: int):
result = s.copy()
result[tuple()] -= i
if result[tuple()] == 0:
del(result[tuple()])
return resultNow 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:
to_factor_coeffs, const = _split_rational(s)
else:
to_factor_coeffs = s
const = 0
if is_zero(to_factor_coeffs):
if const:
return (
multiply_products(
Product(Power(abs(const), one)),
product_one if const >= 0 else product_neg,
),
0
)
else:
return (product_one, 0)
first, *rest = _sum_to_products(to_factor_coeffs)
found = reduce(
lambda common, product: {
base: min(common[base], augmented[base])
for base in common
for augmented in [{1: one} | product]
if base in augmented
},
rest,
{
base: exp
for 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
factored_coeffs, remainder = _split_rational(
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.
factors, remainder = common_factors_of_sum(s, allow_remainder=True)
reduced = _subtract_int_from_sum(s, remainder)
factors_list = factors.items()
for (base, exp) in factors_list:
rest = remove_common_factor(reduced, (base, exp), raw=True)
norm = normalise_sum(rest)
if not sums_are_same(norm, rest):
return add_sums(
Sum(multiply_products(
Product(Power(abs(remainder), 1)),
product_neg if remainder < 0 else product_one
)),
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.