Python101/Course Notes/Python Course Notes.html

404 KiB
Raw Blame History

<html> <head> </head>

Table of Contents

DISCLAIMER

THIS DOCUMENT IS WORK-IN-PROGRESS

General Python Concepts

Indentation

The syntax used in Python relies heavily on indentation. If the code is not indented 100% correctly, running it will result in an error.

Every block of code that follows a : needs to be indented. The standard indent is equal four spaces, which is default in most editors. A code block can be e.g. a conditional statement:

if a > 0:       # <- Remember colon after the condition
    print(a)    # <- Indent by four spaces inside if-block
    print(2*a)  # <- All code in the block must be indented

b = 2           # <- This code is outside the if-block

If this indentation is not followed, an error will be thrown. IF for instance the variable b outside the if statement was indented only a single space, running the code would result in an IndentationError.

After having typed the colon, hitting enter in the editor should automatically indent the code correctly.

Getting used to this indentation can be a little hard at first, but quickly becomes second nature. Such kind of strictness in the syntax is common for all programming languages. Many other languages use parantheses or brackets of some kind or begin/end statements to keep blocks of code separated, which can become a little messier to look at. Some might argue that Python keeps the strictness to a minimum and that following the forced indentation scheme only helps keeping the code neat and tidy. Something that one should also strive to do in other languages.

The same concept of : followed by indentation goes for for and while loops, functions, classes etc.

Common Nomenclature

Iterable

An iterable is an object that can be iterated/looped over. This could be strings, lists, tuples, keys of a dictionary etc. It can occur in a for loop like this:

for item in iterable:
    # Some code

In more detail

An iterable is an object that has an __iter__() method attached to it. Understanding the constuct of Python classes is paramount to fully understand iterables.

Sequence

A construct that supports indexing and slicing. Most important ones are strings, lists, tuples.

Function

A function is a block of code that is first defined, and thereafter can be called to run as many times as needed. A function might have arguments, some of which can be optional if a default value is specified. A function is called by parantheses: function_name(). Arguments are placed inside the paranthes and comma separated if there are more than one. Similar to f(x, y) from mathematics.

A function can return one or more values to the caller. The values to return are put in the return statement, which ends the function. If no return statement is given, the function will return None.

The general syntax of a function is:

def function_name(arg1, arg2, optional_arg1=0, optional_arg2=None):
    '''
    This is the so-called 'docstring', which documents concisely what the function does.
    It is basically a multiline comment.
    '''

    # Function code goes here

    # Possible 'return' statement which terminates the function. 
    # If 'return' is not specified, function returns None.
    return value_to_return

If multiple values are to be returned, they can be separeted by commas. The returned entity will by default be a tuple.

Note: When using default arguments, it is good practice to only use immutable types as defaults. Using mutable types can have unexpected and hard-to-detect side effects.

Class

A class is a convenient way to create customized objects in Python. When classes are being used in code the programming style is referred to as Object Oriented Programming (OOP).

Some benenits of OOP:

  • Code that is naturally tied can bunched together. If one has multiple functions that can perform related but still different opertions, they can be grouped inside a class and turned to methods. It makes the code more modular.

  • It avoids a lot of code repetition.

  • It can make it easier for users of the code to directly create objects and work with them.

Some drawbacks of OOP:

  • The concept is harder to grasp compared to working with standard functions.

  • Will often create many lines of code

OOP is mostly used for actual software programs and perhaps less so for standard single-file scripting.

Regardless whether ones has to use it directly or not, knowing the concept of OOP is still very integral to understanding how Python works. Many built-in features and third party libraries use this concept.

Consider the example below where a class for concrete sections is created:

In [1]:
class ConcreteSection():
    
    def __init__(self, b, h):
        self.b = b
        self.h = h
       
    
# Print to see what it is
print(ConcreteSection)
<class '__main__.ConcreteSection'>

__init__() is a special method that initiates the the class instance. See explanations below for these concepts.

Class instance

Defining a class like ConcreteSection above allows for creating many different concrete sections and storing them in variables:

In [2]:
# Create a concrete section with width 100 and height 200
section1 = ConcreteSection(100, 200)

print(section1)
<__main__.ConcreteSection object at 0x0000021B3FE197B8>

The variable section1 is called an instance of the ConcreteSection class.

The height of the cross section stored in section1 can be accesssed as

In [3]:
print(section1.h)
200

Method

A method is similar to a function, except it resides inside a class.

A method is called by 'dot'-notation: class_instance.method_name()

An example of a class is the predefined list in Python. The list class has many methods, for example list.append() where append() is a 'function' written and contained inside the list-class, therefore called a method instead of a function. The fact that a list is a class can be seen by

In [4]:
# Define a list
w = [1, 2, 3]

# Print the type of the variable 'w' 
print(type(w))
<class 'list'>
In [5]:
# Use the 'append' method
w.append(4)
print(w)
[1, 2, 3, 4]

Continuing from the ConcreteSection class above, we could write a method that calculates and returns the area of the section:

In [6]:
class ConcreteSection():
    
    def __init__(self, b, h):
        self.b = b
        self.h = h
    
    def area(self):
        '''
        This method returns the area of the concrete section.
        '''
        return self.b * self.h

Use of self

Notice the use of self as the first argument to both the special __init__ method and the standard area method. Think of self is the instance itself.

So after an instance (section) is created, computation of the area for that section does not need to have the width and the height as input. These values are already stored inside the instance, i.e. inside self. They can then be referred to as self.b and self.h.

This is a showcase of one of the advantages of OOP. It does require writing a lot of boiler-plate code to set it up, but afterwards it is easier to use since one can work directly with objects.

The 'ease' of use is demonstrated below:

In [7]:
# Define an instance of the ConcreteSection class 
sect = ConcreteSection(b=500, h=1000)

# Use the 'area' method to calculate the area of the instance
print(sect.area())
500000

Print some info for the section:

In [8]:
print(f'The section has dimensions (b, h)=({sect.b}, {sect.h}) and an area of {sect.area()}')
The section has dimensions (b, h)=(500, 1000) and an area of 500000

A more advanced scenario

In the same manner, many addtional methods could be created and tied to the instance sect. Ones all desired methods are written it is easy to calculate everything by simply typing sect.method_name(...).

Consider a more advanced scenario of the ConcreteSection class like:

# Create section as an instance of class ConcreteSection
sect = ConcreteSection(b, h, some_other_needed_parameters)

# Perform some desired operations by calling appropriate methods of the instance (section)
sect.plot_section()
sect.area()
sect.reinforcement_area()
sect.reinforcement_ratio()
sect.plot_mn_diagram()
sect.plot_capacity_surface()
sect.perform_sls_analysis(...)
sect.largest_crack_width(...)
sect.load_combination_with_largest_utilisation_ratio(...)
sect.spit_out_the_entire_damn_documentation_report(...)

Special method

__init__()

In [ ]:
 

Generator

...

In [ ]:
 

Iterator

... __next__() method.

In [ ]:
 

Common error messages

Some common error messages and their meaning:

IndentationError

Occurs when atempting to run code that is uncorrectly indented. Python uses indentation to structure blocks of code instead of parantheses, brackets and end statements. Therefore, it is very strict about correct indentation. A code editor will assist so maintaining correct indentation becomes very easy. Make sure the editor will treat a tab as four spaces, otherwise indentation errors can be hard to spot. This configuration should be default in many editors though.

SyntaxError

Occurs when syntax is incorrect. The following example will throw a SyntaxError since the parathesis in function call to len() is not closed:

len([1, 2, 3]   # <--- Will throw SyntaxError

IndexError

Occurs when atempting to access an index that is not present in the sequence.

In [9]:
L = [1, 2, 3]
L[10]           # <--- IndexError, since index 10 is not present in L
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-9-0328e124d272> in <module>()
      1 L = [1, 2, 3]
----> 2 L[10]           # <--- IndexError, since index 10 is not present in L

IndexError: list index out of range

ValueError

Occurs when ..

TypeError

Occurs when atempting to do an operation that is not supported by the given type. For example a function call of an integer:

In [10]:
4()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-10-2388798513fb> in <module>()
----> 1 4()

TypeError: 'int' object is not callable

Collection of Python Code Snippets

Th chapters belwo contains small chunks of code that can serve as parts of bigger scripts and programs.

Iteration

Simplest forms of iteration

In [11]:
# Iterate over lists
for i in [1, 2, 3]:
    print(i)
1
2
3
In [12]:
# Iterate over strings
for letter in 'abc':
    print(letter)
a
b
c

Iteration while keeping track of index

In [13]:
names = ['Alpha', 'Bravo', 'Charlie']

idx = 0
for name in names:
    print(idx, name)
    idx += 1
0 Alpha
1 Bravo
2 Charlie

A better way using the enumerate function

In [14]:
# Use enumerate to access both index and value during iteration
for idx, name in enumerate(names):  
    print(idx, name)
0 Alpha
1 Bravo
2 Charlie
In [15]:
# Index starting from 1 instead of default 0
for index, name in enumerate(names, start=1):  
    print(index, name)
1 Alpha
2 Bravo
3 Charlie

Thus, enumerate keeps track of both the value and the index that is being iterated over.

Iterating over multiple iterables at once

Iterating over multiple iterables at once accessing the first element of all, then the second element of all etc. can be done in multiple ways. The cleanest way is using the zip function.

An iterable is something that can be iterated over, e.g. lists, stings, tuples, sets etc.

An example with two lists:

In [16]:
x_values = [1, 2, 3]
y_values = [3, 6, 4]

for x, y in zip(x_values, y_values):
    print(f'x = {x} and y = {y}')
x = 1 and y = 3
x = 2 and y = 6
x = 3 and y = 4

An example with three lists:

In [17]:
x_values = [1, 2, 3]
y_values = [3, 6, 4]
z_values = [10, 23, 26]

for x, y, z in zip(x_values, y_values, z_values):
    print(f'x = {x} and y = {y} and z = {z}')
x = 1 and y = 3 and z = 10
x = 2 and y = 6 and z = 23
x = 3 and y = 4 and z = 26
In [18]:
english = ['one', 'two', 'three']
german = ['eins', 'zwei', 'drei']

for e, g in zip(english, german):
    print(f'{g} means {e} in german')
eins means one in german
zwei means two in german
drei means three in german

Iterating over multiple iterables while keeping track of index

By using enumerate and zip together, multiple iteratbles can be iterated over while keeping track of the index (loop counter).

In case of multiple iteratbles, enumerate returns the index and all values in a tuple, which then need to be unpacked. See below:

In [19]:
for idx, val in enumerate(zip([12,3,4], [5, 3, 46], [52, 53, 2])):
    print(f'idx = {idx}   ,   val = {val}')
idx = 0   ,   val = (12, 5, 52)
idx = 1   ,   val = (3, 3, 53)
idx = 2   ,   val = (4, 46, 2)

Another example:

In [20]:
english = ['one', 'two', 'three']
german = ['eins', 'zwei', 'drei']

for idx, val in enumerate( zip(english, german) ):
    print(f'Index {idx}: {val[1]} means {val[0]} in german')
Index 0: eins means one in german
Index 1: zwei means two in german
Index 2: drei means three in german

Printing and formatting

Printing strings containing variables

There is quite often a need for printing a combination of static text and variables. This could e.g. be to output the result of a computation. Often the best way is to use the so-called f-strings. See examples below.

In [21]:
# Basic usage of f-strings
a = 2
b = 27
print(f'Multiplication: a * b = {a} * {b} = {a*b}')
print(f'Division: a / b = {a} / {b} = {a/b}')
Multiplication: a * b = 2 * 27 = 54
Division: a / b = 2 / 27 = 0.07407407407407407
In [ ]:
 

Rounding anf formating numeric string values

When printing variables it can be useful to format them. Some common ways of formatting are shown below:

Number Syntax Output Description
3.1415926 {3.1415926:.2f} 3.14 2 decimal places
3.1415926 {3.1415926:+.2f} +3.14 2 decimal places with sign
-1 {-1:+.2f} -1.00 2 decimal places with sign
2.71828 {2.71828:.0f} 3 No decimal places
5 {5:0>2d} 05 Pad number with zeros (left padding, width 2)
5 {5:x<4d} 5xxx Pad number with xs (right padding, width 4)
10 {10:x<4d} 10xx Pad number with xs (right padding, width 4)
1000000 {1000000:,} 1,000,000 Number format with comma separator
0.25 {0.25:.2%} 25.00% Format percentage
1000000000 {1000000000:.2e} 1.00e+09 Exponent notation

The part of the syntax that actually formats the value is shown in bold.

Source: https://mkaz.blog/code/python-string-format-cookbook/

Some examples are shown below. Note the location where the formatting must be specified, i.e. directly after the variable to be formatted.

In [22]:
c = 50
d = 200
# f-string with formatting for number of decimal places
print(f'Division: c / d = {c} / {d} = {c/d:.3f}')
Division: c / d = 50 / 200 = 0.250
In [23]:
# f-string with formatting as percent
print(f'{c} is {c/d:.1%} of {d}')
50 is 25.0% of 200

List comprehensions

List comprehensions are often shorter and easier to read than their traditional for loop counterparts. They are also faster since the entire list is built in one go instead of by appending element by element.

Consider the following to equivalent loops: TODO INPUT

Simple form

The general form of the simplest list comprehension is

result_list = [expression for item in iterable]
  • iterable is a sequence that can be iterated over, this could be a list, a string, a tuple etc.
  • item is the counter for the iterable, think of this as the i'th element
  • expression can be anything, but will often include the item
# This list comprehension
x = [r**2 for r in [5, 6, 8, 10]]

# Will be computed at this list
x = [5**2, 6**2, 8**2, 10**2]

# To be equal to
x = [25, 36, 64, 100]
In [24]:
x = [1, 2, 3, 4, 5]             # Define a list (iterable)

# Double numbers from old list and put in new list
result_list = [2*i for i in x]  
result_list                 
Out[24]:
[2, 4, 6, 8, 10]
In [25]:
diameters = [10, 12, 16, 20]  # Define list of rebar diameters (iterable)

# Compute rebar areas for each diameter
rebar_areas = [3.14 * dia**2 / 4 for dia in diameters]  
rebar_areas
Out[25]:
[78.5, 113.04, 200.96, 314.0]

Form with conditional if statement

The general form of a list comprehension with a conditional is

result_list = [expression for item in iterable if condition]
  • iterable is a sequence that can be iterated over, this could be a list, a string, a tuple etc.
  • condition is a logical condition, e.g. "item > 3", which returns a boolean (True/False). This can act as as filter.
  • result_list is a new list containing expression for each item that fulfilled the condition
In [26]:
x = [1, 2, 3, 4, 5]   # Define a list (iterable)

# Filter list with list comprehension by a condition
result_list = [i for i in x if i > 3]   
result_list
Out[26]:
[4, 5]

A litlle more complex example that includes slicing of strings and type conversion from strings to integers within the conditional statement:

In [27]:
# Define list of steel profiles as a list of strings
profiles = ['HE170A', 'HE180A', 'HE190A', 'HE200A', 'HE210A', 'HE210A']

# Filter all profiles that are HE200A or smaller
filtered_profiles = [p for p in profiles if int(p[2:5]) <= 200 ]
filtered_profiles
Out[27]:
['HE170A', 'HE180A', 'HE190A', 'HE200A']

Here int(p[2:5]) extracts the number part of the steel profile (e.g. '190' from 'HE190A') and converts it from a string to an integer. This is necessary because it is to be compared with an integer afterwards.

A statement like '190' <= 200 would throw a TypeError: '<=' not supported between instances of 'int' and 'str'

Form with conditional if / else statement

The general form of a list comprehension with a conditional if and else is:

result_list = [expression1 if condition else expression2 for item in iterable]
  • iterable is a sequence that can be iterated over, this could be a list, a string, a tuple etc.
  • condition is a logical condition, e.g. "item > 3", which returns a boolean (True/False). This can act as as filter.
  • result_list is a new list containing elements where each item depends on condition. If it is True, expression1 is put in. Else expression2 is put in.
In [28]:
V = [3, 62, 182, 26, 151, 174]

# Set all elements of V that are less than 100 equal to 0
W = [0 if i < 100 else i for i in V]
W
Out[28]:
[0, 0, 182, 0, 151, 174]

List comprehensions with multiple if / else statements

In [29]:
Q1 = (-18, -27, 2, -21, -15, 5)
[0 if val > 0 else val if 0 > val > -25 else -25 for val in Q1]
Out[29]:
[-18, -25, 0, -21, -15, 0]

Nested list comprehensions

It is possible to make nested list comprehensions, i.e. with for loops inside eachother.

In [30]:
[[j for j in range(25, 30)] for i in range(5)]
Out[30]:
[[25, 26, 27, 28, 29],
 [25, 26, 27, 28, 29],
 [25, 26, 27, 28, 29],
 [25, 26, 27, 28, 29],
 [25, 26, 27, 28, 29]]

Complex one-liners can be quite hard to read, so for nested if statements or loops it is sometimes better to create a traditional multi line for loop.

Dictionaries

Create a dictionary from two lists

Making first list the keys and second the values

In [31]:
names = ['Pelé', 'Maradona', 'Zidane']
countries = ['Brazil', 'Argentina', 'France']

d = dict(zip(names, countries))
d
Out[31]:
{'Maradona': 'Argentina', 'Pelé': 'Brazil', 'Zidane': 'France'}

Functions

Default arguments

A little side note

A good practice is to use only immutable types as default arguments. Python will allow mutable arguments to be given as defaults, but the behavior can be confusing if the default argument is mutated within the function.

Consider this example:

TODO

A list (which is mutable) is given as default value and the list is mutated within the function. This is because it will mutate the deafult argument so the next function call has the mutated value as the default.

Examples

In [32]:
def abbreviate_day(day):    
    '''Return the specified day in abrreviated three character form'''
    
    # Define dictionary for mapping days to their abbreviations
    day_to_abbreviation = {'monday': 'Mon', 'tuesday': 'Tue', 
                           'wednesday': 'Wed', 'thursday': 
                           'Thu', 'friday': 'Fri', 
                           'saturday': 'Sat', 'sunsay': 'Sun'}

    # Convert input string to all lowercase
    day = day.lower()    
    
    # Return abbreviation if day is a valid day, raise error otherwise
    try:
        return day_to_abbreviation[day]
    except KeyError:
        raise ValueError(f"Expected input 'day' to be a day name, but received '{day}'")
        
        
# Test function
d = 'Monday'
print(abbreviate_day(d))
Mon
  • Note: The input parameter when the function was defined was a string called day, but the when the function was called, the input string was called d. Thus, the input parameters passed into a function do not need to have the same name as when the function is defined.
In [ ]:
 

Tables as Pandas dataframes

In [33]:
import pandas as pd

# Allow for dataframes to be displayed as HTML-tables
from IPython.display import display, HTML   

merge, join and concat

The best explanation is arguably from the documentation itself: https://pandas.pydata.org/pandas-docs/stable/user_guide/merging.html

Roughly speaking, merge is used to combine two or more dataframes, while concat is used to 'glue' dataframes together, i.e. append them to eachother or put them side by side. concat is not content aware in that it will might include duplicate columns, whereas merge will remove/merge duplicates. concat requires the dataframe dimensions to be equal along the axis of concatenation, where merge can treat any size.

All join operations could also be achieved by using merge, but join commands have easier/shorter code for some common use cases.

Functionality similar to VLOOKUP in Excel

In [34]:
# Read pile data from csv-file
df_piles = pd.read_csv('piles.csv')

# Read steel profile data from csv-file
df_profiles = pd.read_csv('steel_profiles.csv')

# Merge dataframes on the "Profile" column (similar to Excel VLOOKUP)
df_merged = df_piles.merge(df_profiles, on='Profile', how='left')

display(df_piles, df_profiles, df_merged)
Pile_type Profile
0 P01 HE200A
1 P20 HE220A
2 P05 HE240B
3 P23 NaN
4 P04 HE200A
5 P01 HE300B
Profile h[mm] b[mm] Iy[mm4] Wel_y[mm3] g[kg/m]
0 HE100A 96 100 3490000 72.8 16.7
1 HE120A 114 120 6060000 106.0 19.9
2 HE140A 133 140 10300000 155.0 24.7
3 HE160A 152 160 16700000 220.0 30.4
4 HE180A 171 180 25100000 294.0 35.5
5 HE200A 190 200 36900000 389.0 42.3
6 HE220A 210 220 54100000 515.0 50.5
7 HE240A 230 240 77600000 675.0 60.3
8 HE260A 250 260 104500000 836.0 68.2
9 HE280A 270 280 136700000 1010.0 76.4
10 HE300A 290 300 182600000 1260.0 88.3
11 HE100B 100 100 4500000 89.9 20.4
12 HE120B 120 120 8640000 144.0 26.7
13 HE140B 140 140 15100000 216.0 33.7
14 HE160B 160 160 24900000 311.0 42.6
15 HE180B 180 180 38300000 426.0 51.2
16 HE200B 200 200 57000000 570.0 61.3
17 HE220B 220 220 80900000 736.0 71.5
18 HE240B 240 240 112600000 938.0 83.2
19 HE260B 260 260 149200000 1150.0 93.0
20 HE280B 280 280 192700000 1380.0 103.0
21 HE300B 300 300 251700000 1680.0 117.0
Pile_type Profile h[mm] b[mm] Iy[mm4] Wel_y[mm3] g[kg/m]
0 P01 HE200A 190.0 200.0 36900000.0 389.0 42.3
1 P20 HE220A 210.0 220.0 54100000.0 515.0 50.5
2 P05 HE240B 240.0 240.0 112600000.0 938.0 83.2
3 P23 NaN NaN NaN NaN NaN NaN
4 P04 HE200A 190.0 200.0 36900000.0 389.0 42.3
5 P01 HE300B 300.0 300.0 251700000.0 1680.0 117.0

The first dataframe containing pile types and profile has been populated by the data of the steel profiles. The resulting dataframe has all profile data, but it could have been specified to include only certain columns of the profile properties.

Compared to e.g. Excel, this has the advantage of being scalable in each table direction. Meaning that if more rows or columns were to be added to either dataframe, the code need no change at al, it just adapts. No manual adjustment of ranges/cells.

Plotting with Matplotlib

In [35]:
import matplotlib.pyplot as plt

# Enable for plots to be shown inside notebook cells
%matplotlib inline      

Simplest forms of graphs

In [36]:
# Create x- and y-coordinates for f(x) = x^2 
x = [i for i in range(-100, 105, 5)]
y = [i**2 for i in x]

# Create basic plot
plt.plot(x, y)

# Show plot
plt.show()

Note: When using an editor, one has to call plt.show() to show the figure, which then opens a separate window.

Including title, labels and marker styling:

In [37]:
plt.title('Graph for $f(x) = x^2$')
plt.xlabel('x')
plt.ylabel('f(x)')
plt.plot(x, y, 'x', color='limegreen', markersize=8)
plt.show()

Multiple plots in same figure

In [38]:
plt.plot(x, y, label='$f(x)=x^2$')                   # x and y defined above
plt.plot(x, [2*i for i in y], label='$f(x)=2x^2$')
plt.legend()
plt.show()

Subplots

In order for subplots to be used, it is necessary to access the underlying objects that control ....

In [ ]:
 

Heatmaps/colormaps

In [ ]:
 

Integration with Pandas

In [ ]:
 

Numerical computations

A large portion of the computations are carried out as optimized C-code under the hood, which is pretty much as fast as it gets.

Numpy

TODO: np.linspace()

TODO: np.append()

TODO: np.where()

TODO: Fancy indexing

Scipy

TODO: from scipy.signal import find_peaks

</html>