Surreal Numbers In Python 3
Ratios and Rationalizations
Generating Day 2
So far, we’ve generated 3 forms that we’re familiar with: 0
, 1
, and -1
, represented by {|}
, {0|}
and {|0}
respectively. We also have a batch of new numbers, that we’re not quite sure of:
{1|}{−1|} {0, 1|} {0, −1|} {−1, 1|}
{−1, 0, 1|} {|1} {| − 1} {|0, 1}
{|0, −1} {| − 1, 1} {| − 1, 0, 1} {−1|0}
{−1|1} {−1|0, 1} {0|1} {−1, 0|1}
Moving beyond Day 1, the numbers begin to follow a more intuitive pattern. We can read Surreal numbers as follows:
If S is a surreal number consisting of {a|b}, then S is the number *in-between* a and b
. I’m going to start using a physical “number line” as an analog for the Real Numbers, as it’s easier to concieve of the |
in the surreal-notation to be akin to a marker on an analog scale, where the number can be read by looking at the numbers surrounding the marker-line. Therefore, I will be using “left” and “right” as analogs for “less-than” and “greater-than” respectively.
We can then conceptualize previous numbers, such as {0|} is the number to the right of 0, which is 1
. We can then carry this rule further on. If S = {1|}, then S is the number to the right of 1, which is 2
. We’ve now added 2
to our list of forms. Furthermore, we can take the negative of the statement. If S = {|-1}, then S is the number to the left of -1, which is negative 2
. Another form for our collection, -2.
By this point, you may have noticed that using entire sets for the left and right values of a surreal is a bit redundant. Equivalent forms yield equivalent numbers, so something like:
{-1, 0, 1|} === {1|} = 2
and {|0, 1} === {|0} = -1
and finally {-1, 0| 2} === {0|2} = 1
Essentially, we only care about max(left)
and min(right)
to define a number.
We can add these shortcuts to our Surreal
class as follows
@property
def xl(self):
return max(self.left_set or (None,))
@property
def xr(self):
return min(self.right_set or (None,))
Rationals
Great, so we can generate all the integers. But even grade-schoolers know about decimals/fractions. Where do those numbers come from in our system? If we remember the bathroom-scale analog, we can think of the |
(pipe) as a sort of arrow/indicator, where we can see surrounding numbers to generate further ones. We can start to make some rational numbers by looking “where they live”, in-between integers.
Thus, we can think of the following surreal, {0|1}
, as “the number between 0 and 1”, or 1/2
. Similarly, -1/2
is represented by {-1|0}
. We can now add these rationals to our forms, to look even “further” in.
{1/2|1} = 3/4
, born on day 3. {1/2|3/4} = 5/8
. If you notice, our denominator will always be a power-of-2, so we call these the Dyadic Rationals
.
Actually coding this
Conway uses a recursive function to define his conversion. It follows the common logic of splitting the problem into the base-cases and recursive-case. With surreals, the base cases are -1, 0, and 1
. As such, we’ll include special classmethods
to act as constructors for these special cases.
@classmethod
def zero(cls):
return cls(0, nothing, nothing)
@classmethod
def one(cls):
return cls(1, (0,), nothing)
@classmethod
def neg_one(cls):
return cls(1, nothing, (0,))
We can then start to write a __float__
function, allowing for easy conversion of Surreals to their Real Number counterparts. We’ll start by coding the base-cases:
def __float__(self):
if self == Surreal.zero():
return 0.
elif self == Surreal.one():
return 1.
elif self == Surreal.neg_one():
return -1.
else:
raise NotImplementedError()
In order to cover the rest of the number-line, we need to come up with a general-purpose algorithm. Remember previously, we defined 3 cases where we could make a “form” for our collection: left-number, right-number, and in-between-number, where the first 2 increment/decrement the number, and the last finds the number in-between the two numbers given.
...
else:
if len(self.left_set) == 0: # Left number
return min(self.right_set) - 1
elif len(self.right_set) == 0: # Right number
return max(self.left_set) + 1
else: # In-between
return (min(self.right_set) + max(self.left_set)) / 2
Going back
We should be able to do the inverse of float-conversion, where we can pass a number to our class, and it converts it to a valid Surreal. However, we can’t simply go from a decimal number to a Surreal; we’re only able to generate numbers from a certain domain, so it stands to reason that using the same rules, we can only convert certain numbers back into Surreals. We can generate all integers (k(n+1) = k(n) + 1
) and all dyadic rationals (k(n+1) = (2(k(n) + 1) / 2^n
).
To start, we’ll use a simpler example that follows an easy pattern- the integers. We know that a positive integer-number i
can be expressed in Surreal form as {i-1|}
. Negative integers simply flip which set is used, as i === {|i+1}
. Encapsulating our base-case of zero
, we get the following code:
@classmethod
def from_int(cls, i: int):
if i == 0:
return Surreal.zero()
elif i > 0:
return Surreal(abs(i), (i-1,), nothing)
elif i < 0:
return Surreal(abs(i), nothing,(i+1,))
else:
raise Exception("NaN")
Dyadics are a bit harder than the plain integers, as one needs to find an odd-integer numerator N, and a denominator that’s a power-of-2. Without going through the details of solving the equations, we find that the surreal form of dyadic x
is { x - 1/2^{k} | x + 1/2^{k} }
. This will produce a number whose numeric-average is equal to x
.
(x - 1/2^{k}) + (x + 1/2^{k})/2 = 2x/2 = x
We can use Python’s built-in Fraction
class to help conver to rational-fractions, and store the numerator
and denominator
separately. We can also use math.log2
to help us find k
.
@classmethod
def from_dyadic(cls, f: Union[Fraction, float]):
if isinstance(f, float):
f = Fraction(Decimal(f))
k = log2(f.denominator)
n = abs(f.numerator//2)+1
return Surreal(n, (f - (1/2)**k,), (f + (1/2)**k,))
To check, we can assert
some quick tests:
With all this in place, we can finally create a Surreal generation-function that includes the dyadic-rationals:
Full Jupyter notebook here