Skip to content
GKgkml.dev
← LLM mechanisms

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)
  1. 01Creating, working with and saving Colab notebooks
  2. 02Arithmetic and comments
  3. 03Variables
  4. 04Lists
  5. 05Booleans
  6. 06Dictionaries
  7. 07Indexing
  8. 08Slicing
  9. 09Function inputs and outputs
  10. 10The NumPy library
  11. 11Getting help on functions
  12. 12Creating functions
  13. 13Copying and duplicating variables
  14. 14Generating random numbers
  15. 15For loops
  16. 16If-else statements
  17. 17List comprehension
  18. 18Initialising variables
  19. 19Enumerate
  20. 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.

The Colab essentials
# 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 available

The data types, and what each is for

TypeMutableUse for
int, floatn/aCounts and measurements
strNoText; every operation returns a new string
booln/aTrue/False — a subclass of int, so True + True == 2
listYesOrdered sequences you will modify
tupleNoFixed groupings; hashable, so usable as dict keys
dictYesKey→value lookup in O(1); insertion-ordered since 3.7
setYesMembership and de-duplication in O(1)
Arithmetic gotchas that bite in numerical code
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 matches

Indexing 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.

Slicing, including the shallow-copy trap
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'.

Dictionaries, the workhorse
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.

The copying rules, and the aliasing bug
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 acc
Functions: arguments, returns, and type hints
def 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)))
Flow control, and the comprehension that replaces most loops
# 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)
NumPy: the parts you will use constantly
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.