Rationalising Denominators 2: Products
Posts in this series:
Introduction
Last
time we used Python’s int
and Fraction
types to implement a new type called Power
, to represent nth roots.
We implemented a few normalisation steps, to avoid redundant values
like Power(-1, Fraction(2, 1))
(negative one squared, which
is just one), Power(1, Fraction(10, 1))
(one to the power
of ten, which is also just one) and
Power(0, Fraction(3, 1))
(the cube of zero, which is just
zero).
We were still left with some redundancy though, like
Power(4, Fraction(1, 1))
(four to the power one) and
Power(2, Fraction(2, 1))
(two squared) both being the
number four; or, conversely, Power(2, Fraction(1, 1))
(two
to the power one) and Power(4, Fraction(1, 2))
(square root
of four) both being the number two.
This time we’ll use Power
to implement another type,
called Product
, which completely avoids these
redundancies.
Products
A product is a list of values multiplied together. We can define a
Product
type in Python, whose elements are
Power
values:
def Product(*powers: Power) -> List[Power]:
return list(powers)
This simple implementation can represent all the same values as
Power
(by using a list with a single value), but it lets us
represent some values in a different way. For example
Product(Power(3, Fraction(1, 3)), Power(2, Fraction(1, 2)))
is the cube root of three times the square root of two; which is
equivalent to Power(72, Fraction(1, 6))
(the sixth root of
seventy-two).
Here are some useful constants for this type:
= Product(power_zero)
product_zero = Product(power_one)
product_one = Product(power_neg) product_neg
Multiplication
Since a Product
represents a multiplication of its
elements, we can multiply Product
values by concatenating
their lists:
def multiply_products(*ps: Product) -> Product:
return Product(*sum(ps, []))
Normalisation
There is plenty of redundancy in this simple definition, so we’ll build up a normalisation algorithm which consolidates such values to a single representation.
Permutations
Since our multiplication is commutative (for the moment), changing
the order of elements in the list does not affect the result. We can
solve this quite easily by sorting the values (since each
Power
is a tuple, Python will use a lexicographic ordering
of the fields; with a numerical ordering for the numbers inside a field;
however the the actual order doesn’t matter, so long as some
permutation of our list is used as the “normal” form):
def Product(*powers: Power) -> List[Power]:
return sorted(list(powers))
Repeated Bases
A Product
of Powers
with the same
base
(like
Product(Power(5, Fraction(1, 2)), Power(5, Fraction(3, 4)))
)
is
equivalent to a single Power
with the same base, but
sum of those exponent
s (in this case
Power(5, Fraction(5, 4))
). We’ll prefer the latter, and
normalise our Product
values to that form. We’ll implement
this by summing the exponent
s of given Power
s
in a dictionary, keyed on their base
; then converting the
final entries into the resulting list of Power
s:
def Product(*powers: Power) -> List[Power]:
= {}
combined for base, exp in powers:
if base in combined:
+= exp
combined[base] else:
= exp
combined[base] return [Power(base, exp) for base, exp in sorted(combined.items())]
Absorbing Zero
All products which contain zero are equivalent (since zero is the absorbing value for multiplication). The simplest of these is the product which only contains zero, so we’ll normalise the others to that:
# If any element is zero, the whole Product is zero
if base == 0:
return [power_zero]
Discarding Ones
The number one is the identity value
for multiplication, so its presence in a product does not alter the
result. Hence the normalisation of a Product
should remove
any such Power
. Thankfully, we can rely on
Power
to normalise such values to
Power(1, Fraction(0, 1))
; and indeed we can spot them by
checking if their exponent
is zero:
return [
for base, exp in sorted(combined.items()) if exp != 0
Power(base, exp) ]
Note that, if every Power
given to a
Product
is one, then the result will be the empty product;
and this is the normal form for the Product
representing
the number one!
Fixed Points
Relying on the normalisation of Power
makes our life
easier, but we may still miss redundancies due to each
Power
being normalised individually. In particular,
consider a value like Product(power_neg, power_neg)
: the
repeated base
of -1
will be
combined
into a dictionary like
{ -1: Fraction(2, 1) }
. Since that exponent
is
not 0
, our previous check won’t skip it, and we’ll return a
list containing the element Power(-1, Fraction(2, 1))
,
which normalises to Power(1, Fraction(0, 1))
. Hence
double-negatives will lead to redundant factors of one in a
Product
.
This is unfortunate, since the normalisation performed by
Power
has replaced the exponent
with zero; so
our check could skip it, if we perform it later. There are many
ways we could adjust our code to fix this, but one particularly simple
approach is to repeat the normalisation steps on our result,
until the value no longer changes (i.e. until we reach a fixed
point):
= [
result for base, exp in sorted(combined.items()) if exp != 0
Power(base, exp)
]return result if list(result) == list(powers) else Product(*result)
Factorising
Now we confront the main source of redundancy in both
Product
and Power
: that different sets of
powers can multiply to the same result; and indeed that raising
different base
values to appropriate exponent
s
can also produce the same result.
We will avoid this redundancy by only allowing elements of a
Product
to have base
values which are prime, or the
special values 0
(the only factor of zero), -1
(a factor of every negative number) and 1
(if raised to a
fractional power).
To implement this normalisation, we need some helper functions to factorise
base
values. There are many algorithms we could use, and
the details aren’t too important for our purposes; you can expand the
following box to see the implementation I’ve used.
Implementation of factorise
Factorising can be slow, and was the bottleneck in my initial experiments. The following function uses a few tricks to speed up calculations, compared to a naive approach:
- The
lru_cache
decorator gives the function a memo table, so results which have previously been calculated can be quickly re-used. - During startup there is a one-time cost to calculate the primes up to 1000, which are used to factor small numbers.
- If we have to calculate the factors of some large number, we use a “wheel factorisation” algorithm, which is at least faster than brute-force search!
from functools import lru_cache
def factorise() -> Callable[[int], Product]:
# Precompute primes up to 1000 using Sieve of Eratosthenes
= [True] * 1001
sieve 0] = sieve[1] = False
sieve[for i in range(2, 32): # sqrt(1000) ≈ 31.6
if sieve[i]:
for j in range(i * i, 1001, i):
= False
sieve[j] = [i for i in range(1001) if sieve[i]]
SMALL_PRIMES del(sieve)
# Slow, but works for arbitrary n
def wheel_factorise(n: int) -> Product:
= []
factors
# Handle 2, 3 and 5 separately for efficiency
for p in (2, 3, 5):
if n % p == 0:
= 0
count while n % p == 0:
+= 1
count //= p
n 1)))
factors.append(Power(p, Fraction(count,
# Wheel increments for numbers coprime to 2, 3 and 5
= [4, 2, 4, 2, 4, 6, 2, 6]
increments = 0
i = 7
f
while f * f <= n:
if n % f == 0:
= 0
count while n % f == 0:
+= 1
count //= f
n 1)))
factors.append(Power(f, Fraction(count, += increments[i]
f = (i + 1) % 8
i
if n > 1:
1, 1)))
factors.append(Power(n, Fraction(
return factors
@lru_cache(maxsize=1024)
def factorise(n: int) -> Product:
if n == 0:
return [power_zero]
if n == 1:
return []
if n < 0:
# Combine -1 with factors of the absolute value
return [power_neg] + factorise(-n)
if n > 1000:
# Fallback method for large numbers
return wheel_factorise(n)
else:
# Small numbers can use precomputed primes
= []
factors for p in SMALL_PRIMES:
if p * p > n:
if n > 1:
1, 1)))
factors.append(Power(n, Fraction(break
if n % p == 0:
= 0
count while n % p == 0:
+= 1
count //= p
n 1)))
factors.append(Power(p, Fraction(count, return factors
return lambda n: sorted(factorise(n))
= factorise() factorise
We use that factorise
function to implement the
following split_power
function, which adjusts factors of a
base
to account for the given exponent
:
def split_power(power: Power) -> Product:
= power
base, exp return [Power(b, exp * e) for b, e in factorise(base)]
This can then be used to normalise the elements of a
Product
:
def Product(*powers: Power) -> List[Power]:
= {}
combined for power in powers:
for base, exp in split_power(power):
# If any element is zero, the whole Product is zero
if base == 0:
return [power_zero]
if base in combined:
+= exp
combined[base] else:
= exp
combined[base] = [
result for base, exp in sorted(combined.items()) if exp != 0
Power(base, exp)
]return result if list(result) == list(powers) else Product(*result)
The redundancy in our earlier examples is now avoided, since any
Power
with base
4 will be replaced by an
equivalent with base
2:
Product(Power(4, Fraction(1, 1)))
(four to the power one) will normalise toProduct(Power(2, Fraction(2, 1)))
(two squared)Power(4, Fraction(1, 2))
(square root of four) will normalise toPower(2, Fraction(1, 1))
(two to the power one)
Note that we don’t perform this factorising in Power
,
since it may require a Product
of multiple elements. For
example, normalising Product(Power(18, Fraction(2, 1)))
(eighteen squared) produces:
2, Fraction(2, 1)), Power(3, Fraction(4, 1))) Product(Power(
Roots Of Unity
Our final normalisation step concerns fractional powers of one.
Consider the cube root Power(1, Fraction(1, 3))
: this
represents a value which, when multiplied by itself three times, results
in one. The number one itself has this property, since 1×1×1=1, but it’s
not the only number to do so. In particular, roots of unity
have this property, whilst being distinct from the usual number line. Roots
of unity will come in handy later, so we will not replace them with one
(this is also why Power
uses modulo one to normalise values
with base
one).
There is still some redundant structure in these fractional powers of
one, since any with an exponent
greater than
Fraction(1, 2)
is equivalent to the negative of some
Power
with exponent
less than
Fraction(1, 2)
. We can account for this by having
split_power
replace factors of
Power(1, Fraction(1, 2))
with power_neg
:
def split_power(power: Power) -> Product:
= power
base, exp if base == 1:
if exp >= Fraction(1, 2):
# Half-way-round the roots of unity, we can split off a power of -1
return [power_neg] + split_power(
- Fraction(1, 2))
Power(base, exp
)return [Power(b, exp * e) for b, e in factorise(base)]
Extra Helpers
We’ll be needing the following helper functions, so might as well
define them here alongside Product
:
def eval_product_int(p: Product) -> int:
= 1
result for base, exp in p:
assert exp.denominator == 1, f"Can't eval {base}^{exp} as int"
*= base**exp.numerator
result return result
def product_is_rational(p: Product) -> bool:
return all(exp.denominator == 1 for _, exp in p)
Conclusion
We’ve extended our representation of numbers from Power
,
which suffered from redundancy; to Product
, which does not
have any redundancies. Normalising this Product
representation has exposed some structure that will be useful in future
installments: namely the use of prime factors and roots of
unity.
In the next post we’ll extend our representation again: to sums of products of powers!
Appendixen
Implementation
Here’s the final implementation of Product
, assuming
that we have an implementation of Power
defined:
def Product(*powers: Power) -> List[Power]:
= {}
combined for power in powers:
for base, exp in split_power(power):
if base == 0:
# If any element is zero, the whole Product is zero
return [power_zero]
if base in combined:
+= exp
combined[base] else:
= exp
combined[base] = [
result for base, exp in sorted(combined.items()) if exp != 0
Power(base, exp)
]return result if list(result) == list(powers) else Product(*result)
def multiply_products(*ps: Product) -> Product:
return Product(*sum(ps, []))
def eval_product_int(p: Product) -> int:
= 1
result for base, exp in p:
assert exp.denominator == 1, f"Can't eval {base}^{exp} as int"
*= base**exp.numerator
result return result
def product_is_rational(p: Product) -> bool:
return all(exp.denominator == 1 for _, exp in p)
def split_power(power: Power) -> Product:
= power
base, exp if base == 1:
# Half-way-round the roots of unity, we can split off a power of -1
if exp >= Fraction(1, 2):
return [power_neg] + split_power(
- Fraction(1, 2))
Power(base, exp
)return [Power(b, exp * e) for b, e in factorise(base)]
def factorise() -> Callable[[int], Product]:
# Precompute primes up to 1000 using Sieve of Eratosthenes
= [True] * 1001
sieve 0] = sieve[1] = False
sieve[for i in range(2, 32): # sqrt(1000) ≈ 31.6
if sieve[i]:
for j in range(i * i, 1001, i):
= False
sieve[j] = [i for i in range(1001) if sieve[i]]
SMALL_PRIMES del(sieve)
# Slow, but works for arbitrary n
def wheel_factorise(n: int) -> Product:
= []
factors
# Handle 2, 3 and 5 separately for efficiency
for p in (2, 3, 5):
if n % p == 0:
= 0
count while n % p == 0:
+= 1
count //= p
n 1)))
factors.append(Power(p, Fraction(count,
# Wheel increments for numbers coprime to 2, 3 and 5
= [4, 2, 4, 2, 4, 6, 2, 6]
increments = 0
i = 7
f
while f * f <= n:
if n % f == 0:
= 0
count while n % f == 0:
+= 1
count //= f
n 1)))
factors.append(Power(f, Fraction(count, += increments[i]
f = (i + 1) % 8
i
if n > 1:
1, 1)))
factors.append(Power(n, Fraction(
return factors
@lru_cache(maxsize=1024)
def factorise(n: int) -> Product:
if n == 0:
return [power_zero]
if n == 1:
return []
if n < 0:
# Combine (-1, 1) with positive factorization
return [power_neg] + factorise(-n)
if n > 1000:
# Fallback method for large numbers
return wheel_factorise(n)
else:
# Small numbers can use precomputed primes
= []
factors for p in SMALL_PRIMES:
if p * p > n:
if n > 1:
1, 1)))
factors.append(Power(n, Fraction(break
if n % p == 0:
= 0
count while n % p == 0:
+= 1
count //= p
n 1)))
factors.append(Power(p, Fraction(count, return factors
return lambda n: sorted(factorise(n))
= factorise()
factorise
= Product(power_zero)
product_zero = Product(power_one)
product_one = Product(power_neg) product_neg
Property Tests
The following test suite uses Hypothesis and Pytest to check various properties that
should hold for our Product
type:
import pytest
from hypothesis import assume, given, strategies as st, note
= lambda **xs: (note(repr(dict(**xs))), list(xs.values())[0])[-1]
debug
@lru_cache(maxsize=1024)
def is_prime(n: int) -> bool:
if n == 0:
return False
= factorise(n)
factors return len(factors) == 1 and factors[0][1] == 1
# Test strategies
@st.composite
def primes(draw):
= draw(st.integers(min_value=2, max_value=7))
n return next(p for p in range(n, n+5) if is_prime(p))
@st.composite
def prime_factors(draw, rational=None):
= draw(primes())
base = draw(st.integers(
num ={
min_valueTrue: 1,
False: 0,
None: 0
}[rational],=4
max_value
))= draw(
den
st.integers(={
min_valueTrue: 1,
False: 2,
None: 1
}[rational],={
max_valueTrue: 1,
False: 2,
None: 1
}[rational]filter(lambda d: (d != num) if rational == False else True)
).
)= list(filter(
normalised
{True: lambda x: product_is_rational([x]),
False: lambda x: not product_is_rational([x]),
None: lambda x: True
}[rational],
split_power(Power(base, Fraction(num, den)))
))len(normalised) > 0)
assume(return normalised[0]
def powers(rational=None):
= prime_factors(rational)
just_powers = st.one_of(st.just(power_neg), just_powers)
with_negatives return {
False: just_powers,
True: with_negatives,
None: with_negatives
}[rational]
@st.composite
def products(draw, rational=None):
= draw(st.lists(
main ={
powers(rational# Rational products need entirely rational powers (when in normal
# form), but irrational products don't need *all* of their powers
# to be irrational
True: True,
False: None,
None: None
}[rational]),=0,
min_size=3
max_size
))# We make it more likely for our product to be irrational by appending an
# extra power that's definitely irrational. This may end get cancelled-out
# by something we already have, so we still need a post-filter; we just do
# this to prevent a lot of discarding in the post-filter.
= [draw(powers(rational))] if rational == False else []
extra = Product(*(main + extra))
normal if rational == None:
return normal
= product_is_rational(normal)
is_rat == is_rat)
assume(rational return normal
@given(st.data())
def test_strategy_products(data):
data.draw(products())
@given(products(rational=True))
def test_strategy_products_can_force_rational(p):
assert product_is_rational(p)
@given(products(rational=False))
def test_strategy_products_can_force_irrational(p):
assert not product_is_rational(p)
@given(st.integers(min_value=2, max_value=1000))
def test_prime_factors_are_prime(n: int):
= factorise(n)
prime_factors for base, _ in prime_factors:
assert is_prime(base)
@given(st.integers(min_value=2, max_value=1000))
def test_prime_factors_multiply_to_input(n: int):
= factorise(n)
prime_factors = eval_product_int(prime_factors)
product =prime_factors, product=product)
debug(prime_factorsassert product == n
def whole_products():
return products().map(
lambda xs: Product(*[
for base, exp in xs
Power(base, Fraction(exp.numerator))
])
)
@given(products())
def test_normalise_product_idempotent(p: Product):
assert p == Product(*p)
@given(st.lists(
=-1000, max_value=1000),
st.integers(min_value=0,
min_size=3
max_size
))def test_normalise_product_composites(l: List[int]):
= Product(*[Power(base, Fraction(1)) for base in l])
normal for base, exp in normal:
assert exp.denominator == 1, f"{normal} contains fractional power {exp}"
@given(products(), products())
def test_normalise_product_absorbs(pre: Product, suf: Product):
assert Product(*(pre + [power_zero] + suf)) == product_zero
@given(whole_products(), whole_products())
def test_multiply_products_agrees_with_evaluated(p1, p2):
= eval_product_int(p1)
i1 = eval_product_int(p2)
i2 = eval_product_int(multiply_products(p1, p2))
i3 assert i3 == i1 * i2
@given(products())
def test_multiply_products_identity(p: Product):
assert multiply_products(p, product_one) == p, "1 is right identity"
assert multiply_products(product_one, p) == p, "1 is left identity"
@given(products(), products())
def test_multiply_products_commutative(p1: Product, p2: Product):
= multiply_products(p1, p2)
result_12 = multiply_products(p2, p1)
result_21 assert result_12 == result_21
@given(products(), products(), products())
def test_multiply_products_associative(p1: Product, p2: Product, p3: Product):
= multiply_products(p1, p2)
result_12 = multiply_products(p2, p3)
result_23 assert multiply_products(result_12, p3) == multiply_products(p1, result_23)