a = 6 # Save the number 6 and label it 'a'
b = 3 # Save the number 3 and label it 'b'
# Add the content of each variable together
a + b 9
Up to this point, we have been using the computer in fairly direct ways: we type a command, press , and something happens. We have seen how to list files, create folders, install software, or use Git to record snapshots of our work, but always one command at a time. This way of working is straightforward and often quite effective, but we can do so much more!
This section is meant as an introduction to programming, using Python as our language of choice. There are several reasons for this:
When we program, we write down sequences of instructions that tell the computer how to perform given tasks or solve particular problems. By means of conditional logic and other control structures, we can design programs in creative ways that accommodate a variety of scenarios. Moreover, we can execute them whenever we wish, scale them to handle large workloads, or even automate them!
We can install Python with one of the following CLI commands:
macOS:
brew install pythonsudo apt install python3sudo pacman -S pythonpython and python3Even when Python is properly installed, we might find invoking Python from the command line via python returns an error. This is a historical artifact. In 2008, Python released version 3, which intentionally broke backwards compatibility with Python 2 to fix several architectural flaws. The transition took over a decade, during which operating systems maintained both versions simultaneously to avoid breaking legacy system scripts (Python 2 was officially sunsetted in 2020).
On modern installations, python3 is the explicit command for the interpreter, though many systems now safely alias python to point directly to python3. To verify what our shell executes, we ask for the version detail:
python --versionPython is an interpreted language. Unlike compiled languages (such as C or Fortran), where source code must be entirely translated into machine code by a compiler program before running, Python code is read, parsed, and executed line-by-line, in real time, by a program called the interpreter.
This interpreted nature gives us two distinct ways to interact with the language:
python3 into the terminal with no files specified, we drop into an interactive scratchpad indicated by a >>> prompt. Here, every line we type is executed immediately when we press Enter. This is a useful environment for testing isolated expressions, running quick calculations, or checking syntax.We can exit the REPL in several ways: running exit() or quit() will return us to our normal terminal shell. Alternatively, we can also use the keyboard shortcut to send an “end-of-file” signal to the interpreter.
Script Execution: For structured, reproducible, and long-form projects, we write our plaintext instructions into files ending in the .py extension using a text editor or IDE (Chapter 6). We then hand the entire file to the interpreter via our terminal:
python main.pyWhen executed this way, the interpreter opens the file, scans it from top to bottom for structural or syntax errors, and executes each statement sequentially. Once it reaches the final line, the program terminates, releases its allocated memory back to the operating system, and returns control to the terminal shell prompt.
Jupyter Notebooks: These exist somewhere between a script and the live terminal. Notebooks organize code into discrete “cells” that can be executed independently and re-run in any order, while maintaining a persistent memory state in the background. Because they can blend live executable code blocks with formatted prose and inline mathematical figures, they are a useful medium for drafting computational essays and interactive research journals.
Before we get into the mathematics, we have to learn some baseline grammar. Programming can feel pedantic—computers do exactly what we type, which is often not what we mean.
In mathematical notation, an equals sign usually reflects some notion of balance—when we use it in an equation, we mean that the value, expression, or abstract object on the left is exactly equivalent to the one on the right. On the other hand, in Python, a single equals sign = is a fundamentally asymmetrical action: it takes whatever is on the right side and stores it in a sort of “box” (a variable) named by the left side. This is called assignment.
a = 6 # Save the number 6 and label it 'a'
b = 3 # Save the number 3 and label it 'b'
# Add the content of each variable together
a + b 9
In interactive computational environments (like Jupyter notebooks or the Quarto engine rendering this page), code is organized into executable blocks called cells. When a cell is run, the environment automatically captures and prints the evaluation of the very last line of code.
In mathematics, we often use single-letter names like \(x\), \(y\), or \(A\) for our variables. In computer science, on the other hand, good variable names are descriptive and help us make sense of what the code is actually doing. We will strive for a balance between these conventions in this book! Note that Python variable names must follow some strict rules:
Total and total and TOTAL are all treated as different variables).By convention, Python developers use lowercase letters and underscores to separate words—a style known as “snake case” (e.g., matrix_determinant).
If a single equals sign assigns a value, how do we check if two variables are equal? The convention in Python, as in many other programming languages, is to use a double equals sign ==. We should think of == as asking the computer a question: “Are these two things the same?”
Other fundamental comparison operators include:
< (Strictly less than)> (Strictly greater than)<= (Less than or equal to)>= (Greater than or equal to)!= (Not equal to)We can store the result of such a question into a variable or use them as part of the logical structure of our program, as to be discussed shortly.
To display these evaluations explicitly when they occur in the middle of a script, we wrap them in the print() function. This sends the string representation of the resulting value to the standard output stream (stdout) of our terminal, as shown below:
x = 7
equal_to_7 = (x == 7)
print(f"Is x equal to 7? {equal_to_7}")
less_than_6 = (x < 6)
print(f"Is x less than 6? {less_than_6}")Is x equal to 7? True
Is x less than 6? False
Python classifies data into types. The basic, atomic building blocks—the primitives—are straightforward:
int): Whole numbers (-5, 0, 42).float): Decimal approximations of real numbers (3.14159, -0.001).bool): Logical truth values (True or False).str): Ordered collections of plaintext characters, bounded by single or double quotes ("Euler", 'Matrix').Python is a dynamically typed language. In statically typed languages (like C++ or Java), a variable is a rigidly defined container in physical memory; we must declare that a box holds an integer, and it can never hold anything but an integer. With this analogy, we can imagine variables in Python as sticky labels that we peel off and attach to objects floating in memory. In other words, the data objects themselves have fixed types, but the variable labels can be reassigned to completely different objects!
coordinate = 0 # The label 'coordinate' is stuck to an integer
coordinate = "Origin" # The label is peeled off and stuck to a stringWhile this design provides incredible flexibility, it demands discipline. If we attempt an operation where the underlying data types cannot logically cooperate, the interpreter cannot resolve the command and raises a TypeError.
radius = 5
units = "cm"
total = radius + unitsIf we try to execute the code above, we’ll get an error like so:
Traceback (most recent call last):
File "<python-input-0>", line 3, in <module>
total = radius + units
~~~~~~~^~~~~~~
TypeError: unsupported operand type(s) for +: 'int' and 'str'To resolve this, we must distinguish between two mechanisms for changing data types:
5 + 2.3 mixes an integer and a float; Python automatically coerces the integer 5 into the float 5.0 to return the float 7.3.int(), float(), or str().radius = 5
units = "cm"
labeled_radius = str(radius) + units
print(labeled_radius)5cm
For mathematicians, Python’s int type comes with a nifty structural advantage: arbitrary precision. In languages like C or Java, integers are capped at a specific bit-width (typically 32 or 64 bits), meaning numbers larger than \(2^{63}-1\) suffer from overflow errors. Python integers, by contrast, automatically scale to use much more memory, which allows us to compute with thousands of digits at a time.
In addition to the usual arithmetic operations +, -, and *, Python provides three distinct operators related to division:
a/b always returns a float, even if the numbers divide perfectly (6 / 3 yields 2.0).a//b truncates the remainder, behaving exactly like the quotient \(q\) in the division algorithm (\(a = qb + r\)).a%b computes the remainder \(r\) in the division algorithm.In addition, the exponentiation operator a**b raises a base to a power \(a^b\).
numerator = 17
denominator = 5
quotient = numerator // denominator
remainder = numerator % denominator
print(f"{numerator} = {quotient} * {denominator} + {remainder}")17 = 3 * 5 + 2
Unlike integers, floating-point numbers are stored using a fixed binary fraction representation standard (IEEE 754). Because computers work in base \(2\), many base-\(10\) decimals cannot be represented exactly in binary memory, much like how the fraction \(1/3\) becomes an infinite repeating decimal \(0.3333...\) in base 10.
Consequently, floats are subject to minor round-off errors. As such, checking for strict float equality using == is a fraught endeavor that should be avoided!
result = 0.1 + 0.2
print(f"Does 0.1 + 0.2 equal 0.3? {result == 0.3}")
print(f"Actual memory value of 0.1 + 0.2: {result}")Does 0.1 + 0.2 equal 0.3? False
Actual memory value of 0.1 + 0.2: 0.30000000000000004
When writing numerical scripts where equality of floats must be checked, look to see if the values are within a miniscule distance threshold of each other, or use built-in safety tools like math.isclose() (see Section 7.8.1).
The fundamental operators of propositional logic that we encounter in an introduction-to-proofs course are carried into Python’s booleans:
P and Q, which returns True only if both arguments are true.P or Q, which is True if at least one of the arguments is true.not P, which inverts the logical state of the argument.We can see here one of the previously advertised features of Python: how readable it is!
age = 23
has_id = True
can_enter = (age >= 21) and has_id
print("Access granted:", can_enter)Access granted: True
It is often useful to intermix text with variables to produce readable output from our programs, rather than the basic print() statements we have used until now. We have seen in previous examples that strings can be manually assembled using casting, but a more convenient way to accomplish this is using an f-string (formatted string). By placing a literal f immediately before the opening quote of a string, we can inject variables, arithmetic, or expressions directly into the text using curly braces {}:
name = "Euler"
favorite_number = 2.718281828
print(f"My name is {name} and I like the number {favorite_number}.")My name is Euler and I like the number 2.718281828.
Mathematicians rarely deal in isolated elements—instead, we work with sets, vectors, matrices, and other algebraic structures! By analogy, Python provides several collection types, each with distinct structural properties that dictate how they access and store data.
Before reviewing each collection, note that two foundational operations apply to all of them:
len()): Passing any collection to the len() function returns the total number of elements, \(|X|\).in): This operator behaves exactly like the mathematical set relation \(x \in X\). Evaluating item in collection returns a fast True or False answer.A Python list is a mutable, ordered sequence of elements, enclosed in square brackets [].
primes = [2, 3, 5, 7, 11]Because lists are ordered, every element lives at a distinct integer coordinate (called its index) that we can use to access specific elements. Note that Python uses zero-indexed numbering; the first element of a list lives at position 0, rather than position 1.
primes = [2, 3, 5, 7, 11]
print(primes[0]) # Prints the first element
print(primes[3]) # Prints the fourth element2
7
Lists are mutable, meaning we can alter their contents at will. We can overwrite elements, append new ones to the end, or pop items off the list!
primes[0] = 17 # Overwrites the first element
primes.pop() # Removes 11 from the list
primes.append(13) # Adds 13 to the end
print(primes)[17, 3, 5, 7, 13]
A tuple is an ordered sequence enclosed in parentheses (). This might seem very similar to a list, but a key difference is that tuples are immutable. In other words, once a tuple is created, it cannot be modified, appended to, or rearranged!
my_point = (3, 4, -1)
my_point[0] = 5 # This line will cause an error!If we attempt to execute the above code, we’ll encounter a runtime exception:
Traceback (most recent call last):
File "<python-input-0>", line 2, in <module>
my_point[0] = 5
~~~~~~~~^^^
TypeError: 'tuple' object does not support item assignmentThis immutability comes with some advantages, namely that tuples are more memory-efficient and, as the size of our collections grow, slightly faster for the computer to work with than lists.
Moreover, tuples usually serve a distinct semantic purpose. While a list is typically a homogeneous, open-ended sequence, a tuple often represents a fixed algebraic object whose entries carry structural significance—such as an \((x, y, z)\) coordinate vector in \(\mathbb{R}^3\). Further, tuples are the mechanism behind Python’s native multiple assignment and value unpacking, which will prove very useful going forward. We can think of this as analogous to mathematical functions \(X \to \mathbb{R}^m\), specified by a collection of \(m\) real-valued functions on \(X\).
point = (0.5, -1.0)
x_coord, y_coord = point
print(f"The x-coordinate of my point is {x_coord}.")The x-coordinate of my point is 0.5.
A dictionary (or dict) is a collection of key-value pairs, enclosed in curly braces {} and separated by colons :. Instead of accessing elements via a sequential integer index (like a list or tuple), we retrieve values using a unique key. This is a direct analog to a mathematical function \(f: K \to V\) mapping a domain \(K\) of keys to a codomain \(V\) of values.
We might, for example, use a dictionary to keep track of the nodes of a graph:
flowchart LR
A[A]
B[B]
C[C]
D[D]
E[E]
A --> B --> C --> D
B --> A
B --> D
E --> B
E --> D
graph_connections = {
"A" : ["B"],
"B" : ["A", "C", "D"],
"C" : ["D"],
"D" : [],
"E" : ["B", "D"]
}
print("Outbound paths from B:", graph_connections["B"])Outbound paths from B: ['A', 'C', 'D']
Under the hood, dictionaries are implemented using an optimized data structure known as a hash table. The advantage here is that looking up a key, adding a new key-value pair, or checking if a mapping exists occurs in constant time, i.e., completely independent of whether the dictionary contains three or three million entries. This is in contrast to, say, checking whether an item is in a list, which (on average) takes a proportional amount of effort to the length of the list! As such, dictionaries are an ideal tool for representing complex structural relations, building sparse matrix representations, or more sophisticated memoization techniques (a process of speeding up software via caching evaluations of resource-intensive calculations).
Much like their mathematical counterparts, a Pythonic set is an unordered collection of unique elements enclosed in curly braces {} without key-value pairings.
primes_set = {2, 3, 5, 5, 7}
print(primes_set) # The duplicate 5 does not appear!{2, 3, 5, 7}
Python provides mathematical operators for standard set algebra:
X | YX & YX - YX ^ Yset_a = {1, 2, 3, 4}
set_b = {3, 4, 5, 6, "cat"}
print("Intersection:", set_a & set_b)
print("Union:", set_a | set_b)
print("Difference:", set_a - set_b)Intersection: {3, 4}
Union: {1, 2, 3, 4, 'cat', 5, 6}
Difference: {1, 2}
When interacting with ordered sequences (lists, tuples, or strings), we frequently need to extract specific intervals rather than single indices. Python handles this via slicing, by which we pass up to three parameter arguments separated by colons, e.g., [start:stop:step]. We should understand such a command as follows:
Extract beginning at the index specified by
start, terminating immediately before reaching the index specified bystop, via increments specified bystep.
This means the start index is inclusive, while the stop index is exclusive, which can be confusing at first! To avoid off-by-one errors, it can be helpful to imagine the indices as tracking the spaces between the elements rather than the elements themselves.
numbers = [14, 82, 37, 5, 91, 44, 63, 29, 76, 50]
print("Indices 2 through 5:", numbers[2:6])Indices 2 through 5: [37, 5, 91, 44]
For brevity, Python allows us to omit these parameters entirely when extracting from extreme boundaries. If start is left blank, the notation defaults to 0 (the beginning); if stop is left blank, the default is the full length of the collection (running up to and including the end).
print("Omit start (defaults to beginning):", numbers[:4])
print("Omit stop (defaults to final index):", numbers[7:])
print("Omit both (the entire list):", numbers[:])Omit start (defaults to beginning): [14, 82, 37, 5]
Omit stop (defaults to final index): [29, 76, 50]
Omit both (the entire list): [14, 82, 37, 5, 91, 44, 63, 29, 76, 50]
Python also supports negative indexing by counting backward from the final element (which should be familiar to readers versed in modular arithmetic): An index of -1 references the terminal item, -2 the second-to-last item, and so on.
print("The final item:", numbers[-1])
print("The last three items:", numbers[-3:])The final item: 50
The last three items: [29, 76, 50]
The third optional parameter, step, allows for some neat downsampling or reversal tricks:
print("Every second item:", numbers[::2])
print("Reversing the sequence:", numbers[::-1])Every second item: [14, 37, 91, 63, 76]
Reversing the sequence: [50, 76, 29, 63, 44, 91, 5, 37, 82, 14]
Conditional logic allows our programs to make decisions: by evaluating whether an expression is True or False, Python can choose between different paths of execution. This makes our programs dynamic, allowing them to respond to user input, changing data, and other circumstances!
Unlike languages like C or Java that use curly braces to group code blocks, Python uses horizontal indentation (whitespace) to determine program structure. Code blocks nested under conditional statements or loops must be indented—by exactly four spaces! The code block is closed by a line of code dedented back towards the left margin.
We branch execution paths using the if statement, adding optional alternative checkpoints via elif (“else if”) and an else catch-all:
temp_fahrenheit = 72
if temp_fahrenheit >= 80:
print("It's a hot day.")
elif temp_fahrenheit >= 60:
print("The weather is pleasant.")
else:
print("It might be chilly!")The weather is pleasant.
We can tie multiple predicates together into a single condition using logical operators (and, or, not). Python evaluates these expressions using short-circuit evaluation: If the first condition in an and statement is false, Python knows the entire phrase cannot be true and immediately halts evaluation of the remaining expressions. This is useful for preventing crashes—we can check if a variable is safe to compute before performing a potentially intensive or illegal calculation. For example:
if trials > 0 and (successes / trials) >= 0.5:
print("The experimental probability is at least 0.5.")The trials > 0 check prevents accidental division by zero in the second term!
Iteration is the process of repeating a sequence of actions some number of times, over a given collection, or until a specific logical constraint shifts.
while LoopsA while loop is used to repeatedly execute a block of code, so long as a specified logical condition remains True.
val = 6
hailstone = []
while val > 1:
hailstone.append(val)
if val % 2 == 0:
val = val // 2
else:
val = 3 * val + 1
hailstone.append(1)
print(f"Collatz orbit has {len(hailstone)-1} steps: {hailstone}")Collatz orbit has 8 steps: [6, 3, 10, 5, 16, 8, 4, 2, 1]
An important mistake to make when learning about while loops is accidentally creating one which never terminates—thereby locking our terminal! If we find ourselves trapped in an infinite execution cycle, we can send a manual keyboard interrupt signal to force the operating system to abort the script by pressing .
for Loops and the range() GeneratorA for loop iterates over any iterable object—e.g., a list, tuple, dictionary, or set—by executing its code block exactly once for each item in the collection.
for subject in ("topology", "algebra", "analysis"):
print(f"I am studying {subject}.")I am studying topology.
I am studying algebra.
I am studying analysis.
To loop over numerical intervals, we can utilize the built-in range(start, stop, step) function which behaves just like a list slice: it yields values sequentially up to, but excluding, the stop boundary.
sum_squares = 0
for n in range(1, 6):
sum_squares += n**2
print("Sum of squares:", sum_squares)Sum of squares: 55
These are a wonderful Python feature, giving us a concise way to create a new collection from an existing one in analogy to set-builder notation. Instead of writing a loop that builds a list, set, or dictionary one element at a time, a comprehension lets us describe the result in a single expression.
Suppose we want to compute the first \(5\) square integers, which we can accomplish by modifying the previous code block:
squares = []
for number in range(1, 6):
squares.append(n**2)
print(squares)[25, 25, 25, 25, 25]
A neater, more Pythonic way to accomplish this is with a one-line comprehension:
squares = [n**2 for n in range(1, 6)]
print(squares)[1, 4, 9, 16, 25]
The general pattern for a list comprehension is [expression for item in iterable]. We can also include a condition to filter which items are included:
even_numbers = [n for n in range(1, 11) if n % 2 == 0]
print(even_numbers)[2, 4, 6, 8, 10]
Comprehensions are also not limited to lists! Python also supports set and dictionary comprehensions:
lengths = {len(word) for word in ["cat", "dog", "turtle"]}
squares = {n: n**2 for n in range(1, 6)}
print(lengths)
print(squares){3, 6}
{1: 1, 2: 4, 3: 9, 4: 16, 5: 25}
Comprehensions are often shorter and easier to read than equivalent loops. On the other hand, if a comprehension becomes long or difficult to understand, a traditional for loop is usually the better choice! Remember, code that is clear and readable is almost always preferable to code that is simply shorter.
math
Comments
Before writing actual code, we’ll start with comments, which are notes embedded in our programs to provide context for other programmers. Writing clear comments is a core part of documenting our code, ensuring that others (and our future selves!) can easily understand the purpose and logic of what we’ve written.
In Python, any text to the right of a hash symbol
#is completely ignored by the interpreter. We use this to leave notes for ourselves and collaborators explaining why code is written a certain way.