Part 7 · Python foundations · module 1 of 2 · 18 min
Python foundations
Colab and notebooks, data types, indexing and slicing, functions, NumPy, flow control and comprehensions — the subset this work actually uses.
The essence
Almost all LLM code is a small slice of Python used heavily: dictionaries for lookups, list comprehensions for transformation, slicing for sequences, and NumPy for anything numerical. Learning that slice well beats learning the whole language broadly, because the same handful of constructs appears in every script in this curriculum.
Why it matters
Every bug you will spend an afternoon on is in this material — a mutable default argument, a shared reference after a shallow copy, an off-by-one slice, an accidental integer division. The concepts are elementary and the failures are silent, which is exactly the combination that wastes time.
Topics covered (20)
- 01Creating, working with and saving Colab notebooks
- 02Arithmetic and comments
- 03Variables
- 04Lists
- 05Booleans
- 06Dictionaries
- 07Indexing
- 08Slicing
- 09Function inputs and outputs
- 10The NumPy library
- 11Getting help on functions
- 12Creating functions
- 13Copying and duplicating variables
- 14Generating random numbers
- 15For loops
- 16If-else statements
- 17List comprehension
- 18Initialising variables
- 19Enumerate
- 20Zip
Notebooks, and their one real hazard
Colab gives you a free GPU and a preinstalled scientific stack, which is why nearly all of this work happens there. Cells run in whatever order you execute them, and that is the hazard: state persists, so a notebook that works for you may not run top to bottom for anyone else.
Two habits prevent almost all notebook pain. Restart and run all before you trust or share a result. And keep functions in cells you can re-run cheaply, so re-running does not mean retraining.
# Check what hardware you actually got.
import torch
print(torch.__version__, torch.cuda.is_available())
if torch.cuda.is_available():
print(torch.cuda.get_device_name(0))
!nvidia-smi # ! runs a shell command
# Install what is missing. -q keeps the output short.
!pip install -q transformers datasets tiktoken
# Persist anything you care about — the runtime is deleted when it disconnects.
from google.colab import drive
drive.mount("/content/drive")
torch.save(model.state_dict(), "/content/drive/MyDrive/model.pt")
# Timing and inspection:
%time slow_function()
%%time
# times the whole cell
?torch.softmax # docstring
help(torch.softmax) # same, printed
torch.softmax?? # source, where availableThe data types, and what each is for
| Type | Mutable | Use for |
|---|---|---|
| int, float | n/a | Counts and measurements |
| str | No | Text; every operation returns a new string |
| bool | n/a | True/False — a subclass of int, so True + True == 2 |
| list | Yes | Ordered sequences you will modify |
| tuple | No | Fixed groupings; hashable, so usable as dict keys |
| dict | Yes | Key→value lookup in O(1); insertion-ordered since 3.7 |
| set | Yes | Membership and de-duplication in O(1) |
print(7 / 2) # 3.5 — true division, ALWAYS a float
print(7 // 2) # 3 — floor division
print(-7 // 2) # -4 — floors toward -infinity, not toward zero
print(-7 % 2) # 1 — sign follows the divisor, unlike C
print(2 ** 10) # 1024 — exponentiation
print(0.1 + 0.2 == 0.3) # False — binary floating point
print(abs(0.1 + 0.2 - 0.3) < 1e-9) # the correct comparison
import math
print(math.isclose(0.1 + 0.2, 0.3)) # or use this
print(True + True) # 2 — bool subclasses int
print(sum([True, False, True])) # 2 — which is how you count matchesIndexing and slicing
Indexing is zero-based, and negative indices count from the end: a[-1] is the last element. Slicing is a[start:stop:step], half-open so stop is excluded — which is the convention that makes len(a[i:j]) equal j − i and makes a[:k] + a[k:] reconstruct the list.
Two behaviours worth knowing. An out-of-range index raises IndexError, but an out-of-range slice does not — a[5:100] on a three-element list quietly returns what exists, which is why slicing bugs are silent. And a[::-1] reverses, which is the idiomatic reversal.
a = [0, 1, 2, 3, 4, 5]
print(a[2]) # 2
print(a[-1]) # 5
print(a[1:4]) # [1, 2, 3] — stop excluded
print(a[:3]) # [0, 1, 2]
print(a[3:]) # [3, 4, 5]
print(a[::2]) # [0, 2, 4]
print(a[::-1]) # [5, 4, 3, 2, 1, 0]
print(a[10]) # IndexError
print(a[3:100]) # [3, 4, 5] — no error, which is why slice bugs hide
# Slicing a LIST copies (shallowly). Slicing a NUMPY ARRAY is a view.
import numpy as np
x = np.arange(6)
v = x[1:4]
v[0] = 99
print(x) # [ 0 99 2 3 4 5] — the original changed!
safe = x[1:4].copy() # take an explicit copy when you mean one
# Strings slice the same way but are immutable.
s = "tokenization"
print(s[:5], s[-4:], s[::-1])Note: That NumPy view semantics is deliberate — it avoids copying large arrays — and it is the single most common source of 'my data changed and I do not know why'.
vocab = {"the": 0, "cat": 1, "sat": 2}
print(vocab["cat"]) # 1
print(vocab.get("dog", -1)) # -1 — no KeyError
vocab["dog"] = 3 # insert or overwrite
for word, idx in vocab.items():
print(word, idx)
# Inverting a dict — needed constantly for id -> token.
itos = {i: s for s, i in vocab.items()}
# Counting: Counter, or setdefault, or defaultdict.
from collections import Counter, defaultdict
counts = Counter("mississippi")
print(counts.most_common(3)) # [('i', 4), ('s', 4), ('p', 2)]
groups = defaultdict(list)
for word in ["cat", "cot", "dog"]:
groups[word[0]].append(word) # no need to initialise the list
# Keys must be hashable, i.e. immutable.
d = {(1, 2): "tuple key works"}
# d = {[1, 2]: "fails"} -> TypeError: unhashable type: 'list'Copying: the concept behind a whole class of bugs
Assignment binds a name to an object; it never copies. So b = a means both names refer to the same list, and mutating through either is visible through both.
a[:] and list(a) and copy.copy(a) each make a shallow copy: a new outer container holding the same inner objects. For a list of lists, that means the rows are still shared. copy.deepcopy recurses and copies everything, which is correct and slow.
The practical rule: if the structure is nested and you intend to mutate it, use deepcopy or rebuild it with a comprehension. This is exactly the bug behind [[0] * 3] * 2.
import copy
a = [1, 2, 3]
b = a # same object
b.append(4)
print(a) # [1, 2, 3, 4] — a changed
print(a is b) # True — identity, not equality
c = a[:] # shallow copy
c.append(5)
print(a) # unchanged
nested = [[1, 2], [3, 4]]
shallow = nested[:] # outer copied, INNER LISTS SHARED
shallow[0].append(99)
print(nested) # [[1, 2, 99], [3, 4]] — still changed
deep = copy.deepcopy(nested) # fully independent
# The classic:
grid = [[0] * 3] * 2 # both rows are THE SAME list
grid[0][0] = 9
print(grid) # [[9, 0, 0], [9, 0, 0]]
grid = [[0] * 3 for _ in range(2)] # correct
# Same trap in function defaults — evaluated ONCE at definition:
def bad(x, acc=[]):
acc.append(x)
return acc
print(bad(1), bad(2)) # [1, 2] [1, 2] — shared across calls
def good(x, acc=None):
acc = [] if acc is None else acc
acc.append(x)
return accdef tokenize(text, lowercase=True, max_len=None):
"""Split text into tokens.
Args:
text: the input string.
lowercase: fold case before splitting.
max_len: truncate to this many tokens; None keeps all.
Returns:
A list of tokens.
"""
if lowercase:
text = text.lower()
tokens = text.split()
return tokens[:max_len] if max_len else tokens
tokenize("Hello World") # positional
tokenize("Hello World", max_len=1) # keyword — always clearer
# Multiple returns are a tuple, and unpack directly.
def stats(xs):
return min(xs), max(xs), sum(xs) / len(xs)
lo, hi, mean = stats([1, 2, 3, 4])
# *args and **kwargs collect the rest.
def log(prefix, *values, sep=" ", **meta):
print(prefix, sep.join(map(str, values)), meta)
# Type hints: not enforced at runtime, but they make editors useful and
# document the contract. Worth writing in anything you will reread.
def cosine(a: "np.ndarray", b: "np.ndarray") -> float:
import numpy as np
return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))# for over an iterable — you rarely need range(len(x))
for token in tokens:
process(token)
# enumerate when you need the index too
for i, token in enumerate(tokens):
print(i, token)
for i, token in enumerate(tokens, start=1): # 1-based
print(i, token)
# zip to walk several sequences together — stops at the SHORTEST
for token, score in zip(tokens, scores):
print(token, score)
for token, score in zip(tokens, scores, strict=True): # 3.10+: error if unequal
pass
# Comprehensions: build a new list from an old one. Faster and clearer than
# append in a loop, and the standard idiom in this kind of code.
lengths = [len(t) for t in tokens]
long = [t for t in tokens if len(t) > 3]
upper = [t.upper() if t.isalpha() else t for t in tokens] # note: if/else BEFORE for
pairs = [(i, j) for i in range(3) for j in range(3) if i != j] # nested
squares = {t: len(t) for t in tokens} # dict comprehension
unique = {t.lower() for t in tokens} # set comprehension
gen = (len(t) for t in tokens) # generator — lazy, no list built
# Initialise before accumulating, or you get NameError on the first iteration.
total, best = 0, float("-inf")
for score in scores:
total += score
best = max(best, score)import numpy as np
x = np.array([[1., 2., 3.], [4., 5., 6.]])
print(x.shape, x.dtype, x.ndim) # (2, 3) float64 2
np.zeros((2, 3)); np.ones(4); np.eye(3)
np.arange(0, 1, 0.25) # [0, 0.25, 0.5, 0.75]
np.linspace(0, 1, 5) # [0, 0.25, 0.5, 0.75, 1] — includes 1
# Vectorise: operate on whole arrays, never element by element in Python.
print(x * 2)
print(x @ x.T) # matrix multiply
print(x.sum(axis=0)) # down the columns -> shape (3,)
print(x.mean(axis=1, keepdims=True)) # across rows, shape kept as (2, 1)
# keepdims is what makes broadcasting work for normalisation:
print(x / np.linalg.norm(x, axis=1, keepdims=True))
# Boolean masking
print(x[x > 3]) # 1-D array of the matches
print(np.where(x > 3, x, 0)) # elementwise conditional
# Reproducible randomness — the modern API, one generator you pass around.
rng = np.random.default_rng(seed=42)
rng.standard_normal((2, 3))
rng.integers(0, 10, size=5)
rng.choice(["a", "b", "c"], size=3, replace=False)
rng.permutation(10)
# Reshaping: -1 means 'infer this dimension'.
print(x.reshape(3, 2), x.reshape(-1), x.T.shape)Note: Prefer np.random.default_rng(seed) over np.random.seed(). It gives you an isolated generator instead of mutating global state, which matters the moment two parts of your code both want reproducibility.
Worth remembering
- Slices are half-open: a[i:j] includes i and excludes j, which makes len == j - i.
- Assignment never copies — it binds another name to the same object.
- Never use a mutable default argument; it is created once, at definition time.