Representing Rational Numbers With Python Fractions – Real Python


The fractions module in Python is arguably one of the most underused elements of the standard library. Even though it may not be well-known, it’s a useful tool to have under your belt because it can help address the shortcomings of floating-point arithmetic in binary. That’s essential if you plan to work with financial data or if you require infinite precision for your calculations.

Towards the end of this tutorial, you’ll see a few hands-on examples where fractions are the most suitable and elegant choice. You’ll also learn about their weaknesses and how to make the best use of them along the way.

In this tutorial, you’ll learn how to:

  • Convert between decimal and fractional notation
  • Perform rational number arithmetic
  • Approximate irrational numbers
  • Represent fractions exactly with infinite precision
  • Know when to choose Fraction over Decimal or float

The majority of this tutorial goes over the fractions module, which in itself doesn’t require in-depth Python knowledge other than an understanding of its numeric types. However, you’ll be in a good place to work through all the code examples that follow if you’re familiar with more advanced concepts such as Python’s built-in collections module, itertools module, and generators. You should already be comfortable with these topics if you want to make the most out of this tutorial.

Decimal vs Fractional Notation

Let’s take a walk down memory lane to bring back your school knowledge of numbers and avoid possible confusion. There are four concepts at play here:

  1. Types of numbers in mathematics
  2. Numeral systems
  3. Notations of numbers
  4. Numeric data types in Python

You’ll get a quick overview of each of these now to better understand the purpose of the Fraction data type in Python.

Classification of Numbers

If you don’t remember the classification of numbers, here’s a quick refresher:

The Main Types of Numbers in Mathematics
Types of Numbers

There are many more types of numbers in mathematics, but these are the most relevant in day-to-day life. At the very top, you’ll find complex numbers that include imaginary and real numbers. Real numbers are, in turn, comprised of rational and irrational numbers. Finally, rational numbers contain integers and natural numbers.

Numeral Systems and Notations

There have been various systems of expressing numbers visually over the centuries. Today, most people use a positional numeral system based on Hindu-Arabic symbols. You can choose any base or radix for such a system. However, while people prefer the decimal system (base-10), computers work best in the binary system (base-2).

Within the decimal system itself, you can represent some numbers using alternative notations:

  • Decimal: 0.75
  • Fractional: ¾

Neither of these is better or more precise than the other. Expressing a number in decimal notation is perhaps more intuitive because it resembles a percentage. Comparing decimals is also more straightforward since they already have a common denominator—the base of the system. Finally, decimal numbers can communicate precision by keeping the trailing and leading zeros.

On the other hand, fractions are more convenient in performing symbolic algebra by hand, which is why they’re mainly used in school. But can you recall the last time you used fractions? If you can’t, then that’s because decimal notation is central in calculators and computers nowadays.

The fractional notation is typically associated with rational numbers only. After all, the very definition of a rational number states that you can express it as a quotient, or a fraction, of two integers as long as the denominator is nonzero. However, that’s not the whole story when you factor in infinite continued fractions that can approximate irrational numbers:

Decimal vs Fractional Notation

Irrational numbers always have a non-terminating and non-repeating decimal expansion. For example, the decimal expansion of pi (π) never runs out of digits that seem to have a random distribution. If you were to plot their histogram, then each digit would have a roughly similar frequency.

On the other hand, most rational numbers have a terminating decimal expansion. However, some can have an infinite recurring decimal expansion with one or more digits repeated over a period. The repeated digits are commonly denoted with an ellipsis (0.33333…) in the decimal notation. Regardless of their decimal expansion, rational numbers such as the number representing one-third always look elegant and compact in the fractional notation.

Numeric Data Types in Python

Numbers with infinite decimal expansions cause rounding errors when stored as a floating-point data type in computer memory, which itself is finite. To make matters worse, it’s often impossible to exactly represent numbers with terminating decimal expansion in binary!

That’s known as the floating-point representation error, which affects all programming languages, including Python. Every programmer faces this problem sooner or later. For example, you can’t use float in applications like banking, where numbers must be stored and acted on without any loss of precision.

Python’s Fraction is one of the solutions to these obstacles. While it represents a rational number, the name Rational already represents an abstract base class in the numbers module. The numbers module defines a hierarchy of abstract numeric data types to model the classification of numbers in mathematics:

Type Hierarchy for Numbers in Python
Type hierarchy for numbers in Python

Fraction is a direct and concrete subclass of Rational, which provides the complete implementation for rational numbers in Python. Integral types like int and bool also derive from Rational, but those are more specific.

With this theoretical background out of the way, it’s time to create your first fraction!

Creating a Python Fraction From Different Data Types

Unlike int or float, fractions aren’t a built-in data type in Python, which means you have to import a corresponding module from the standard library to use them. However, once you get past this extra step, you’ll find that fractions just represent another numeric type that you can freely mix with other numbers and mathematical operators in arithmetic expressions.

There are a few ways to create a fraction in Python, and they all involve using the Fraction class. It’s the only thing you ever need to import from the fractions module. The class constructor accepts zero, one, or two arguments of various types:

>>>

>>> from fractions import Fraction
>>> print(Fraction())
0
>>> print(Fraction(0.75))
3/4
>>> print(Fraction(3, 4))
3/4

When you call the class constructor without arguments, it creates a new fraction representing the number zero. A single-argument flavor attempts to convert the value of another data type to a fraction. Passing in a second argument makes the constructor expect a numerator and a denominator, which must be instances of the Rational class or its descendants.

Note that you must print() a fraction to reveal its human-friendly textual representation with the slash character (/) between the numerator and the denominator. If you don’t, it will fall back to a slightly more explicit string representation made up of a piece of Python code. You’ll learn how to convert fractions to strings later in this tutorial.

Rational Numbers

When you call the Fraction() constructor with two arguments, they must both be rational numbers such as integers or other fractions. If either the numerator or denominator isn’t a rational number, then you won’t be able to create a new fraction:

>>>

>>> Fraction(3, 4.0)
Traceback (most recent call last):
  ...
    raise TypeError("both arguments should be "
TypeError: both arguments should be Rational instances

You get a TypeError instead. Although 4.0 is a rational number in mathematics, it isn’t considered as such by Python. That’s because the value is stored as a floating-point data type, which is too broad and can be used to represent any real number.

Similarly, you can’t create a fraction whose denominator is zero because that would lead to a division by zero, which is undefined and has no meaning in mathematics:

>>>

>>> Fraction(3, 0)
Traceback (most recent call last):
  ...
    raise ZeroDivisionError('Fraction(%s, 0)' % numerator)
ZeroDivisionError: Fraction(3, 0)

Python raises the ZeroDivisionError. However, when you specify a valid numerator and a valid denominator, they’ll be automatically normalized for you as long as they have a common divisor:

>>>

>>> Fraction(9, 12)  # GCD(9, 12) = 3
Fraction(3, 4)

>>> Fraction(0, 12)  # GCD(0, 12) = 12
Fraction(0, 1)

Both magnitudes get simplified by their greatest common divisor (GCD), which happens to be three and twelve, respectively. The normalization also takes the minus sign into account when you define negative fractions:

>>>

>>> -Fraction(9, 12)
Fraction(-3, 4)

>>> Fraction(-9, 12)
Fraction(-3, 4)

>>> Fraction(9, -12)
Fraction(-3, 4)

Whether you put the minus sign before the constructor or before either of the arguments, for consistency, Python will always associate the sign of a fraction with its numerator. There’s currently a way of disabling this behavior, but it’s undocumented and could get removed in the future.

You’ll typically define fractions as a quotient of two integers. Whenever you provide only one integer, Python will turn that number into an improper fraction by assuming the denominator is 1:

>>>

>>> Fraction(3)
Fraction(3, 1)

Conversely, if you skip both arguments, the numerator will be 0:

>>>

>>> Fraction()
Fraction(0, 1)

You don’t always have to provide integers for the numerator and denominator, though. The documentation states that they can be any rational numbers, including other fractions:

>>>

>>> one_third = Fraction(1, 3)

>>> Fraction(one_third, 3)
Fraction(1, 9)

>>> Fraction(3, one_third)
Fraction(9, 1)

>>> Fraction(one_third, one_third)
Fraction(1, 1)

In each case, you’ll get a fraction as a result, even though they sometimes represent integer values like 9 and 1. Later, you’ll see how to convert fractions to other data types.

What happens if you give the Fraction constructor a single argument that also happens to be a fraction? Try this code to find out:

>>>

>>> Fraction(one_third) == one_third
True

>>> Fraction(one_third) is one_third
False

You’re getting the same value, but it’s a distinct copy of the input fraction. That’s because calling the constructor always produces a new instance, which coincides with the fact that fractions are immutable, just like other numeric types in Python.

Floating-Point and Decimal Numbers

So far, you’ve only used rational numbers to create fractions. After all, the two-argument version of the Fraction constructor requires that both numbers are Rational instances. However, that’s not the case with the single-argument constructor, which will happily accept any real number and even a non-numeric value such as a string.

Two prime examples of real number data types in Python are float and decimal.Decimal. While only the latter can represent rational numbers exactly, both can approximate irrational numbers just fine. Related to this, if you were wondering, Fraction is similar to Decimal in this regard since it’s a descendant of Real.

Unlike float or Fraction, the Decimal class isn’t formally registered as a subclass of numbers.Real despite implementing its methods:

>>>

>>> from numbers import Real
>>> issubclass(float, Real)
True

>>> from fractions import Fraction
>>> issubclass(Fraction, Real)
True

>>> from decimal import Decimal
>>> issubclass(Decimal, Real)
False

That’s intentional since decimal floating-point numbers don’t play well with their binary counterparts:

>>>

>>> from decimal import Decimal
>>> Decimal("0.75") - 0.25
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for -: 'decimal.Decimal' and 'float'

On the other hand, replacing Decimal with an equivalent Fraction would yield a float result in the example above.

Before Python 3.2, you could only create fractions from real numbers using the .from_float() and .from_decimal() class methods. While not deprecated, they’re redundant today because the Fraction constructor can take both data types directly as an argument:

>>>

>>> from decimal import Decimal
>>> Fraction(0.75) == Fraction(Decimal("0.75"))
True

Whether you make Fraction objects from float or Decimal objects, their values are the same. You’ve seen a fraction created from a floating-point value before:

>>>

>>> print(Fraction(0.75))
3/4

The result is the same number expressed in fractional notation. However, this code works as expected only by coincidence. In most cases, you won’t get the intended value due to the representation error that affects float numbers, whether they’re rational or not:

>>>

>>> print(Fraction(0.1))
3602879701896397/36028797018963968

Whoa! What happened here?

Let’s break it down in slow motion. The previous number, which can be represented as either 0.75 or ¾, can also be expressed as the sum of ½ and ¼, which are negative powers of 2 that have exact binary representations. On the other hand, the number ⅒ can only be approximated with a non-terminating repeating expansion of binary digits:

The Binary Expansion of One Tenth

Because the binary string must eventually end due to the finite memory, its tail gets rounded. By default, Python only shows the most significant digits defined in sys.float_info.dig, but you can format a floating-point number with an arbitrary number of digits if you want to:

>>>

>>> str(0.1)
'0.1'

>>> format(0.1, ".17f")
'0.10000000000000001'
>>> format(0.1, ".18f")
'0.100000000000000006'
>>> format(0.1, ".19f")
'0.1000000000000000056'
>>> format(0.1, ".55f")
'0.1000000000000000055511151231257827021181583404541015625'

When you pass a float or a Decimal number to the Fraction constructor, it calls their .as_integer_ratio() method to obtain a tuple of two irreducible integers whose ratio gives precisely the same decimal expansion as the input argument. These two numbers are then assigned to the numerator and denominator of your new fraction.

Now, you can piece together where these two big numbers came from:

>>>

>>> Fraction(0.1)
Fraction(3602879701896397, 36028797018963968)

>>> (0.1).as_integer_ratio()
(3602879701896397, 36028797018963968)

If you pull out your pocket calculator and punch these numbers in, then you’ll get back 0.1 as a result of the division. However, if you were to divide them by hand or use a tool like WolframAlpha, then you’d end up with those fifty-five decimal places you saw earlier.

There is a way to find close approximations of your fraction that have more down-to-earth values. You can use .limit_denominator(), for example, which you’ll learn more about later in this tutorial:

>>>

>>> one_tenth = Fraction(0.1)
>>> one_tenth
Fraction(3602879701896397, 36028797018963968)

>>> one_tenth.limit_denominator()
Fraction(1, 10)

>>> one_tenth.limit_denominator(max_denominator=int(1e16))
Fraction(1000000000000000, 9999999999999999)

This might not always give you the best approximation, though. The bottom line is that you should never try to create fractions straight from real numbers such as float if you want to avoid the rounding errors that will likely come up. Even the Decimal class might be susceptible to that if you’re not careful enough.

Anyway, fractions let you communicate the decimal notation most accurately with a string in their constructor.

Strings

There are two string formats that the Fraction constructor accepts, which correspond to decimal and fractional notation:

>>>

>>> Fraction("0.1")
Fraction(1, 10)

>>> Fraction("1/10")
Fraction(1, 10)

Both notations can optionally have a plus sign (+) or a minus sign (-), while the decimal one can additionally include the exponent in case you want to use the scientific notation:

>>>

>>> Fraction("-2e-3")
Fraction(-1, 500)

>>> Fraction("+2/1000")
Fraction(1, 500)

The only difference between the two results is that one is negative and one is positive.

When you use the fractional notation, you can’t use whitespace characters around the slash character (/):

>>>

>>> Fraction("1 / 10")
Traceback (most recent call last):
  ...
    raise ValueError('Invalid literal for Fraction: %r' %
ValueError: Invalid literal for Fraction: '1 / 10'

To find out exactly which strings are valid or not, you can explore the regular expression in the module’s source code. Remember to create fractions from a string or a correctly instantiated Decimal object rather than a float value so that you can retain maximum precision.

Now that you’ve created a few fractions, you might be wondering what they can do for you other than group two numbers. That’s a great question!

Inspecting a Python Fraction

The Rational abstract base class defines two read-only attributes for accessing a fraction’s numerator and denominator:

>>>

>>> from fractions import Fraction
>>> half = Fraction(1, 2)
>>> half.numerator
1
>>> half.denominator
2

Since fractions are immutable, you can’t change their internal state:

>>>

>>> half.numerator = 2
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: can't set attribute

If you try assigning a new value to one of the fraction’s attributes, then you’ll get an error. In fact, you have to create a new fraction whenever you’d like to modify one. For example, to invert your fraction, you could call .as_integer_ratio() to get a tuple and then use the slicing syntax to reverse its elements:

>>>

>>> Fraction(*half.as_integer_ratio()[::-1])
Fraction(2, 1)

The unary star operator (*) unpacks your reversed tuple and relays its elements to the Fraction constructor.

Another useful method that comes with every fraction lets you find the closest rational approximation to a number given in the decimal notation. It’s the .limit_denominator() method, which you’ve already touched on earlier in this tutorial. You can optionally request the maximum denominator for your approximation:

>>>

>>> pi = Fraction("3.141592653589793")

>>> pi
Fraction(3141592653589793, 1000000000000000)

>>> pi.limit_denominator(20_000)
Fraction(62813, 19994)

>>> pi.limit_denominator(100)
Fraction(311, 99)

>>> pi.limit_denominator(10)
Fraction(22, 7)

The initial approximation might not be the most convenient to use, but it is the most faithful. This method can also help you recover a rational number stored as a floating-point data type. Remember that float may not represent all rational numbers exactly, even when they have terminating decimal expansions:

>>>

>>> pi = Fraction(3.141592653589793)

>>> pi
Fraction(884279719003555, 281474976710656)

>>> pi.limit_denominator()
Fraction(3126535, 995207)

>>> pi.limit_denominator(10)
Fraction(22, 7)

You’ll notice a different result on the highlighted line as compared to the previous code block, even though the float instance looks the same as the string literal that you passed to the constructor before! Later, you’ll explore an example of using .limit_denominator() to find approximations of irrational numbers.

Converting a Python Fraction to Other Data Types

You’ve learned how to create fractions from the following data types:

  • str
  • int
  • float
  • decimal.Decimal
  • fractions.Fraction

What about the opposite? How do you convert a Fraction instance back to these types? You’ll find out in this section.

Floating-Point and Integer Numbers

Converting between native data types in Python usually involves calling one of the built-in functions such as int() or float() on an object. These conversions work as long as the object implements the corresponding special methods such as .__int__() or .__float__(). Fractions happen to inherit only the latter from the Rational abstract base class:

>>>

>>> from fractions import Fraction
>>> three_quarters = Fraction(3, 4)

>>> float(three_quarters)
0.75

>>> three_quarters.__float__()  # Don't call special methods directly
0.75

>>> three_quarters.__int__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'Fraction' object has no attribute '__int__'

You’re not supposed to call special methods on objects directly, but it’s helpful for demonstration purposes. Here, you’ll notice that fractions implement only .__float__() and not .__int__().

When you investigate the source code, you’ll notice that the .__float__() method conveniently divides a fraction’s numerator by its denominator to get a floating-point number:

>>>

>>> three_quarters.numerator / three_quarters.denominator
0.75

Bear in mind that turning a Fraction instance into a float instance will likely result in a lossy conversion, meaning you might end up with a number that’s slightly off:

>>>

>>> float(Fraction(3, 4)) == Fraction(3, 4)
True

>>> float(Fraction(1, 3)) == Fraction(1, 3)
False

>>> float(Fraction(1, 10)) == Fraction(1, 10)
False

Although fractions don’t provide the implementation for the integer conversion, all real numbers can be truncated, which is a fall-back for the int() function:

>>>

>>> fraction = Fraction(14, 5)

>>> int(fraction)
2

>>> import math
>>> math.trunc(fraction)
2

>>> fraction.__trunc__()  # Don't call special methods directly
2

You’ll discover a few other related methods in a section about rounding fractions later on.

Decimal Numbers

If you try creating a Decimal number from a Fraction instance, then you’ll quickly find out that such a direct conversion isn’t possible:

>>>

>>> from decimal import Decimal
>>> Decimal(Fraction(3, 4))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: conversion from Fraction to Decimal is not supported

When you try, you get a TypeError. Since a fraction represents a division, however, you can bypass that limitation by wrapping only one of the numbers with Decimal and dividing them manually:

>>>

>>> fraction = Fraction(3, 4)
>>> fraction.numerator / Decimal(fraction.denominator)
Decimal('0.75')

Unlike float but similar to Fraction, the Decimal data type is free from the floating-point representation error. So, when you convert a rational number that can’t be represented exactly in binary floating-point, you’ll retain the number’s precision:

>>>

>>> fraction = Fraction(1, 10)
>>> decimal = fraction.numerator / Decimal(fraction.denominator)

>>> fraction == decimal
True

>>> fraction == 0.1
False

>>> decimal == 0.1
False

At the same time, rational numbers with non-terminating repeating decimal expansion will lead to precision loss when converted from fractional to decimal notation:

>>>

>>> fraction = Fraction(1, 3)
>>> decimal = fraction.numerator / Decimal(fraction.denominator)

>>> fraction == decimal
False

>>> decimal
Decimal('0.3333333333333333333333333333')

That’s because there’s an infinite number of threes in the decimal expansion of one-third, or Fraction(1, 3), while the Decimal type has a fixed precision. By default, it stores only twenty-eight decimal places. You can adjust it if you want, but it’s going to be finite nevertheless.

Strings

The string representation of fractions reveals their values using the familiar fractional notation, while their canonical representation outputs a piece of Python code comprised of a call to the Fraction constructor:

>>>

>>> one_third = Fraction(1, 3)

>>> str(one_third)
'1/3'

>>> repr(one_third)
'Fraction(1, 3)'

Whether you use str() or repr(), the result is a string, but their contents are different.

Unlike other numeric types, fractions don’t support string formatting in Python:

>>>

>>> from decimal import Decimal
>>> format(Decimal("0.3333333333333333333333333333"), ".2f")
'0.33'

>>> format(Fraction(1, 3), ".2f")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported format string passed to Fraction.__format__

If you try, then you get a TypeError. That might be an issue if you’d like to refer to a Fraction instance in a string template to fill out the placeholders, for example. On the other hand, you can quickly fix this by converting fractions to floating-point numbers, especially as you don’t need to care about precision in such a scenario.

If you’re working in a Jupyter Notebook, then you might want to render LaTeX formulas based on your fractions instead of their regular textual representation. To do that, you must monkey patch the Fraction data type by adding a new method, ._repr_pretty_(), which Jupyter Notebook recognizes:

from fractions import Fraction
from IPython.display import display, Math

Fraction._repr_pretty_ = lambda self, *args: 
    display(Math(rf"$$frac{{{self.numerator}}}{{{self.denominator}}}"))

It wraps a piece of LaTeX markup in a Math object and sends it to your notebook’s rich display, which can render the markup using the MathJax library:

LaTeX Fraction In a Jupyter Notebook

The next time you evaluate a notebook cell that contains a Fraction instance, it’ll draw a beautiful math formula instead of printing text.

Performing Rational Number Arithmetic on Fractions

As mentioned before, you can use fractions in arithmetic expressions consisting of other numeric types. Fractions will interoperate with most numeric types except for decimal.Decimal, which has its own set of rules. Moreover, the data type of the other operand, regardless of whether it lies to the left or the right of your fraction, will determine the type of your arithmetic operation’s result.

Addition

You can add two or more fractions without having to think about reducing them to their common denominator:

>>>

>>> from fractions import Fraction
>>> Fraction(1, 2) + Fraction(2, 3) + Fraction(3, 4)
Fraction(23, 12)

The result is a new fraction that’s the sum of all the input fractions. The same will happen when you add up integers and fractions:

>>>

>>> Fraction(1, 2) + 3
Fraction(7, 2)

However, as soon as you start mixing fractions with non-rational numbers—that is, numbers that aren’t subclasses of numbers.Rational—then your fraction will be converted to that type first before being added:

>>>

>>> Fraction(3, 10) + 0.1
0.4

>>> float(Fraction(3, 10)) + 0.1
0.4

You get the same result whether or not you explicitly use float(). That conversion may result in some loss of precision since your fraction as well as the outcome are now stored in floating-point representation. Even though the number 0.4 seems right, it’s not exactly equal to the fraction 4/10.

Subtraction

Subtracting fractions is no different than adding them. Python will find the common denominator for you:

>>>

>>> Fraction(3, 4) - Fraction(2, 3) - Fraction(1, 2)
Fraction(-5, 12)

>>> Fraction(4, 10) - 0.1
0.30000000000000004

This time, the precision loss is so significant that it’s visible at a glance. Notice the long stream of zeros followed by a digit 4 at the end of the decimal expansion. It’s the result of rounding a value that would otherwise require an infinite number of binary digits.

Multiplication

When you multiply two fractions, their numerators and denominators get multiplied element-wise, and the resulting fraction gets automatically reduced if necessary:

>>>

>>> Fraction(1, 4) * Fraction(3, 2)
Fraction(3, 8)

>>> Fraction(1, 4) * Fraction(4, 5)  # The result is 4/20
Fraction(1, 5)

>>> Fraction(1, 4) * 3
Fraction(3, 4)

>>> Fraction(1, 4) * 3.0
0.75

Again, depending on the type of the other operand, you’ll end up with a different data type in the result.

Division

There are two division operators in Python, and fractions support both of them:

  1. True division: /
  2. Floor division: //

The true division results in another fraction, while a floor division always returns a whole number with the fractional part truncated:

>>>

>>> Fraction(7, 2) / Fraction(2, 3)
Fraction(21, 4)

>>> Fraction(7, 2) // Fraction(2, 3)
5

>>> Fraction(7, 2) / 2
Fraction(7, 4)

>>> Fraction(7, 2) // 2
1

>>> Fraction(7, 2) / 2.0
1.75

>>> Fraction(7, 2) // 2.0
1.0

Note that the floor division’s result isn’t always an integer! The result may end up a float depending on what data type you use together with the fraction. Fractions also support the modulo operator (%) as well as the divmod() function, which might help in creating mixed fractions from improper ones:

>>>

>>> def mixed(fraction):
...     floor, rest = divmod(fraction.numerator, fraction.denominator)
...     return f"{floor} and {Fraction(rest, fraction.denominator)}"
...
>>> mixed(Fraction(22, 7))
'3 and 1/7'

Instead of generating a string like in the output above, you could update the function to return a tuple comprised of the whole part and the fractional remainder. Go ahead and try modifying the return value of the function to see the difference.

Exponentiation

You can raise fractions to a power with the binary exponentiation operator (**) or the built-in pow() function. You can also use fractions themselves as exponents. Go back to your Python interpreter now and start exploring how to raise fractions to a power:

>>>

>>> Fraction(3, 4) ** 2
Fraction(9, 16)

>>> Fraction(3, 4) ** (-2)
Fraction(16, 9)

>>> Fraction(3, 4) ** 2.0
0.5625

You’ll notice that you can use both positive and negative exponent values. When the exponent isn’t a Rational number, your fraction is automatically converted to float before proceeding.

Things get more complicated when the exponent is a Fraction instance. Since fractional powers typically produce irrational numbers, both operands are converted to float unless the base and the exponent are whole numbers:

>>>

>>> 2 ** Fraction(2, 1)
4

>>> 2.0 ** Fraction(2, 1)
4.0

>>> Fraction(3, 4) ** Fraction(1, 2)
0.8660254037844386

>>> Fraction(3, 4) ** Fraction(2, 1)
Fraction(9, 16)

The only time you get a fraction as a result is when the denominator of the exponent is equal to one and you’re raising a Fraction instance.

Rounding a Python Fraction

There are many strategies for rounding numbers in Python and even more in mathematics. You can use the same set of built-in global and module-level functions for fractions and decimal numbers. They will let you assign an integer to a fraction or make a new fraction corresponding to fewer decimal places.

You already learned about a crude rounding method when you converted fractions to an int, which truncated the fractional part leaving only the whole part, if any:

>>>

>>> from fractions import Fraction

>>> int(Fraction(22, 7))
3

>>> import math
>>> math.trunc(Fraction(22, 7))
3

>>> math.trunc(-Fraction(22, 7))
-3

In this case, calling int() is equivalent to calling math.trunc(), which rounds positive fractions down and negative fractions up. These two operations are known as floor and ceiling, respectively. You can use both directly if you want to:

>>>

>>> math.floor(-Fraction(22, 7))
-4

>>> math.floor(Fraction(22, 7))
3

>>> math.ceil(-Fraction(22, 7))
-3

>>> math.ceil(Fraction(22, 7))
4

Compare the results of math.floor() and math.ceil() with your earlier calls to math.trunc(). Each function has a different rounding bias, which may affect the statistical properties of your rounded data set. Fortunately, there’s a strategy known as rounding half to even, which is less biased than truncation, floor, or ceiling.

Essentially, it rounds your fraction to the nearest whole number while preferring the closest even number for the equidistant halves. You can call round() to take advantage of this strategy:

>>>

>>> round(Fraction(3, 2))  # 1.5
2

>>> round(Fraction(5, 2))  # 2.5
2

>>> round(Fraction(7, 2))  # 3.5
4

Notice how those fractions get rounded up or down depending on where the closest even number is? Naturally, this rule only applies to ties when the distance to the nearest whole number on the left is the same as the one on the right. Otherwise, the rounding direction is based on the shortest distance to a whole number regardless of whether it’s even or not.

You can optionally provide the round() function with the second parameter, which indicates how many decimal places you want to retain. When you do, you’ll always get a Fraction rather than an integer, even when you request zero digits:

>>>

>>> fraction = Fraction(22, 7)  # 3.142857142857143

>>> round(fraction, 0)
Fraction(3, 1)

>>> round(fraction, 1)  # 3.1
Fraction(31, 10)

>>> round(fraction, 2)  # 3.14
Fraction(157, 50)

>>> round(fraction, 3)  # 3.143
Fraction(3143, 1000)

However, notice the difference between calling round(fraction) and round(fraction, 0), which yields the same value but uses a different data type:

>>>

>>> round(fraction)
3

>>> round(fraction, 0)
Fraction(3, 1)

When you omit the second argument, round() will return the nearest integer. Otherwise, you’ll get a reduced fraction whose denominator was originally a power of ten corresponding to the number of decimal digits you requested.

Comparing Fractions in Python

In real life, comparing numbers written in fractional notation can be more difficult than comparing numbers written in decimal notation because fractional notation is comprised of two values instead of just one. To make sense of those numbers, you typically reduce them to a common denominator and compare only their numerators. For example, try arranging the following fractions in ascending order according to their value:

It’s not as convenient as with decimal notation. Things get even worse with mixed notations. However, when you rewrite those fractions with a common denominator, sorting them becomes straightforward:

The greatest common divisor of 3, 8, and 13 is 1. This means that the smallest common denominator for all three fractions is their product, 312. Once you’ve converted all fractions to use their smallest common denominator, you can ignore the denominator and focus on comparing the numerators.

In Python, this works behind the scenes when you compare and sort Fraction objects:

>>>

>>> from fractions import Fraction

>>> Fraction(8, 13) < Fraction(5, 8)
True

>>> sorted([Fraction(2, 3), Fraction(5, 8), Fraction(8, 13)])
[Fraction(8, 13), Fraction(5, 8), Fraction(2, 3)]

Python can quickly sort the Fraction objects using the built-in sorted() function. Helpfully, all the comparison operators work as intended. You can even use them against other numeric types, except for complex numbers:

>>>

>>> Fraction(2, 3) < 0.625
False

>>> from decimal import Decimal
>>> Fraction(2, 3) < Decimal("0.625")
False

>>> Fraction(2, 3) < 3 + 2j
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: '<' not supported between instances of 'Fraction' and 'complex'

The comparison operators worked with floats and decimals, but you get an error when you try with the complex number 3 + 2j. This has to do with the fact that complex numbers don’t define a natural ordering relation, so you can’t compare them to anything—including fractions.

Choosing Between Fraction, Decimal, and Float

If you need to pick just one thing to remember from reading this tutorial, then it should be when to choose Fraction over Decimal and float. All these numeric types have their use cases, so it’s good to understand their strengths and weaknesses. In this section, you’ll have a brief look at how numbers are represented in each of these three data types.

Binary Floating-Point: float

The float data type should be your default choice for representing real numbers in most situations. For example, it’s suitable in science, engineering, and computer graphics, where execution speed is more important than precision. Hardly any program requires higher precision than you can get with a floating-point anyway.

The unparalleled speed of floating-point arithmetic stems from its implementation in hardware rather than software. Virtually all math coprocessors conform to the IEEE 754 standard, which describes how to represent numbers in binary floating-point. The downside of using the binary system is, as you guessed, the infamous representation error.

However, unless you have a specific reason to use a different numeric type, you should just stick to float or int if possible.

Decimal Floating-Point and Fixed-Point: Decimal

There are times when using the binary system doesn’t provide enough precision for real numbers. One notable example is financial calculations, which involve dealing with very large and very small numbers at the same time. They also tend to repeat the same arithmetic operation over and over again, which could accumulate a significant rounding error.

You can store real numbers using decimal floating-point arithmetic to mitigate these problems and eliminate the binary representation error. It’s similar to float as it moves the decimal point around to accommodate larger or smaller magnitudes. However, it operates in the decimal system instead of in the binary.

Another strategy to increase numerical precision is fixed-point arithmetic, which allocates a specific number of digits for the decimal expansion. For example, a precision of up to four decimal places would require storing fractions as integers scaled up by a factor of 10,000. To recover the original fractions, they would be scaled down accordingly.

Python’s decimal.Decimal data type is a hybrid of decimal floating-point and fixed-point representations under the hood. It also follows these two standards:

  1. General Decimal Arithmetic Specification (IBM)
  2. Radix-Independent Floating-Point Arithmetic (IEEE 854-1987)

They’re emulated in software rather than hardware, making this data type much less efficient in terms of time and space than float. On the other hand, it can represent numbers with arbitrary yet finite precision, which you’re free to adjust. Note that you may still face round-off errors if an arithmetic operation exceeds the maximum number of decimal places.

However, the safety buffer provided by the fixed precision today might become insufficient tomorrow. Consider hyperinflation or dealing with multiple currencies having vastly different rates, such as Bitcoin (0.000029 BTC) and Iranian Rial (42,105.00 IRR). If you want infinite precision, then use Fraction.

Infinite Precision Rational Number: Fraction

Both the Fraction and Decimal types share a few similarities. They address the binary representation error, they’re implemented in software, and you can use them for monetary applications. Nevertheless, the primary use for fractions is to represent rational numbers, so they might be less convenient for storing money than decimals.

There are two advantages to using Fraction over Decimal. The first one is infinite precision bounded only by your available memory. This lets you represent rational numbers with non-terminating and recurring decimal expansion without any loss of information:

>>>

>>> from fractions import Fraction
>>> one_third = Fraction(1, 3)
>>> print(3 * one_third)
1

>>> from decimal import Decimal
>>> one_third = 1 / Decimal(3)
>>> print(3 * one_third)
0.9999999999999999999999999999

Multiplying 1/3 by 3 gives you exactly 1 in the fractional notation, but the result is rounded in the decimal notation. It has twenty-eight decimal places, which is the default precision of the Decimal type.

Take a second look at another benefit of fractions, one which you already started learning about earlier. Unlike Decimal, fractions can interoperate with binary floating-point numbers:

>>>

>>> Fraction("0.75") - 0.25
0.5

>>> Decimal("0.75") - 0.25
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for -: 'decimal.Decimal' and 'float'

When you mix fractions with floats, you get a floating-point number as a result. On the other hand, if you try to mix fractions with a Decimal data type, then you’ll run into a TypeError.

Studying a Python Fraction in Action

In this section, you’ll go through a few fun and practical examples of using the Fraction data type in Python. You might be surprised how handy fractions can be and simultaneously how undervalued they are. Get ready to dive right in!

Approximating Irrational Numbers

Irrational numbers play an important role in mathematics, which is why they occur tangentially to many subfields such as arithmetic, calculus, and geometry. Some of the most famous ones that you might have heard of before are:

  • The square root of two (√2)
  • Archimedes’ constant (π)
  • The golden ratio (φ)
  • Euler’s number (e)

In the history of mathematics, pi (π) has been particularly interesting, which resulted in many attempts at finding accurate approximations for it.

While ancient philosophers had to go to great lengths, today you can use Python to find pretty good estimates of pi using Monte Carlo methods, such as the Buffon’s needle or similar. However, having only a rough approximation in the form of a convenient fraction should suffice in most day-to-day problems. Here’s how you can determine a quotient of two integers that give gradually better approximations of an irrational number:

from fractions import Fraction
from itertools import count

def approximate(number):
    history = set()
    for max_denominator in count(1):
        fraction = Fraction(number).limit_denominator(max_denominator)
        if fraction not in history:
            history.add(fraction)
            yield fraction

The function accepts an irrational number, converts it to a fraction, and finds a different fraction with fewer decimal digits. The Python set prevents yielding duplicate values by keeping historical data, and the itertools module’s count() iterator counts to infinity.

You can now use this function to find the first ten fractional approximations of pi:

>>>

>>> from itertools import islice
>>> import math

>>> for fraction in islice(approximate(math.pi), 10):
...     print(f"{str(fraction):>7}", "→", float(fraction))
...
      3 → 3.0
   13/4 → 3.25
   16/5 → 3.2
   19/6 → 3.1666666666666665
   22/7 → 3.142857142857143
 179/57 → 3.1403508771929824
 201/64 → 3.140625
 223/71 → 3.140845070422535
 245/78 → 3.141025641025641
 267/85 → 3.1411764705882352

Nice! The rational number 22/7 is already quite close, which shows that pi can be approximated early on and isn’t particularly irrational after all. The islice() iterator stops the infinite iteration after receiving the requested ten values. Go ahead and play with this example by bumping up the number of results or finding approximations of other irrational numbers.

Getting a Display’s Aspect Ratio

The aspect ratio of an image or a display is a quotient of its width to height that conveniently expresses proportions. It’s commonly used in film and digital media, while movie directors like to take advantage of the aspect ratio as an artistic measure. If you’ve ever been on a hunt for a new smartphone, then the specs might have mentioned a screen ratio such as 16:9, for example.

You can find out your computer monitor’s aspect ratio by measuring its width and height with Tkinter, which comes with the official Python distribution:

>>>

>>> import tkinter as tk
>>> window = tk.Tk()
>>> window.winfo_screenwidth()
2560
>>> window.winfo_screenheight()
1440

Note that if you have multiple monitors connected, then this code might not work as expected.

Calculating the aspect ratio is a matter of creating a fraction that will reduce itself:

>>>

>>> from fractions import Fraction
>>> Fraction(2560, 1440)
Fraction(16, 9)

There you go. The monitor has a 16:9 resolution. However, if you’re on a laptop that has a smaller screen size, then your fraction might not work out at first, and you’ll need to limit its denominator accordingly:

>>>

>>> Fraction(1360, 768)
Fraction(85, 48)

>>> Fraction(1360, 768).limit_denominator(10)
Fraction(16, 9)

Keep in mind that if you’re dealing with a mobile device’s vertical screen, you should swap the dimensions so that the first one is greater than the following one. You can encapsulate this logic in a reusable function:

from fractions import Fraction

def aspect_ratio(width, height, max_denominator=10):
    if height > width:
        width, height = height, width
    ratio = Fraction(width, height).limit_denominator(max_denominator)
    return f"{ratio.numerator}:{ratio.denominator}"

This will ensure consistent aspect ratios regardless of the order of arguments:

>>>

>>> aspect_ratio(1080, 2400)
'20:9'

>>> aspect_ratio(2400, 1080)
'20:9'

Whether you’re looking at the measurements of a horizontal or a vertical screen, the aspect ratios are the same.

So far, width and height have been integers, but what about fractional values? For example, some Canon cameras have an APS-C crop sensor, whose dimensions are 22.8 mm by 14.8 mm. Fractions choke on floating-point and decimal numbers, but you can turn them into rational approximations:

>>>

>>> aspect_ratio(22.2, 14.8)
Traceback (most recent call last):
  ...
    raise TypeError("both arguments should be "
TypeError: both arguments should be Rational instances

>>> aspect_ratio(Fraction("22.2"), Fraction("14.8"))
'3:2'

In this case, the aspect ratio turns out to be exactly 1.5 or 3:2, but many cameras use a slightly longer width for their sensors, which gives a ratio of 1.555… or 14:9. When you do the math, you’ll find out that it’s the arithmetic mean of the wide-format (16:9) and the four-thirds system (4:3), which is a compromise to let you display pictures acceptably well in both of these popular formats.

Calculating the Exposure Value of a Photo

The standard format for embedding metadata in digital images, Exif (Exchangeable Image File Format), uses ratios to store multiple values. Some of the most important ratios describe the exposure of your photo:

  • Aperture Value
  • Exposure Time
  • Exposure Bias
  • Focal Length
  • F-Stop
  • Shutter Speed

The shutter speed is colloquially synonymous with the exposure time, but it’s stored as a fraction in the metadata using the APEX system based on a logarithmic scale. It means that a camera would take the reciprocal of your exposure time and then calculate the logarithm base 2 of it. So, for example, 1/200 of a second exposure time would be written as 7643856/1000000 to the file. Here’s how you can calculate it:

>>>

>>> from fractions import Fraction
>>> exposure_time = Fraction(1, 200)

>>> from math import log2, trunc
>>> precision = 1_000_000
>>> trunc(log2(Fraction(1, exposure_time)) * precision)
7643856

You could use Python fractions to recover the original exposure time if you manually read this metadata without the help of any external libraries:

>>>

>>> shutter_speed = Fraction(7643856, 1_000_000)
>>> Fraction(1, round(2 ** shutter_speed))
Fraction(1, 200)

When you combine the individual pieces of the puzzle—that is, the aperture, the shutter speed, and the ISO speed—you’ll be able to calculate a single exposure value (EV), which describes the average amount of captured light. You can then use it to derive a log mean of the luminance in the photographed scene, which is invaluable in post-processing and applying special effects.

The formula for calculating the exposure value is as follows:

from math import log2

def exposure_value(f_stop, exposure_time, iso_speed):
    return log2(f_stop ** 2 / exposure_time) - log2(iso_speed / 100)

Keep in mind that it doesn’t take into account other factors such as the exposure bias or the flash-lamp that your camera might apply. Anyway, give it a try against some sample values:

>>>

>>> exposure_value(
...     f_stop=Fraction(28, 5),
...     exposure_time=Fraction(1, 750),
...     iso_speed=400
... )
12.521600439723727

>>> exposure_value(f_stop=5.6, exposure_time=1/750, iso_speed=400)
12.521600439723727

You can use fractions or other numeric types for the input values. In this case, the exposure value is around +13, which is relatively bright. The picture was taken outside on a sunny day, albeit in the shade.

Solving the Change-Making Problem

You can use fractions to tackle computer science’s classic change-making problem, which you might encounter on a job interview. It asks for the minimum number of coins to get a certain amount of money. For example, if you consider the most popular coins of the US dollar, then you could represent $2.67 as ten quarters (10 × $0.25), one dime (1 × $0.10), one nickel (1 × $0.05), and two pennies (2 × $0.01).

Fractions can be a convenient tool to represent coins in a wallet or a cash register. You could define the coins of the US dollar in the following way:

from fractions import Fraction

penny = Fraction(1, 100)
nickel = Fraction(5, 100)
dime = Fraction(10, 100)
quarter = Fraction(25, 100)

Some of them will automatically reduce themselves, but that’s okay because you’ll format them using decimal notation. You can use these coins to calculate the total value of your wallet:

>>>

>>> wallet = [8 * quarter, 5 * dime, 3 * nickel, 2 * penny]
>>> print(f"${float(sum(wallet)):.2f}")
$2.67

Your wallet amounts to $2.67, but it has as many as eighteen coins. It’s possible to use fewer coins for the same amount. One way of approaching the change-making problem is by using a greedy algorithm, such as this one:

def change(amount, coins):
    while amount > 0:
        for coin in sorted(coins, reverse=True):
            if coin <= amount:
                amount -= coin
                yield coin
                break
        else:
            raise Exception("There's no solution")

The algorithm tries to find a coin with the highest denomination that’s no greater than the remaining amount. While it’s relatively straightforward to implement, it might not give an optimal solution in all coin systems. Here’s an example for the coins of the US dollar:

>>>

>>> from collections import Counter

>>> amount = Fraction("2.67")
>>> usd = [penny, nickel, dime, quarter]

>>> for coin, count in Counter(change(amount, usd)).items():
...     print(f"{count:>2} × ${float(coin):.2f}")
...
10 × $0.25
 1 × $0.10
 1 × $0.05
 2 × $0.01

Using rational numbers is mandatory to find a solution because the floating-point values won’t cut it. Since change() is a generator function yielding coins that might repeat, you can use Counter to group them.

You might modify this problem by asking a slightly different question. For instance, what would be the optimal set of coins given the total price, customer’s coins, and seller’s coins available in the cash register?

Producing and Expanding Continued Fractions

At the beginning of this tutorial, you learned that irrational numbers could be represented as infinite continued fractions. Such fractions would require an infinite amount of memory to exist, but you can choose when to stop producing their coefficients to get a reasonable approximation.

The following generator function will yield the given number’s coefficients endlessly in a lazy-evaluated fashion:

 1def continued_fraction(number):
 2    while True:
 3        yield (whole_part := int(number))
 4        fractional_part = number - whole_part
 5        try:
 6            number = 1 / fractional_part
 7        except ZeroDivisionError:
 8            break

The function truncates the number and keeps expressing the remaining fraction as a reciprocal that’s fed back as the input. To eliminate code duplication, it uses an assignment expression on line 3, more commonly known as the walrus operator introduced in Python 3.8.

Interestingly enough, you can create continued fractions for rational numbers too:

>>>

>>> list(continued_fraction(42))
[42]

>>> from fractions import Fraction
>>> list(continued_fraction(Fraction(3, 4)))
[0, 1, 3]

The number 42 has just one coefficient and no fractional part. Conversely, 3/4 has no whole part and a continued fraction comprised of 1 over 1 + 1/3:

Continued Fraction of One-Third

As usual, you should watch out for the floating-point representation error that may creep in when you switch over to float:

>>>

>>> list(continued_fraction(0.75))
[0, 1, 3, 1125899906842624]

While you can represent 0.75 in binary faithfully, its reciprocal has a non-terminating decimal expansion despite being a rational number. As you go through the rest of the coefficients, you’ll eventually hit this huge magnitude in the denominator representing a negligibly small value. That’s your approximation error.

You can get rid of this error by replacing real numbers with Python fractions:

from fractions import Fraction

def continued_fraction(number):
    while True:
        yield (whole_part := int(number))
        fractional_part = Fraction(number) - whole_part
        try:
            number = Fraction(1, fractional_part)
        except ZeroDivisionError:
            break

This small change lets you reliably generate the coefficients of continued fractions corresponding to decimal numbers. Otherwise, you could end up in an infinite loop even for terminating decimal expansions.

Okay, let’s do something more fun and generate the coefficients of irrational numbers with their decimal expansions chopped off at the fiftieth decimal place. For the sake of precision, define them as Decimal instances :

>>>

>>> from decimal import Decimal
>>> pi = Decimal("3.14159265358979323846264338327950288419716939937510")
>>> sqrt2 = Decimal("1.41421356237309504880168872420969807856967187537694")
>>> phi = Decimal("1.61803398874989484820458683436563811772030917980576")

Now, you can check the first few coefficients of their continued fractions using the familiar islice() iterator:

>>>

>>> from itertools import islice

>>> numbers = {
...     " π": pi,
...     "√2": sqrt2,
...     " φ": phi
... }

>>> for label, number in numbers.items():
...     print(label, list(islice(continued_fraction(number), 20)))
...
 π [3, 7, 15, 1, 292, 1, 1, 1, 2, 1, 3, 1, 14, 2, 1, 1, 2, 2, 2, 2]
√2 [1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
 φ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

The first four coefficients of pi give a surprisingly good approximation followed by an insignificant remainder. However, the continued fractions of the other two constants look very peculiar. They repeat the same number over and over again till infinity. Knowing this, you could approximate them by expanding those coefficients back to their decimal form:

def expand(coefficients):
    if len(coefficients) > 1:
        return coefficients[0] + Fraction(1, expand(coefficients[1:]))
    else:
        return Fraction(coefficients[0])

It’s convenient to define this function recursively so that it can call itself on successively smaller lists of coefficients. In the base case, there’s only one whole number, which is the roughest approximation possible. If there are two or more, then the result is the sum of the first coefficient followed by a reciprocal of the remaining coefficients expanded.

You can verify if both functions work as expected by calling them on their opposite return values:

>>>

>>> list(continued_fraction(3.14159))
[3, 7, 15, 1, 25, 1, 7, 4, 851921, 1, 1, 2, 880, 1, 2]

>>> float(expand([3, 7, 15, 1, 25, 1, 7, 4, 851921, 1, 1, 2, 880, 1, 2]))
3.14159

Perfect! If you feed the result of continued_fraction() to expand(), then you get back the initial value you had at the start. In some cases, though, you might need to convert the expanded fraction to the Decimal type instead of float for greater precision.

Conclusion

You might have never thought about how computers store fractional numbers before reading this tutorial. After all, maybe it seemed that your good old friend float could handle them just fine. However, history has shown that this misconception may eventually lead to catastrophic failures that can cost big money.

Using Python’s Fraction is one way to avoid such catastrophes. You’ve seen the pros and cons of fractional notation, its practical applications, and methods for using it in Python. Now, you can make an informed choice about which numeric type is the most appropriate in your use case.

In this tutorial, you learned how to:

  • Convert between decimal and fractional notation
  • Perform rational number arithmetic
  • Approximate irrational numbers
  • Represent fractions exactly with infinite precision
  • Know when to choose Fraction over Decimal or float



Source link

Latest articles

Related articles

Leave a reply

Please enter your comment!
Please enter your name here