The program we wrote at the end of the last part performs a series of repetitive operations, but did it by copying and pasting the call to our function and editing the input. As noted above, this is a likely source of errors. Instead we want to write a formula, algorithm or abstraction of our repeated operation, and reproduce that in code.
First we reproduce that function:
In [1]:
from math import pi
def degrees_to_radians(theta_d):
"""
Convert an angle from degrees to radians.
Parameters
----------
theta_d : float
The angle in degrees.
Returns
-------
theta_r : float
The angle in radians.
"""
theta_r = pi / 180.0 * theta_d
return theta_r
We showed above how to use this code to print the angles $(n \pi)/ 12$ for $n = 1, 2, \dots, 6$. We did this by calling the degrees_to_radians
function on the angles $15 n$ degrees for $n = 1, 2, \dots, 6$. So this is the formula we want to reproduce in code. To do that we write a loop.
Here is a standard way to do it:
In [2]:
theta_d = 0.0
while theta_d <= 90.0:
print(degrees_to_radians(theta_d))
theta_d = theta_d + 15.0
Let's examine this line by line. The first line defines the angle in degrees, theta_d
. We start from $\theta_d=0$.
The next line defines the loop. This has similarities to our definition of a function. We use the keyword while
to say that what follows is going to be a loop. We then define a logical condition that will be either True
or False
. Whilst the condition is True
, the statements in the loop will be executed. The colon :
at the end of the line ends the logical condition and says that what follows will be the statements inside the loop. As with the function, the code block with the statements inside the loop is indented by four spaces or one tab.
The code block contains two lines, both of which will be executed. The first prints the converted angle to the screen. The second increases the angle in degrees by 15. At the end of the code block, Python will check the logical condition theta_d <= 90.0
again. If it is True
the statements inside the loop are executed again. If it is False
, the code moves on to the next line after the loop.
There is another way to write a loop that we can use:
In [3]:
steps = 1, 2, 3, 4, 5, 6
for n in steps:
print(degrees_to_radians(15*n))
Let's examine this code line by line. It first defines a set of numbers, steps
, which contains the integers from 1 to 6 (we will make this more precise later when we discuss lists and tuples). We then define the loop using the for
command. This looks at the set of numbers steps
and picks an entry out one at a time, setting the variable n
to be the value of that member of the set. So, the first time through the loop n=1
. The next, n=2
. Once it has iterated through all members of the set steps
, it stops.
The colon :
at the end of the line defines the code block that each iteration of the loop should perform. Exactly as when we defined a function, the code block is indented by four spaces or one tab. In each iteration through the loop, the commands indented by this amount will be run. In this case, only one line (the print...
line) will be run. On each iteration the value of n
changes, leading to the different angle.
Writing out a long list of integers is a bad idea. A better approach is the use the range
function. This compresses the code to:
In [4]:
for n in range(1,7):
print(degrees_to_radians(15*n))
The range
function takes the input arguments <start>
and <end>
, and the optional input argument <step>
, to produce the integers from the start up to, but not including, the end. If the <start>
is not given it defaults to 0
, and if the <step>
is not given it defaults to 1
.
(Strictly, range
does not return the full list in one go. It generates the results one at a time. This is much faster and more efficient. In the for
loop this is all you need. To actually view what range
generates all together, convert it to a list
, as list(range(...))
).
Check this against examples such as:
In [5]:
print(list(range(4)))
print(list(range(-1,3)))
print(list(range(1,10,2)))
In some programming languages this is where the discussion of a for
loop would end: the "loop counter" must be an integer. In Python, a loop is just iterating over a set of values, and these can be much more general. An alternative way (using floats) to do the same loop would be
In [6]:
angles = 15.0, 30.0, 45.0, 60.0, 75.0, 90.0
for angle in angles:
print(degrees_to_radians(angle))
But we can get much more general than that. The different things in the set don't have to have the same type:
In [7]:
things = 1, 2.3, True, degrees_to_radians
for thing in things:
print(thing)
This can be used to write very efficient code, but is a feature that isn't always available in other programming languages.
When should we use for
loops and when while
loops? In most cases either will work. Different algorithms have different conventions, so where possible follow the convention. The advantage of the for
loop is that it is clearer how much work will be done by the loop (as, in principle, we know how many times the loop block will be executed). However, sometimes you need to perform a repetitive operation but don't know in advance how often you'll need to do it. For example, to the nearest degree, what is the largest angle $\theta_d$ that when converted to radians is less than 4? (For the picky, we're restricting $\theta_d$ so that $0^{\circ} \le \theta_d < 360^{\circ}$).
Rather than doing the sensible thing and doing the analytic calculation, we can do the following:
If $\theta_r < 4$:
We can reproduce this algorithm using a while
loop:
In [8]:
theta_d = 0.0
while degrees_to_radians(theta_d) < 4.0:
theta_d = theta_d + 1.0
This could be done in a for
loop, but not so straightforwardly.
To summarize:
The structure of the while
loop is similar to the for
loop. The loop is defined by a keyword (while
or for
) and the end of the line defining the loop condition is given by a colon. With each iteration of the loop the indented code is executed. The difference is in how the code decides when to stop looping, and what changes with each iteration.
In the for
loop the code iterates over the objects in a set, and some variable is modified with each iteration based on the new object. Once all objects in the set have been iterated over, the loop stops.
In a while
loop some condition is checked; while it is true the loop continues, and as soon as it is false the loop stops. Here we are checking if $\theta_r$, given by degrees_to_radians(theta_d)
, is still less than 4. However, nothing in the definition of the loop actually changes: it is the statement within the loop that actually changes the angle $\theta_d$.
We quickly check that the answer given makes sense:
In [9]:
print(theta_d - 1.0)
print(theta_d)
print(degrees_to_radians(theta_d-1.0) / 4.0)
print(degrees_to_radians(theta_d) / 4.0)
We see that the answer is $229^{\circ}$.
We noted above that whether or not a while
loop executes depends on the truth (or not) of a particular statement. In programming these logical statements take Boolean values (either true or false). In Python, the values of a Boolean statement are either True
or False
, which are the keywords used to refer to them. Multiple statements can be chained together using the logical operators and
, or
, not
. For example:
In [10]:
print(True)
print(6 < 7 and 10 > 9)
print(1 < 2 or 1 < 0)
print(not (6 < 7) and 10 > 9)
print(6 < 7 < 8)
The last example is particularly important, as this chained example (6 < 7 < 8
) is equivalent to (6 < 7) and (7 < 8)
. This checks both inequalities - checking that $6 < x < 8$ when $x=7$, by checking that both $6 < x$ and $x < 8$ is true when $x=7$, which is true (and mathematically what you would expect). However, many programming languages would not interpret it this way, but would instead interpret it as (6 < 7) < 8
, which is equivalent to True < 8
, which is nonsense. Chaining operations in this way is useful in Python, but don't expect it to always work in other languages.
When talking about loops we informally introduced the collection of objects 1, 2, 3, 4, 5, 6
, and assigned it to a single variable steps
. This is one of many types of container: a single object that contains other objects. If the objects in the container have an order then the container is often called a sequence: an object that contains an ordered sequence of other objects. These sorts of objects are everywhere in mathematics: sets, groups, vectors, matrices, equivalence classes, categories, ... Programming languages also implement a large number of them. Python has four essential containers, the most important of which for our purposes are lists, tuples, and dictionaries.
A list is an ordered collection of objects. For example:
In [11]:
list1 = [1, 2, 3, 4, 5, 6]
list2 = [15.0, 30.0, 45.0, 60.0, 75.0, 90.0]
list3 = [1, 2.3, True, degrees_to_radians]
list4 = ["hello", list1, False]
list5 = []
Lists are defined by square brackets, []
. Objects in the list are separated by commas. A list can be empty (list5
above). A list can contain other lists (list4
above). The objects in the list don't have to have the same type (list3
and list4
above).
We can access a member of a list by giving its name, square brackets, and the index of the member (starting from 0!):
In [12]:
list1[0]
Out[12]:
In [13]:
list2[3]
Out[13]:
Entries in a list can be modified:
In [14]:
list4[1] = "goodbye"
list4
Out[14]:
Additional entries can be appended onto the end of a list:
In [15]:
list4.append('end')
list4
Out[15]:
Entries can be removed (popped) from the end of a list:
In [16]:
entry = list4.pop()
print(entry)
list4
Out[16]:
The length of a list can be found:
In [17]:
len(list4)
Out[17]:
Lists are probably the most used container, but there's a closely related container that we've already used: the tuple.
Tuples are ordered collections of objects that, once created, cannot be modified. For example:
In [18]:
tuple1 = 1, 2, 3, 4, 5, 6
tuple2 = (15.0, 30.0, 45.0, 60.0, 75.0, 90.0)
tuple3 = (1, 2.3, True, degrees_to_radians)
tuple4 = ("hello", list1, False)
tuple5 = ()
tuple6 = (5,)
Tuples are defined by the commas separating the entries. The round brackets ()
surrounding the entries are conventional, useful for clarity, and for grouping. If you want to create an empty tuple (tuple5
) the round brackets are necessary. A tuple containing a single entry (tuple6
) must have a trailing comma.
Tuples can be accessed in the same ways as lists, and their length found with len
in the same way. But they cannot be modified, so we cannot add additional entries, or remove them, or alter any:
In [19]:
tuple1[0]
Out[19]:
In [20]:
tuple4[1] = "goodbye"
However, if a member of a tuple can itself be modified (for example, it's a list, as tuple4[1]
is), then that entry can be modified:
In [21]:
print(tuple4[1])
tuple4[1][1] = 33
print(tuple4[1])
Tuples appear a lot when using functions, either when passing in parameters, or when returning results. They can often be treated like lists, and there are functions that convert lists to tuples and vice versa:
In [22]:
converted_list1 = list(tuple1)
converted_tuple1 = tuple(list1)
Accessing and manipulating multiple entries of a list at once is an efficient and effective way of coding: it shows up a lot in, for example, linear algebra. This is where slicing comes in.
We have seen that we can access a single element of a list using square brackets and an index. We can use similar notation to access multiple elements:
In [23]:
list1 = [1, 2, 3, 4, 5, 6]
print(list1[0])
print(list1[1:3])
print(list1[2:])
print(list1[:4])
The slicing notation [<start>:<end>]
returns all entries from the <start>
to the entry before the <end>
. So:
list1[1:3]
returns all entries from the second (index 1) to the third (the entry before index 3). <end>
is not given all entries up to the end are returned, so list1[2:]
returns all entries from the third (index 2) to the end. <start>
is not given all entries from the start are returned, so list1[:4]
returns all entries from the start until the fourth (the entry before index 4).There are a number of other ways that slicing can be used. First, we can specify the step:
In [24]:
print(list1[0:6:2])
print(list1[1::3])
print(list1[4:1:-1])
By using a negative step we can reverse the order (as shown in the final example), but then we need to be careful with the <start>
and <end>
.
This <start>:<end>:<step>
notation varies between programming languages: some use <start>:<step>:<end>
.
Second, we can give an index that counts from the end, where the final entry is -1
:
In [25]:
print(list1[-1])
print(list1[-2])
print(list1[2:-2])
print(list1[-4:-2])
Slicing is often seen as part of assignment. For example
In [26]:
list_slice = [0, 0, 0, 0, 0, 0, 0, 0]
list_slice[1:4] = list1[3:]
print(list_slice)
This is related to a very useful Python feature: unpacking. Normally we have assigned a single variable to a single value (although that value might be a container such as a list). However, we can assign multiple values in one go:
In [27]:
a, b, c = list1[3:]
print(a)
print(b)
print(c)
This can be used to directly swap two variables, for example:
In [28]:
a, b = b, a
print(a)
print(b)
The number of entries on both sides must match.
All the containers we have seen so far have had an order - lists and tuples are sequences, and we access the objects within them using list[0]
or tuple[3]
, for example. A dictionary is our first unordered container. These are useful for collections of objects with meaningful names, but where the order of the objects has no importance.
Dictionaries are defined using curly braces. The "name" of each entry is given first (usually called its key), followed by a :
, and then after the colon comes its value. Multiple entries are separated by commas. For example:
In [29]:
from math import sin, cos, exp, log
functions = {"sine" : sin,
"cosine" : cos,
"exponential" : exp,
"logarithm" : log}
print(functions)
Note that the order it prints out need not match the order we entered the values in. In fact, the order could change if we used a different machine, or entered the values again. This emphasizes the unordered nature of dictionaries.
To access an individual value, we use its key:
In [30]:
print(functions["exponential"])
To find all the keys or values we can use dictionary methods:
In [31]:
print(functions.keys())
print(functions.values())
Depending on the version of Python you are using, this might either give a list or an iterator.
When iterating over a dictionary (for k in dict:
) the key is returned, as if we had said for key in dict.keys():
. This is most useful in a loop, such as the following. Think carefully about this code, and make sure you understand what is happening!
In [32]:
for name in functions:
print("The result of {}(1) is {}.".format(name, functions[name](1.0)))
To explain: The first line says that we are going to iterate over each entry in the dictionary by assigning the value of the key (which is the name of the function in functions
) to the variable name
. The next line extracts the function associated with that name using functions[name]
and applies that function to the value 1 using functions[name](1.0)
. The full line prints out the result in a human readable form.
But Python has other ways of iterating over dictionaries that can make life even easier, such as the items
function:
In [33]:
for name, function in functions.items():
print("The result of {}(1) is {}.".format(name, function(1.0)))
So this does exactly the same thing as the previous loop, and most of the code is the same. However, rather than accessing the dictionary each time (using functions[name]
), the value in the dictionary has been returned at the start. What is happening is that the items
function is returning both the key and the value as a tuple on each iteration through the loop. The name, function
notation then uses unpacking to appropriately set the variables. This form is "more pythonic" (ie, is shorter, clearer to many people, and faster).
Above we have always set the key to be a string. This is not necessary - it can be an integer, or a float, or any constant object. We have also set the values to have the same type. As with lists this is not necessary.
Dictionaries are very useful for adding simple structure to your code, and allow you to pass around complex sets of parameters easily.
Not every algorithm can be expressed as a single mathematical formula in the manner used so far. Alternatively, it may make the algorithm appear considerably simpler if it isn't expressed in one complex formula but in multiple simpler forms. This is where the computer has to be able to make choices, to control when and if a particular formula is used.
As a simple example, if we used our degrees_to_radians
calculation as previously given, then if the angle $\theta_d$ is outside the standard $[0, 360]^{\circ}$ interval then the converted angle in radians $\theta_r$ will be outside the $[0, 2\pi]$ interval.
Suppose we want to "normalize" all our angles to lie within the $[0, 2\pi]$ interval. We could use modular arithmetic using the %
operator:
In [34]:
theta_d = 5134.6
theta_d_normalized = theta_d % 360.0
print(theta_d_normalized)
But it might be that the input is just wrong: there's a typo, and the caller should be warned, and maybe the input completely rejected. We can write a different function to check that.
In [35]:
from math import pi
def check_angle_normalized(theta_d):
"""
Check that an angle lies within [0, 360] degrees.
Parameters
----------
theta_d : float
The angle in degrees.
Returns
-------
normalized : Boolean
Whether the angle lies within the range
"""
normalized = True
if theta_d > 360.0:
normalized = False
print("Input angle greater than 360 degrees. Did you mean this?")
if theta_d < 0.0:
normalized = False
print("Input angle less than 0 degrees. Did you mean this?")
return normalized
In [36]:
theta_d = 5134.6
print(check_angle_normalized(theta_d))
theta_d = -52.3
print(check_angle_normalized(theta_d))
The control flow here uses the if
statement. As with loops such as the for
and while
loops we have a condition which is checked which, if satisfied, leads to the indented code block after the colon being executed. The logical statements theta_d > 360.0
and theta_d < 0.0
are evaluated and return either True
or False
(which is how Python represents boolean values). If True
, then the statement is executed.
We could use only a single logical statement to check if $\theta_d$ lies in an acceptable range by using logical relations. For example, we could replace the two if
statements by the single statement
if (theta_d > 360.0) or (theta_d < 0.0):
normalized = False
print("Input angle outside [0, 360] degrees. Did you mean this?")
The logical statement (theta_d > 360.0) or (theta_d < 0.0)
is either True
or False
as above. In addition to the logical or
statement, Python also has the logical and
and logical not
statements, from which more complex statements can be generated.
Often we want to do one thing if a condition is true, and another if the condition is false. A full example of this would be to rewrite the whole function as:
In [37]:
from math import pi
def check_angle_normalized(theta_d):
"""
Check that an angle lies within [0, 360] degrees.
Parameters
----------
theta_d : float
The angle in degrees.
Returns
-------
normalized : Boolean
Whether the angle lies within the range
"""
normalized = True
if theta_d > 360.0:
normalized = False
print("Input angle greater than 360 degrees. Did you mean this?")
elif theta_d < 0.0:
normalized = False
print("Input angle less than 0 degrees. Did you mean this?")
else:
print("Input angle in range [0, 360] degrees. Good.")
return normalized
The elif
statement allows another condition to be checked - it is how Python represents "else if", or "all previous checks have been false; let's check this statement as well". Multiple elif
blocks can be included to check more conditions. The else
statement contains no logical check: this code block will always be executed if all previous statements were false.
For example:
In [38]:
theta_d = 543.2
print(check_angle_normalized(theta_d))
theta_d = -123.4
print(check_angle_normalized(theta_d))
theta_d = 89.12
print(check_angle_normalized(theta_d))
We can nest statements as deep as we like, nesting loops and control flow statements as we go. We have to ensure that the indentation level is consistent. Here is a silly example.
In [39]:
angles = [-123.4, 543.2, 89.12, 0.67, 5143.6, 30.0, 270.0]
# We run through all the angles, but only print those that are
# - in the range [0, 360], and
# - if sin^2(angle) < 0.5
from math import sin
for angle in angles:
print("Input angle in degrees:", angle)
if (check_angle_normalized(angle)):
angle_r = degrees_to_radians(angle)
if (sin(angle_r)**2 < 0.5):
print("Valid angle in radians:", angle_r)
Earlier we saw how to read error messages to debug single statements. When we start including loops and functions it may be more complex and the information from the error message alone, whilst useful, may not be enough.
In these more complex cases the reason for the error depends on the calculations inside the code, and the steps through the code need inspecting in detail. This is where a debugger is useful. It allows you to run the code, pause at specific points or conditions, step through it as it runs line-by-line, and inspect all the values as you go. There are a number of Python debuggers - pdb
and ipdb
being the most basic. However, spyder
has a debugger built in, and learning to use it will make your life considerably easier.
The main use of the debugger is to inspect the internal state of a code whilst it is running. To do that we have to stop the execution of the code somewhere. This is typically done using breakpoints.
Copy the following function into a file named breakpoints.py
:
def test_sequence(N):
"""
Compute the infinite sum of 2^{-n} starting from n = 0, truncating
at n = N, returning the value of 2^{-n} and the truncated sum.
Parameters
----------
N : int
Positive integer, giving the number of terms in the sum
Returns
-------
limit : float
The value of 2^{-N}
partial_sum : float
The value of the truncated sum
Notes
-----
The limiting value should be zero, and the value of the sum should
converge to 2.
"""
# Start sum from zero, so give zeroth term
limit = 1.0
partial_sum = 1.0
# At each step, increment sum and change summand
for n in range(1, N+1):
partial_sum = partial_sum + limit
limit = limit / 2.0
return limit, partial_sum
if __name__ == '__main__':
print(test_sequence(50))
This computes the value $2^{-N}$ and the partial sum $\sum_{n=0}^N 2^{-n}$. The limit as $N\to\infty$ of the value should be zero, and of the sum should be two.
The final two lines ensures that, if the file is run as a python script, the function will be called (with N=50
). The if
statement is a standard Python convention: if you have code that you want executed only if the file is run as a script, and not if the file is imported as a module.
If we run the function we find it does not work as expected:
In [40]:
import breakpoints
print(breakpoints.test_sequence(10))
print(breakpoints.test_sequence(100))
print(breakpoints.test_sequence(1000))
The value is tending to the right limit; the sum is not. The test (that we should have written formally) has failed. It may be obvious what the problem is, but we illustrate how to find it using the debugger.
First, we must know what to expect. The terms in the partial sum (which is where the problem lies) should be
\begin{equation}
\sum_{n=0}^N 2^{-n} = 1 + \frac{1}{2} + \frac{1}{4} + \dots
\end{equation}
We want to inspect what the partial sum actually does. This is performed in the code within the for
loop, which starts on line 32 of the code.
spyder
. spyder
window, click on the "Variable explorer" tab. This shows the values of all the variables at this point. Note that line 32 has not yet been executed. The value of the partial sum, given by partial_sum
, is currently 1
(as the N=0
term is set outside of the sum).n
appear in the Variable explorer, set to 1.limit
, which is meant to represent $2^{-n}$, to the partial sum partial_sum
. However, this value is 1, so partial_sum
becomes 2. According to the sum written out above, this $n=1$ term should be $1/2$. So this is a bug.To fix this bug, the simplest thing to do is to ensure that limit
has the value corresponding to the n
th part of the sum before it is used, by swapping lines 33 and 34.
You should experiment with adding breakpoints and stepping through some of your own codes. In particular, you should note that "Run"ning the current line of code will skip over any function calls, including calls to functions you have yourself defined. If you want to follow the code as it executes functions, you need to use the "Step into" button instead.
Write a function to compute all prime factors of an integer $n$, including their multiplicities. Test it by printing the prime factors (without multiplicities) of $n = 17, \dots, 20$ and the multiplicities (without factors) of $n = 48$.
One effective solution is to return a dictionary, where the keys are the factors and the values are the multiplicities.
Investigate the timeit
function in Python or IPython. Use this to measure how long your function takes to check that, if $k$ on the Mersenne list then $n = 2^{k-1} \times (2^k - 1)$ is a perfect number, using your functions. Stop increasing $k$ when the time takes too long!
You could waste considerable time on this, and on optimizing the functions above to work efficiently. It is not worth it, other than to show how rapidly the computation time can grow!