Python fundamentals
Lesson 1 — Python
Learning objectives
- Run Python from the REPL, a script and a notebook
- Use the core scalar types and understand dynamic typing
- Write conditionals and loops with correct indentation
- Format strings with f-strings
- Import and use modules
- Read a Python traceback
Running Python
python # interactive REPL
python script.py # run a script
python -m module # run a module
python -c "print(1 + 1)" # one-liner
ipython # a much better REPL
jupyter lab # notebooksIn a script:
#!/usr/bin/env python3
"""Compute study day from two dates."""
from datetime import date
def study_day(event: date, reference: date) -> int:
"""Return the study day; day 1 is the reference date, with no day zero."""
delta = (event - reference).days
return delta + 1 if delta >= 0 else delta
if __name__ == "__main__":
print(study_day(date(2026, 3, 20), date(2026, 3, 15)))The if __name__ == "__main__": guard means the code runs when the file is executed but not when it is imported. It is the Python equivalent of keeping side effects out of a sourced R file, and it is a strong convention rather than an option.
Indentation is syntax
Python has no braces. Indentation defines blocks.
if age >= 65:
group = "elderly"
print(group)
else:
group = "adult"
print("done") # outside the if# R equivalent
if (age >= 65) {
group <- "elderly"
print(group)
} else {
group <- "adult"
}
print("done")Four spaces per level, never tabs. Mixing them is a syntax error, and the error message is unhelpful. Configure your editor to insert spaces.
Types
x = 42 # int
y = 3.14 # float
name = "Ram" # str
flag = True # bool (capitalised!)
nothing = None # NoneType
c = 1 + 2j # complex
type(x) # <class 'int'>
isinstance(x, int) # TruePython is dynamically typed but strongly typed: a variable can hold any type, but types are not silently coerced.
"1" + 1
# TypeError: can only concatenate str (not "int") to str
1 + True # 2 — bool IS a subclass of intNumbers
7 / 2 # 3.5 true division, always float
7 // 2 # 3 floor division
7 % 2 # 1 modulo
2 ** 10 # 1024 exponent (R uses ^)
abs(-5) # 5
round(3.567, 2) # 3.57
divmod(7, 2) # (3, 1)
int("42") # 42
float("3.14") # 3.14
int(3.99) # 3 truncates, does not roundround() uses banker’s rounding
round(0.5) # 0
round(1.5) # 2
round(2.5) # 2Same as R, different from SAS. For half-up rounding:
from decimal import Decimal, ROUND_HALF_UP
def round_half_up(x, digits=0):
q = Decimal(10) ** -digits
return float(Decimal(str(x)).quantize(q, rounding=ROUND_HALF_UP))
round_half_up(0.5) # 1.0
round_half_up(2.5) # 3.0Decimal(str(x)) rather than Decimal(x) — passing a float directly carries its binary representation error into the Decimal.
Integers have unlimited precision:
2 ** 1000 # a 302-digit integer, exactlyFloats do not:
0.1 + 0.2 == 0.3 # False
0.1 + 0.2 # 0.30000000000000004
import math
math.isclose(0.1 + 0.2, 0.3) # TrueStrings
s = "Hello"
s2 = 'Hello' # identical; quotes are interchangeable
multi = """Line one
Line two"""
len(s) # 5
s.upper() # "HELLO"
s.lower()
s.strip() # remove surrounding whitespace
s.replace("l", "L") # "HeLLo"
s.split(",") # list
",".join(["a","b"]) # "a,b"
s.startswith("He") # True
"ell" in s # True
s[0] # "H" 0-indexed!
s[-1] # "o" negative indexes from the end
s[1:3] # "el" slice: start inclusive, stop EXCLUSIVE
s[:3] # "Hel"
s[::2] # "Hlo" every second character
s[::-1] # "olleH" reversedx = [10, 20, 30, 40, 50]
x[0] # 10 first element
x[1:3] # [20, 30] positions 1 and 2, NOT 3x <- c(10, 20, 30, 40, 50)
x[1] # 10 first element
x[2:3] # 20 30 both endpoints includedThis is the single most common source of off-by-one errors when moving between the languages. The Python convention has a virtue: x[:n] and x[n:] partition the list exactly, with no overlap or gap.
f-strings
The modern way to build strings:
name = "Ram"
age = 45
value = 3.14159
f"{name} is {age}" # "Ram is 45"
f"{value:.2f}" # "3.14"
f"{value:8.2f}" # " 3.14" width 8, right aligned
f"{value:<8.2f}" # "3.14 " left aligned
f"{age:03d}" # "045" zero padded
f"{0.4567:.1%}" # "45.7%"
f"{1234567:,}" # "1,234,567"
f"{name=}" # "name='Ram'" debugging shorthand
# Expressions work inside
f"{age * 2}" # "90"
f"{'elderly' if age >= 65 else 'adult'}"The :.2f mini-language is the equivalent of sprintf("%.2f", x) in R and is worth learning properly — it appears throughout pandas and matplotlib.
Control flow
# if / elif / else
if age < 18:
group = "child"
elif age < 65:
group = "adult"
else:
group = "elderly"
# Ternary
group = "elderly" if age >= 65 else "adult"
# for over any iterable
for x in [1, 2, 3]:
print(x)
for i, x in enumerate(["a", "b", "c"]):
print(i, x) # 0 a, 1 b, 2 c
for k, v in {"a": 1, "b": 2}.items():
print(k, v)
for a, b in zip([1, 2, 3], ["x", "y", "z"]):
print(a, b)
for i in range(5): # 0, 1, 2, 3, 4
print(i)
for i in range(1, 10, 2): # 1, 3, 5, 7, 9
print(i)
# while
n = 0
while n < 5:
n += 1
# break, continue, else
for x in values:
if x < 0:
break
if x == 0:
continue
process(x)
else:
print("completed without break") # runs only if no breakThe for ... else construct is unusual and rarely needed, but you will meet it.
Truthiness
bool(0) # False
bool("") # False
bool([]) # False
bool({}) # False
bool(None) # False
bool(0.0) # False
bool(1) # True
bool("a") # True
bool([0]) # True a non-empty list is truthy even if it contains 0if not results: # idiomatic "if the list is empty"
print("no results")
if x is None: # test for None with 'is', never '=='
...is versus ==
a = [1, 2]
b = [1, 2]
a == b # True — same contents
a is b # False — different objects
x = None
x is None # True — the correct test
x == None # works, but is not idiomatic and can be overriddenUse is for None, True and False. Use == for values.
Modules and imports
import math
math.sqrt(16) # 4.0
import numpy as np # aliased — the universal convention
np.array([1, 2, 3])
from datetime import date, timedelta
date.today()
from statistics import mean, median
import pandas as pd # the conventions matter; use them
import matplotlib.pyplot as plt
import seaborn as snsNever from module import * — it pollutes the namespace and makes the origin of every name invisible. This is the same argument as preferring pkg::fn() in R.
The standard library is large and worth knowing:
import os, sys, pathlib # filesystem and interpreter
import json, csv # data formats
import re # regular expressions
import datetime # dates and times
import itertools, functools # functional tools
import collections # Counter, defaultdict, namedtuple
import logging # logging
import pathlib # modern path handlingfrom pathlib import Path
p = Path("data") / "raw" / "dm.csv" # / operator joins paths
p.exists()
p.suffix # ".csv"
p.stem # "dm"
list(Path("data").glob("**/*.csv"))pathlib is the modern replacement for os.path string manipulation. Use it.
Reading a traceback
Traceback (most recent call last):
File "analysis.py", line 42, in <module>
result = process(data)
File "analysis.py", line 15, in process
return derive(df["AGE"])
File "analysis.py", line 8, in derive
return x / 0
ZeroDivisionError: division by zeroRead from the bottom: the error type and message, then the innermost frame. Unlike an R traceback, Python’s is ordered most-recent-last, so the last frame is where it broke.
Common exceptions:
| Exception | Means |
|---|---|
NameError |
Variable not defined |
TypeError |
Wrong type for the operation |
ValueError |
Right type, wrong value |
KeyError |
Dictionary key not present |
IndexError |
Sequence index out of range |
AttributeError |
Object has no such attribute or method |
ImportError |
Module not found |
FileNotFoundError |
Path does not exist |
R and Python side by side
| Concept | R | Python |
|---|---|---|
| Assignment | x <- 5 |
x = 5 |
| Index base | 1 | 0 |
| Slice endpoints | Both inclusive | Stop exclusive |
| Scalars | None — length-1 vectors | Yes |
| Blocks | { } |
Indentation |
| Comment | # |
# |
| Null | NULL |
None |
| Missing | NA |
None or float('nan') |
| Boolean | TRUE / FALSE |
True / False |
| Exponent | ^ |
** |
| Integer division | %/% |
// |
| String concat | paste0(a, b) |
a + b or f-string |
| Length | length(x) |
len(x) |
| Sequence | 1:5 |
range(1, 6) |
| Function | f <- function(x) x |
def f(x): return x |
| Anonymous | \(x) x + 1 |
lambda x: x + 1 |
| Pipe | \|> |
Method chaining . |
| Package | library(x) |
import x |
| Help | ?fn |
help(fn) or fn? in IPython |
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
| Mixing tabs and spaces | IndentationError |
Four spaces, editor configured |
x[1] expecting the first element |
Off by one | Python is 0-indexed |
x[1:3] expecting three elements |
Gets two | Stop is exclusive |
"1" + 1 |
TypeError |
Convert explicitly |
x == None |
Works but not idiomatic | x is None |
from module import * |
Namespace pollution | Import what you need |
| Mutable default argument | Shared state across calls | See lesson 3 |
| Reading a traceback top-down | Confusion | Read from the bottom |
Exercise 1.1 — Translate from R
Translate this R code to Python.
classify <- function(age) {
if (is.na(age)) {
"Missing"
} else if (age < 18) {
"<18"
} else if (age < 65) {
"18-64"
} else {
">=65"
}
}
ages <- c(45, 72, NA, 16)
groups <- sapply(ages, classify)
for (i in seq_along(ages)) {
cat(sprintf("Age %s -> %s\n", ages[i], groups[i]))
}Show solution
def classify(age):
"""Return an age group label, or 'Missing' for None/NaN."""
if age is None:
return "Missing"
if age < 18:
return "<18"
if age < 65:
return "18-64"
return ">=65"
ages = [45, 72, None, 16]
groups = [classify(a) for a in ages]
for age, group in zip(ages, groups):
print(f"Age {age} -> {group}")Age 45 -> 18-64
Age 72 -> >=65
Age None -> Missing
Age 16 -> <18
Four differences worth noting:
- Early returns rather than a nested if/else chain. Python style favours returning as soon as the answer is known; the R version returns the value of the whole
ifexpression, which is idiomatic R. - List comprehension
[classify(a) for a in ages]replacessapply(). It is the standard Python idiom for “apply a function to each element” — see lesson 2. zip()iterates two sequences together, replacing the index loop. Looping over indexes to access elements is a common R habit that reads as unidiomatic in Python.is Nonenot== None.
If the missing value were float('nan') rather than None — which is what it will be coming from pandas — the check must change:
import math
def classify(age):
if age is None or (isinstance(age, float) and math.isnan(age)):
return "Missing"
...nan is not equal to itself, so age == float('nan') is always False. This catches people constantly.
Exercise 1.2 — Format a summary line
Write format_summary(name, n, mean, sd, pct) producing output like:
Age (years) n=254 Mean (SD): 75.14 (8.253) Elderly: 84.9%
with the name left-padded to 20 characters and the numbers aligned.
Show solution
def format_summary(name: str, n: int, mean: float, sd: float, pct: float) -> str:
"""Format a one-line summary of a continuous variable.
Args:
name: Variable label, truncated to 20 characters.
n: Number of non-missing observations.
mean: Arithmetic mean.
sd: Standard deviation.
pct: Percentage, as a number between 0 and 100.
"""
return (
f"{name:<20.20}"
f" n={n:<6}"
f" Mean (SD): {mean:6.2f} ({sd:.3f})"
f" Elderly: {pct:.1f}%"
)
print(format_summary("Age (years)", 254, 75.1417, 8.2534, 84.88))
print(format_summary("Weight (kg)", 251, 78.3, 15.1204, 12.5))
print(format_summary("Body Mass Index (kg/m2)", 249, 26.44, 4.9, 33.2))Age (years) n=254 Mean (SD): 75.14 (8.253) Elderly: 84.9%
Weight (kg) n=251 Mean (SD): 78.30 (15.120) Elderly: 12.5%
Body Mass Index (kg/ n=249 Mean (SD): 26.44 (4.900) Elderly: 33.2%
The format specifiers:
{name:<20.20}— left align in 20 characters, and truncate to 20. The second number is a precision, which for strings means maximum length. Without it, a long name pushes everything out of alignment, as the third row would.{n:<6}— left align in 6 characters{mean:6.2f}— width 6, two decimals, right aligned (the default for numbers){sd:.3f}— three decimals, no minimum width{pct:.1f}%— one decimal, literal percent sign
+. That is idiomatic and much easier to read than one very long line.
Recap
- Indentation is syntax; four spaces, never tabs
- 0-indexed, and slice stops are exclusive — the main source of off-by-one bugs
- Python has scalars; R does not
- Strongly typed:
"1" + 1is an error, not a coercion - f-strings with the
:.2fmini-language for all formatting is Nonefor None;==for values- Read tracebacks from the bottom
if __name__ == "__main__":keeps side effects out of importable modules