18. Complex type, I

Up to this point, we have mostly ignored the more mathematical type for complex numbers. We have waited until now, because they are simpler to use when we have a grasp of applying methods.

18.1. Complex type

In mathematics, the set of complex numbers \mathbb{C} includes real \mathbb{R} and imaginary \mathbb{I}~(=
j\mathbb{R}) numbers, as well as their sum, where the "imaginary number" is defined as j^2\equiv-1. (There is a divide in the mathematical sciences -- many people use i\equiv\sqrt{-1} to denote the imaginary number, but we use j here because that is what Python syntax uses. Congratulations, engineers!) In general, a complex number number can be written as the sum of a real component a and an imaginary component b as c = a + bj, where a, b \in \mathbb{R} (and we stress that the "imaginary component" b is itself a real number). Each component can be found using the following functions: a\equiv\mathbf{Re}(c) and b\equiv\mathbf{Im}(c). So, a real number is just a special case of a complex number with b=0, and an imaginary number is one where a=0.

We can picture such numbers on a 2D complex plane (or Argand diagram). The horiontal axis is real \mathbb{R} and the vertical axis is imaginary i\mathbb{R}. Thus, a complex point c is given by the coordinate pair (a, b), such that the real component provides the "x" coordinate and the imaginary component, the "y" coordinate.

We can translate these to Python using the type which is evocatively named complex. Note that not every programming language has such a type; in others, you have consider the real and imaginary components as forming a short array (mimicking a vector with 2 components, say). This will be a useful picture to keep in mind at times, but we can also avail ourselves of Python's type/class and related functionality.

In Python, we denote a complex type by including an imaginary-looking term, which is a number followed by immediately by the letter j, with no space in between. Thus, the following are all recognized as complex by Python (you can use type() on each to verify it):

c1 = -2 + 3j          # nonzero real and imag components
c2 = 3j - 2           # order of components does not matter
c3 = 34.1 - 1j        # more nonzero real and imag components
c4 = 5 + 0j           # a complex with zero imag component
c5 = 0 + 9j           # a complex with zero real component
c6 = 9j               # same as above: complex with zero real component
c7 = complex(4)       # arg by position; imag=0 by default
c8 = complex(5,6)     # args by position, both real and imag set
c9 = complex()        # no args; real=0 and imag=0 by default
+ show/hide prints

... which would print, respectively, to (comments added):

(-2+3j)               # c1
(-2+3j)               # c2
(34.1-1j)             # c3
(5+0j)                # c4
9j                    # c5
9j                    # c6
(4+0j)                # c7
(5+6j)                # c8
0j                    # c9

Some comments:

  • For Python to recognize "j" as the imaginary number, it must always be preprended by a number. If we write 1+j, Python will treat j as a variable that must have been defined above; we would have to write 1+1j to denote a complex number (assuming we hadn't defined a complex-valued variable j previously...).

  • Note that c5 and c6 are both of type complex, as there is no separate "imaginary" type in Python; by including a j on one number, the result will be a complex, whether or not there is real term added in.

  • When there is a nonzero real component, the result is written in parentheses. This allows us to have expected behavior when multiplying by a constant: mathematically, 3c = 3a + 3bj. The following evaluate very differently: (34.1 - 1j)*5 vs 34.1 - 1j*5.

  • To have a complex type, there must be an imaginary component, even if it is zero. c4 is complex, while c4b = 5 would be int.

  • In defining c7, c8, and c9, we used the built-in complex() function with different numbers of args. We can read the help docstring via complex? to understand the default input values depending on how any args we enter:

    1Init signature: complex(real=0, imag=0)
    2Docstring:
    3Create a complex number from a real part and an optional imaginary part.
    4
    5This is equivalent to (real + imag*1j) where imag defaults to 0.
    6Type:           type
    7Subclasses:     complex128
    

Using basic mathematical operators with complex numbers just follows the mathematical rules. For example, c2 + c3 evaluates to (32.1+2j), which is the expected sum of real and imaginary components.

The magnitude |c| and phase \theta of a complex number are important concepts, forming a complementary pair of real values to describe it (the polar form, as opposed to the rectangular form above): c=|c|\,e^{i\theta}. These can also be viewed on the complex plane, where |c| is essentially the hypotenuse of the triangle formed by the sides a and b from the origin, and \theta is the angle of this hypotenuse line the positive real (\mathbb{R}^+) part of the horizontal axis. One can relate the rectangular and polar coordinate pairs mathematically:

a &= |c|\, \cos(\theta) \\
b &= |c|\, \sin(\theta) \\
|c|    &= \sqrt{a^2 + b^2} \\[0.1cm]
\theta &= \tan^{-1}\left(\dfrac{b}{a}\right)

... and we could translate these definitions to computational forms. Or, since they are pretty fundamental quantities, we could see if things exist in Python to generate the values from the complex directly. And there are, noting that "magnitude" is the same as the absolute value and "phase" is an angle:

print(np.real(c1))
print(np.imag(c1))
print(np.abs(c1))
print(np.angle(c1))

... which output:

-2.0
3.0
3.605551275463989
2.158798930342464

And of course, we shouldn't just guess that the outputs are correct and/or what we want-- we should verify them. The real and imaginary parts are straightforward to verify by inspection, and we translate the mathematical expressions above to verify the polar values. In particular, we will also want to know if the angle value is in radians or degrees; looking at part of the help string for it (np.angle?):

+ show/hide help

... we see that: 1) the output value is in radians by default, and 2) we can control this with a kwarg.

If we want to initialize an array of complex numbers, we can use all we have learned so far about arrays, and just extend it to having a complex datatype:

arr_comp = np.zeros(5, dtype=complex)
print(arr_comp)

... produces:

[0.+0.j 0.+0.j 0.+0.j 0.+0.j 0.+0.j]

One can have arrays and sequence of complex numbers; consider for example the following mathematical sequence (recognizing j as the imaginary number here, not an index, based on the syntax!):

C_k = (2k+1) j^k,~~ k \in [0,10)\,.

Q: Use a for-loop to create the above array of values in Python.

+ show/hide code

Q: Make an array c_real of \mathbf{Re}(C_k) and c_imag of \mathbf{Im}(C_k) for all k, and plot these. You have just made an Argand diagram, plotting on the complex plane.

+ show/hide code

18.2. Complex type methods

With our new found appreciation for methods that go with each type/class, let's look at some for the complex type with help(complex):

 1Help on class complex in module builtins:
 2
 3class complex(object)
 4 |  complex(real=0, imag=0)
 5 |
 6 |  Create a complex number from a real part and an optional imaginary part.
 7 |
 8 |  This is equivalent to (real + imag*1j) where imag defaults to 0.
 9 |
10 |  Methods defined here:
11 |
12 |  __abs__(self, /)
13 |      abs(self)
14 |
15 |  __add__(self, value, /)
16 |      Return self+value.
17 |
18 |  __bool__(self, /)
19 |      self != 0
20...
21
22 |  conjugate(...)
23 |      complex.conjugate() -> complex
24 |
25 |      Return the complex conjugate of its argument. (3-4j).conjugate() == 3+4j.
26 |
27...
28
29 |  Data descriptors defined here:
30 |
31 |  imag
32 |      the imaginary part of a complex number
33 |
34 |  real
35 |      the real part of a complex number

Some of these look familiar from our early adventures into methods, such as __abs__() and __add(). Though, as we noted before, these methods are specific to this class/type, so they just happen to have the same name.

The absolute value (AKA magnitude) is pretty interesting. For a complex number, we have a mathematical definition in terms of real and imaginary components as |c|\equiv\sqrt{a^2+b^2}. If we try this method out using one of the above variables:

print(c1.__abs__())

... we see that the result 3.605551275463989 is indeed what we would expect from the component values, running the check by evaluating ((-2)**2 + 3**2)**0.5.

Looking further down the methods list, we also see conjugate. This shares the name of the important mathematical operation for complex numbers, where the complex conjugate of c is defined as c^* \equiv a - bj. Testing out this complex class method, we see that:

print(c1.conjugate())

... evaluates according to our expectation: (-2-3j). Phew.

Finally, we don't see a list of attributes in the docstring, but we do see a list of data descriptors that looks kind of similar (i.e., like variables inside the type/class). Descriptors are different than attributes, and that differentiation is deeper, internal Python topic than we will cover here. But from a practical "usage" point of view, we can typically think of them as synonymous to attributes and use them similarly when we see them in docstring descriptions. So, let's do so here:

print(c1.real)
print(c1.imag)

... produces:

-2.0
3.0

That looks correct, based on the value assigned to the complex. (It also matches with the functional values above from np.real() and np.imag().

18.3. Practice

  1. Evaluate (17 - 4j).real + 1.

  2. Evaluate the square of the complex number 5+3j.

  3. What type are each of the real and imag components of a complex in Python? Is it always the same, e.g., whether one has 1+2j, 1.0+2j or 1+2.0j? What does this imply about testing equality with either a full complex or any of its components?