Machine Numbers

Code
import math
import struct
from fractions import Fraction
import numpy as np
import matplotlib.pyplot as plt
import gradio as gr

1 Introduction

This 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:

  • The structure: Composed of 1 bit for the sign, 11 bits for the exponent, and 52 bits for the mantissa.
  • The representation format:

\[ \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.

2 Python Implementation

2.1 Integer Representation

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:

Code
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)

2.2 Rational Numbers

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:

  • A zero denominator results in \(\infty\) or \(-\infty\) based on the numerator’s sign.
Code
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)

2.3 Floating Point Number Representation

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:

  • A complete 64-bit binary string.
  • Sign, exponent, and mantissa breakdown.

The layout consists of:

  • Bit 63: Sign
  • Bits 62–52: Exponent
  • Bits 51–0: Mantissa
Code
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,
    }

2.4 Machine Precision and Epsilon

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:

Code
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 prev

2.5 Next Representable Float

The 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:

Code
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))

2.6 Plotting the Distribution of Floating Point Gaps

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:

Code
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 fig

3 Interactive Dashboard

The interactive dashboard enables exploration and visualization through the following tabs:

  1. Integer Representation:
    Enter an integer to observe its 64-bit binary (two’s complement) representation.

  2. Rational Demonstration:
    Provide a numerator and a denominator to see the simplified rational output or infinity if applicable.

  3. Floating Point Representation:
    Input a float to obtain its detailed binary structure, including sign, exponent, and mantissa.

  4. Machine Epsilon and Subnormals:
    Discover machine epsilon’s direct and simulated values, including the smallest positive subnormal.

  5. Gap Distribution Plot:
    Adjust the exponent range to visualize the gap sizes between consecutive floating point numbers.

Code
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 fig
Code
with 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)
Code
demo.launch(pwa=True, show_api=False, show_error=True)
Code
# Output of this cell set dynamically in Quarto filter step
import micropip await micropip.install('plotly==5.24.1'); import math import struct from fractions import Fraction import numpy as np import matplotlib.pyplot as plt import gradio as gr 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) 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) 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, } 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 prev 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)) 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 fig 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 fig with 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) demo.launch(pwa=True, show_api=False, show_error=True)