# Hands-on Introduction to Python And Machine Learning

Instructor: Tak-Kei Lam

(Readers are assumed to have a little bit programming background.)

# Getting started with Python
(adapted from [this github repository](https://github.com/ehmatthes/intro_programming))

## Variables
A variable holds a value of various types such as string, integer, real number and boolean.

```python
message = "Hello world!" # message is a variable of type string, it holds 'Hello world!'
print(message)
```

In Python, the value of a variable can be modified.

```python
message = "Hello world!"
print(message)

message = 'Hello world! I love Python!' # message's value is changed here
print(message)
```

### Naming rules

+ Variables can contain only letters, numbers, and underscores. Variable names can start with a letter or an underscore, but can not start with a number

+ Spaces are not allowed in variable names, so we use underscores instead of spaces. For example, use student_name instead of "student name"

+ You cannot use [Python keywords](http://docs.python.org/3/reference/lexical_analysis.html#keywords) as variable names

+ Variable names should be descriptive, without being too long

+ *Case sensitive*

+ The naming rules actually also apply to other Python constructs

If you don't follow the rules, the Python interpreter will shout at you...

```python
1lovehk = 'I love HK'
```

```python
i love hk = 'I love HK'
```

```python
for='Hong kong forever! (so does Wakanda)'
```

If you attempt to use variables that have not been defined...

```python
message = 'What are the differences between a python and an anaconda?'
print(mesage)
```

Beware of typing mistakes!

** Exercise **:
- Try to create a variable of any kind, name it in whatever way and see whether there are errors
- And then type:
<code>
type(your variable)
</code>

## Strings
Strings are sets of characters.

### Single and double quotes
Strings are contained by either single or double quotes.

```python
my_string = "This is a double-quoted string."
my_string = 'This is a single-quoted string.' # use single quote if you are lazy
```

This lets us make strings that contain quotations without the need of _escape characters_.

By the way, the inventor of another programming language, Perl, stated the *three virtues of a great programmer*:
> Laziness: The quality that makes you go to great effort to reduce overall energy expenditure. It makes you write labor-saving programs that other people will find useful and document what you wrote so you don't have to answer so many questions about it.
>
> Impatience: The anger you feel when the computer is being lazy. This makes you write programs that don't just react to your needs, but actually anticipate them. Or at least pretend to.
>
> Hubris: The quality that makes you write (and maintain) programs that other people won't want to say bad things about.

```python
quote = "Linus Torvalds once said, 'Any program is only as good as it is useful.'"
print(quote)
```

### Changing case
You can easily change the case of a string, to present it the way you want it to look.

```python
name = 'ada wong '

print(name)
print(name.title())
```

```python
first_name = 'ada'

print(first_name)
print(first_name.title())
print(first_name.upper())

first_name = 'Ada'
print(first_name.lower())
```

You will see this syntax quite often, where a variable name is followed by a dot and then the name of an action, followed by a set of parentheses. The parentheses may be empty, or they may contain some values.

variable_name.action()

In this example, the word "action" is the name of a method. A method is something that can be done to a variable. The methods 'lower', 'title', and 'upper' are all functions that have been written into the Python language, which do something to strings. Later on, you will learn to write your own methods.

### Combining strings (concatenation)
It is often very useful to be able to combine strings into a message or page element that we want to display. Again, this is easier to understand through an example.

```python
first_name = 'ada'
last_name = 'wong'

full_name = first_name + ' ' + last_name

print(full_name.title())
```

The plus sign combines two strings into one, which is called "concatenation". You can use as many plus signs as you want in composing messages.

```python
first_name = 'ada'
last_name = 'lovelace'
full_name = first_name + ' ' + last_name

message = full_name.title() + ' ' + "was considered the world's first computer programmer."

print(message)
```

### Whitespace
The term "whitespace" refers to characters that the computer is aware of, but are invisible to readers. The most common whitespace characters are spaces, tabs, and newlines.

A space is just " ". The two-character sequence "\t" makes a tab appear in a string. Tabs can be used anywhere you like in a string. Similarly, newlines are created by a two-character sequence "\n".

```python
print('Hello everyone!')
```

```python
print('\tHello everyone!')
```

```python
print('Hello \teveryone!')
```

The combination "\n" makes a newline appear in a string. You can use newlines anywhere you like in a string.

```python
print('Hello everyone!')
```

```python
print('\nHello everyone!'')

```

```python
print('Hello \neveryone!')
```

```python
print('\n\n\nHello everyone!')
```

### Stripping whitespace

Many times you will allow users to enter text into a box, and then you will read that text and use it. It is really easy for people to include extra whitespace at the beginning or end of their text. Whitespace includes spaces, tabs, and newlines.

It is often a good idea to strip this whitespace from strings before you start working with them. In Python, it is really easy to strip whitespace from the left side, the right side, or both sides of a string.

```python
name = ' ada '

print(name.lstrip()) # strip the spaces on the left hand side
print(name.rstrip()) # strip the spaces on the right hand side
print(name.strip())  # strip the spaces on both sides
```

It's hard to see exactly what is happening, so maybe the following will make it a little more clear:

```python
name = ' ada '

print('-' + name.lstrip() + '-')
print('-' + name.rstrip() + '-')
print('-' + name.strip() + '-')
```

** Exercise **:
- Try to print the following lines using only one print() (excluding the #s):

```python
#********************************************************
#*                                                      *
#*   I'm loving Python                                  *
#*              Let's make programming GREAT again      *
#*                                                      *
#********************************************************
```

## Numbers
Dealing with simple numerical data is fairly straightforward in Python, but there are a few things you should know about.

### Integers

You can do all of the basic arithmetic operations with integers, and everything should behave as you expect.

```python
print(3+2)
```

```python
print(3-2)
```

```python
print(3*2)
```

```python
print(3/2)
```

```python
print(3**2)
```

### Arithmetic Operators

| Symbol | Task Performed |
|----|---|
| +  | Addition |
| -  | Subtraction |
| *  | multiplication |
| **  | to the power of |
| /  | division |
| //  | floor division (divide and then round down to the nearest integer)|
| %  | mod |

You can use parenthesis to modify the standard order of operations.

```python
standard_order = 2+3*4
print(standard_order)
```

```python
my_order = (2+3)*4
print(my_order)
```

### Floating-point numbers
Floating-point numbers refer to any number with a decimal point. Most of the time, you can think of floating point numbers as decimals, and they will behave as you expect them to. All the arithematic operators also apply to them.

```python
print(0.1+0.1)
```

However, sometimes you will get an answer with an unexpectly long decimal part:

```python
print(0.1+0.2)
```

This happens because of the way computers represent numbers internally; this has nothing to do with Python itself. Basically, we are used to working in powers of ten, where one tenth plus two tenths is just three tenths. But computers work in powers of two. So your computer has to represent 0.1 in a power of two, and then 0.2 as a power of two, and express their sum as a power of two. There is no exact representation for 0.3 in powers of two, and we see that in the answer to 0.1+0.2.

Python tries to hide this kind of stuff when possible. Don't worry about it much for now; just don't be surprised by it, and know that we will learn to clean up our results a little later on.

You can also get the same kind of result with other operations.

```python
print(3*0.1)
```

### Floating-point division

```python
print(4/2)

# Note: the behaviour of Python 3 and Python 2 regarding floating-point division is different.
# In Python 2, the result will be 2.

# If you are getting numerical results that you don't expect, or that don't make sense,
# check if the version of Python you are using is treating integers differently than you expect.
```

```python
print(3/2)

# Note: the behaviour of Python 3 and Python 2 regarding floating-point division is different.
# In Python 2, the result will be 2.

# If you are getting numerical results that you don't expect, or that don't make sense,
# check if the version of Python you are using is treating integers differently than you expect.
```

** Exercise **:
- Write some code that calculates the roots of a quadratic function given the variable coefficients:a, b, c

The formula is: $ \frac{-b \pm \sqrt{b^2-4ac}}{2a}$.

If there are no roots, print "I'm groot!"; print the roots otherwise.

## Comments
As you begin to write more complicated code, you will have to spend more time thinking about how to code solutions to the problems you want to solve. Once you come up with an idea, you will spend a fair amount of time troubleshooting your code, and revising your overall approach.

Comments allow you to write more detailed and more human readable explanations about your program. In Python, any line that starts with a pound (#) symbol is ignored by the Python interpreter and is known as a line of comment.

```python
# This line is a comment.
print('# This line is not a comment, it is code.')
```

### What makes a good comment?
- It is short and to the point, but a complete thought. Most comments should be written in complete sentences
- It explains your thinking, so that when you return to the code later you will understand how you were approaching the problem
- It explains your thinking, so that others who work with your code will understand your overall approach to a problem
- It explains particularly difficult sections of code in detail

### When should you write comments?

- When you have to think about code before writing it
- When you are likely to forget later exactly how you were approaching a problem
- When there is more than one way to solve a problem
- When others are unlikely to anticipate your way of thinking about a problem

Writing good comments is one of the clear signs of a good programmer. If you have any real interest in taking programming seriously, start using comments now.

## Lists

A list is a collection of items, that is stored in a variable. The items should be related in some way, but there are no restrictions on what can be stored in a list. Here is a simple example of a list, and how we can quickly access each item in the list.

```python
students = ['bernice', 'aaron', 'cody']

for student in students: # Hey this is a for-loop. We'll study it later.
    print("Hello, " + student.title() + "!")
```

### Naming and defining a list
Since lists are collection of objects, it is good practice to give them a plural name. If each item in your list is a car, call the list 'cars'. If each item is a dog, call your list 'dogs'. This gives you a straightforward way to refer to the entire list ('dogs'), and to a single item in the list ('dog').

In Python, square brackets designate a list. To define a list, you give the name of the list, the equals sign, and the values you want to include in your list within square brackets.

** Exercise **:
- Declare a list of numbers

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
```

### Accessing one item in a list
Items in a list are identified by their position in the list, **starting with zero**. This will almost certainly trip you up at some point. Believe it or not, programmers even joke about how often we all make "off-by-one" errors, so don't feel bad when you make this kind of error.

To access the first element in a list, you give the name of the list, followed by a zero in parentheses.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dog = dogs[0]
print(dog.title())
```

The number in parentheses is called the _index_ of the item. Because lists start at zero, the index of an item is always one less than its position in the list. So to get the second item in the list, we need to use an index of 1.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dog = dogs[1]
print(dog.title())
```

### Accessing the last items in a list
You can probably see that to get the last item in this list, we would use an index of 2. This works, but it would only work because our list has exactly three items. To get the last item in a list, no matter how long the list is, you can use an index of -1. (Negative index are not quite common in programming languages. )

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dog = dogs[-1]
print(dog.title())
```

This syntax also works for the second to last item, the third to last, and so forth.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dog = dogs[-2]
print(dog.title())
```

You cannot use a number larger than the length of the list.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dog = dogs[3]
print(dog.title())
```

Similarly, you can't use a negative number larger than the length of the list.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dog = dogs[-4]
print(dog.title())
```

## Lists and Looping

### Accessing all elements in a list
This is one of the most important concepts related to lists. You can have a list with a million items in it, and in three lines of code you can write a sentence for each of those million items. If you want to understand lists, and become a competent programmer, make sure you take the time to understand this section.

We use a loop to access all the elements in a list. A loop is a block of code that repeats itself until it runs out of items to work with, or until a certain condition is met. In this case, our loop will run once for every item in our list. With a list that is three items long, our loop will run three times.

Let's take a look at how we access all the items in a list, and then try to understand how it works.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

for dog in dogs:
    print(dog) # hey, why is this line indented?
```

We have already seen how to create a list, so we are really just trying to understand how the last two lines work. These last two lines make up a loop, and the language here can help us see what is happening:
```python
    for dog in dogs:
```
- The keyword "for" tells Python to get ready to use a loop.
- The variable "dog", with no "s" on it, is a temporary placeholder variable. This is the variable that Python will place each item in the list into, one at a time.
- The first time through the loop, the value of "dog" will be 'border collie'.
- The second time through the loop, the value of "dog" will be 'australian cattle dog'.
- The third time through, "dog" will be 'labrador retriever'.
- After this, there are no more items in the list, and the loop will end.

### Doing more with each item

We can do whatever we want with the value of "dog" inside the loop. In this case, we just print the name of the dog.
```python
    print(dog)
```
We are not limited to just printing the word dog. We can do whatever we want with this value, and this action will be carried out for every item in the list. Let's say something about each dog in our list.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

for dog in dogs:
    print('I like ' + dog + 's.')
```

### Inside and outside the loop

Python uses **indentation** to decide what is inside the loop and what is outside the loop. Code that is inside the loop will be run for every item in the list. Code that is not indented, which comes after the loop, will be run once just like regular code.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

for dog in dogs:
    # are we doing two or three things per iteration?
    print('I like ' + dog + 's.')
    print('No, I really really like ' + dog +'s!\n')

print("\nThat's just how I feel about dogs.")
```

```python
# how about this version?
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

for dog in dogs:
    # how about writing the code in this way?
    print('I like ' + dog + 's.')
print('No, I really really like ' + dog +'s!\n')

print("\nThat's just how I feel about dogs.")
```

By the way, indentation in Python really matters. Please pay attention to it when writing Python. We should be consistent: if we use two spaces for one level of indentation on one line, don't use three or four or other amount of spaces on other lines.

You may be intersted in this article:
[https://stackoverflow.blog/2017/06/15/developers-use-spaces-make-money-use-tabs/](https://stackoverflow.blog/2017/06/15/developers-use-spaces-make-money-use-tabs/)

### Enumerating a list
When you are looping through a list, you may want to know the index of the current item. You could always use the *list.index(value)* syntax, but there is a simpler way. The *enumerate()* function tracks the index of each item for you, as it loops through the list:

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

print("Results for the dog show are as follows:\n")
for index, dog in enumerate(dogs):
    place = str(index)
    print("Place: " + place + " Dog: " + dog.title())
```

To enumerate a list, you need to add an *index* variable to hold the current index. So instead of
```python
    for dog in dogs:
```

You have
```python
    for index, dog in enumerate(dogs)
 ```

The value in the variable *index* is always an integer. If you want to print it in a string, you have to turn the integer into a string:
```python
    str(index)
```

The index always starts at 0, so in this example the value of *place* should actually be the current index, plus one:

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

print("Results for the dog show are as follows:\n")
for index, dog in enumerate(dogs):
    place = str(index + 1)
    print("Place: " + place + " Dog: " + dog.title())
```

List enumeration is particularly useful when a data is represented by multiple elements from different arrays (not a good practice, though). For instance:

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
bark = ['bark', 'bark bark', 'bark bark bark']

print('Barking dogs:\n')
for index, dog in enumerate(dogs):
      print(dogs[index] + ': ' + bark[index])
```

## Common list operations

### Modifying elements in a list
You can change the value of any element in a list if you know the position of that item.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

dogs[0] = 'australian shepherd'
print(dogs)
```

### Finding an element in a list
If you want to find out the position of an element in a list, you can use the index() function.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

print(dogs.index('australian cattle dog')) # the function index() here is not the variable 'index' we used in the previous examples
```

This method returns a ValueError if the requested item is not in the list.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

print(dogs.index('poodle'))
```

### Testing whether an item is in a list
You can test whether an item is in a list using the "in" keyword. This will become more useful after learning how to use if-else statements.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']

print('australian cattle dog' in dogs)
print('poodle' in dogs)
```

### Adding items to a list
#### Appending items to the end of a list
We can add an item to a list using the append() method. This method adds the new item to the end of the list.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
dogs.append('poodle')

for dog in dogs:
    print(dog.title() + "s are cool.")
```

#### Inserting items into a list
We can also insert items anywhere we want in a list, using the **insert()** function. We specify the position we want the item to have, and everything from that point on is shifted one position to the right. In other words, the index of every item after the new item is increased by one.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
dogs.insert(1, 'poodle')

print(dogs)
```

Note that you have to give the position of the new item first, and then the value of the new item. If you do it in the reverse order, you will get an error.

### Creating an empty list
Now that we know how to add items to a list after it is created, we can use lists more dynamically. We are no longer stuck defining our entire list at once.

A common approach with lists is to define an empty list, and then let your program add items to the list as necessary. This approach works, for example, when starting to build an interactive web site. Your list of users might start out empty, and then as people register for the site it will grow. This is a simplified approach to how web sites actually work, but the idea is realistic.

Here is a brief example of how to start with an empty list, start to fill it up, and work with the items in the list. The only new thing here is the way we define an empty list, which is just an empty set of square brackets.

```python
# Create an empty list to hold our users.
usernames = []

# Add some users.
usernames.append('bernice')
usernames.append('cody')
usernames.append('aaron')

# Greet all of our users.
for username in usernames:
    print("Welcome, " + username.title() + '!')
```

If we don't change the order in our list, we can use the list to figure out who our oldest and newest users are.

```python
# Create an empty list to hold our users.
usernames = []

# Add some users.
usernames.append('bernice')
usernames.append('cody')
usernames.append('aaron')

# Greet all of our users.
for username in usernames:
    print("Welcome, " + username.title() + '!')

# Recognize our first user, and welcome our newest user.
print("\nThank you for being our very first user, " + usernames[0].title() + '!')
print("And a warm welcome to our newest user, " + usernames[-1].title() + '!')
```

Note that the code welcoming our newest user will always work, because we have used the index -1. If we had used the index 2 we would always get the third user, even as our list of users grows and grows.

### Sorting a List
We can sort a list alphabetically, in either order.

```python
students = ['bernice', 'aaron', 'cody']

# Put students in alphabetical order.
students.sort()

# Display the list in its current order.
print("Our students are currently in alphabetical order.")
for student in students:
    print(student.title())

#Put students in reverse alphabetical order.
students.sort(reverse=True)

# Display the list in its current order.
print("\nOur students are now in reverse alphabetical order.")
for student in studenbts:
    print(student.title())
```

#### *sorted()* vs. *sort()*
Whenever you consider sorting a list using sort(), keep in mind that you can not recover the original order. If you want to display a list in sorted order, but preserve the original order, you can use the *sorted()* function. The *sorted()* function also accepts the optional *reverse=True* argument. Please note that sorted() is not a function of the list datastructure.

```python
students = ['bernice', 'aaron', 'cody']

# Display students in alphabetical order, but keep the original order.
print("Here is the list in alphabetical order:")
for student in sorted(students):
    print(student.title())

# Display students in reverse alphabetical order, but keep the original order.
print("\nHere is the list in reverse alphabetical order:")
for student in sorted(students, reverse=True):
    print(student.title())

print("\nHere is the list in its original order:")
# Show that the list is still in its original order.
for student in students:
    print(student.title())
```

#### Reversing a list
We have seen three possible orders for a list:
- The original order in which the list was created
- Alphabetical order
- Reverse alphabetical order

There is one more order we can use, and that is the reverse of the original order of the list. The *reverse()* function gives us this order.

```python
students = ['bernice', 'aaron', 'cody']
students.reverse()

print(students)
```

Note that reverse is permanent, although you could follow up with another call to *reverse()* and get back the original order of the list.

#### Sorting a numerical list
All of the sorting functions work for numerical lists as well.

```python
numbers = [1, 3, 4, 2]

# sort() puts numbers in increasing order.
numbers.sort()
print(numbers)

# sort(reverse=True) puts numbers in decreasing order.
numbers.sort(reverse=True)
print(numbers)

```

```python
numbers = [1, 3, 4, 2]

# sorted() preserves the original order of the list:
print(sorted(numbers))
print(numbers)
```

```python
numbers = [1, 3, 4, 2]

# The reverse() function also works for numerical lists.
numbers.reverse()
print(numbers)
```

** Exercise **:
- Write a program to find the 2nd largest of an integer array.
- If the array is not large enough, print "Not enough data"; print the result otherwise.

For example, suppose the interger array (<code>intarray</code>) is:
intarray = [1, 2, 3, 4, 5, 6, 7, 9, 10]

The result should be 9.

### Finding the length of a list
You can find the length of a list using the *len()* function.

```python
usernames = ['bernice', 'cody', 'aaron']
user_count = len(usernames)

print(user_count)
```

There are many situations where you might want to know how many items in a list. If you have a list that stores your users, you can find the length of your list at any time, and know how many users you have.

```python
# Create an empty list to hold our users.
usernames = []

# Add some users, and report on how many users we have.
usernames.append('bernice')
user_count = len(usernames)

print("We have " + str(user_count) + " user!")

usernames.append('cody')
usernames.append('aaron')
user_count = len(usernames)

print("We have " + str(user_count) + " users!")
```

On a technical note, the *len()* function returns an integer, which can't be printed directly with strings. We use the *str()* function to turn the integer into a string so that it prints nicely:

```python
usernames = ['bernice', 'cody', 'aaron']
user_count = len(usernames)

print("This will cause an error: " + user_count)
```

```python
usernames = ['bernice', 'cody', 'aaron']
user_count = len(usernames)

print("This will work: " + str(user_count))
```

### Removing Items from a List
Hopefully you can see by now that lists are a dynamic structure. We can define an empty list and then fill it up as information comes into our program. To become really dynamic, we need some ways to remove items from a list when we no longer need them. You can remove items from a list through their position, or through their value.

#### Removing items by position
If you know the position of an item in a list, you can remove that item using the *del* command. To use this approach, give the command *del* and the name of your list, with the index of the item you want to move in square brackets:

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
# Remove the first dog from the list.
del dogs[0]

print(dogs)
```

#### Removing items by value
You can also remove an item from a list if you know its value. To do this, we use the *remove()* function. Give the name of the list, followed by the word remove with the value of the item you want to remove in parentheses. Python looks through your list, finds the first item with this value, and removes it.

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
# Remove australian cattle dog from the list.
dogs.remove('australian cattle dog')

print(dogs)
```

Be careful to note, however, that *only* the first item with this value is removed. If you have multiple items with the same value, you will have some items with this value left in your list.

```python
letters = ['a', 'b', 'c', 'a', 'b', 'c']
# Remove the letter a from the list.
letters.remove('a')

print(letters)
```

### Popping items from a list

There is a cool concept in programming called "popping" items from a collection. Every programming language has some sort of data structure similar to Python's lists. All of these structures can be used as queues, and there are various ways of processing the items in a queue.

One simple approach is to start with an empty list, and then add items to that list. When you want to work with the items in the list, you always take the last item from the list, do something with it, and then remove that item. The *pop()* function makes this easy. It removes the last item from the list, and gives it to us so we can work with it. This is easier to show with an example:

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
last_dog = dogs.pop()

print(last_dog)
print(dogs)
```

This is an example of a first-in, last-out approach. The first item in the list would be the last item processed if you kept using this approach. We will see a full implementation of this approach later on, when we learn about *while* loops.

You can actually pop any item you want from a list, by giving the index of the item you want to pop. So we could do a first-in, first-out approach by popping the first iem in the list:

```python
dogs = ['border collie', 'australian cattle dog', 'labrador retriever']
first_dog = dogs.pop(0)

print(first_dog)
print(dogs)
```

** Exercise **:
- Write code to delete consecutive duplicates of list elements

For example, given:
x = [1, 1, 2, 3, 4, 5, 6, 6, 6, 7]

The result should be:
[1, 2, 3, 4, 5, 6, 7]

** Exercise **:
- Write code to duplicate the elements of a list

For example, given:
x = [1, 1, 2, 3, 4, 5, 6, 6, 6, 7]

The result should be:
[1, 1, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 6, 6, 6, 6, 7, 7]

### Slicing a List
Since a list is a collection of items, we should be able to get any subset of those items. For example, if we want to get just the first three items from the list, we should be able to do so easily. The same should be true for any three items in the middle of the list, or the last three items, or any x items from anywhere in the list. These subsets of a list are called *slices*.

To get a subset of a list, we give the position of the first item we want, and the position of the first item we do *not* want to include in the subset. So the slice *list[0:3]* will return a list containing items 0, 1, and 2, but not item 3. Here is how you get a batch containing the first three items.

```python
usernames = ['bernice', 'cody', 'aaron', 'ever', 'dalia']

# Grab the first three users in the list.
first_batch = usernames[0:3]

for user in first_batch:
    print(user.title())
```

If you want to grab everything up to a certain position in the list, you can also leave the first index blank:

```python
usernames = ['bernice', 'cody', 'aaron', 'ever', 'dalia']

# Grab the first three users in the list.
first_batch = usernames[:3]

for user in first_batch:
    print(user.title())
```

When we grab a slice from a list, the original list is not affected:

```python
usernames = ['bernice', 'cody', 'aaron', 'ever', 'dalia']

# Grab the first three users in the list.
first_batch = usernames[0:3]

# The original list is unaffected.
for user in usernames:
    print(user.title())
```

We can get any segment of a list we want, using the slice method:

```python
usernames = ['bernice', 'cody', 'aaron', 'ever', 'dalia']

# Grab a batch from the middle of the list.
middle_batch = usernames[1:4]

for user in middle_batch:
    print(user.title())
```

To get all items from one position in the list to the end of the list, we can leave off the second index:

```python
usernames = ['bernice', 'cody', 'aaron', 'ever', 'dalia']

# Grab all users from the third to the end.
end_batch = usernames[2:]

for user in end_batch:
    print(user.title())
```

### Copying a list (Please pay attention to this section)
You can use the slice notation to make a copy of a list, by leaving out both the starting and the ending index. This causes the slice to consist of everything from the first item to the last, which is the entire list.

```python
usernames = ['bernice', 'cody', 'aaron', 'ever', 'dalia']

# Make a copy of the list.
copied_usernames = usernames[:]
print("The full copied list:\n\t", copied_usernames)

# Remove the first two users from the copied list.
del copied_usernames[0]
del copied_usernames[0]
print("\nTwo users removed from copied list:\n\t", copied_usernames)

# The original list is unaffected.
print("\nThe original list:\n\t", usernames)
```

## Numerical lists
There is nothing special about lists of numbers, but there are some functions you can use to make working with numerical lists more efficient. Let's make a list of the first ten numbers, and start working with it to see how we can use numbers in a list.

```python
# Print out the first ten numbers.
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

for number in numbers:
    print(number)
```

** Exercise **:
- Shift the elements of an integer list by one to the left, and then increment the value of each cell by its new index. The rightmost element of the original list should be placed at the end after shifting.

For example,
x = [1, 2, 3, 4]

After shifting, the result should be:
[2, 3, 4, 1]

The final result should be:
[2, 4, 6, 4]

### The *range()* function
This works, but it is not very efficient if we want to work with a large set of numbers. The *range()* function helps us generate long lists of numbers. Here are two ways to do the same thing, using the *range* function.

```python
# Print the first ten numbers.
for number in range(1,11):
    print(number)
```

The range function takes in a starting number, and an end number. You get all integers, up to but not including the end number. You can also add a *step* value, which tells the *range* function how big of a step to take between numbers:

```python
# Print the first ten odd numbers.
for number in range(1,21,2):
    print(number)
```

If we want to store these numbers in a list, we can use the *list()* function. This function takes in a range, and turns it into a list:

```python
# Create a list of the first ten numbers.
numbers = list(range(1,11))
print(numbers)
```

This is incredibly powerful; we can now create a list of the first million numbers, just as easily as we made a list of the first ten numbers. It doesn't really make sense to print the million numbers here, but we can show that the list really does have one million items in it, and we can print the last ten items to show that the list is correct.

```python
# Store the first million numbers in a list.
numbers = list(range(1,1000001))

# Show the length of the list:
print("The list 'numbers' has " + str(len(numbers)) + " numbers in it.")

# Show the last ten numbers:
print("\nThe last ten numbers in the list are:")
for number in numbers[-10:]:
    print(number)
```

There are two things here that might be a little unclear. The expression

    str(len(numbers))

takes the length of the *numbers* list, and turns it into a string that can be printed.

The expression

    numbers[-10:]

gives us a *slice* of the list. The index `-1` is the last item in the list, and the index `-10` is the item ten places from the end of the list. So the slice `numbers[-10:]` gives us everything from that item to the end of the list.

** Exercise **:
- Split a list into two. The first list should contain N randomly drawn elements from the original list of length L; whereas the second list should contain the remaining (L-N) elements in the original list.

You can use the following code to generate a list of random integers (please modify it according to your need):

```python
import numpy as np

np.random.randint(low=0,high=10,size=10)
```

### The *min()*, *max()*, and *sum()* functions

There are three functions you can easily use with numerical lists. As you might expect, the *min()* function returns the smallest number in the list, the *max()* function returns the largest number in the list, and the *sum()* function returns the total of all numbers in the list.

```python
ages = [23, 16, 14, 28, 19, 11, 38]

youngest = min(ages)
oldest = max(ages)
total_years = sum(ages)

print("Our youngest reader is " + str(youngest) + " years old.")
print("Our oldest reader is " + str(oldest) + " years old.")
print("Together, we have " + str(total_years) + " years worth of life experience.")
```

## List comprehensions
If you are brand new to programming, list comprehensions may look confusing at first. They are a shorthand way of creating and working with lists. It is good to be aware of list comprehensions, because you will see them in other people's code, and they are really useful when you understand how to use them. That said, if they don't make sense to you yet, don't worry about using them right away. When you have worked with enough lists, you will want to use comprehensions. For now, it is good enough to know they exist, and to recognize them when you see them. If you like them, go ahead and start trying to use them now. (Using list comprehensions is a more idiomatic way of programming in Python.)

### Numerical comprehensions
Let's consider how we might make a list of the first ten square numbers. We could do it like this:

```python
# Store the first ten square numbers in a list.
# Make an empty list that will hold our square numbers.
squares = []

# Go through the first ten numbers, square them, and add them to our list.
for number in range(1,11):
    new_square = number**2
    squares.append(new_square)

# Show that our list is correct.
for square in squares:
    print(square)
```

This should make sense at this point. If it doesn't, go over the code with these thoughts in mind:
- We make an empty list called *squares* that will hold the values we are interested in.
- Using the *range()* function, we start a loop that will go through the numbers 1-10.
- Each time we pass through the loop, we find the square of the current number by raising it to the second power.
- We add this new value to our list *squares*.
- We go through our newly-defined list and print out each square.

Now let's make this code more efficient. We don't really need to store the new square in its own variable *new_square*; we can just add it directly to the list of squares. The line

    new_square = number**2

is taken out, and the next line takes care of the squaring:

```python
# Store the first ten square numbers in a list.
# Make an empty list that will hold our square numbers.
squares = []

# Go through the first ten numbers, square them, and add them to our list.
for number in range(1,11):
    squares.append(number**2)

# Show that our list is correct.
for square in squares:
    print(square)
```

List comprehensions allow us to collapse the first three lines of code into one line. Here's what it looks like:

```python
# Store the first ten square numbers in a list.
squares = [number**2 for number in range(1,11)]

# Show that our list is correct.
for square in squares:
    print(square)
```

It should be pretty clear that this code is more efficient than our previous approach, but it may not be clear what is happening. Let's take a look at everything that is happening in that first line:

We define a list called *squares*.

Look at the second part of what's in square brackets:
```python
    for number in range(1,11)
```
This sets up a loop that goes through the numbers 1-10, storing each value in the variable *number*. Now we can see what happens to each *number* in the loop:
```python
    number**2
```
Each number is raised to the second power, and this is the value that is stored in the list we defined. We might read this line in the following way:

squares = [raise *number* to the second power, for each *number* in the range 1-10]

Or more mathematical:
\begin{align}
\text{squares} &= \{x^2  |  x \in \mathbb{Z} \land x >=1 \land x<10\}
\end{align}

It is probably helpful to see a few more examples of how comprehensions can be used. Let's try to make the first ten even numbers, the longer way:

```python
# Make an empty list that will hold the even numbers.
evens = []

# Loop through the numbers 1-10, double each one, and add it to our list.
for number in range(1,11):
    evens.append(number*2)

# Show that our list is correct:
for even in evens:
    print(even)
```

Here's how we might think of doing the same thing, using a list comprehension:

evens = [multiply each *number* by 2, for each *number* in the range 1-10]

Here is the same line in code:

```python
# Make a list of the first ten even numbers.
evens = [number*2 for number in range(1,11)]

for even in evens:
    print(even)
```

### Non-numerical comprehensions
We can use comprehensions with non-numerical lists as well. In this case, we will create an initial list, and then use a comprehension to make a second list from the first one. Here is a simple example, without using comprehensions:

```python
# Consider some students.
students = ['bernice', 'aaron', 'cody']

# Let's turn them into great students.
great_students = []
for student in students:
    great_students.append(student.title() + " the great!")

# Let's greet each great student.
for great_student in great_students:
    print("Hello, " + great_student)
```

To use a comprehension in this code, we want to write something like this:

great_students = [add 'the great' to each *student*, for each *student* in the list of *students*]

Here's what it looks like:

```python
# Consider some students.
students = ['bernice', 'aaron', 'cody']

# Let's turn them into great students.
great_students = [student.title() + " the great!" for student in students]

# Let's greet each great student.
for great_student in great_students:
    print("Hello, " + great_student)
```

## Strings as Lists

Now that you have some familiarity with lists, we can take a second look at strings. A string is really a list of characters, so many of the concepts from working with lists behave the same with strings.

### Strings as a list of characters
We can loop through a string using a *for* loop, just like we loop through a list:

```python
message = "Hello!"

for letter in message:
    print(letter)
```

We can create a list from a string. The list will have one element for each character in the string:

```python
message = "Hello world!"

message_list = list(message)
print(message_list)
```

### Slicing strings
We can access any character in a string by its position, just as we access individual items in a list:

```python
message = "Hello World!"
first_char = message[0]
last_char = message[-1]

print(first_char, last_char)
```

We can extend this to take slices of a string:

```python
message = "Hello World!"
first_three = message[:3]
last_three = message[-3:]

print(first_three, last_three)
```

### Finding substrings
Now that you have seen what indexes mean for strings, we can search for *substrings*. A substring is a series of characters that appears in a string.

You can use the *in* keyword to find out whether a particular substring appears in a string:

```python
message = "I like cats and dogs."
dog_present = 'dog' in message
print(dog_present)
```

If you want to know where a substring appears in a string, you can use the *find()* method. The *find()* method tells you the index at which the substring begins.

```python
message = "I like cats and dogs."
dog_index = message.find('dog')
print(dog_index)
```

Note, however, that this function only returns the index of the first appearance of the substring you are looking for. If the substring appears more than once, you will miss the other substrings.

```python
message = "I like cats and dogs, but I'd much rather own a dog."
dog_index = message.find('dog')
print(dog_index)
```

If you want to find the last appearance of a substring, you can use the *rfind()* function:

```python
message = "I like cats and dogs, but I'd much rather own a dog."
last_dog_index = message.rfind('dog')
print(last_dog_index)
```

### Replacing substrings
You can use the *replace()* function to replace any substring with another substring. To use the *replace()* function, give the substring you want to replace, and then the substring you want to replace it with. You also need to store the new string, either in the same string variable or in a new variable.

```python
message = "I like cats and dogs, but I'd much rather own a dog."
message = message.replace('dog', 'snake')
print(message)
```

### Counting substrings
If you want to know how many times a substring appears within a string, you can use the *count()* method.

```python
message = "I like cats and dogs, but I'd much rather own a dog."
number_dogs = message.count('dog')
print(number_dogs)
```

### Splitting strings
Strings can be split into a set of substrings when they are separated by a repeated character. If a string consists of a simple sentence, the string can be split based on spaces. The *split()* function returns a list of substrings. The *split()* function takes one argument, the character that separates the parts of the string.

```python
message = "I like cats and dogs, but I'd much rather own a dog."
words = message.split(' ')
print(words)
```

Notice that the punctuation is left in the substrings.

It is more common to split strings that are really lists, separated by something like a comma. The *split()* function gives you an easy way to turn comma-separated strings, which you can't do much with in Python, into lists. Once you have your data in a list, you can work with it in much more powerful ways.

```python
animals = "dog, cat, tiger, mouse, liger, bear"

# Rewrite the string as a list, and store it in the same variable
animals = animals.split(',')
print(animals)
```

Notice that in this case, the spaces are also ignored. It is a good idea to test the output of the *split()* function and make sure it is doing what you want with the data you are interested in.

One use of this is to work with spreadsheet data in your Python programs. Most spreadsheet applications allow you to dump your data into a comma-separated text file. You can read this file into your Python program, or even copy and paste from the text file into your program file, and then turn the data into a list. You can then process your spreadsheet data using a *for* loop.

### Other string methods
There are a number of [other string methods](https://docs.python.org/3.8/library/stdtypes.html#string-methods) that we won't go into right here, but you might want to take a look at them. Most of these methods should make sense to you at this point. You might not have use for any of them right now, but it is good to know what you can do with strings. This way you will have a sense of how to solve certain problems, even if it means referring back to the list of methods to remind yourself how to write the correct syntax when you need it.

## Tuples
Tuples are basically lists that can never be changed. Lists are quite dynamic; they can grow as you append and insert items, and they can shrink as you remove items. You can modify any element you want to in a list. Sometimes we like this behavior, but other times we may want to ensure that no user or no part of a program can change a list. That's what tuples are for.

Technically, lists are *mutable* objects and tuples are *immutable* objects. Mutable objects can change (think of *mutations*), and immutable objects can not change.

### Defining tuples, and accessing elements

You define a tuple just like you define a list, except you use parentheses instead of square brackets. Once you have a tuple, you can access individual elements just like you can with a list, and you can loop through the tuple with a *for* loop:

```python
colors = ('red', 'green', 'blue')
print("The first color is: " + colors[0])

print("\nThe available colors are:")
for color in colors:
    print("- " + color)
```

If you try to add something to a tuple, you will get an error:

```python
colors = ('red', 'green', 'blue')
colors.append('purple')
```

The same kind of thing happens when you try to remove something from a tuple, or modify one of its elements. Once you define a tuple, you can be confident that its values will not change.

### Using tuples to make strings
We have seen that it is pretty useful to be able to mix raw English strings with values that are stored in variables, as in the following:

```python
animal = 'dog'
print("I have a " + animal + ".")
```

This was especially useful when we had a series of similar statements to make:

```python
animals = ['dog', 'cat', 'bear']
for animal in animals:
    print("I have a " + animal + ".")
```

I like this approach of using the plus sign to build strings because it is fairly intuitive. We can see that we are adding several smaller strings together to make one longer string. This is intuitive, but it is a lot of typing. There is a shorter way to do this, using *placeholders*.

Python ignores most of the characters we put inside of strings. There are a few characters that Python pays attention to, as we saw with strings such as "\t" and "\n". Python also pays attention to "%s" and "%d". These are placeholders. When Python sees the "%s" placeholder, it looks ahead and pulls in the first argument after the % sign:

```python
animal = 'dog'
print("I have a %s." % animal)
```

This is a much cleaner way of generating strings that include values. We compose our sentence all in one string, and then tell Python what values to pull into the string, in the appropriate places.

This is called *string formatting*, and it looks the same when you use a list:

```python
animals = ['dog', 'cat', 'bear']
for animal in animals:
    print("I have a %s." % animal)
```

If you have more than one value to put into the string you are composing, you have to pack the values into a tuple:

```python
animals = ['dog', 'cat', 'bear']
print("I have a %s, a %s, and a %s." % (animals[0], animals[1], animals[2]))
```

### String formatting with numbers

If you recall, printing a number with a string can cause an error:

```python
number = 23
print("My favorite number is " + number + ".")
```

Python knows that you could be talking about the value 23, or the characters '23'. So it throws an error, forcing us to clarify that we want Python to treat the number as a string. We do this by *casting* the number into a string using the *str()* function:

```python
number = 23
print("My favorite number is " + str(number) + ".")
```

The format string "%d" takes care of this for us. Watch how clean this code is:

```python
number = 23
print("My favorite number is %d." % number)
```

If you want to use a series of numbers, you pack them into a tuple just like we saw with strings:

```python
numbers = [7, 23, 42]
print("My favorite numbers are %d, %d, and %d." % (numbers[0], numbers[1], numbers[2]))
```

Just for clarification, look at how much longer the code is if you use concatenation instead of string formatting:

```python
numbers = [7, 23, 42]
print("My favorite numbers are " + str(numbers[0]) + ", " + str(numbers[1]) + ", and " + str(numbers[2]) + ".")
```

You can mix string and numerical placeholders in any order you want.

```python
names = ['Ada', 'ever']
numbers = [23, 2]
print("%s's favorite number is %d, and %s's favorite number is %d." % (names[0].title(), numbers[0], names[1].title(), numbers[1]))
```

There are more sophisticated ways to do string formatting in Python 3, but we will save that for later because it's a bit less intuitive than this approach. For now, you can use whichever approach consistently gets you the output that you want to see.

## If Statements
By allowing you to respond selectively to different situations and conditions, if statements open up whole new possibilities for your programs. In this section, you will learn how to test for certain conditions, and then respond in appropriate ways to those conditions.

### What is an *if* statement?
An *if* statement tests for a condition, and then responds to that condition. If the condition is true, then whatever action is listed next gets carried out. You can test for multiple conditions at the same time, and respond appropriately to each condition.

Here is an example that shows a number of the desserts I like. It lists those desserts, but lets you know which one is my favorite.

```python
# A list of desserts I like.
desserts = ['ice cream', 'chocolate', 'apple crisp', 'cookies']
favorite_dessert = 'apple crisp'

# Print the desserts out, but let everyone know my favorite dessert.
for dessert in desserts:
    if dessert == favorite_dessert:
        # This dessert is my favorite, let's let everyone know!
        print("%s is my favorite dessert!" % dessert.title())
    else:
        # I like these desserts, but they are not my favorite.
        print("I like %s." % dessert)
```

#### What happens in this program?

- The program starts out with a list of desserts, and one dessert is identified as a favorite.
- The for loop runs through all the desserts.
- Inside the for loop, each item in the list is tested.
    - If the current value of *dessert* is equal to the value of *favorite_dessert*, a message is printed that this is my favorite.
    - If the current value of *dessert* is not equal to the value of *favorite_dessert*, a message is printed that I just like the dessert.

You can test as many conditions as you want in an if statement, as you will see in a little bit.

### Logical Tests
Every if statement evaluates to *True* or *False*. *True* and *False* are Python keywords, which have special meanings attached to them. You can test for the following conditions in your if statements:

- [equality](#equality) (==)
- [inequality](#inequality) (!=)
- [other inequalities](#other_inequalities)
    - greater than (>)
    - greater than or equal to (>=)
    - less than (<)
    - less than or equal to (<=)
- [You can test if an item is **in** a list.](#in_list)

#### Equality
Two items are *equal* if they have the same value. You can test for equality between numbers, strings, and a number of other objects which you will learn about later. Some of these results may be surprising, so take a careful look at the examples below.

In Python, as in many programming languages, two equals signs tests for equality.

**Watch out!** Be careful of accidentally using one equals sign, which can really throw things off because that one equals sign actually sets your item to the value you are testing for!

```python
5 == 5
```

```python
3 == 5
```

```python
5 == 5.0
```

```python
'ada' == 'ada'
```

```python
'Ada' == 'ada'
```

```python
'Ada'.lower() == 'ada'.lower()
```

```python
'5' == 5
```

```python
'5' == str(5)
```

#### Inequality
Two items are *inequal* if they do not have the same value. In Python, we test for inequality using the exclamation point and one equals sign.

Sometimes you want to test for equality and if that fails, assume inequality. Sometimes it makes more sense to test for inequality directly.

```python
3 != 5
```

```python
5 != 5
```

```python
'Ada' != 'ada'
```

#### Other Inequalities
##### greater than

```python
5 > 3
```

##### greater than or equal to

```python
5 >= 3
```

```python
3 >= 3
```

##### less than

```python
3 < 5
```

##### less than or equal to

```python
3 <= 5
```

```python
3 <= 3
```

### Checking if an item is **in** a list
You can check if an item is in a list using the **in** keyword.

```python
vowels = ['a', 'e', 'i', 'o', 'u']
'a' in vowels
```

```python
vowels = ['a', 'e', 'i', 'o', 'u']
'b' in vowels
```

### The if-elif...else chain
You can test whatever series of conditions you want to, and you can test your conditions in any combination you want.

### Simple if statements
The simplest test has a single **if** statement, and a single statement to execute if the condition is **True**.

```python
dogs = ['willie', 'hootz', 'peso', 'juno']

if len(dogs) > 3:
    print("Wow, we have a lot of dogs here!")
```

In this situation, nothing happens if the test does not pass.

```python
dogs = ['willie', 'hootz']

if len(dogs) > 3:
    print("Wow, we have a lot of dogs here!")
```

Notice that there are no errors. The condition `len(dogs) > 3` evaluates to False, and the program moves on to any lines after the **if** block.

### if-else statements
Many times you will want to respond in two possible ways to a test. If the test evaluates to **True**, you will want to do one thing. If the test evaluates to **False**, you will want to do something else. The **if-else** structure lets you do that easily. Here's what it looks like:

```python
dogs = ['willie', 'hootz', 'peso', 'juno']

if len(dogs) > 3:
    print("Wow, we have a lot of dogs here!")
else:
    print("Okay, this is a reasonable number of dogs.")
```

Our results have not changed in this case, because if the test evaluates to **True** only the statements under the **if** statement are executed. The statements under **else** area only executed if the test fails:

```python
dogs = ['willie', 'hootz']

if len(dogs) > 3:
    print("Wow, we have a lot of dogs here!")
else:
    print("Okay, this is a reasonable number of dogs.")
```

The test evaluated to **False**, so only the statement under `else` is run.

### if-elif...else chains
Many times, you will want to test a series of conditions, rather than just an either-or situation. You can do this with a series of if-elif-else statements

There is no limit to how many conditions you can test. You always need one if statement to start the chain, and you can never have more than one else statement. But you can have as many elif statements as you want.

```python
dogs = ['willie', 'hootz', 'peso', 'monty', 'juno', 'turkey']

if len(dogs) >= 5:
    print("Holy mackerel, we might as well start a dog hostel!")
elif len(dogs) >= 3:
    print("Wow, we have a lot of dogs here!")
else:
    print("Okay, this is a reasonable number of dogs.")
```

It is important to note that in situations like this, only the first test is evaluated. In an if-elif-else chain, once a test passes the rest of the conditions are ignored.

```python
dogs = ['willie', 'hootz', 'peso', 'monty']

if len(dogs) >= 5:
    print("Holy mackerel, we might as well start a dog hostel!")
elif len(dogs) >= 3:
    print("Wow, we have a lot of dogs here!")
else:
    print("Okay, this is a reasonable number of dogs.")
```

The first test failed, so Python evaluated the second test. That test passed, so the statement corresponding to `len(dogs) >= 3` is executed.

```python
dogs = ['willie', 'hootz']

if len(dogs) >= 5:
    print("Holy mackerel, we might as well start a dog hostel!")
elif len(dogs) >= 3:
    print("Wow, we have a lot of dogs here!")
else:
    print("Okay, this is a reasonable number of dogs.")
```

In this situation, the first two tests fail, so the statement in the else clause is executed. Note that this statement would be executed even if there are no dogs at all:

```python
dogs = []

if len(dogs) >= 5:
    print("Holy mackerel, we might as well start a dog hostel!")
elif len(dogs) >= 3:
    print("Wow, we have a lot of dogs here!")
else:
    print("Okay, this is a reasonable number of dogs.")
```

Note that you don't have to take any action at all when you start a series of if statements. You could simply do nothing in the situation that there are no dogs by replacing the `else` clause with another `elif` clause:

```python
dogs = []

if len(dogs) >= 5:
    print("Holy mackerel, we might as well start a dog hostel!")
elif len(dogs) >= 3:
    print("Wow, we have a lot of dogs here!")
elif len(dogs) >= 1:
    print("Okay, this is a reasonable number of dogs.")
```

In this case, we only print a message if there is at least one dog present. Of course, you could add a new `else` clause to respond to the situation in which there are no dogs at all:

```python
dogs = []

if len(dogs) >= 5:
    print("Holy mackerel, we might as well start a dog hostel!")
elif len(dogs) >= 3:
    print("Wow, we have a lot of dogs here!")
elif len(dogs) >= 1:
    print("Okay, this is a reasonable number of dogs.")
else:
    print("I wish we had a dog here.")
```

As you can see, the if-elif-else chain lets you respond in very specific ways to any given situation.

### More than one passing test
In all of the examples we have seen so far, only one test can pass. As soon as the first test passes, the rest of the tests are ignored. This is really good, because it allows our code to run more efficiently. Many times only one condition can be true, so testing every condition after one passes would be meaningless.

There are situations in which you want to run a series of tests, where every single test runs. These are situations where any or all of the tests could pass, and you want to respond to each passing test. Consider the following example, where we want to greet each dog that is present:

```python
dogs = ['willie', 'hootz']

if 'willie' in dogs:
    print("Hello, Willie!")
if 'hootz' in dogs:
    print("Hello, Hootz!")
if 'peso' in dogs:
    print("Hello, Peso!")
if 'monty' in dogs:
    print("Hello, Monty!")
```

If we had done this using an if-elif-else chain, only the first dog that is present would be greeted:

```python
dogs = ['willie', 'hootz']

if 'willie' in dogs:
    print("Hello, Willie!")
elif 'hootz' in dogs:
    print("Hello, Hootz!")
elif 'peso' in dogs:
    print("Hello, Peso!")
elif 'monty' in dogs:
    print("Hello, Monty!")
```

Of course, this could be written much more cleanly using lists and for loops. See if you can follow this code.

```python
dogs_we_know = ['willie', 'hootz', 'peso', 'monty', 'juno', 'turkey']
dogs_present = ['willie', 'hootz']

# Go through all the dogs that are present, and greet the dogs we know.
for dog in dogs_present:
    if dog in dogs_we_know:
        print("Hello, %s!" % dog.title())
```

This is the kind of code you should be aiming to write. It is fine to come up with code that is less efficient at first. When you notice yourself writing the same kind of code repeatedly in one program, look to see if you can use a loop or a function to make your code more efficient.

## True and False values
Every value can be evaluated as True or False. The general rule is that any non-zero or non-empty value will evaluate to True. If you are ever unsure, you can open a Python terminal and write two lines to find out if the value you are considering is True or False. Take a look at the following examples, keep them in mind, and test any value you are curious about. I am using a slightly longer test just to make sure something gets printed each time.

```python
if 0:
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
if 1:
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
# Arbitrary non-zero numbers evaluate to True.
if 1253756:
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
# Negative numbers are not zero, so they evaluate to True.
if -1:
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
# An empty string evaluates to False.
if '':
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
# Any other string, including a space, evaluates to True.
if ' ':
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
# Any other string, including a space, evaluates to True.
if 'hello':
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

```python
# None is a special object in Python. It evaluates to False.
if None:
    print("This evaluates to True.")
else:
    print("This evaluates to False.")
```

** Exercise (the exercises are getting harder from now on) **:
- Given two strings a and b, find the longest substring in A that can be found in B. The index of the first character of the matched longest substring in B should also be reported.

For example,

a = "a dream"

b = "I have a dream that one day this nation will rise up"

The result is "a dream". The index of the 'a' in "a dream" is 7 in b.

## While Loops

A while loop tests an initial condition. If that condition is true, the loop starts executing. Every time the loop finishes, the condition is reevaluated. As long as the condition remains true, the loop keeps executing. As soon as the condition becomes false, the loop stops executing.

### General syntax

```python
# Set an initial condition.
game_active = True

# Set up the while loop.
while game_active:
    # Run the game.
    # At some point, the game ends and game_active will be set to False.
    #   When that happens, the loop will stop executing.

# Do anything else you want done after the loop runs.
```

- Every while loop needs an initial condition that starts out true.
- The `while` statement includes a condition to test.
- All of the code in the loop will run as long as the condition remains true.
- As soon as something in the loop changes the condition such that the test no longer passes, the loop stops executing.
- Any code that is defined after the loop will run at this point.

Here is a simple example, showing how a game will stay active as long as the player has enough power.

```python
# The player's power starts out at 5.
power = 5

# The player is allowed to keep playing as long as their power is over 0.
while power > 0:
    print("You are still playing, because your power is %d." % power)
    # Your game code would go here, which includes challenges that make it
    #   possible to lose power.
    # We can represent that by just taking away from the power.
    power = power - 1

print("\nOh no, your power dropped to 0! Game Over.")
```

** Exercise **:
- Write the functionally equivalent while-loop version for the following code:

```
sum = 0
for i in range(1, 100, 2):
    sum = sum + i
```

** Exercise **:
- Write the functionally equivalent while-loop version for the following code:

```
sum = 9999
for i in range(100, 0, -1):
    sum = sum - i
```

** Exercise **:
- Write the functionally equivalent while-loop version for the following code:

```
sum = 9999
for i in range(100, 0):
    sum = sum - i
```

** Exercise **:
- State whether the following code fragments are functionally equivalent:

```
i = 0
s = 0
for i in range(0, 9999):
    if i % 4 != 0:
        s += i
```

```
i = 0
s = 0
while i < 9999:
    if i % 4 != 0:
        s += i
        i +=1
```

```
i = 0
s = 0
while ++i < 9999:
    if i % 4 != 0:
        s += i
```

### Accidental Infinite loops
Sometimes we want a while loop to run until a defined action is completed, such as emptying out a list. Sometimes we want a loop to run for an unknown period of time, for example when we are allowing users to give as much input as they want. What we rarely want, however, is a true 'runaway' infinite loop.

Take a look at the following example. Can you pick out why this loop will never stop?

```python
# /////////////////////////////////////////
# /// don't execute thie piece of code! ///
# /////////////////////////////////////////

current_number = 1

# Count up to 5, printing the number each time.
while current_number <= 5:
    print(current_number)
```

```python
1
1
1
1
1
...
```

I faked that output, because if I ran it the output would fill up the browser. You can try to run it on your computer, as long as you know how to interrupt runaway processes:

- On most systems, Ctrl-C will interrupt the currently running program.
- If you are using Geany, your output is displayed in a popup terminal window. You can either press Ctrl-C, or you can use your pointer to close the terminal window.

The loop runs forever, because there is no way for the test condition to ever fail. The programmer probably meant to add a line that increments current_number by 1 each time through the loop:

```python
current_number = 1

# Count up to 5, printing the number each time.
while current_number <= 5:
    print(current_number)
    current_number = current_number + 1
```

You will certainly make some loops run infintely at some point. When you do, just interrupt the loop and figure out the logical error you made.

Infinite loops will not be a real problem until you have users who run your programs on their machines. You won't want infinite loops then, because your users would have to shut down your program, and they would consider it buggy and unreliable. Learn to spot infinite loops, and make sure they don't pop up in your polished programs later on.

Here is one more example of an accidental infinite loop:

```python
# /////////////////////////////////////////
# /// don't execute thie piece of code! ///
# /////////////////////////////////////////

current_number = 1

# Count up to 5, printing the number each time.
while current_number <= 5:
    print(current_number)
    current_number = current_number - 1
```

```python
1
0
-1
-2
-3
...
```

In this example, we accidentally started counting down. The value of `current_number` will always be less than 5, so the loop will run forever.

## Introducing Functions
One of the core principles of any programming language is, "Don't Repeat Yourself". If you have an action that should occur many times, you can define that action once and then call that code whenever you need to carry out that action.

We are already repeating ourselves in our code, so this is a good time to introduce simple functions. Functions mean less work for us as programmers, and effective use of functions results in code that is less error-prone.

Functions are a set of actions that we group together, and give a name to. You have already used a number of functions from the core Python language, such as *string.title()* and *list.sort()*. We can define our own functions, which allows us to "teach" Python new behavior.

### General Syntax
A general function looks something like this:

```python
# Let's define a function.
def function_name(argument_1, argument_2):
	# Do whatever we want this function to do,
	#  using argument_1 and argument_2

# Use function_name to call the function.
function_name(value_1, value_2)
```

This code will not run, but it shows how functions are used in general.

- **Defining a function**
    - Give the keyword `def`, which tells Python that you are about to *define* a function.
    - Give your function a name. A variable name tells you what kind of value the variable contains; a function name should tell you what the function does.
    - Give names for each value the function needs in order to do its work.
        - These are basically variable names, but they are only used in the function.
        - They can be different names than what you use in the rest of your program.
        - These are called the function's *arguments*.
    - Make sure the function definition line ends with a colon.
    - Inside the function, write whatever code you need to make the function do its work.
- **Using your function**
    - To *call* your function, write its name followed by parentheses.
    - Inside the parentheses, give the values you want the function to work with.
        - These can be variables such as `current_name` and `current_age`, or they can be actual values such as 'ada' and 5.

```python
print("You are doing good work, Adriana!")
print("Thank you very much for your efforts on this project.")

print("\nYou are doing good work, Billy!")
print("Thank you very much for your efforts on this project.")

print("\nYou are doing good work, Caroline!")
print("Thank you very much for your efforts on this project.")
```

Functions take repeated code, put it in one place, and then you call that code when you want to use it. Here's what the same program looks like with a function.

```python
def thank_you(name):
    # This function prints a two-line personalized thank you message.
    print("\nYou are doing good work, %s!" % name)
    print("Thank you very much for your efforts on this project.")

thank_you('Adriana')
thank_you('Billy')
thank_you('Caroline')
```

In our original code, each pair of print statements was run three times, and the only difference was the name of the person being thanked. When you see repetition like this, you can usually make your program more efficient by defining a function.

The keyword *def* tells Python that we are about to define a function. We give our function a name, *thank\_you()* in this case. A variable's name should tell us what kind of information it holds; a function's name should tell us what the variable does.  We then put parentheses. Inside these parenthese we create variable names for any variable the function will need to be given in order to do its job. In this case the function will need a name to include in the thank you message. The variable `name` will hold the value that is passed into the function *thank\_you()*.

To use a function we give the function's name, and then put any values the function needs in order to do its work. In this case we call the function three times, each time passing it a different name.

### A common error
A function must be defined before you use it in your program. For example, putting the function at the end of the program would not work.

```python
thank_you('Adriana')
thank_you('Billy')
thank_you('Caroline')

def thank_you(name):
    # This function prints a two-line personalized thank you message.
    print("\nYou are doing good work, %s!" % name)
    print("Thank you very much for your efforts on this project.")
```

On the first line we ask Python to run the function *thank\_you()*, but Python does not yet know how to do this function. We define our functions at the beginning of our programs, and then we can use them when we need to.

A second example
---
When we introduced the different methods for [sorting a list](Python%20-%20Hands-on%20Introduction%20to%20Python%20and%20Machine%20Learning.ipynb#Sorting-a-List), our code got very repetitive. It takes two lines of code to print a list using a for loop, so these two lines are repeated whenever you want to print out the contents of a list. This is the perfect opportunity to use a function, so let's see how the code looks with a function.

First, let's see the code we had without a function:

```python
students = ['bernice', 'aaron', 'cody']

# Put students in alphabetical order.
students.sort()

# Display the list in its current order.
print("Our students are currently in alphabetical order.")
for student in students:
    print(student.title())

# Put students in reverse alphabetical order.
students.sort(reverse=True)

# Display the list in its current order.
print("\nOur students are now in reverse alphabetical order.")
for student in students:
    print(student.title())
```

Here's what the same code looks like, using a function to print out the list:

```python
def show_students(students, message):
    # Print out a message, and then the list of students
    print(message)
    for student in students:
        print(student.title())

students = ['bernice', 'aaron', 'cody']

# Put students in alphabetical order.
students.sort()
show_students(students, "Our students are currently in alphabetical order.")

#Put students in reverse alphabetical order.
students.sort(reverse=True)
show_students(students, "\nOur students are now in reverse alphabetical order.")
```

This is much cleaner code. We have an action we want to take, which is to show the students in our list along with a message. We give this action a name, *show\_students()*.

This function needs two pieces of information to do its work, the list of students and a message to display. Inside the function, the code for printing the message and looping through the list is exactly as it was in the non-function code.

Now the rest of our program is cleaner, because it gets to focus on the things we are changing in the list, rather than having code for printing the list. We define the list, then we sort it and call our function to print the list. We sort it again, and then call the printing function a second time, with a different message. This is much more readable code.

### Advantages of using functions
You might be able to see some advantages of using functions, through this example:

- We write a set of instructions once. We save some work in this simple example, and we save even more work in larger programs.
- When our function works, we don't have to worry about that code anymore. Every time you repeat code in your program, you introduce an opportunity to make a mistake. Writing a function means there is one place to fix mistakes, and when those bugs are fixed, we can be confident that this function will continue to work correctly.
- We can modify our function's behavior, and that change takes effect every time the function is called. This is much better than deciding we need some new behavior, and then having to change code in many different places in our program.

For a quick example, let's say we decide our printed output would look better with some form of a bulleted list. Without functions, we'd have to change each print statement. With a function, we change just the print statement in the function:

```python
def show_students(students, message):
    # Print out a message, and then the list of students
    print(message)
    for student in students:
        print("- " + student.title())

students = ['bernice', 'aaron', 'cody']

# Put students in alphabetical order.
students.sort()
show_students(students, "Our students are currently in alphabetical order.")

#Put students in reverse alphabetical order.
students.sort(reverse=True)
show_students(students, "\nOur students are now in reverse alphabetical order.")
```

You can think of functions as a way to "teach" Python some new behavior. In this case, we taught Python how to create a list of students using hyphens; now we can tell Python to do this with our students whenever we want to.

### Returning a Value
Each function you create can return a value. This can be in addition to the primary work the function does, or it can be the function's main job. The following function takes in a number, and returns the corresponding word for that number:

```python
def get_number_word(number):
    # Takes in a numerical value, and returns
    #  the word corresponding to that number.
    if number == 1:
        return 'one'
    elif number == 2:
        return 'two'
    elif number == 3:
        return 'three'
    # ...

# Let's try out our function.
for current_number in range(0,4):
    number_word = get_number_word(current_number)
    print(current_number, number_word)
```

It's helpful sometimes to see programs that don't quite work as they are supposed to, and then see how those programs can be improved. In this case, there are no Python errors; all of the code has proper Python syntax. But there is a logical error, in the first line of the output.

We want to either not include 0 in the range we send to the function, or have the function return something other than `None` when it receives a value that it doesn't know. Let's teach our function the word 'zero', but let's also add an `else` clause that returns a more informative message for numbers that are not in the if-chain.

```python
def get_number_word(number):
    # Takes in a numerical value, and returns
    #  the word corresponding to that number.
    if number == 0:
        return 'zero'
    elif number == 1:
        return 'one'
    elif number == 2:
        return 'two'
    elif number == 3:
        return 'three'
    else:
        return "I'm sorry, I don't know that number."

# Let's try out our function.
for current_number in range(0,6):
    number_word = get_number_word(current_number)
    print(current_number, number_word)
```

If you use a return statement in one of your functions, keep in mind that the function stops executing as soon as it hits a return statement. For example, we can add a line to the *get\_number\_word()* function that will never execute, because it comes after the function has returned a value:

```python
def get_number_word(number):
    # Takes in a numerical value, and returns
    #  the word corresponding to that number.
    if number == 0:
        return 'zero'
    elif number == 1:
        return 'one'
    elif number == 2:
        return 'two'
    elif number == 3:
        return 'three'
    else:
        return "I'm sorry, I don't know that number."

    # This line will never execute, because the function has already
    #  returned a value and stopped executing.
    print("This message will never be printed.")

# Let's try out our function.
for current_number in range(0,6):
    number_word = get_number_word(current_number)
    print(current_number, number_word)
```

### More Later
There is much more to learn about functions, but we will get to those details later. For now, feel free to use functions whenever you find yourself writing the same code several times in a program. Some of the things you will learn when we focus on functions:

- How to give the arguments in your function default values.
- How to let your functions accept different numbers of arguments.

## User input
Almost all interesting programs accept input from the user at some point. You can start accepting user input in your programs by using the `input()` function. The input function displays a messaget to the user describing the kind of input you are looking for, and then it waits for the user to enter a value. When the user presses Enter, the value is passed to your variable.

### General syntax
The general case for accepting input looks something like this:

```python
# Get some input from the user.
variable = input('Please enter a value: ')
# Do something with the value that was entered.
```

You need a variable that will hold whatever value the user enters, and you need a message that will be displayed to the user.

<a id="Example-input"></a>
In the following example, we have a list of names. We ask the user for a name, and we add it to our list of names.

```python
# Start with a list containing several names.
names = ['guido', 'tim', 'jesse']

# Ask the user for a name.
new_name = input("Please tell me someone I should know: ")

# Add the new name to our list.
names.append(new_name)

# Show that the name has been added to the list.
print(names)
```

### Using while loops to keep your programs running
Most of the programs we use every day run until we tell them to quit, and in the background this is often done with a while loop. Here is an example of how to let the user enter an arbitrary number of names.

```python
# Start with an empty list. You can 'seed' the list with
#  some predefined values if you like.
names = []

# Set new_name to something other than 'quit'.
new_name = ''

# Start a loop that will run until the user enters 'quit'.
while new_name != 'quit':
    # Ask the user for a name.
    new_name = input("Please tell me someone I should know, or enter 'quit': ")

    # Add the new name to our list.
    names.append(new_name)

# Show that the name has been added to the list.
print(names)
```

That worked, except we ended up with the name 'quit' in our list. We can use a simple `if` test to eliminate this bug:

```python
# Start with an empty list. You can 'seed' the list with
#  some predefined values if you like.
names = []

# Set new_name to something other than 'quit'.
new_name = ''

# Start a loop that will run until the user enters 'quit'.
while new_name != 'quit':
    # Ask the user for a name.
    new_name = input("Please tell me someone I should know, or enter 'quit': ")

    # Add the new name to our list.
    if new_name != 'quit':
        names.append(new_name)

# Show that the name has been added to the list.
print(names)
```

This is pretty cool! We now have a way to accept input from users while our programs run, and we have a way to let our programs  run until our users are finished working.

#### Using while loops to make menus
You now have enough Python under your belt to offer users a set of choices, and then respond to those choices until they choose to quit. Let's look at a simple example, and then analyze the code:

```python
# Give the user some context.
print("\nWelcome to the nature center. What would you like to do?")

# Set an initial value for choice other than the value for 'quit'.
choice = ''

# Start a loop that runs until the user enters the value for 'quit'.
while choice != 'q':
    # Give all the choices in a series of print statements.
    print("\n[1] Enter 1 to take a bicycle ride.")
    print("[2] Enter 2 to go for a run.")
    print("[3] Enter 3 to climb a mountain.")
    print("[q] Enter q to quit.")

    # Ask for the user's choice.
    choice = input("\nWhat would you like to do? ")

    # Respond to the user's choice.
    if choice == '1':
        print("\nHere's a bicycle. Have fun!\n")
    elif choice == '2':
        print("\nHere are some running shoes. Run fast!\n")
    elif choice == '3':
        print("\nHere's a map. Can you leave a trip plan for us?\n")
    elif choice == 'q':
        print("\nThanks for playing. See you later.\n")
    else:
        print("\nI don't understand that choice, please try again.\n")

# Print a message that we are all finished.
print("Thanks again, bye now.")
```

Our programs are getting rich enough now, that we could do many different things with them. Let's clean this up in one really useful way. There are three main choices here, so let's define a function for each of those items. This way, our menu code remains really simple even as we add more complicated code to the actions of riding a bicycle, going for a run, or climbing a mountain.

```python
# Define the actions for each choice we want to offer.
def ride_bicycle():
    print("\nHere's a bicycle. Have fun!\n")

def go_running():
    print("\nHere are some running shoes. Run fast!\n")

def climb_mountain():
    print("\nHere's a map. Can you leave a trip plan for us?\n")

# Give the user some context.
print("\nWelcome to the nature center. What would you like to do?")

# Set an initial value for choice other than the value for 'quit'.
choice = ''

# Start a loop that runs until the user enters the value for 'quit'.
while choice != 'q':
    # Give all the choices in a series of print statements.
    print("\n[1] Enter 1 to take a bicycle ride.")
    print("[2] Enter 2 to go for a run.")
    print("[3] Enter 3 to climb a mountain.")
    print("[q] Enter q to quit.")

    # Ask for the user's choice.
    choice = input("\nWhat would you like to do? ")

    # Respond to the user's choice.
    if choice == '1':
        ride_bicycle()
    elif choice == '2':
        go_running()
    elif choice == '3':
        climb_mountain()
    elif choice == 'q':
        print("\nThanks for playing. See you later.\n")
    else:
        print("\nI don't understand that choice, please try again.\n")

# Print a message that we are all finished.
print("Thanks again, bye now.")
```

This is much cleaner code, and it gives us space to separate the details of taking an action from the act of choosing that action.

### Using while loops to process items in a list
In the section on Lists, you saw that we can `pop()` items from a list. You can use a while list to pop items one at a time from one list, and work with them in whatever way you need. Let's look at an example where we process a list of unconfirmed users.

```python
# Start with a list of unconfirmed users, and an empty list of confirmed users.
unconfirmed_users = ['ada', 'billy', 'clarence', 'daria']
confirmed_users = []

# Work through the list, and confirm each user.
while len(unconfirmed_users) > 0:

    # Get the latest unconfirmed user, and process them.
    current_user = unconfirmed_users.pop()
    print("Confirming user %s...confirmed!" % current_user.title())

    # Move the current user to the list of confirmed users.
    confirmed_users.append(current_user)

# Prove that we have finished confirming all users.
print("\nUnconfirmed users:")
for user in unconfirmed_users:
    print('- ' + user.title())

print("\nConfirmed users:")
for user in confirmed_users:
    print('- ' + user.title())
```

This works, but let's make one small improvement. The current program always works with the most recently added user. If users are joining faster than we can confirm them, we will leave some users behind. If we want to work on a 'first come, first served' model, or a 'first in first out' model, we can pop the first item in the list each time.

```python
# Start with a list of unconfirmed users, and an empty list of confirmed users.
unconfirmed_users = ['ada', 'billy', 'clarence', 'daria']
confirmed_users = []

# Work through the list, and confirm each user.
while len(unconfirmed_users) > 0:

    # Get the latest unconfirmed user, and process them.
    current_user = unconfirmed_users.pop(0)
    print("Confirming user %s...confirmed!" % current_user.title())

    # Move the current user to the list of confirmed users.
    confirmed_users.append(current_user)

# Prove that we have finished confirming all users.
print("\nUnconfirmed users:")
for user in unconfirmed_users:
    print('- ' + user.title())

print("\nConfirmed users:")
for user in confirmed_users:
    print('- ' + user.title())
```

This is a little nicer, because we are sure to get to everyone, even when our program is running under a heavy load. We also preserve the order of people as they join our project. Notice that this all came about by adding *one character* to our program!

** Exercise **:
- Write a program that asks the user to enter a string and do the followings:
    1. Determine the length of the string, $l$
    2. Set $n = l$
    3. Repeat printing the string for $n$ times on the same new line
    4. Decrement $n$ by 1
    5. Repeat Step 3 to 4 until $n$ is zero

    (This is just the *pseudo code*. You may use other methods and approaches to achieve the goal.)

For example, if the user enters -\_-, the result should be:

```
-_--_--_-
-_--_-
-_-
```

If the user enters 囧囧囧, the result has to be:

```
囧囧囧囧囧囧囧囧囧
囧囧囧囧囧囧
囧囧囧
```

** Exercise **:
- Write a function to take a string input from the user, and return it
- Write a function <code>encrypt(input)</code> that takes a string, and encrypt it using the following algorithm:
  - For each letter $c$, if $c \in \{a \dots y\}$ or if $c \in \{A \dots Y\}$ , shift it to the next character. That means, for instances, 'a' in the original string will be changed to 'b', 'Y' will be changed to 'Z'.
  - Other letters (including punctuation marks) remain unchanged in the result.

For example, given "I go to school by bus", the result is "J hp up tdippm cz cvt".

** Exercise **:
- Write a function to compute the mean of a list of integers
- Write a function to compute the standard deviation of a list of integers
- Write a function to compute the maximum of a list of integers
- Write a function to compute the minimum of a list of integers
- Write a function to compute the median of a list of integers

Python - Hands-on Introduction to Python And Machine Learning.ipynb