Code
import math
import struct
from fractions import Fraction
import numpy as np
import matplotlib.pyplot as plt
import gradio as grThis notebook provides insights into how computers represent numbers, with a specific focus on three major types:
Integers
We cover the representation of 64-bit signed integers and delve into the concept of overflow, where values exceed the maximum representable integer.
Rationals
Introduce rational numbers, conceptualized using fractions derived from 64-bit integers. Discuss encoding concepts for special cases like infinity.
Floating Point Numbers
A detailed look at the IEEE 754 double precision (64-bit) standard, covering:
\[ \pm (1,\,a_1 a_2\ldots a_{52})_2 \cdot 2^\alpha = (-1)^s 2^\alpha\left(1+\sum_{k=1}^{52} a_k 2^{-k}\right), \]
where \(s,\,a_k\in\{0,1\}\) and exponent \(\alpha\in\{-1022,\ldots,1023\}\) with special values for \(-1023\) and \(1024\). - The concept of machine precision:
\[ \mathrm{eps}_{64} = 2^{-52}\approx 2.220446049250313 \times 10^{-16}, \]
defining the bounds of relative error when rounding real numbers.
Subnormal numbers
These provide extended range of representation near zero.
Understanding the binary representation of integers is foundational. We represent 64-bit signed integers using NumPy’s np.int64. Binary representation is obtained using two’s complement method. The code below defines int64_bitstring, which outputs the 64-bit binary equivalent of an integer:
def int64_bitstring(n: int) -> str:
"""
Return the 64-bit two's complement binary representation of an integer.
Args:
n (int): The integer (should be in the range of 64-bit signed integers).
Returns:
str: A string of 64 characters ('0' or '1') representing the number.
"""
# Convert to numpy.int64 to simulate 64-bit behavior
n64 = np.int64(n)
# np.binary_repr works for negative numbers if width is provided.
return np.binary_repr(n64, width=64)Rational numbers can be depicted with Python’s Fraction class. This handles fractions using pairs of integers. A special consideration is made for division by zero, where we conventionally define:
def rational(p: int, q: int) -> Fraction | float:
"""
Create a rational number from two integers.
If q == 0, return positive or negative infinity based on the sign of p.
Args:
p (int): Numerator.
q (int): Denominator.
Returns:
Fraction or float: A Fraction instance if q != 0, or math.inf/-math.inf if q == 0.
"""
if q == 0:
return math.inf if p >= 0 else -math.inf
return Fraction(p, q)Floating point numbers follow the IEEE 754 format. This section highlights code implementation for extracting components of a double-precision float:
The function float_to_bitstring unpacks a float into:
The layout consists of:
def float_to_bitstring(x: float) -> dict:
"""
Convert a Python float into its IEEE 754 double precision bit components.
Args:
x (float): The floating point number.
Returns:
dict: A dictionary with keys 'full', 'sign', 'exponent', 'mantissa'.
"""
# Ensure x is a numpy.float64 to avoid precision issues
x = np.float64(x)
# Pack float into 8 bytes and unpack as unsigned long long (64-bit integer)
packed = struct.pack("!d", x)
(bits,) = struct.unpack("!Q", packed)
full = format(bits, "064b")
sign = full[0]
exponent_bits = full[1:12]
mantissa_bits = full[12:]
exponent_val = int(exponent_bits, 2)
mantissa_val = int(mantissa_bits, 2)
return {
"full": full,
"sign": sign,
"exponent": exponent_val,
"mantissa": mantissa_val,
}Machine precision, or epsilon (\(\epsilon_{64}\)), is crucial for understanding floating point accuracy:
\[ \epsilon_{64} = 2^{-52}. \]
Here’s the code that returns machine epsilon directly and via simulation:
def machine_epsilon() -> np.float64:
"""
Return the IEEE 754 double precision machine epsilon, defined as 2^-52.
Returns:
np.float64: The machine precision.
"""
return np.float64(2.0) ** np.float64(-52)
def simulate_myeps() -> np.float64:
"""
Simulate the determination of machine epsilon by iterative halving.
Returns:
np.float64: The computed machine epsilon.
"""
eps = np.float64(1.0)
while np.float64(1.0) + eps > np.float64(1.0):
prev = eps
eps /= np.float64(2.0)
return prevThe concept of the next representable float demonstrates how floating point precision works. next_float, facilitated by NumPy’s nextafter, finds the subsequent float in a specified direction:
def next_float(x: float, direction: float) -> np.float64:
"""
Get the next representable floating point number after x in the direction of 'direction'.
Args:
x (float): Starting floating point number.
direction (float): The direction (e.g., 1.0 for upward, -1.0 for downward).
Returns:
np.float64: The next representable floating point number.
"""
# Convert inputs to np.float64 to ensure correct behavior.
return np.nextafter(np.float64(x), np.float64(direction))Between any two consecutive normalized floating point numbers with exponent \(\alpha\), the gap size is:
\[ \text{gap} = 2^{\alpha-52}. \]
The plot_vlines function visualizes these gap sizes across different exponents by plotting vertical lines:
def plot_vlines(n: int) -> plt.Figure:
"""
Plot vertical lines at positions f(alpha)=2^alpha for alpha from -1022 up to n.
Args:
n (int): The maximum exponent (n must be between -1021 and 1023).
Returns:
plt.Figure: The generated matplotlib figure.
"""
fig, ax = plt.subplots(figsize=(8, 6))
x_vals = [2.0**i for i in range(-1022, n + 1)]
for x in x_vals:
ax.axvline(x=x, color="blue")
ax.set_xlim([0, 2.0**n])
ax.set_ylim([0, 1])
ax.set_title(r"there are $2^{52} - 1$ Float64 numbers between two lines")
ax.set_xlabel(r"$2^\alpha$, where $\alpha = -1022, \dots," + f"{n}$")
plt.close(fig) # Prevents immediate display in non-interactive environments
return figThe interactive dashboard enables exploration and visualization through the following tabs:
Integer Representation:
Enter an integer to observe its 64-bit binary (two’s complement) representation.
Rational Demonstration:
Provide a numerator and a denominator to see the simplified rational output or infinity if applicable.
Floating Point Representation:
Input a float to obtain its detailed binary structure, including sign, exponent, and mantissa.
Machine Epsilon and Subnormals:
Discover machine epsilon’s direct and simulated values, including the smallest positive subnormal.
Gap Distribution Plot:
Adjust the exponent range to visualize the gap sizes between consecutive floating point numbers.
def gradio_int_representation(n: int) -> dict:
"""
Gradio interface function to return the 64-bit representation of an integer.
Args:
n (int): The input integer.
Returns:
dict: A dictionary containing the original integer and its 64-bit binary string.
"""
return {"input_integer": n, "64-bit_representation": int64_bitstring(n)}
def gradio_rational(p: int, q: int) -> dict:
"""
Gradio interface function for rational numbers.
Args:
p (int): Numerator.
q (int): Denominator.
Returns:
dict: A dictionary with the input, simplified fraction (if possible),
or an indication of infinity when q == 0.
"""
result = rational(p, q)
if isinstance(result, Fraction):
simplified = result # Fraction automatically simplifies.
else:
simplified = result
return {"numerator": p, "denominator": q, "result": str(simplified)}
def gradio_float_representation(x: float) -> dict:
"""
Gradio interface function for floating point representation.
Args:
x (float): The input floating point number.
Returns:
dict: A dictionary containing the float's IEEE 754 components.
"""
comp = float_to_bitstring(x)
return {
"input_float": x,
"IEEE754_full": comp["full"],
"Sign_bit": comp["sign"],
"Exponent_field (as integer)": comp["exponent"],
"Mantissa_field (as integer)": comp["mantissa"],
}
def gradio_machine_epsilon() -> dict:
"""
Gradio interface function to display machine epsilon and next float after 0.
Returns:
dict: A dictionary with the direct machine epsilon, simulated epsilon,
and the smallest positive subnormal (next float after 0).
"""
eps_direct = float(machine_epsilon())
eps_sim = float(simulate_myeps())
next_after_zero = float(next_float(0.0, 1.0))
return {
"machine_epsilon (2^-52)": eps_direct,
"simulated_machine_epsilon": eps_sim,
"next_float(0.0, 1.0) [smallest positive subnormal]": next_after_zero,
}
def gradio_gap_distribution(n: int) -> gr.Plot:
"""
Gradio interface function to produce a plot with vertical lines.
Args:
n (int): The maximum exponent for which to plot the vertical lines.
Returns:
gr.Plot: A Gradio-compatible plot generated by plot_vlines.
"""
fig = plot_vlines(n)
return figwith gr.Blocks(css="""gradio-app {background: #222222 !important}""") as demo:
gr.Markdown("# IEEE 754 and Number Representations")
with gr.Tabs():
with gr.TabItem("Gap Distribution Plot"):
gr.Markdown("### Distribution of Gap Sizes in Floating Point Numbers")
n_slider = gr.Slider(
label="Max Exponent",
value=-1021,
interactive=True,
minimum=-1021,
maximum=1023,
step=1,
)
plot_output = gr.Plot(label="Gap Distribution")
n_slider.change(
fn=gradio_gap_distribution, inputs=n_slider, outputs=plot_output
)
demo.load(fn=gradio_gap_distribution, inputs=n_slider, outputs=plot_output)
with gr.TabItem("Integer Representation"):
gr.Markdown("### 64-bit Integer Representation")
int_input = gr.Number(label="Enter an integer", value=42, precision=0)
int_output = gr.JSON(label="64-bit Representation")
int_input.change(
fn=gradio_int_representation, inputs=int_input, outputs=int_output
)
demo.load(
fn=gradio_int_representation, inputs=int_input, outputs=int_output
)
with gr.TabItem("Rational Demonstration"):
gr.Markdown("### Rational Numbers")
p_input = gr.Number(label="Numerator (p)", value=2, precision=0)
q_input = gr.Number(label="Denominator (q)", value=3, precision=0)
rat_output = gr.JSON(label="Rational Output")
p_input.change(
fn=gradio_rational, inputs=[p_input, q_input], outputs=rat_output
)
q_input.change(
fn=gradio_rational, inputs=[p_input, q_input], outputs=rat_output
)
demo.load(fn=gradio_rational, inputs=[p_input, q_input], outputs=rat_output)
with gr.TabItem("Floating Point Representation"):
gr.Markdown("### IEEE 754 Double Precision Breakdown")
float_input = gr.Number(
label="Enter a floating point number", value=3.14159
)
float_output = gr.JSON(label="IEEE 754 Components")
float_input.change(
fn=gradio_float_representation, inputs=float_input, outputs=float_output
)
demo.load(
fn=gradio_float_representation, inputs=float_input, outputs=float_output
)
with gr.TabItem("Machine Epsilon and Subnormals"):
gr.Markdown("### Machine Precision and Next Float")
eps_output = gr.JSON(label="Machine Epsilon and Next Float")
eps_button = gr.Button("Show Machine Epsilon and Next Float")
eps_button.click(fn=gradio_machine_epsilon, inputs=[], outputs=eps_output)